qs_dart 1.0.0 copy "qs_dart: ^1.0.0" to clipboard
qs_dart: ^1.0.0 copied to clipboard

A query string encoding and decoding library for Dart. Ported from qs for JavaScript.

qs_dart #

A query string encoding and decoding library for Dart. Ported from qs for JavaScript.

Test codecov

Usage #

A simple usage example:

import 'package:qs_dart/qs_dart.dart';
import 'package:test/test.dart';

void main() {
  test('Simple example', () {
    expect(
      QS.decode('a=c'),
      equals({'a': 'c'}),
    );

    expect(
      QS.encode({'a': 'c'}),
      equals('a=c'),
    );
  });
}

Decoding Maps #

Map decode(
  dynamic str, [
  DecodeOptions options = const DecodeOptions(),
]);

QS allows you to create nested Maps within your query strings, by surrounding the name of sub-keys with square brackets []. For example, the string 'foo[bar]=baz' converts to:

expect(
  QS.decode('foo[bar]=baz'),
  equals({'foo': {'bar': 'baz'}}),
);

URI encoded strings work too:

expect(
  QS.decode('a%5Bb%5D=c'),
  equals({'a': {'b': 'c'}}),
);

You can also nest your Maps, like 'foo[bar][baz]=foobarbaz':

expect(
  QS.decode('foo[bar][baz]=foobarbaz'),
  equals({'foo': {'bar': {'baz': 'foobarbaz'}}}),
);

By default, when nesting Maps qs will only parse up to 5 children deep. This means if you attempt to parse a string like 'a[b][c][d][e][f][g][h][i]=j' your resulting Map will be:

expect(
  QS.decode('a[b][c][d][e][f][g][h][i]=j'),
  equals({
    'a': {
      'b': {
        'c': {
          'd': {
            'e': {
              'f': {
                '[g][h][i]': 'j'
              }
            }
          }
        }
      }
    }
  }),
);

This depth can be overridden by passing a depth option to DecodeOptions.depth:

expect(
  QS.decode(
    'a[b][c][d][e][f][g][h][i]=j',
    const DecodeOptions(depth: 1),
  ),
  equals({
    'a': {
      'b': {'[c][d][e][f][g][h][i]': 'j'},
    },
  }),
);

The depth limit helps mitigate abuse when qs is used to parse user input, and it is recommended to keep it a reasonably small number.

For similar reasons, by default QS will only parse up to 1000 parameters. This can be overridden by passing a DecodeOptions.parameterLimit option:

expect(
  QS.decode(
    'a=b&c=d',
    const DecodeOptions(parameterLimit: 1),
  ),
  equals({'a': 'b'}),
);

To bypass the leading question mark, use DecodeOptions.ignoreQueryPrefix:

expect(
  QS.decode(
    '?a=b&c=d',
    const DecodeOptions(ignoreQueryPrefix: true),
  ),
  equals(
    {'a': 'b', 'c': 'd'},
  ),
);

An optional DecodeOptions.delimiter can also be passed:

expect(
  QS.decode(
    'a=b;c=d',
    const DecodeOptions(delimiter: ';'),
  ),
  equals({'a': 'b', 'c': 'd'}),
);

DecodeOptions.delimiter can be a regular expression too:

expect(
  QS.decode(
    'a=b;c=d',
    DecodeOptions(delimiter: RegExp(r'[;,]')),
  ),
  equals({'a': 'b', 'c': 'd'}),
);

Option DecodeOptions.allowDots can be used to enable dot notation:

expect(
  QS.decode(
    'a.b=c',
    const DecodeOptions(allowDots: true),
  ),
  equals({'a': {'b': 'c'}}),
);

Option DecodeOptions.decodeDotInKeys can be used to decode dots in keys

Note: it implies DecodeOptions.allowDots, so decode will error if you set DecodeOptions.decodeDotInKeys to true, and DecodeOptions.allowDots to false.

expect(
  QS.decode(
    'name%252Eobj.first=John&name%252Eobj.last=Doe',
    const DecodeOptions(decodeDotInKeys: true),
  ),
  equals({
    'name.obj': {'first': 'John', 'last': 'Doe'}
  }),
);

