diff --git a/lib/utils/query_parameters.dart b/lib/utils/query_parameters.dart index 913c827..6e39c39 100644 --- a/lib/utils/query_parameters.dart +++ b/lib/utils/query_parameters.dart @@ -1,69 +1,36 @@ +import 'package:qs_dart/qs_dart.dart' as qs; +import 'package:validators/validators.dart' as validators; + /// Takes a string and appends [parameters] as query parameters of [url]. /// -/// It validates the URL structure and properly encodes both keys and values -/// to prevent URL injection attacks. +/// Throws [ArgumentError] if [url] is not a valid URL. String buildUrlString(String url, Map? parameters) { - // Avoids unnecessary processing. - if (parameters == null) return url; - - // Check if there are parameters to add. - if (parameters.isNotEmpty) { - // Validate URL structure to prevent injection - // First check if it looks like a valid HTTP/HTTPS URL - if (!url.startsWith('http://') && !url.startsWith('https://')) { - throw ArgumentError( - 'Invalid URL structure: $url - must be a valid HTTP/HTTPS URL', - ); - } - - try { - final uri = Uri.parse(url); - // Additional validation: ensure it has a host - if (uri.host.isEmpty) { - throw ArgumentError( - 'Invalid URL structure: $url - must have a valid host', - ); - } - } catch (e) { - if (e is ArgumentError) { - rethrow; - } - throw ArgumentError('Invalid URL structure: $url'); - } + late final Uri uri; - // Checks if the string url already has parameters. - if (url.contains("?")) { - url += "&"; - } else { - url += "?"; + try { + if (!validators.isURL(url)) { + throw FormatException('Invalid URL format'); } - - // Concat every parameter to the string url with proper encoding - parameters.forEach((key, value) { - // Encode the key to prevent injection - final encodedKey = Uri.encodeQueryComponent(key); - - if (value is List) { - if (value is List) { - for (String singleValue in value) { - url += "$encodedKey=${Uri.encodeQueryComponent(singleValue)}&"; - } - } else { - for (dynamic singleValue in value) { - url += - "$encodedKey=${Uri.encodeQueryComponent(singleValue.toString())}&"; - } - } - } else if (value is String) { - url += "$encodedKey=${Uri.encodeQueryComponent(value)}&"; - } else { - url += "$encodedKey=${Uri.encodeQueryComponent(value.toString())}&"; - } - }); - - // Remove last '&' character. - url = url.substring(0, url.length - 1); + uri = Uri.parse(url); + } on FormatException { + throw ArgumentError.value(url, 'url', 'Must be a valid URL'); } - return url; + return parameters?.isNotEmpty ?? false + ? uri + .replace( + query: qs.encode( + { + ...uri.queryParametersAll, + ...?parameters, + }, + qs.EncodeOptions( + listFormat: qs.ListFormat.repeat, + skipNulls: false, + strictNullHandling: false, + ), + ), + queryParameters: null) + .toString() + : url; } diff --git a/pubspec.yaml b/pubspec.yaml index 7a48f4f..fc917a7 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -12,6 +12,8 @@ environment: dependencies: http: ^1.2.1 + qs_dart: ^1.3.8 + validators: ^3.0.0 dev_dependencies: lints: ^4.0.0 diff --git a/test/utils/utils_test.dart b/test/utils/utils_test.dart index 12279b8..33c58f5 100644 --- a/test/utils/utils_test.dart +++ b/test/utils/utils_test.dart @@ -1,15 +1,15 @@ -import 'package:http_interceptor/utils/utils.dart'; import 'package:test/test.dart'; +import 'package:http_interceptor/utils/utils.dart'; main() { group("buildUrlString", () { test("Adds parameters to a URL string without parameters", () { // Arrange - String url = "https://www.google.com/helloworld"; - Map parameters = {"foo": "bar", "num": "0"}; + final String url = "https://www.google.com/helloworld"; + final Map parameters = {"foo": "bar", "num": "0"}; // Act - String parameterUrl = buildUrlString(url, parameters); + final String parameterUrl = buildUrlString(url, parameters); // Assert expect( @@ -17,13 +17,17 @@ main() { equals("https://www.google.com/helloworld?foo=bar&num=0"), ); }); + test("Adds parameters to a URL string with parameters", () { // Arrange - String url = "https://www.google.com/helloworld?foo=bar&num=0"; - Map parameters = {"extra": "1", "extra2": "anotherone"}; + final String url = "https://www.google.com/helloworld?foo=bar&num=0"; + final Map parameters = { + "extra": "1", + "extra2": "anotherone" + }; // Act - String parameterUrl = buildUrlString(url, parameters); + final String parameterUrl = buildUrlString(url, parameters); // Assert expect( @@ -33,16 +37,17 @@ main() { ), ); }); + test("Adds parameters with array to a URL string without parameters", () { // Arrange - String url = "https://www.google.com/helloworld"; - Map parameters = { + final String url = "https://www.google.com/helloworld"; + final Map parameters = { "foo": "bar", "num": ["0", "1"], }; // Act - String parameterUrl = buildUrlString(url, parameters); + final String parameterUrl = buildUrlString(url, parameters); // Assert expect( @@ -51,53 +56,231 @@ main() { ); }); - test("Properly encodes parameter keys to prevent injection", () { - // Arrange - String url = "https://www.google.com/helloworld"; - Map parameters = { - "normal_key": "normal_value", - "key&with=special": "value&with=special", + test("Null parameters returns original URL", () { + final url = "https://example.com/path"; + expect( + buildUrlString(url, null), + equals(url), + ); + }); + + test("Empty parameters returns original URL", () { + final url = "https://example.com/path"; + expect( + buildUrlString(url, {}), + equals(url), + ); + }); + + test("Null parameter value becomes empty assignment", () { + final url = "https://example.com/path"; + final params = {"a": null}; + expect( + buildUrlString(url, params), + equals("https://example.com/path?a="), + ); + }); + + test("Overrides existing parameter", () { + final url = "https://example.com/path?foo=bar"; + final params = {"foo": "baz", "x": "y"}; + expect( + buildUrlString(url, params), + equals("https://example.com/path?foo=baz&x=y"), + ); + }); + + test("Preserves fragment without existing query", () { + final url = "https://example.com/path#section"; + final params = {"a": "1"}; + expect(buildUrlString(url, params), + equals("https://example.com/path?a=1#section")); + }); + + test("Preserves fragment with existing query", () { + final url = "https://example.com/path?foo=bar#section"; + final params = {"baz": "qux"}; + expect(buildUrlString(url, params), + equals("https://example.com/path?foo=bar&baz=qux#section")); + }); + + test("Invalid URL does not trigger concatenation fallback", () { + final url = "not a valid url"; + final params = {"a": "b"}; + expect(() => buildUrlString(url, params), throwsArgumentError); + }); + + test("Encodes special characters in keys and values", () { + final url = "https://example.com"; + final params = {"a b": "c d", "ä": "ö"}; + expect( + buildUrlString(url, params), + equals("https://example.com?a%20b=c%20d&%C3%A4=%C3%B6"), + ); + }); + + test("Numeric and boolean values are stringified", () { + final url = "https://example.com"; + final params = {"int": 42, "bool": true}; + expect( + buildUrlString(url, params), + equals("https://example.com?int=42&bool=true"), + ); + }); + + test("List parameter overrides existing singular key", () { + final url = "https://example.com/path?x=1"; + final params = { + "x": ["2", "3"] }; + expect( + buildUrlString(url, params), + equals("https://example.com/path?x=2&x=3"), + ); + }); - // Act - String parameterUrl = buildUrlString(url, parameters); + test('encodes a query string object (basic key/value)', () { + final String testUrl = 'https://example.com/path'; + expect( + buildUrlString(testUrl, {'a': 'b'}), + equals('$testUrl?a=b'), + ); + expect( + buildUrlString(testUrl, {'a': '1'}), + equals('$testUrl?a=1'), + ); + expect( + buildUrlString(testUrl, {'a': '1', 'b': '2'}), + equals('$testUrl?a=1&b=2'), + ); + expect( + buildUrlString(testUrl, {'a': 'A_Z'}), + equals('$testUrl?a=A_Z'), + ); + }); - // Assert - expect(parameterUrl, contains("normal_key=normal_value")); + test('encodes various unicode characters', () { + final String testUrl = 'https://example.com/path'; expect( - parameterUrl, - contains(Uri.encodeQueryComponent("key&with=special")), + buildUrlString(testUrl, {'a': '€'}), + equals('$testUrl?a=%E2%82%AC'), ); expect( - parameterUrl, - contains(Uri.encodeQueryComponent("value&with=special")), + buildUrlString(testUrl, {'a': ''}), + equals('$testUrl?a=%EE%80%80'), + ); + expect( + buildUrlString(testUrl, {'a': 'א'}), + equals('$testUrl?a=%D7%90'), + ); + expect( + buildUrlString(testUrl, {'a': '𐐷'}), + equals('$testUrl?a=%F0%90%90%B7'), ); - // Should not contain unencoded special characters that could cause injection - expect(parameterUrl.split('?')[1], isNot(contains("&with=special&"))); }); - test("Validates URL structure and throws error for invalid URLs", () { - // Arrange - String invalidUrl = "not a valid url"; - Map parameters = {"key": "value"}; + test('increasing number of pairs', () { + final String testUrl = 'https://example.com/path'; + expect( + buildUrlString(testUrl, {'a': 'b', 'c': 'd'}), + equals('$testUrl?a=b&c=d'), + ); + expect( + buildUrlString(testUrl, {'a': 'b', 'c': 'd', 'e': 'f'}), + equals('$testUrl?a=b&c=d&e=f'), + ); + expect( + buildUrlString(testUrl, {'a': 'b', 'c': 'd', 'e': 'f', 'g': 'h'}), + equals('$testUrl?a=b&c=d&e=f&g=h'), + ); + }); - // Act & Assert + test('list values get repeated keys', () { + final String testUrl = 'https://example.com/path'; expect( - () => buildUrlString(invalidUrl, parameters), - throwsA(isA()), + buildUrlString(testUrl, { + 'a': ['b', 'c', 'd'], + 'e': 'f' + }), + equals('$testUrl?a=b&a=c&a=d&e=f'), ); }); - test("Validates URL structure and throws error for URLs without scheme", - () { - // Arrange - String invalidUrl = "example.com/path"; // No scheme - Map parameters = {"key": "value"}; + test('empty map yields no query string', () { + final String testUrl = 'https://example.com/path'; + expect( + buildUrlString(testUrl, {}), + buildUrlString(testUrl, {}).toString(), + ); + }); + + test('single key with empty string value', () { + final String testUrl = 'https://example.com/path'; + expect( + buildUrlString(testUrl, {'a': ''}), + equals('$testUrl?a='), + ); + }); + + test('null value is not skipped', () { + final String testUrl = 'https://example.com/path'; + expect( + buildUrlString(testUrl, {'a': null, 'b': '2'}), + equals('$testUrl?a=&b=2'), + ); + }); + + test('keys with special characters are encoded', () { + final String testUrl = 'https://example.com/path'; + expect( + buildUrlString(testUrl, {'a b': 'c d'}), + equals('$testUrl?a%20b=c%20d'), + ); + expect( + buildUrlString(testUrl, {'ä': 'ö'}), + equals('$testUrl?%C3%A4=%C3%B6'), + ); + }); + + test('values containing reserved characters', () { + final String testUrl = 'https://example.com/path'; + expect( + buildUrlString(testUrl, {'q': 'foo@bar.com'}), + equals('$testUrl?q=foo%40bar.com'), + ); + expect( + buildUrlString(testUrl, {'path': '/home'}), + equals('$testUrl?path=%2Fhome'), + ); + }); + + test('plus sign and space in value', () { + final String testUrl = 'https://example.com/path'; + expect( + buildUrlString(testUrl, {'v': 'a+b c'}), + equals('$testUrl?v=a%2Bb%20c'), + ); + }); + + test('list values including numbers and empty strings', () { + final String testUrl = 'https://example.com/path'; + expect( + buildUrlString(testUrl, { + 'x': ['1', '', '3'], + }), + equals('$testUrl?x=1&x=&x=3'), + ); + }); - // Act & Assert + test('multiple keys maintain insertion order', () { + final String testUrl = 'https://example.com/path'; expect( - () => buildUrlString(invalidUrl, parameters), - throwsA(isA()), + buildUrlString(testUrl, { + 'first': '1', + 'second': '2', + 'third': '3', + }), + equals('$testUrl?first=1&second=2&third=3'), ); }); });