Skip to content

Commit

Permalink
✨ decode: add throwOnLimitExceeded option qs#517
Browse files Browse the repository at this point in the history
  • Loading branch information
techouse committed Nov 22, 2024
1 parent a46a104 commit e8ac65d
Show file tree
Hide file tree
Showing 8 changed files with 202 additions and 32 deletions.
70 changes: 61 additions & 9 deletions lib/src/extensions/decode.dart
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,25 @@ extension _$Decode on QS {
),
);

static dynamic _parseArrayValue(dynamic val, DecodeOptions options) =>
val is String && val.isNotEmpty && options.comma && val.contains(',')
? val.split(',')
: val;
static dynamic _parseListValue(
dynamic val,
DecodeOptions options,
int currentListLength,
) {
if (val is String && val.isNotEmpty && options.comma && val.contains(',')) {
return val.split(',');
}

if (options.throwOnLimitExceeded &&
currentListLength >= options.listLimit) {
throw RangeError(
'List limit exceeded. '
'Only ${options.listLimit} element${options.listLimit == 1 ? '' : 's'} allowed in a list.',
);
}

return val;
}

static Map<String, dynamic> _parseQueryStringValues(
String str, [
Expand All @@ -23,12 +38,23 @@ extension _$Decode on QS {
(options.ignoreQueryPrefix ? str.replaceFirst('?', '') : str)
.replaceAll(RegExp(r'%5B', caseSensitive: false), '[')
.replaceAll(RegExp(r'%5D', caseSensitive: false), ']');
final num? limit = options.parameterLimit == double.infinity

final int? limit = options.parameterLimit == double.infinity
? null
: options.parameterLimit;
: options.parameterLimit.toInt();

final Iterable<String> parts = limit != null && limit > 0
? cleanStr.split(options.delimiter).take(limit.toInt())
? cleanStr
.split(options.delimiter)
.take(options.throwOnLimitExceeded ? limit + 1 : limit)
: cleanStr.split(options.delimiter);

if (options.throwOnLimitExceeded && limit != null && parts.length > limit) {
throw RangeError(
'Parameter limit exceeded. Only $limit parameter${limit == 1 ? '' : 's'} allowed.',
);
}

int skipIndex = -1; // Keep track of where the utf8 sentinel was found
int i;

Expand Down Expand Up @@ -65,7 +91,13 @@ extension _$Decode on QS {
} else {
key = options.decoder(part.slice(0, pos), charset: charset);
val = Utils.apply<dynamic>(
_parseArrayValue(part.slice(pos + 1), options),
_parseListValue(
part.slice(pos + 1),
options,
obj.containsKey(key) && obj[key] is List
? (obj[key] as List).length
: 0,
),
(dynamic val) => options.decoder(val, charset: charset),
);
}
Expand Down Expand Up @@ -102,7 +134,27 @@ extension _$Decode on QS {
DecodeOptions options,
bool valuesParsed,
) {
dynamic leaf = valuesParsed ? val : _parseArrayValue(val, options);
late final int currentListLength;

if (chain.isNotEmpty && chain.last == '[]') {
final int? parentKey = int.tryParse(chain.slice(0, -1).join(''));

currentListLength = parentKey != null &&
val is List &&
val.firstWhereIndexedOrNull((int i, _) => i == parentKey) != null
? val.elementAt(parentKey).length

Check warning on line 145 in lib/src/extensions/decode.dart

View check run for this annotation

Codecov / codecov/patch

lib/src/extensions/decode.dart#L143-L145

Added lines #L143 - L145 were not covered by tests
: 0;
} else {
currentListLength = 0;
}

dynamic leaf = valuesParsed
? val
: _parseListValue(
val,
options,
currentListLength,
);

for (int i = chain.length - 1; i >= 0; --i) {
dynamic obj;
Expand Down
16 changes: 11 additions & 5 deletions lib/src/extensions/extensions.dart
Original file line number Diff line number Diff line change
@@ -1,14 +1,20 @@
import 'dart:math' show min;
import 'package:qs_dart/src/models/undefined.dart';

extension IterableExtension<T> on Iterable<T> {
/// Returns a new [Iterable] without [Undefined] elements.
Iterable<T> whereNotUndefined() => where((T el) => el is! Undefined);
/// Returns a new [Iterable] without elements of type [Q].
Iterable<T> whereNotType<Q>() => where((T el) => el is! Q);
}

extension ListExtension<T> on List<T> {
/// Returns a new [List] without [Undefined] elements.
List<T> whereNotUndefined() => where((T el) => el is! Undefined).toList();
/// Extracts a section of a list and returns a new list.
///
/// Modeled after JavaScript's `Array.prototype.slice()` method.
/// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/slice
List<T> slice([int start = 0, int? end]) => sublist(
(start < 0 ? length + start : start).clamp(0, length),
(end == null ? length : (end < 0 ? length + end : end))
.clamp(0, length),
);
}

extension StringExtension on String {
Expand Down
4 changes: 4 additions & 0 deletions lib/src/models/decode_options.dart
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ final class DecodeOptions with EquatableMixin {
this.parseLists = true,
this.strictDepth = false,
this.strictNullHandling = false,
this.throwOnLimitExceeded = false,
}) : allowDots = allowDots ?? decodeDotInKeys == true || false,
decodeDotInKeys = decodeDotInKeys ?? false,
_decoder = decoder,
Expand Down Expand Up @@ -110,6 +111,9 @@ final class DecodeOptions with EquatableMixin {
/// Set to true to decode values without `=` to `null`.
final bool strictNullHandling;

/// Set to `true` to throw an error when the limit is exceeded.
final bool throwOnLimitExceeded;

/// Set a [Decoder] to affect the decoding of the input.
final Decoder? _decoder;

Expand Down
1 change: 1 addition & 0 deletions lib/src/qs.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import 'dart:convert' show latin1, utf8, Encoding;
import 'dart:typed_data' show ByteBuffer;

import 'package:collection/collection.dart' show IterableExtension;
import 'package:qs_dart/src/enums/duplicates.dart';
import 'package:qs_dart/src/enums/format.dart';
import 'package:qs_dart/src/enums/list_format.dart';
Expand Down
29 changes: 17 additions & 12 deletions lib/src/utils.dart
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,14 @@ final class Utils {
target_[target_.length] = source;
}

if (target is Set) {
target = target_.values.whereNotUndefined().toSet();
} else {
target = target_.values.whereNotUndefined().toList();
}
target = target_.values.any((el) => el is Undefined)
? SplayTreeMap.from({
for (final MapEntry<int, dynamic> entry in target_.entries)
if (entry.value is! Undefined) entry.key: entry.value,
})
: target is Set
? target_.values.toSet()
: target_.values.toList();
} else {
if (source is Iterable) {
// check if source is a list of maps and target is a list of maps
Expand All @@ -70,9 +73,11 @@ final class Utils {
}
} else {
if (target is Set) {
target = Set.of(target)..addAll(source.whereNotUndefined());
target = Set.of(target)
..addAll(source.whereNotType<Undefined>());
} else {
target = List.of(target)..addAll(source.whereNotUndefined());
target = List.of(target)
..addAll(source.whereNotType<Undefined>());
}
}
} else if (source != null) {
Expand All @@ -96,7 +101,7 @@ final class Utils {
}
} else if (source != null) {
if (target is! Iterable && source is Iterable) {
return [target, ...source.whereNotUndefined()];
return [target, ...source.whereNotType<Undefined>()];
}
return [target, source];
}
Expand All @@ -115,11 +120,11 @@ final class Utils {

return [
if (target is Iterable)
...target.whereNotUndefined()
...target.whereNotType<Undefined>()

Check warning on line 123 in lib/src/utils.dart

View check run for this annotation

Codecov / codecov/patch

lib/src/utils.dart#L123

Added line #L123 was not covered by tests
else if (target != null)
target,
if (source is Iterable)
...(source as Iterable).whereNotUndefined()
...(source as Iterable).whereNotType<Undefined>()

Check warning on line 127 in lib/src/utils.dart

View check run for this annotation

Codecov / codecov/patch

lib/src/utils.dart#L127

Added line #L127 was not covered by tests
else
source,
];
Expand Down Expand Up @@ -381,9 +386,9 @@ final class Utils {

if (obj is Iterable) {
if (obj is Set) {
item['obj'][item['prop']] = obj.whereNotUndefined().toSet();
item['obj'][item['prop']] = obj.whereNotType<Undefined>().toSet();

Check warning on line 389 in lib/src/utils.dart

View check run for this annotation

Codecov / codecov/patch

lib/src/utils.dart#L389

Added line #L389 was not covered by tests
} else {
item['obj'][item['prop']] = obj.whereNotUndefined().toList();
item['obj'][item['prop']] = obj.whereNotType<Undefined>().toList();

Check warning on line 391 in lib/src/utils.dart

View check run for this annotation

Codecov / codecov/patch

lib/src/utils.dart#L391

Added line #L391 was not covered by tests
}
}
}
Expand Down
90 changes: 88 additions & 2 deletions test/unit/decode_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -412,7 +412,7 @@ void main() {
expect(
QS.decode('a[1]=b&a=c', const DecodeOptions(listLimit: 20)),
equals({
'a': ['b', 'c']
'a': {1: 'b', 2: 'c'}
}),
);
expect(
Expand Down Expand Up @@ -818,7 +818,7 @@ void main() {
expect(
QS.decode('a[10]=1&a[2]=2', const DecodeOptions(listLimit: 20)),
equals({
'a': ['2', '1']
'a': {2: '2', 10: '1'}
}),
);
expect(
Expand Down Expand Up @@ -1768,4 +1768,90 @@ void main() {
},
);
});

group('parameter limit', () {
test('does not throw error when within parameter limit', () {
expect(
QS.decode('a=1&b=2&c=3',
const DecodeOptions(parameterLimit: 5, throwOnLimitExceeded: true)),
equals({'a': '1', 'b': '2', 'c': '3'}),
);
});

test('throws error when parameter limit exceeded', () {
expect(
() => QS.decode(
'a=1&b=2&c=3&d=4&e=5&f=6',
const DecodeOptions(parameterLimit: 3, throwOnLimitExceeded: true),
),
throwsA(isA<RangeError>()),
);
});

test('silently truncates when throwOnLimitExceeded is not given', () {
expect(
QS.decode(
'a=1&b=2&c=3&d=4&e=5',
const DecodeOptions(parameterLimit: 3),
),
equals({'a': '1', 'b': '2', 'c': '3'}),
);
});

test('silently truncates when parameter limit exceeded without error', () {
expect(
QS.decode(
'a=1&b=2&c=3&d=4&e=5',
const DecodeOptions(parameterLimit: 3, throwOnLimitExceeded: false),
),
equals({'a': '1', 'b': '2', 'c': '3'}),
);
});

test('allows unlimited parameters when parameterLimit set to Infinity', () {
expect(
QS.decode(
'a=1&b=2&c=3&d=4&e=5&f=6',
const DecodeOptions(parameterLimit: double.infinity),
),
equals({'a': '1', 'b': '2', 'c': '3', 'd': '4', 'e': '5', 'f': '6'}),
);
});
});

group('list limit tests', () {
test('does not throw error when list is within limit', () {
expect(
QS.decode(
'a[]=1&a[]=2&a[]=3',
const DecodeOptions(listLimit: 5, throwOnLimitExceeded: true),
),
equals({
'a': ['1', '2', '3']
}),
);
});

test('throws error when list limit exceeded', () {
expect(
() => QS.decode(
'a[]=1&a[]=2&a[]=3&a[]=4',
const DecodeOptions(listLimit: 3, throwOnLimitExceeded: true),
),
throwsA(isA<RangeError>()),
);
});

test('converts list to map if length is greater than limit', () {
expect(
QS.decode(
'a[1]=1&a[2]=2&a[3]=3&a[4]=4&a[5]=5&a[6]=6',
const DecodeOptions(listLimit: 5),
),
equals({
'a': {'1': '1', '2': '2', '3': '3', '4': '4', '5': '5', '6': '6'}
}),
);
});
});
}
20 changes: 18 additions & 2 deletions test/unit/extensions/extensions_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ void main() {
group('IterableExtension', () {
test('whereNotUndefined', () {
const Iterable<dynamic> iterable = [1, 2, Undefined(), 4, 5];
final Iterable<dynamic> result = iterable.whereNotUndefined();
final Iterable<dynamic> result = iterable.whereNotType<Undefined>();
expect(result, isA<Iterable<dynamic>>());
expect(result, [1, 2, 4, 5]);
});
Expand All @@ -15,10 +15,26 @@ void main() {
group('ListExtension', () {
test('whereNotUndefined', () {
const List<dynamic> list = [1, 2, Undefined(), 4, 5];
final List<dynamic> result = list.whereNotUndefined();
final List<dynamic> result = list.whereNotType<Undefined>().toList();
expect(result, isA<List<dynamic>>());
expect(result, [1, 2, 4, 5]);
});

test('slice', () {
const List<String> animals = [
'ant',
'bison',
'camel',
'duck',
'elephant',
];
expect(animals.slice(2), ['camel', 'duck', 'elephant']);
expect(animals.slice(2, 4), ['camel', 'duck']);
expect(animals.slice(1, 5), ['bison', 'camel', 'duck', 'elephant']);
expect(animals.slice(-2), ['duck', 'elephant']);
expect(animals.slice(2, -1), ['camel', 'duck']);
expect(animals.slice(), ['ant', 'bison', 'camel', 'duck', 'elephant']);
});
});

group('StringExtensions', () {
Expand Down
4 changes: 2 additions & 2 deletions test/unit/uri_extension_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -439,7 +439,7 @@ void main() {
Uri.parse('$testUrl?a[1]=b&a=c')
.queryParametersQs(const DecodeOptions(listLimit: 20)),
equals({
'a': ['b', 'c']
'a': {1: 'b', 2: 'c'}
}),
);
expect(
Expand Down Expand Up @@ -864,7 +864,7 @@ void main() {
Uri.parse('$testUrl?a[10]=1&a[2]=2')
.queryParametersQs(const DecodeOptions(listLimit: 20)),
equals({
'a': ['2', '1']
'a': {2: '2', 10: '1'}
}),
);
expect(
Expand Down

0 comments on commit e8ac65d

Please sign in to comment.