Option DecodeOptions.allowEmptyLists can be used to allowing empty list values in Map

expect(
  QS.decode(
    'foo[]&bar=baz',
    const DecodeOptions(allowEmptyLists: true),
  ),
  equals({
    'foo': [],
    'bar': 'baz',
  }),
);

Option DecodeOptions.duplicates can be used to change the behavior when duplicate keys are encountered

expect(
  QS.decode('foo=bar&foo=baz'),
  equals({
    'foo': ['bar', 'baz']
  }),
);

expect(
  QS.decode(
    'foo=bar&foo=baz',
    const DecodeOptions(duplicates: Duplicates.combine),
  ),
  equals({
    'foo': ['bar', 'baz']
  }),
);

expect(
  QS.decode(
    'foo=bar&foo=baz',
    const DecodeOptions(duplicates: Duplicates.first),
  ),
  equals({'foo': 'bar'}),
);

expect(
  QS.decode(
    'foo=bar&foo=baz',
    const DecodeOptions(duplicates: Duplicates.last),
  ),
  equals({'foo': 'baz'}),
);

If you have to deal with legacy browsers or services, there's also support for decoding percent-encoded octets as latin1:

expect(
  QS.decode(
    'a=%A7',
    const DecodeOptions(charset: latin1),
  ),
  equals({'a': '§'}),
);

Some services add an initial utf8=✓ value to forms so that old Internet Explorer versions are more likely to submit the form as utf-8. Additionally, the server can check the value against wrong encodings of the checkmark character and detect that a query string or application/x-www-form-urlencoded body was not sent as utf-8, eg. if the form had an accept-charset parameter or the containing page had a different character set.

QS supports this mechanism via the DecodeOptions.charsetSentinel option. If specified, the utf8 parameter will be omitted from the returned Map. It will be used to switch to latin1/utf-8 mode depending on how the checkmark is encoded.

Important: When you specify both the DecodeOptions.charset option and the DecodeOptions.charsetSentinel option, the DecodeOptions.charset will be overridden when the request contains a utf8 parameter from which the actual charset can be deduced. In that sense the DecodeOptions.charset will behave as the default charset rather than the authoritative charset.

expect(
  QS.decode(
    'utf8=%E2%9C%93&a=%C3%B8',
    const DecodeOptions(
      charset: latin1,
      charsetSentinel: true,
    ),
  ),
  equals({'a': 'ø'}),
);

expect(
  QS.decode(
    'utf8=%26%2310003%3B&a=%F8',
    const DecodeOptions(
      charset: utf8,
      charsetSentinel: true,
    ),
  ),
  equals({'a': 'ø'}),
);

If you want to decode the &#...; syntax to the actual character, you can specify the DecodeOptions.interpretNumericEntities option as well:

expect(
  QS.decode(
    'a=%26%239786%3B',
    const DecodeOptions(
      charset: latin1,
      interpretNumericEntities: true,
    ),
  ),
  equals({'a': '☺'}),
);

It also works when the charset has been detected in DecodeOptions.charsetSentinel mode.

Decoding Lists #

QS can also decode Lists using a similar [] notation:

expect(
  QS.decode('a[]=b&a[]=c'),
  equals({
    'a': ['b', 'c']
  }),
);

You may specify an index as well:

expect(
  QS.decode('a[1]=c&a[0]=b'),
  equals({
    'a': ['b', 'c']
  }),
);

Note that the only difference between an index in a List and a key in a Map is that the value between the brackets must be a number to create a List. When creating Lists with specific indices, QS will compact a sparse List to only the existing values preserving their order:

expect(
  QS.decode('a[1]=b&a[15]=c'),
  equals({
    'a': ['b', 'c']
  }),
);

Note that an empty string is also a value, and will be preserved:

expect(
  QS.decode('a[]=&a[]=b'),
  equals({
    'a': ['', 'b']
  }),
);
expect(
  QS.decode('a[0]=b&a[1]=&a[2]=c'),
  equals({
    'a': ['b', '', 'c']
  }),
);

