From d79012b48ebd82c1719b7da9c5fb024f09b6fc4c Mon Sep 17 00:00:00 2001 From: Tobias Ottenweller Date: Sun, 20 Jul 2025 17:35:51 +0200 Subject: [PATCH] feat: Uri property encoding and decoding --- docs/data_types.md | 4 +- packages/tonik/README.md | 1 - packages/tonik_core/lib/src/model/model.dart | 7 + .../model/model_type_properties_test.dart | 4 + .../from_json_value_expression_generator.dart | 4 + ...rom_simple_value_expression_generator.dart | 10 + .../to_json_value_expression_generator.dart | 1 + .../src/util/type_reference_generator.dart | 7 + .../test/src/model/class_generator_test.dart | 130 +++++++ .../src/model/class_json_generator_test.dart | 170 +++++++++ .../model/class_simple_generator_test.dart | 172 ++++++++++ .../src/model/typedef_generator_test.dart | 95 ++++++ .../tonik_parse/lib/src/model_importer.dart | 2 + .../test/model/model_property_test.dart | 18 + .../test/model/single_schema_import_test.dart | 72 ++++ .../lib/src/decoding/json_decoder.dart | 46 ++- .../lib/src/decoding/simple_decoder.dart | 40 +++ .../lib/src/encoding/base_encoder.dart | 38 ++- .../lib/src/encoding/deep_object_encoder.dart | 15 +- .../lib/src/encoding/delimited_encoder.dart | 6 +- .../lib/src/encoding/form_encoder.dart | 12 +- .../lib/src/encoding/label_encoder.dart | 10 +- .../lib/src/encoding/matrix_encoder.dart | 10 +- .../lib/src/encoding/simple_encoder.dart | 11 +- .../test/src/decoding/json_decoder_test.dart | 48 ++- .../src/decoding/simple_decoder_test.dart | 31 ++ .../src/encoder/datetime_extension_test.dart | 126 +++---- .../src/encoder/deep_object_encoder_test.dart | 33 ++ .../src/encoder/delimited_encoder_test.dart | 248 ++++++++++++++ .../test/src/encoder/form_encoder_test.dart | 323 ++++++++++++++++++ .../test/src/encoder/label_encoder_test.dart | 181 ++++++++++ .../test/src/encoder/matrix_encoder_test.dart | 237 +++++++++++++ .../test/src/encoder/simple_encoder_test.dart | 177 ++++++++++ 33 files changed, 2191 insertions(+), 98 deletions(-) diff --git a/docs/data_types.md b/docs/data_types.md index 951b631..680e7e2 100644 --- a/docs/data_types.md +++ b/docs/data_types.md @@ -10,6 +10,7 @@ This document provides information about how Tonik is mapping data types in Open | `string` | `date-time` | `DateTime` | `dart:core` | See [Timezone-Aware DateTime Parsing](#timezone-aware-datetime-parsing) | | `string` | `date` | `Date` | `tonik_util` | RFC3339 date format (YYYY-MM-DD) | | `string` | `decimal`, `currency`, `money`, `number` | `BigDecimal` | `big_decimal` | High-precision decimal numbers | +| `string` | `uri`, `url` | `Uri` | `dart:core` | URI/URL parsing and validation | | `string` | `enum` | `enum` | Generated | Custom enum type | | `string` | (default) | `String` | `dart:core` | Standard string type | | `number` | `float`, `double` | `double` | `dart:core` | 64-bit floating point | @@ -42,5 +43,6 @@ For strings with timezone offsets (e.g., `+05:00`), Tonik intelligently selects 1. **Prefers common locations** from the timezone package's curated list of 535+ well-known timezones 2. **Accounts for DST changes** by checking the offset at the specific timestamp 3. **Avoids deprecated locations** (e.g., `US/Eastern` → `America/New_York`) -4. **Falls back to fixed offset** locations (`Etc/GMT±N`) when no match is found +4. **Attempts fixed offset locations** (`Etc/GMT±N`) for standard hour offsets when no timezone match is found +5. **Falls back to UTC** for non-standard offsets or when `Etc/GMT±N` locations are unavailable diff --git a/packages/tonik/README.md b/packages/tonik/README.md index c6b1c09..78dd103 100644 --- a/packages/tonik/README.md +++ b/packages/tonik/README.md @@ -96,7 +96,6 @@ For a full list of changes of each release, refer to [release notes](https://git ### Short term goals - `allowReserved` support for query parameters -- `format: uri` mapping to Dart `Uri` - More E2E tests - Full decoding and encoding support for any of and one of - Support for `x-dart-name`, `x-dart-type` and `x-dart-enums` diff --git a/packages/tonik_core/lib/src/model/model.dart b/packages/tonik_core/lib/src/model/model.dart index bc92452..619d496 100644 --- a/packages/tonik_core/lib/src/model/model.dart +++ b/packages/tonik_core/lib/src/model/model.dart @@ -235,6 +235,13 @@ class DecimalModel extends PrimitiveModel { String toString() => 'DecimalModel'; } +class UriModel extends PrimitiveModel { + const UriModel({required super.context}); + + @override + String toString() => 'UriModel'; +} + @immutable class Property { const Property({ diff --git a/packages/tonik_core/test/model/model_type_properties_test.dart b/packages/tonik_core/test/model/model_type_properties_test.dart index b20fc81..92dbf37 100644 --- a/packages/tonik_core/test/model/model_type_properties_test.dart +++ b/packages/tonik_core/test/model/model_type_properties_test.dart @@ -36,6 +36,10 @@ void main() { DecimalModel(context: Context.initial()).encodingShape, EncodingShape.simple, ); + expect( + UriModel(context: Context.initial()).encodingShape, + EncodingShape.simple, + ); }); test('Enum models are simple', () { diff --git a/packages/tonik_generate/lib/src/util/from_json_value_expression_generator.dart b/packages/tonik_generate/lib/src/util/from_json_value_expression_generator.dart index f30b4d0..01ba7b7 100644 --- a/packages/tonik_generate/lib/src/util/from_json_value_expression_generator.dart +++ b/packages/tonik_generate/lib/src/util/from_json_value_expression_generator.dart @@ -59,6 +59,10 @@ Expression buildFromJsonValueExpression( return refer(value) .property(isNullable ? 'decodeJsonNullableDate' : 'decodeJsonDate') .call([], contextParam); + case UriModel(): + return refer(value) + .property(isNullable ? 'decodeJsonNullableUri' : 'decodeJsonUri') + .call([], contextParam); case ListModel(): return _buildListFromJsonExpression( value, diff --git a/packages/tonik_generate/lib/src/util/from_simple_value_expression_generator.dart b/packages/tonik_generate/lib/src/util/from_simple_value_expression_generator.dart index 40441ab..0447dfd 100644 --- a/packages/tonik_generate/lib/src/util/from_simple_value_expression_generator.dart +++ b/packages/tonik_generate/lib/src/util/from_simple_value_expression_generator.dart @@ -62,6 +62,10 @@ Expression buildSimpleValueExpression( isRequired ? value.property('decodeSimpleDate').call([], contextParam) : value.property('decodeSimpleNullableDate').call([], contextParam), + UriModel() => + isRequired + ? value.property('decodeSimpleUri').call([], contextParam) + : value.property('decodeSimpleNullableUri').call([], contextParam), EnumModel() || ClassModel() || AllOfModel() || @@ -207,6 +211,12 @@ Expression _buildListFromSimpleExpression( isRequired, contextParam: contextParam, ), + UriModel() => _buildPrimitiveList( + listDecode, + 'decodeSimpleUri', + isRequired, + contextParam: contextParam, + ), ClassModel() => throw UnimplementedError( 'ClassModel is not supported in lists for simple encoding', diff --git a/packages/tonik_generate/lib/src/util/to_json_value_expression_generator.dart b/packages/tonik_generate/lib/src/util/to_json_value_expression_generator.dart index 80011cc..7d4982a 100644 --- a/packages/tonik_generate/lib/src/util/to_json_value_expression_generator.dart +++ b/packages/tonik_generate/lib/src/util/to_json_value_expression_generator.dart @@ -49,6 +49,7 @@ String? _getSerializationSuffix(Model model, bool isNullable) { return switch (model) { DateTimeModel() => '$nullablePart.toTimeZonedIso8601String()', DecimalModel() => '$nullablePart.toString()', + UriModel() => '$nullablePart.toString()', DateModel() || EnumModel() || ClassModel() || diff --git a/packages/tonik_generate/lib/src/util/type_reference_generator.dart b/packages/tonik_generate/lib/src/util/type_reference_generator.dart index 63d6e85..4af221a 100644 --- a/packages/tonik_generate/lib/src/util/type_reference_generator.dart +++ b/packages/tonik_generate/lib/src/util/type_reference_generator.dart @@ -82,6 +82,13 @@ TypeReference typeReference( ..url = 'package:big_decimal/big_decimal.dart' ..isNullable = isNullableOverride, ), + UriModel _ => TypeReference( + (b) => + b + ..symbol = 'Uri' + ..url = 'dart:core' + ..isNullable = isNullableOverride, + ), final CompositeModel m => TypeReference( (b) => b diff --git a/packages/tonik_generate/test/src/model/class_generator_test.dart b/packages/tonik_generate/test/src/model/class_generator_test.dart index 10c9360..7c73a17 100644 --- a/packages/tonik_generate/test/src/model/class_generator_test.dart +++ b/packages/tonik_generate/test/src/model/class_generator_test.dart @@ -372,5 +372,135 @@ void main() { expect(field.annotations, isEmpty); }); }); + + test( + 'generates constructor with required fields before non-required fields', + () { + final model = ClassModel( + name: 'User', + properties: [ + Property( + name: 'id', + model: IntegerModel(context: context), + isRequired: true, + isNullable: false, + isDeprecated: false, + ), + Property( + name: 'name', + model: StringModel(context: context), + isRequired: false, + isNullable: true, + isDeprecated: false, + ), + ], + context: context, + ); + + final result = generator.generateClass(model); + final constructor = result.constructors.first; + + expect(constructor.optionalParameters, hasLength(2)); + + final idParam = constructor.optionalParameters[0]; + expect(idParam.name, 'id'); + expect(idParam.required, isTrue); + + final nameParam = constructor.optionalParameters[1]; + expect(nameParam.name, 'name'); + expect(nameParam.required, isFalse); + }, + ); + + test('generates field with Uri type for UriModel property', () { + final model = ClassModel( + name: 'Resource', + properties: [ + Property( + name: 'endpoint', + model: UriModel(context: context), + isRequired: true, + isNullable: false, + isDeprecated: false, + ), + ], + context: context, + ); + + final result = generator.generateClass(model); + final field = result.fields.first; + + expect(field.name, 'endpoint'); + expect(field.modifier, FieldModifier.final$); + + final typeRef = field.type! as TypeReference; + expect(typeRef.symbol, 'Uri'); + expect(typeRef.url, 'dart:core'); + expect(typeRef.isNullable, isFalse); + }); + + test('generates nullable Uri field for nullable UriModel property', () { + final model = ClassModel( + name: 'Resource', + properties: [ + Property( + name: 'optionalEndpoint', + model: UriModel(context: context), + isRequired: false, + isNullable: true, + isDeprecated: false, + ), + ], + context: context, + ); + + final result = generator.generateClass(model); + final field = result.fields.first; + + expect(field.name, 'optionalEndpoint'); + + final typeRef = field.type! as TypeReference; + expect(typeRef.symbol, 'Uri'); + expect(typeRef.url, 'dart:core'); + expect(typeRef.isNullable, isTrue); + }); + + test('generates constructor parameter for Uri property', () { + final model = ClassModel( + name: 'Resource', + properties: [ + Property( + name: 'endpoint', + model: UriModel(context: context), + isRequired: true, + isNullable: false, + isDeprecated: false, + ), + Property( + name: 'callback', + model: UriModel(context: context), + isRequired: false, + isNullable: true, + isDeprecated: false, + ), + ], + context: context, + ); + + final result = generator.generateClass(model); + final constructor = result.constructors.first; + + expect(constructor.optionalParameters, hasLength(2)); + + final endpointParam = constructor.optionalParameters[0]; + expect(endpointParam.name, 'endpoint'); + expect(endpointParam.required, isTrue); + expect(endpointParam.toThis, isTrue); + + final callbackParam = constructor.optionalParameters[1]; + expect(callbackParam.name, 'callback'); + expect(callbackParam.required, isFalse); + expect(callbackParam.toThis, isTrue); + }); }); } diff --git a/packages/tonik_generate/test/src/model/class_json_generator_test.dart b/packages/tonik_generate/test/src/model/class_json_generator_test.dart index 8aba69c..a8ba3bc 100644 --- a/packages/tonik_generate/test/src/model/class_json_generator_test.dart +++ b/packages/tonik_generate/test/src/model/class_json_generator_test.dart @@ -410,6 +410,176 @@ void main() { contains(collapseWhitespace(expectedMethod)), ); }); + + test('generates toJson method with polymorphic model types', () { + final baseModel = ClassModel( + name: 'Base', + properties: const [], + context: context, + ); + final mixinModel = ClassModel( + name: 'Mixin', + properties: const [], + context: context, + ); + + final allOfModel = AllOfModel( + name: 'Combined', + models: {baseModel, mixinModel}, + context: context, + ); + + final catModel = ClassModel( + name: 'Cat', + properties: const [], + context: context, + ); + final dogModel = ClassModel( + name: 'Dog', + properties: const [], + context: context, + ); + + final oneOfModel = OneOfModel( + name: 'Pet', + models: { + (discriminatorValue: 'cat', model: catModel), + (discriminatorValue: 'dog', model: dogModel), + }, + discriminator: 'petType', + context: context, + ); + + final model = ClassModel( + name: 'Container', + properties: [ + Property( + name: 'combinedData', + model: allOfModel, + isRequired: true, + isNullable: false, + isDeprecated: false, + ), + Property( + name: 'pet', + model: oneOfModel, + isRequired: false, + isNullable: true, + isDeprecated: false, + ), + ], + context: context, + ); + + const expectedMethod = ''' + Object? toJson() => { + r'combinedData': combinedData.toJson(), + r'pet': pet?.toJson(), + }; + '''; + + final generatedClass = generator.generateClass(model); + expect( + collapseWhitespace(format(generatedClass.accept(emitter).toString())), + contains(collapseWhitespace(expectedMethod)), + ); + }); + + test('generates toJson method for Uri property', () { + final model = ClassModel( + name: 'Resource', + properties: [ + Property( + name: 'endpoint', + model: UriModel(context: context), + isRequired: true, + isNullable: false, + isDeprecated: false, + ), + ], + context: context, + ); + + const expectedMethod = ''' + Object? toJson() => {r'endpoint': endpoint.toString()}; + '''; + + final generatedClass = generator.generateClass(model); + expect( + collapseWhitespace(format(generatedClass.accept(emitter).toString())), + contains(collapseWhitespace(expectedMethod)), + ); + }); + + test('generates toJson method for nullable Uri property', () { + final model = ClassModel( + name: 'Resource', + properties: [ + Property( + name: 'callback', + model: UriModel(context: context), + isRequired: false, + isNullable: true, + isDeprecated: false, + ), + ], + context: context, + ); + + const expectedMethod = ''' + Object? toJson() => {r'callback': callback?.toString()}; + '''; + + final generatedClass = generator.generateClass(model); + expect( + collapseWhitespace(format(generatedClass.accept(emitter).toString())), + contains(collapseWhitespace(expectedMethod)), + ); + }); + + test('generates toJson method for multiple Uri properties', () { + final model = ClassModel( + name: 'Resource', + properties: [ + Property( + name: 'endpoint', + model: UriModel(context: context), + isRequired: true, + isNullable: false, + isDeprecated: false, + ), + Property( + name: 'callback', + model: UriModel(context: context), + isRequired: false, + isNullable: true, + isDeprecated: false, + ), + Property( + name: 'webhook', + model: UriModel(context: context), + isRequired: true, + isNullable: false, + isDeprecated: false, + ), + ], + context: context, + ); + + const expectedMethod = ''' + Object? toJson() => { + r'endpoint': endpoint.toString(), + r'callback': callback?.toString(), + r'webhook': webhook.toString(), + }; + '''; + + final generatedClass = generator.generateClass(model); + expect( + collapseWhitespace(format(generatedClass.accept(emitter).toString())), + contains(collapseWhitespace(expectedMethod)), + ); + }); }); group('ClassGenerator fromJson generation', () { diff --git a/packages/tonik_generate/test/src/model/class_simple_generator_test.dart b/packages/tonik_generate/test/src/model/class_simple_generator_test.dart index 8189a6a..06bdab5 100644 --- a/packages/tonik_generate/test/src/model/class_simple_generator_test.dart +++ b/packages/tonik_generate/test/src/model/class_simple_generator_test.dart @@ -473,5 +473,177 @@ void main() { ); expect(hasFromSimple, isFalse); }); + + test('fromSimple handles unsupported complex properties', () { + final complexModel = ClassModel( + name: 'Address', + properties: const [], + context: context, + ); + final model = ClassModel( + name: 'User', + properties: [ + Property( + name: 'id', + model: IntegerModel(context: context), + isRequired: true, + isNullable: false, + isDeprecated: false, + ), + Property( + name: 'address', + model: complexModel, + isRequired: true, + isNullable: false, + isDeprecated: false, + ), + ], + context: context, + ); + + final generatedClass = generator.generateClass(model); + final constructors = generatedClass.constructors; + + // Should not have fromSimple constructor due to complex property + final fromSimpleConstructor = + constructors.where((c) => c.name == 'fromSimple').firstOrNull; + expect(fromSimpleConstructor, isNull); + }); + + test('generates fromSimple for Uri property', () { + final model = ClassModel( + name: 'Resource', + properties: [ + Property( + name: 'endpoint', + model: UriModel(context: context), + isRequired: true, + isNullable: false, + isDeprecated: false, + ), + ], + context: context, + ); + + final generatedClass = generator.generateClass(model); + final classCode = format(generatedClass.accept(emitter).toString()); + + const expectedMethod = r''' + factory Resource.fromSimple(String? value) { + final properties = value.decodeSimpleStringList(context: r'Resource'); + if (properties.length < 1) { + throw SimpleDecodingException('Invalid value for Resource: $value'); + } + return Resource( + endpoint: properties[0].decodeSimpleUri(context: r'Resource.endpoint'), + ); + } + '''; + + expect( + collapseWhitespace(classCode), + contains(collapseWhitespace(expectedMethod)), + ); + }); + + test('generates fromSimple for nullable Uri property', () { + final model = ClassModel( + name: 'Resource', + properties: [ + Property( + name: 'callback', + model: UriModel(context: context), + isRequired: false, + isNullable: true, + isDeprecated: false, + ), + ], + context: context, + ); + + final generatedClass = generator.generateClass(model); + final classCode = format(generatedClass.accept(emitter).toString()); + + const expectedMethod = r''' + factory Resource.fromSimple(String? value) { + final properties = value.decodeSimpleStringList(context: r'Resource'); + if (properties.length < 1) { + throw SimpleDecodingException('Invalid value for Resource: $value'); + } + return Resource( + callback: properties[0].decodeSimpleNullableUri( + context: r'Resource.callback', + ), + ); + } + '''; + + expect( + collapseWhitespace(classCode), + contains(collapseWhitespace(expectedMethod)), + ); + }); + + test('generates fromSimple for mixed Uri and primitive properties', () { + final model = ClassModel( + name: 'Resource', + properties: [ + Property( + name: 'name', + model: StringModel(context: context), + isRequired: true, + isNullable: false, + isDeprecated: false, + ), + Property( + name: 'endpoint', + model: UriModel(context: context), + isRequired: true, + isNullable: false, + isDeprecated: false, + ), + Property( + name: 'port', + model: IntegerModel(context: context), + isRequired: true, + isNullable: false, + isDeprecated: false, + ), + Property( + name: 'callback', + model: UriModel(context: context), + isRequired: false, + isNullable: true, + isDeprecated: false, + ), + ], + context: context, + ); + + final generatedClass = generator.generateClass(model); + final classCode = format(generatedClass.accept(emitter).toString()); + + const expectedMethod = r''' + factory Resource.fromSimple(String? value) { + final properties = value.decodeSimpleStringList(context: r'Resource'); + if (properties.length < 4) { + throw SimpleDecodingException('Invalid value for Resource: $value'); + } + return Resource( + name: properties[0].decodeSimpleString(context: r'Resource.name'), + endpoint: properties[1].decodeSimpleUri(context: r'Resource.endpoint'), + port: properties[2].decodeSimpleInt(context: r'Resource.port'), + callback: properties[3].decodeSimpleNullableUri( + context: r'Resource.callback', + ), + ); + } + '''; + + expect( + collapseWhitespace(classCode), + contains(collapseWhitespace(expectedMethod)), + ); + }); }); } diff --git a/packages/tonik_generate/test/src/model/typedef_generator_test.dart b/packages/tonik_generate/test/src/model/typedef_generator_test.dart index 4d9d270..6c2d7a3 100644 --- a/packages/tonik_generate/test/src/model/typedef_generator_test.dart +++ b/packages/tonik_generate/test/src/model/typedef_generator_test.dart @@ -115,6 +115,7 @@ void main() { (model: DateTimeModel(context: context), expectedType: 'DateTime'), (model: DateModel(context: context), expectedType: 'Date'), (model: DecimalModel(context: context), expectedType: 'BigDecimal'), + (model: UriModel(context: context), expectedType: 'Uri'), ]; for (final (index, type) in primitiveTypes.indexed) { @@ -132,6 +133,83 @@ void main() { } }); + group('Uri typedef generation', () { + test('generates typedef for Uri type', () { + final model = AliasModel( + name: 'ApiEndpoint', + model: UriModel(context: context), + context: context, + ); + + final result = generator.generateAlias(model); + final typedef = generator.generateAliasTypedef(model); + + expect(result.filename, 'api_endpoint.dart'); + expect( + typedef.accept(emitter).toString().trim(), + 'typedef ApiEndpoint = Uri;', + ); + }); + + test('generates typedef for required Uri type', () { + final model = AliasModel( + name: 'RequiredEndpoint', + model: UriModel(context: context), + context: context, + ); + + final typedef = generator.generateAliasTypedef(model); + + expect( + typedef.accept(emitter).toString().trim(), + 'typedef RequiredEndpoint = Uri;', + ); + }); + + test('generates typedef for list of URIs', () { + final model = AliasModel( + name: 'EndpointList', + model: ListModel( + content: UriModel(context: context), + context: context, + ), + context: context, + ); + + final result = generator.generateAlias(model); + final typedef = generator.generateAliasTypedef(model); + + expect(result.filename, 'endpoint_list.dart'); + expect( + typedef.accept(emitter).toString().trim(), + 'typedef EndpointList = List;', + ); + }); + + test('generates typedef for nested list of URIs', () { + final model = AliasModel( + name: 'EndpointMatrix', + model: ListModel( + content: ListModel( + content: UriModel(context: context), + context: context, + ), + context: context, + ), + context: context, + ); + + final result = generator.generateAlias(model); + final typedef = generator.generateAliasTypedef(model); + + expect(result.filename, 'endpoint_matrix.dart'); + expect( + typedef.accept(emitter).toString().trim(), + 'typedef EndpointMatrix = List>;', + ); + }); + }); + group('generateFromList', () { test('generates typedef for list of primitive types', () { final model = ListModel( @@ -208,6 +286,23 @@ void main() { 'typedef Anonymous = List;', ); }); + + test('generates typedef for list of URIs', () { + final model = ListModel( + name: 'UriList', + content: UriModel(context: context), + context: context, + ); + + final result = generator.generateList(model); + final typedef = generator.generateListTypedef(model); + + expect(result.filename, 'uri_list.dart'); + expect( + typedef.accept(emitter).toString().trim(), + 'typedef UriList = List;', + ); + }); }); }); } diff --git a/packages/tonik_parse/lib/src/model_importer.dart b/packages/tonik_parse/lib/src/model_importer.dart index 5ec0bd0..e79bc6b 100644 --- a/packages/tonik_parse/lib/src/model_importer.dart +++ b/packages/tonik_parse/lib/src/model_importer.dart @@ -127,6 +127,8 @@ class ModelImporter { 'number', ].contains(schema.format) => DecimalModel(context: context), + 'string' when schema.format == 'uri' || schema.format == 'url' => + UriModel(context: context), 'string' when schema.enumerated != null => _parseEnum( name, schema.enumerated!, diff --git a/packages/tonik_parse/test/model/model_property_test.dart b/packages/tonik_parse/test/model/model_property_test.dart index a93c39f..8155e53 100644 --- a/packages/tonik_parse/test/model/model_property_test.dart +++ b/packages/tonik_parse/test/model/model_property_test.dart @@ -26,6 +26,8 @@ void main() { 'boolean': {'type': 'boolean'}, 'date': {'type': 'string', 'format': 'date'}, 'dateTime': {'type': 'string', 'format': 'date-time'}, + 'uri': {'type': 'string', 'format': 'uri'}, + 'url': {'type': 'string', 'format': 'url'}, }, }, }, @@ -147,4 +149,20 @@ void main() { final dateTime = model.properties.firstWhere((p) => p.name == 'dateTime'); expect(dateTime.model, isA()); }); + + test('imports uri format', () { + final api = Importer().import(fileContent); + + final model = api.models.first as ClassModel; + final uri = model.properties.firstWhere((p) => p.name == 'uri'); + expect(uri.model, isA()); + }); + + test('imports url format', () { + final api = Importer().import(fileContent); + + final model = api.models.first as ClassModel; + final url = model.properties.firstWhere((p) => p.name == 'url'); + expect(url.model, isA()); + }); } diff --git a/packages/tonik_parse/test/model/single_schema_import_test.dart b/packages/tonik_parse/test/model/single_schema_import_test.dart index 37dcfe6..464fc07 100644 --- a/packages/tonik_parse/test/model/single_schema_import_test.dart +++ b/packages/tonik_parse/test/model/single_schema_import_test.dart @@ -86,6 +86,46 @@ void main() { ), ); + final inlineUri = InlinedObject( + Schema( + type: ['string'], + format: 'uri', + required: [], + enumerated: null, + allOf: null, + anyOf: null, + oneOf: null, + not: null, + items: null, + properties: {}, + description: '', + isNullable: false, + discriminator: null, + isDeprecated: false, + uniqueItems: false, + ), + ); + + final inlineUrl = InlinedObject( + Schema( + type: ['string'], + format: 'url', + required: [], + enumerated: null, + allOf: null, + anyOf: null, + oneOf: null, + not: null, + items: null, + properties: {}, + description: '', + isNullable: false, + discriminator: null, + isDeprecated: false, + uniqueItems: false, + ), + ); + final reference = Reference('#/components/schemas/TestModel'); late ModelImporter importer; @@ -132,4 +172,36 @@ void main() { expect(importer.models, models); }); + + test('returns UriModel for uri format schema', () { + final context = Context.initial().pushAll(['components', 'schemas']); + + final result = importer.importSchema(inlineUri, context); + + expect(result, isA()); + expect(result.context.path, ['components', 'schemas']); + }); + + test('returns UriModel for url format schema', () { + final context = Context.initial().pushAll(['components', 'schemas']); + + final result = importer.importSchema(inlineUrl, context); + + expect(result, isA()); + expect(result.context.path, ['components', 'schemas']); + }); + + test('does not add inline uri schema to models', () { + final context = Context.initial().pushAll(['components', 'schemas']); + + final result = importer.importSchema(inlineUri, context); + expect(importer.models.contains(result), isFalse); + }); + + test('does not add inline url schema to models', () { + final context = Context.initial().pushAll(['components', 'schemas']); + + final result = importer.importSchema(inlineUrl, context); + expect(importer.models.contains(result), isFalse); + }); } diff --git a/packages/tonik_util/lib/src/decoding/json_decoder.dart b/packages/tonik_util/lib/src/decoding/json_decoder.dart index eae0956..0630df7 100644 --- a/packages/tonik_util/lib/src/decoding/json_decoder.dart +++ b/packages/tonik_util/lib/src/decoding/json_decoder.dart @@ -384,12 +384,54 @@ extension JsonDecoder on Object? { /// Decodes a JSON value to a nullable Date. /// - /// Returns null if the value is null or an empty string. + /// Returns null if the value is null. /// Throws [InvalidTypeException] if the value is not a valid date string. Date? decodeJsonNullableDate({String? context}) { - if (this == null || (this is String && (this! as String).isEmpty)) { + if (this == null) { return null; } return decodeJsonDate(context: context); } + + /// Decodes a JSON value to a Uri. + /// + /// Expects a valid URI string. + /// Throws [InvalidTypeException] if the value is not a valid URI string + /// or if the value is null. + Uri decodeJsonUri({String? context}) { + if (this == null) { + throw InvalidTypeException( + value: 'null', + targetType: Uri, + context: context, + ); + } + if (this is! String) { + throw InvalidTypeException( + value: toString(), + targetType: Uri, + context: context, + ); + } + try { + return Uri.parse(this! as String); + } on FormatException catch (e) { + throw InvalidTypeException( + value: this! as String, + targetType: Uri, + context: e.message, + ); + } + } + + /// Decodes a JSON value to a nullable Uri. + /// + /// Returns null if the value is null. + /// Throws [InvalidTypeException] if the value is not a valid URI string. + Uri? decodeJsonNullableUri({String? context}) { + if (this == null) { + return null; + } + return decodeJsonUri(context: context); + } } diff --git a/packages/tonik_util/lib/src/decoding/simple_decoder.dart b/packages/tonik_util/lib/src/decoding/simple_decoder.dart index 151b593..930c665 100644 --- a/packages/tonik_util/lib/src/decoding/simple_decoder.dart +++ b/packages/tonik_util/lib/src/decoding/simple_decoder.dart @@ -300,4 +300,44 @@ extension SimpleDecoder on String? { if (this?.isEmpty ?? true) return null; return decodeSimpleDate(context: context); } + + /// Decodes a string to a Uri. + /// + /// Expects a valid URI string. + /// Throws [InvalidTypeException] if the value is null or if the string + /// is not a valid URI. + Uri decodeSimpleUri({String? context}) { + if (this == null) { + throw InvalidTypeException( + value: 'null', + targetType: Uri, + context: context, + ); + } + if (this!.isEmpty) { + throw InvalidTypeException( + value: 'empty string', + targetType: Uri, + context: context, + ); + } + try { + return Uri.parse(this!); + } on FormatException catch (e) { + throw InvalidTypeException( + value: this!, + targetType: Uri, + context: e.message, + ); + } + } + + /// Decodes a string to a nullable Uri. + /// + /// Returns null if the string is empty or null. + /// Throws [InvalidTypeException] if the string is not a valid URI. + Uri? decodeSimpleNullableUri({String? context}) { + if (this?.isEmpty ?? true) return null; + return decodeSimpleUri(context: context); + } } diff --git a/packages/tonik_util/lib/src/encoding/base_encoder.dart b/packages/tonik_util/lib/src/encoding/base_encoder.dart index eb21e97..49ca516 100644 --- a/packages/tonik_util/lib/src/encoding/base_encoder.dart +++ b/packages/tonik_util/lib/src/encoding/base_encoder.dart @@ -17,7 +17,11 @@ abstract class BaseEncoder { /// that don't support object encoding). @protected void checkSupportedType(dynamic value, {bool supportMaps = true}) { - if (value == null || value is String || value is num || value is bool) { + if (value == null || + value is String || + value is num || + value is bool || + value is Uri) { return; } @@ -31,6 +35,7 @@ abstract class BaseEncoder { return element is! String && element is! num && element is! bool && + element is! Uri && element != null; }); @@ -63,7 +68,11 @@ abstract class BaseEncoder { } final hasUnsupportedValue = value.values.any((val) { - return val is! String && val is! num && val is! bool && val != null; + return val is! String && + val is! num && + val is! bool && + val is! Uri && + val != null; }); if (hasUnsupportedValue) { @@ -87,6 +96,10 @@ abstract class BaseEncoder { return value.toTimeZonedIso8601String(); } + if (value is Uri) { + return value.toString(); + } + return value.toString(); } @@ -107,4 +120,25 @@ abstract class BaseEncoder { return Uri.encodeComponent(value); } } + + /// Encodes a dynamic value, handling URI objects specially. + /// + /// For URI objects: + /// - When [useQueryEncoding] is true (query parameters), returns the URI + /// string as-is since URIs are already properly encoded + /// - When [useQueryEncoding] is false (path parameters), encodes the URI + /// string for use in path segments + /// + /// For other values, converts to string and applies standard encoding. + @protected + String encodeValueDynamic(dynamic value, {bool useQueryEncoding = false}) { + final stringValue = valueToString(value); + + if (value is Uri && useQueryEncoding) { + // URIs are already properly encoded, don't double-encode for query params + return stringValue; + } + + return encodeValue(stringValue, useQueryEncoding: useQueryEncoding); + } } diff --git a/packages/tonik_util/lib/src/encoding/deep_object_encoder.dart b/packages/tonik_util/lib/src/encoding/deep_object_encoder.dart index f6d35ea..72df291 100644 --- a/packages/tonik_util/lib/src/encoding/deep_object_encoder.dart +++ b/packages/tonik_util/lib/src/encoding/deep_object_encoder.dart @@ -83,8 +83,8 @@ class DeepObjectEncoder extends BaseEncoder { } else if (value is String && !allowEmpty && value.isEmpty) { throw const EmptyValueException(); } else { - final encodedValue = encodeValue( - valueToString(value), + final encodedValue = encodeValueDynamic( + value, useQueryEncoding: true, ); result.add((name: '$path[$key]', value: encodedValue)); @@ -112,7 +112,11 @@ class DeepObjectEncoder extends BaseEncoder { for (final entry in value.entries) { final val = entry.value; - if (val == null || val is String || val is num || val is bool) { + if (val == null || + val is String || + val is num || + val is bool || + val is Uri) { continue; } @@ -143,6 +147,11 @@ class DeepObjectEncoder extends BaseEncoder { if (value == null) { return ''; } + + if (value is Uri) { + return value.toString(); + } + return value.toString(); } } diff --git a/packages/tonik_util/lib/src/encoding/delimited_encoder.dart b/packages/tonik_util/lib/src/encoding/delimited_encoder.dart index bc4ac30..9dcbfbd 100644 --- a/packages/tonik_util/lib/src/encoding/delimited_encoder.dart +++ b/packages/tonik_util/lib/src/encoding/delimited_encoder.dart @@ -75,7 +75,7 @@ class DelimitedEncoder extends BaseEncoder { return value .map( (item) => - encodeValue(valueToString(item), useQueryEncoding: true), + encodeValueDynamic(item, useQueryEncoding: true), ) .toList(); } else { @@ -84,14 +84,14 @@ class DelimitedEncoder extends BaseEncoder { value .map( (item) => - encodeValue(valueToString(item), useQueryEncoding: true), + encodeValueDynamic(item, useQueryEncoding: true), ) .join(delimiter), ]; } } - return [encodeValue(valueToString(value), useQueryEncoding: true)]; + return [encodeValueDynamic(value, useQueryEncoding: true)]; } } diff --git a/packages/tonik_util/lib/src/encoding/form_encoder.dart b/packages/tonik_util/lib/src/encoding/form_encoder.dart index fab5344..e1c4284 100644 --- a/packages/tonik_util/lib/src/encoding/form_encoder.dart +++ b/packages/tonik_util/lib/src/encoding/form_encoder.dart @@ -50,7 +50,7 @@ class FormEncoder extends BaseEncoder { if (value is Iterable) { final values = value.map( - (item) => encodeValue(valueToString(item), useQueryEncoding: true), + (item) => encodeValueDynamic(item, useQueryEncoding: true), ); if (explode) { @@ -67,8 +67,8 @@ class FormEncoder extends BaseEncoder { .map( (entry) => ( name: entry.key, - value: encodeValue( - valueToString(entry.value), + value: encodeValueDynamic( + entry.value, useQueryEncoding: true, ), ), @@ -83,8 +83,8 @@ class FormEncoder extends BaseEncoder { .expand( (entry) => [ entry.key, - encodeValue( - valueToString(entry.value), + encodeValueDynamic( + entry.value, useQueryEncoding: true, ), ], @@ -98,7 +98,7 @@ class FormEncoder extends BaseEncoder { return [ ( name: paramName, - value: encodeValue(valueToString(value), useQueryEncoding: true), + value: encodeValueDynamic(value, useQueryEncoding: true), ), ]; } diff --git a/packages/tonik_util/lib/src/encoding/label_encoder.dart b/packages/tonik_util/lib/src/encoding/label_encoder.dart index 30c2c90..abca55f 100644 --- a/packages/tonik_util/lib/src/encoding/label_encoder.dart +++ b/packages/tonik_util/lib/src/encoding/label_encoder.dart @@ -52,12 +52,12 @@ class LabelEncoder extends BaseEncoder { if (explode) { // With explode=true, each value gets its own dot prefix return value - .map((item) => '.${encodeValue(valueToString(item))}') + .map((item) => '.${encodeValueDynamic(item)}') .join(); } else { // With explode=false (default), comma-separate the values final encodedValues = value - .map((item) => encodeValue(valueToString(item))) + .map(encodeValueDynamic) .join(','); return '.$encodedValues'; } @@ -69,20 +69,20 @@ class LabelEncoder extends BaseEncoder { return value.entries .map( (entry) => - '.${entry.key}=${encodeValue(valueToString(entry.value))}', + '.${entry.key}=${encodeValueDynamic(entry.value)}', ) .join(); } else { // With explode=false, properties are comma-separated pairs final encodedPairs = value.entries .expand( - (entry) => [entry.key, encodeValue(valueToString(entry.value))], + (entry) => [entry.key, encodeValueDynamic(entry.value)], ) .join(','); return '.$encodedPairs'; } } - return '.${encodeValue(valueToString(value))}'; + return '.${encodeValueDynamic(value)}'; } } diff --git a/packages/tonik_util/lib/src/encoding/matrix_encoder.dart b/packages/tonik_util/lib/src/encoding/matrix_encoder.dart index 856211c..5aae4dc 100644 --- a/packages/tonik_util/lib/src/encoding/matrix_encoder.dart +++ b/packages/tonik_util/lib/src/encoding/matrix_encoder.dart @@ -57,12 +57,12 @@ class MatrixEncoder extends BaseEncoder { if (explode) { return value - .map((item) => ';$paramName=${encodeValue(valueToString(item))}') + .map((item) => ';$paramName=${encodeValueDynamic(item)}') .join(); } else { // With explode=false (default), comma-separate the values final encodedValues = value - .map((item) => encodeValue(valueToString(item))) + .map(encodeValueDynamic) .join(','); return ';$paramName=$encodedValues'; } @@ -83,7 +83,7 @@ class MatrixEncoder extends BaseEncoder { .map( (entry) => ';$paramName.${entry.key}=' - '${encodeValue(valueToString(entry.value))}', + '${encodeValueDynamic(entry.value)}', ) .join(); } else { @@ -91,13 +91,13 @@ class MatrixEncoder extends BaseEncoder { // comma-separated pairs: ;point=x,1,y,2 final encodedPairs = value.entries .expand( - (entry) => [entry.key, encodeValue(valueToString(entry.value))], + (entry) => [entry.key, encodeValueDynamic(entry.value)], ) .join(','); return ';$paramName=$encodedPairs'; } } - return ';$paramName=${encodeValue(valueToString(value))}'; + return ';$paramName=${encodeValueDynamic(value)}'; } } diff --git a/packages/tonik_util/lib/src/encoding/simple_encoder.dart b/packages/tonik_util/lib/src/encoding/simple_encoder.dart index a7cce20..7816531 100644 --- a/packages/tonik_util/lib/src/encoding/simple_encoder.dart +++ b/packages/tonik_util/lib/src/encoding/simple_encoder.dart @@ -52,10 +52,10 @@ class SimpleEncoder extends BaseEncoder { // but since SimpleEncoder only encodes the value part // (not the full parameter), we'll use comma-separated values // as a fallback - return value.map((item) => encodeValue(valueToString(item))).join(','); + return value.map(encodeValueDynamic).join(','); } else { // With explode=false, comma-separate the values - return value.map((item) => encodeValue(valueToString(item))).join(','); + return value.map(encodeValueDynamic).join(','); } } @@ -64,20 +64,19 @@ class SimpleEncoder extends BaseEncoder { // With explode=true, key=value pairs are comma-separated return value.entries .map( - (entry) => - '${entry.key}=${encodeValue(valueToString(entry.value))}', + (entry) => '${entry.key}=${encodeValueDynamic(entry.value)}', ) .join(','); } else { // With explode=false, keys and values are comma-separated return value.entries .expand( - (entry) => [entry.key, encodeValue(valueToString(entry.value))], + (entry) => [entry.key, encodeValueDynamic(entry.value)], ) .join(','); } } - return encodeValue(valueToString(value)); + return encodeValueDynamic(value); } } diff --git a/packages/tonik_util/test/src/decoding/json_decoder_test.dart b/packages/tonik_util/test/src/decoding/json_decoder_test.dart index e474fcc..d8b2057 100644 --- a/packages/tonik_util/test/src/decoding/json_decoder_test.dart +++ b/packages/tonik_util/test/src/decoding/json_decoder_test.dart @@ -258,7 +258,10 @@ void main() { final date = Date(2024, 3, 15); expect('2024-03-15'.decodeJsonNullableDate(), date); expect(null.decodeJsonNullableDate(), isNull); - expect(''.decodeJsonNullableDate(), isNull); + expect( + () => ''.decodeJsonNullableDate(), + throwsA(isA()), + ); expect( () => 123.decodeJsonNullableDate(), throwsA(isA()), @@ -285,6 +288,49 @@ void main() { ); }); }); + + group('Uri', () { + test('decodes Uri values', () { + final uri = Uri.parse('https://example.com'); + expect('https://example.com'.decodeJsonUri(), uri); + expect('ftp://files.example.com/file.txt'.decodeJsonUri(), + Uri.parse('ftp://files.example.com/file.txt')); + expect('/relative/path'.decodeJsonUri(), Uri.parse('/relative/path')); + expect('mailto:user@example.com'.decodeJsonUri(), + Uri.parse('mailto:user@example.com')); + expect( + () => 123.decodeJsonUri(), + throwsA(isA()), + ); + expect( + () => null.decodeJsonUri(), + throwsA(isA()), + ); + }); + + test('decodes nullable Uri values', () { + final uri = Uri.parse('https://example.com'); + expect('https://example.com'.decodeJsonNullableUri(), uri); + expect('/api/v1/users'.decodeJsonNullableUri(), Uri.parse('/api/v1/users')); + expect(null.decodeJsonNullableUri(), isNull); + expect(''.decodeJsonNullableUri(), Uri.parse('')); + expect( + () => 123.decodeJsonNullableUri(), + throwsA(isA()), + ); + }); + + test('handles invalid URI strings', () { + expect( + () => ':::invalid:::'.decodeJsonUri(), + throwsA(isA()), + ); + expect( + () => ':::invalid:::'.decodeJsonNullableUri(), + throwsA(isA()), + ); + }); + }); }); group('List', () { diff --git a/packages/tonik_util/test/src/decoding/simple_decoder_test.dart b/packages/tonik_util/test/src/decoding/simple_decoder_test.dart index 65fceb4..6524bd4 100644 --- a/packages/tonik_util/test/src/decoding/simple_decoder_test.dart +++ b/packages/tonik_util/test/src/decoding/simple_decoder_test.dart @@ -126,6 +126,27 @@ void main() { throwsA(isA()), ); }); + + test('decodes Uri values', () { + final uri = Uri.parse('https://example.com'); + expect('https://example.com'.decodeSimpleUri(), uri); + expect('ftp://files.example.com/file.txt'.decodeSimpleUri(), + Uri.parse('ftp://files.example.com/file.txt')); + expect('/relative/path'.decodeSimpleUri(), Uri.parse('/relative/path')); + expect('mailto:user@example.com'.decodeSimpleUri(), + Uri.parse('mailto:user@example.com')); + expect( + () => null.decodeSimpleUri(), + throwsA(isA()), + ); + }); + + test('handles URI parsing errors', () { + expect( + () => ':::invalid:::'.decodeSimpleUri(), + throwsA(isA()), + ); + }); }); group('Nullable Values', () { @@ -136,6 +157,7 @@ void main() { expect(''.decodeSimpleNullableDateTime(), isNull); expect(''.decodeSimpleNullableBigDecimal(), isNull); expect(''.decodeSimpleNullableDate(), isNull); + expect(''.decodeSimpleNullableUri(), isNull); expect(null.decodeSimpleNullableInt(), isNull); expect(null.decodeSimpleNullableDouble(), isNull); @@ -143,6 +165,7 @@ void main() { expect(null.decodeSimpleNullableDateTime(), isNull); expect(null.decodeSimpleNullableBigDecimal(), isNull); expect(null.decodeSimpleNullableDate(), isNull); + expect(null.decodeSimpleNullableUri(), isNull); }); test('decodes non-empty strings for nullable types', () { @@ -161,6 +184,10 @@ void main() { '2024-03-15'.decodeSimpleNullableDate(), Date(2024, 3, 15), ); + expect( + 'https://example.com'.decodeSimpleNullableUri(), + Uri.parse('https://example.com'), + ); }); }); @@ -190,6 +217,10 @@ void main() { () => ''.decodeSimpleDate(), throwsA(isA()), ); + expect( + () => ''.decodeSimpleUri(), + throwsA(isA()), + ); }); }); diff --git a/packages/tonik_util/test/src/encoder/datetime_extension_test.dart b/packages/tonik_util/test/src/encoder/datetime_extension_test.dart index 77be07d..0490711 100644 --- a/packages/tonik_util/test/src/encoder/datetime_extension_test.dart +++ b/packages/tonik_util/test/src/encoder/datetime_extension_test.dart @@ -11,7 +11,7 @@ void main() { test('encodes UTC DateTime correctly', () { final utcDateTime = DateTime.utc(2023, 12, 25, 15, 30, 45, 123); final result = utcDateTime.toTimeZonedIso8601String(); - + // Should match toIso8601String for UTC dates expect(result, utcDateTime.toIso8601String()); expect(result, '2023-12-25T15:30:45.123Z'); @@ -22,17 +22,17 @@ void main() { test('encodes in EST (UTC-5:00)', () { final estLocation = tz.getLocation('America/New_York'); final estDateTime = tz.TZDateTime( - estLocation, - 2023, - 12, - 25, - 15, - 30, + estLocation, + 2023, + 12, + 25, + 15, + 30, 45, ); - + final result = estDateTime.toTimeZonedIso8601String(); - + // Should include EST timezone offset (-05:00) expect(result, '2023-12-25T15:30:45-05:00'); }); @@ -40,17 +40,17 @@ void main() { test('encodes in PST (UTC-8:00)', () { final pstLocation = tz.getLocation('America/Los_Angeles'); final pstDateTime = tz.TZDateTime( - pstLocation, - 2023, - 12, - 25, - 18, - 30, + pstLocation, + 2023, + 12, + 25, + 18, + 30, 45, ); - + final result = pstDateTime.toTimeZonedIso8601String(); - + // Should include PST timezone offset (-08:00) expect(result, '2023-12-25T18:30:45-08:00'); }); @@ -58,17 +58,17 @@ void main() { test('encodes in IST (UTC+5:30)', () { final istLocation = tz.getLocation('Asia/Kolkata'); final istDateTime = tz.TZDateTime( - istLocation, - 2023, - 12, - 25, - 20, - 0, + istLocation, + 2023, + 12, + 25, + 20, + 0, 45, ); - + final result = istDateTime.toTimeZonedIso8601String(); - + // Should include IST timezone offset (+05:30) expect(result, '2023-12-25T20:00:45+05:30'); }); @@ -76,17 +76,17 @@ void main() { test('encodes in CET (UTC+1:00)', () { final cetLocation = tz.getLocation('Europe/Paris'); final cetDateTime = tz.TZDateTime( - cetLocation, - 2023, - 12, - 25, - 16, - 30, + cetLocation, + 2023, + 12, + 25, + 16, + 30, 45, ); - + final result = cetDateTime.toTimeZonedIso8601String(); - + // Should include CET timezone offset (+01:00) expect(result, '2023-12-25T16:30:45+01:00'); }); @@ -94,17 +94,17 @@ void main() { test('encodes in GMT (UTC+0:00)', () { final gmtLocation = tz.getLocation('Europe/London'); final gmtDateTime = tz.TZDateTime( - gmtLocation, - 2023, - 12, - 25, - 15, - 30, + gmtLocation, + 2023, + 12, + 25, + 15, + 30, 45, ); - + final result = gmtDateTime.toTimeZonedIso8601String(); - + // Should include GMT timezone offset (+00:00) expect(result, '2023-12-25T15:30:45+00:00'); }); @@ -112,18 +112,18 @@ void main() { test('encodes with milliseconds in timezone', () { final estLocation = tz.getLocation('America/New_York'); final estDateTime = tz.TZDateTime( - estLocation, - 2023, - 12, - 25, - 15, - 30, - 45, + estLocation, + 2023, + 12, + 25, + 15, + 30, + 45, 123, ); - + final result = estDateTime.toTimeZonedIso8601String(); - + // Should include EST timezone offset (-05:00) and milliseconds expect(result, '2023-12-25T15:30:45.123-05:00'); }); @@ -131,19 +131,19 @@ void main() { test('encodes with microseconds in timezone', () { final pstLocation = tz.getLocation('America/Los_Angeles'); final pstDateTime = tz.TZDateTime( - pstLocation, - 2023, - 12, - 25, - 18, - 30, - 45, - 123, + pstLocation, + 2023, + 12, + 25, + 18, + 30, + 45, + 123, 456, ); - + final result = pstDateTime.toTimeZonedIso8601String(); - + // Should include PST timezone offset (-08:00) and microseconds expect(result, '2023-12-25T18:30:45.123456-08:00'); }); @@ -151,12 +151,12 @@ void main() { test('encodes in JST (UTC+9:00)', () { final jstLocation = tz.getLocation('Asia/Tokyo'); final jstDateTime = tz.TZDateTime(jstLocation, 2009, 6, 30, 18, 30); - + final result = jstDateTime.toTimeZonedIso8601String(); - + // Should include JST timezone offset (+09:00) expect(result, '2009-06-30T18:30:00+09:00'); }); }); }); -} +} diff --git a/packages/tonik_util/test/src/encoder/deep_object_encoder_test.dart b/packages/tonik_util/test/src/encoder/deep_object_encoder_test.dart index 9bfd98b..536a117 100644 --- a/packages/tonik_util/test/src/encoder/deep_object_encoder_test.dart +++ b/packages/tonik_util/test/src/encoder/deep_object_encoder_test.dart @@ -38,6 +38,39 @@ void main() { ]); }); + test('encodes Uri properties', () { + final result = encoder.encode( + 'filter', + { + 'endpoint': Uri.parse('https://example.com/api/v1'), + 'callback': Uri.parse('https://example.com/callback'), + }, + allowEmpty: true, + ); + + expect(result, [ + (name: 'filter[endpoint]', value: 'https://example.com/api/v1'), + (name: 'filter[callback]', value: 'https://example.com/callback'), + ]); + }); + + test('encodes Uri properties with special characters', () { + final result = encoder.encode( + 'params', + { + 'url': Uri.parse('https://example.com/search?q=hello world'), + }, + allowEmpty: true, + ); + + expect(result, [ + ( + name: 'params[url]', + value: 'https://example.com/search?q=hello%20world', + ), + ]); + }); + test('encodes an object with a null value', () { final result = encoder.encode( 'filter', diff --git a/packages/tonik_util/test/src/encoder/delimited_encoder_test.dart b/packages/tonik_util/test/src/encoder/delimited_encoder_test.dart index 5c2bbbb..c7bcc47 100644 --- a/packages/tonik_util/test/src/encoder/delimited_encoder_test.dart +++ b/packages/tonik_util/test/src/encoder/delimited_encoder_test.dart @@ -42,6 +42,20 @@ void main() { ]); }); + test('encodes Uri value', () { + final uri = Uri.parse('https://example.com/api/v1'); + expect(encoder.encode(uri, explode: false, allowEmpty: true), [ + 'https://example.com/api/v1', + ]); + }); + + test('encodes Uri value with special characters', () { + final uri = Uri.parse('https://example.com/search?q=hello world'); + expect(encoder.encode(uri, explode: false, allowEmpty: true), [ + 'https://example.com/search?q=hello%20world', + ]); + }); + test('encodes null value when allowEmpty is true', () { expect(encoder.encode(null, explode: false, allowEmpty: true), ['']); }); @@ -265,6 +279,20 @@ void main() { ]); }); + test('encodes Uri value', () { + final uri = Uri.parse('https://example.com/api/v1'); + expect(encoder.encode(uri, explode: false, allowEmpty: true), [ + 'https://example.com/api/v1', + ]); + }); + + test('encodes Uri value with special characters', () { + final uri = Uri.parse('https://example.com/search?q=hello world'); + expect(encoder.encode(uri, explode: false, allowEmpty: true), [ + 'https://example.com/search?q=hello%20world', + ]); + }); + test('encodes null value when allowEmpty is true', () { expect(encoder.encode(null, explode: false, allowEmpty: true), ['']); }); @@ -448,6 +476,226 @@ void main() { ); }); }); + + group('RFC 3986 reserved character encoding', () { + group('gen-delims characters', () { + test('encodes colon (:) properly', () { + expect( + encoder.encode( + 'http://example.com', + explode: false, + allowEmpty: true, + ), + ['http%3A%2F%2Fexample.com'], + ); + }); + + test('encodes forward slash (/) properly', () { + expect( + encoder.encode('/api/v1/users', explode: false, allowEmpty: true), + ['%2Fapi%2Fv1%2Fusers'], + ); + }); + + test('encodes question mark (?) properly', () { + expect( + encoder.encode( + 'search?term=test', + explode: false, + allowEmpty: true, + ), + ['search%3Fterm%3Dtest'], + ); + }); + + test('encodes hash (#) properly', () { + expect( + encoder.encode('page#section1', explode: false, allowEmpty: true), + ['page%23section1'], + ); + }); + + test('encodes square brackets ([]) properly', () { + expect( + encoder.encode('[2001:db8::1]', explode: false, allowEmpty: true), + ['%5B2001%3Adb8%3A%3A1%5D'], + ); + }); + + test('encodes at symbol (@) properly', () { + expect( + encoder.encode( + 'user@example.com', + explode: false, + allowEmpty: true, + ), + ['user%40example.com'], + ); + }); + }); + + group('sub-delims characters', () { + test('encodes exclamation mark (!) properly', () { + expect( + encoder.encode('Hello!', explode: false, allowEmpty: true), + ['Hello%21'], + ); + }); + + test(r'encodes dollar sign ($) properly', () { + expect( + encoder.encode(r'$19.99', explode: false, allowEmpty: true), + ['%2419.99'], + ); + }); + + test('encodes ampersand (&) properly', () { + expect( + encoder.encode( + 'Johnson & Johnson', + explode: false, + allowEmpty: true, + ), + ['Johnson+%26+Johnson'], + ); + }); + + test("encodes single quote (') properly", () { + expect( + encoder.encode("It's working", explode: false, allowEmpty: true), + ['It%27s+working'], + ); + }); + + test('encodes parentheses () properly', () { + expect( + encoder.encode( + '(555) 123-4567', + explode: false, + allowEmpty: true, + ), + ['%28555%29+123-4567'], + ); + }); + + test('encodes asterisk (*) properly', () { + expect( + encoder.encode('file*.txt', explode: false, allowEmpty: true), + ['file%2A.txt'], + ); + }); + + test('encodes plus (+) properly', () { + expect( + encoder.encode('2+2=4', explode: false, allowEmpty: true), + ['2%2B2%3D4'], + ); + }); + + test('encodes comma (,) properly', () { + expect( + encoder.encode( + 'apple,banana,cherry', + explode: false, + allowEmpty: true, + ), + ['apple%2Cbanana%2Ccherry'], + ); + }); + + test('encodes semicolon (;) properly', () { + expect( + encoder.encode('a=1;b=2', explode: false, allowEmpty: true), + ['a%3D1%3Bb%3D2'], + ); + }); + + test('encodes equals (=) properly', () { + expect( + encoder.encode('x=y', explode: false, allowEmpty: true), + ['x%3Dy'], + ); + }); + }); + + group('unreserved characters should NOT be encoded', () { + test('does not encode letters', () { + expect( + encoder.encode('ABCdef', explode: false, allowEmpty: true), + ['ABCdef'], + ); + }); + + test('does not encode digits', () { + expect( + encoder.encode('1234567890', explode: false, allowEmpty: true), + ['1234567890'], + ); + }); + + test('does not encode hyphen (-)', () { + expect( + encoder.encode( + '123e4567-e89b-12d3', + explode: false, + allowEmpty: true, + ), + ['123e4567-e89b-12d3'], + ); + }); + + test('does not encode period (.)', () { + expect( + encoder.encode('example.com', explode: false, allowEmpty: true), + ['example.com'], + ); + }); + + test('does not encode underscore (_)', () { + expect( + encoder.encode('my_variable', explode: false, allowEmpty: true), + ['my_variable'], + ); + }); + + test('does not encode tilde (~)', () { + expect( + encoder.encode('~%2Fdocuments', explode: false, allowEmpty: true), + ['~%252Fdocuments'], + ); + }); + }); + + group('percent-encoding normalization', () { + test('uses uppercase hex digits for encoding', () { + expect( + encoder.encode('hello world!', explode: false, allowEmpty: true), + ['hello+world%21'], + ); + }); + + test('properly encodes non-ASCII characters', () { + expect( + encoder.encode('café', explode: false, allowEmpty: true), + ['caf%C3%A9'], + ); + }); + + test('properly encodes emoji', () { + expect( + encoder.encode('👍', explode: false, allowEmpty: true), + ['%F0%9F%91%8D'], + ); + }); + + test('properly encodes Chinese characters', () { + expect( + encoder.encode('你好', explode: false, allowEmpty: true), + ['%E4%BD%A0%E5%A5%BD'], + ); + }); + }); + }); }); }); } diff --git a/packages/tonik_util/test/src/encoder/form_encoder_test.dart b/packages/tonik_util/test/src/encoder/form_encoder_test.dart index ec39673..e900e14 100644 --- a/packages/tonik_util/test/src/encoder/form_encoder_test.dart +++ b/packages/tonik_util/test/src/encoder/form_encoder_test.dart @@ -46,6 +46,22 @@ void main() { ); }); + test('encodes Uri value', () { + final uri = Uri.parse('https://example.com/api/v1'); + expect( + encoder.encode('endpoint', uri, explode: false, allowEmpty: true), + [(name: 'endpoint', value: 'https://example.com/api/v1')], + ); + }); + + test('encodes Uri value with special characters', () { + final uri = Uri.parse('https://example.com/search?q=hello world'); + expect( + encoder.encode('url', uri, explode: false, allowEmpty: true), + [(name: 'url', value: 'https://example.com/search?q=hello%20world')], + ); + }); + group('empty value handling', () { test('encodes null value when allowEmpty is true', () { expect( @@ -346,6 +362,313 @@ void main() { throwsA(isA()), ); }); + + group('RFC 3986 reserved character encoding', () { + group('gen-delims characters', () { + test('encodes colon (:) properly', () { + expect( + encoder.encode( + 'url', + 'http://example.com', + explode: false, + allowEmpty: true, + ), + [(name: 'url', value: 'http%3A%2F%2Fexample.com')], + ); + }); + + test('encodes forward slash (/) properly', () { + expect( + encoder.encode( + 'path', + '/api/v1/users', + explode: false, + allowEmpty: true, + ), + [(name: 'path', value: '%2Fapi%2Fv1%2Fusers')], + ); + }); + + test('encodes question mark (?) properly', () { + expect( + encoder.encode( + 'query', + 'search?term=test', + explode: false, + allowEmpty: true, + ), + [(name: 'query', value: 'search%3Fterm%3Dtest')], + ); + }); + + test('encodes hash (#) properly', () { + expect( + encoder.encode( + 'fragment', + 'page#section1', + explode: false, + allowEmpty: true, + ), + [(name: 'fragment', value: 'page%23section1')], + ); + }); + + test('encodes square brackets ([]) properly', () { + expect( + encoder.encode( + 'ipv6', + '[2001:db8::1]', + explode: false, + allowEmpty: true, + ), + [(name: 'ipv6', value: '%5B2001%3Adb8%3A%3A1%5D')], + ); + }); + + test('encodes at symbol (@) properly', () { + expect( + encoder.encode( + 'email', + 'user@example.com', + explode: false, + allowEmpty: true, + ), + [(name: 'email', value: 'user%40example.com')], + ); + }); + }); + + group('sub-delims characters', () { + test('encodes exclamation mark (!) properly', () { + expect( + encoder.encode( + 'exclaim', + 'Hello!', + explode: false, + allowEmpty: true, + ), + [(name: 'exclaim', value: 'Hello%21')], + ); + }); + + test(r'encodes dollar sign ($) properly', () { + expect( + encoder.encode( + 'price', + r'$19.99', + explode: false, + allowEmpty: true, + ), + [(name: 'price', value: '%2419.99')], + ); + }); + + test('encodes ampersand (&) properly', () { + expect( + encoder.encode( + 'company', + 'Johnson & Johnson', + explode: false, + allowEmpty: true, + ), + [(name: 'company', value: 'Johnson+%26+Johnson')], + ); + }); + + test("encodes single quote (') properly", () { + expect( + encoder.encode( + 'text', + "It's working", + explode: false, + allowEmpty: true, + ), + [(name: 'text', value: 'It%27s+working')], + ); + }); + + test('encodes parentheses () properly', () { + expect( + encoder.encode( + 'phone', + '(555) 123-4567', + explode: false, + allowEmpty: true, + ), + [(name: 'phone', value: '%28555%29+123-4567')], + ); + }); + + test('encodes asterisk (*) properly', () { + expect( + encoder.encode( + 'wildcard', + 'file*.txt', + explode: false, + allowEmpty: true, + ), + [(name: 'wildcard', value: 'file%2A.txt')], + ); + }); + + test('encodes plus (+) properly', () { + expect( + encoder.encode('math', '2+2=4', explode: false, allowEmpty: true), + [(name: 'math', value: '2%2B2%3D4')], + ); + }); + + test('encodes comma (,) properly', () { + expect( + encoder.encode( + 'list', + 'apple,banana,cherry', + explode: false, + allowEmpty: true, + ), + [(name: 'list', value: 'apple%2Cbanana%2Ccherry')], + ); + }); + + test('encodes semicolon (;) properly', () { + expect( + encoder.encode( + 'params', + 'a=1;b=2', + explode: false, + allowEmpty: true, + ), + [(name: 'params', value: 'a%3D1%3Bb%3D2')], + ); + }); + + test('encodes equals (=) properly', () { + expect( + encoder.encode( + 'equation', + 'x=y', + explode: false, + allowEmpty: true, + ), + [(name: 'equation', value: 'x%3Dy')], + ); + }); + }); + + group('unreserved characters should NOT be encoded', () { + test('does not encode letters', () { + expect( + encoder.encode( + 'text', + 'ABCdef', + explode: false, + allowEmpty: true, + ), + [(name: 'text', value: 'ABCdef')], + ); + }); + + test('does not encode digits', () { + expect( + encoder.encode( + 'numbers', + '1234567890', + explode: false, + allowEmpty: true, + ), + [(name: 'numbers', value: '1234567890')], + ); + }); + + test('does not encode hyphen (-)', () { + expect( + encoder.encode( + 'uuid', + '123e4567-e89b-12d3', + explode: false, + allowEmpty: true, + ), + [(name: 'uuid', value: '123e4567-e89b-12d3')], + ); + }); + + test('does not encode period (.)', () { + expect( + encoder.encode( + 'domain', + 'example.com', + explode: false, + allowEmpty: true, + ), + [(name: 'domain', value: 'example.com')], + ); + }); + + test('does not encode underscore (_)', () { + expect( + encoder.encode( + 'var', + 'my_variable', + explode: false, + allowEmpty: true, + ), + [(name: 'var', value: 'my_variable')], + ); + }); + + test('does not encode tilde (~)', () { + expect( + encoder.encode( + 'path', + '~/documents', + explode: false, + allowEmpty: true, + ), + [(name: 'path', value: '~%2Fdocuments')], + ); + }); + }); + + group('percent-encoding normalization', () { + test('uses uppercase hex digits for encoding', () { + expect( + encoder.encode( + 'special', + 'hello world!', + explode: false, + allowEmpty: true, + ), + [(name: 'special', value: 'hello+world%21')], + ); + }); + + test('properly encodes non-ASCII characters', () { + expect( + encoder.encode( + 'unicode', + 'café', + explode: false, + allowEmpty: true, + ), + [(name: 'unicode', value: 'caf%C3%A9')], + ); + }); + + test('properly encodes emoji', () { + expect( + encoder.encode('emoji', '👍', explode: false, allowEmpty: true), + [(name: 'emoji', value: '%F0%9F%91%8D')], + ); + }); + + test('properly encodes Chinese characters', () { + expect( + encoder.encode('chinese', '你好', explode: false, allowEmpty: true), + [(name: 'chinese', value: '%E4%BD%A0%E5%A5%BD')], + ); + }); + }); + }); }); }); } diff --git a/packages/tonik_util/test/src/encoder/label_encoder_test.dart b/packages/tonik_util/test/src/encoder/label_encoder_test.dart index 2054309..55a1c72 100644 --- a/packages/tonik_util/test/src/encoder/label_encoder_test.dart +++ b/packages/tonik_util/test/src/encoder/label_encoder_test.dart @@ -34,6 +34,22 @@ void main() { expect(encoder.encode(false, explode: false, allowEmpty: true), '.false'); }); + test('encodes Uri value', () { + final uri = Uri.parse('https://example.com/api/v1'); + expect( + encoder.encode(uri, explode: false, allowEmpty: true), + '.https%3A%2F%2Fexample.com%2Fapi%2Fv1', + ); + }); + + test('encodes Uri value with special characters', () { + final uri = Uri.parse('https://example.com/search?q=hello world'); + expect( + encoder.encode(uri, explode: false, allowEmpty: true), + '.https%3A%2F%2Fexample.com%2Fsearch%3Fq%3Dhello%2520world', + ); + }); + test('encodes null value', () { expect(encoder.encode(null, explode: false, allowEmpty: true), '.'); }); @@ -206,6 +222,171 @@ void main() { throwsA(isA()), ); }); + + group('RFC 3986 reserved character encoding', () { + group('gen-delims characters', () { + test('encodes colon (:) properly', () { + expect( + encoder.encode( + 'http://example.com', + explode: false, + allowEmpty: true, + ), + '.http%3A%2F%2Fexample.com', + ); + }); + + test('encodes forward slash (/) properly', () { + expect( + encoder.encode('/api/v1/users', explode: false, allowEmpty: true), + '.%2Fapi%2Fv1%2Fusers', + ); + }); + + test('encodes question mark (?) properly', () { + expect( + encoder.encode( + 'search?term=test', + explode: false, + allowEmpty: true, + ), + '.search%3Fterm%3Dtest', + ); + }); + + test('encodes hash (#) properly', () { + expect( + encoder.encode('page#section1', explode: false, allowEmpty: true), + '.page%23section1', + ); + }); + + test('encodes square brackets ([]) properly', () { + expect( + encoder.encode('[2001:db8::1]', explode: false, allowEmpty: true), + '.%5B2001%3Adb8%3A%3A1%5D', + ); + }); + + test('encodes at symbol (@) properly', () { + expect( + encoder.encode( + 'user@example.com', + explode: false, + allowEmpty: true, + ), + '.user%40example.com', + ); + }); + }); + + group('sub-delims characters', () { + test('encodes exclamation mark (!) properly', () { + expect( + encoder.encode('Hello!', explode: false, allowEmpty: true), + '.Hello!', + ); + }); + + test(r'encodes dollar sign ($) properly', () { + expect( + encoder.encode(r'$19.99', explode: false, allowEmpty: true), + '.%2419.99', + ); + }); + + test('encodes ampersand (&) properly', () { + expect( + encoder.encode( + 'Johnson & Johnson', + explode: false, + allowEmpty: true, + ), + '.Johnson%20%26%20Johnson', + ); + }); + + test("encodes single quote (') properly", () { + expect( + encoder.encode("It's working", explode: false, allowEmpty: true), + ".It's%20working", + ); + }); + + test('encodes parentheses () properly', () { + expect( + encoder.encode( + '(555) 123-4567', + explode: false, + allowEmpty: true, + ), + '.(555)%20123-4567', + ); + }); + + test('encodes asterisk (*) properly', () { + expect( + encoder.encode('file*.txt', explode: false, allowEmpty: true), + '.file*.txt', + ); + }); + + test('encodes plus (+) properly', () { + expect( + encoder.encode('2+2=4', explode: false, allowEmpty: true), + '.2%2B2%3D4', + ); + }); + + test('encodes comma (,) properly', () { + expect( + encoder.encode( + 'apple,banana,cherry', + explode: false, + allowEmpty: true, + ), + '.apple%2Cbanana%2Ccherry', + ); + }); + + test('encodes semicolon (;) properly', () { + expect( + encoder.encode('a=1;b=2', explode: false, allowEmpty: true), + '.a%3D1%3Bb%3D2', + ); + }); + + test('encodes equals (=) properly', () { + expect( + encoder.encode('x=y', explode: false, allowEmpty: true), + '.x%3Dy', + ); + }); + }); + + group('percent-encoding normalization', () { + test('properly encodes non-ASCII characters', () { + expect( + encoder.encode('café', explode: false, allowEmpty: true), + '.caf%C3%A9', + ); + }); + + test('properly encodes emoji', () { + expect( + encoder.encode('👍', explode: false, allowEmpty: true), + '.%F0%9F%91%8D', + ); + }); + + test('properly encodes Chinese characters', () { + expect( + encoder.encode('你好', explode: false, allowEmpty: true), + '.%E4%BD%A0%E5%A5%BD', + ); + }); + }); + }); }); }); } diff --git a/packages/tonik_util/test/src/encoder/matrix_encoder_test.dart b/packages/tonik_util/test/src/encoder/matrix_encoder_test.dart index e9072c9..ffb1555 100644 --- a/packages/tonik_util/test/src/encoder/matrix_encoder_test.dart +++ b/packages/tonik_util/test/src/encoder/matrix_encoder_test.dart @@ -48,6 +48,22 @@ void main() { ); }); + test('encodes Uri value', () { + final uri = Uri.parse('https://example.com/api/v1'); + expect( + encoder.encode('endpoint', uri, explode: false, allowEmpty: true), + ';endpoint=https%3A%2F%2Fexample.com%2Fapi%2Fv1', + ); + }); + + test('encodes Uri value with special characters', () { + final uri = Uri.parse('https://example.com/search?q=hello world'); + expect( + encoder.encode('url', uri, explode: false, allowEmpty: true), + ';url=https%3A%2F%2Fexample.com%2Fsearch%3Fq%3Dhello%2520world', + ); + }); + test('encodes null value', () { expect( encoder.encode('nullValue', null, explode: false, allowEmpty: true), @@ -285,6 +301,227 @@ void main() { throwsA(isA()), ); }); + + group('RFC 3986 reserved character encoding', () { + group('gen-delims characters', () { + test('encodes colon (:) properly', () { + expect( + encoder.encode( + 'url', + 'http://example.com', + explode: false, + allowEmpty: true, + ), + ';url=http%3A%2F%2Fexample.com', + ); + }); + + test('encodes forward slash (/) properly', () { + expect( + encoder.encode( + 'path', + '/api/v1/users', + explode: false, + allowEmpty: true, + ), + ';path=%2Fapi%2Fv1%2Fusers', + ); + }); + + test('encodes question mark (?) properly', () { + expect( + encoder.encode( + 'query', + 'search?term=test', + explode: false, + allowEmpty: true, + ), + ';query=search%3Fterm%3Dtest', + ); + }); + + test('encodes hash (#) properly', () { + expect( + encoder.encode( + 'fragment', + 'page#section1', + explode: false, + allowEmpty: true, + ), + ';fragment=page%23section1', + ); + }); + + test('encodes square brackets ([]) properly', () { + expect( + encoder.encode( + 'ipv6', + '[2001:db8::1]', + explode: false, + allowEmpty: true, + ), + ';ipv6=%5B2001%3Adb8%3A%3A1%5D', + ); + }); + + test('encodes at symbol (@) properly', () { + expect( + encoder.encode( + 'email', + 'user@example.com', + explode: false, + allowEmpty: true, + ), + ';email=user%40example.com', + ); + }); + }); + + group('sub-delims characters', () { + test('encodes exclamation mark (!) properly', () { + expect( + encoder.encode( + 'exclaim', + 'Hello!', + explode: false, + allowEmpty: true, + ), + ';exclaim=Hello!', + ); + }); + + test(r'encodes dollar sign ($) properly', () { + expect( + encoder.encode( + 'price', + r'$19.99', + explode: false, + allowEmpty: true, + ), + ';price=%2419.99', + ); + }); + + test('encodes ampersand (&) properly', () { + expect( + encoder.encode( + 'company', + 'Johnson & Johnson', + explode: false, + allowEmpty: true, + ), + ';company=Johnson%20%26%20Johnson', + ); + }); + + test("encodes single quote (') properly", () { + expect( + encoder.encode( + 'text', + "It's working", + explode: false, + allowEmpty: true, + ), + ";text=It's%20working", + ); + }); + + test('encodes parentheses () properly', () { + expect( + encoder.encode( + 'phone', + '(555) 123-4567', + explode: false, + allowEmpty: true, + ), + ';phone=(555)%20123-4567', + ); + }); + + test('encodes asterisk (*) properly', () { + expect( + encoder.encode( + 'wildcard', + 'file*.txt', + explode: false, + allowEmpty: true, + ), + ';wildcard=file*.txt', + ); + }); + + test('encodes plus (+) properly', () { + expect( + encoder.encode('math', '2+2=4', explode: false, allowEmpty: true), + ';math=2%2B2%3D4', + ); + }); + + test('encodes comma (,) properly', () { + expect( + encoder.encode( + 'list', + 'apple,banana,cherry', + explode: false, + allowEmpty: true, + ), + ';list=apple%2Cbanana%2Ccherry', + ); + }); + + test('encodes semicolon (;) properly', () { + expect( + encoder.encode( + 'params', + 'a=1;b=2', + explode: false, + allowEmpty: true, + ), + ';params=a%3D1%3Bb%3D2', + ); + }); + + test('encodes equals (=) properly', () { + expect( + encoder.encode( + 'equation', + 'x=y', + explode: false, + allowEmpty: true, + ), + ';equation=x%3Dy', + ); + }); + }); + + group('percent-encoding normalization', () { + test('properly encodes non-ASCII characters', () { + expect( + encoder.encode( + 'unicode', + 'café', + explode: false, + allowEmpty: true, + ), + ';unicode=caf%C3%A9', + ); + }); + + test('properly encodes emoji', () { + expect( + encoder.encode('emoji', '👍', explode: false, allowEmpty: true), + ';emoji=%F0%9F%91%8D', + ); + }); + + test('properly encodes Chinese characters', () { + expect( + encoder.encode('chinese', '你好', explode: false, allowEmpty: true), + ';chinese=%E4%BD%A0%E5%A5%BD', + ); + }); + }); + }); }); }); } diff --git a/packages/tonik_util/test/src/encoder/simple_encoder_test.dart b/packages/tonik_util/test/src/encoder/simple_encoder_test.dart index d59464d..4d3b3fb 100644 --- a/packages/tonik_util/test/src/encoder/simple_encoder_test.dart +++ b/packages/tonik_util/test/src/encoder/simple_encoder_test.dart @@ -34,6 +34,22 @@ void main() { expect(encoder.encode(false, explode: false, allowEmpty: true), 'false'); }); + test('encodes Uri value', () { + final uri = Uri.parse('https://example.com/api/v1'); + expect( + encoder.encode(uri, explode: false, allowEmpty: true), + 'https%3A%2F%2Fexample.com%2Fapi%2Fv1', + ); + }); + + test('encodes Uri value with special characters', () { + final uri = Uri.parse('https://example.com/search?q=hello world'); + expect( + encoder.encode(uri, explode: false, allowEmpty: true), + 'https%3A%2F%2Fexample.com%2Fsearch%3Fq%3Dhello%2520world', + ); + }); + test('encodes null value', () { expect(encoder.encode(null, explode: false, allowEmpty: true), ''); }); @@ -201,5 +217,166 @@ void main() { ); }); }); + + group('RFC 3986 reserved character encoding', () { + group('gen-delims characters', () { + test('encodes colon (:) properly', () { + expect( + encoder.encode( + 'http://example.com', + explode: false, + allowEmpty: true, + ), + 'http%3A%2F%2Fexample.com', + ); + }); + + test('encodes forward slash (/) properly', () { + expect( + encoder.encode('/api/v1/users', explode: false, allowEmpty: true), + '%2Fapi%2Fv1%2Fusers', + ); + }); + + test('encodes question mark (?) properly', () { + expect( + encoder.encode( + 'search?term=test', + explode: false, + allowEmpty: true, + ), + 'search%3Fterm%3Dtest', + ); + }); + + test('encodes hash (#) properly', () { + expect( + encoder.encode('page#section1', explode: false, allowEmpty: true), + 'page%23section1', + ); + }); + + test('encodes square brackets ([]) properly', () { + expect( + encoder.encode('[2001:db8::1]', explode: false, allowEmpty: true), + '%5B2001%3Adb8%3A%3A1%5D', + ); + }); + + test('encodes at symbol (@) properly', () { + expect( + encoder.encode( + 'user@example.com', + explode: false, + allowEmpty: true, + ), + 'user%40example.com', + ); + }); + }); + + group('sub-delims characters', () { + test('encodes exclamation mark (!) properly', () { + expect( + encoder.encode('Hello!', explode: false, allowEmpty: true), + 'Hello!', + ); + }); + + test(r'encodes dollar sign ($) properly', () { + expect( + encoder.encode(r'$19.99', explode: false, allowEmpty: true), + '%2419.99', + ); + }); + + test('encodes ampersand (&) properly', () { + expect( + encoder.encode( + 'Johnson & Johnson', + explode: false, + allowEmpty: true, + ), + 'Johnson%20%26%20Johnson', + ); + }); + + test("encodes single quote (') properly", () { + expect( + encoder.encode("It's working", explode: false, allowEmpty: true), + "It's%20working", + ); + }); + + test('encodes parentheses () properly', () { + expect( + encoder.encode('(555) 123-4567', explode: false, allowEmpty: true), + '(555)%20123-4567', + ); + }); + + test('encodes asterisk (*) properly', () { + expect( + encoder.encode('file*.txt', explode: false, allowEmpty: true), + 'file*.txt', + ); + }); + + test('encodes plus (+) properly', () { + expect( + encoder.encode('2+2=4', explode: false, allowEmpty: true), + '2%2B2%3D4', + ); + }); + + test('encodes comma (,) properly', () { + expect( + encoder.encode( + 'apple,banana,cherry', + explode: false, + allowEmpty: true, + ), + 'apple%2Cbanana%2Ccherry', + ); + }); + + test('encodes semicolon (;) properly', () { + expect( + encoder.encode('a=1;b=2', explode: false, allowEmpty: true), + 'a%3D1%3Bb%3D2', + ); + }); + + test('encodes equals (=) properly', () { + expect( + encoder.encode('x=y', explode: false, allowEmpty: true), + 'x%3Dy', + ); + }); + }); + + group('percent-encoding normalization', () { + test('properly encodes non-ASCII characters', () { + expect( + encoder.encode('café', explode: false, allowEmpty: true), + 'caf%C3%A9', + ); + }); + + test('properly encodes emoji', () { + expect( + encoder.encode('👍', explode: false, allowEmpty: true), + '%F0%9F%91%8D', + ); + }); + + test('properly encodes Chinese characters', () { + expect( + encoder.encode('你好', explode: false, allowEmpty: true), + '%E4%BD%A0%E5%A5%BD', + ); + }); + }); + }); }); }