QS will also limit specifying indices in a List to a maximum index of 20. Any List members with an index of greater than 20 will instead be converted to a Map with the index as the key. This is needed to handle cases when someone sent, for example, a[999999999] and it will take significant time to iterate over this huge List.

expect(
  QS.decode('a[100]=b'),
  equals({
    'a': {100: 'b'}
  }),
);

This limit can be overridden by passing an DecodeOptions.listLimit option:

expect(
  QS.decode(
    'a[1]=b',
    const DecodeOptions(listLimit: 0),
  ),
  equals({
    'a': {1: 'b'}
  }),
);

To disable List parsing entirely, set DecodeOptions.parseLists to false.

expect(
  QS.decode(
    'a[]=b',
    const DecodeOptions(parseLists: false),
  ),
  equals({
    'a': {0: 'b'}
  }),
);

If you mix notations, QS will merge the two items into a Map:

expect(
  QS.decode('a[0]=b&a[b]=c'),
  equals({
    'a': {0: 'b', 'b': 'c'}
  }),
);

You can also create Lists of Maps:

expect(
  QS.decode('a[][b]=c'),
  equals({
    'a': [
      {'b': 'c'}
    ]
  }),
);

Some people use comma to join array, QS can parse it:

expect(
  QS.decode(
    'a=b,c',
    const DecodeOptions(comma: true),
  ),
  equals({
    'a': ['b', 'c']
  }),
);

(QS cannot convert nested Maps, such as 'a={b:1},{c:d}')

Decoding primitive/scalar values (num, bool, null, etc.) #

By default, all values are parsed as Strings.

expect(
  QS.decode('a=15&b=true&c=null'),
  equals({
    'a': '15',
    'b': 'true',
    'c': 'null',
  }),
);

Encoding #

String encode(
  Object? object, [
  EncodeOptions options = const EncodeOptions(),
]);

When encoding, QS by default URI encodes output. Maps are stringified as you would expect:

expect(
  QS.encode({'a': 'b'}),
  equals('a=b'),
);
expect(
  QS.encode({'a': {'b': 'c'}}),
  equals('a%5Bb%5D=c'),
);

This encoding can be disabled by setting the EncodeOptions.encode option to false:

expect(
  QS.encode(
    {
      'a': {'b': 'c'}
    },
    const EncodeOptions(encode: false),
  ),
  equals('a[b]=c'),
);

Encoding can be disabled for keys by setting the EncodeOptions.encodeValuesOnly option to true:

expect(
  QS.encode(
    {
      'a': 'b',
      'c': ['d', 'e=f'],
      'f': [
        ['g'],
        ['h']
      ]
    },
    const EncodeOptions(encodeValuesOnly: true),
  ),
  equals('a=b&c[0]=d&c[1]=e%3Df&f[0][0]=g&f[1][0]=h'),
);

This encoding can also be replaced by a custom Encoder set as EncodeOptions.encoder option:

expect(
  QS.encode(
    {
      'a': {'b': 'č'}
    },
    EncodeOptions(
      encoder: (
        str, {
        Encoding? charset,
        Format? format,
      }) =>
          switch (str) {
        'č' => 'c',
        _ => str,
      },
    ),
  ),
  equals('a[b]=c'),
);

(Note: the EncodeOptions.encoder option does not apply if EncodeOptions.encode is false)

Similar to EncodeOptions.encoder there is a DecodeOptions.decoder option for decode to override decoding of properties and values:

expect(
  QS.decode(
    'foo=123', 
    DecodeOptions(
      decoder: (String? str, {Encoding? charset}) =>
        num.tryParse(str ?? '') ?? str,
    ),
  ),
  equals({'foo': 123}),
);

Examples beyond this point will be shown as though the output is not URI encoded for clarity. Please note that the return values in these cases will be URI encoded during real usage.

When Lists are encoded, they follow the EncodeOptions.listFormat option, which defaults to ListFormat.indices:

expect(
  QS.encode(
    {
      'a': ['b', 'c', 'd']
    },
    const EncodeOptions(encode: false),
  ),
  equals('a[0]=b&a[1]=c&a[2]=d'),
);

You may override this by setting the EncodeOptions.indices option to false, or to be more explicit, the EncodeOptions.listFormat option to ListFormat.repeat:

expect(
  QS.encode(
    {
      'a': ['b', 'c', 'd']
    },
    const EncodeOptions(
      encode: false,
      indices: false,
    ),
  ),
  equals('a=b&a=c&a=d'),
);

You may use the EncodeOptions.listFormat option to specify the format of the output List:

expect(
  QS.encode(
    {
      'a': ['b', 'c']
    },
    const EncodeOptions(
      encode: false,
      listFormat: ListFormat.indices,
    ),
  ),
  equals('a[0]=b&a[1]=c'),
);

expect(
  QS.encode(
    {
      'a': ['b', 'c']
    },
    const EncodeOptions(
      encode: false,
      listFormat: ListFormat.brackets,
    ),
  ),
  equals('a[]=b&a[]=c'),
);

expect(
  QS.encode(
    {
      'a': ['b', 'c']
    },
    const EncodeOptions(
      encode: false,
      listFormat: ListFormat.repeat,
    ),
  ),
  equals('a=b&a=c'),
);

expect(
  QS.encode(
    {
      'a': ['b', 'c']
    },
    const EncodeOptions(
      encode: false,
      listFormat: ListFormat.comma,
    ),
  ),
  equals('a=b,c'),
);

Note: When using EncodeOptions.listFormat set to ListFormat.comma, you can also pass the EncodeOptions.commaRoundTrip option set to true or false, to append [] on single-item Lists, so that they can round trip through a parse.

When Maps are encoded, by default they use bracket notation:

expect(
  QS.encode(
    {
      'a': {
        'b': {'c': 'd', 'e': 'f'}
      }
    },
    const EncodeOptions(encode: false),
  ),
  equals('a[b][c]=d&a[b][e]=f'),
);

You may override this to use dot notation by setting the EncodeOptions.allowDots option to true:

expect(
  QS.encode(
    {
      'a': {
        'b': {'c': 'd', 'e': 'f'}
      }
    },
    const EncodeOptions(
      encode: false,
      allowDots: true,
    ),
  ),
  equals('a.b.c=d&a.b.e=f'),
);

You may encode the dot notation in the keys of Map with option EncodeOptions.encodeDotInKeys by setting it to true: Note: It implies EncodeOptions.allowDots, so encode will error if you set EncodeOptions.decodeDotInKeys to true, and EncodeOptions.allowDots to false. Caveat: when EncodeOptions.encodeValuesOnly is true as well as EncodeOptions.encodeDotInKeys, only dots in keys and nothing else will be encoded.

expect(
  QS.encode(
    {
      'name.obj': {'first': 'John', 'last': 'Doe'}
    },
    const EncodeOptions(
      allowDots: true,
      encodeDotInKeys: true,
    ),
  ),
  equals('name%252Eobj.first=John&name%252Eobj.last=Doe'),
);

You may allow empty array values by setting the EncodeOptions.allowEmptyLists option to true:

expect(
  QS.encode(
    {
      'foo': [],
      'bar': 'baz',
    },
    const EncodeOptions(
      encode: false,
      allowEmptyLists: true,
    ),
  ),
  equals('foo[]&bar=baz'),
);

Empty strings and null values will omit the value, but the equals sign (=) remains in place:

expect(
  QS.encode(
    {
      'a': '',
    },
  ),
  equals('a='),
);

Key with no values (such as an empty Map or List) will return nothing:

expect(
  QS.encode(
    {
      'a': [],
    },
  ),
  equals(''),
);

expect(
  QS.encode(
    {
      'a': {},
    },
  ),
  equals(''),
);

expect(
  QS.encode(
    {
      'a': [{}],
    },
  ),
  equals('')
);

expect(
  QS.encode(
    {
      'a': {'b': []},
    },
  ),
  equals('')
);

expect(
  QS.encode(
    {
      'a': {'b': {}},
    },
  ),
  equals('')
);

Properties that are Undefined will be omitted entirely:

expect(
  QS.encode(
    {
      'a': null,
      'b': const Undefined(),
    },
  ),
  equals('a='),
);

The query string may optionally be prepended with a question mark:

expect(
  QS.encode(
    {
      'a': 'b',
      'c': 'd',
    },
    const EncodeOptions(addQueryPrefix: true),
  ),
  equals('?a=b&c=d'),
);

The delimiter may be overridden as well:

expect(
  QS.encode(
    {
      'a': 'b',
      'c': 'd',
    },
    const EncodeOptions(delimiter: ';'),
  ),
  equals('a=b;c=d'),
);

If you only want to override the serialization of DateTime objects, you can provide a custom DateSerializer in the EncodeOptions.serializeDate option:

expect(
  QS.encode(
    {
      'a': DateTime.fromMillisecondsSinceEpoch(7).toUtc(),
    },
    const EncodeOptions(encode: false),
  ),
  equals('a=1970-01-01T00:00:00.007Z'),
);
expect(
  QS.encode(
    {
      'a': DateTime.fromMillisecondsSinceEpoch(7).toUtc(),
    },
    EncodeOptions(
      encode: false,
      serializeDate: (DateTime date) =>
          date.millisecondsSinceEpoch.toString(),
    ),
  ),
  equals('a=7'),
);

You may use the EncodeOptions.sort option to affect the order of parameter keys:

expect(
  QS.encode(
    {
      'a': 'c',
      'z': 'y',
      'b': 'f',
    },
    EncodeOptions(
      encode: false,
      sort: (a, b) => a.compareTo(b),
    ),
  ),
  equals('a=c&b=f&z=y'),
);

Finally, you can use the EncodeOptions.filter option to restrict which keys will be included in the encoded output. If you pass a Function, it will be called for each key to obtain the replacement value. Otherwise, if you pass a List, it will be used to select properties and List indices to be encoded:

expect(
  QS.encode(
    {
      'a': 'b',
      'c': 'd',
      'e': {
        'f': DateTime.fromMillisecondsSinceEpoch(123),
        'g': [2],
      },
    },
    EncodeOptions(
      encode: false,
      filter: (prefix, value) => switch (prefix) {
        'b' => const Undefined(),
        'e[f]' => (value as DateTime).millisecondsSinceEpoch,
        'e[g][0]' => (value as num) * 2,
        _ => value,
      },
    ),
  ),
  equals('a=b&c=d&e[f]=123&e[g][0]=4'),
);

expect(
  QS.encode(
    {
      'a': 'b',
      'c': 'd',
      'e': 'f',
    },
    const EncodeOptions(
      encode: false,
      filter: ['a', 'e'],
    ),
  ),
  equals('a=b&e=f'),
);

expect(
  QS.encode(
    {
      'a': ['b', 'c', 'd'],
      'e': 'f',
    },
    const EncodeOptions(
      encode: false,
      filter: ['a', 0, 2],
    ),
  ),
  equals('a[0]=b&a[2]=d'),
);

Handling of null values #

By default, null values are treated like empty strings:

expect(
  QS.encode(
    {
      'a': null,
      'b': '',
    },
  ),
  equals('a=&b='),
);

Decoding does not distinguish between parameters with and without equal signs. Both are converted to empty strings.

expect(
  QS.decode('a&b='),
  equals({
    'a': '',
    'b': '',
  }),
);

To distinguish between null values and empty Strings use the EncodeOptions.strictNullHandling flag. In the result string the null values have no = sign:

expect(
  QS.encode(
    {
      'a': null,
      'b': '',
    },
    const EncodeOptions(strictNullHandling: true),
  ),
  equals('a&b='),
);

To decode values without = back to null use the DecodeOptions.strictNullHandling flag:

expect(
  QS.decode(
    'a&b=',
    const DecodeOptions(strictNullHandling: true),
  ),
  equals({
    'a': null,
    'b': '',
  }),
);

To completely skip rendering keys with null values, use the EncodeOptions.skipNulls flag:

expect(
  QS.encode(
    {
      'a': 'b',
      'c': null,
    },
    const EncodeOptions(skipNulls: true),
  ),
  equals('a=b'),
);

If you're communicating with legacy systems, you can switch to latin1 using the charset option:

expect(
  QS.encode(
    {
      'æ': 'æ',
    },
    const EncodeOptions(charset: latin1),
  ),
  equals('%E6=%E6'),
);

Characters that don't exist in latin1 will be converted to numeric entities, similar to what browsers do:

expect(
  QS.encode(
    {
      'a': '☺',
    },
    const EncodeOptions(charset: latin1),
  ),
  equals('a=%26%239786%3B'),
);

You can use the EncodeOptions.charsetSentinel option to announce the character by including an utf8=✓ parameter with the proper encoding if the checkmark, similar to what Ruby on Rails and others do when submitting forms.

expect(
  QS.encode(
    {
      'a': '☺',
    },
    const EncodeOptions(charsetSentinel: true),
  ),
  equals('utf8=%E2%9C%93&a=%E2%98%BA'),
);
expect(
  QS.encode(
    {
      'a': 'æ',
    },
    const EncodeOptions(
      charset: latin1,
      charsetSentinel: true,
    ),
  ),
  equals('utf8=%26%2310003%3B&a=%E6'),
);

Dealing with special character sets #

By default, the encoding and decoding of characters is done in utf8, and latin1 support is also built in via the EncodeOptions.charset and DecodeOptions.charset parameter, respectively.

If you wish to encode query strings to a different character set (i.e. Shift JIS) you can use the euc package

expect(
  QS.encode(
    {
      'a': 'こんにちは!',
    },
    EncodeOptions(
      encoder: (str, {Encoding? charset, Format? format}) {
        if ((str as String?)?.isNotEmpty ?? false) {
          final Uint8List buf = Uint8List.fromList(
            ShiftJIS().encode(str!),
          );
          final List<String> result = [
            for (int i = 0; i < buf.length; ++i) buf[i].toRadixString(16)
          ];
          return '%${result.join('%')}';
        }
        return '';
      },
    ),
  ),
  equals('%61=%82%b1%82%f1%82%c9%82%bf%82%cd%81%49'),
);

This also works for decoding of query strings:

expect(
  QS.decode(
    '%61=%82%b1%82%f1%82%c9%82%bf%82%cd%81%49',
    DecodeOptions(
      decoder: (str, {Encoding? charset, Format? format}) {
        if (str == null) {
          return null;
        }

        final RegExp reg = RegExp(r'%([0-9A-F]{2})', caseSensitive: false);
        final List<int> result = [];
        Match? parts;
        while ((parts = reg.firstMatch(str!)) != null && parts != null) {
          result.add(int.parse(parts.group(1)!, radix: 16));
          str = str.substring(parts.end);
        }
        return ShiftJIS().decode(
          Uint8List.fromList(result),
        );
      },
    ),
  ),
  equals({
    'a': 'こんにちは!',
  }),
);

RFC 3986 and RFC 1738 space encoding #

The default EncodeOptions.format is Format.rfc3986 which encodes ' ' to %20 which is backward compatible. You can also set the EncodeOptions.format to Format.rfc1738 which encodes ' ' to +.

expect(
  QS.encode(
    {
      'a': 'b c',
    },
  ),
  equals('a=b%20c'),
);

expect(
  QS.encode(
    {
      'a': 'b c',
    },
    const EncodeOptions(format: Format.rfc3986),
  ),
  equals('a=b%20c'),
);

expect(
  QS.encode(
    {
      'a': 'b c',
    },
    const EncodeOptions(format: Format.rfc1738),
  ),
  equals('a=b+c'),
);

Special thanks to the authors of qs for JavaScript:

6
likes
0
pub points
66%
popularity

Publisher

verified publishertusar.dev

A query string encoding and decoding library for Dart. Ported from qs for JavaScript.

Repository (GitHub)
View/report issues

License

unknown (license)

Dependencies

collection, equatable, meta, weak_map

More

Packages that depend on qs_dart