From 26cab774a84bf33b439cb5685b010bbc6acb43af Mon Sep 17 00:00:00 2001 From: Tobias Ottenweller Date: Mon, 2 Jun 2025 21:53:19 +0200 Subject: [PATCH 01/37] chore: add analyzer action --- .github/workflows/lint.yml | 27 ++++++++ packages/tonik/analysis_options.yaml | 4 ++ packages/tonik/bin/tonik.dart | 65 ++++++++++--------- packages/tonik/lib/src/openapi_loader.dart | 15 +++-- packages/tonik/pubspec.yaml | 6 +- .../tonik/test/src/openapi_loader_test.dart | 24 +++---- 6 files changed, 89 insertions(+), 52 deletions(-) create mode 100644 .github/workflows/lint.yml diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..fcc0569 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,27 @@ +name: Tests + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Setup Dart + uses: dart-lang/setup-dart@v1 + with: + sdk: stable + - name: Install dependencies + run: | + dart pub global activate melos + melos bootstrap + + - name: Analyze code + run: melos exec dart analyze + \ No newline at end of file diff --git a/packages/tonik/analysis_options.yaml b/packages/tonik/analysis_options.yaml index b5cd897..f7f584e 100644 --- a/packages/tonik/analysis_options.yaml +++ b/packages/tonik/analysis_options.yaml @@ -3,3 +3,7 @@ include: package:very_good_analysis/analysis_options.yaml linter: rules: public_member_api_docs: false + +analyzer: + errors: + avoid_print: ignore \ No newline at end of file diff --git a/packages/tonik/bin/tonik.dart b/packages/tonik/bin/tonik.dart index dbbd4db..e2b171b 100644 --- a/packages/tonik/bin/tonik.dart +++ b/packages/tonik/bin/tonik.dart @@ -1,10 +1,11 @@ import 'dart:io'; + import 'package:args/args.dart'; import 'package:logging/logging.dart'; import 'package:tonik/src/openapi_loader.dart'; import 'package:tonik_core/tonik_core.dart'; -import 'package:tonik_parse/tonik_parse.dart'; import 'package:tonik_generate/tonik_generate.dart'; +import 'package:tonik_parse/tonik_parse.dart'; const issueUrl = 'https://github.com/t-unit/tonik/issues'; @@ -53,14 +54,14 @@ void printUsage(ArgParser argParser) { final Logger logger = Logger('tonik'); void main(List arguments) { - final ArgParser argParser = buildParser(); + final argParser = buildParser(); String logLevel; String packageName; String openApiPath; String outputDir; try { - final ArgResults results = argParser.parse(arguments); + final results = argParser.parse(arguments); if (results.flag('help')) { printUsage(argParser); @@ -75,7 +76,7 @@ void main(List arguments) { print(formatException.message); printUsage(argParser); exit(128); - } catch (_) { + } on Object catch (_) { printUsage(argParser); exit(128); } @@ -110,10 +111,11 @@ void main(List arguments) { } }); - logger.info('Starting Tonik'); - logger.fine('Package name: $packageName'); - logger.fine('OpenAPI document: $openApiPath'); - logger.fine('Output directory: $outputDir'); + logger + ..info('Starting Tonik') + ..fine('Package name: $packageName') + ..fine('OpenAPI document: $openApiPath') + ..fine('Output directory: $outputDir'); Map apiSpec; try { @@ -122,13 +124,14 @@ void main(List arguments) { } on OpenApiLoaderException catch (e) { logger.severe(e.message); exit(1); - } catch (e, s) { - logger.fine('Failed to load OpenAPI document', e, s); - logger.severe( - 'Unexpected error while loading OpenAPI document. ' - 'Make sure to run with verbose logging and report this issue at ' - '$issueUrl', - ); + } on Object catch (e, s) { + logger + ..fine('Failed to load OpenAPI document', e, s) + ..severe( + 'Unexpected error while loading OpenAPI document. ' + 'Make sure to run with verbose logging and report this issue at ' + '$issueUrl', + ); exit(1); } @@ -136,30 +139,32 @@ void main(List arguments) { try { apiDocument = Importer().import(apiSpec); logger.info('Successfully parsed OpenAPI document'); - } catch (e, s) { - logger.fine('Failed to parse OpenAPI document', e, s); - logger.severe( - 'Unexpected error while parsing OpenAPI document. ' - 'If you think your document is valid, please run ' - 'with verbose logging and report this issue at $issueUrl', - ); + } on Object catch (e, s) { + logger + ..fine('Failed to parse OpenAPI document', e, s) + ..severe('Unexpected error while parsing OpenAPI document. ' + 'Unexpected error while parsing OpenAPI document. ' + 'If you think your document is valid, please run ' + 'with verbose logging and report this issue at $issueUrl', + ); exit(1); } try { - Generator().generate( + const Generator().generate( apiDocument: apiDocument, outputDirectory: outputDir, package: packageName, ); logger.info('Successfully generated code'); - } catch (e, s) { - logger.fine('Failed to generate code', e, s); - logger.severe( - 'Unexpected error while generating code. ' - 'If you think your document is valid, please run with ' - 'verbose logging and report this issue at $issueUrl', - ); + } on Object catch (e, s) { + logger + ..fine('Failed to generate code', e, s) + ..severe( + 'Unexpected error while generating code. ' + 'If you think your document is valid, please run with ' + 'verbose logging and report this issue at $issueUrl', + ); exit(1); } } diff --git a/packages/tonik/lib/src/openapi_loader.dart b/packages/tonik/lib/src/openapi_loader.dart index 82b77d0..ef9a88c 100644 --- a/packages/tonik/lib/src/openapi_loader.dart +++ b/packages/tonik/lib/src/openapi_loader.dart @@ -1,5 +1,5 @@ -import 'dart:io'; import 'dart:convert'; +import 'dart:io'; import 'package:logging/logging.dart'; import 'package:yaml/yaml.dart'; @@ -11,13 +11,13 @@ Logger logger = Logger('openapi_loader'); /// /// Returns a Map representation of the OpenAPI document. Map loadOpenApiDocument(String path) { - final File file = File(path); + final file = File(path); if (!file.existsSync()) { throw OpenApiLoaderException('OpenAPI document not found'); } - final String content = file.readAsStringSync(); - final String extension = path.toLowerCase().split('.').last; + final content = file.readAsStringSync(); + final extension = path.toLowerCase().split('.').last; try { final apiSpec = switch (extension) { @@ -25,14 +25,15 @@ Map loadOpenApiDocument(String path) { 'yaml' || 'yml' => _convertYamlToMap(loadYaml(content)), _ => throw OpenApiLoaderException( - 'Unsupported file extension: .$extension. Must be .json, .yaml, or .yml', + 'Unsupported file extension: .$extension. ' + 'Must be .json, .yaml, or .yml', ), }; logger.fine('Parsed OpenAPI document as ${extension.toUpperCase()}'); return apiSpec; - } catch (e) { + } on Object catch (e) { logger.fine('Failed to parse OpenAPI document. $e'); throw OpenApiLoaderException('Failed to parse OpenAPI document.'); } @@ -54,7 +55,7 @@ dynamic _convertYamlNode(dynamic yaml) { ); } if (yaml is YamlList) { - return yaml.map((node) => _convertYamlNode(node)).toList(); + return yaml.map(_convertYamlNode).toList(); } return yaml; } diff --git a/packages/tonik/pubspec.yaml b/packages/tonik/pubspec.yaml index 9aab500..f702b2e 100644 --- a/packages/tonik/pubspec.yaml +++ b/packages/tonik/pubspec.yaml @@ -19,12 +19,12 @@ environment: dependencies: args: ^2.5.0 logging: ^1.3.0 - yaml: ^3.1.3 - tonik_parse: ^0.0.4 tonik_core: ^0.0.4 tonik_generate: ^0.0.4 + tonik_parse: ^0.0.4 + yaml: ^3.1.3 dev_dependencies: - very_good_analysis: ^7.0.0 path: ^1.9.1 test: ^1.24.0 + very_good_analysis: ^7.0.0 diff --git a/packages/tonik/test/src/openapi_loader_test.dart b/packages/tonik/test/src/openapi_loader_test.dart index f7b1789..e5d2bca 100644 --- a/packages/tonik/test/src/openapi_loader_test.dart +++ b/packages/tonik/test/src/openapi_loader_test.dart @@ -19,8 +19,8 @@ void main() { group('loadOpenApiDocument', () { test('loads valid JSON file', () { - final jsonFile = File(path.join(tempDir.path, 'test.json')); - jsonFile.writeAsStringSync(''' + final jsonFile = File(path.join(tempDir.path, 'test.json')) + ..writeAsStringSync(''' { "openapi": "3.0.0", "info": { @@ -38,8 +38,8 @@ void main() { }); test('loads valid YAML file', () { - final yamlFile = File(path.join(tempDir.path, 'test.yaml')); - yamlFile.writeAsStringSync(''' + final yamlFile = File(path.join(tempDir.path, 'test.yaml')) + ..writeAsStringSync(''' openapi: 3.0.0 info: title: Test API @@ -68,8 +68,8 @@ info: }); test('throws on unsupported file extension', () { - final txtFile = File(path.join(tempDir.path, 'test.txt')); - txtFile.writeAsStringSync('invalid'); + final txtFile = File(path.join(tempDir.path, 'test.txt')) + ..writeAsStringSync('invalid'); expect( () => loadOpenApiDocument(txtFile.path), @@ -78,8 +78,8 @@ info: }); test('throws on invalid JSON', () { - final jsonFile = File(path.join(tempDir.path, 'invalid.json')); - jsonFile.writeAsStringSync('invalid json'); + final jsonFile = File(path.join(tempDir.path, 'invalid.json')) + ..writeAsStringSync('invalid json'); expect( () => loadOpenApiDocument(jsonFile.path), @@ -94,8 +94,8 @@ info: }); test('throws on invalid YAML', () { - final yamlFile = File(path.join(tempDir.path, 'invalid.yaml')); - yamlFile.writeAsStringSync(''' + final yamlFile = File(path.join(tempDir.path, 'invalid.yaml')) + ..writeAsStringSync(''' invalid yaml: - misaligned: wrong indentation @@ -109,8 +109,8 @@ invalid yaml: }); test('handles complex YAML structures', () { - final yamlFile = File(path.join(tempDir.path, 'complex.yaml')); - yamlFile.writeAsStringSync(''' + final yamlFile = File(path.join(tempDir.path, 'complex.yaml')) + ..writeAsStringSync(''' openapi: 3.0.0 info: title: Complex API From c29dc62eabaf53bb4510614ac256967979805405 Mon Sep 17 00:00:00 2001 From: Tobias Ottenweller Date: Mon, 2 Jun 2025 21:56:08 +0200 Subject: [PATCH 02/37] chore: update readme --- packages/tonik/README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/tonik/README.md b/packages/tonik/README.md index c835147..4328338 100644 --- a/packages/tonik/README.md +++ b/packages/tonik/README.md @@ -29,6 +29,8 @@ There are already numerous projects available to generate Dart code from OpenAPI This package aims to overcome these shortcomings. +Special thanks goes out to [felixwoestmann](https://github.com/felixwoestmann), as this project would not have been possible without him. + ## Features coming soon From c3b56ab25a9193dcdc5d259bc9b70aa5934fa033 Mon Sep 17 00:00:00 2001 From: Tobias Ottenweller Date: Mon, 2 Jun 2025 21:56:54 +0200 Subject: [PATCH 03/37] chore: fix action name --- .github/workflows/lint.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index fcc0569..a38e3e8 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -1,4 +1,4 @@ -name: Tests +name: Lint on: push: @@ -24,4 +24,3 @@ jobs: - name: Analyze code run: melos exec dart analyze - \ No newline at end of file From b77529430e1fe5f4f82a8318d4816e7c5d813053 Mon Sep 17 00:00:00 2001 From: Tobias Ottenweller Date: Mon, 2 Jun 2025 22:02:01 +0200 Subject: [PATCH 04/37] chore: bump very_good_analysis --- .idea/runConfigurations/melos_run_test.xml | 11 +++++++++++ melos.yaml | 2 +- packages/tonik/pubspec.yaml | 8 ++++---- packages/tonik_core/pubspec.yaml | 2 +- .../tonik_generate/lib/src/naming/name_utils.dart | 2 +- packages/tonik_generate/pubspec.yaml | 4 ++-- packages/tonik_parse/pubspec.yaml | 4 ++-- packages/tonik_util/pubspec.yaml | 2 +- 8 files changed, 23 insertions(+), 12 deletions(-) create mode 100644 .idea/runConfigurations/melos_run_test.xml diff --git a/.idea/runConfigurations/melos_run_test.xml b/.idea/runConfigurations/melos_run_test.xml new file mode 100644 index 0000000..23f9c3d --- /dev/null +++ b/.idea/runConfigurations/melos_run_test.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/melos.yaml b/melos.yaml index 56ed201..45d0430 100644 --- a/melos.yaml +++ b/melos.yaml @@ -34,4 +34,4 @@ command: tonik_util: ^0.0.1 dev_dependencies: test: ^1.24.0 - very_good_analysis: ^7.0.0 \ No newline at end of file + very_good_analysis: ^8.0.0 \ No newline at end of file diff --git a/packages/tonik/pubspec.yaml b/packages/tonik/pubspec.yaml index f702b2e..c73aaca 100644 --- a/packages/tonik/pubspec.yaml +++ b/packages/tonik/pubspec.yaml @@ -19,12 +19,12 @@ environment: dependencies: args: ^2.5.0 logging: ^1.3.0 - tonik_core: ^0.0.4 - tonik_generate: ^0.0.4 - tonik_parse: ^0.0.4 + tonik_core: ^0.0.1 + tonik_generate: ^0.0.1 + tonik_parse: ^0.0.1 yaml: ^3.1.3 dev_dependencies: path: ^1.9.1 test: ^1.24.0 - very_good_analysis: ^7.0.0 + very_good_analysis: ^8.0.0 diff --git a/packages/tonik_core/pubspec.yaml b/packages/tonik_core/pubspec.yaml index d9d8654..cde46c5 100644 --- a/packages/tonik_core/pubspec.yaml +++ b/packages/tonik_core/pubspec.yaml @@ -13,5 +13,5 @@ dependencies: dev_dependencies: build_runner: ^2.4.15 test: ^1.24.0 - very_good_analysis: ^7.0.0 + very_good_analysis: ^8.0.0 diff --git a/packages/tonik_generate/lib/src/naming/name_utils.dart b/packages/tonik_generate/lib/src/naming/name_utils.dart index 17bc7ec..56e3e22 100644 --- a/packages/tonik_generate/lib/src/naming/name_utils.dart +++ b/packages/tonik_generate/lib/src/naming/name_utils.dart @@ -87,7 +87,7 @@ const generatedClassTokens = { 'hashCode', }; -const allKeywords = {...dartKeywords, ...generatedClassTokens}; +const Set allKeywords = {...dartKeywords, ...generatedClassTokens}; /// Ensures a name is not a Dart keyword by adding a $ prefix if necessary. String ensureNotKeyword(String name) { diff --git a/packages/tonik_generate/pubspec.yaml b/packages/tonik_generate/pubspec.yaml index 9d89cad..40feb05 100644 --- a/packages/tonik_generate/pubspec.yaml +++ b/packages/tonik_generate/pubspec.yaml @@ -15,8 +15,8 @@ dependencies: meta: ^1.16.0 path: ^1.9.1 spell_out_numbers: ^1.0.0 - tonik_core: ^0.0.4 + tonik_core: ^0.0.1 dev_dependencies: test: ^1.24.0 - very_good_analysis: ^7.0.0 + very_good_analysis: ^8.0.0 diff --git a/packages/tonik_parse/pubspec.yaml b/packages/tonik_parse/pubspec.yaml index 8d69b6c..b8e1a97 100644 --- a/packages/tonik_parse/pubspec.yaml +++ b/packages/tonik_parse/pubspec.yaml @@ -10,10 +10,10 @@ dependencies: collection: ^1.19.1 json_annotation: ^4.9.0 logging: ^1.3.0 - tonik_core: ^0.0.4 + tonik_core: ^0.0.1 dev_dependencies: build_runner: ^2.3.3 json_serializable: ^6.8.0 test: ^1.24.0 - very_good_analysis: ^7.0.0 + very_good_analysis: ^8.0.0 diff --git a/packages/tonik_util/pubspec.yaml b/packages/tonik_util/pubspec.yaml index 027689b..e2f648f 100644 --- a/packages/tonik_util/pubspec.yaml +++ b/packages/tonik_util/pubspec.yaml @@ -14,4 +14,4 @@ dependencies: dev_dependencies: test: ^1.24.0 - very_good_analysis: ^7.0.0 + very_good_analysis: ^8.0.0 From babbb7e3b85b8119368ad0bac4832689a075e696 Mon Sep 17 00:00:00 2001 From: Tobias Ottenweller Date: Mon, 2 Jun 2025 22:22:39 +0200 Subject: [PATCH 05/37] chore: update to melos 7 --- .idea/runConfigurations/melos_bootstrap.xml | 11 - .idea/runConfigurations/melos_clean.xml | 11 - .../runConfigurations/melos_run_generate.xml | 11 - .idea/runConfigurations/melos_run_test.xml | 11 - melos.yaml | 37 --- packages/tonik/pubspec.yaml | 1 + packages/tonik/pubspec_overrides.yaml | 8 - packages/tonik_core/pubspec.yaml | 1 + .../lib/src/pubspec_generator.dart | 2 +- packages/tonik_generate/pubspec.yaml | 1 + .../tonik_generate/pubspec_overrides.yaml | 4 - packages/tonik_parse/pubspec.yaml | 1 + packages/tonik_parse/pubspec_overrides.yaml | 4 - packages/tonik_util/pubspec.yaml | 1 + pubspec.lock | 214 ++++++++++++++++-- pubspec.yaml | 40 +++- 16 files changed, 238 insertions(+), 120 deletions(-) delete mode 100644 .idea/runConfigurations/melos_bootstrap.xml delete mode 100644 .idea/runConfigurations/melos_clean.xml delete mode 100644 .idea/runConfigurations/melos_run_generate.xml delete mode 100644 .idea/runConfigurations/melos_run_test.xml delete mode 100644 melos.yaml delete mode 100644 packages/tonik/pubspec_overrides.yaml delete mode 100644 packages/tonik_generate/pubspec_overrides.yaml delete mode 100644 packages/tonik_parse/pubspec_overrides.yaml diff --git a/.idea/runConfigurations/melos_bootstrap.xml b/.idea/runConfigurations/melos_bootstrap.xml deleted file mode 100644 index 0365420..0000000 --- a/.idea/runConfigurations/melos_bootstrap.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - diff --git a/.idea/runConfigurations/melos_clean.xml b/.idea/runConfigurations/melos_clean.xml deleted file mode 100644 index 82bd956..0000000 --- a/.idea/runConfigurations/melos_clean.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - diff --git a/.idea/runConfigurations/melos_run_generate.xml b/.idea/runConfigurations/melos_run_generate.xml deleted file mode 100644 index 575608a..0000000 --- a/.idea/runConfigurations/melos_run_generate.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - diff --git a/.idea/runConfigurations/melos_run_test.xml b/.idea/runConfigurations/melos_run_test.xml deleted file mode 100644 index 23f9c3d..0000000 --- a/.idea/runConfigurations/melos_run_test.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - diff --git a/melos.yaml b/melos.yaml deleted file mode 100644 index 45d0430..0000000 --- a/melos.yaml +++ /dev/null @@ -1,37 +0,0 @@ -name: tonik - -packages: - - packages/* - -scripts: - test: - run: dart test - exec: - concurrency: 1 - - generate: - run: flutter pub run build_runner build --delete-conflicting-outputs - exec: - concurrency: 1 - select-package: - depends-on: build_runner - -command: - bootstrap: - environment: - sdk: ">=3.7.0 <4.0.0" - dependencies: - collection: ^1.19.1 - dart_style: ^3.0.1 - logging: ^1.3.0 - meta: ^1.16.0 - path: ^1.9.1 - spell_out_numbers: ^1.0.0 - tonik: ^0.0.1 - tonik_core: ^0.0.1 - tonik_parse: ^0.0.1 - tonik_generate: ^0.0.1 - tonik_util: ^0.0.1 - dev_dependencies: - test: ^1.24.0 - very_good_analysis: ^8.0.0 \ No newline at end of file diff --git a/packages/tonik/pubspec.yaml b/packages/tonik/pubspec.yaml index c73aaca..30785d1 100644 --- a/packages/tonik/pubspec.yaml +++ b/packages/tonik/pubspec.yaml @@ -2,6 +2,7 @@ name: tonik description: A Dart code generator for OpenAPI 3.0 and 3.1 specifications. version: 0.0.4 repository: https://github.com/t-unit/tonik +resolution: workspace executables: tonik: tonik diff --git a/packages/tonik/pubspec_overrides.yaml b/packages/tonik/pubspec_overrides.yaml deleted file mode 100644 index 0bed283..0000000 --- a/packages/tonik/pubspec_overrides.yaml +++ /dev/null @@ -1,8 +0,0 @@ -# melos_managed_dependency_overrides: tonik_core,tonik_parse,tonik_generate -dependency_overrides: - tonik_core: - path: ../tonik_core - tonik_generate: - path: ../tonik_generate - tonik_parse: - path: ../tonik_parse diff --git a/packages/tonik_core/pubspec.yaml b/packages/tonik_core/pubspec.yaml index cde46c5..9a6c074 100644 --- a/packages/tonik_core/pubspec.yaml +++ b/packages/tonik_core/pubspec.yaml @@ -2,6 +2,7 @@ name: tonik_core description: Core data structures and utilities for Tonik. version: 0.0.4 repository: https://github.com/t-unit/tonik +resolution: workspace environment: sdk: ">=3.7.0 <4.0.0" diff --git a/packages/tonik_generate/lib/src/pubspec_generator.dart b/packages/tonik_generate/lib/src/pubspec_generator.dart index 3c646b1..ef578fb 100644 --- a/packages/tonik_generate/lib/src/pubspec_generator.dart +++ b/packages/tonik_generate/lib/src/pubspec_generator.dart @@ -26,7 +26,7 @@ dependencies: collection: ^1.17.0 dio: ^5.8.0+1 meta: ^1.16.0 - tonik_util: ^0.0.3 + tonik_util: ^0.0.5 '''; pubspecFile.writeAsStringSync(content); diff --git a/packages/tonik_generate/pubspec.yaml b/packages/tonik_generate/pubspec.yaml index 40feb05..e11a076 100644 --- a/packages/tonik_generate/pubspec.yaml +++ b/packages/tonik_generate/pubspec.yaml @@ -2,6 +2,7 @@ name: tonik_generate description: A code generation package for Tonik. version: 0.0.4 repository: https://github.com/t-unit/tonik +resolution: workspace environment: sdk: ">=3.7.0 <4.0.0" diff --git a/packages/tonik_generate/pubspec_overrides.yaml b/packages/tonik_generate/pubspec_overrides.yaml deleted file mode 100644 index 2f79484..0000000 --- a/packages/tonik_generate/pubspec_overrides.yaml +++ /dev/null @@ -1,4 +0,0 @@ -# melos_managed_dependency_overrides: tonik_core -dependency_overrides: - tonik_core: - path: ../tonik_core diff --git a/packages/tonik_parse/pubspec.yaml b/packages/tonik_parse/pubspec.yaml index b8e1a97..e9e4ea1 100644 --- a/packages/tonik_parse/pubspec.yaml +++ b/packages/tonik_parse/pubspec.yaml @@ -2,6 +2,7 @@ name: tonik_parse description: The parsing module for Tonik. version: 0.0.4 repository: https://github.com/t-unit/tonik +resolution: workspace environment: sdk: ">=3.7.0 <4.0.0" diff --git a/packages/tonik_parse/pubspec_overrides.yaml b/packages/tonik_parse/pubspec_overrides.yaml deleted file mode 100644 index 2f79484..0000000 --- a/packages/tonik_parse/pubspec_overrides.yaml +++ /dev/null @@ -1,4 +0,0 @@ -# melos_managed_dependency_overrides: tonik_core -dependency_overrides: - tonik_core: - path: ../tonik_core diff --git a/packages/tonik_util/pubspec.yaml b/packages/tonik_util/pubspec.yaml index e2f648f..b8fd96d 100644 --- a/packages/tonik_util/pubspec.yaml +++ b/packages/tonik_util/pubspec.yaml @@ -2,6 +2,7 @@ name: tonik_util description: Runtime tools for packages generated by Tonik. version: 0.0.4 repository: https://github.com/t-unit/tonik +resolution: workspace environment: sdk: ">=3.7.0 <4.0.0" diff --git a/pubspec.lock b/pubspec.lock index 7cfc0c8..1b7df99 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -41,6 +41,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.13.0" + big_decimal: + dependency: transitive + description: + name: big_decimal + sha256: "301158ec5a646d1e1a0ca7a97fbfab7be18a8df700adb7f7cb9c4149e75c8f0c" + url: "https://pub.dev" + source: hosted + version: "0.5.0" boolean_selector: dependency: transitive description: @@ -49,6 +57,78 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.2" + build: + dependency: transitive + description: + name: build + sha256: cef23f1eda9b57566c81e2133d196f8e3df48f244b317368d65c5943d91148f0 + url: "https://pub.dev" + source: hosted + version: "2.4.2" + build_config: + dependency: transitive + description: + name: build_config + sha256: "4ae2de3e1e67ea270081eaee972e1bd8f027d459f249e0f1186730784c2e7e33" + url: "https://pub.dev" + source: hosted + version: "1.1.2" + build_daemon: + dependency: transitive + description: + name: build_daemon + sha256: "8e928697a82be082206edb0b9c99c5a4ad6bc31c9e9b8b2f291ae65cd4a25daa" + url: "https://pub.dev" + source: hosted + version: "4.0.4" + build_resolvers: + dependency: transitive + description: + name: build_resolvers + sha256: b9e4fda21d846e192628e7a4f6deda6888c36b5b69ba02ff291a01fd529140f0 + url: "https://pub.dev" + source: hosted + version: "2.4.4" + build_runner: + dependency: transitive + description: + name: build_runner + sha256: "058fe9dce1de7d69c4b84fada934df3e0153dd000758c4d65964d0166779aa99" + url: "https://pub.dev" + source: hosted + version: "2.4.15" + build_runner_core: + dependency: transitive + description: + name: build_runner_core + sha256: "22e3aa1c80e0ada3722fe5b63fd43d9c8990759d0a2cf489c8c5d7b2bdebc021" + url: "https://pub.dev" + source: hosted + version: "8.0.0" + built_collection: + dependency: transitive + description: + name: built_collection + sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100" + url: "https://pub.dev" + source: hosted + version: "5.1.1" + built_value: + dependency: transitive + description: + name: built_value + sha256: "082001b5c3dc495d4a42f1d5789990505df20d8547d42507c29050af6933ee27" + url: "https://pub.dev" + source: hosted + version: "8.10.1" + change_case: + dependency: transitive + description: + name: change_case + sha256: e41ef3df58521194ef8d7649928954805aeb08061917cf658322305e61568003 + url: "https://pub.dev" + source: hosted + version: "2.2.0" charcode: dependency: transitive description: @@ -97,8 +177,16 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.2" - collection: + code_builder: dependency: transitive + description: + name: code_builder + sha256: "0ec10bf4a89e4c613960bf1e8b42c64127021740fb21640c29c909826a5eea3e" + url: "https://pub.dev" + source: hosted + version: "4.10.1" + collection: + dependency: "direct main" description: name: collection sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" @@ -109,10 +197,10 @@ packages: dependency: transitive description: name: conventional_commit - sha256: fad254feb6fb8eace2be18855176b0a4b97e0d50e416ff0fe590d5ba83735d34 + sha256: c40b1b449ce2a63fa2ce852f35e3890b1e182f5951819934c0e4a66254bc0dc3 url: "https://pub.dev" source: hosted - version: "0.6.1" + version: "0.6.1+1" convert: dependency: transitive description: @@ -125,10 +213,10 @@ packages: dependency: transitive description: name: coverage - sha256: "802bd084fb82e55df091ec8ad1553a7331b61c08251eef19a508b6f3f3a9858d" + sha256: "4b8701e48a58f7712492c9b1f7ba0bb9d525644dd66d023b62e1fc8cdb560c8a" url: "https://pub.dev" source: hosted - version: "1.13.1" + version: "1.14.0" crypto: dependency: transitive description: @@ -137,6 +225,30 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.6" + dart_style: + dependency: "direct main" + description: + name: dart_style + sha256: "5b236382b47ee411741447c1f1e111459c941ea1b3f2b540dde54c210a3662af" + url: "https://pub.dev" + source: hosted + version: "3.1.0" + dio: + dependency: transitive + description: + name: dio + sha256: "253a18bbd4851fecba42f7343a1df3a9a4c1d31a2c1b37e221086b4fa8c8dbc9" + url: "https://pub.dev" + source: hosted + version: "5.8.0+1" + dio_web_adapter: + dependency: transitive + description: + name: dio_web_adapter + sha256: "7586e476d70caecaf1686d21eee7247ea43ef5c345eab9e0cc3583ff13378d78" + url: "https://pub.dev" + source: hosted + version: "2.1.1" file: dependency: transitive description: @@ -145,6 +257,14 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" frontend_server_client: dependency: transitive description: @@ -197,10 +317,10 @@ packages: dependency: transitive description: name: intl - sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf + sha256: "910f85bce16fb5c6f614e117efa303e85a1731bb0081edf3604a2ae6e9a3cc91" url: "https://pub.dev" source: hosted - version: "0.19.0" + version: "0.17.0" io: dependency: transitive description: @@ -225,8 +345,16 @@ packages: url: "https://pub.dev" source: hosted version: "4.9.0" - logging: + json_serializable: dependency: transitive + description: + name: json_serializable + sha256: c50ef5fc083d5b5e12eef489503ba3bf5ccc899e487d691584699b4bdefeea8c + url: "https://pub.dev" + source: hosted + version: "6.9.5" + logging: + dependency: "direct main" description: name: logging sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 @@ -245,12 +373,12 @@ packages: dependency: "direct dev" description: name: melos - sha256: "3f3ab3f902843d1e5a1b1a4dd39a4aca8ba1056f2d32fd8995210fa2843f646f" + sha256: "51e7902a164d7563cf1b1de04272eb4348a551c1e7885875353e82e8928c90e0" url: "https://pub.dev" source: hosted - version: "6.3.2" + version: "7.0.0-dev.9" meta: - dependency: transitive + dependency: "direct main" description: name: meta sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" @@ -290,7 +418,7 @@ packages: source: hosted version: "2.2.0" path: - dependency: transitive + dependency: "direct main" description: name: path sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" @@ -317,10 +445,10 @@ packages: dependency: transitive description: name: process - sha256: "107d8be718f120bbba9dcd1e95e3bd325b1b4a4f07db64154635ba03f2567a0d" + sha256: "44b4226c0afd4bc3b7c7e67d44c4801abd97103cf0c84609e2654b664ca2798c" url: "https://pub.dev" source: hosted - version: "5.0.3" + version: "5.0.4" prompts: dependency: transitive description: @@ -341,10 +469,10 @@ packages: dependency: transitive description: name: pub_updater - sha256: "54e8dc865349059ebe7f163d6acce7c89eb958b8047e6d6e80ce93b13d7c9e60" + sha256: "739a0161d73a6974c0675b864fb0cf5147305f7b077b7f03a58fa7a9ab3e7e7d" url: "https://pub.dev" source: hosted - version: "0.4.0" + version: "0.5.0" pubspec_parse: dependency: transitive description: @@ -385,6 +513,22 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.0" + source_gen: + dependency: transitive + description: + name: source_gen + sha256: "35c8150ece9e8c8d263337a265153c3329667640850b9304861faea59fc98f6b" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + source_helper: + dependency: transitive + description: + name: source_helper + sha256: "86d247119aedce8e63f4751bd9626fc9613255935558447569ad42f9f5b48b3c" + url: "https://pub.dev" + source: hosted + version: "1.3.5" source_map_stack_trace: dependency: transitive description: @@ -409,6 +553,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.10.1" + spell_out_numbers: + dependency: "direct main" + description: + name: spell_out_numbers + sha256: "7ac80a4e306002526490654f32c4e26f4d97e110cb11dd7b5bd4dfe627452f57" + url: "https://pub.dev" + source: hosted + version: "1.0.0" stack_trace: dependency: transitive description: @@ -425,6 +577,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.4" + stream_transform: + dependency: transitive + description: + name: stream_transform + sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 + url: "https://pub.dev" + source: hosted + version: "2.1.1" string_scanner: dependency: transitive description: @@ -465,6 +625,22 @@ packages: url: "https://pub.dev" source: hosted version: "0.6.11" + timing: + dependency: transitive + description: + name: timing + sha256: "62ee18aca144e4a9f29d212f5a4c6a053be252b895ab14b5821996cff4ed90fe" + url: "https://pub.dev" + source: hosted + version: "1.0.2" + tuple: + dependency: transitive + description: + name: tuple + sha256: a97ce2013f240b2f3807bcbaf218765b6f301c3eff91092bcfa23a039e7dd151 + url: "https://pub.dev" + source: hosted + version: "2.0.2" typed_data: dependency: transitive description: @@ -477,10 +653,10 @@ packages: dependency: "direct dev" description: name: very_good_analysis - sha256: "62d2b86d183fb81b2edc22913d9f155d26eb5cf3855173adb1f59fac85035c63" + sha256: c529563be4cbba1137386f2720fb7ed69e942012a28b13398d8a5e3e6ef551a7 url: "https://pub.dev" source: hosted - version: "7.0.0" + version: "8.0.0" vm_service: dependency: transitive description: @@ -546,4 +722,4 @@ packages: source: hosted version: "2.2.2" sdks: - dart: ">=3.7.0 <4.0.0" + dart: ">=3.8.0 <4.0.0" diff --git a/pubspec.yaml b/pubspec.yaml index 3abf071..403f4d7 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,9 +1,43 @@ -name: tonik +name: tonik_workspace + +workspace: + - packages/tonik + - packages/tonik_core + - packages/tonik_generate + - packages/tonik_parse + - packages/tonik_util environment: sdk: '>=3.7.0 <4.0.0' +dependencies: + collection: ^1.19.1 + dart_style: ^3.0.1 + logging: ^1.3.0 + meta: ^1.16.0 + path: ^1.9.1 + spell_out_numbers: ^1.0.0 + tonik: ^0.0.4 + tonik_core: ^0.0.4 + tonik_generate: ^0.0.4 + tonik_parse: ^0.0.4 + tonik_util: ^0.0.4 + dev_dependencies: - melos: ^6.2.0 + melos: ^7.0.0-dev.9 test: ^1.25.15 - very_good_analysis: ^7.0.0 \ No newline at end of file + very_good_analysis: ^8.0.0 + +melos: + scripts: + test: + run: dart test + exec: + concurrency: 1 + + generate: + run: flutter pub run build_runner build --delete-conflicting-outputs + exec: + concurrency: 1 + select-package: + depends-on: build_runner \ No newline at end of file From 94546cb7c5605bdf464c62ddbd8583c9f758a98c Mon Sep 17 00:00:00 2001 From: Tobias Ottenweller Date: Mon, 2 Jun 2025 22:24:13 +0200 Subject: [PATCH 06/37] chore(release): publish packages - tonik@0.0.5 - tonik_core@0.0.5 - tonik_generate@0.0.5 - tonik_parse@0.0.5 - tonik_util@0.0.5 --- CHANGELOG.md | 31 ++++++++++++++++++++++++++++ packages/tonik/CHANGELOG.md | 2 ++ packages/tonik/pubspec.yaml | 8 +++---- packages/tonik_core/CHANGELOG.md | 2 ++ packages/tonik_core/pubspec.yaml | 2 +- packages/tonik_generate/CHANGELOG.md | 2 ++ packages/tonik_generate/pubspec.yaml | 4 ++-- packages/tonik_parse/CHANGELOG.md | 2 ++ packages/tonik_parse/pubspec.yaml | 4 ++-- packages/tonik_util/CHANGELOG.md | 2 ++ packages/tonik_util/pubspec.yaml | 2 +- 11 files changed, 51 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e056f7..d181de6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,37 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## 2025-06-02 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`tonik` - `v0.0.5`](#tonik---v005) + - [`tonik_core` - `v0.0.5`](#tonik_core---v005) + - [`tonik_generate` - `v0.0.5`](#tonik_generate---v005) + - [`tonik_parse` - `v0.0.5`](#tonik_parse---v005) + - [`tonik_util` - `v0.0.5`](#tonik_util---v005) + +--- + +#### `tonik` - `v0.0.5` + +#### `tonik_core` - `v0.0.5` + +#### `tonik_generate` - `v0.0.5` + +#### `tonik_parse` - `v0.0.5` + +#### `tonik_util` - `v0.0.5` + + ## 2025-06-02 ### Changes diff --git a/packages/tonik/CHANGELOG.md b/packages/tonik/CHANGELOG.md index 395336f..c83e019 100644 --- a/packages/tonik/CHANGELOG.md +++ b/packages/tonik/CHANGELOG.md @@ -1,3 +1,5 @@ +## 0.0.5 + ## 0.0.4 - **FIX**: define executables for tonik. diff --git a/packages/tonik/pubspec.yaml b/packages/tonik/pubspec.yaml index 30785d1..07d446f 100644 --- a/packages/tonik/pubspec.yaml +++ b/packages/tonik/pubspec.yaml @@ -1,6 +1,6 @@ name: tonik description: A Dart code generator for OpenAPI 3.0 and 3.1 specifications. -version: 0.0.4 +version: 0.0.5 repository: https://github.com/t-unit/tonik resolution: workspace @@ -20,9 +20,9 @@ environment: dependencies: args: ^2.5.0 logging: ^1.3.0 - tonik_core: ^0.0.1 - tonik_generate: ^0.0.1 - tonik_parse: ^0.0.1 + tonik_core: ^0.0.5 + tonik_generate: ^0.0.5 + tonik_parse: ^0.0.5 yaml: ^3.1.3 dev_dependencies: diff --git a/packages/tonik_core/CHANGELOG.md b/packages/tonik_core/CHANGELOG.md index 8ecd282..47f0e8a 100644 --- a/packages/tonik_core/CHANGELOG.md +++ b/packages/tonik_core/CHANGELOG.md @@ -1,3 +1,5 @@ +## 0.0.5 + ## 0.0.4 - **FEAT**: generate all of classes. diff --git a/packages/tonik_core/pubspec.yaml b/packages/tonik_core/pubspec.yaml index 9a6c074..694bcfb 100644 --- a/packages/tonik_core/pubspec.yaml +++ b/packages/tonik_core/pubspec.yaml @@ -1,6 +1,6 @@ name: tonik_core description: Core data structures and utilities for Tonik. -version: 0.0.4 +version: 0.0.5 repository: https://github.com/t-unit/tonik resolution: workspace diff --git a/packages/tonik_generate/CHANGELOG.md b/packages/tonik_generate/CHANGELOG.md index 9b20748..7149c21 100644 --- a/packages/tonik_generate/CHANGELOG.md +++ b/packages/tonik_generate/CHANGELOG.md @@ -1,3 +1,5 @@ +## 0.0.5 + ## 0.0.4 - **FEAT**: generate all of classes. diff --git a/packages/tonik_generate/pubspec.yaml b/packages/tonik_generate/pubspec.yaml index e11a076..0ab9a31 100644 --- a/packages/tonik_generate/pubspec.yaml +++ b/packages/tonik_generate/pubspec.yaml @@ -1,6 +1,6 @@ name: tonik_generate description: A code generation package for Tonik. -version: 0.0.4 +version: 0.0.5 repository: https://github.com/t-unit/tonik resolution: workspace @@ -16,7 +16,7 @@ dependencies: meta: ^1.16.0 path: ^1.9.1 spell_out_numbers: ^1.0.0 - tonik_core: ^0.0.1 + tonik_core: ^0.0.5 dev_dependencies: test: ^1.24.0 diff --git a/packages/tonik_parse/CHANGELOG.md b/packages/tonik_parse/CHANGELOG.md index 376a73b..ca6a9fc 100644 --- a/packages/tonik_parse/CHANGELOG.md +++ b/packages/tonik_parse/CHANGELOG.md @@ -1,3 +1,5 @@ +## 0.0.5 + ## 0.0.4 - no changes diff --git a/packages/tonik_parse/pubspec.yaml b/packages/tonik_parse/pubspec.yaml index e9e4ea1..6f1e610 100644 --- a/packages/tonik_parse/pubspec.yaml +++ b/packages/tonik_parse/pubspec.yaml @@ -1,6 +1,6 @@ name: tonik_parse description: The parsing module for Tonik. -version: 0.0.4 +version: 0.0.5 repository: https://github.com/t-unit/tonik resolution: workspace @@ -11,7 +11,7 @@ dependencies: collection: ^1.19.1 json_annotation: ^4.9.0 logging: ^1.3.0 - tonik_core: ^0.0.1 + tonik_core: ^0.0.5 dev_dependencies: build_runner: ^2.3.3 diff --git a/packages/tonik_util/CHANGELOG.md b/packages/tonik_util/CHANGELOG.md index bf6138e..b0c37af 100644 --- a/packages/tonik_util/CHANGELOG.md +++ b/packages/tonik_util/CHANGELOG.md @@ -1,3 +1,5 @@ +## 0.0.5 + ## 0.0.4 - no changes diff --git a/packages/tonik_util/pubspec.yaml b/packages/tonik_util/pubspec.yaml index b8fd96d..00b6106 100644 --- a/packages/tonik_util/pubspec.yaml +++ b/packages/tonik_util/pubspec.yaml @@ -1,6 +1,6 @@ name: tonik_util description: Runtime tools for packages generated by Tonik. -version: 0.0.4 +version: 0.0.5 repository: https://github.com/t-unit/tonik resolution: workspace From 095b390db9991914f87ecb1f58871e6ec7794360 Mon Sep 17 00:00:00 2001 From: Tobias Ottenweller Date: Mon, 2 Jun 2025 23:45:32 +0200 Subject: [PATCH 07/37] fix: proper hash code for classes with >20 properties --- .../lib/src/util/hash_code_generator.dart | 2 +- .../test/src/model/all_of_generator_test.dart | 4 +- .../model/class_hash_code_generator_test.dart | 12 ++--- .../response_class_generator_test.dart | 6 +-- .../src/util/hash_code_generator_test.dart | 53 ++++++++++++++++++- 5 files changed, 64 insertions(+), 13 deletions(-) diff --git a/packages/tonik_generate/lib/src/util/hash_code_generator.dart b/packages/tonik_generate/lib/src/util/hash_code_generator.dart index e882b22..be1d733 100644 --- a/packages/tonik_generate/lib/src/util/hash_code_generator.dart +++ b/packages/tonik_generate/lib/src/util/hash_code_generator.dart @@ -80,7 +80,7 @@ Method generateHashCodeMethod({ refer( 'Object', 'dart:core', - ).property('hash').call(hashArgs, {}, []).returned.statement, + ).property('hashAll').call([literalList(hashArgs)]).returned.statement, ); } diff --git a/packages/tonik_generate/test/src/model/all_of_generator_test.dart b/packages/tonik_generate/test/src/model/all_of_generator_test.dart index 4c9030e..f1875bd 100644 --- a/packages/tonik_generate/test/src/model/all_of_generator_test.dart +++ b/packages/tonik_generate/test/src/model/all_of_generator_test.dart @@ -327,7 +327,7 @@ void main() { const expectedHashCode = ''' @override int get hashCode { - return Object.hash(base, mixin); + return Object.hashAll([base, mixin]); } '''; @@ -472,7 +472,7 @@ void main() { const expectedHashCode = ''' @override int get hashCode { - return Object.hash(status, string); + return Object.hashAll([status, string]); } '''; diff --git a/packages/tonik_generate/test/src/model/class_hash_code_generator_test.dart b/packages/tonik_generate/test/src/model/class_hash_code_generator_test.dart index f3ba80b..588fda2 100644 --- a/packages/tonik_generate/test/src/model/class_hash_code_generator_test.dart +++ b/packages/tonik_generate/test/src/model/class_hash_code_generator_test.dart @@ -54,7 +54,7 @@ void main() { const expectedMethod = ''' @override - int get hashCode { return Object.hash(name, age); } + int get hashCode { return Object.hashAll([name, age]); } '''; final generatedClass = generator.generateClass(model); @@ -102,7 +102,7 @@ void main() { const expectedMethod = ''' @override - int get hashCode { return Object.hash(id, name, email, age); } + int get hashCode { return Object.hashAll([id, name, email, age]); } '''; final generatedClass = generator.generateClass(model); @@ -136,7 +136,7 @@ void main() { const expectedMethod = ''' @override - int get hashCode { return Object.hash(name, bio); } + int get hashCode { return Object.hashAll([name, bio]); } '''; final generatedClass = generator.generateClass(model); @@ -170,7 +170,7 @@ void main() { const expectedMethod = ''' @override - int get hashCode { return Object.hash(firstName, lastName); } + int get hashCode { return Object.hashAll([firstName, lastName]); } '''; final generatedClass = generator.generateClass(model); @@ -209,7 +209,7 @@ void main() { @override int get hashCode { const deepEquals = DeepCollectionEquality(); - return Object.hash(name, deepEquals.hash(tags)); + return Object.hashAll([name, deepEquals.hash(tags)]); } '''; @@ -252,7 +252,7 @@ void main() { @override int get hashCode { const deepEquals = DeepCollectionEquality(); - return Object.hash(name, deepEquals.hash(nestedList)); + return Object.hashAll([name, deepEquals.hash(nestedList)]); } '''; diff --git a/packages/tonik_generate/test/src/response/response_class_generator_test.dart b/packages/tonik_generate/test/src/response/response_class_generator_test.dart index ca46a52..ae48ec6 100644 --- a/packages/tonik_generate/test/src/response/response_class_generator_test.dart +++ b/packages/tonik_generate/test/src/response/response_class_generator_test.dart @@ -311,7 +311,7 @@ void main() { const expectedMethod = ''' @override int get hashCode { - return Object.hash(xTest, body); + return Object.hashAll([xTest, body]); } '''; @@ -361,7 +361,7 @@ void main() { const expectedMethod = ''' @override int get hashCode { - return Object.hash(xTest, body, xOther); + return Object.hashAll([xTest, body, xOther]); } '''; @@ -406,7 +406,7 @@ void main() { @override int get hashCode { const deepEquals = DeepCollectionEquality(); - return Object.hash(deepEquals.hash(xList), body); + return Object.hashAll([deepEquals.hash(xList), body]); } '''; diff --git a/packages/tonik_generate/test/src/util/hash_code_generator_test.dart b/packages/tonik_generate/test/src/util/hash_code_generator_test.dart index ade3fbd..1f773a8 100644 --- a/packages/tonik_generate/test/src/util/hash_code_generator_test.dart +++ b/packages/tonik_generate/test/src/util/hash_code_generator_test.dart @@ -95,7 +95,58 @@ void main() { @override int get hashCode { const deepEquals = DeepCollectionEquality(); - return Object.hash(id, deepEquals.hash(items), name); + return Object.hashAll([id, deepEquals.hash(items), name]); + } + '''; + + expect( + collapseWhitespace(formatMethod(method)), + contains(collapseWhitespace(expectedMethod)), + ); + }); + + test('generates hash code method for class with many properties', () { + final method = generateHashCodeMethod( + properties: List.generate( + 25, + (i) => ( + normalizedName: 'prop$i', + hasCollectionValue: i.isEven, + ), + ), + ); + + const expectedMethod = ''' + @override + int get hashCode { + const deepEquals = DeepCollectionEquality(); + return Object.hashAll([ + deepEquals.hash(prop0), + prop1, + deepEquals.hash(prop2), + prop3, + deepEquals.hash(prop4), + prop5, + deepEquals.hash(prop6), + prop7, + deepEquals.hash(prop8), + prop9, + deepEquals.hash(prop10), + prop11, + deepEquals.hash(prop12), + prop13, + deepEquals.hash(prop14), + prop15, + deepEquals.hash(prop16), + prop17, + deepEquals.hash(prop18), + prop19, + deepEquals.hash(prop20), + prop21, + deepEquals.hash(prop22), + prop23, + deepEquals.hash(prop24), + ]); } '''; From 9eafa9337d81d527cea4e1ee6d439c92d0d4aae2 Mon Sep 17 00:00:00 2001 From: Tobias Ottenweller Date: Tue, 3 Jun 2025 21:44:48 +0200 Subject: [PATCH 08/37] chore: fix or ignore linter issues in generated code --- .../lib/src/analysis_options_generator.dart | 35 +++++++++++++++++ .../tonik_generate/lib/src/generator.dart | 6 +++ .../lib/src/model/class_generator.dart | 25 ++++++++++++ .../lib/src/pubspec_generator.dart | 1 + .../lib/src/util/format_with_header.dart | 11 +----- .../src/model/class_json_generator_test.dart | 38 +++++++++++++++++++ 6 files changed, 106 insertions(+), 10 deletions(-) create mode 100644 packages/tonik_generate/lib/src/analysis_options_generator.dart diff --git a/packages/tonik_generate/lib/src/analysis_options_generator.dart b/packages/tonik_generate/lib/src/analysis_options_generator.dart new file mode 100644 index 0000000..1d65ffc --- /dev/null +++ b/packages/tonik_generate/lib/src/analysis_options_generator.dart @@ -0,0 +1,35 @@ +import 'dart:io'; +import 'package:path/path.dart' as path; + +void generateAnalysisOptions({ + required String outputDirectory, + required String package, +}) { + final packageDir = path.join(outputDirectory, package); + final analysisOptionsFile = File( + path.join(packageDir, 'analysis_options.yaml'), + ); + + if (!analysisOptionsFile.parent.existsSync()) { + analysisOptionsFile.parent.createSync(recursive: true); + } + + const content = ''' +include: package:lints/recommended.yaml + +analyzer: + errors: + lines_longer_than_80_chars: ignore + unnecessary_raw_strings: ignore + unnecessary_brace_in_string_interps: ignore + no_leading_underscores_for_local_identifiers: ignore + cascade_invocations: ignore + deprecated_member_use_from_same_package: ignore + no_leading_underscores_for_library_prefixes: ignore + unused_import: ignore + prefer_is_empty: ignore + unnecessary_nullable_for_final_variable_declarations: ignore +'''; + + analysisOptionsFile.writeAsStringSync(content); +} diff --git a/packages/tonik_generate/lib/src/generator.dart b/packages/tonik_generate/lib/src/generator.dart index 09c8eaf..a35fb18 100644 --- a/packages/tonik_generate/lib/src/generator.dart +++ b/packages/tonik_generate/lib/src/generator.dart @@ -1,4 +1,5 @@ import 'package:tonik_core/tonik_core.dart'; +import 'package:tonik_generate/src/analysis_options_generator.dart'; import 'package:tonik_generate/src/api_client/api_client_file_generator.dart'; import 'package:tonik_generate/src/api_client/api_client_generator.dart'; import 'package:tonik_generate/src/library_generator.dart'; @@ -132,6 +133,11 @@ class Generator { package: package, ); + generateAnalysisOptions( + outputDirectory: outputDirectory, + package: package, + ); + modelGenerator.writeFiles( apiDocument: apiDocument, outputDirectory: outputDirectory, diff --git a/packages/tonik_generate/lib/src/model/class_generator.dart b/packages/tonik_generate/lib/src/model/class_generator.dart index dd604c4..8f5f4cf 100644 --- a/packages/tonik_generate/lib/src/model/class_generator.dart +++ b/packages/tonik_generate/lib/src/model/class_generator.dart @@ -183,6 +183,26 @@ class ClassGenerator { Constructor _buildFromSimpleConstructor(String className, ClassModel model) { final normalizedProperties = normalizeProperties(model.properties.toList()); + + // If there are no properties, just return the constructor call + if (normalizedProperties.isEmpty) { + return Constructor( + (b) => + b + ..factory = true + ..name = 'fromSimple' + ..requiredParameters.add( + Parameter( + (b) => + b + ..name = 'value' + ..type = refer('String?', 'dart:core'), + ), + ) + ..body = Code('return $className();'), + ); + } + final propertyAssignments = >[]; for (var i = 0; i < normalizedProperties.length; i++) { final prop = normalizedProperties[i]; @@ -258,6 +278,11 @@ class ClassGenerator { Code _buildFromJsonBody(String className, ClassModel model) { final normalizedProperties = normalizeProperties(model.properties.toList()); + // If there are no properties, just return the constructor call + if (normalizedProperties.isEmpty) { + return Block.of([Code('return $className();')]); + } + final codes = [ Code("final map = json.decodeMap(context: '$className');"), ]; diff --git a/packages/tonik_generate/lib/src/pubspec_generator.dart b/packages/tonik_generate/lib/src/pubspec_generator.dart index ef578fb..257a253 100644 --- a/packages/tonik_generate/lib/src/pubspec_generator.dart +++ b/packages/tonik_generate/lib/src/pubspec_generator.dart @@ -25,6 +25,7 @@ dependencies: big_decimal: ^0.5.0 collection: ^1.17.0 dio: ^5.8.0+1 + lints: ^6.0.0 meta: ^1.16.0 tonik_util: ^0.0.5 '''; diff --git a/packages/tonik_generate/lib/src/util/format_with_header.dart b/packages/tonik_generate/lib/src/util/format_with_header.dart index 6a51ab5..d6f305d 100644 --- a/packages/tonik_generate/lib/src/util/format_with_header.dart +++ b/packages/tonik_generate/lib/src/util/format_with_header.dart @@ -1,19 +1,10 @@ import 'package:dart_style/dart_style.dart'; extension FormatWithHeader on DartFormatter { - static const _ignores = [ - 'lines_longer_than_80_chars', - 'unnecessary_raw_strings', - 'unnecessary_brace_in_string_interps', - 'no_leading_underscores_for_local_identifiers', - 'cascade_invocations', - 'prefer_is_empty', - ]; - String formatWithHeader(String code) { return format(''' // Generated code - do not modify by hand -${_ignores.map((i) => '// ignore_for_file: $i').join('\n')} + $code'''); } } 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 2b391bb..7fb5505 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 @@ -597,5 +597,43 @@ void main() { contains(collapseWhitespace(expectedMethod)), ); }); + + test('generates fromJson method for class without properties', () { + final model = ClassModel( + context: context, + name: 'EmptyClass', + properties: const [], + ); + + const expectedMethod = ''' + factory EmptyClass.fromJson(Object? json) { + return EmptyClass(); + }'''; + + final generatedClass = generator.generateClass(model); + expect( + collapseWhitespace(format(generatedClass.accept(emitter).toString())), + contains(collapseWhitespace(expectedMethod)), + ); + }); + + test('generates fromSimple method for class without properties', () { + final model = ClassModel( + context: context, + name: 'EmptyClass', + properties: const [], + ); + + const expectedMethod = ''' + factory EmptyClass.fromSimple(String? value) { + return EmptyClass(); + }'''; + + final generatedClass = generator.generateClass(model); + expect( + collapseWhitespace(format(generatedClass.accept(emitter).toString())), + contains(collapseWhitespace(expectedMethod)), + ); + }); }); } From 11e0fbd74bb86ba1a01ce4fc76372adc7f6f2d01 Mon Sep 17 00:00:00 2001 From: Tobias Ottenweller Date: Tue, 3 Jun 2025 21:50:34 +0200 Subject: [PATCH 09/37] chore: add additional badge --- packages/tonik/README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/tonik/README.md b/packages/tonik/README.md index 4328338..7a7673d 100644 --- a/packages/tonik/README.md +++ b/packages/tonik/README.md @@ -8,7 +8,8 @@ pub verion pub likes stars on github -tests +tests +

From 740afe730b4ed46587f8a4b7fd482c3f1aa96e9b Mon Sep 17 00:00:00 2001 From: Tobias Ottenweller Date: Sun, 8 Jun 2025 22:41:51 +0200 Subject: [PATCH 10/37] fix: prio for explicitly defined names --- .../lib/src/naming/name_manager.dart | 16 +++- .../test/src/naming/name_manager_test.dart | 94 ++++++++++++++++--- 2 files changed, 95 insertions(+), 15 deletions(-) diff --git a/packages/tonik_generate/lib/src/naming/name_manager.dart b/packages/tonik_generate/lib/src/naming/name_manager.dart index aadbe2d..ddb95fb 100644 --- a/packages/tonik_generate/lib/src/naming/name_manager.dart +++ b/packages/tonik_generate/lib/src/naming/name_manager.dart @@ -58,9 +58,19 @@ class NameManager { _logServerName(entry.value, entry.key); } - for (final model in models) { - final name = modelName(model); - _logModelName(name, model); + // Process models with explicit names first, then anonymous models + final modelsList = models.toList(); + for (final model in modelsList) { + if (model is NamedModel && model.name != null) { + final name = modelName(model); + _logModelName(name, model); + } + } + for (final model in modelsList) { + if (model is! NamedModel || model.name == null) { + final name = modelName(model); + _logModelName(name, model); + } } for (final response in responses) { diff --git a/packages/tonik_generate/test/src/naming/name_manager_test.dart b/packages/tonik_generate/test/src/naming/name_manager_test.dart index 4102156..191c4c7 100644 --- a/packages/tonik_generate/test/src/naming/name_manager_test.dart +++ b/packages/tonik_generate/test/src/naming/name_manager_test.dart @@ -599,6 +599,76 @@ void main() { expect(baseName2, baseName); expect(identical(implementationNames, implementationNames2), isTrue); }); + + group('model naming behavior', () { + late Context userContext; + late Context anonymousContext; + + setUp(() { + userContext = Context.initial().pushAll([ + 'components', + 'schemas', + 'user', + ]); + anonymousContext = Context.initial().pushAll([ + 'components', + 'schemas', + 'anonymous', + ]); + }); + + test( + 'named model keeps original name and anonymous model gets Model suffix', + () { + final models = [ + ClassModel( + name: 'User', + properties: const [], + context: userContext, + ), + ClassModel(properties: const [], context: userContext), + ]; + + manager.prime( + models: models, + responses: const [], + operations: const [], + tags: const [], + requestBodies: const [], + servers: const [], + ); + + expect(manager.modelName(models[0]), 'User'); + expect(manager.modelName(models[1]), 'UserModel'); + }, + ); + + test( + 'named model takes precedence over anonymous model with same context', + () { + final models = [ + ClassModel(properties: const [], context: userContext), + ClassModel( + name: 'User', + properties: const [], + context: userContext, + ), + ]; + + manager.prime( + models: models, + responses: const [], + operations: const [], + tags: const [], + requestBodies: const [], + servers: const [], + ); + + expect(manager.modelName(models[0]), 'UserModel'); + expect(manager.modelName(models[1]), 'User'); + }, + ); + }); }); group('Server names with list-based caching', () { @@ -626,10 +696,10 @@ void main() { const Server(url: 'https://staging.example.com', description: null), const Server(url: 'https://dev.example.com', description: null), ]; - + // Identity should be different but content equal expect(identical(servers, identicalContentServers), isFalse); - + // Second call with different list but same content should use cache final result2 = manager.serverNames(identicalContentServers); @@ -637,18 +707,18 @@ void main() { expect(result1.serverMap.length, result2.serverMap.length); expect(result1.baseName, result2.baseName); expect(result1.customName, result2.customName); - + // The cache should only have one entry despite using two different lists expect(manager.serverNamesCache.length, 1); - + // Check that corresponding servers in each list have the same names for (var i = 0; i < servers.length; i++) { final server1 = servers[i]; final server2 = identicalContentServers[i]; - + final name1 = result1.serverMap[server1]; final name2 = result2.serverMap[server2]; - + expect(name1, name2); } }); @@ -674,28 +744,28 @@ void main() { // Verify the server names are cached expect(manager.serverNamesCache.length, 1); - + // Verify the cache contains the correct key expect(manager.serverNamesCache.containsKey(cacheKey), isTrue); - + // Get the cached result final cachedResult = manager.serverNamesCache[cacheKey]!; - + // Verify the cached result has correct server map size expect(cachedResult.serverMap.length, 2); - + // Check that the servers are properly mapped to their expected names for (final server in servers) { final name = cachedResult.serverMap[server]; expect(name != null, isTrue); - + if (server.url == 'https://api.example.com') { expect(name!.startsWith('Api'), isTrue); } else if (server.url == 'https://staging.example.com') { expect(name!.startsWith('Staging'), isTrue); } } - + // Verify custom name exists expect(cachedResult.customName.contains('Custom'), isTrue); }); From edfc24ef6c0ca83571b5ba6322a2f0cda6d20c5c Mon Sep 17 00:00:00 2001 From: Tobias Ottenweller Date: Mon, 9 Jun 2025 12:04:25 +0200 Subject: [PATCH 11/37] fix: priority for exlict defined names of schemas --- .../lib/src/naming/name_manager.dart | 23 +++++++++---------- .../test/src/naming/name_manager_test.dart | 6 ----- 2 files changed, 11 insertions(+), 18 deletions(-) diff --git a/packages/tonik_generate/lib/src/naming/name_manager.dart b/packages/tonik_generate/lib/src/naming/name_manager.dart index ddb95fb..a3c7671 100644 --- a/packages/tonik_generate/lib/src/naming/name_manager.dart +++ b/packages/tonik_generate/lib/src/naming/name_manager.dart @@ -58,19 +58,18 @@ class NameManager { _logServerName(entry.value, entry.key); } - // Process models with explicit names first, then anonymous models - final modelsList = models.toList(); - for (final model in modelsList) { - if (model is NamedModel && model.name != null) { - final name = modelName(model); - _logModelName(name, model); - } + for (final model in models.where( + (m) => m is NamedModel && m.name != null, + )) { + final name = modelName(model); + _logModelName(name, model); } - for (final model in modelsList) { - if (model is! NamedModel || model.name == null) { - final name = modelName(model); - _logModelName(name, model); - } + + for (final model in models.where( + (m) => m is! NamedModel || m.name == null, + )) { + final name = modelName(model); + _logModelName(name, model); } for (final response in responses) { diff --git a/packages/tonik_generate/test/src/naming/name_manager_test.dart b/packages/tonik_generate/test/src/naming/name_manager_test.dart index 191c4c7..2eb923e 100644 --- a/packages/tonik_generate/test/src/naming/name_manager_test.dart +++ b/packages/tonik_generate/test/src/naming/name_manager_test.dart @@ -602,7 +602,6 @@ void main() { group('model naming behavior', () { late Context userContext; - late Context anonymousContext; setUp(() { userContext = Context.initial().pushAll([ @@ -610,11 +609,6 @@ void main() { 'schemas', 'user', ]); - anonymousContext = Context.initial().pushAll([ - 'components', - 'schemas', - 'anonymous', - ]); }); test( From 8413cf7676ef1d488ab0f84f7b223dbcf6dbdc24 Mon Sep 17 00:00:00 2001 From: Tobias Ottenweller Date: Mon, 9 Jun 2025 12:04:43 +0200 Subject: [PATCH 12/37] chore: ignore all dart_tool folders --- .gitignore | 2 +- packages/tonik/README.md | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index 466097b..6a07362 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ .DS_Store -/.dart_tool +.dart_tool # FVM Version Cache .fvm/ diff --git a/packages/tonik/README.md b/packages/tonik/README.md index 7a7673d..5f80f6f 100644 --- a/packages/tonik/README.md +++ b/packages/tonik/README.md @@ -10,11 +10,10 @@ stars on github tests +

- - # Tonik A Dart code generator for OpenAPI 3.0 and 3.1 specifications. From d81cb132833babda4fb77bbfc7290829915fe8e7 Mon Sep 17 00:00:00 2001 From: Tobias Ottenweller Date: Mon, 9 Jun 2025 12:05:02 +0200 Subject: [PATCH 13/37] test: add get an album 200 test --- .../analysis_options.yaml | 12 + .../imposter/imposter-config.json | 4 + .../music_streaming_test/pubspec.lock | 468 ++++++++++++++++++ .../music_streaming_test/pubspec.yaml | 18 + .../test/albums_test.dart | 193 ++++++++ .../test/test_helper.dart | 84 ++++ 6 files changed, 779 insertions(+) create mode 100644 integration_test/music_streaming/music_streaming_test/analysis_options.yaml create mode 100644 integration_test/music_streaming/music_streaming_test/imposter/imposter-config.json create mode 100644 integration_test/music_streaming/music_streaming_test/pubspec.lock create mode 100644 integration_test/music_streaming/music_streaming_test/pubspec.yaml create mode 100644 integration_test/music_streaming/music_streaming_test/test/albums_test.dart create mode 100644 integration_test/music_streaming/music_streaming_test/test/test_helper.dart diff --git a/integration_test/music_streaming/music_streaming_test/analysis_options.yaml b/integration_test/music_streaming/music_streaming_test/analysis_options.yaml new file mode 100644 index 0000000..773cbeb --- /dev/null +++ b/integration_test/music_streaming/music_streaming_test/analysis_options.yaml @@ -0,0 +1,12 @@ + +include: package:very_good_analysis/analysis_options.yaml + +linter: + rules: + public_member_api_docs: false + avoid_print: false + +analyzer: + errors: + deprecated_member_use: ignore + \ No newline at end of file diff --git a/integration_test/music_streaming/music_streaming_test/imposter/imposter-config.json b/integration_test/music_streaming/music_streaming_test/imposter/imposter-config.json new file mode 100644 index 0000000..ad98044 --- /dev/null +++ b/integration_test/music_streaming/music_streaming_test/imposter/imposter-config.json @@ -0,0 +1,4 @@ +{ + "plugin": "openapi", + "specFile": "../../openapi.yaml" +} \ No newline at end of file diff --git a/integration_test/music_streaming/music_streaming_test/pubspec.lock b/integration_test/music_streaming/music_streaming_test/pubspec.lock new file mode 100644 index 0000000..f7a4c2d --- /dev/null +++ b/integration_test/music_streaming/music_streaming_test/pubspec.lock @@ -0,0 +1,468 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: e55636ed79578b9abca5fecf9437947798f5ef7456308b5cb85720b793eac92f + url: "https://pub.dev" + source: hosted + version: "82.0.0" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: "904ae5bb474d32c38fb9482e2d925d5454cda04ddd0e55d2e6826bc72f6ba8c0" + url: "https://pub.dev" + source: hosted + version: "7.4.5" + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" + url: "https://pub.dev" + source: hosted + version: "2.13.0" + big_decimal: + dependency: transitive + description: + name: big_decimal + sha256: "301158ec5a646d1e1a0ca7a97fbfab7be18a8df700adb7f7cb9c4149e75c8f0c" + url: "https://pub.dev" + source: hosted + version: "0.5.0" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + checked_yaml: + dependency: transitive + description: + name: checked_yaml + sha256: feb6bed21949061731a7a75fc5d2aa727cf160b91af9a3e464c5e3a32e28b5ff + url: "https://pub.dev" + source: hosted + version: "2.0.3" + cli_config: + dependency: transitive + description: + name: cli_config + sha256: ac20a183a07002b700f0c25e61b7ee46b23c309d76ab7b7640a028f18e4d99ec + url: "https://pub.dev" + source: hosted + version: "0.2.0" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + coverage: + dependency: transitive + description: + name: coverage + sha256: "4b8701e48a58f7712492c9b1f7ba0bb9d525644dd66d023b62e1fc8cdb560c8a" + url: "https://pub.dev" + source: hosted + version: "1.14.0" + crypto: + dependency: transitive + description: + name: crypto + sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855" + url: "https://pub.dev" + source: hosted + version: "3.0.6" + dio: + dependency: "direct main" + description: + name: dio + sha256: "253a18bbd4851fecba42f7343a1df3a9a4c1d31a2c1b37e221086b4fa8c8dbc9" + url: "https://pub.dev" + source: hosted + version: "5.8.0+1" + dio_web_adapter: + dependency: transitive + description: + name: dio_web_adapter + sha256: "7586e476d70caecaf1686d21eee7247ea43ef5c345eab9e0cc3583ff13378d78" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + frontend_server_client: + dependency: transitive + description: + name: frontend_server_client + sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 + url: "https://pub.dev" + source: hosted + version: "4.0.0" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.dev" + source: hosted + version: "2.1.3" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 + url: "https://pub.dev" + source: hosted + version: "3.2.2" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + io: + dependency: transitive + description: + name: io + sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b + url: "https://pub.dev" + source: hosted + version: "1.0.5" + js: + dependency: transitive + description: + name: js + sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc" + url: "https://pub.dev" + source: hosted + version: "0.7.2" + json_annotation: + dependency: transitive + description: + name: json_annotation + sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" + url: "https://pub.dev" + source: hosted + version: "4.9.0" + lints: + dependency: transitive + description: + name: lints + sha256: a5e2b223cb7c9c8efdc663ef484fdd95bb243bff242ef5b13e26883547fce9a0 + url: "https://pub.dev" + source: hosted + version: "6.0.0" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + url: "https://pub.dev" + source: hosted + version: "0.12.17" + meta: + dependency: transitive + description: + name: meta + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + url: "https://pub.dev" + source: hosted + version: "1.17.0" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + music_streaming_api: + dependency: "direct main" + description: + path: "../music_streaming_api" + relative: true + source: path + version: "1.0.0" + node_preamble: + dependency: transitive + description: + name: node_preamble + sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + package_config: + dependency: transitive + description: + name: package_config + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + url: "https://pub.dev" + source: hosted + version: "2.2.0" + path: + dependency: "direct main" + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + pool: + dependency: transitive + description: + name: pool + sha256: "20fe868b6314b322ea036ba325e6fc0711a22948856475e2c2b6306e8ab39c2a" + url: "https://pub.dev" + source: hosted + version: "1.5.1" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + pubspec_parse: + dependency: transitive + description: + name: pubspec_parse + sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082" + url: "https://pub.dev" + source: hosted + version: "1.5.0" + shelf: + dependency: transitive + description: + name: shelf + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 + url: "https://pub.dev" + source: hosted + version: "1.4.2" + shelf_packages_handler: + dependency: transitive + description: + name: shelf_packages_handler + sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + shelf_static: + dependency: transitive + description: + name: shelf_static + sha256: c87c3875f91262785dade62d135760c2c69cb217ac759485334c5857ad89f6e3 + url: "https://pub.dev" + source: hosted + version: "1.1.3" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" + url: "https://pub.dev" + source: hosted + version: "3.0.0" + source_map_stack_trace: + dependency: transitive + description: + name: source_map_stack_trace + sha256: c0713a43e323c3302c2abe2a1cc89aa057a387101ebd280371d6a6c9fa68516b + url: "https://pub.dev" + source: hosted + version: "2.1.2" + source_maps: + dependency: transitive + description: + name: source_maps + sha256: "190222579a448b03896e0ca6eca5998fa810fda630c1d65e2f78b3f638f54812" + url: "https://pub.dev" + source: hosted + version: "0.10.13" + source_span: + dependency: transitive + description: + name: source_span + sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" + url: "https://pub.dev" + source: hosted + version: "1.10.1" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test: + dependency: "direct dev" + description: + name: test + sha256: "65e29d831719be0591f7b3b1a32a3cda258ec98c58c7b25f7b84241bc31215bb" + url: "https://pub.dev" + source: hosted + version: "1.26.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" + url: "https://pub.dev" + source: hosted + version: "0.7.6" + test_core: + dependency: transitive + description: + name: test_core + sha256: "80bf5a02b60af04b09e14f6fe68b921aad119493e26e490deaca5993fef1b05a" + url: "https://pub.dev" + source: hosted + version: "0.6.11" + tonik_util: + dependency: "direct main" + description: + name: tonik_util + sha256: f16c86d5349fac40893d1d8ae87f6507192d8886f16ba6acefeabef4a61ae3ef + url: "https://pub.dev" + source: hosted + version: "0.0.5" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + very_good_analysis: + dependency: "direct dev" + description: + name: very_good_analysis + sha256: "62d2b86d183fb81b2edc22913d9f155d26eb5cf3855173adb1f59fac85035c63" + url: "https://pub.dev" + source: hosted + version: "7.0.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "6f82e9ee8e7339f5d8b699317f6f3afc17c80a68ebef1bc0d6f52a678c14b1e6" + url: "https://pub.dev" + source: hosted + version: "15.0.1" + watcher: + dependency: transitive + description: + name: watcher + sha256: "69da27e49efa56a15f8afe8f4438c4ec02eff0a117df1b22ea4aad194fe1c104" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 + url: "https://pub.dev" + source: hosted + version: "3.0.3" + webkit_inspection_protocol: + dependency: transitive + description: + name: webkit_inspection_protocol + sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" +sdks: + dart: ">=3.8.0 <4.0.0" diff --git a/integration_test/music_streaming/music_streaming_test/pubspec.yaml b/integration_test/music_streaming/music_streaming_test/pubspec.yaml new file mode 100644 index 0000000..b7b61fb --- /dev/null +++ b/integration_test/music_streaming/music_streaming_test/pubspec.yaml @@ -0,0 +1,18 @@ +name: music_streaming_test +description: Integration tests for the music streaming API +version: 1.0.0 +publish_to: none + +environment: + sdk: ^3.8.0 + +dependencies: + dio: ^5.8.0 + music_streaming_api: + path: ../music_streaming_api + path: ^1.8.3 + tonik_util: ^0.0.2 + +dev_dependencies: + test: ^1.24.0 + very_good_analysis: ^7.0.0 diff --git a/integration_test/music_streaming/music_streaming_test/test/albums_test.dart b/integration_test/music_streaming/music_streaming_test/test/albums_test.dart new file mode 100644 index 0000000..190105f --- /dev/null +++ b/integration_test/music_streaming/music_streaming_test/test/albums_test.dart @@ -0,0 +1,193 @@ +import 'package:dio/dio.dart'; +import 'package:music_streaming_api/music_streaming_api.dart'; +import 'package:test/test.dart'; +import 'package:tonik_util/tonik_util.dart'; + +import 'test_helper.dart'; + +void main() { + const port = 8080; + const baseUrl = 'http://localhost:$port/v1'; + + late ImposterServer imposterServer; + + setUpAll(() async { + imposterServer = ImposterServer(port: port); + await setupImposterServer(imposterServer); + }); + + AlbumsApi buildAlbumsApi({required String responseStatus}) { + return AlbumsApi( + CustomServer( + baseUrl: baseUrl, + serverConfig: ServerConfig( + baseOptions: BaseOptions( + headers: {'X-Response-Status': responseStatus}, + ), + ), + ), + ); + } + + group('getAnAlbum', () { + test('200', () async { + final albumsApi = buildAlbumsApi(responseStatus: '200'); + + final response = await albumsApi.getAnAlbum(id: '123'); + + expect(response, isA>()); + final success = response as TonikSuccess; + expect(success.response.statusCode, 200); + expect(success.value, isA()); + + final value = success.value as GetAnAlbumResponse200; + expect(value.body, isA()); + final albumBase = value.body.albumBase; + + expect(albumBase, isA()); + expect(albumBase.albumType, isA()); + expect(AlbumBaseAlbumType.values.map((v) => v.rawValue), [ + 'album', + 'single', + 'compilation', + ]); + expect(albumBase.totalTracks, isA()); + expect(albumBase.availableMarkets, isA>()); + expect(albumBase.externalUrls, isA()); + + expect(albumBase.href, isA()); + expect(albumBase.id, isA()); + expect(albumBase.images, isA>()); + expect(albumBase.name, isA()); + expect(albumBase.releaseDate, isA()); + expect( + albumBase.releaseDatePrecision, + isA(), + ); + expect(AlbumBaseReleaseDatePrecision.values.map((v) => v.rawValue), [ + 'year', + 'month', + 'day', + ]); + expect(albumBase.restrictions, isA()); + expect(albumBase.$type, isA()); + expect(AlbumBaseType.values.map((v) => v.rawValue), ['album']); + expect(albumBase.uri, isA()); + + final externalUrls = albumBase.externalUrls.externalUrlObject; + expect(externalUrls.spotify, isA()); + + final image = albumBase.images.first; + expect(image.url, isA()); + expect(image.height, isA()); + expect(image.width, isA()); + + final releaseDatePrecision = + albumBase.restrictions?.albumRestrictionObject; + expect(releaseDatePrecision?.reason, isA()); + expect(AlbumRestrictionObjectReason.values.map((v) => v.rawValue), [ + 'market', + 'product', + 'explicit', + ]); + + final albumObject = value.body.albumObjectModel; + expect(albumObject, isA()); + + // Note: api document intends to have properties of + // [SimplifiedArtistObject] to be non-nullable, but + // required is defined on wrong level and will get + // ingnored by tonic. + expect(albumObject.artists, isA?>()); + final artist = albumObject.artists?.first; + expect(artist?.externalUrls, isA()); + expect(artist?.externalUrls?.externalUrlObject, isA()); + expect(artist?.externalUrls?.externalUrlObject.spotify, isA()); + expect(artist?.href, isA()); + expect(artist?.id, isA()); + expect(artist?.name, isA()); + expect(artist?.$type, isA()); + expect(SimplifiedArtistObjectType.values.map((v) => v.rawValue), [ + 'artist', + ]); + expect(artist?.uri, isA()); + + expect(albumObject.tracks, isA()); + expect( + albumObject.tracks?.pagingSimplifiedTrackObject, + isA(), + ); + final track = albumObject.tracks?.pagingSimplifiedTrackObject; + expect(track?.pagingObject, isA()); + expect(track?.pagingObject.href, isA()); + expect(track?.pagingObject.limit, isA()); + expect(track?.pagingObject.next, isA()); + expect(track?.pagingObject.offset, isA()); + expect(track?.pagingObject.previous, isA()); + expect(track?.pagingObject.total, isA()); + + expect( + track?.pagingSimplifiedTrackObjectModel, + isA(), + ); + expect( + track?.pagingSimplifiedTrackObjectModel.items, + isA>(), + ); + final trackItem = track?.pagingSimplifiedTrackObjectModel.items?.first; + expect(trackItem?.artists, isA?>()); + expect(trackItem?.availableMarkets, isA?>()); + expect(trackItem?.discNumber, isA()); + expect(trackItem?.durationMs, isA()); + expect(trackItem?.explicit, isA()); + expect( + trackItem?.externalUrls, + isA(), + ); + expect( + trackItem?.externalUrls?.externalUrlObject, + isA(), + ); + expect( + trackItem?.externalUrls?.externalUrlObject.spotify, + isA(), + ); + expect(trackItem?.href, isA()); + expect(trackItem?.id, isA()); + expect(trackItem?.isPlayable, isA()); + expect(trackItem?.linkedFrom, isA()); + expect( + trackItem?.linkedFrom?.linkedTrackObject.externalUrls, + isA(), + ); + expect(trackItem?.linkedFrom?.linkedTrackObject.href, isA()); + expect(trackItem?.linkedFrom?.linkedTrackObject.id, isA()); + expect(trackItem?.linkedFrom?.linkedTrackObject.$type, isA()); + expect(trackItem?.linkedFrom?.linkedTrackObject.uri, isA()); + expect( + trackItem?.restrictions, + isA(), + ); + expect( + trackItem?.restrictions?.trackRestrictionObject.reason, + isA(), + ); + expect(trackItem?.name, isA()); + expect(trackItem?.previewUrl, isA()); + expect(trackItem?.trackNumber, isA()); + expect(trackItem?.$type, isA()); + expect(trackItem?.uri, isA()); + expect(trackItem?.isLocal, isA()); + + expect(albumObject.copyrights, isA?>()); + final copyright = albumObject.copyrights?.first; + expect(copyright?.text, isA()); + expect(copyright?.$type, isA()); + + expect(albumObject.externalIds, isA()); + expect(albumObject.genres, isA?>()); + expect(albumObject.label, isA()); + expect(albumObject.popularity, isA()); + }); + }); +} diff --git a/integration_test/music_streaming/music_streaming_test/test/test_helper.dart b/integration_test/music_streaming/music_streaming_test/test/test_helper.dart new file mode 100644 index 0000000..5af0ef0 --- /dev/null +++ b/integration_test/music_streaming/music_streaming_test/test/test_helper.dart @@ -0,0 +1,84 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:path/path.dart' as path; +import 'package:test/test.dart'; + +class ImposterServer { + ImposterServer({required this.port}); + + Process? _process; + final int port; + + Future start() async { + final imposterJar = path.join( + Directory.current.parent.parent.path, + 'imposter.jar', + ); + + if (!File(imposterJar).existsSync()) { + throw Exception( + 'Imposter JAR not found at $imposterJar. Please download it first.', + ); + } + + _process = await Process.start('java', [ + '-jar', + imposterJar, + '--listenPort', + port.toString(), + '--configDir', + path.join(Directory.current.path, 'imposter'), + '--plugin', + 'openapi', + '--plugin', + 'rest', + ], + environment: { + ...Platform.environment, + 'IMPOSTER_LOG_LEVEL': 'DEBUG', + }); + + _process!.stdout.transform(const Utf8Decoder()).listen((data) { + print('Imposter stdout: $data'); + }); + _process!.stderr.transform(const Utf8Decoder()).listen((data) { + print('Imposter stderr: $data'); + }); + + await _waitForImposterReady(); + } + + Future _waitForImposterReady({int timeoutSec = 10}) async { + final deadline = DateTime.now().add(Duration(seconds: timeoutSec)); + final client = HttpClient(); + + while (DateTime.now().isBefore(deadline)) { + try { + final request = await client.getUrl( + Uri.parse('http://localhost:$port'), + ); + await request.close(); + + return true; // No exception means the server is ready. + } on SocketException catch (_) { + // ignore + } + await Future.delayed(const Duration(milliseconds: 300)); + } + return false; + } + + Future stop() async { + if (_process != null) { + _process!.kill(); + await _process!.exitCode; + _process = null; + } + } +} + +Future setupImposterServer(ImposterServer server) async { + await server.start(); + addTearDown(() => server.stop()); +} From 96e85d08d837871e3161044e9deb25cc2b5e5790 Mon Sep 17 00:00:00 2001 From: Tobias Ottenweller Date: Mon, 9 Jun 2025 12:54:10 +0200 Subject: [PATCH 14/37] test: add get album error response tests --- .../imposter/imposter-config.json | 5 +- .../imposter/response.groovy | 7 +++ .../test/albums_test.dart | 51 +++++++++++++++++++ 3 files changed, 62 insertions(+), 1 deletion(-) create mode 100644 integration_test/music_streaming/music_streaming_test/imposter/response.groovy diff --git a/integration_test/music_streaming/music_streaming_test/imposter/imposter-config.json b/integration_test/music_streaming/music_streaming_test/imposter/imposter-config.json index ad98044..a2480b3 100644 --- a/integration_test/music_streaming/music_streaming_test/imposter/imposter-config.json +++ b/integration_test/music_streaming/music_streaming_test/imposter/imposter-config.json @@ -1,4 +1,7 @@ { "plugin": "openapi", - "specFile": "../../openapi.yaml" + "specFile": "../../openapi.yaml", + "response": { + "scriptFile": "response.groovy" + } } \ No newline at end of file diff --git a/integration_test/music_streaming/music_streaming_test/imposter/response.groovy b/integration_test/music_streaming/music_streaming_test/imposter/response.groovy new file mode 100644 index 0000000..65422ea --- /dev/null +++ b/integration_test/music_streaming/music_streaming_test/imposter/response.groovy @@ -0,0 +1,7 @@ +// Get the response status from the request header +def responseStatus = context.request.headers['X-Response-Status'] ?: '200' + +// Set the response status code and use the OpenAPI specification +respond() + .withStatusCode(Integer.parseInt(responseStatus)) + .usingDefaultBehaviour() diff --git a/integration_test/music_streaming/music_streaming_test/test/albums_test.dart b/integration_test/music_streaming/music_streaming_test/test/albums_test.dart index 190105f..d410e37 100644 --- a/integration_test/music_streaming/music_streaming_test/test/albums_test.dart +++ b/integration_test/music_streaming/music_streaming_test/test/albums_test.dart @@ -189,5 +189,56 @@ void main() { expect(albumObject.label, isA()); expect(albumObject.popularity, isA()); }); + + test('401', () async { + final albumsApi = buildAlbumsApi(responseStatus: '401'); + + final response = await albumsApi.getAnAlbum(id: 'abc', market: 'en'); + + expect(response, isA>()); + final success = response as TonikSuccess; + expect(success.response.statusCode, 401); + expect(success.value, isA()); + + final value = success.value as GetAnAlbumResponse401; + expect(value.body, isA()); + expect(value.body.error, isA()); + expect(value.body.error.status, isA()); + expect(value.body.error.message, isA()); + }); + + test('403', () async { + final albumsApi = buildAlbumsApi(responseStatus: '403'); + + final response = await albumsApi.getAnAlbum(id: 'abc', market: 'en'); + + expect(response, isA>()); + final success = response as TonikSuccess; + expect(success.response.statusCode, 403); + expect(success.value, isA()); + + final value = success.value as GetAnAlbumResponse403; + expect(value.body, isA()); + expect(value.body.error, isA()); + expect(value.body.error.status, isA()); + expect(value.body.error.message, isA()); + }); + + test('429', () async { + final albumsApi = buildAlbumsApi(responseStatus: '429'); + + final response = await albumsApi.getAnAlbum(id: 'abc', market: 'en'); + + expect(response, isA>()); + final success = response as TonikSuccess; + expect(success.response.statusCode, 429); + expect(success.value, isA()); + + final value = success.value as GetAnAlbumResponse429; + expect(value.body, isA()); + expect(value.body.error, isA()); + expect(value.body.error.status, isA()); + expect(value.body.error.message, isA()); + }); }); } From cc516881e2d659bd119c41060a620d3631378e95 Mon Sep 17 00:00:00 2001 From: Tobias Ottenweller Date: Sun, 15 Jun 2025 14:44:48 +0200 Subject: [PATCH 15/37] chore: update very_good_analysis --- .gitignore | 1 + .../music_streaming_test/pubspec.lock | 4 +- .../music_streaming_test/pubspec.yaml | 2 +- .../petstore/petstore_test/pubspec.yaml | 2 +- packages/tonik/pubspec.yaml | 1 - packages/tonik_core/pubspec.yaml | 1 - packages/tonik_generate/pubspec.yaml | 1 - packages/tonik_parse/pubspec.yaml | 1 - packages/tonik_util/pubspec.yaml | 1 - pubspec.lock | 725 ------------------ pubspec.yaml | 2 +- 11 files changed, 6 insertions(+), 735 deletions(-) delete mode 100644 pubspec.lock diff --git a/.gitignore b/.gitignore index 6a07362..e9fabb6 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ /integration_test/petstore/petstore_api /integration_test/imposter.jar /integration_test/music_streaming/music_streaming_api +/pubspec.lock diff --git a/integration_test/music_streaming/music_streaming_test/pubspec.lock b/integration_test/music_streaming/music_streaming_test/pubspec.lock index f7a4c2d..0c8693c 100644 --- a/integration_test/music_streaming/music_streaming_test/pubspec.lock +++ b/integration_test/music_streaming/music_streaming_test/pubspec.lock @@ -404,10 +404,10 @@ packages: dependency: "direct dev" description: name: very_good_analysis - sha256: "62d2b86d183fb81b2edc22913d9f155d26eb5cf3855173adb1f59fac85035c63" + sha256: e479fbc0941009262343db308133e121bf8660c2c81d48dd8e952df7b7e1e382 url: "https://pub.dev" source: hosted - version: "7.0.0" + version: "9.0.0" vm_service: dependency: transitive description: diff --git a/integration_test/music_streaming/music_streaming_test/pubspec.yaml b/integration_test/music_streaming/music_streaming_test/pubspec.yaml index b7b61fb..27ed7d8 100644 --- a/integration_test/music_streaming/music_streaming_test/pubspec.yaml +++ b/integration_test/music_streaming/music_streaming_test/pubspec.yaml @@ -15,4 +15,4 @@ dependencies: dev_dependencies: test: ^1.24.0 - very_good_analysis: ^7.0.0 + very_good_analysis: ^9.0.0 diff --git a/integration_test/petstore/petstore_test/pubspec.yaml b/integration_test/petstore/petstore_test/pubspec.yaml index 047addc..6e483f5 100644 --- a/integration_test/petstore/petstore_test/pubspec.yaml +++ b/integration_test/petstore/petstore_test/pubspec.yaml @@ -16,5 +16,5 @@ dependencies: dev_dependencies: test: ^1.24.0 - very_good_analysis: ^7.0.0 + very_good_analysis: ^9.0.0 diff --git a/packages/tonik/pubspec.yaml b/packages/tonik/pubspec.yaml index 07d446f..858d73c 100644 --- a/packages/tonik/pubspec.yaml +++ b/packages/tonik/pubspec.yaml @@ -28,4 +28,3 @@ dependencies: dev_dependencies: path: ^1.9.1 test: ^1.24.0 - very_good_analysis: ^8.0.0 diff --git a/packages/tonik_core/pubspec.yaml b/packages/tonik_core/pubspec.yaml index 694bcfb..d7939b9 100644 --- a/packages/tonik_core/pubspec.yaml +++ b/packages/tonik_core/pubspec.yaml @@ -14,5 +14,4 @@ dependencies: dev_dependencies: build_runner: ^2.4.15 test: ^1.24.0 - very_good_analysis: ^8.0.0 diff --git a/packages/tonik_generate/pubspec.yaml b/packages/tonik_generate/pubspec.yaml index 0ab9a31..aadd59d 100644 --- a/packages/tonik_generate/pubspec.yaml +++ b/packages/tonik_generate/pubspec.yaml @@ -20,4 +20,3 @@ dependencies: dev_dependencies: test: ^1.24.0 - very_good_analysis: ^8.0.0 diff --git a/packages/tonik_parse/pubspec.yaml b/packages/tonik_parse/pubspec.yaml index 6f1e610..dec966c 100644 --- a/packages/tonik_parse/pubspec.yaml +++ b/packages/tonik_parse/pubspec.yaml @@ -17,4 +17,3 @@ dev_dependencies: build_runner: ^2.3.3 json_serializable: ^6.8.0 test: ^1.24.0 - very_good_analysis: ^8.0.0 diff --git a/packages/tonik_util/pubspec.yaml b/packages/tonik_util/pubspec.yaml index 00b6106..babc13d 100644 --- a/packages/tonik_util/pubspec.yaml +++ b/packages/tonik_util/pubspec.yaml @@ -15,4 +15,3 @@ dependencies: dev_dependencies: test: ^1.24.0 - very_good_analysis: ^8.0.0 diff --git a/pubspec.lock b/pubspec.lock deleted file mode 100644 index 1b7df99..0000000 --- a/pubspec.lock +++ /dev/null @@ -1,725 +0,0 @@ -# Generated by pub -# See https://dart.dev/tools/pub/glossary#lockfile -packages: - _fe_analyzer_shared: - dependency: transitive - description: - name: _fe_analyzer_shared - sha256: e55636ed79578b9abca5fecf9437947798f5ef7456308b5cb85720b793eac92f - url: "https://pub.dev" - source: hosted - version: "82.0.0" - analyzer: - dependency: transitive - description: - name: analyzer - sha256: "904ae5bb474d32c38fb9482e2d925d5454cda04ddd0e55d2e6826bc72f6ba8c0" - url: "https://pub.dev" - source: hosted - version: "7.4.5" - ansi_styles: - dependency: transitive - description: - name: ansi_styles - sha256: "9c656cc12b3c27b17dd982b2cc5c0cfdfbdabd7bc8f3ae5e8542d9867b47ce8a" - url: "https://pub.dev" - source: hosted - version: "0.3.2+1" - args: - dependency: transitive - description: - name: args - sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 - url: "https://pub.dev" - source: hosted - version: "2.7.0" - async: - dependency: transitive - description: - name: async - sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" - url: "https://pub.dev" - source: hosted - version: "2.13.0" - big_decimal: - dependency: transitive - description: - name: big_decimal - sha256: "301158ec5a646d1e1a0ca7a97fbfab7be18a8df700adb7f7cb9c4149e75c8f0c" - url: "https://pub.dev" - source: hosted - version: "0.5.0" - boolean_selector: - dependency: transitive - description: - name: boolean_selector - sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" - url: "https://pub.dev" - source: hosted - version: "2.1.2" - build: - dependency: transitive - description: - name: build - sha256: cef23f1eda9b57566c81e2133d196f8e3df48f244b317368d65c5943d91148f0 - url: "https://pub.dev" - source: hosted - version: "2.4.2" - build_config: - dependency: transitive - description: - name: build_config - sha256: "4ae2de3e1e67ea270081eaee972e1bd8f027d459f249e0f1186730784c2e7e33" - url: "https://pub.dev" - source: hosted - version: "1.1.2" - build_daemon: - dependency: transitive - description: - name: build_daemon - sha256: "8e928697a82be082206edb0b9c99c5a4ad6bc31c9e9b8b2f291ae65cd4a25daa" - url: "https://pub.dev" - source: hosted - version: "4.0.4" - build_resolvers: - dependency: transitive - description: - name: build_resolvers - sha256: b9e4fda21d846e192628e7a4f6deda6888c36b5b69ba02ff291a01fd529140f0 - url: "https://pub.dev" - source: hosted - version: "2.4.4" - build_runner: - dependency: transitive - description: - name: build_runner - sha256: "058fe9dce1de7d69c4b84fada934df3e0153dd000758c4d65964d0166779aa99" - url: "https://pub.dev" - source: hosted - version: "2.4.15" - build_runner_core: - dependency: transitive - description: - name: build_runner_core - sha256: "22e3aa1c80e0ada3722fe5b63fd43d9c8990759d0a2cf489c8c5d7b2bdebc021" - url: "https://pub.dev" - source: hosted - version: "8.0.0" - built_collection: - dependency: transitive - description: - name: built_collection - sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100" - url: "https://pub.dev" - source: hosted - version: "5.1.1" - built_value: - dependency: transitive - description: - name: built_value - sha256: "082001b5c3dc495d4a42f1d5789990505df20d8547d42507c29050af6933ee27" - url: "https://pub.dev" - source: hosted - version: "8.10.1" - change_case: - dependency: transitive - description: - name: change_case - sha256: e41ef3df58521194ef8d7649928954805aeb08061917cf658322305e61568003 - url: "https://pub.dev" - source: hosted - version: "2.2.0" - charcode: - dependency: transitive - description: - name: charcode - sha256: fb0f1107cac15a5ea6ef0a6ef71a807b9e4267c713bb93e00e92d737cc8dbd8a - url: "https://pub.dev" - source: hosted - version: "1.4.0" - checked_yaml: - dependency: transitive - description: - name: checked_yaml - sha256: feb6bed21949061731a7a75fc5d2aa727cf160b91af9a3e464c5e3a32e28b5ff - url: "https://pub.dev" - source: hosted - version: "2.0.3" - cli_config: - dependency: transitive - description: - name: cli_config - sha256: ac20a183a07002b700f0c25e61b7ee46b23c309d76ab7b7640a028f18e4d99ec - url: "https://pub.dev" - source: hosted - version: "0.2.0" - cli_launcher: - dependency: transitive - description: - name: cli_launcher - sha256: "5e7e0282b79e8642edd6510ee468ae2976d847a0a29b3916e85f5fa1bfe24005" - url: "https://pub.dev" - source: hosted - version: "0.3.1" - cli_util: - dependency: transitive - description: - name: cli_util - sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c - url: "https://pub.dev" - source: hosted - version: "0.4.2" - clock: - dependency: transitive - description: - name: clock - sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b - url: "https://pub.dev" - source: hosted - version: "1.1.2" - code_builder: - dependency: transitive - description: - name: code_builder - sha256: "0ec10bf4a89e4c613960bf1e8b42c64127021740fb21640c29c909826a5eea3e" - url: "https://pub.dev" - source: hosted - version: "4.10.1" - collection: - dependency: "direct main" - description: - name: collection - sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" - url: "https://pub.dev" - source: hosted - version: "1.19.1" - conventional_commit: - dependency: transitive - description: - name: conventional_commit - sha256: c40b1b449ce2a63fa2ce852f35e3890b1e182f5951819934c0e4a66254bc0dc3 - url: "https://pub.dev" - source: hosted - version: "0.6.1+1" - convert: - dependency: transitive - description: - name: convert - sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 - url: "https://pub.dev" - source: hosted - version: "3.1.2" - coverage: - dependency: transitive - description: - name: coverage - sha256: "4b8701e48a58f7712492c9b1f7ba0bb9d525644dd66d023b62e1fc8cdb560c8a" - url: "https://pub.dev" - source: hosted - version: "1.14.0" - crypto: - dependency: transitive - description: - name: crypto - sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855" - url: "https://pub.dev" - source: hosted - version: "3.0.6" - dart_style: - dependency: "direct main" - description: - name: dart_style - sha256: "5b236382b47ee411741447c1f1e111459c941ea1b3f2b540dde54c210a3662af" - url: "https://pub.dev" - source: hosted - version: "3.1.0" - dio: - dependency: transitive - description: - name: dio - sha256: "253a18bbd4851fecba42f7343a1df3a9a4c1d31a2c1b37e221086b4fa8c8dbc9" - url: "https://pub.dev" - source: hosted - version: "5.8.0+1" - dio_web_adapter: - dependency: transitive - description: - name: dio_web_adapter - sha256: "7586e476d70caecaf1686d21eee7247ea43ef5c345eab9e0cc3583ff13378d78" - url: "https://pub.dev" - source: hosted - version: "2.1.1" - file: - dependency: transitive - description: - name: file - sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 - url: "https://pub.dev" - source: hosted - version: "7.0.1" - fixnum: - dependency: transitive - description: - name: fixnum - sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be - url: "https://pub.dev" - source: hosted - version: "1.1.1" - frontend_server_client: - dependency: transitive - description: - name: frontend_server_client - sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 - url: "https://pub.dev" - source: hosted - version: "4.0.0" - glob: - dependency: transitive - description: - name: glob - sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de - url: "https://pub.dev" - source: hosted - version: "2.1.3" - graphs: - dependency: transitive - description: - name: graphs - sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0" - url: "https://pub.dev" - source: hosted - version: "2.3.2" - http: - dependency: transitive - description: - name: http - sha256: "2c11f3f94c687ee9bad77c171151672986360b2b001d109814ee7140b2cf261b" - url: "https://pub.dev" - source: hosted - version: "1.4.0" - http_multi_server: - dependency: transitive - description: - name: http_multi_server - sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 - url: "https://pub.dev" - source: hosted - version: "3.2.2" - http_parser: - dependency: transitive - description: - name: http_parser - sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" - url: "https://pub.dev" - source: hosted - version: "4.1.2" - intl: - dependency: transitive - description: - name: intl - sha256: "910f85bce16fb5c6f614e117efa303e85a1731bb0081edf3604a2ae6e9a3cc91" - url: "https://pub.dev" - source: hosted - version: "0.17.0" - io: - dependency: transitive - description: - name: io - sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b - url: "https://pub.dev" - source: hosted - version: "1.0.5" - js: - dependency: transitive - description: - name: js - sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc" - url: "https://pub.dev" - source: hosted - version: "0.7.2" - json_annotation: - dependency: transitive - description: - name: json_annotation - sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" - url: "https://pub.dev" - source: hosted - version: "4.9.0" - json_serializable: - dependency: transitive - description: - name: json_serializable - sha256: c50ef5fc083d5b5e12eef489503ba3bf5ccc899e487d691584699b4bdefeea8c - url: "https://pub.dev" - source: hosted - version: "6.9.5" - logging: - dependency: "direct main" - description: - name: logging - sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 - url: "https://pub.dev" - source: hosted - version: "1.3.0" - matcher: - dependency: transitive - description: - name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 - url: "https://pub.dev" - source: hosted - version: "0.12.17" - melos: - dependency: "direct dev" - description: - name: melos - sha256: "51e7902a164d7563cf1b1de04272eb4348a551c1e7885875353e82e8928c90e0" - url: "https://pub.dev" - source: hosted - version: "7.0.0-dev.9" - meta: - dependency: "direct main" - description: - name: meta - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" - url: "https://pub.dev" - source: hosted - version: "1.17.0" - mime: - dependency: transitive - description: - name: mime - sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" - url: "https://pub.dev" - source: hosted - version: "2.0.0" - mustache_template: - dependency: transitive - description: - name: mustache_template - sha256: a46e26f91445bfb0b60519be280555b06792460b27b19e2b19ad5b9740df5d1c - url: "https://pub.dev" - source: hosted - version: "2.0.0" - node_preamble: - dependency: transitive - description: - name: node_preamble - sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db" - url: "https://pub.dev" - source: hosted - version: "2.0.2" - package_config: - dependency: transitive - description: - name: package_config - sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc - url: "https://pub.dev" - source: hosted - version: "2.2.0" - path: - dependency: "direct main" - description: - name: path - sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" - url: "https://pub.dev" - source: hosted - version: "1.9.1" - platform: - dependency: transitive - description: - name: platform - sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" - url: "https://pub.dev" - source: hosted - version: "3.1.6" - pool: - dependency: transitive - description: - name: pool - sha256: "20fe868b6314b322ea036ba325e6fc0711a22948856475e2c2b6306e8ab39c2a" - url: "https://pub.dev" - source: hosted - version: "1.5.1" - process: - dependency: transitive - description: - name: process - sha256: "44b4226c0afd4bc3b7c7e67d44c4801abd97103cf0c84609e2654b664ca2798c" - url: "https://pub.dev" - source: hosted - version: "5.0.4" - prompts: - dependency: transitive - description: - name: prompts - sha256: "3773b845e85a849f01e793c4fc18a45d52d7783b4cb6c0569fad19f9d0a774a1" - url: "https://pub.dev" - source: hosted - version: "2.0.0" - pub_semver: - dependency: transitive - description: - name: pub_semver - sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" - url: "https://pub.dev" - source: hosted - version: "2.2.0" - pub_updater: - dependency: transitive - description: - name: pub_updater - sha256: "739a0161d73a6974c0675b864fb0cf5147305f7b077b7f03a58fa7a9ab3e7e7d" - url: "https://pub.dev" - source: hosted - version: "0.5.0" - pubspec_parse: - dependency: transitive - description: - name: pubspec_parse - sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082" - url: "https://pub.dev" - source: hosted - version: "1.5.0" - shelf: - dependency: transitive - description: - name: shelf - sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 - url: "https://pub.dev" - source: hosted - version: "1.4.2" - shelf_packages_handler: - dependency: transitive - description: - name: shelf_packages_handler - sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e" - url: "https://pub.dev" - source: hosted - version: "3.0.2" - shelf_static: - dependency: transitive - description: - name: shelf_static - sha256: c87c3875f91262785dade62d135760c2c69cb217ac759485334c5857ad89f6e3 - url: "https://pub.dev" - source: hosted - version: "1.1.3" - shelf_web_socket: - dependency: transitive - description: - name: shelf_web_socket - sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" - url: "https://pub.dev" - source: hosted - version: "3.0.0" - source_gen: - dependency: transitive - description: - name: source_gen - sha256: "35c8150ece9e8c8d263337a265153c3329667640850b9304861faea59fc98f6b" - url: "https://pub.dev" - source: hosted - version: "2.0.0" - source_helper: - dependency: transitive - description: - name: source_helper - sha256: "86d247119aedce8e63f4751bd9626fc9613255935558447569ad42f9f5b48b3c" - url: "https://pub.dev" - source: hosted - version: "1.3.5" - source_map_stack_trace: - dependency: transitive - description: - name: source_map_stack_trace - sha256: c0713a43e323c3302c2abe2a1cc89aa057a387101ebd280371d6a6c9fa68516b - url: "https://pub.dev" - source: hosted - version: "2.1.2" - source_maps: - dependency: transitive - description: - name: source_maps - sha256: "190222579a448b03896e0ca6eca5998fa810fda630c1d65e2f78b3f638f54812" - url: "https://pub.dev" - source: hosted - version: "0.10.13" - source_span: - dependency: transitive - description: - name: source_span - sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" - url: "https://pub.dev" - source: hosted - version: "1.10.1" - spell_out_numbers: - dependency: "direct main" - description: - name: spell_out_numbers - sha256: "7ac80a4e306002526490654f32c4e26f4d97e110cb11dd7b5bd4dfe627452f57" - url: "https://pub.dev" - source: hosted - version: "1.0.0" - stack_trace: - dependency: transitive - description: - name: stack_trace - sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" - url: "https://pub.dev" - source: hosted - version: "1.12.1" - stream_channel: - dependency: transitive - description: - name: stream_channel - sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" - url: "https://pub.dev" - source: hosted - version: "2.1.4" - stream_transform: - dependency: transitive - description: - name: stream_transform - sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 - url: "https://pub.dev" - source: hosted - version: "2.1.1" - string_scanner: - dependency: transitive - description: - name: string_scanner - sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" - url: "https://pub.dev" - source: hosted - version: "1.4.1" - term_glyph: - dependency: transitive - description: - name: term_glyph - sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" - url: "https://pub.dev" - source: hosted - version: "1.2.2" - test: - dependency: "direct dev" - description: - name: test - sha256: "65e29d831719be0591f7b3b1a32a3cda258ec98c58c7b25f7b84241bc31215bb" - url: "https://pub.dev" - source: hosted - version: "1.26.2" - test_api: - dependency: transitive - description: - name: test_api - sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" - url: "https://pub.dev" - source: hosted - version: "0.7.6" - test_core: - dependency: transitive - description: - name: test_core - sha256: "80bf5a02b60af04b09e14f6fe68b921aad119493e26e490deaca5993fef1b05a" - url: "https://pub.dev" - source: hosted - version: "0.6.11" - timing: - dependency: transitive - description: - name: timing - sha256: "62ee18aca144e4a9f29d212f5a4c6a053be252b895ab14b5821996cff4ed90fe" - url: "https://pub.dev" - source: hosted - version: "1.0.2" - tuple: - dependency: transitive - description: - name: tuple - sha256: a97ce2013f240b2f3807bcbaf218765b6f301c3eff91092bcfa23a039e7dd151 - url: "https://pub.dev" - source: hosted - version: "2.0.2" - typed_data: - dependency: transitive - description: - name: typed_data - sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 - url: "https://pub.dev" - source: hosted - version: "1.4.0" - very_good_analysis: - dependency: "direct dev" - description: - name: very_good_analysis - sha256: c529563be4cbba1137386f2720fb7ed69e942012a28b13398d8a5e3e6ef551a7 - url: "https://pub.dev" - source: hosted - version: "8.0.0" - vm_service: - dependency: transitive - description: - name: vm_service - sha256: "6f82e9ee8e7339f5d8b699317f6f3afc17c80a68ebef1bc0d6f52a678c14b1e6" - url: "https://pub.dev" - source: hosted - version: "15.0.1" - watcher: - dependency: transitive - description: - name: watcher - sha256: "69da27e49efa56a15f8afe8f4438c4ec02eff0a117df1b22ea4aad194fe1c104" - url: "https://pub.dev" - source: hosted - version: "1.1.1" - web: - dependency: transitive - description: - name: web - sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" - url: "https://pub.dev" - source: hosted - version: "1.1.1" - web_socket: - dependency: transitive - description: - name: web_socket - sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" - url: "https://pub.dev" - source: hosted - version: "1.0.1" - web_socket_channel: - dependency: transitive - description: - name: web_socket_channel - sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 - url: "https://pub.dev" - source: hosted - version: "3.0.3" - webkit_inspection_protocol: - dependency: transitive - description: - name: webkit_inspection_protocol - sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572" - url: "https://pub.dev" - source: hosted - version: "1.2.1" - yaml: - dependency: transitive - description: - name: yaml - sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce - url: "https://pub.dev" - source: hosted - version: "3.1.3" - yaml_edit: - dependency: transitive - description: - name: yaml_edit - sha256: fb38626579fb345ad00e674e2af3a5c9b0cc4b9bfb8fd7f7ff322c7c9e62aef5 - url: "https://pub.dev" - source: hosted - version: "2.2.2" -sdks: - dart: ">=3.8.0 <4.0.0" diff --git a/pubspec.yaml b/pubspec.yaml index 403f4d7..9d59be4 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -26,7 +26,7 @@ dependencies: dev_dependencies: melos: ^7.0.0-dev.9 test: ^1.25.15 - very_good_analysis: ^8.0.0 + very_good_analysis: ^9.0.0 melos: scripts: From 7138035d044d27fb4325e1c55a6e186d0c677faf Mon Sep 17 00:00:00 2001 From: Tobias Ottenweller Date: Sun, 15 Jun 2025 17:06:16 +0200 Subject: [PATCH 16/37] chore: format code --- .../test/albums_test.dart | 2 +- .../test/test_helper.dart | 37 ++++---- .../petstore_test/test/store_test.dart | 2 +- .../petstore_test/test/test_helper.dart | 37 ++++---- packages/tonik/bin/tonik.dart | 3 +- .../tonik/test/src/openapi_loader_test.dart | 12 +-- packages/tonik_core/lib/src/model/model.dart | 6 +- .../api_client/api_client_file_generator.dart | 4 +- .../lib/src/model/one_of_generator.dart | 5 +- .../lib/src/operation/parse_generator.dart | 37 ++++---- .../response_wrapper_generator.dart | 2 +- .../lib/src/server/server_file_generator.dart | 2 +- .../lib/src/util/doc_comment_formatter.dart | 12 +-- .../src/util/exception_code_generator.dart | 2 - .../util/response_property_normalizer.dart | 1 - .../lib/src/util/response_type_generator.dart | 4 +- .../api_client_file_generator_test.dart | 2 +- .../model/class_simple_generator_test.dart | 4 +- .../test/src/naming/name_generator_test.dart | 8 +- .../operation_generator_response_test.dart | 84 +++++++++---------- .../server/server_file_generator_test.dart | 18 ++-- .../src/server/server_generator_test.dart | 8 +- .../src/util/doc_comment_formatter_test.dart | 20 ++--- ..._json_value_expression_generator_test.dart | 17 ++-- ...imple_value_expression_generator_test.dart | 2 +- .../tonik_util/lib/src/dio/server_config.dart | 6 +- 26 files changed, 174 insertions(+), 163 deletions(-) diff --git a/integration_test/music_streaming/music_streaming_test/test/albums_test.dart b/integration_test/music_streaming/music_streaming_test/test/albums_test.dart index d410e37..9a7c481 100644 --- a/integration_test/music_streaming/music_streaming_test/test/albums_test.dart +++ b/integration_test/music_streaming/music_streaming_test/test/albums_test.dart @@ -189,7 +189,7 @@ void main() { expect(albumObject.label, isA()); expect(albumObject.popularity, isA()); }); - + test('401', () async { final albumsApi = buildAlbumsApi(responseStatus: '401'); diff --git a/integration_test/music_streaming/music_streaming_test/test/test_helper.dart b/integration_test/music_streaming/music_streaming_test/test/test_helper.dart index 5af0ef0..6347edb 100644 --- a/integration_test/music_streaming/music_streaming_test/test/test_helper.dart +++ b/integration_test/music_streaming/music_streaming_test/test/test_helper.dart @@ -6,7 +6,7 @@ import 'package:test/test.dart'; class ImposterServer { ImposterServer({required this.port}); - + Process? _process; final int port; @@ -22,22 +22,25 @@ class ImposterServer { ); } - _process = await Process.start('java', [ - '-jar', - imposterJar, - '--listenPort', - port.toString(), - '--configDir', - path.join(Directory.current.path, 'imposter'), - '--plugin', - 'openapi', - '--plugin', - 'rest', - ], - environment: { - ...Platform.environment, - 'IMPOSTER_LOG_LEVEL': 'DEBUG', - }); + _process = await Process.start( + 'java', + [ + '-jar', + imposterJar, + '--listenPort', + port.toString(), + '--configDir', + path.join(Directory.current.path, 'imposter'), + '--plugin', + 'openapi', + '--plugin', + 'rest', + ], + environment: { + ...Platform.environment, + 'IMPOSTER_LOG_LEVEL': 'DEBUG', + }, + ); _process!.stdout.transform(const Utf8Decoder()).listen((data) { print('Imposter stdout: $data'); diff --git a/integration_test/petstore/petstore_test/test/store_test.dart b/integration_test/petstore/petstore_test/test/store_test.dart index db39421..40e2312 100644 --- a/integration_test/petstore/petstore_test/test/store_test.dart +++ b/integration_test/petstore/petstore_test/test/store_test.dart @@ -16,7 +16,7 @@ void main() { await setupImposterServer(imposterServer); }); - StoreApi buildStoreApi({required String responseStatus}) { + StoreApi buildStoreApi({required String responseStatus}) { return StoreApi( CustomServer( baseUrl: baseUrl, diff --git a/integration_test/petstore/petstore_test/test/test_helper.dart b/integration_test/petstore/petstore_test/test/test_helper.dart index 5af0ef0..6347edb 100644 --- a/integration_test/petstore/petstore_test/test/test_helper.dart +++ b/integration_test/petstore/petstore_test/test/test_helper.dart @@ -6,7 +6,7 @@ import 'package:test/test.dart'; class ImposterServer { ImposterServer({required this.port}); - + Process? _process; final int port; @@ -22,22 +22,25 @@ class ImposterServer { ); } - _process = await Process.start('java', [ - '-jar', - imposterJar, - '--listenPort', - port.toString(), - '--configDir', - path.join(Directory.current.path, 'imposter'), - '--plugin', - 'openapi', - '--plugin', - 'rest', - ], - environment: { - ...Platform.environment, - 'IMPOSTER_LOG_LEVEL': 'DEBUG', - }); + _process = await Process.start( + 'java', + [ + '-jar', + imposterJar, + '--listenPort', + port.toString(), + '--configDir', + path.join(Directory.current.path, 'imposter'), + '--plugin', + 'openapi', + '--plugin', + 'rest', + ], + environment: { + ...Platform.environment, + 'IMPOSTER_LOG_LEVEL': 'DEBUG', + }, + ); _process!.stdout.transform(const Utf8Decoder()).listen((data) { print('Imposter stdout: $data'); diff --git a/packages/tonik/bin/tonik.dart b/packages/tonik/bin/tonik.dart index e2b171b..73ee388 100644 --- a/packages/tonik/bin/tonik.dart +++ b/packages/tonik/bin/tonik.dart @@ -142,7 +142,8 @@ void main(List arguments) { } on Object catch (e, s) { logger ..fine('Failed to parse OpenAPI document', e, s) - ..severe('Unexpected error while parsing OpenAPI document. ' + ..severe( + 'Unexpected error while parsing OpenAPI document. ' 'Unexpected error while parsing OpenAPI document. ' 'If you think your document is valid, please run ' 'with verbose logging and report this issue at $issueUrl', diff --git a/packages/tonik/test/src/openapi_loader_test.dart b/packages/tonik/test/src/openapi_loader_test.dart index e5d2bca..211eb9d 100644 --- a/packages/tonik/test/src/openapi_loader_test.dart +++ b/packages/tonik/test/src/openapi_loader_test.dart @@ -20,7 +20,7 @@ void main() { group('loadOpenApiDocument', () { test('loads valid JSON file', () { final jsonFile = File(path.join(tempDir.path, 'test.json')) - ..writeAsStringSync(''' + ..writeAsStringSync(''' { "openapi": "3.0.0", "info": { @@ -39,7 +39,7 @@ void main() { test('loads valid YAML file', () { final yamlFile = File(path.join(tempDir.path, 'test.yaml')) - ..writeAsStringSync(''' + ..writeAsStringSync(''' openapi: 3.0.0 info: title: Test API @@ -69,7 +69,7 @@ info: test('throws on unsupported file extension', () { final txtFile = File(path.join(tempDir.path, 'test.txt')) - ..writeAsStringSync('invalid'); + ..writeAsStringSync('invalid'); expect( () => loadOpenApiDocument(txtFile.path), @@ -79,7 +79,7 @@ info: test('throws on invalid JSON', () { final jsonFile = File(path.join(tempDir.path, 'invalid.json')) - ..writeAsStringSync('invalid json'); + ..writeAsStringSync('invalid json'); expect( () => loadOpenApiDocument(jsonFile.path), @@ -95,7 +95,7 @@ info: test('throws on invalid YAML', () { final yamlFile = File(path.join(tempDir.path, 'invalid.yaml')) - ..writeAsStringSync(''' + ..writeAsStringSync(''' invalid yaml: - misaligned: wrong indentation @@ -110,7 +110,7 @@ invalid yaml: test('handles complex YAML structures', () { final yamlFile = File(path.join(tempDir.path, 'complex.yaml')) - ..writeAsStringSync(''' + ..writeAsStringSync(''' openapi: 3.0.0 info: title: Complex API diff --git a/packages/tonik_core/lib/src/model/model.dart b/packages/tonik_core/lib/src/model/model.dart index c2ba93e..bc92452 100644 --- a/packages/tonik_core/lib/src/model/model.dart +++ b/packages/tonik_core/lib/src/model/model.dart @@ -45,9 +45,9 @@ class AliasModel extends Model with NamedModel { final Model model; Model get resolved => switch (model) { - final AliasModel alias => alias.resolved, - _ => model, - }; + final AliasModel alias => alias.resolved, + _ => model, + }; @override EncodingShape get encodingShape => resolved.encodingShape; diff --git a/packages/tonik_generate/lib/src/api_client/api_client_file_generator.dart b/packages/tonik_generate/lib/src/api_client/api_client_file_generator.dart index 2c4efb2..fa405ec 100644 --- a/packages/tonik_generate/lib/src/api_client/api_client_file_generator.dart +++ b/packages/tonik_generate/lib/src/api_client/api_client_file_generator.dart @@ -29,14 +29,14 @@ class ApiClientFileGenerator { ]); Directory(clientDirectory).createSync(recursive: true); - + // Get the servers list final servers = apiDocument.servers.toList(); // Process operations with tags for (final entry in apiDocument.operationsByTag.entries) { final result = apiClientGenerator.generate( - entry.value, + entry.value, entry.key, servers, ); diff --git a/packages/tonik_generate/lib/src/model/one_of_generator.dart b/packages/tonik_generate/lib/src/model/one_of_generator.dart index 06976db..6ec9beb 100644 --- a/packages/tonik_generate/lib/src/model/one_of_generator.dart +++ b/packages/tonik_generate/lib/src/model/one_of_generator.dart @@ -366,8 +366,9 @@ class OneOfGenerator { // Throw if no match found. blocks.add( - generateJsonDecodingExceptionExpression('Invalid JSON for $className') - .statement, + generateJsonDecodingExceptionExpression( + 'Invalid JSON for $className', + ).statement, ); return Block.of(blocks); diff --git a/packages/tonik_generate/lib/src/operation/parse_generator.dart b/packages/tonik_generate/lib/src/operation/parse_generator.dart index 99d45a1..0b7fb9a 100644 --- a/packages/tonik_generate/lib/src/operation/parse_generator.dart +++ b/packages/tonik_generate/lib/src/operation/parse_generator.dart @@ -16,11 +16,12 @@ class ParseGenerator { /// Generates the _parseResponse method for the operation. Method generateParseResponseMethod(Operation operation) { final responses = operation.responses; - final responseType = resultTypeForOperation( - operation, - nameManager, - package, - ).types.first; + final responseType = + resultTypeForOperation( + operation, + nameManager, + package, + ).types.first; final cases = []; // Check if we have a default response with null content type @@ -49,7 +50,7 @@ class ParseGenerator { } } - // Only add a default case if we don't have a default response with + // Only add a default case if we don't have a default response with // null content type final switchCases = [ const Code( @@ -60,17 +61,19 @@ class ParseGenerator { ]; if (!hasDefaultWithNullContentType) { - switchCases.add(Block.of([ - const Code('default:'), - const Code( - "final content = response.headers.value('content-type') " - "?? 'not specified';", - ), - const Code('final status = response.statusCode;'), - generateDecodingExceptionExpression( - r'Unexpected content type: $content for status code: $status', - ).statement, - ]),); + switchCases.add( + Block.of([ + const Code('default:'), + const Code( + "final content = response.headers.value('content-type') " + "?? 'not specified';", + ), + const Code('final status = response.statusCode;'), + generateDecodingExceptionExpression( + r'Unexpected content type: $content for status code: $status', + ).statement, + ]), + ); } switchCases.add(const Code('}')); diff --git a/packages/tonik_generate/lib/src/response_wrapper/response_wrapper_generator.dart b/packages/tonik_generate/lib/src/response_wrapper/response_wrapper_generator.dart index 7cfc1b9..f5601d7 100644 --- a/packages/tonik_generate/lib/src/response_wrapper/response_wrapper_generator.dart +++ b/packages/tonik_generate/lib/src/response_wrapper/response_wrapper_generator.dart @@ -84,7 +84,7 @@ class ResponseWrapperGenerator { (response.bodyCount > 1 || response.hasHeaders)) { final responseClassName = nameManager.responseNames(response.resolved).baseName; - + bodyField = Field( (b) => b diff --git a/packages/tonik_generate/lib/src/server/server_file_generator.dart b/packages/tonik_generate/lib/src/server/server_file_generator.dart index fcaefd8..488c832 100644 --- a/packages/tonik_generate/lib/src/server/server_file_generator.dart +++ b/packages/tonik_generate/lib/src/server/server_file_generator.dart @@ -40,4 +40,4 @@ class ServerFileGenerator { final filePath = path.join(serverDirPath, result.filename); File(filePath).writeAsStringSync(result.code); } -} +} diff --git a/packages/tonik_generate/lib/src/util/doc_comment_formatter.dart b/packages/tonik_generate/lib/src/util/doc_comment_formatter.dart index 55e774d..c3860aa 100644 --- a/packages/tonik_generate/lib/src/util/doc_comment_formatter.dart +++ b/packages/tonik_generate/lib/src/util/doc_comment_formatter.dart @@ -1,18 +1,18 @@ /// Formats a single string as a doc comment. -/// +/// /// If the string is multiline, each line will be prefixed with '/// '. /// Returns an empty list if the input is null or empty. List formatDocComment(String? text) { if (text == null || text.isEmpty) { return []; } - + // Split by newlines and prefix each line with '/// ' return text.split('\n').map((line) => '/// $line').toList(); } /// Formats a list of strings as doc comments. -/// +/// /// Each string in the list is processed with [formatDocComment], /// and the results are flattened into a single list. /// Null and empty strings are filtered out. @@ -23,12 +23,12 @@ List formatDocComments(List? texts) { } final result = []; - + for (final text in texts) { if (text != null && text.isNotEmpty) { result.addAll(formatDocComment(text)); } } - + return result; -} +} diff --git a/packages/tonik_generate/lib/src/util/exception_code_generator.dart b/packages/tonik_generate/lib/src/util/exception_code_generator.dart index 5580b9d..ec738fb 100644 --- a/packages/tonik_generate/lib/src/util/exception_code_generator.dart +++ b/packages/tonik_generate/lib/src/util/exception_code_generator.dart @@ -1,7 +1,5 @@ import 'package:code_builder/code_builder.dart'; - - /// Generates a throw expression for ArgumentError. Expression generateArgumentErrorExpression(String message) { return _generateExceptionExpression('ArgumentError', message); diff --git a/packages/tonik_generate/lib/src/util/response_property_normalizer.dart b/packages/tonik_generate/lib/src/util/response_property_normalizer.dart index 1b9c137..ea7ef4c 100644 --- a/packages/tonik_generate/lib/src/util/response_property_normalizer.dart +++ b/packages/tonik_generate/lib/src/util/response_property_normalizer.dart @@ -7,7 +7,6 @@ List<({String normalizedName, Property property, ResponseHeader? header})> normalizeResponseProperties(ResponseObject response) { final headerMap = {}; - final headerProperties = response.headers.entries.map((header) { final property = Property( name: diff --git a/packages/tonik_generate/lib/src/util/response_type_generator.dart b/packages/tonik_generate/lib/src/util/response_type_generator.dart index 4666e0f..281898e 100644 --- a/packages/tonik_generate/lib/src/util/response_type_generator.dart +++ b/packages/tonik_generate/lib/src/util/response_type_generator.dart @@ -4,7 +4,7 @@ import 'package:tonik_core/tonik_core.dart'; import 'package:tonik_generate/src/naming/name_manager.dart'; import 'package:tonik_generate/src/util/type_reference_generator.dart'; -/// Generates the appropriate return type for an operation +/// Generates the appropriate return type for an operation /// based on its responses. TypeReference resultTypeForOperation( Operation operation, @@ -63,4 +63,4 @@ TypeReference resultTypeForOperation( ), ), }; -} +} diff --git a/packages/tonik_generate/test/src/api_client/api_client_file_generator_test.dart b/packages/tonik_generate/test/src/api_client/api_client_file_generator_test.dart index 7d69fed..379f41d 100644 --- a/packages/tonik_generate/test/src/api_client/api_client_file_generator_test.dart +++ b/packages/tonik_generate/test/src/api_client/api_client_file_generator_test.dart @@ -215,7 +215,7 @@ void main() { // Read the generated file to verify it contains the operation final fileContent = File(clientDir.listSync().first.path).readAsStringSync(); - + expect(fileContent, contains('untaggedOperation')); expect(fileContent, contains('class DefaultApi')); }); 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 420155c..8189a6a 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 @@ -232,7 +232,7 @@ void main() { ); } '''; - + expect( collapseWhitespace(classCode), contains(collapseWhitespace(expectedMethod)), @@ -355,7 +355,7 @@ void main() { ); } '''; - + expect( collapseWhitespace(classCode), contains(collapseWhitespace(expectedMethod)), diff --git a/packages/tonik_generate/test/src/naming/name_generator_test.dart b/packages/tonik_generate/test/src/naming/name_generator_test.dart index b2c3858..8e2e473 100644 --- a/packages/tonik_generate/test/src/naming/name_generator_test.dart +++ b/packages/tonik_generate/test/src/naming/name_generator_test.dart @@ -1086,7 +1086,10 @@ void main() { final generator = NameGenerator(); final servers = [ const Server(url: 'https://api.dev.example.com', description: null), - const Server(url: 'https://api.staging.example.com', description: null), + const Server( + url: 'https://api.staging.example.com', + description: null, + ), const Server(url: 'https://api.prod.example.com', description: null), ]; @@ -1178,7 +1181,8 @@ void main() { expect(result.serverMap[servers[0]], 'CustomServer'); expect(result.customName, r'CustomServer$'); expect(result.baseName, 'ApiServer'); - }); + }, + ); test('uses default names on invalid URLs', () { final generator = NameGenerator(); diff --git a/packages/tonik_generate/test/src/operation/operation_generator_response_test.dart b/packages/tonik_generate/test/src/operation/operation_generator_response_test.dart index e9eba7b..b3b8c0b 100644 --- a/packages/tonik_generate/test/src/operation/operation_generator_response_test.dart +++ b/packages/tonik_generate/test/src/operation/operation_generator_response_test.dart @@ -169,49 +169,47 @@ void main() { ); }); - test( - 'returns result with model for single status code with body only', - () { - final operation = Operation( - operationId: 'bodyStatus', - context: context, - summary: '', - description: '', - tags: const {}, - isDeprecated: false, - path: '/body', - method: HttpMethod.get, - headers: const {}, - queryParameters: const {}, - pathParameters: const {}, - requestBody: null, - responses: { - const ExplicitResponseStatus(statusCode: 200): ResponseObject( - name: 'BodyResponse', - context: context, - headers: const {}, - description: '', - bodies: { - ResponseBody( - model: StringModel(context: context), - rawContentType: 'application/json', - contentType: ContentType.json, - ), - }, - ), - }, - ); - const normalizedParams = NormalizedRequestParameters( - pathParameters: [], - queryParameters: [], - headers: [], - ); - final method = generator.generateCallMethod( - operation, - normalizedParams, - ); - expect( - method.returns?.accept(emitter).toString(), + test('returns result with model for single status code with body only', () { + final operation = Operation( + operationId: 'bodyStatus', + context: context, + summary: '', + description: '', + tags: const {}, + isDeprecated: false, + path: '/body', + method: HttpMethod.get, + headers: const {}, + queryParameters: const {}, + pathParameters: const {}, + requestBody: null, + responses: { + const ExplicitResponseStatus(statusCode: 200): ResponseObject( + name: 'BodyResponse', + context: context, + headers: const {}, + description: '', + bodies: { + ResponseBody( + model: StringModel(context: context), + rawContentType: 'application/json', + contentType: ContentType.json, + ), + }, + ), + }, + ); + const normalizedParams = NormalizedRequestParameters( + pathParameters: [], + queryParameters: [], + headers: [], + ); + final method = generator.generateCallMethod( + operation, + normalizedParams, + ); + expect( + method.returns?.accept(emitter).toString(), 'Future>', ); }); diff --git a/packages/tonik_generate/test/src/server/server_file_generator_test.dart b/packages/tonik_generate/test/src/server/server_file_generator_test.dart index 8b1b600..580bd63 100644 --- a/packages/tonik_generate/test/src/server/server_file_generator_test.dart +++ b/packages/tonik_generate/test/src/server/server_file_generator_test.dart @@ -100,17 +100,17 @@ void main() { final serverDir = Directory( path.join(tempDir.path, 'test_package', 'lib', 'src', 'server'), ); - + expect(serverDir.listSync(), hasLength(1)); - + // Get file name and content final generatedFile = serverDir.listSync().first; final actualFileName = path.basename(generatedFile.path); final fileContent = File(generatedFile.path).readAsStringSync(); - + // Check file name expect(actualFileName, equals('api_server.dart')); - + // Check file content expect(fileContent, contains('sealed class ApiServer')); expect(fileContent, contains('class ProductionServer')); @@ -128,7 +128,7 @@ void main() { models: {}, responseHeaders: {}, requestHeaders: {}, - servers: {}, // Empty servers collection + servers: {}, // Empty servers collection operations: {}, responses: {}, queryParameters: {}, @@ -148,17 +148,17 @@ void main() { ); expect(serverDir.existsSync(), isTrue); expect(serverDir.listSync(), hasLength(1)); - + // Get file content final generatedFile = serverDir.listSync().first; final fileContent = File(generatedFile.path).readAsStringSync(); - + // Expect base class and custom class to be generated expect(fileContent, contains('sealed class ApiServer')); expect(fileContent, contains('class CustomServer')); - + // No server-specific classes should be present expect(fileContent.split('class').length, 3); }); }); -} +} diff --git a/packages/tonik_generate/test/src/server/server_generator_test.dart b/packages/tonik_generate/test/src/server/server_generator_test.dart index 77c768c..8f32742 100644 --- a/packages/tonik_generate/test/src/server/server_generator_test.dart +++ b/packages/tonik_generate/test/src/server/server_generator_test.dart @@ -65,7 +65,7 @@ void main() { test('generates constructor with named parameters', () { final constructor = baseClass.constructors.first; - // Constructor should not be const since _dio is not final + // Constructor should not be const since _dio is not final // and initialized later expect(constructor.constant, isFalse); expect(constructor.optionalParameters.length, 2); @@ -121,7 +121,7 @@ void main() { final productionClass = generatedClasses[1]; final productionConstructor = productionClass.constructors.first; - // Constructor should not be const since base class + // Constructor should not be const since base class // constructor isn't const expect(productionConstructor.constant, isFalse); expect(productionConstructor.optionalParameters.length, 1); @@ -155,7 +155,7 @@ void main() { final stagingClass = generatedClasses[2]; final stagingConstructor = stagingClass.constructors.first; - // Constructor should not be const since base class constructor + // Constructor should not be const since base class constructor // isn't const expect(stagingConstructor.constant, isFalse); expect(stagingConstructor.optionalParameters.length, 1); @@ -191,7 +191,7 @@ void main() { final customClass = generatedClasses.last; final customConstructor = customClass.constructors.first; - // Constructor should not be const since base class constructor + // Constructor should not be const since base class constructor // isn't const expect(customConstructor.constant, isFalse); expect(customConstructor.optionalParameters.length, 2); diff --git a/packages/tonik_generate/test/src/util/doc_comment_formatter_test.dart b/packages/tonik_generate/test/src/util/doc_comment_formatter_test.dart index bc9d990..8f7452b 100644 --- a/packages/tonik_generate/test/src/util/doc_comment_formatter_test.dart +++ b/packages/tonik_generate/test/src/util/doc_comment_formatter_test.dart @@ -5,7 +5,7 @@ void main() { group('doc comment formatter', () { test('formats a single line string', () { final result = formatDocComment('This is a doc comment'); - + expect(result, isNotEmpty); expect(result.length, 1); expect(result.first, '/// This is a doc comment'); @@ -15,7 +15,7 @@ void main() { final result = formatDocComment( 'This is a multiline\ndoc comment\nwith three lines', ); - + expect(result, isNotEmpty); expect(result.length, 3); expect(result[0], '/// This is a multiline'); @@ -25,13 +25,13 @@ void main() { test('returns empty list for null string', () { final result = formatDocComment(null); - + expect(result, isEmpty); }); test('returns empty list for empty string', () { final result = formatDocComment(''); - + expect(result, isEmpty); }); @@ -43,7 +43,7 @@ void main() { '', 'Last line', ]); - + expect(result, isNotEmpty); expect(result.length, 3); expect(result[0], '/// First line'); @@ -53,19 +53,19 @@ void main() { test('returns empty list for null list', () { final result = formatDocComments(null); - + expect(result, isEmpty); }); test('returns empty list for empty list', () { final result = formatDocComments([]); - + expect(result, isEmpty); }); test('returns empty list for list of nulls and empty strings', () { final result = formatDocComments([null, '', null]); - + expect(result, isEmpty); }); @@ -74,7 +74,7 @@ void main() { 'First\nMultiline', 'Second', ]); - + expect(result, isNotEmpty); expect(result.length, 3); expect(result[0], '/// First'); @@ -82,4 +82,4 @@ void main() { expect(result[2], '/// Second'); }); }); -} +} diff --git a/packages/tonik_generate/test/src/util/from_json_value_expression_generator_test.dart b/packages/tonik_generate/test/src/util/from_json_value_expression_generator_test.dart index 4fab605..ee76425 100644 --- a/packages/tonik_generate/test/src/util/from_json_value_expression_generator_test.dart +++ b/packages/tonik_generate/test/src/util/from_json_value_expression_generator_test.dart @@ -514,14 +514,15 @@ void main() { context: context, ); - final expresion = buildFromJsonValueExpression( - 'value', - model: nestedListModel, - nameManager: nameManager, - package: 'package:my_package/my_package.dart', - contextClass: 'Order', - contextProperty: 'items', - ).accept(emitter).toString(); + final expresion = + buildFromJsonValueExpression( + 'value', + model: nestedListModel, + nameManager: nameManager, + package: 'package:my_package/my_package.dart', + contextClass: 'Order', + contextProperty: 'items', + ).accept(emitter).toString(); expect( expresion, diff --git a/packages/tonik_generate/test/src/util/from_simple_value_expression_generator_test.dart b/packages/tonik_generate/test/src/util/from_simple_value_expression_generator_test.dart index 9aea25b..244a69a 100644 --- a/packages/tonik_generate/test/src/util/from_simple_value_expression_generator_test.dart +++ b/packages/tonik_generate/test/src/util/from_simple_value_expression_generator_test.dart @@ -296,7 +296,7 @@ void main() { test('passes context parameter to decode methods when provided', () { final value = refer('value'); - + expect( buildSimpleValueExpression( value, diff --git a/packages/tonik_util/lib/src/dio/server_config.dart b/packages/tonik_util/lib/src/dio/server_config.dart index f5fa6f4..8f4c48e 100644 --- a/packages/tonik_util/lib/src/dio/server_config.dart +++ b/packages/tonik_util/lib/src/dio/server_config.dart @@ -50,15 +50,15 @@ class ServerConfig { // Set the server URL dio.options.baseUrl = serverUrl; - + // Add all interceptors for (final interceptor in interceptors) { dio.interceptors.add(interceptor); } - + // Set httpClientAdapter if provided if (httpClientAdapter != null) { dio.httpClientAdapter = httpClientAdapter!; } } -} +} From a060662a50942cb71f9faed22e0becefc708defc Mon Sep 17 00:00:00 2001 From: Tobias Ottenweller Date: Sun, 15 Jun 2025 17:06:34 +0200 Subject: [PATCH 17/37] fix: proper handle dates --- .../to_json_value_expression_generator.dart | 3 +- .../src/util/type_reference_generator.dart | 7 +- .../test/src/model/all_of_generator_test.dart | 10 +- .../src/model/typedef_generator_test.dart | 2 +- .../src/operation/data_generator_test.dart | 4 +- ..._json_value_expression_generator_test.dart | 2 +- packages/tonik_util/lib/src/date.dart | 16 +- .../lib/src/decoding/json_decoder.dart | 43 ++++++ .../lib/src/decoding/simple_decoder.dart | 46 ++++++ packages/tonik_util/test/src/date_test.dart | 54 +++++-- .../test/src/decoding/json_decoder_test.dart | 67 ++++++++ .../src/decoding/simple_decoder_test.dart | 44 ++++++ .../src/encoder/deep_object_encoder_test.dart | 144 ++++++++++++------ 13 files changed, 360 insertions(+), 82 deletions(-) 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 eaf0927..7d0a55d 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 @@ -47,8 +47,9 @@ String? _getSerializationSuffix(Model model, bool isNullable) { isNullable || (model is EnumModel && model.isNullable) ? '?' : ''; return switch (model) { - DateTimeModel() || DateModel() => '$nullablePart.toIso8601String()', + DateTimeModel() => '$nullablePart.toIso8601String()', DecimalModel() => '$nullablePart.toString()', + DateModel() || EnumModel() || ClassModel() || AllOfModel() || 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 9be2bb4..63d6e85 100644 --- a/packages/tonik_generate/lib/src/util/type_reference_generator.dart +++ b/packages/tonik_generate/lib/src/util/type_reference_generator.dart @@ -71,8 +71,8 @@ TypeReference typeReference( DateModel _ => TypeReference( (b) => b - ..symbol = 'DateTime' - ..url = 'dart:core' + ..symbol = 'Date' + ..url = 'package:tonik_util/tonik_util.dart' ..isNullable = isNullableOverride, ), DecimalModel _ => TypeReference( @@ -87,8 +87,7 @@ TypeReference typeReference( b ..symbol = nameManager.modelName(m) ..url = package - ..isNullable = - isNullableOverride, + ..isNullable = isNullableOverride, ), }; } diff --git a/packages/tonik_generate/test/src/model/all_of_generator_test.dart b/packages/tonik_generate/test/src/model/all_of_generator_test.dart index f1875bd..1a67894 100644 --- a/packages/tonik_generate/test/src/model/all_of_generator_test.dart +++ b/packages/tonik_generate/test/src/model/all_of_generator_test.dart @@ -501,14 +501,14 @@ void main() { expect(combinedClass.fields, hasLength(2)); expect( combinedClass.fields.map((f) => f.name), - containsAll(['dateTime', 'string']), + containsAll(['date', 'string']), ); // Check field types final dateField = combinedClass.fields.firstWhere( - (f) => f.name == 'dateTime', + (f) => f.name == 'date', ); - expect(dateField.type?.accept(emitter).toString(), 'DateTime'); + expect(dateField.type?.accept(emitter).toString(), 'Date'); final stringField = combinedClass.fields.firstWhere( (f) => f.name == 'string', @@ -517,7 +517,7 @@ void main() { // Check toJson - should return the ISO string const expectedToJson = ''' - Object? toJson() => dateTime.toIso8601String(); + Object? toJson() => date.toJson(); '''; expect( @@ -529,7 +529,7 @@ void main() { const expectedFromJson = ''' factory DateStringModel.fromJson(Object? json) { return DateStringModel( - dateTime: json.decodeJsonDate(context: r'DateStringModel'), + date: json.decodeJsonDate(context: r'DateStringModel'), string: json.decodeJsonString(context: r'DateStringModel'), ); } 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 fe4f88c..4d9d270 100644 --- a/packages/tonik_generate/test/src/model/typedef_generator_test.dart +++ b/packages/tonik_generate/test/src/model/typedef_generator_test.dart @@ -113,7 +113,7 @@ void main() { (model: NumberModel(context: context), expectedType: 'num'), (model: BooleanModel(context: context), expectedType: 'bool'), (model: DateTimeModel(context: context), expectedType: 'DateTime'), - (model: DateModel(context: context), expectedType: 'DateTime'), + (model: DateModel(context: context), expectedType: 'Date'), (model: DecimalModel(context: context), expectedType: 'BigDecimal'), ]; diff --git a/packages/tonik_generate/test/src/operation/data_generator_test.dart b/packages/tonik_generate/test/src/operation/data_generator_test.dart index 284bfd5..66c8e16 100644 --- a/packages/tonik_generate/test/src/operation/data_generator_test.dart +++ b/packages/tonik_generate/test/src/operation/data_generator_test.dart @@ -239,8 +239,8 @@ void main() { ); const expectedMethod = ''' - Object? _data({required DateTime? body}) { - return body?.toIso8601String(); + Object? _data({required Date? body}) { + return body?.toJson(); } '''; diff --git a/packages/tonik_generate/test/src/util/to_json_value_expression_generator_test.dart b/packages/tonik_generate/test/src/util/to_json_value_expression_generator_test.dart index 4f48260..f54b21a 100644 --- a/packages/tonik_generate/test/src/util/to_json_value_expression_generator_test.dart +++ b/packages/tonik_generate/test/src/util/to_json_value_expression_generator_test.dart @@ -67,7 +67,7 @@ void main() { ); expect( buildToJsonPropertyExpression('dueDate', property), - 'dueDate?.toIso8601String()', + 'dueDate?.toJson()', ); }); diff --git a/packages/tonik_util/lib/src/date.dart b/packages/tonik_util/lib/src/date.dart index 0f94661..b422e3f 100644 --- a/packages/tonik_util/lib/src/date.dart +++ b/packages/tonik_util/lib/src/date.dart @@ -10,7 +10,7 @@ import 'package:tonik_util/src/decoding/simple_decoder.dart'; class Date { /// Creates a new [Date] instance. /// - /// Throws [RangeError] if any of the date components are invalid. + /// Throws [FormatException] if any of the date components are invalid. Date(this.year, this.month, this.day) { _validate(); } @@ -24,7 +24,8 @@ class Date { /// Creates a [Date] from an ISO 8601 formatted string (YYYY-MM-DD). /// - /// Throws [FormatException] if the string is not in the correct format. + /// Throws [FormatException] if the string is not in the correct format + /// or if any of the date components are invalid. factory Date.fromString(String dateString) { final parts = dateString.split('-'); if (parts.length != 3) { @@ -37,7 +38,7 @@ class Date { final day = int.parse(parts[2]); final date = Date(year, month, day).._validate(); return date; - } on FormatException { + } on Object { throw const FormatException('Invalid date format. Expected YYYY-MM-DD'); } } @@ -114,12 +115,17 @@ class Date { void _validate() { if (month < 1 || month > 12) { - throw RangeError.range(month, 1, 12, 'month'); + throw FormatException( + 'Invalid month: $month. Month must be between 1 and 12.', + ); } final daysInMonth = DateTime(year, month + 1, 0).day; if (day < 1 || day > daysInMonth) { - throw RangeError.range(day, 1, daysInMonth, 'day'); + throw FormatException( + 'Invalid day: $day. Day must be between 1 and $daysInMonth for ' + 'month $month.', + ); } } } diff --git a/packages/tonik_util/lib/src/decoding/json_decoder.dart b/packages/tonik_util/lib/src/decoding/json_decoder.dart index 4192acf..957f61d 100644 --- a/packages/tonik_util/lib/src/decoding/json_decoder.dart +++ b/packages/tonik_util/lib/src/decoding/json_decoder.dart @@ -1,4 +1,5 @@ import 'package:big_decimal/big_decimal.dart'; +import 'package:tonik_util/src/date.dart'; import 'package:tonik_util/src/decoding/decoding_exception.dart'; /// Extensions for decoding JSON values. @@ -348,4 +349,46 @@ extension JsonDecoder on Object? { } return this! as bool; } + + /// Decodes a JSON value to a Date. + /// + /// Expects ISO 8601 format string (YYYY-MM-DD). + /// Throws [InvalidTypeException] if the value is not a valid date string + /// or if the value is null. + Date decodeJsonDate({String? context}) { + if (this == null) { + throw InvalidTypeException( + value: 'null', + targetType: Date, + context: context, + ); + } + if (this is! String) { + throw InvalidTypeException( + value: toString(), + targetType: Date, + context: context, + ); + } + try { + return Date.fromString(this! as String); + } on FormatException catch (e) { + throw InvalidTypeException( + value: this! as String, + targetType: Date, + context: e.message, + ); + } + } + + /// Decodes a JSON value to a nullable Date. + /// + /// Returns null if the value is null or an empty string. + /// 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)) { + return null; + } + return decodeJsonDate(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 c5f7957..9f7d9a8 100644 --- a/packages/tonik_util/lib/src/decoding/simple_decoder.dart +++ b/packages/tonik_util/lib/src/decoding/simple_decoder.dart @@ -1,4 +1,5 @@ import 'package:big_decimal/big_decimal.dart'; +import 'package:tonik_util/src/date.dart'; import 'package:tonik_util/src/decoding/decoding_exception.dart'; /// Extensions for decoding simple form values from strings. @@ -253,4 +254,49 @@ extension SimpleDecoder on String? { if (this?.isEmpty ?? true) return null; return decodeSimpleStringNullableList(context: context); } + + /// Decodes a string to a Date. + /// + /// The string must be in ISO 8601 format (YYYY-MM-DD). + /// Throws [FormatException] if the string is not in the correct format or if + /// any of the date components are invalid. + /// Throws [InvalidTypeException] if the value is null or empty. + Date decodeSimpleDate({String? context}) { + if (this == null) { + throw InvalidTypeException( + value: 'null', + targetType: Date, + context: context, + ); + } + if (this!.isEmpty) { + throw InvalidTypeException( + value: 'empty string', + targetType: Date, + context: context, + ); + } + try { + return Date.fromString(this!); + } on FormatException { + rethrow; + } on Object { + throw InvalidTypeException( + value: this!, + targetType: Date, + context: context, + ); + } + } + + /// Decodes a string to a nullable Date. + /// + /// Returns null if the string is empty or null. + /// The string must be in ISO 8601 format (YYYY-MM-DD). + /// Throws [FormatException] if the string is not in the correct format or if + /// any of the date components are invalid. + Date? decodeSimpleNullableDate({String? context}) { + if (this?.isEmpty ?? true) return null; + return decodeSimpleDate(context: context); + } } diff --git a/packages/tonik_util/test/src/date_test.dart b/packages/tonik_util/test/src/date_test.dart index 98c3be7..25ce1b3 100644 --- a/packages/tonik_util/test/src/date_test.dart +++ b/packages/tonik_util/test/src/date_test.dart @@ -98,13 +98,25 @@ void main() { }); test('fromJson throws on invalid date values', () { - expect(() => Date.fromJson('2024-00-15'), throwsA(isA())); - expect(() => Date.fromJson('2024-13-15'), throwsA(isA())); - expect(() => Date.fromJson('2024-03-00'), throwsA(isA())); - expect(() => Date.fromJson('2024-03-32'), throwsA(isA())); + expect( + () => Date.fromJson('2024-00-15'), + throwsA(isA()), + ); + expect( + () => Date.fromJson('2024-13-15'), + throwsA(isA()), + ); + expect( + () => Date.fromJson('2024-03-00'), + throwsA(isA()), + ); + expect( + () => Date.fromJson('2024-03-32'), + throwsA(isA()), + ); expect( () => Date.fromJson('2024-02-30'), - throwsA(isA()), + throwsA(isA()), ); // February 30th }); @@ -128,24 +140,36 @@ void main() { }); test('fromSimple throws on invalid date values', () { - expect(() => Date.fromSimple('2024-00-15'), throwsA(isA())); - expect(() => Date.fromSimple('2024-13-15'), throwsA(isA())); - expect(() => Date.fromSimple('2024-03-00'), throwsA(isA())); - expect(() => Date.fromSimple('2024-03-32'), throwsA(isA())); + expect( + () => Date.fromSimple('2024-00-15'), + throwsA(isA()), + ); + expect( + () => Date.fromSimple('2024-13-15'), + throwsA(isA()), + ); + expect( + () => Date.fromSimple('2024-03-00'), + throwsA(isA()), + ); + expect( + () => Date.fromSimple('2024-03-32'), + throwsA(isA()), + ); expect( () => Date.fromSimple('2024-02-30'), - throwsA(isA()), + throwsA(isA()), ); // February 30th }); test('validates date components', () { - expect(() => Date(2024, 0, 15), throwsA(isA())); - expect(() => Date(2024, 13, 15), throwsA(isA())); - expect(() => Date(2024, 3, 0), throwsA(isA())); - expect(() => Date(2024, 3, 32), throwsA(isA())); + expect(() => Date(2024, 0, 15), throwsA(isA())); + expect(() => Date(2024, 13, 15), throwsA(isA())); + expect(() => Date(2024, 3, 0), throwsA(isA())); + expect(() => Date(2024, 3, 32), throwsA(isA())); expect( () => Date(2024, 2, 30), - throwsA(isA()), + throwsA(isA()), ); // February 30th }); }); 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 f2cc630..1b21dc2 100644 --- a/packages/tonik_util/test/src/decoding/json_decoder_test.dart +++ b/packages/tonik_util/test/src/decoding/json_decoder_test.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'package:big_decimal/big_decimal.dart'; import 'package:test/test.dart'; +import 'package:tonik_util/src/date.dart'; import 'package:tonik_util/src/decoding/decoding_exception.dart'; import 'package:tonik_util/src/decoding/json_decoder.dart'; @@ -196,6 +197,72 @@ void main() { ); }); }); + + group('Date', () { + test('decodes Date values', () { + final date = Date(2024, 3, 15); + expect('2024-03-15'.decodeJsonDate(), date); + expect( + () => 123.decodeJsonDate(), + throwsA(isA()), + ); + expect( + () => null.decodeJsonDate(), + throwsA(isA()), + ); + expect( + () => '2024-00-15'.decodeJsonDate(), + throwsA(isA()), + ); + expect( + () => '2024-13-15'.decodeJsonDate(), + throwsA(isA()), + ); + expect( + () => '2024-03-00'.decodeJsonDate(), + throwsA(isA()), + ); + expect( + () => '2024-03-32'.decodeJsonDate(), + throwsA(isA()), + ); + expect( + () => '2024-02-30'.decodeJsonDate(), + throwsA(isA()), + ); + }); + + test('decodes nullable Date values', () { + final date = Date(2024, 3, 15); + expect('2024-03-15'.decodeJsonNullableDate(), date); + expect(null.decodeJsonNullableDate(), isNull); + expect(''.decodeJsonNullableDate(), isNull); + expect( + () => 123.decodeJsonNullableDate(), + throwsA(isA()), + ); + expect( + () => '2024-00-15'.decodeJsonNullableDate(), + throwsA(isA()), + ); + expect( + () => '2024-13-15'.decodeJsonNullableDate(), + throwsA(isA()), + ); + expect( + () => '2024-03-00'.decodeJsonNullableDate(), + throwsA(isA()), + ); + expect( + () => '2024-03-32'.decodeJsonNullableDate(), + throwsA(isA()), + ); + expect( + () => '2024-02-30'.decodeJsonNullableDate(), + 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 85818cc..366b6a1 100644 --- a/packages/tonik_util/test/src/decoding/simple_decoder_test.dart +++ b/packages/tonik_util/test/src/decoding/simple_decoder_test.dart @@ -1,5 +1,6 @@ import 'package:big_decimal/big_decimal.dart'; import 'package:test/test.dart'; +import 'package:tonik_util/src/date.dart'; import 'package:tonik_util/src/decoding/decoding_exception.dart'; import 'package:tonik_util/src/decoding/simple_decoder.dart'; @@ -70,6 +71,39 @@ void main() { throwsA(isA()), ); }); + + test('decodes Date values', () { + final date = Date(2024, 3, 15); + expect('2024-03-15'.decodeSimpleDate(), date); + expect( + () => 'not-a-date'.decodeSimpleDate(), + throwsA(isA()), + ); + expect( + () => null.decodeSimpleDate(), + throwsA(isA()), + ); + expect( + () => '2024-00-15'.decodeSimpleDate(), + throwsA(isA()), + ); + expect( + () => '2024-13-15'.decodeSimpleDate(), + throwsA(isA()), + ); + expect( + () => '2024-03-00'.decodeSimpleDate(), + throwsA(isA()), + ); + expect( + () => '2024-03-32'.decodeSimpleDate(), + throwsA(isA()), + ); + expect( + () => '2024-02-30'.decodeSimpleDate(), + throwsA(isA()), + ); + }); }); group('Nullable Values', () { @@ -79,12 +113,14 @@ void main() { expect(''.decodeSimpleNullableBool(), isNull); expect(''.decodeSimpleNullableDateTime(), isNull); expect(''.decodeSimpleNullableBigDecimal(), isNull); + expect(''.decodeSimpleNullableDate(), isNull); expect(null.decodeSimpleNullableInt(), isNull); expect(null.decodeSimpleNullableDouble(), isNull); expect(null.decodeSimpleNullableBool(), isNull); expect(null.decodeSimpleNullableDateTime(), isNull); expect(null.decodeSimpleNullableBigDecimal(), isNull); + expect(null.decodeSimpleNullableDate(), isNull); }); test('decodes non-empty strings for nullable types', () { @@ -99,6 +135,10 @@ void main() { '3.14'.decodeSimpleNullableBigDecimal(), BigDecimal.parse('3.14'), ); + expect( + '2024-03-15'.decodeSimpleNullableDate(), + Date(2024, 3, 15), + ); }); }); @@ -124,6 +164,10 @@ void main() { () => ''.decodeSimpleBigDecimal(), throwsA(isA()), ); + expect( + () => ''.decodeSimpleDate(), + throwsA(isA()), + ); }); }); 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 1182669..9bfd98b 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 @@ -7,10 +7,14 @@ void main() { group('DeepObjectEncoder', () { test('encodes a simple object', () { - final result = encoder.encode('filter', { - 'color': 'red', - 'size': 'large', - }, allowEmpty: true,); + final result = encoder.encode( + 'filter', + { + 'color': 'red', + 'size': 'large', + }, + allowEmpty: true, + ); expect(result, [ (name: 'filter[color]', value: 'red'), @@ -19,10 +23,14 @@ void main() { }); test('encodes boolean properties', () { - final result = encoder.encode('filter', { - 'active': true, - 'premium': false, - }, allowEmpty: true,); + final result = encoder.encode( + 'filter', + { + 'active': true, + 'premium': false, + }, + allowEmpty: true, + ); expect(result, [ (name: 'filter[active]', value: 'true'), @@ -31,10 +39,14 @@ void main() { }); test('encodes an object with a null value', () { - final result = encoder.encode('filter', { - 'color': null, - 'size': 'large', - }, allowEmpty: true,); + final result = encoder.encode( + 'filter', + { + 'color': null, + 'size': 'large', + }, + allowEmpty: true, + ); expect(result, [ (name: 'filter[color]', value: ''), @@ -53,9 +65,13 @@ void main() { }); test('encodes nested objects', () { - final result = encoder.encode('filter', { - 'product': {'color': 'blue', 'size': 'medium'}, - }, allowEmpty: true,); + final result = encoder.encode( + 'filter', + { + 'product': {'color': 'blue', 'size': 'medium'}, + }, + allowEmpty: true, + ); expect(result, [ (name: 'filter[product][color]', value: 'blue'), @@ -64,11 +80,15 @@ void main() { }); test('encodes deeply nested objects', () { - final result = encoder.encode('filter', { - 'product': { - 'attributes': {'color': 'blue', 'size': 'medium'}, + final result = encoder.encode( + 'filter', + { + 'product': { + 'attributes': {'color': 'blue', 'size': 'medium'}, + }, }, - }, allowEmpty: true,); + allowEmpty: true, + ); expect(result, [ (name: 'filter[product][attributes][color]', value: 'blue'), @@ -78,9 +98,13 @@ void main() { test('throws for objects containing arrays', () { expect( - () => encoder.encode('filter', { - 'colors': ['red', 'blue', 'green'], - }, allowEmpty: true,), + () => encoder.encode( + 'filter', + { + 'colors': ['red', 'blue', 'green'], + }, + allowEmpty: true, + ), throwsA(isA()), ); }); @@ -95,20 +119,28 @@ void main() { test('throws for objects containing sets', () { expect( - () => encoder.encode('filter', { - 'sizes': {'small', 'medium', 'large'}, - }, allowEmpty: true,), + () => encoder.encode( + 'filter', + { + 'sizes': {'small', 'medium', 'large'}, + }, + allowEmpty: true, + ), throwsA(isA()), ); }); test('encodes a complex object with various types', () { - final result = encoder.encode('params', { - 'name': 'John', - 'age': 30, - 'active': true, - 'address': {'street': '123 Main St', 'city': 'New York'}, - }, allowEmpty: true,); + final result = encoder.encode( + 'params', + { + 'name': 'John', + 'age': 30, + 'active': true, + 'address': {'street': '123 Main St', 'city': 'New York'}, + }, + allowEmpty: true, + ); expect(result, [ (name: 'params[name]', value: 'John'), @@ -180,11 +212,15 @@ void main() { group('allowEmpty parameter', () { test('allows empty values when allowEmpty is true', () { - final result = encoder.encode('filter', { - 'emptyString': '', - 'emptyMap': {}, - 'normalValue': 'test', - }, allowEmpty: true,); + final result = encoder.encode( + 'filter', + { + 'emptyString': '', + 'emptyMap': {}, + 'normalValue': 'test', + }, + allowEmpty: true, + ); expect(result, [ (name: 'filter[emptyString]', value: ''), @@ -195,10 +231,14 @@ void main() { test('throws when allowEmpty is false and value is empty string', () { expect( - () => encoder.encode('filter', { - 'emptyString': '', - 'normalValue': 'test', - }, allowEmpty: false,), + () => encoder.encode( + 'filter', + { + 'emptyString': '', + 'normalValue': 'test', + }, + allowEmpty: false, + ), throwsA(isA()), ); }); @@ -213,10 +253,14 @@ void main() { test('throws when allowEmpty is false and nested map is empty', () { expect( - () => encoder.encode('filter', { - 'nested': {}, - 'normalValue': 'test', - }, allowEmpty: false,), + () => encoder.encode( + 'filter', + { + 'nested': {}, + 'normalValue': 'test', + }, + allowEmpty: false, + ), throwsA(isA()), ); }); @@ -238,10 +282,14 @@ void main() { }); test('allows non-empty values when allowEmpty is false', () { - final result = encoder.encode('filter', { - 'string': 'value', - 'nested': {'inner': 'value'}, - }, allowEmpty: false,); + final result = encoder.encode( + 'filter', + { + 'string': 'value', + 'nested': {'inner': 'value'}, + }, + allowEmpty: false, + ); expect(result, [ (name: 'filter[string]', value: 'value'), From 38e3bd5a9f16873d5c75b073c37d0aa41f886217 Mon Sep 17 00:00:00 2001 From: Tobias Ottenweller Date: Sun, 15 Jun 2025 17:07:46 +0200 Subject: [PATCH 18/37] chore: add gov integration tests --- .gitignore | 5 +- integration_test/gov/gov_test/.gitignore | 7 + .../gov/gov_test/analysis_options.yaml | 30 ++ .../gov_test/imposter/imposter-config.json | 7 + .../gov/gov_test/imposter/response.groovy | 7 + integration_test/gov/gov_test/pubspec.yaml | 19 + .../gov/gov_test/test/default_test.dart | 120 +++++ .../gov/gov_test/test/test_helper.dart | 87 ++++ integration_test/gov/openapi.yaml | 431 ++++++++++++++++++ integration_test/setup.sh | 3 + 10 files changed, 715 insertions(+), 1 deletion(-) create mode 100644 integration_test/gov/gov_test/.gitignore create mode 100644 integration_test/gov/gov_test/analysis_options.yaml create mode 100644 integration_test/gov/gov_test/imposter/imposter-config.json create mode 100644 integration_test/gov/gov_test/imposter/response.groovy create mode 100644 integration_test/gov/gov_test/pubspec.yaml create mode 100644 integration_test/gov/gov_test/test/default_test.dart create mode 100644 integration_test/gov/gov_test/test/test_helper.dart create mode 100644 integration_test/gov/openapi.yaml diff --git a/.gitignore b/.gitignore index e9fabb6..2d4b255 100644 --- a/.gitignore +++ b/.gitignore @@ -5,7 +5,10 @@ .fvm/ # Integration Test -/integration_test/petstore/petstore_api /integration_test/imposter.jar +/integration_test/petstore/petstore_api /integration_test/music_streaming/music_streaming_api +/integration_test/gov/gov_api + +# Dependencies /pubspec.lock diff --git a/integration_test/gov/gov_test/.gitignore b/integration_test/gov/gov_test/.gitignore new file mode 100644 index 0000000..3cceda5 --- /dev/null +++ b/integration_test/gov/gov_test/.gitignore @@ -0,0 +1,7 @@ +# https://dart.dev/guides/libraries/private-files +# Created by `dart pub` +.dart_tool/ + +# Avoid committing pubspec.lock for library packages; see +# https://dart.dev/guides/libraries/private-files#pubspeclock. +pubspec.lock diff --git a/integration_test/gov/gov_test/analysis_options.yaml b/integration_test/gov/gov_test/analysis_options.yaml new file mode 100644 index 0000000..dee8927 --- /dev/null +++ b/integration_test/gov/gov_test/analysis_options.yaml @@ -0,0 +1,30 @@ +# This file configures the static analysis results for your project (errors, +# warnings, and lints). +# +# This enables the 'recommended' set of lints from `package:lints`. +# This set helps identify many issues that may lead to problems when running +# or consuming Dart code, and enforces writing Dart using a single, idiomatic +# style and format. +# +# If you want a smaller set of lints you can change this to specify +# 'package:lints/core.yaml'. These are just the most critical lints +# (the recommended set includes the core lints). +# The core lints are also what is used by pub.dev for scoring packages. + +include: package:lints/recommended.yaml + +# Uncomment the following section to specify additional rules. + +# linter: +# rules: +# - camel_case_types + +# analyzer: +# exclude: +# - path/to/excluded/files/** + +# For more information about the core and recommended set of lints, see +# https://dart.dev/go/core-lints + +# For additional information about configuring this file, see +# https://dart.dev/guides/language/analysis-options diff --git a/integration_test/gov/gov_test/imposter/imposter-config.json b/integration_test/gov/gov_test/imposter/imposter-config.json new file mode 100644 index 0000000..a2480b3 --- /dev/null +++ b/integration_test/gov/gov_test/imposter/imposter-config.json @@ -0,0 +1,7 @@ +{ + "plugin": "openapi", + "specFile": "../../openapi.yaml", + "response": { + "scriptFile": "response.groovy" + } +} \ No newline at end of file diff --git a/integration_test/gov/gov_test/imposter/response.groovy b/integration_test/gov/gov_test/imposter/response.groovy new file mode 100644 index 0000000..65422ea --- /dev/null +++ b/integration_test/gov/gov_test/imposter/response.groovy @@ -0,0 +1,7 @@ +// Get the response status from the request header +def responseStatus = context.request.headers['X-Response-Status'] ?: '200' + +// Set the response status code and use the OpenAPI specification +respond() + .withStatusCode(Integer.parseInt(responseStatus)) + .usingDefaultBehaviour() diff --git a/integration_test/gov/gov_test/pubspec.yaml b/integration_test/gov/gov_test/pubspec.yaml new file mode 100644 index 0000000..da73204 --- /dev/null +++ b/integration_test/gov/gov_test/pubspec.yaml @@ -0,0 +1,19 @@ +name: gov_test +description: A starting point for Dart libraries or applications. +version: 1.0.0 +publish_to: none + +environment: + sdk: ^3.8.0 + +dependencies: + dio: ^5.8.0 + gov_api: + path: ../gov_api + path: ^1.8.3 + tonik_util: ^0.0.7 + +dev_dependencies: + test: ^1.24.0 + very_good_analysis: ^9.0.0 + diff --git a/integration_test/gov/gov_test/test/default_test.dart b/integration_test/gov/gov_test/test/default_test.dart new file mode 100644 index 0000000..59efdeb --- /dev/null +++ b/integration_test/gov/gov_test/test/default_test.dart @@ -0,0 +1,120 @@ +import 'package:dio/dio.dart'; +import 'package:gov_api/gov_api.dart'; +import 'package:test/test.dart'; +import 'package:tonik_util/tonik_util.dart'; + +import 'test_helper.dart'; + +void main() { + const port = 8080; + const baseUrl = 'http://localhost:$port'; + + late ImposterServer imposterServer; + + setUpAll(() async { + imposterServer = ImposterServer(port: port); + await setupImposterServer(imposterServer); + }); + + DefaultApi buildAlbumsApi({required String responseStatus}) { + return DefaultApi( + CustomServer( + baseUrl: baseUrl, + serverConfig: ServerConfig( + baseOptions: BaseOptions( + headers: {'X-Response-Status': responseStatus}, + ), + ), + ), + ); + } + + group('findForms', () { + test('200', () async { + final defaultApi = buildAlbumsApi(responseStatus: '200'); + + final response = await defaultApi.findForms(query: '10-10EZ'); + + expect(response, isA>()); + final success = response as TonikSuccess; + expect(success.response.statusCode, 200); + expect(success.value, isA()); + + final value = success.value as FindFormsResponse200; + expect(value.body, isA()); + + final body = value.body; + expect(body.data, isA>()); + + final formIndex = body.data.first; + expect(formIndex.attributes?.benefitCategories, isA>()); + expect(formIndex.id, isA()); + expect(formIndex.$type, isA()); + + final attributes = formIndex.attributes; + final benefitCategory = attributes?.benefitCategories?.first; + expect(benefitCategory?.name, isA()); + expect(benefitCategory?.description, isA()); + + expect(attributes?.deletedAt, isA()); + expect(attributes?.firstIssuedOn, isA()); + expect(attributes?.formDetailsUrl, isA()); + expect(attributes?.formName, isA()); + expect(attributes?.formToolIntro, isA()); + expect(attributes?.formToolUrl, isA()); + expect(attributes?.formType, isA()); + expect(attributes?.formUsage, isA()); + expect(attributes?.language, isA()); + expect(attributes?.lastRevisionOn, isA()); + expect(attributes?.lastSha256Change, isA()); + expect(attributes?.pages, isA()); + expect(attributes?.relatedForms, isA?>()); + expect(attributes?.sha256, isA()); + expect(attributes?.title, isA()); + expect(attributes?.url, isA()); + expect(attributes?.vaFormAdministration, isA()); + expect(attributes?.validPdf, isA()); + }); + + test('401', () async { + final defaultApi = buildAlbumsApi(responseStatus: '401'); + + final response = await defaultApi.findForms(); + + expect(response, isA>()); + final success = response as TonikSuccess; + + expect(success.response.statusCode, 401); + expect(success.value, isA()); + + final value = success.value as FindFormsResponse401; + expect(value.body, isA()); + + final body = value.body; + expect(body.message, isA()); + }); + + test('429', () async { + final defaultApi = buildAlbumsApi(responseStatus: '429'); + + final response = await defaultApi.findForms(); + + expect(response, isA>()); + final success = response as TonikSuccess; + + expect(success.response.statusCode, 429); + expect(success.value, isA()); + + final value = success.value as FindFormsResponse429; + expect(value.body, isA()); + }); + + test('unexpected status code', () async { + final defaultApi = buildAlbumsApi(responseStatus: '500'); + + final response = await defaultApi.findForms(); + + expect(response, isA>()); + }); + }); +} diff --git a/integration_test/gov/gov_test/test/test_helper.dart b/integration_test/gov/gov_test/test/test_helper.dart new file mode 100644 index 0000000..6347edb --- /dev/null +++ b/integration_test/gov/gov_test/test/test_helper.dart @@ -0,0 +1,87 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:path/path.dart' as path; +import 'package:test/test.dart'; + +class ImposterServer { + ImposterServer({required this.port}); + + Process? _process; + final int port; + + Future start() async { + final imposterJar = path.join( + Directory.current.parent.parent.path, + 'imposter.jar', + ); + + if (!File(imposterJar).existsSync()) { + throw Exception( + 'Imposter JAR not found at $imposterJar. Please download it first.', + ); + } + + _process = await Process.start( + 'java', + [ + '-jar', + imposterJar, + '--listenPort', + port.toString(), + '--configDir', + path.join(Directory.current.path, 'imposter'), + '--plugin', + 'openapi', + '--plugin', + 'rest', + ], + environment: { + ...Platform.environment, + 'IMPOSTER_LOG_LEVEL': 'DEBUG', + }, + ); + + _process!.stdout.transform(const Utf8Decoder()).listen((data) { + print('Imposter stdout: $data'); + }); + _process!.stderr.transform(const Utf8Decoder()).listen((data) { + print('Imposter stderr: $data'); + }); + + await _waitForImposterReady(); + } + + Future _waitForImposterReady({int timeoutSec = 10}) async { + final deadline = DateTime.now().add(Duration(seconds: timeoutSec)); + final client = HttpClient(); + + while (DateTime.now().isBefore(deadline)) { + try { + final request = await client.getUrl( + Uri.parse('http://localhost:$port'), + ); + await request.close(); + + return true; // No exception means the server is ready. + } on SocketException catch (_) { + // ignore + } + await Future.delayed(const Duration(milliseconds: 300)); + } + return false; + } + + Future stop() async { + if (_process != null) { + _process!.kill(); + await _process!.exitCode; + _process = null; + } + } +} + +Future setupImposterServer(ImposterServer server) async { + await server.start(); + addTearDown(() => server.stop()); +} diff --git a/integration_test/gov/openapi.yaml b/integration_test/gov/openapi.yaml new file mode 100644 index 0000000..5d1c219 --- /dev/null +++ b/integration_test/gov/openapi.yaml @@ -0,0 +1,431 @@ +openapi: 3.0.0 +servers: + - description: VA.gov API sandbox environment + url: https://sandbox-api.va.gov/services/va_forms/{version} + variables: + version: + default: v0 + - description: VA.gov API production environment + url: https://api.va.gov/services/va_forms/{version} + variables: + version: + default: v0 +info: + contact: + name: va.gov + description: | + Use the VA Forms API to search for VA forms, get the form's PDF link and metadata, and check for new versions. + + Visit our VA Lighthouse [Contact Us page](https://developer.va.gov/support) for further assistance. + + ## Background + This API offers an efficient way to stay up-to-date with the latest VA forms and information. The forms information listed on VA.gov matches the information returned by this API. + - Search by form number, keyword, or title + - Get a link to the form in PDF format + - Get detailed form metadata including the number of pages, related forms, benefit categories, language, and more + - Retrieve the latest date of PDF changes and the SHA256 checksum + - Identify when a form is deleted by the VA + + ## Technical summary + The VA Forms API collects form data from the official VA Form Repository on a nightly basis. The Index endpoint can return all available forms or, if an optional query parameter is passed, will return only forms that may relate to the query value. When a valid form name is passed to the Show endpoint, it will return a single form with additional metadata and full revision history. A JSON response is given with the PDF link (if published) and the corresponding form metadata. + + ### Authentication and authorization + The form information shared by this API is publicly available. API requests are authorized through a symmetric API token, provided in an HTTP header with name apikey. [Get a sandbox API Key](https://developer.va.gov/apply). + + ### Testing in sandbox environment + Form data in the sandbox environment is for testing your API only, and is not guaranteed to be up-to-date. This API also has a reduced API rate limit. When you're ready to move to production, be sure to [request a production API key.](https://developer.va.gov/go-live) + + ### SHA256 revision history + Each form is checked nightly for recent file changes. A corresponding SHA256 checksum is calculated, which provides a record of when the PDF changed and the SHA256 hash that was calculated. This allows end users to know that they have the most recent version and can verify the integrity of a previously downloaded PDF. + + ### Valid PDF link + Additionally, during the nightly refresh process, the link to the form PDF is verified and the `valid_pdf` metadata is updated accordingly. If marked `true`, the link is valid and is a current form. If marked `false`, the link is either broken or the form has been removed. + + ### Deleted forms + If the `deleted_at` metadata is set, that means the VA has removed this form from the repository and it is no longer to be used. + title: VA Forms + version: 0.0.0 + x-apisguru-categories: + - forms + x-logo: + url: https://prod-va-gov-assets.s3-us-gov-west-1.amazonaws.com/img/design/icons/apple-touch-icon.png + x-origin: + - format: openapi + url: https://api.va.gov/services/va_forms/docs/v0/api + version: "3.0" + x-providerName: va.gov + x-serviceName: forms +paths: + /forms: + get: + description: Returns an index of all available VA forms. Optionally, pass a query parameter to filter forms by form number or title. + operationId: findForms + parameters: + - description: Returns form data based on entered form name. + in: query + name: query + required: false + schema: + type: string + responses: + "200": + content: + application/json: + schema: + properties: + data: + items: + $ref: "#/components/schemas/FormsIndex" + type: array + required: + - data + type: object + description: VA Forms index response + "401": + content: + application/json: + schema: + properties: + message: + example: Invalid authentication credentials + type: string + description: Unauthorized + "429": + content: + application/json: + schema: + properties: + message: + example: API rate limit exceeded + type: string + description: Too many requests + security: + - apikey: [] + summary: Returns all VA Forms and their last revision date + tags: + - Forms + "/forms/{form_name}": + get: + description: Returns a single form and the full revision history + operationId: findFormByFormName + parameters: + - description: The VA form_name of the form being requested. The exact form name must be passed, including proper placement of prefixes and/or hyphens. + example: 10-10EZ + in: path + name: form_name + required: true + schema: + type: string + responses: + "200": + content: + application/json: + schema: + properties: + data: + $ref: "#/components/schemas/FormShow" + required: + - data + type: object + description: VA Form Show response + "401": + content: + application/json: + schema: + properties: + message: + example: Invalid authentication credentials + type: string + description: Unauthorized + "404": + content: + application/json: + schema: + properties: + errors: + items: + properties: + message: + example: Form not found + type: string + type: array + required: + - errors + type: object + description: Not Found + "429": + content: + application/json: + schema: + properties: + message: + example: API rate limit exceeded + type: string + description: Too many requests + security: + - apikey: [] + summary: Find form by form name + tags: + - Forms +components: + examples: {} + links: {} + parameters: {} + requestBodies: {} + responses: {} + schemas: + FormShow: + description: Data for a particular VA form, including form version history. + properties: + attributes: + properties: + benefit_categories: + description: Listing of benefit categories and match + items: + properties: + description: + description: Description of the benefit category of the form + example: VA health care + type: string + name: + description: Name of the benefit category of the form + example: Health care + type: string + nullable: true + type: array + created_at: + description: Internal field for VA.gov use + example: 2021-03-30T16:28:30.338Z + format: date-time + nullable: true + type: string + deleted_at: + description: The timestamp at which the form was deleted + example: null + format: date-time + nullable: true + type: string + first_issued_on: + description: The date the form first became available + example: 2016-07-10 + format: date + nullable: true + type: string + form_details_url: + description: Location on www.va.gov of the info page for this form + example: https://www.va.gov/find-forms/about-form-10-10ez + nullable: true + type: string + form_name: + description: Name of the VA Form + example: 10-10EZ + type: string + form_tool_intro: + description: Introductory text describing the VA online tool for this form + example: You can apply online instead of filling out and sending us the paper form. + nullable: true + type: string + form_tool_url: + description: Location of the online tool for this form + example: https://www.va.gov/health-care/apply/application/introduction + nullable: true + type: string + form_type: + description: VA Type of the form + example: benefit + nullable: true + type: string + form_usage: + description: A description of how the form is to be used + example:

Use VA Form 10-10EZ if you’re a Veteran and want to apply for VA health care. You must be enrolled in...

+ nullable: true + type: string + language: + description: Language code of the form + example: en + nullable: true + type: string + last_revision_on: + description: The date the form was last updated + example: 2020-01-17 + format: date + nullable: true + type: string + pages: + description: Number of pages contained in the form + example: 5 + type: integer + related_forms: + description: A listing of other forms that relate to current form + items: + example: 10-10EZR + type: string + nullable: true + type: array + sha256: + description: A sha256 hash of the form contents + example: 5fe171299ece147e8b456961a38e17f1391026f26e9e170229317bc95d9827b7 + nullable: true + type: string + title: + description: Title of the form as given by VA + example: Instructions and Enrollment Application for Health Benefits + type: string + url: + description: Web location of the form + example: https://www.va.gov/vaforms/medical/pdf/10-10EZ-fillable.pdf + type: string + va_form_administration: + description: The VA organization that administers the form + example: Veterans Health Administration + nullable: true + type: string + valid_pdf: + description: A flag indicating whether the form url was confirmed as a valid download + example: "true" + type: boolean + versions: + description: The version history of revisions to the form + items: + properties: + revision_on: + description: The date the sha256 hash was calculated + example: 2012-01-01 + format: date + type: string + sha256: + description: A sha256 hash of the form contents for that version + example: 5fe171299ece147e8b456961a38e17f1391026f26e9e170229317bc95d9827b7 + type: string + nullable: true + type: array + id: + description: JSON API identifier + example: 10-10-EZ + type: string + type: + description: JSON API type specification + example: va_form + type: string + FormsIndex: + description: A listing of available VA forms and their location. + properties: + attributes: + properties: + benefit_categories: + description: Listing of benefit categories and match + items: + properties: + description: + description: Description of the benefit category of the form + example: VA health care + type: string + name: + description: Name of the benefit category of the form + example: Health care + type: string + nullable: true + type: array + deleted_at: + description: The timestamp at which the form was deleted + example: "null" + format: date-time + nullable: true + type: string + first_issued_on: + description: The date the form first became available + example: 2016-07-10 + format: date + nullable: true + type: string + form_details_url: + description: Location on www.va.gov of the info page for this form + example: https://www.va.gov/find-forms/about-form-10-10ez + nullable: true + type: string + form_name: + description: Name of the VA Form + example: 10-10EZ + type: string + form_tool_intro: + description: Introductory text describing the VA online tool for this form + example: You can apply online instead of filling out and sending us the paper form. + nullable: true + type: string + form_tool_url: + description: Location of the online tool for this form + example: https://www.va.gov/health-care/apply/application/introduction + nullable: true + type: string + form_type: + description: VA Type of the form + example: benefit + nullable: true + type: string + form_usage: + description: A description of how the form is to be used + example:

Use VA Form 10-10EZ if you’re a Veteran and want to apply for VA health care. You must be enrolled in...

+ nullable: true + type: string + language: + description: Language code of the form + example: en + type: string + last_revision_on: + description: The date the form was last updated + example: 2020-01-17 + format: date + nullable: true + type: string + last_sha256_change: + description: The date of the last sha256 hash change + example: 2019-05-30 + format: date + nullable: true + type: string + pages: + description: Number of pages contained in the form + example: 5 + type: integer + related_forms: + description: A listing of other forms that relate to current form + items: + example: 10-10EZR + type: string + nullable: true + type: array + sha256: + description: A sha256 hash of the form contents + example: 6e6465e2e1c89225871daa9b6d86b92d1c263c7b02f98541212af7b35272372b + nullable: true + type: string + title: + description: Title of the form as given by VA + example: Instructions and Enrollment Application for Health Benefits + type: string + url: + description: Web location of the form + example: https://www.va.gov/vaforms/medical/pdf/10-10EZ-fillable.pdf + type: string + va_form_administration: + description: The VA organization that administers the form + example: Veterans Health Administration + nullable: true + type: string + valid_pdf: + description: A flag indicating whether the form url was confirmed as a valid download + example: "true" + type: boolean + id: + description: JSON API identifier + example: "5403" + type: string + type: + description: JSON API type specification + example: va_form + type: string + securitySchemes: + apikey: + in: header + name: apikey + type: apiKey \ No newline at end of file diff --git a/integration_test/setup.sh b/integration_test/setup.sh index a87b847..472b9c6 100755 --- a/integration_test/setup.sh +++ b/integration_test/setup.sh @@ -21,6 +21,9 @@ cd petstore/petstore_api && dart pub get && cd ../.. dart run ../packages/tonik/bin/tonik.dart -p music_streaming_api -s music_streaming/openapi.yaml -o music_streaming --log-level verbose cd music_streaming/music_streaming_api && dart pub get && cd ../.. +dart run ../packages/tonik/bin/tonik.dart -p gov_api -s gov/openapi.yaml -o gov --log-level verbose +cd gov/gov_api && dart pub get && cd ../.. + # Download Imposter JAR only if it doesn't exist if [ ! -f imposter.jar ]; then echo "Downloading Imposter JAR..." From 2929544c80ee1882d07420343b4cb2b4b1495069 Mon Sep 17 00:00:00 2001 From: Tobias Ottenweller Date: Sun, 15 Jun 2025 17:13:30 +0200 Subject: [PATCH 19/37] chore: prep release 0.0.6 --- integration_test/gov/gov_test/pubspec.yaml | 2 +- packages/tonik_generate/lib/src/pubspec_generator.dart | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/integration_test/gov/gov_test/pubspec.yaml b/integration_test/gov/gov_test/pubspec.yaml index da73204..b4c67f7 100644 --- a/integration_test/gov/gov_test/pubspec.yaml +++ b/integration_test/gov/gov_test/pubspec.yaml @@ -11,7 +11,7 @@ dependencies: gov_api: path: ../gov_api path: ^1.8.3 - tonik_util: ^0.0.7 + tonik_util: ^0.0.6 dev_dependencies: test: ^1.24.0 diff --git a/packages/tonik_generate/lib/src/pubspec_generator.dart b/packages/tonik_generate/lib/src/pubspec_generator.dart index 257a253..7bb991e 100644 --- a/packages/tonik_generate/lib/src/pubspec_generator.dart +++ b/packages/tonik_generate/lib/src/pubspec_generator.dart @@ -27,7 +27,7 @@ dependencies: dio: ^5.8.0+1 lints: ^6.0.0 meta: ^1.16.0 - tonik_util: ^0.0.5 + tonik_util: ^0.0.6 '''; pubspecFile.writeAsStringSync(content); From 8dec4bee938d6c1ccda9c4d0a332cb0609e67219 Mon Sep 17 00:00:00 2001 From: Tobias Ottenweller Date: Sun, 15 Jun 2025 17:14:51 +0200 Subject: [PATCH 20/37] chore(release): publish packages - tonik@0.0.6 - tonik_core@0.0.6 - tonik_generate@0.0.6 - tonik_parse@0.0.6 - tonik_util@0.0.6 --- CHANGELOG.md | 38 ++++++++++++++++++++++++++++ packages/tonik/CHANGELOG.md | 2 ++ packages/tonik/pubspec.yaml | 8 +++--- packages/tonik_core/CHANGELOG.md | 2 ++ packages/tonik_core/pubspec.yaml | 2 +- packages/tonik_generate/CHANGELOG.md | 7 +++++ packages/tonik_generate/pubspec.yaml | 4 +-- packages/tonik_parse/CHANGELOG.md | 2 ++ packages/tonik_parse/pubspec.yaml | 4 +-- packages/tonik_util/CHANGELOG.md | 4 +++ packages/tonik_util/pubspec.yaml | 2 +- 11 files changed, 65 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d181de6..2bb3ab4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,44 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## 2025-06-15 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`tonik` - `v0.0.6`](#tonik---v006) + - [`tonik_core` - `v0.0.6`](#tonik_core---v006) + - [`tonik_generate` - `v0.0.6`](#tonik_generate---v006) + - [`tonik_parse` - `v0.0.6`](#tonik_parse---v006) + - [`tonik_util` - `v0.0.6`](#tonik_util---v006) + +--- + +#### `tonik` - `v0.0.6` + +#### `tonik_core` - `v0.0.6` + +#### `tonik_generate` - `v0.0.6` + + - **FIX**: proper handle dates. + - **FIX**: priority for exlict defined names of schemas. + - **FIX**: prio for explicitly defined names. + - **FIX**: proper hash code for classes with >20 properties. + +#### `tonik_parse` - `v0.0.6` + +#### `tonik_util` - `v0.0.6` + + - **FIX**: proper handle dates. + + ## 2025-06-02 ### Changes diff --git a/packages/tonik/CHANGELOG.md b/packages/tonik/CHANGELOG.md index c83e019..a9ba185 100644 --- a/packages/tonik/CHANGELOG.md +++ b/packages/tonik/CHANGELOG.md @@ -1,3 +1,5 @@ +## 0.0.6 + ## 0.0.5 ## 0.0.4 diff --git a/packages/tonik/pubspec.yaml b/packages/tonik/pubspec.yaml index 858d73c..df60006 100644 --- a/packages/tonik/pubspec.yaml +++ b/packages/tonik/pubspec.yaml @@ -1,6 +1,6 @@ name: tonik description: A Dart code generator for OpenAPI 3.0 and 3.1 specifications. -version: 0.0.5 +version: 0.0.6 repository: https://github.com/t-unit/tonik resolution: workspace @@ -20,9 +20,9 @@ environment: dependencies: args: ^2.5.0 logging: ^1.3.0 - tonik_core: ^0.0.5 - tonik_generate: ^0.0.5 - tonik_parse: ^0.0.5 + tonik_core: ^0.0.6 + tonik_generate: ^0.0.6 + tonik_parse: ^0.0.6 yaml: ^3.1.3 dev_dependencies: diff --git a/packages/tonik_core/CHANGELOG.md b/packages/tonik_core/CHANGELOG.md index 47f0e8a..9396082 100644 --- a/packages/tonik_core/CHANGELOG.md +++ b/packages/tonik_core/CHANGELOG.md @@ -1,3 +1,5 @@ +## 0.0.6 + ## 0.0.5 ## 0.0.4 diff --git a/packages/tonik_core/pubspec.yaml b/packages/tonik_core/pubspec.yaml index d7939b9..53e6254 100644 --- a/packages/tonik_core/pubspec.yaml +++ b/packages/tonik_core/pubspec.yaml @@ -1,6 +1,6 @@ name: tonik_core description: Core data structures and utilities for Tonik. -version: 0.0.5 +version: 0.0.6 repository: https://github.com/t-unit/tonik resolution: workspace diff --git a/packages/tonik_generate/CHANGELOG.md b/packages/tonik_generate/CHANGELOG.md index 7149c21..0eb61b4 100644 --- a/packages/tonik_generate/CHANGELOG.md +++ b/packages/tonik_generate/CHANGELOG.md @@ -1,3 +1,10 @@ +## 0.0.6 + + - **FIX**: proper handle dates. + - **FIX**: priority for exlict defined names of schemas. + - **FIX**: prio for explicitly defined names. + - **FIX**: proper hash code for classes with >20 properties. + ## 0.0.5 ## 0.0.4 diff --git a/packages/tonik_generate/pubspec.yaml b/packages/tonik_generate/pubspec.yaml index aadd59d..ef81ba1 100644 --- a/packages/tonik_generate/pubspec.yaml +++ b/packages/tonik_generate/pubspec.yaml @@ -1,6 +1,6 @@ name: tonik_generate description: A code generation package for Tonik. -version: 0.0.5 +version: 0.0.6 repository: https://github.com/t-unit/tonik resolution: workspace @@ -16,7 +16,7 @@ dependencies: meta: ^1.16.0 path: ^1.9.1 spell_out_numbers: ^1.0.0 - tonik_core: ^0.0.5 + tonik_core: ^0.0.6 dev_dependencies: test: ^1.24.0 diff --git a/packages/tonik_parse/CHANGELOG.md b/packages/tonik_parse/CHANGELOG.md index ca6a9fc..85629aa 100644 --- a/packages/tonik_parse/CHANGELOG.md +++ b/packages/tonik_parse/CHANGELOG.md @@ -1,3 +1,5 @@ +## 0.0.6 + ## 0.0.5 ## 0.0.4 diff --git a/packages/tonik_parse/pubspec.yaml b/packages/tonik_parse/pubspec.yaml index dec966c..0dfe1a0 100644 --- a/packages/tonik_parse/pubspec.yaml +++ b/packages/tonik_parse/pubspec.yaml @@ -1,6 +1,6 @@ name: tonik_parse description: The parsing module for Tonik. -version: 0.0.5 +version: 0.0.6 repository: https://github.com/t-unit/tonik resolution: workspace @@ -11,7 +11,7 @@ dependencies: collection: ^1.19.1 json_annotation: ^4.9.0 logging: ^1.3.0 - tonik_core: ^0.0.5 + tonik_core: ^0.0.6 dev_dependencies: build_runner: ^2.3.3 diff --git a/packages/tonik_util/CHANGELOG.md b/packages/tonik_util/CHANGELOG.md index b0c37af..8a18a94 100644 --- a/packages/tonik_util/CHANGELOG.md +++ b/packages/tonik_util/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.0.6 + + - **FIX**: proper handle dates. + ## 0.0.5 ## 0.0.4 diff --git a/packages/tonik_util/pubspec.yaml b/packages/tonik_util/pubspec.yaml index babc13d..2bccc0a 100644 --- a/packages/tonik_util/pubspec.yaml +++ b/packages/tonik_util/pubspec.yaml @@ -1,6 +1,6 @@ name: tonik_util description: Runtime tools for packages generated by Tonik. -version: 0.0.5 +version: 0.0.6 repository: https://github.com/t-unit/tonik resolution: workspace From 310d96533147a201cdb2278dc946d54684f5fac1 Mon Sep 17 00:00:00 2001 From: Tobias Ottenweller Date: Sun, 22 Jun 2025 17:56:58 +0200 Subject: [PATCH 21/37] chore: declare platform support --- packages/tonik/pubspec.yaml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/tonik/pubspec.yaml b/packages/tonik/pubspec.yaml index df60006..442cb3f 100644 --- a/packages/tonik/pubspec.yaml +++ b/packages/tonik/pubspec.yaml @@ -14,6 +14,14 @@ topics: - rest - swagger +platforms: + android: + ios: + linux: + macos: + web: + windows: + environment: sdk: ">=3.7.0 <4.0.0" From ec5a8bb4ac8955ad888278031ac96dfa163eae4b Mon Sep 17 00:00:00 2001 From: Tobias Ottenweller Date: Sun, 22 Jun 2025 18:52:41 +0200 Subject: [PATCH 22/37] feat: drop api prefix from generated server class. Many APIs use an `api` subdomain. This results in `ApiServer` and `ApiServer2` getting generated. With this change we this will becom `Server` and `ApiServer` and much better user experience. --- .../music_streaming_test/pubspec.lock | 4 +-- .../lib/src/naming/name_generator.dart | 4 +-- .../test/src/naming/name_generator_test.dart | 30 +++++++++---------- .../server/server_file_generator_test.dart | 6 ++-- .../src/server/server_generator_test.dart | 6 ++-- 5 files changed, 25 insertions(+), 25 deletions(-) diff --git a/integration_test/music_streaming/music_streaming_test/pubspec.lock b/integration_test/music_streaming/music_streaming_test/pubspec.lock index 0c8693c..8e8b3d4 100644 --- a/integration_test/music_streaming/music_streaming_test/pubspec.lock +++ b/integration_test/music_streaming/music_streaming_test/pubspec.lock @@ -388,10 +388,10 @@ packages: dependency: "direct main" description: name: tonik_util - sha256: f16c86d5349fac40893d1d8ae87f6507192d8886f16ba6acefeabef4a61ae3ef + sha256: d1fc175e99d440d654d4be8b78f008d2d0614820fb8d9f40507cd3e5ee34fcca url: "https://pub.dev" source: hosted - version: "0.0.5" + version: "0.0.6" typed_data: dependency: transitive description: diff --git a/packages/tonik_generate/lib/src/naming/name_generator.dart b/packages/tonik_generate/lib/src/naming/name_generator.dart index 937f658..2547e1c 100644 --- a/packages/tonik_generate/lib/src/naming/name_generator.dart +++ b/packages/tonik_generate/lib/src/naming/name_generator.dart @@ -376,7 +376,7 @@ class NameGenerator { ({String baseName, Map serverMap, String customName}) _generateServerNames(List servers, List uniqueNames) { - final baseName = _makeUnique('ApiServer', ''); + final baseName = _makeUnique('Server', ''); final resultMap = {}; for (var index = 0; index < servers.length; index++) { @@ -398,7 +398,7 @@ class NameGenerator { ({String baseName, Map serverMap, String customName}) _generateFallbackServerNames(List servers) { - final baseName = _makeUnique('ApiServer', ''); + final baseName = _makeUnique('Server', ''); final resultMap = {}; for (final server in servers) { diff --git a/packages/tonik_generate/test/src/naming/name_generator_test.dart b/packages/tonik_generate/test/src/naming/name_generator_test.dart index 8e2e473..8c35cff 100644 --- a/packages/tonik_generate/test/src/naming/name_generator_test.dart +++ b/packages/tonik_generate/test/src/naming/name_generator_test.dart @@ -1075,11 +1075,11 @@ void main() { final result = generator.generateServerNames(servers); expect(result.serverMap.length, 3); - expect(result.serverMap[servers[0]], 'ApiServer2'); + expect(result.serverMap[servers[0]], 'ApiServer'); expect(result.serverMap[servers[1]], 'StagingServer'); expect(result.serverMap[servers[2]], 'DevServer'); expect(result.customName, 'CustomServer'); - expect(result.baseName, 'ApiServer'); + expect(result.baseName, 'Server'); }); test('generates names based on multi-level subdomain differences', () { @@ -1100,7 +1100,7 @@ void main() { expect(result.serverMap[servers[1]], 'ApiStagingServer'); expect(result.serverMap[servers[2]], 'ApiProdServer'); expect(result.customName, 'CustomServer'); - expect(result.baseName, 'ApiServer'); + expect(result.baseName, 'Server'); }); test( @@ -1120,7 +1120,7 @@ void main() { expect(result.serverMap[servers[1]], 'AcmeServer'); expect(result.serverMap[servers[2]], 'TestServer'); expect(result.customName, 'CustomServer'); - expect(result.baseName, 'ApiServer'); + expect(result.baseName, 'Server'); }, ); @@ -1140,7 +1140,7 @@ void main() { expect(result.serverMap[servers[1]], 'V2Server'); expect(result.serverMap[servers[2]], 'BetaServer'); expect(result.customName, 'CustomServer'); - expect(result.baseName, 'ApiServer'); + expect(result.baseName, 'Server'); }); test( @@ -1156,11 +1156,11 @@ void main() { final result = generator.generateServerNames(servers); expect(result.serverMap.length, 3); - expect(result.serverMap[servers[0]], 'Server'); - expect(result.serverMap[servers[1]], 'Server2'); - expect(result.serverMap[servers[2]], 'Server3'); + expect(result.serverMap[servers[0]], 'Server2'); + expect(result.serverMap[servers[1]], 'Server3'); + expect(result.serverMap[servers[2]], 'Server4'); expect(result.customName, 'CustomServer'); - expect(result.baseName, 'ApiServer'); + expect(result.baseName, 'Server'); }, ); @@ -1180,7 +1180,7 @@ void main() { expect(result.serverMap.length, 1); expect(result.serverMap[servers[0]], 'CustomServer'); expect(result.customName, r'CustomServer$'); - expect(result.baseName, 'ApiServer'); + expect(result.baseName, 'Server'); }, ); @@ -1199,12 +1199,12 @@ void main() { final result = generator.generateServerNames(servers); expect(result.serverMap.length, 4); - expect(result.serverMap[servers[0]], 'Server'); - expect(result.serverMap[servers[1]], 'Server2'); - expect(result.serverMap[servers[2]], 'Server3'); - expect(result.serverMap[servers[3]], 'Server4'); + expect(result.serverMap[servers[0]], 'Server2'); + expect(result.serverMap[servers[1]], 'Server3'); + expect(result.serverMap[servers[2]], 'Server4'); + expect(result.serverMap[servers[3]], 'Server5'); expect(result.customName, 'CustomServer'); - expect(result.baseName, 'ApiServer'); + expect(result.baseName, 'Server'); }); }); }); diff --git a/packages/tonik_generate/test/src/server/server_file_generator_test.dart b/packages/tonik_generate/test/src/server/server_file_generator_test.dart index 580bd63..32de2f7 100644 --- a/packages/tonik_generate/test/src/server/server_file_generator_test.dart +++ b/packages/tonik_generate/test/src/server/server_file_generator_test.dart @@ -109,10 +109,10 @@ void main() { final fileContent = File(generatedFile.path).readAsStringSync(); // Check file name - expect(actualFileName, equals('api_server.dart')); + expect(actualFileName, equals('server.dart')); // Check file content - expect(fileContent, contains('sealed class ApiServer')); + expect(fileContent, contains('sealed class Server')); expect(fileContent, contains('class ProductionServer')); expect(fileContent, contains('class StagingServer')); expect(fileContent, contains('class CustomServer')); @@ -154,7 +154,7 @@ void main() { final fileContent = File(generatedFile.path).readAsStringSync(); // Expect base class and custom class to be generated - expect(fileContent, contains('sealed class ApiServer')); + expect(fileContent, contains('sealed class Server')); expect(fileContent, contains('class CustomServer')); // No server-specific classes should be present diff --git a/packages/tonik_generate/test/src/server/server_generator_test.dart b/packages/tonik_generate/test/src/server/server_generator_test.dart index 8f32742..97e912a 100644 --- a/packages/tonik_generate/test/src/server/server_generator_test.dart +++ b/packages/tonik_generate/test/src/server/server_generator_test.dart @@ -110,7 +110,7 @@ void main() { final productionClass = generatedClasses[1]; expect(productionClass.name, 'ProductionServer'); - expect(productionClass.extend?.accept(emitter).toString(), 'ApiServer'); + expect(productionClass.extend?.accept(emitter).toString(), 'Server'); expect( productionClass.docs.first, '/// Production server - https://production.example.com', @@ -144,7 +144,7 @@ void main() { final stagingClass = generatedClasses[2]; expect(stagingClass.name, 'StagingServer'); - expect(stagingClass.extend?.accept(emitter).toString(), 'ApiServer'); + expect(stagingClass.extend?.accept(emitter).toString(), 'Server'); expect( stagingClass.docs.first, '/// Staging server - https://staging.example.com', @@ -180,7 +180,7 @@ void main() { final customClass = generatedClasses.last; expect(customClass.name, 'CustomServer'); - expect(customClass.extend?.accept(emitter).toString(), 'ApiServer'); + expect(customClass.extend?.accept(emitter).toString(), 'Server'); expect( customClass.docs.first, '/// Custom server with user-defined base URL', From 1d6d61a71f15a29884508b49a18091db25a09570 Mon Sep 17 00:00:00 2001 From: Tobias Ottenweller Date: Sun, 22 Jun 2025 18:54:01 +0200 Subject: [PATCH 23/37] chore: cleanup readme --- packages/tonik/README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/tonik/README.md b/packages/tonik/README.md index 5f80f6f..c6b1c09 100644 --- a/packages/tonik/README.md +++ b/packages/tonik/README.md @@ -97,7 +97,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` -- Add custom `Date` model in util package to handle `format: date` properly - 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` From 51c3b93c5d1d80c0fa18ad4165b72b04c6092407 Mon Sep 17 00:00:00 2001 From: Tobias Ottenweller Date: Sun, 20 Jul 2025 11:12:27 +0200 Subject: [PATCH 24/37] feat: more verbose decimal parsing --- packages/tonik_parse/lib/src/model_importer.dart | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/tonik_parse/lib/src/model_importer.dart b/packages/tonik_parse/lib/src/model_importer.dart index 12bb191..5ec0bd0 100644 --- a/packages/tonik_parse/lib/src/model_importer.dart +++ b/packages/tonik_parse/lib/src/model_importer.dart @@ -119,7 +119,13 @@ class ModelImporter { context: context, ), 'string' when schema.format == 'date' => DateModel(context: context), - 'string' when schema.format == 'decimal' || schema.format == 'currency' => + 'string' + when [ + 'decimal', + 'currency', + 'money', + 'number', + ].contains(schema.format) => DecimalModel(context: context), 'string' when schema.enumerated != null => _parseEnum( name, From 76a6143535a7abcbf6bdc8589a675ab95e5e5eb5 Mon Sep 17 00:00:00 2001 From: Tobias Ottenweller Date: Sun, 20 Jul 2025 12:48:53 +0200 Subject: [PATCH 25/37] feat: time zone aware encoding of date time objects --- .../to_json_value_expression_generator.dart | 2 +- .../src/model/class_json_generator_test.dart | 6 +- .../src/operation/options_generator_test.dart | 2 +- ..._json_value_expression_generator_test.dart | 18 +- .../lib/src/encoding/base_encoder.dart | 4 +- .../lib/src/encoding/datetime_extension.dart | 60 +++++++ packages/tonik_util/lib/tonik_util.dart | 1 + packages/tonik_util/pubspec.yaml | 1 + .../test/src/decoding/json_decoder_test.dart | 8 +- .../src/decoding/simple_decoder_test.dart | 9 +- .../src/encoder/datetime_extension_test.dart | 162 ++++++++++++++++++ 11 files changed, 254 insertions(+), 19 deletions(-) create mode 100644 packages/tonik_util/lib/src/encoding/datetime_extension.dart create mode 100644 packages/tonik_util/test/src/encoder/datetime_extension_test.dart 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 7d0a55d..80011cc 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 @@ -47,7 +47,7 @@ String? _getSerializationSuffix(Model model, bool isNullable) { isNullable || (model is EnumModel && model.isNullable) ? '?' : ''; return switch (model) { - DateTimeModel() => '$nullablePart.toIso8601String()', + DateTimeModel() => '$nullablePart.toTimeZonedIso8601String()', DecimalModel() => '$nullablePart.toString()', DateModel() || EnumModel() || 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 7fb5505..8aba69c 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 @@ -258,7 +258,7 @@ void main() { const expectedMethod = ''' Object? toJson() => { r'name': name, - r'createdAt': createdAt.toIso8601String(), + r'createdAt': createdAt.toTimeZonedIso8601String(), r'status': status.toJson(), r'homeAddress': homeAddress?.toJson(), }; @@ -323,7 +323,9 @@ void main() { const expectedMethod = ''' Object? toJson() => { r'tags': tags, - r'meetingTimes': meetingTimes.map((e) => e.toIso8601String()).toList(), + r'meetingTimes': meetingTimes + .map((e) => e.toTimeZonedIso8601String()) + .toList(), r'addresses': addresses?.map((e) => e.toJson()).toList(), }; '''; diff --git a/packages/tonik_generate/test/src/operation/options_generator_test.dart b/packages/tonik_generate/test/src/operation/options_generator_test.dart index dd7b4cf..255633c 100644 --- a/packages/tonik_generate/test/src/operation/options_generator_test.dart +++ b/packages/tonik_generate/test/src/operation/options_generator_test.dart @@ -367,7 +367,7 @@ void main() { allowEmpty: false, ); headers[r'X-Required-Date'] = headerEncoder.encode( - xRequiredDate.toIso8601String(), + xRequiredDate.toTimeZonedIso8601String(), explode: false, allowEmpty: true, ); diff --git a/packages/tonik_generate/test/src/util/to_json_value_expression_generator_test.dart b/packages/tonik_generate/test/src/util/to_json_value_expression_generator_test.dart index f54b21a..2c7e9e9 100644 --- a/packages/tonik_generate/test/src/util/to_json_value_expression_generator_test.dart +++ b/packages/tonik_generate/test/src/util/to_json_value_expression_generator_test.dart @@ -53,7 +53,7 @@ void main() { ); expect( buildToJsonPropertyExpression('startTime', property), - 'startTime.toIso8601String()', + 'startTime.toTimeZonedIso8601String()', ); }); @@ -188,7 +188,7 @@ void main() { ); expect( buildToJsonPropertyExpression('meetingTimes', property), - 'meetingTimes.map((e) => e.toIso8601String()).toList()', + 'meetingTimes.map((e) => e.toTimeZonedIso8601String()).toList()', ); }); @@ -259,7 +259,7 @@ void main() { ); expect( buildToJsonPropertyExpression('createdAt', property), - 'createdAt.toIso8601String()', + 'createdAt.toTimeZonedIso8601String()', ); }); @@ -278,7 +278,7 @@ void main() { ); expect( buildToJsonPropertyExpression('updatedAt', property), - 'updatedAt?.toIso8601String()', + 'updatedAt?.toTimeZonedIso8601String()', ); }); @@ -497,7 +497,7 @@ void main() { ); expect( buildToJsonPropertyExpression('meetingTimes', property), - 'meetingTimes.map((e) => e.toIso8601String()).toList()', + 'meetingTimes.map((e) => e.toTimeZonedIso8601String()).toList()', ); }); @@ -519,7 +519,7 @@ void main() { ); expect( buildToJsonPropertyExpression('meetingTimes', property), - 'meetingTimes?.map((e) => e.toIso8601String()).toList()', + 'meetingTimes?.map((e) => e.toTimeZonedIso8601String()).toList()', ); }); @@ -626,7 +626,7 @@ void main() { ); expect( buildToJsonPathParameterExpression('startTime', parameter), - 'startTime.toIso8601String()', + 'startTime.toTimeZonedIso8601String()', ); }); @@ -713,7 +713,7 @@ void main() { ); expect( buildToJsonQueryParameterExpression('startTime', parameter), - 'startTime.toIso8601String()', + 'startTime.toTimeZonedIso8601String()', ); }); @@ -779,7 +779,7 @@ void main() { ); expect( buildToJsonHeaderParameterExpression('timestamp', parameter), - 'timestamp.toIso8601String()', + 'timestamp.toTimeZonedIso8601String()', ); }); diff --git a/packages/tonik_util/lib/src/encoding/base_encoder.dart b/packages/tonik_util/lib/src/encoding/base_encoder.dart index 145cefa..eb21e97 100644 --- a/packages/tonik_util/lib/src/encoding/base_encoder.dart +++ b/packages/tonik_util/lib/src/encoding/base_encoder.dart @@ -1,5 +1,5 @@ import 'package:meta/meta.dart'; -import 'package:tonik_util/src/encoding/encoding_exception.dart'; +import 'package:tonik_util/tonik_util.dart'; /// Base class for OpenAPI parameter style encoders. /// @@ -84,7 +84,7 @@ abstract class BaseEncoder { } if (value is DateTime) { - return value.toIso8601String(); + return value.toTimeZonedIso8601String(); } return value.toString(); diff --git a/packages/tonik_util/lib/src/encoding/datetime_extension.dart b/packages/tonik_util/lib/src/encoding/datetime_extension.dart new file mode 100644 index 0000000..0561254 --- /dev/null +++ b/packages/tonik_util/lib/src/encoding/datetime_extension.dart @@ -0,0 +1,60 @@ +/// Extension on DateTime to provide timezone-aware encoding. +/// +/// This extension ensures that DateTime objects are encoded with their full +/// timezone information, unlike [DateTime.toIso8601String()] which only +/// works correctly for UTC dates. +extension DateTimeEncodingExtension on DateTime { + /// Encodes this DateTime to a string representation that preserves + /// timezone information. + /// + /// For UTC dates, this returns the same as [DateTime.toIso8601String()]. + /// For local dates, this ensures the timezone offset is properly included. + String toTimeZonedIso8601String() { + if (isUtc) { + // For UTC dates, use toIso8601String which works correctly + return toIso8601String(); + } + + // For local dates, we need to include timezone offset + // Format the base date and time manually to match toIso8601String format + final year = this.year; + final month = _twoDigits(this.month); + final day = _twoDigits(this.day); + final hour = _twoDigits(this.hour); + final minute = _twoDigits(this.minute); + final second = _twoDigits(this.second); + + // Add milliseconds if present + final millisecondString = millisecond > 0 + ? '.${_threeDigits(millisecond)}' + : ''; + + // Add microseconds if present + final microsecondString = microsecond > 0 + ? _threeDigits(microsecond) + : ''; + + // Get the timezone offset in hours and minutes + final offset = timeZoneOffset; + final offsetHours = offset.inHours.abs(); + final offsetMinutes = offset.inMinutes.abs() % 60; + + // Format timezone offset + final offsetSign = offset.isNegative ? '-' : '+'; + final offsetString = '$offsetSign' + '${_twoDigits(offsetHours)}:${_twoDigits(offsetMinutes)}'; + + return '$year-$month-${day}T$hour:$minute:$second' + '$millisecondString$microsecondString$offsetString'; + } + + /// Formats a number as two digits with leading zero if needed. + static String _twoDigits(int n) { + return n.toString().padLeft(2, '0'); + } + + /// Formats a number as three digits with leading zeros if needed. + static String _threeDigits(int n) { + return n.toString().padLeft(3, '0'); + } +} diff --git a/packages/tonik_util/lib/tonik_util.dart b/packages/tonik_util/lib/tonik_util.dart index 936c273..98bbe84 100644 --- a/packages/tonik_util/lib/tonik_util.dart +++ b/packages/tonik_util/lib/tonik_util.dart @@ -6,6 +6,7 @@ export 'src/decoding/decoding_exception.dart'; export 'src/decoding/json_decoder.dart'; export 'src/decoding/simple_decoder.dart'; export 'src/dio/server_config.dart'; +export 'src/encoding/datetime_extension.dart'; export 'src/encoding/deep_object_encoder.dart'; export 'src/encoding/delimited_encoder.dart'; export 'src/encoding/encoding_exception.dart'; diff --git a/packages/tonik_util/pubspec.yaml b/packages/tonik_util/pubspec.yaml index 2bccc0a..ac77faa 100644 --- a/packages/tonik_util/pubspec.yaml +++ b/packages/tonik_util/pubspec.yaml @@ -12,6 +12,7 @@ dependencies: collection: ^1.19.1 dio: ^5.0.0 meta: ^1.16.0 + timezone: ^0.10.1 dev_dependencies: test: ^1.24.0 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 1b21dc2..98038eb 100644 --- a/packages/tonik_util/test/src/decoding/json_decoder_test.dart +++ b/packages/tonik_util/test/src/decoding/json_decoder_test.dart @@ -5,13 +5,14 @@ import 'package:test/test.dart'; import 'package:tonik_util/src/date.dart'; import 'package:tonik_util/src/decoding/decoding_exception.dart'; import 'package:tonik_util/src/decoding/json_decoder.dart'; +import 'package:tonik_util/src/encoding/datetime_extension.dart'; void main() { group('JsonDecoder', () { group('DateTime', () { test('decodes DateTime values', () { final date = DateTime.utc(2024, 3, 14); - expect(date.toIso8601String().decodeJsonDateTime(), date); + expect(date.toTimeZonedIso8601String().decodeJsonDateTime(), date); expect( () => 123.decodeJsonDateTime(), throwsA(isA()), @@ -24,7 +25,10 @@ void main() { test('decodes nullable DateTime values', () { final date = DateTime.utc(2024, 3, 14); - expect(date.toIso8601String().decodeJsonNullableDateTime(), date); + expect( + date.toTimeZonedIso8601String().decodeJsonNullableDateTime(), + date, + ); expect(null.decodeJsonNullableDateTime(), isNull); expect(''.decodeJsonNullableDateTime(), isNull); expect( 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 366b6a1..5fb8ac9 100644 --- a/packages/tonik_util/test/src/decoding/simple_decoder_test.dart +++ b/packages/tonik_util/test/src/decoding/simple_decoder_test.dart @@ -3,6 +3,7 @@ import 'package:test/test.dart'; import 'package:tonik_util/src/date.dart'; import 'package:tonik_util/src/decoding/decoding_exception.dart'; import 'package:tonik_util/src/decoding/simple_decoder.dart'; +import 'package:tonik_util/src/encoding/datetime_extension.dart'; void main() { group('SimpleDecoder', () { @@ -48,7 +49,7 @@ void main() { test('decodes DateTime values', () { final date = DateTime.utc(2024, 3, 14); - expect(date.toIso8601String().decodeSimpleDateTime(), date); + expect(date.toTimeZonedIso8601String().decodeSimpleDateTime(), date); expect( () => 'not-a-date'.decodeSimpleDateTime(), throwsA(isA()), @@ -128,7 +129,11 @@ void main() { expect('3.14'.decodeSimpleNullableDouble(), 3.14); expect('true'.decodeSimpleNullableBool(), isTrue); expect( - '2024-03-14T00:00:00.000Z'.decodeSimpleNullableDateTime(), + DateTime.utc( + 2024, + 3, + 14, + ).toTimeZonedIso8601String().decodeSimpleNullableDateTime(), DateTime.utc(2024, 3, 14), ); expect( diff --git a/packages/tonik_util/test/src/encoder/datetime_extension_test.dart b/packages/tonik_util/test/src/encoder/datetime_extension_test.dart new file mode 100644 index 0000000..117a4db --- /dev/null +++ b/packages/tonik_util/test/src/encoder/datetime_extension_test.dart @@ -0,0 +1,162 @@ +import 'package:test/test.dart'; +import 'package:timezone/data/latest.dart' as tz; +import 'package:timezone/timezone.dart' as tz; +import 'package:tonik_util/src/encoding/datetime_extension.dart'; + +void main() { + setUpAll(tz.initializeTimeZones); + + group('DateTimeEncodingExtension', () { + group('toTimeZonedIso8601String', () { + 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, equals(utcDateTime.toIso8601String())); + expect(result, equals('2023-12-25T15:30:45.123Z')); + }); + }); + + group('timezone handling', () { + test('encodes in EST (UTC-5:00)', () { + final estLocation = tz.getLocation('America/New_York'); + final estDateTime = tz.TZDateTime( + estLocation, + 2023, + 12, + 25, + 15, + 30, + 45, + ); + + final result = estDateTime.toTimeZonedIso8601String(); + + // Should include EST timezone offset (-05:00) + expect(result, equals('2023-12-25T15:30:45-05:00')); + }); + + test('encodes in PST (UTC-8:00)', () { + final pstLocation = tz.getLocation('America/Los_Angeles'); + final pstDateTime = tz.TZDateTime( + pstLocation, + 2023, + 12, + 25, + 18, + 30, + 45, + ); + + final result = pstDateTime.toTimeZonedIso8601String(); + + // Should include PST timezone offset (-08:00) + expect(result, equals('2023-12-25T18:30:45-08:00')); + }); + + test('encodes in IST (UTC+5:30)', () { + final istLocation = tz.getLocation('Asia/Kolkata'); + final istDateTime = tz.TZDateTime( + istLocation, + 2023, + 12, + 25, + 20, + 0, + 45, + ); + + final result = istDateTime.toTimeZonedIso8601String(); + + // Should include IST timezone offset (+05:30) + expect(result, equals('2023-12-25T20:00:45+05:30')); + }); + + test('encodes in CET (UTC+1:00)', () { + final cetLocation = tz.getLocation('Europe/Paris'); + final cetDateTime = tz.TZDateTime( + cetLocation, + 2023, + 12, + 25, + 16, + 30, + 45, + ); + + final result = cetDateTime.toTimeZonedIso8601String(); + + // Should include CET timezone offset (+01:00) + expect(result, equals('2023-12-25T16:30:45+01:00')); + }); + + test('encodes in GMT (UTC+0:00)', () { + final gmtLocation = tz.getLocation('Europe/London'); + final gmtDateTime = tz.TZDateTime( + gmtLocation, + 2023, + 12, + 25, + 15, + 30, + 45, + ); + + final result = gmtDateTime.toTimeZonedIso8601String(); + + // Should include GMT timezone offset (+00:00) + expect(result, equals('2023-12-25T15:30:45+00:00')); + }); + + test('encodes with milliseconds in timezone', () { + final estLocation = tz.getLocation('America/New_York'); + final estDateTime = tz.TZDateTime( + estLocation, + 2023, + 12, + 25, + 15, + 30, + 45, + 123, + ); + + final result = estDateTime.toTimeZonedIso8601String(); + + // Should include EST timezone offset (-05:00) and milliseconds + expect(result, equals('2023-12-25T15:30:45.123-05:00')); + }); + + 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, + 456, + ); + + final result = pstDateTime.toTimeZonedIso8601String(); + + // Should include PST timezone offset (-08:00) and microseconds + expect(result, equals('2023-12-25T18:30:45.123456-08:00')); + }); + + 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, equals('2009-06-30T18:30:00+09:00')); + }); + }); + }); +} From 1c2c1647504f82a96d8f424f99af38bd0b298569 Mon Sep 17 00:00:00 2001 From: Tobias Ottenweller Date: Sun, 20 Jul 2025 13:16:00 +0200 Subject: [PATCH 26/37] chore: matcher cleanup --- .../test/model/query_parameter_test.dart | 24 +++++++++---------- .../test/model/request_header_test.dart | 24 +++++++++---------- .../test/model/response_header_test.dart | 22 ++++++++--------- .../response_class_generator_test.dart | 10 ++++---- .../src/response/response_generator_test.dart | 4 ++-- .../server/server_file_generator_test.dart | 2 +- .../test/model/model_alias_test.dart | 6 ++--- .../test/model/model_multiple_types_test.dart | 8 +++---- .../test/src/decoding/json_decoder_test.dart | 2 +- .../src/encoder/datetime_extension_test.dart | 20 ++++++++-------- 10 files changed, 61 insertions(+), 61 deletions(-) diff --git a/packages/tonik_core/test/model/query_parameter_test.dart b/packages/tonik_core/test/model/query_parameter_test.dart index 732ede2..76446c1 100644 --- a/packages/tonik_core/test/model/query_parameter_test.dart +++ b/packages/tonik_core/test/model/query_parameter_test.dart @@ -23,17 +23,17 @@ void main() { final resolved = param.resolve(name: 'newName'); - expect(resolved.name, equals('newName')); - expect(resolved.rawName, equals('originalRawName')); - expect(resolved.description, equals('description')); + expect(resolved.name, 'newName'); + expect(resolved.rawName, 'originalRawName'); + expect(resolved.description, 'description'); expect(resolved.isRequired, isTrue); expect(resolved.isDeprecated, isFalse); expect(resolved.allowEmptyValue, isFalse); expect(resolved.allowReserved, isFalse); expect(resolved.explode, isFalse); - expect(resolved.model, equals(model)); - expect(resolved.encoding, equals(QueryParameterEncoding.form)); - expect(resolved.context, equals(context)); + expect(resolved.model, model); + expect(resolved.encoding, QueryParameterEncoding.form); + expect(resolved.context, context); }); test('resolve preserves original name when no new name provided', () { @@ -56,7 +56,7 @@ void main() { final resolved = param.resolve(); - expect(resolved.name, equals('originalName')); + expect(resolved.name, 'originalName'); }); test('QueryParameterAlias.resolve resolves with alias name', () { @@ -85,8 +85,8 @@ void main() { final resolved = alias.resolve(); - expect(resolved.name, equals('aliasName')); - expect(resolved.rawName, equals('originalRawName')); + expect(resolved.name, 'aliasName'); + expect(resolved.rawName, 'originalRawName'); }); test( @@ -117,7 +117,7 @@ void main() { final resolved = alias.resolve(name: 'overrideName'); - expect(resolved.name, equals('overrideName')); + expect(resolved.name, 'overrideName'); }, ); @@ -153,8 +153,8 @@ void main() { final resolved = secondAlias.resolve(); - expect(resolved.name, equals('secondAliasName')); - expect(resolved.rawName, equals('originalRawName')); + expect(resolved.name, 'secondAliasName'); + expect(resolved.rawName, 'originalRawName'); }); }); } diff --git a/packages/tonik_core/test/model/request_header_test.dart b/packages/tonik_core/test/model/request_header_test.dart index b6edca2..38b5147 100644 --- a/packages/tonik_core/test/model/request_header_test.dart +++ b/packages/tonik_core/test/model/request_header_test.dart @@ -22,16 +22,16 @@ void main() { final resolved = header.resolve(name: 'newName'); - expect(resolved.name, equals('newName')); - expect(resolved.rawName, equals('originalRawName')); - expect(resolved.description, equals('description')); + expect(resolved.name, 'newName'); + expect(resolved.rawName, 'originalRawName'); + expect(resolved.description, 'description'); expect(resolved.isRequired, isTrue); expect(resolved.isDeprecated, isFalse); expect(resolved.allowEmptyValue, isFalse); expect(resolved.explode, isFalse); - expect(resolved.model, equals(model)); - expect(resolved.encoding, equals(HeaderParameterEncoding.simple)); - expect(resolved.context, equals(context)); + expect(resolved.model, model); + expect(resolved.encoding, HeaderParameterEncoding.simple); + expect(resolved.context, context); }); test('resolve preserves original name when no new name provided', () { @@ -53,7 +53,7 @@ void main() { final resolved = header.resolve(); - expect(resolved.name, equals('originalName')); + expect(resolved.name, 'originalName'); }); test('RequestHeaderAlias.resolve resolves with alias name', () { @@ -81,8 +81,8 @@ void main() { final resolved = alias.resolve(); - expect(resolved.name, equals('aliasName')); - expect(resolved.rawName, equals('originalRawName')); + expect(resolved.name, 'aliasName'); + expect(resolved.rawName, 'originalRawName'); }); test( @@ -112,7 +112,7 @@ void main() { final resolved = alias.resolve(name: 'overrideName'); - expect(resolved.name, equals('overrideName')); + expect(resolved.name, 'overrideName'); }, ); @@ -147,8 +147,8 @@ void main() { final resolved = secondAlias.resolve(); - expect(resolved.name, equals('secondAliasName')); - expect(resolved.rawName, equals('originalRawName')); + expect(resolved.name, 'secondAliasName'); + expect(resolved.rawName, 'originalRawName'); }); }); } diff --git a/packages/tonik_core/test/model/response_header_test.dart b/packages/tonik_core/test/model/response_header_test.dart index 628766b..7bef64a 100644 --- a/packages/tonik_core/test/model/response_header_test.dart +++ b/packages/tonik_core/test/model/response_header_test.dart @@ -20,14 +20,14 @@ void main() { final resolved = header.resolve(name: 'newName'); - expect(resolved.name, equals('newName')); - expect(resolved.description, equals('description')); + expect(resolved.name, 'newName'); + expect(resolved.description, 'description'); expect(resolved.isRequired, isTrue); expect(resolved.isDeprecated, isFalse); expect(resolved.explode, isFalse); - expect(resolved.model, equals(model)); - expect(resolved.encoding, equals(ResponseHeaderEncoding.simple)); - expect(resolved.context, equals(context)); + expect(resolved.model, model); + expect(resolved.encoding, ResponseHeaderEncoding.simple); + expect(resolved.context, context); }); test('resolve preserves original name when no new name provided', () { @@ -47,7 +47,7 @@ void main() { final resolved = header.resolve(); - expect(resolved.name, equals('originalName')); + expect(resolved.name, 'originalName'); }); test('ResponseHeaderAlias.resolve resolves with alias name', () { @@ -73,8 +73,8 @@ void main() { final resolved = alias.resolve(); - expect(resolved.name, equals('aliasName')); - expect(resolved.description, equals('description')); + expect(resolved.name, 'aliasName'); + expect(resolved.description, 'description'); }); test( @@ -102,7 +102,7 @@ void main() { final resolved = alias.resolve(name: 'overrideName'); - expect(resolved.name, equals('overrideName')); + expect(resolved.name, 'overrideName'); }, ); @@ -135,8 +135,8 @@ void main() { final resolved = secondAlias.resolve(); - expect(resolved.name, equals('secondAliasName')); - expect(resolved.description, equals('description')); + expect(resolved.name, 'secondAliasName'); + expect(resolved.description, 'description'); }); }); } diff --git a/packages/tonik_generate/test/src/response/response_class_generator_test.dart b/packages/tonik_generate/test/src/response/response_class_generator_test.dart index ae48ec6..f5438a5 100644 --- a/packages/tonik_generate/test/src/response/response_class_generator_test.dart +++ b/packages/tonik_generate/test/src/response/response_class_generator_test.dart @@ -80,21 +80,21 @@ void main() { // Required header field final xTestField = fields[0]; expect(xTestField.name, 'xTest'); - expect(xTestField.modifier, equals(FieldModifier.final$)); + expect(xTestField.modifier, FieldModifier.final$); expect(xTestField.type?.accept(emitter).toString(), 'String'); expect(xTestField.annotations.isEmpty, isTrue); // Optional header field final xOptionalField = fields[2]; expect(xOptionalField.name, 'xOptional'); - expect(xOptionalField.modifier, equals(FieldModifier.final$)); + expect(xOptionalField.modifier, FieldModifier.final$); expect(xOptionalField.type?.accept(emitter).toString(), 'int?'); expect(xOptionalField.annotations.isEmpty, isTrue); // Body field final bodyField = fields[1]; expect(bodyField.name, 'body'); - expect(bodyField.modifier, equals(FieldModifier.final$)); + expect(bodyField.modifier, FieldModifier.final$); expect(bodyField.type?.accept(emitter).toString(), 'String'); expect(bodyField.annotations.isEmpty, isTrue); @@ -158,14 +158,14 @@ void main() { final headerField = fields[0]; expect(headerField.name, 'bodyHeader'); expect(headerField.type?.accept(emitter).toString(), 'String'); - expect(headerField.modifier, equals(FieldModifier.final$)); + expect(headerField.modifier, FieldModifier.final$); expect(headerField.annotations.isEmpty, isTrue); // Body field should keep original name final bodyField = fields[1]; expect(bodyField.name, 'body'); expect(bodyField.type?.accept(emitter).toString(), 'int'); - expect(bodyField.modifier, equals(FieldModifier.final$)); + expect(bodyField.modifier, FieldModifier.final$); expect(bodyField.annotations.isEmpty, isTrue); // Verify constructor parameters maintain the same names diff --git a/packages/tonik_generate/test/src/response/response_generator_test.dart b/packages/tonik_generate/test/src/response/response_generator_test.dart index c1920a8..1c99560 100644 --- a/packages/tonik_generate/test/src/response/response_generator_test.dart +++ b/packages/tonik_generate/test/src/response/response_generator_test.dart @@ -84,7 +84,7 @@ void main() { ); final result = generator.generate(aliasResponse); - expect(result.filename, equals('alias_response.dart')); + expect(result.filename, 'alias_response.dart'); expect(result.code, contains('typedef AliasResponse =')); }); @@ -115,7 +115,7 @@ void main() { ); final result = generator.generate(response); - expect(result.filename, equals('single_body_response.dart')); + expect(result.filename, 'single_body_response.dart'); expect(result.code, contains('class SingleBodyResponse')); }); diff --git a/packages/tonik_generate/test/src/server/server_file_generator_test.dart b/packages/tonik_generate/test/src/server/server_file_generator_test.dart index 32de2f7..3015cab 100644 --- a/packages/tonik_generate/test/src/server/server_file_generator_test.dart +++ b/packages/tonik_generate/test/src/server/server_file_generator_test.dart @@ -109,7 +109,7 @@ void main() { final fileContent = File(generatedFile.path).readAsStringSync(); // Check file name - expect(actualFileName, equals('server.dart')); + expect(actualFileName, 'server.dart'); // Check file content expect(fileContent, contains('sealed class Server')); diff --git a/packages/tonik_parse/test/model/model_alias_test.dart b/packages/tonik_parse/test/model/model_alias_test.dart index cf390d3..dfe2fd8 100644 --- a/packages/tonik_parse/test/model/model_alias_test.dart +++ b/packages/tonik_parse/test/model/model_alias_test.dart @@ -85,7 +85,7 @@ void main() { context: context, ); - expect(alias.resolved, equals(stringModel)); + expect(alias.resolved, stringModel); }); test('resolves single-level alias', () { @@ -102,7 +102,7 @@ void main() { context: context, ); - expect(outerAlias.resolved, equals(stringModel)); + expect(outerAlias.resolved, stringModel); }); test('resolves multi-level alias', () { @@ -124,7 +124,7 @@ void main() { context: context, ); - expect(level1.resolved, equals(stringModel)); + expect(level1.resolved, stringModel); }); }); } diff --git a/packages/tonik_parse/test/model/model_multiple_types_test.dart b/packages/tonik_parse/test/model/model_multiple_types_test.dart index d2f9dca..9ce32d2 100644 --- a/packages/tonik_parse/test/model/model_multiple_types_test.dart +++ b/packages/tonik_parse/test/model/model_multiple_types_test.dart @@ -66,7 +66,7 @@ void main() { expect(stringOrNumber.isNullable, isFalse); final oneOf = stringOrNumber.model as OneOfModel; - expect(oneOf.models.length, equals(2)); + expect(oneOf.models.length, 2); expect( oneOf.models.map((m) => m.model).toList(), containsAll([isA(), isA()]), @@ -109,7 +109,7 @@ void main() { expect(nullableMultiType.isNullable, isTrue); final oneOf = nullableMultiType.model as OneOfModel; - expect(oneOf.models.length, equals(2)); + expect(oneOf.models.length, 2); expect( oneOf.models.map((m) => m.model).toList(), containsAll([isA(), isA()]), @@ -128,7 +128,7 @@ void main() { expect(nullableMultiType.isNullable, isTrue); final oneOf = nullableMultiType.model as OneOfModel; - expect(oneOf.models.length, equals(2)); + expect(oneOf.models.length, 2); expect( oneOf.models.map((m) => m.model).toList(), containsAll([isA(), isA()]), @@ -150,7 +150,7 @@ void main() { expect(listModel.content, isA()); final content = listModel.content as OneOfModel; - expect(content.models.length, equals(2)); + expect(content.models.length, 2); expect( content.models.map((m) => m.model).toList(), containsAll([isA(), isA()]), 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 98038eb..1f6981e 100644 --- a/packages/tonik_util/test/src/decoding/json_decoder_test.dart +++ b/packages/tonik_util/test/src/decoding/json_decoder_test.dart @@ -321,7 +321,7 @@ void main() { group('decodeMap', () { test('decodes valid map', () { final map = {'key': 'value'}; - expect(map.decodeMap(), equals(map)); + expect(map.decodeMap(), map); }); test('throws on null', () { 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 117a4db..77be07d 100644 --- a/packages/tonik_util/test/src/encoder/datetime_extension_test.dart +++ b/packages/tonik_util/test/src/encoder/datetime_extension_test.dart @@ -13,8 +13,8 @@ void main() { final result = utcDateTime.toTimeZonedIso8601String(); // Should match toIso8601String for UTC dates - expect(result, equals(utcDateTime.toIso8601String())); - expect(result, equals('2023-12-25T15:30:45.123Z')); + expect(result, utcDateTime.toIso8601String()); + expect(result, '2023-12-25T15:30:45.123Z'); }); }); @@ -34,7 +34,7 @@ void main() { final result = estDateTime.toTimeZonedIso8601String(); // Should include EST timezone offset (-05:00) - expect(result, equals('2023-12-25T15:30:45-05:00')); + expect(result, '2023-12-25T15:30:45-05:00'); }); test('encodes in PST (UTC-8:00)', () { @@ -52,7 +52,7 @@ void main() { final result = pstDateTime.toTimeZonedIso8601String(); // Should include PST timezone offset (-08:00) - expect(result, equals('2023-12-25T18:30:45-08:00')); + expect(result, '2023-12-25T18:30:45-08:00'); }); test('encodes in IST (UTC+5:30)', () { @@ -70,7 +70,7 @@ void main() { final result = istDateTime.toTimeZonedIso8601String(); // Should include IST timezone offset (+05:30) - expect(result, equals('2023-12-25T20:00:45+05:30')); + expect(result, '2023-12-25T20:00:45+05:30'); }); test('encodes in CET (UTC+1:00)', () { @@ -88,7 +88,7 @@ void main() { final result = cetDateTime.toTimeZonedIso8601String(); // Should include CET timezone offset (+01:00) - expect(result, equals('2023-12-25T16:30:45+01:00')); + expect(result, '2023-12-25T16:30:45+01:00'); }); test('encodes in GMT (UTC+0:00)', () { @@ -106,7 +106,7 @@ void main() { final result = gmtDateTime.toTimeZonedIso8601String(); // Should include GMT timezone offset (+00:00) - expect(result, equals('2023-12-25T15:30:45+00:00')); + expect(result, '2023-12-25T15:30:45+00:00'); }); test('encodes with milliseconds in timezone', () { @@ -125,7 +125,7 @@ void main() { final result = estDateTime.toTimeZonedIso8601String(); // Should include EST timezone offset (-05:00) and milliseconds - expect(result, equals('2023-12-25T15:30:45.123-05:00')); + expect(result, '2023-12-25T15:30:45.123-05:00'); }); test('encodes with microseconds in timezone', () { @@ -145,7 +145,7 @@ void main() { final result = pstDateTime.toTimeZonedIso8601String(); // Should include PST timezone offset (-08:00) and microseconds - expect(result, equals('2023-12-25T18:30:45.123456-08:00')); + expect(result, '2023-12-25T18:30:45.123456-08:00'); }); test('encodes in JST (UTC+9:00)', () { @@ -155,7 +155,7 @@ void main() { final result = jstDateTime.toTimeZonedIso8601String(); // Should include JST timezone offset (+09:00) - expect(result, equals('2009-06-30T18:30:00+09:00')); + expect(result, '2009-06-30T18:30:00+09:00'); }); }); }); From 0a112a65ac558cb6a83baab587f9b5082a9ba6d0 Mon Sep 17 00:00:00 2001 From: Tobias Ottenweller Date: Sun, 20 Jul 2025 15:30:27 +0200 Subject: [PATCH 27/37] feat: time zone aware date time parsing --- docs/data_types.md | 27 +- .../test/model/model_property_test.dart | 4 +- .../decoding/datetime_decoding_extension.dart | 286 +++++++++ .../lib/src/decoding/json_decoder.dart | 5 +- .../lib/src/decoding/simple_decoder.dart | 5 +- .../datetime_decoding_extension_test.dart | 548 ++++++++++++++++++ .../test/src/decoding/json_decoder_test.dart | 34 +- .../src/decoding/simple_decoder_test.dart | 37 +- 8 files changed, 922 insertions(+), 24 deletions(-) create mode 100644 packages/tonik_util/lib/src/decoding/datetime_decoding_extension.dart create mode 100644 packages/tonik_util/test/src/decoding/datetime_decoding_extension_test.dart diff --git a/docs/data_types.md b/docs/data_types.md index 39cd8fd..951b631 100644 --- a/docs/data_types.md +++ b/docs/data_types.md @@ -7,7 +7,7 @@ This document provides information about how Tonik is mapping data types in Open | OAS Type | OAS Format | Dart Type | Dart Package | Comment | |----------|------------|-----------|--------------|---------| -| `string` | `date-time` | `DateTime` | `dart:core` | ISO 8601 datetime format | +| `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` | `enum` | `enum` | Generated | Custom enum type | @@ -19,3 +19,28 @@ This document provides information about how Tonik is mapping data types in Open | `boolean` | (any) | `bool` | `dart:core` | Boolean type | | `array` | (any) | `List` | `dart:core` | List of specified type | +### Timezone-Aware DateTime Parsing + +Tonik provides intelligent timezone-aware parsing for `date-time` format strings. The parsing behavior depends on the timezone information present in the input: + +> **⚠️ Important:** Before using timezone-aware parsing features, you must initialize the timezone database by calling `tz.initializeTimeZones()` from the `timezone` package. This is typically done in your application's setup code. + +All generated code will always expose Dart `DateTime` objects. However, standard Dart `DateTime` objects do not preserve timezone information, which is why Tonik uses `TZDateTime` internally during parsing to maintain timezone location data. During parsing, Tonik selects the most appropriate type to represent the date and time value: + +| Input Format | Return Type | Example | Description | +|--------------|-------------|---------|-------------| +| UTC (with Z) | `DateTime` (UTC) | `2023-12-25T15:30:45Z` | Standard Dart DateTime in UTC | +| Local (no timezone) | `DateTime` (local) | `2023-12-25T15:30:45` | Standard Dart DateTime in local timezone | +| Timezone offset | `TZDateTime` | `2023-12-25T15:30:45+05:00` | Timezone-aware DateTime with proper location | + + + +#### Timezone Location Selection + +For strings with timezone offsets (e.g., `+05:00`), Tonik intelligently selects the best matching timezone location: + +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 + diff --git a/packages/tonik_parse/test/model/model_property_test.dart b/packages/tonik_parse/test/model/model_property_test.dart index a1bc71d..a93c39f 100644 --- a/packages/tonik_parse/test/model/model_property_test.dart +++ b/packages/tonik_parse/test/model/model_property_test.dart @@ -118,7 +118,9 @@ void main() { final api = Importer().import(fileContent); final model = api.models.first as ClassModel; - final numberString = model.properties.firstWhere((p) => p.name == 'numberString'); + final numberString = model.properties.firstWhere( + (p) => p.name == 'numberString', + ); expect(numberString.model, isA()); }); diff --git a/packages/tonik_util/lib/src/decoding/datetime_decoding_extension.dart b/packages/tonik_util/lib/src/decoding/datetime_decoding_extension.dart new file mode 100644 index 0000000..adde16d --- /dev/null +++ b/packages/tonik_util/lib/src/decoding/datetime_decoding_extension.dart @@ -0,0 +1,286 @@ +import 'package:timezone/timezone.dart' as tz; +import 'package:tonik_util/src/decoding/decoding_exception.dart'; + +/// Extension on DateTime to provide timezone-aware parsing. +/// +/// This extension handles timezone information correctly: +/// - UTC strings return DateTime.utc objects +/// - Strings without timezone info return local DateTime objects +/// - Strings with timezone offsets return TZDateTime objects +extension DateTimeParsingExtension on DateTime { + /// Parses an ISO8601 datetime string with proper timezone handling. + /// + /// Returns: + /// - [DateTime] (UTC) for strings ending with 'Z' + /// - [DateTime] (local) for strings without timezone information + /// - [tz.TZDateTime] for strings with timezone offset information + /// + /// Throws [DecodingException] if the string is not a valid ISO8601 format. + static DateTime parseWithTimeZone(String input) { + if (input.isEmpty) { + throw const InvalidFormatException( + value: '', + format: 'ISO8601 datetime string', + ); + } + + // Handle different separator formats (T or space) + final normalizedInput = input.replaceFirst(' ', 'T'); + + // Check if it has timezone offset (±HH:MM or ±HHMM) + final timezoneRegex = RegExp(r'[+-]\d{2}:?\d{2}$'); + final timezoneMatch = timezoneRegex.firstMatch(normalizedInput); + + if (timezoneMatch != null) { + return _parseWithTimezoneOffset(normalizedInput, timezoneMatch); + } + + // Parse as UTC (ends with Z) or local time (no timezone info) + try { + return DateTime.parse(normalizedInput); + } on FormatException { + throw InvalidFormatException( + value: normalizedInput, + format: 'ISO8601 datetime format', + ); + } + } + + /// Parses a datetime string with timezone offset. + static tz.TZDateTime _parseWithTimezoneOffset( + String input, + RegExpMatch timezoneMatch, + ) { + final offsetString = timezoneMatch.group(0)!; + final datetimeString = input.substring( + 0, + input.length - offsetString.length, + ); + + final offset = _parseTimezoneOffset(offsetString); + final localDateTime = DateTime.parse(datetimeString); + final location = _findLocationForOffset(offset, localDateTime); + + // For standard offsets that have proper timezone locations, use them + if (location.name != 'UTC' || offset == Duration.zero) { + final utcDateTime = localDateTime.subtract(offset); + + final utcTz = tz.TZDateTime.utc( + utcDateTime.year, + utcDateTime.month, + utcDateTime.day, + utcDateTime.hour, + utcDateTime.minute, + utcDateTime.second, + utcDateTime.millisecond, + utcDateTime.microsecond, + ); + + return tz.TZDateTime.from(utcTz, location); + } + + // For unusual offsets that don't have proper timezone locations, + // fall back to UTC and convert the time correctly + final utcDateTime = localDateTime.subtract(offset); + + return tz.TZDateTime.utc( + utcDateTime.year, + utcDateTime.month, + utcDateTime.day, + utcDateTime.hour, + utcDateTime.minute, + utcDateTime.second, + utcDateTime.millisecond, + utcDateTime.microsecond, + ); + } + + /// Finds the best timezone location for a given offset at a + /// specific datetime. + /// + /// This leverages the timezone package's comprehensive database to find + /// locations that match the offset, taking into account DST changes. + static tz.Location _findLocationForOffset( + Duration offset, + DateTime dateTime, + ) { + final offsetMinutes = offset.inMinutes; + final timestamp = dateTime.millisecondsSinceEpoch; + + for (final locationName in _commonLocations) { + try { + final location = tz.getLocation(locationName); + final timeZone = location.timeZone(timestamp); + if (timeZone.offset == offsetMinutes * 60 * 1000) { + return location; + } + } on tz.LocationNotFoundException { + // Location doesn't exist, continue + } + } + + final matchingLocations = []; + for (final location in tz.timeZoneDatabase.locations.values) { + final timeZone = location.timeZone(timestamp); + if (timeZone.offset == offsetMinutes * 60 * 1000) { + matchingLocations.add(location); + } + } + + if (matchingLocations.isNotEmpty) { + // Prefer locations that don't use deprecated prefixes + final preferredMatches = + matchingLocations + .where( + (loc) => + !loc.name.startsWith('US/') && + !loc.name.startsWith('Etc/') && + !loc.name.contains('GMT'), + ) + .toList(); + + if (preferredMatches.isNotEmpty) { + return preferredMatches.first; + } + + return matchingLocations.first; + } + + return _createFixedOffsetLocation(offset); + } + + /// Creates a location with a fixed offset when no matching timezone is found. + static tz.Location _createFixedOffsetLocation(Duration offset) { + final offsetMinutes = offset.inMinutes; + + // For standard hour offsets, try to use Etc/GMT locations + if (offsetMinutes % 60 == 0) { + final offsetHours = offsetMinutes ~/ 60; + // Use Etc/GMT format which is supported by the timezone database + // Note: Etc/GMT offsets are inverted (Etc/GMT+5 is actually GMT-5) + final etcName = + offset.isNegative + ? 'Etc/GMT+${offsetHours.abs()}' + : 'Etc/GMT-${offsetHours.abs()}'; + + try { + return tz.getLocation(etcName); + } on tz.LocationNotFoundException { + // Fall through to UTC + } + } + + // For non-standard offsets, fall back to UTC + // This is a limitation - the timezone package doesn't easily support + // arbitrary fixed offsets, so we use UTC as fallback + return tz.getLocation('UTC'); + } + + /// Parses timezone offset string (±HH:MM or ±HHMM) into Duration. + static Duration _parseTimezoneOffset(String offsetString) { + // Remove optional colon for compact format + final normalized = offsetString.replaceAll(':', ''); + + if (normalized.length != 5) { + throw InvalidFormatException( + value: offsetString, + format: '±HHMM or ±HH:MM timezone offset', + ); + } + + final sign = normalized[0] == '+' ? 1 : -1; + final hoursStr = normalized.substring(1, 3); + final minutesStr = normalized.substring(3, 5); + + final hours = int.parse(hoursStr); + final minutes = int.parse(minutesStr); + + // Let Duration handle any overflow gracefully + return Duration(hours: sign * hours, minutes: sign * minutes); + } +} + +/// Commonly used timezone locations, prioritized for offset matching. +/// Based on major cities and avoiding deprecated location names. +const _commonLocations = [ + // Europe + 'Europe/London', + 'Europe/Paris', + 'Europe/Berlin', + 'Europe/Rome', + 'Europe/Madrid', + 'Europe/Amsterdam', + 'Europe/Brussels', + 'Europe/Vienna', + 'Europe/Zurich', + 'Europe/Stockholm', + 'Europe/Oslo', + 'Europe/Copenhagen', + 'Europe/Helsinki', + 'Europe/Warsaw', + 'Europe/Prague', + 'Europe/Budapest', + 'Europe/Athens', + 'Europe/Istanbul', + 'Europe/Moscow', + + // Americas + 'America/New_York', + 'America/Chicago', + 'America/Denver', + 'America/Los_Angeles', + 'America/Toronto', + 'America/Vancouver', + 'America/Montreal', + 'America/Mexico_City', + 'America/Sao_Paulo', + 'America/Buenos_Aires', + 'America/Lima', + 'America/Bogota', + 'America/Caracas', + 'America/Santiago', + 'America/Montevideo', + + // Asia + 'Asia/Tokyo', + 'Asia/Seoul', + 'Asia/Shanghai', + 'Asia/Hong_Kong', + 'Asia/Singapore', + 'Asia/Bangkok', + 'Asia/Jakarta', + 'Asia/Manila', + 'Asia/Kuala_Lumpur', + 'Asia/Kolkata', + 'Asia/Mumbai', + 'Asia/Karachi', + 'Asia/Dubai', + 'Asia/Riyadh', + 'Asia/Baghdad', + 'Asia/Tehran', + 'Asia/Kabul', + 'Asia/Tashkent', + 'Asia/Almaty', + + // Australia & Pacific + 'Australia/Sydney', + 'Australia/Melbourne', + 'Australia/Brisbane', + 'Australia/Perth', + 'Australia/Adelaide', + 'Pacific/Auckland', + 'Pacific/Honolulu', + 'Pacific/Fiji', + + // Africa + 'Africa/Cairo', + 'Africa/Johannesburg', + 'Africa/Lagos', + 'Africa/Nairobi', + 'Africa/Casablanca', + 'Africa/Tunis', + 'Africa/Algiers', + + // UTC + 'UTC', +]; diff --git a/packages/tonik_util/lib/src/decoding/json_decoder.dart b/packages/tonik_util/lib/src/decoding/json_decoder.dart index 957f61d..eae0956 100644 --- a/packages/tonik_util/lib/src/decoding/json_decoder.dart +++ b/packages/tonik_util/lib/src/decoding/json_decoder.dart @@ -1,10 +1,11 @@ import 'package:big_decimal/big_decimal.dart'; import 'package:tonik_util/src/date.dart'; +import 'package:tonik_util/src/decoding/datetime_decoding_extension.dart'; import 'package:tonik_util/src/decoding/decoding_exception.dart'; /// Extensions for decoding JSON values. extension JsonDecoder on Object? { - /// Decodes a JSON value to a DateTime. + /// Decodes a JSON value to a DateTime with timezone awareness. /// /// Expects ISO 8601 format string. /// Throws [InvalidTypeException] if the value is not a valid date string @@ -25,7 +26,7 @@ extension JsonDecoder on Object? { ); } try { - return DateTime.parse(this! as String); + return DateTimeParsingExtension.parseWithTimeZone(this! as String); } on FormatException catch (e) { throw InvalidTypeException( value: this! as String, diff --git a/packages/tonik_util/lib/src/decoding/simple_decoder.dart b/packages/tonik_util/lib/src/decoding/simple_decoder.dart index 9f7d9a8..151b593 100644 --- a/packages/tonik_util/lib/src/decoding/simple_decoder.dart +++ b/packages/tonik_util/lib/src/decoding/simple_decoder.dart @@ -1,5 +1,6 @@ import 'package:big_decimal/big_decimal.dart'; import 'package:tonik_util/src/date.dart'; +import 'package:tonik_util/src/decoding/datetime_decoding_extension.dart'; import 'package:tonik_util/src/decoding/decoding_exception.dart'; /// Extensions for decoding simple form values from strings. @@ -131,7 +132,7 @@ extension SimpleDecoder on String? { return decodeSimpleBool(context: context); } - /// Decodes a string to a DateTime. + /// Decodes a string to a DateTime with timezone awareness. /// /// Expects ISO 8601 format. /// Throws [InvalidTypeException] if the string is not a valid date @@ -145,7 +146,7 @@ extension SimpleDecoder on String? { ); } try { - return DateTime.parse(this!); + return DateTimeParsingExtension.parseWithTimeZone(this!); } on Object { throw InvalidTypeException( value: this!, diff --git a/packages/tonik_util/test/src/decoding/datetime_decoding_extension_test.dart b/packages/tonik_util/test/src/decoding/datetime_decoding_extension_test.dart new file mode 100644 index 0000000..ed6b868 --- /dev/null +++ b/packages/tonik_util/test/src/decoding/datetime_decoding_extension_test.dart @@ -0,0 +1,548 @@ +import 'package:test/test.dart'; +import 'package:timezone/data/latest.dart' as tz; +import 'package:tonik_util/src/decoding/datetime_decoding_extension.dart'; +import 'package:tonik_util/src/decoding/decoding_exception.dart'; + +void main() { + setUpAll(tz.initializeTimeZones); + + group('DateTimeParsingExtension', () { + group('parseWithTimeZone', () { + group('UTC parsing', () { + test('parses UTC datetime with Z suffix', () { + const input = '2023-12-25T15:30:45Z'; + final result = DateTimeParsingExtension.parseWithTimeZone(input); + + expect(result.isUtc, isTrue); + expect(result.year, 2023); + expect(result.month, 12); + expect(result.day, 25); + expect(result.hour, 15); + expect(result.minute, 30); + expect(result.second, 45); + expect(result.millisecond, 0); + expect(result.microsecond, 0); + expect(result.timeZoneOffset, Duration.zero); + }); + + test('parses UTC datetime with milliseconds', () { + const input = '2023-12-25T15:30:45.123Z'; + final result = DateTimeParsingExtension.parseWithTimeZone(input); + + expect(result.isUtc, isTrue); + expect(result.year, 2023); + expect(result.month, 12); + expect(result.day, 25); + expect(result.hour, 15); + expect(result.minute, 30); + expect(result.second, 45); + expect(result.millisecond, 123); + expect(result.microsecond, 0); + expect(result.timeZoneOffset, Duration.zero); + }); + + test('parses UTC datetime with microseconds', () { + const input = '2023-12-25T15:30:45.123456Z'; + final result = DateTimeParsingExtension.parseWithTimeZone(input); + + expect(result.isUtc, isTrue); + expect(result.year, 2023); + expect(result.month, 12); + expect(result.day, 25); + expect(result.hour, 15); + expect(result.minute, 30); + expect(result.second, 45); + expect(result.millisecond, 123); + expect(result.microsecond, 456); + expect(result.timeZoneOffset, Duration.zero); + }); + + test('parses UTC datetime at midnight', () { + const input = '2023-12-25T00:00:00Z'; + final result = DateTimeParsingExtension.parseWithTimeZone(input); + + expect(result.isUtc, isTrue); + expect(result.year, 2023); + expect(result.month, 12); + expect(result.day, 25); + expect(result.hour, 0); + expect(result.minute, 0); + expect(result.second, 0); + expect(result.millisecond, 0); + expect(result.microsecond, 0); + expect(result.timeZoneOffset, Duration.zero); + }); + + test('parses UTC datetime at end of day', () { + const input = '2023-12-25T23:59:59Z'; + final result = DateTimeParsingExtension.parseWithTimeZone(input); + + expect(result.isUtc, isTrue); + expect(result.year, 2023); + expect(result.month, 12); + expect(result.day, 25); + expect(result.hour, 23); + expect(result.minute, 59); + expect(result.second, 59); + expect(result.millisecond, 0); + expect(result.microsecond, 0); + expect(result.timeZoneOffset, Duration.zero); + }); + }); + + group('local time parsing (no timezone info)', () { + test('parses datetime without timezone as local time', () { + const input = '2023-12-25T15:30:45'; + final result = DateTimeParsingExtension.parseWithTimeZone(input); + final expected = DateTime(2023, 12, 25, 15, 30, 45); + + expect(result.isUtc, isFalse); + expect(result.year, 2023); + expect(result.month, 12); + expect(result.day, 25); + expect(result.hour, 15); + expect(result.minute, 30); + expect(result.second, 45); + expect(result.millisecond, 0); + expect(result.microsecond, 0); + expect(result.timeZoneOffset, expected.timeZoneOffset); + }); + + test('parses datetime with milliseconds as local time', () { + const input = '2023-12-25T15:30:45.123'; + final result = DateTimeParsingExtension.parseWithTimeZone(input); + final expected = DateTime(2023, 12, 25, 15, 30, 45, 123); + + expect(result.isUtc, isFalse); + expect(result.year, 2023); + expect(result.month, 12); + expect(result.day, 25); + expect(result.hour, 15); + expect(result.minute, 30); + expect(result.second, 45); + expect(result.millisecond, 123); + expect(result.microsecond, 0); + expect(result.timeZoneOffset, expected.timeZoneOffset); + }); + + test('parses datetime with microseconds as local time', () { + const input = '2023-12-25T15:30:45.123456'; + final result = DateTimeParsingExtension.parseWithTimeZone(input); + final expected = DateTime(2023, 12, 25, 15, 30, 45, 123, 456); + + expect(result.isUtc, isFalse); + expect(result.year, 2023); + expect(result.month, 12); + expect(result.day, 25); + expect(result.hour, 15); + expect(result.minute, 30); + expect(result.second, 45); + expect(result.millisecond, 123); + expect(result.microsecond, 456); + expect(result.timeZoneOffset, expected.timeZoneOffset); + }); + + test('parses date-only format as local midnight', () { + const input = '2023-12-25'; + final result = DateTimeParsingExtension.parseWithTimeZone(input); + final expected = DateTime(2023, 12, 25); + + expect(result.isUtc, isFalse); + expect(result.year, 2023); + expect(result.month, 12); + expect(result.day, 25); + expect(result.hour, 0); + expect(result.minute, 0); + expect(result.second, 0); + expect(result.millisecond, 0); + expect(result.microsecond, 0); + expect(result.timeZoneOffset, expected.timeZoneOffset); + }); + }); + + group('timezone offset parsing', () { + test('parses positive timezone offset (+05:00)', () { + const input = '2023-12-25T15:30:45+05:00'; + final result = DateTimeParsingExtension.parseWithTimeZone(input); + + expect(result.year, 2023); + expect(result.month, 12); + expect(result.day, 25); + expect(result.hour, 15); + expect(result.minute, 30); + expect(result.second, 45); + expect(result.millisecond, 0); + expect(result.microsecond, 0); + expect(result.timeZoneOffset.inHours, 5); + }); + + test('parses negative timezone offset (-08:00)', () { + const input = '2023-12-25T15:30:45-08:00'; + final result = DateTimeParsingExtension.parseWithTimeZone(input); + + expect(result.year, 2023); + expect(result.month, 12); + expect(result.day, 25); + expect(result.hour, 15); + expect(result.minute, 30); + expect(result.second, 45); + expect(result.millisecond, 0); + expect(result.microsecond, 0); + expect(result.timeZoneOffset.inHours, -8); + }); + + test('parses timezone offset with 30-minute offset (+05:30)', () { + const input = '2023-12-25T15:30:45+05:30'; + final result = DateTimeParsingExtension.parseWithTimeZone(input); + + expect(result.year, 2023); + expect(result.month, 12); + expect(result.day, 25); + expect(result.hour, 15); + expect(result.minute, 30); + expect(result.second, 45); + expect(result.millisecond, 0); + expect(result.microsecond, 0); + expect(result.timeZoneOffset.inMinutes, 330); // 5.5 hours + }); + + test('parses timezone offset with 45-minute offset (+05:45)', () { + const input = '2023-12-25T15:30:45+05:45'; + final result = DateTimeParsingExtension.parseWithTimeZone(input); + + expect(result.year, 2023); + expect(result.month, 12); + expect(result.day, 25); + expect(result.hour, 15); + expect(result.minute, 30); + expect(result.second, 45); + expect(result.millisecond, 0); + expect(result.microsecond, 0); + expect(result.timeZoneOffset.inMinutes, 345); // 5.75 hours + }); + + test('parses timezone offset with milliseconds', () { + const input = '2023-12-25T15:30:45.123+05:00'; + final result = DateTimeParsingExtension.parseWithTimeZone(input); + + expect(result.year, 2023); + expect(result.month, 12); + expect(result.day, 25); + expect(result.hour, 15); + expect(result.minute, 30); + expect(result.second, 45); + expect(result.millisecond, 123); + expect(result.microsecond, 0); + expect(result.timeZoneOffset.inHours, 5); + }); + + test('parses timezone offset with microseconds', () { + const input = '2023-12-25T15:30:45.123456+05:00'; + final result = DateTimeParsingExtension.parseWithTimeZone(input); + + expect(result.year, 2023); + expect(result.month, 12); + expect(result.day, 25); + expect(result.hour, 15); + expect(result.minute, 30); + expect(result.second, 45); + expect(result.millisecond, 123); + expect(result.microsecond, 456); + expect(result.timeZoneOffset.inHours, 5); + }); + + test('parses compact timezone offset format (+0500)', () { + const input = '2023-12-25T15:30:45+0500'; + final result = DateTimeParsingExtension.parseWithTimeZone(input); + + expect(result.year, 2023); + expect(result.month, 12); + expect(result.day, 25); + expect(result.hour, 15); + expect(result.minute, 30); + expect(result.second, 45); + expect(result.millisecond, 0); + expect(result.microsecond, 0); + expect(result.timeZoneOffset.inHours, 5); + }); + + test('parses compact negative timezone offset format (-0800)', () { + const input = '2023-12-25T15:30:45-0800'; + final result = DateTimeParsingExtension.parseWithTimeZone(input); + + expect(result.year, 2023); + expect(result.month, 12); + expect(result.day, 25); + expect(result.hour, 15); + expect(result.minute, 30); + expect(result.second, 45); + expect(result.millisecond, 0); + expect(result.microsecond, 0); + expect(result.timeZoneOffset.inHours, -8); + }); + }); + + group('timezone location matching', () { + test('maps common European timezone offset to CET', () { + const input = '2023-12-25T15:30:45+01:00'; // CET (winter time) + final result = DateTimeParsingExtension.parseWithTimeZone(input); + + expect(result.timeZoneName, 'CET'); + expect(result.timeZoneOffset.inHours, 1); + }); + + test('maps summer time European offset to CEST', () { + const input = '2023-07-25T15:30:45+02:00'; // CEST (summer time) + final result = DateTimeParsingExtension.parseWithTimeZone(input); + + expect(result.timeZoneName, 'CEST'); + expect(result.timeZoneOffset.inHours, 2); + }); + + test('maps US Eastern timezone offset to EST', () { + const input = '2023-12-25T15:30:45-05:00'; // EST (winter time) + final result = DateTimeParsingExtension.parseWithTimeZone(input); + + expect(result.timeZoneName, 'EST'); + expect(result.timeZoneOffset.inHours, -5); + }); + + test('maps US Eastern summer time offset to EDT', () { + const input = '2023-07-25T15:30:45-04:00'; // EDT (summer time) + final result = DateTimeParsingExtension.parseWithTimeZone(input); + + expect(result.timeZoneName, 'EDT'); + expect(result.timeZoneOffset.inHours, -4); + }); + + test('maps India Standard Time offset to IST', () { + const input = '2023-12-25T15:30:45+05:30'; + final result = DateTimeParsingExtension.parseWithTimeZone(input); + expect(result.timeZoneName, 'IST'); + expect(result.timeZoneOffset.inMinutes, 330); // 5.5 hours + }); + + test('maps Japan Standard Time offset to JST', () { + const input = '2023-12-25T15:30:45+09:00'; + final result = DateTimeParsingExtension.parseWithTimeZone(input); + + expect(result.timeZoneName, 'JST'); + expect(result.timeZoneOffset.inHours, 9); + }); + + test( + 'handles unusual timezone offset by falling back to UTC', + () { + const input = '2023-12-25T15:30:45+03:17'; // Unusual offset + final result = DateTimeParsingExtension.parseWithTimeZone(input); + + // For unusual offsets, falls back to UTC due to + // timezone package limitations + expect(result.timeZoneOffset.inMinutes, 0); // UTC + expect(result.timeZoneName, 'UTC'); + + // But the parsed time should still be correctly converted + // Original: 15:30:45+03:17 should convert to UTC time + expect(result.year, 2023); + expect(result.month, 12); + expect(result.day, 25); + expect(result.hour, 12); // 15:30 - 3:17 = 12:13 + expect(result.minute, 13); + expect(result.second, 45); + }, + ); + }); + + group('edge cases', () { + test('parses leap year date (UTC)', () { + const input = '2024-02-29T12:00:00Z'; + final result = DateTimeParsingExtension.parseWithTimeZone(input); + + expect(result.year, 2024); + expect(result.month, 2); + expect(result.day, 29); + }); + + test('parses leap year date (local)', () { + const input = '2024-02-29T12:00:00'; + final result = DateTimeParsingExtension.parseWithTimeZone(input); + + expect(result.year, 2024); + expect(result.month, 2); + expect(result.day, 29); + }); + + test('parses leap year date (timezone offset)', () { + const input = '2024-02-29T12:00:00+03:00'; + final result = DateTimeParsingExtension.parseWithTimeZone(input); + + expect(result.year, 2024); + expect(result.month, 2); + expect(result.day, 29); + }); + + test('parses year boundaries correctly (UTC)', () { + const input = '2023-12-31T23:59:59Z'; + final result = DateTimeParsingExtension.parseWithTimeZone(input); + + expect(result.year, 2023); + expect(result.month, 12); + expect(result.day, 31); + }); + + test('parses year boundaries correctly (local)', () { + const input = '2023-12-31T23:59:59'; + final result = DateTimeParsingExtension.parseWithTimeZone(input); + + expect(result.year, 2023); + expect(result.month, 12); + expect(result.day, 31); + }); + + test('parses year boundaries correctly (timezone offset)', () { + const input = '2023-12-31T23:59:59-05:00'; + final result = DateTimeParsingExtension.parseWithTimeZone(input); + + expect(result.year, 2023); + expect(result.month, 12); + expect(result.day, 31); + }); + + test('parses new year correctly (UTC)', () { + const input = '2024-01-01T00:00:00Z'; + final result = DateTimeParsingExtension.parseWithTimeZone(input); + + expect(result.year, 2024); + expect(result.month, 1); + expect(result.day, 1); + }); + + test('parses new year correctly (local)', () { + const input = '2024-01-01T00:00:00'; + final result = DateTimeParsingExtension.parseWithTimeZone(input); + + expect(result.year, 2024); + expect(result.month, 1); + expect(result.day, 1); + }); + + test('parses new year correctly (timezone offset)', () { + const input = '2024-01-01T00:00:00+09:00'; + final result = DateTimeParsingExtension.parseWithTimeZone(input); + + expect(result.year, 2024); + expect(result.month, 1); + expect(result.day, 1); + }); + + test('handles single digit milliseconds (UTC)', () { + const input = '2023-12-25T15:30:45.1Z'; + final result = DateTimeParsingExtension.parseWithTimeZone(input); + + expect(result.millisecond, 100); + }); + + test('handles single digit milliseconds (local)', () { + const input = '2023-12-25T15:30:45.1'; + final result = DateTimeParsingExtension.parseWithTimeZone(input); + + expect(result.millisecond, 100); + }); + + test('handles single digit milliseconds (timezone offset)', () { + const input = '2023-12-25T15:30:45.1+02:00'; + final result = DateTimeParsingExtension.parseWithTimeZone(input); + + expect(result.millisecond, 100); + }); + + test('handles two digit milliseconds (UTC)', () { + const input = '2023-12-25T15:30:45.12Z'; + final result = DateTimeParsingExtension.parseWithTimeZone(input); + + expect(result.millisecond, 120); + }); + + test('handles two digit milliseconds (local)', () { + const input = '2023-12-25T15:30:45.12'; + final result = DateTimeParsingExtension.parseWithTimeZone(input); + + expect(result.millisecond, 120); + }); + + test('handles two digit milliseconds (timezone offset)', () { + const input = '2023-12-25T15:30:45.12-07:00'; + final result = DateTimeParsingExtension.parseWithTimeZone(input); + + expect(result.millisecond, 120); + }); + }); + + group('error handling', () { + test('throws InvalidFormatException for invalid format', () { + const input = 'invalid-date-format'; + expect( + () => DateTimeParsingExtension.parseWithTimeZone(input), + throwsA(isA()), + ); + }); + + test('throws InvalidFormatException for incomplete date', () { + const input = '2023-12'; + expect( + () => DateTimeParsingExtension.parseWithTimeZone(input), + throwsA(isA()), + ); + }); + }); + + group('RFC3339 compliance', () { + test('parses full RFC3339 format with T separator', () { + const input = '2023-12-25T15:30:45.123456+05:00'; + final result = DateTimeParsingExtension.parseWithTimeZone(input); + + expect(result.year, 2023); + expect(result.month, 12); + expect(result.day, 25); + expect(result.hour, 15); + expect(result.minute, 30); + expect(result.second, 45); + expect(result.millisecond, 123); + expect(result.microsecond, 456); + expect(result.timeZoneOffset.inHours, 5); + }); + + test('parses RFC3339 format with space separator', () { + const input = '2023-12-25 15:30:45.123+05:00'; + final result = DateTimeParsingExtension.parseWithTimeZone(input); + + expect(result.year, 2023); + expect(result.month, 12); + expect(result.day, 25); + expect(result.hour, 15); + expect(result.minute, 30); + expect(result.second, 45); + expect(result.millisecond, 123); + expect(result.microsecond, 0); + expect(result.timeZoneOffset.inHours, 5); + }); + + test('parses minimum required RFC3339 format', () { + const input = '2023-12-25T15:30:45Z'; + final result = DateTimeParsingExtension.parseWithTimeZone(input); + + expect(result.isUtc, isTrue); + expect(result.year, 2023); + expect(result.month, 12); + expect(result.day, 25); + expect(result.hour, 15); + expect(result.minute, 30); + expect(result.second, 45); + expect(result.millisecond, 0); + expect(result.microsecond, 0); + expect(result.timeZoneOffset, Duration.zero); + }); + }); + }); + }); +} 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 1f6981e..e474fcc 100644 --- a/packages/tonik_util/test/src/decoding/json_decoder_test.dart +++ b/packages/tonik_util/test/src/decoding/json_decoder_test.dart @@ -2,17 +2,36 @@ import 'dart:convert'; import 'package:big_decimal/big_decimal.dart'; import 'package:test/test.dart'; +import 'package:timezone/data/latest.dart' as tz; +import 'package:timezone/timezone.dart' as tz; import 'package:tonik_util/src/date.dart'; import 'package:tonik_util/src/decoding/decoding_exception.dart'; import 'package:tonik_util/src/decoding/json_decoder.dart'; -import 'package:tonik_util/src/encoding/datetime_extension.dart'; void main() { + setUpAll(tz.initializeTimeZones); group('JsonDecoder', () { group('DateTime', () { - test('decodes DateTime values', () { - final date = DateTime.utc(2024, 3, 14); - expect(date.toTimeZonedIso8601String().decodeJsonDateTime(), date); + test('decodes DateTime values with timezone awareness', () { + // Test UTC parsing + const utcString = '2024-03-14T10:30:45Z'; + final utcResult = utcString.decodeJsonDateTime(); + expect(utcResult.isUtc, isTrue); + expect(utcResult, DateTime.utc(2024, 3, 14, 10, 30, 45)); + + // Test local time parsing + const localString = '2024-03-14T10:30:45'; + final localResult = localString.decodeJsonDateTime(); + expect(localResult.isUtc, isFalse); + expect(localResult, DateTime(2024, 3, 14, 10, 30, 45)); + + // Test timezone offset parsing + const offsetString = '2024-03-14T10:30:45+05:00'; + final offsetResult = offsetString.decodeJsonDateTime(); + expect(offsetResult, isA()); + expect(offsetResult.timeZoneOffset.inHours, 5); + + // Test error cases expect( () => 123.decodeJsonDateTime(), throwsA(isA()), @@ -23,11 +42,10 @@ void main() { ); }); - test('decodes nullable DateTime values', () { - final date = DateTime.utc(2024, 3, 14); + test('decodes nullable DateTime values with timezone awareness', () { expect( - date.toTimeZonedIso8601String().decodeJsonNullableDateTime(), - date, + '2024-03-14T10:30:45Z'.decodeJsonNullableDateTime(), + DateTime.utc(2024, 3, 14, 10, 30, 45), ); expect(null.decodeJsonNullableDateTime(), isNull); expect(''.decodeJsonNullableDateTime(), isNull); 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 5fb8ac9..65fceb4 100644 --- a/packages/tonik_util/test/src/decoding/simple_decoder_test.dart +++ b/packages/tonik_util/test/src/decoding/simple_decoder_test.dart @@ -1,11 +1,13 @@ import 'package:big_decimal/big_decimal.dart'; import 'package:test/test.dart'; +import 'package:timezone/data/latest.dart' as tz; +import 'package:timezone/timezone.dart' as tz; import 'package:tonik_util/src/date.dart'; import 'package:tonik_util/src/decoding/decoding_exception.dart'; import 'package:tonik_util/src/decoding/simple_decoder.dart'; -import 'package:tonik_util/src/encoding/datetime_extension.dart'; void main() { + setUpAll(tz.initializeTimeZones); group('SimpleDecoder', () { group('Simple Values', () { test('decodes integer values', () { @@ -47,9 +49,28 @@ void main() { ); }); - test('decodes DateTime values', () { - final date = DateTime.utc(2024, 3, 14); - expect(date.toTimeZonedIso8601String().decodeSimpleDateTime(), date); + test('decodes DateTime values with timezone awareness', () { + // Test UTC parsing + final utcDate = DateTime.utc(2024, 3, 14, 10, 30, 45); + const utcString = '2024-03-14T10:30:45Z'; + final utcResult = utcString.decodeSimpleDateTime(); + expect(utcResult.isUtc, isTrue); + expect(utcResult, utcDate); + + // Test local time parsing + const localString = '2024-03-14T10:30:45'; + final localResult = localString.decodeSimpleDateTime(); + final expectedLocal = DateTime(2024, 3, 14, 10, 30, 45); + expect(localResult.isUtc, isFalse); + expect(localResult, expectedLocal); + + // Test timezone offset parsing + const offsetString = '2024-03-14T10:30:45+05:00'; + final offsetResult = offsetString.decodeSimpleDateTime(); + expect(offsetResult, isA()); + expect(offsetResult.timeZoneOffset.inHours, 5); + + // Test error cases expect( () => 'not-a-date'.decodeSimpleDateTime(), throwsA(isA()), @@ -129,12 +150,8 @@ void main() { expect('3.14'.decodeSimpleNullableDouble(), 3.14); expect('true'.decodeSimpleNullableBool(), isTrue); expect( - DateTime.utc( - 2024, - 3, - 14, - ).toTimeZonedIso8601String().decodeSimpleNullableDateTime(), - DateTime.utc(2024, 3, 14), + '2024-03-14T10:30:45Z'.decodeSimpleNullableDateTime(), + DateTime.utc(2024, 3, 14, 10, 30, 45), ); expect( '3.14'.decodeSimpleNullableBigDecimal(), From d79012b48ebd82c1719b7da9c5fb024f09b6fc4c Mon Sep 17 00:00:00 2001 From: Tobias Ottenweller Date: Sun, 20 Jul 2025 17:35:51 +0200 Subject: [PATCH 28/37] 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', + ); + }); + }); + }); }); } From 59f327f13f9dda49fe1f5f57e8fbea38e6e3b691 Mon Sep 17 00:00:00 2001 From: Tobias Ottenweller Date: Sun, 20 Jul 2025 21:25:41 +0200 Subject: [PATCH 29/37] chore: replace unmaintained spell_out_numbers --- .../lib/src/naming/name_utils.dart | 97 ++++++++++++-- packages/tonik_generate/pubspec.yaml | 2 +- .../test/src/naming/name_utils_test.dart | 121 ++++++++++++++++++ pubspec.yaml | 2 +- 4 files changed, 206 insertions(+), 16 deletions(-) create mode 100644 packages/tonik_generate/test/src/naming/name_utils_test.dart diff --git a/packages/tonik_generate/lib/src/naming/name_utils.dart b/packages/tonik_generate/lib/src/naming/name_utils.dart index 56e3e22..6857c0e 100644 --- a/packages/tonik_generate/lib/src/naming/name_utils.dart +++ b/packages/tonik_generate/lib/src/naming/name_utils.dart @@ -1,5 +1,5 @@ import 'package:change_case/change_case.dart'; -import 'package:spell_out_numbers/spell_out_numbers.dart'; +import 'package:number_to_words_english/number_to_words_english.dart'; /// Default prefix used for empty or invalid enum values. const defaultEnumPrefix = 'value'; @@ -107,15 +107,57 @@ String ensureNotKeyword(String name) { final processedPart = part.replaceAll(RegExp('[^a-zA-Z0-9]'), ''); if (processedPart.isEmpty) return (processed: '', number: null); + /// Helper function to normalize case: only convert to lowercase if all caps + String normalizeCase(String text, {required bool isFirst}) { + if (text.isEmpty) return text; + + final isAllCaps = + text == text.toUpperCase() && text != text.toLowerCase(); + + // Special handling for Dart keywords: keep them lowercase for first part + if (isFirst && allKeywords.contains(text.toLowerCase())) { + return text.toLowerCase(); + } + + if (isFirst) { + // For first part, convert to lowercase if all caps, otherwise camelCase + if (isAllCaps) { + return text.toLowerCase(); + } else { + // For mixed case, convert to proper camelCase (first letter lowercase) + return text.length == 1 + ? text.toLowerCase() + : text.substring(0, 1).toLowerCase() + text.substring(1); + } + } else { + // For subsequent parts, convert to PascalCase if all caps, otherwise + // ensure PascalCase + if (isAllCaps) { + return text.toPascalCase(); + } else { + // For mixed case, ensure it starts with uppercase + return text.length == 1 + ? text.toUpperCase() + : text.substring(0, 1).toUpperCase() + text.substring(1); + } + } + } + // Handle numbers differently for first part vs subsequent parts if (isFirstPart) { final numberMatch = RegExp(r'^(\d+)(.+)$').firstMatch(processedPart); if (numberMatch != null) { final number = numberMatch.group(1)!; final rest = numberMatch.group(2)!; - return (processed: rest.toCamelCase(), number: number); + return ( + processed: normalizeCase(rest, isFirst: true), + number: number, + ); } - return (processed: processedPart.toCamelCase(), number: null); + return ( + processed: normalizeCase(processedPart, isFirst: true), + number: null, + ); } else { final numberMatch = RegExp( r'^(\d+)(.+)$|^(.+?)(\d+)$', @@ -127,18 +169,33 @@ String ensureNotKeyword(String name) { final trailingNumber = numberMatch.group(4); if (leadingNumber != null && leadingRest != null) { - return (processed: leadingRest.toPascalCase(), number: leadingNumber); + return ( + processed: normalizeCase(leadingRest, isFirst: false), + number: leadingNumber, + ); } else if (trailingBase != null && trailingNumber != null) { - return (processed: trailingBase.toPascalCase(), number: trailingNumber); + return ( + processed: normalizeCase(trailingBase, isFirst: false), + number: trailingNumber, + ); } } - return (processed: processedPart.toPascalCase(), number: null); + return ( + processed: normalizeCase(processedPart, isFirst: false), + number: null, + ); } } /// Splits a string into parts based on common separators and case boundaries. -List splitIntoParts(String value) => - value.split(RegExp(r'[_\- ]|(?=[A-Z])')); +List splitIntoParts(String value) { + // Split on explicit separators and case boundaries + final parts = value.split( + RegExp(r'[_\- ]|(?<=[a-z])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])'), + ); + + return parts.where((part) => part.isNotEmpty).toList(); +} /// Processes parts into a normalized name. String processPartsIntoName(List parts) { @@ -204,16 +261,28 @@ String normalizeSingle(String name, {bool preserveNumbers = false}) { /// Normalizes an enum value name, handling special cases like integers. String normalizeEnumValueName(String value) { - // For integer values, spell out the number - if (RegExp(r'^\d+$').hasMatch(value)) { + // Only spell out numbers if the entire value is just a number (no prefix) + if (RegExp(r'^-?\d+$').hasMatch(value)) { final number = int.parse(value); - final words = EnglishNumberScheme().toWord(number); + final words = number < 0 + ? 'minus ${NumberToWordsEnglish.convert(number.abs())}' + : NumberToWordsEnglish.convert(number); final normalized = normalizeSingle(words); - return normalized.isEmpty ? defaultEnumPrefix : normalized; + return normalized.isEmpty + ? defaultEnumPrefix + : normalized.toCamelCase(); } - final normalized = normalizeSingle(value); - return normalized.isEmpty ? defaultEnumPrefix : normalized; + // For values with prefixes (like ERROR_404), preserve numbers as-is + final normalized = normalizeSingle(value, preserveNumbers: true); + if (normalized.isEmpty) return defaultEnumPrefix; + + // Don't apply toCamelCase if the normalized value starts with $ + if (normalized.startsWith(r'$')) { + return normalized; + } + + return normalized.toCamelCase(); } /// Ensures uniqueness in a list of normalized names diff --git a/packages/tonik_generate/pubspec.yaml b/packages/tonik_generate/pubspec.yaml index ef81ba1..fcbd649 100644 --- a/packages/tonik_generate/pubspec.yaml +++ b/packages/tonik_generate/pubspec.yaml @@ -14,8 +14,8 @@ dependencies: dart_style: ^3.0.1 logging: ^1.3.0 meta: ^1.16.0 + number_to_words_english: ^2.0.2 path: ^1.9.1 - spell_out_numbers: ^1.0.0 tonik_core: ^0.0.6 dev_dependencies: diff --git a/packages/tonik_generate/test/src/naming/name_utils_test.dart b/packages/tonik_generate/test/src/naming/name_utils_test.dart new file mode 100644 index 0000000..336b77c --- /dev/null +++ b/packages/tonik_generate/test/src/naming/name_utils_test.dart @@ -0,0 +1,121 @@ +import 'package:test/test.dart'; +import 'package:tonik_generate/src/naming/name_utils.dart'; + +void main() { + group('normalizeEnumValueName', () { + group('number conversion', () { + test('converts single digits to words', () { + expect(normalizeEnumValueName('0'), 'zero'); + expect(normalizeEnumValueName('1'), 'one'); + expect(normalizeEnumValueName('2'), 'two'); + expect(normalizeEnumValueName('3'), 'three'); + expect(normalizeEnumValueName('9'), 'nine'); + }); + + test('converts teen numbers to words', () { + expect(normalizeEnumValueName('10'), 'ten'); + expect(normalizeEnumValueName('11'), 'eleven'); + expect(normalizeEnumValueName('15'), 'fifteen'); + expect(normalizeEnumValueName('19'), 'nineteen'); + }); + + test('converts larger numbers to exact expected output', () { + expect(normalizeEnumValueName('42'), 'fortyTwo'); + expect(normalizeEnumValueName('100'), 'oneHundred'); + expect(normalizeEnumValueName('123'), 'oneHundredTwentyThree'); + expect(normalizeEnumValueName('1000'), 'oneThousand'); + }); + + test('handles negative numbers with exact output', () { + expect(normalizeEnumValueName('-1'), 'minusOne'); + expect(normalizeEnumValueName('-42'), 'minusFortyTwo'); + expect(normalizeEnumValueName('-100'), 'minusOneHundred'); + expect(normalizeEnumValueName('-999'), 'minusNineHundredNinetyNine'); + }); + + test('produces camelCase identifiers', () { + // Based on existing test expectations + expect(normalizeEnumValueName('1'), 'one'); + expect(normalizeEnumValueName('2'), 'two'); + expect(normalizeEnumValueName('3'), 'three'); + }); + }); + + group('string normalization', () { + test('normalizes simple strings', () { + expect(normalizeEnumValueName('active'), 'active'); + expect(normalizeEnumValueName('inactive'), 'inactive'); + expect(normalizeEnumValueName('pending'), 'pending'); + }); + + test('handles case conversion properly', () { + expect(normalizeEnumValueName('ACTIVE'), 'active'); // Clean lowercase + expect(normalizeEnumValueName('InActive'), 'inActive'); + expect(normalizeEnumValueName('PENDING'), 'pending'); + }); + + test('handles strings with separators', () { + expect(normalizeEnumValueName('in-progress'), 'inProgress'); + expect(normalizeEnumValueName('not_started'), 'notStarted'); + expect(normalizeEnumValueName('on hold'), 'onHold'); + }); + + test('handles mixed alphanumeric strings', () { + expect(normalizeEnumValueName('status1'), 'status1'); + expect( + normalizeEnumValueName('1status'), + 'status1', + ); // Number moved to end + expect(normalizeEnumValueName('v2_final'), 'v2Final'); + }); + + test('comprehensive real-world enum value cases', () { + // Common API status codes and enum patterns + expect(normalizeEnumValueName('SUCCESS_CODE'), 'successCode'); + expect(normalizeEnumValueName('ERROR_404'), 'error404'); + expect(normalizeEnumValueName('HTTP_STATUS'), 'httpStatus'); + expect(normalizeEnumValueName('NOT_FOUND'), 'notFound'); + expect(normalizeEnumValueName('API_VERSION_2'), 'apiVersion2'); + expect(normalizeEnumValueName('USER-ACCOUNT'), 'userAccount'); + expect(normalizeEnumValueName('data_model'), 'dataModel'); + expect(normalizeEnumValueName('ADMIN'), 'admin'); + expect(normalizeEnumValueName('guest'), 'guest'); + expect(normalizeEnumValueName('999'), 'nineHundredNinetyNine'); + expect(normalizeEnumValueName('2024'), 'twoThousandTwentyFour'); + }); + }); + + group('edge cases', () { + test('handles empty and invalid inputs', () { + expect(normalizeEnumValueName(''), 'value'); + expect(normalizeEnumValueName('_'), 'value'); + expect(normalizeEnumValueName('__'), 'value'); + }); + + test('handles special characters', () { + expect(normalizeEnumValueName('!@#'), 'value'); + expect(normalizeEnumValueName('status!'), 'status'); + expect(normalizeEnumValueName('test@#123'), 'test123'); + }); + + test('handles leading underscores', () { + expect(normalizeEnumValueName('_active'), 'active'); + expect(normalizeEnumValueName('__pending'), 'pending'); + }); + test('matches expected enum generation behavior', () { + // These are the expectations from the existing enum generator tests + expect(normalizeEnumValueName('1'), 'one'); + expect(normalizeEnumValueName('2'), 'two'); + expect(normalizeEnumValueName('3'), 'three'); + }); + + test('produces clean, readable identifiers', () { + // Common enum value patterns should be clean and readable + expect(normalizeEnumValueName('SUCCESS'), 'success'); + expect(normalizeEnumValueName('ERROR'), 'error'); + expect(normalizeEnumValueName('PENDING'), 'pending'); + expect(normalizeEnumValueName('IN_PROGRESS'), 'inProgress'); + }); + }); + }); +} diff --git a/pubspec.yaml b/pubspec.yaml index 9d59be4..ac6c8cb 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -15,8 +15,8 @@ dependencies: dart_style: ^3.0.1 logging: ^1.3.0 meta: ^1.16.0 + number_to_words_english: ^2.0.2 path: ^1.9.1 - spell_out_numbers: ^1.0.0 tonik: ^0.0.4 tonik_core: ^0.0.4 tonik_generate: ^0.0.4 From 89d228866630b711eaaa430d09a4dff170d30b60 Mon Sep 17 00:00:00 2001 From: Tobias Ottenweller Date: Sun, 20 Jul 2025 22:15:07 +0200 Subject: [PATCH 30/37] cleanup --- .../lib/src/naming/name_utils.dart | 280 ++++++++++-------- packages/tonik_generate/pubspec.yaml | 1 - .../test/src/naming/name_utils_test.dart | 33 +++ pubspec.yaml | 1 - 4 files changed, 184 insertions(+), 131 deletions(-) diff --git a/packages/tonik_generate/lib/src/naming/name_utils.dart b/packages/tonik_generate/lib/src/naming/name_utils.dart index 6857c0e..70871a0 100644 --- a/packages/tonik_generate/lib/src/naming/name_utils.dart +++ b/packages/tonik_generate/lib/src/naming/name_utils.dart @@ -1,5 +1,4 @@ import 'package:change_case/change_case.dart'; -import 'package:number_to_words_english/number_to_words_english.dart'; /// Default prefix used for empty or invalid enum values. const defaultEnumPrefix = 'value'; @@ -89,6 +88,72 @@ const generatedClassTokens = { const Set allKeywords = {...dartKeywords, ...generatedClassTokens}; +/// Converts a number to its English word representation. +/// Supports numbers up to trillions. +String _numberToWords(int number) { + if (number == 0) return 'zero'; + + const ones = [ + '', 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', + 'nine', 'ten', 'eleven', 'twelve', 'thirteen', 'fourteen', 'fifteen', + 'sixteen', 'seventeen', 'eighteen', 'nineteen' + ]; + + const tens = [ + '', '', 'twenty', 'thirty', 'forty', 'fifty', 'sixty', 'seventy', + 'eighty', 'ninety' + ]; + + final result = []; + var remaining = number; + + if (remaining >= 1000000000000) { + result + ..add(_numberToWords(remaining ~/ 1000000000000)) + ..add('trillion'); + remaining %= 1000000000000; + } + + if (remaining >= 1000000000) { + result + ..add(_numberToWords(remaining ~/ 1000000000)) + ..add('billion'); + remaining %= 1000000000; + } + + if (remaining >= 1000000) { + result + ..add(_numberToWords(remaining ~/ 1000000)) + ..add('million'); + remaining %= 1000000; + } + + if (remaining >= 1000) { + result + ..add(_numberToWords(remaining ~/ 1000)) + ..add('thousand'); + remaining %= 1000; + } + + if (remaining >= 100) { + result + ..add(ones[remaining ~/ 100]) + ..add('hundred'); + remaining %= 100; + } + + if (remaining >= 20) { + result.add(tens[remaining ~/ 10]); + if (remaining % 10 != 0) { + result.add(ones[remaining % 10]); + } + } else if (remaining > 0) { + result.add(ones[remaining]); + } + + return result.join(' ').trim(); +} + /// Ensures a name is not a Dart keyword by adding a $ prefix if necessary. String ensureNotKeyword(String name) { if (allKeywords.contains(name.toCamelCase()) || @@ -98,137 +163,103 @@ String ensureNotKeyword(String name) { return name; } -/// Processes a part of a name, handling numbers and casing. -/// If [isFirstPart] is true, numbers at the start will be moved to the end. -({String processed, String? number}) processPart( - String part, { - required bool isFirstPart, -}) { - final processedPart = part.replaceAll(RegExp('[^a-zA-Z0-9]'), ''); - if (processedPart.isEmpty) return (processed: '', number: null); - - /// Helper function to normalize case: only convert to lowercase if all caps - String normalizeCase(String text, {required bool isFirst}) { - if (text.isEmpty) return text; +/// Splits text into tokens and normalizes each one. +String _normalizeText(String text, {bool preserveNumbers = false}) { + if (text.isEmpty) return ''; + + // Clean invalid characters but preserve separators for splitting + final cleaned = text.replaceAll(RegExp(r'[^a-zA-Z0-9_\-\s]'), ''); + + // Split on separators and case boundaries + final tokens = cleaned + .split(RegExp(r'[_\-\s]+|(?<=[a-z])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])')) + .where((token) => token.isNotEmpty) + .toList(); + + if (tokens.isEmpty) return ''; + + final result = []; + final numbersToAppend = []; + + for (var i = 0; i < tokens.length; i++) { + final token = tokens[i]; + final isFirst = i == 0; - final isAllCaps = - text == text.toUpperCase() && text != text.toLowerCase(); + // Extract numbers from token + final numberMatch = + RegExp(r'^(\d+)(.*)$|^(.+?)(\d+)$').firstMatch(token); - // Special handling for Dart keywords: keep them lowercase for first part - if (isFirst && allKeywords.contains(text.toLowerCase())) { - return text.toLowerCase(); - } + String textPart; + String? numberPart; - if (isFirst) { - // For first part, convert to lowercase if all caps, otherwise camelCase - if (isAllCaps) { - return text.toLowerCase(); + if (numberMatch != null) { + if (numberMatch.group(1) != null) { + // Leading number: 123abc + numberPart = numberMatch.group(1); + textPart = numberMatch.group(2) ?? ''; } else { - // For mixed case, convert to proper camelCase (first letter lowercase) - return text.length == 1 - ? text.toLowerCase() - : text.substring(0, 1).toLowerCase() + text.substring(1); + // Trailing number: abc123 + textPart = numberMatch.group(3) ?? ''; + numberPart = numberMatch.group(4); } + } else if (RegExp(r'^\d+$').hasMatch(token)) { + // Pure number + numberPart = token; + textPart = ''; } else { - // For subsequent parts, convert to PascalCase if all caps, otherwise - // ensure PascalCase - if (isAllCaps) { - return text.toPascalCase(); - } else { - // For mixed case, ensure it starts with uppercase - return text.length == 1 - ? text.toUpperCase() - : text.substring(0, 1).toUpperCase() + text.substring(1); - } + // No numbers + textPart = token; + numberPart = null; } - } - - // Handle numbers differently for first part vs subsequent parts - if (isFirstPart) { - final numberMatch = RegExp(r'^(\d+)(.+)$').firstMatch(processedPart); - if (numberMatch != null) { - final number = numberMatch.group(1)!; - final rest = numberMatch.group(2)!; - return ( - processed: normalizeCase(rest, isFirst: true), - number: number, - ); + + // Process text part + if (textPart.isNotEmpty) { + final normalized = _normalizeCasing(textPart, isFirst: isFirst); + result.add(normalized); } - return ( - processed: normalizeCase(processedPart, isFirst: true), - number: null, - ); - } else { - final numberMatch = RegExp( - r'^(\d+)(.+)$|^(.+?)(\d+)$', - ).firstMatch(processedPart); - if (numberMatch != null) { - final leadingNumber = numberMatch.group(1); - final leadingRest = numberMatch.group(2); - final trailingBase = numberMatch.group(3); - final trailingNumber = numberMatch.group(4); - - if (leadingNumber != null && leadingRest != null) { - return ( - processed: normalizeCase(leadingRest, isFirst: false), - number: leadingNumber, - ); - } else if (trailingBase != null && trailingNumber != null) { - return ( - processed: normalizeCase(trailingBase, isFirst: false), - number: trailingNumber, - ); + + // Handle numbers + if (numberPart != null) { + if (isFirst && textPart.isNotEmpty && + numberMatch?.group(1) != null) { + // Move leading numbers from first token to end + // (e.g., "1status" -> "status1") + numbersToAppend.add(numberPart); + } else { + // Keep numbers in place for trailing numbers or non-first tokens + result.add(numberPart); } } - return ( - processed: normalizeCase(processedPart, isFirst: false), - number: null, - ); } -} - -/// Splits a string into parts based on common separators and case boundaries. -List splitIntoParts(String value) { - // Split on explicit separators and case boundaries - final parts = value.split( - RegExp(r'[_\- ]|(?<=[a-z])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])'), - ); - return parts.where((part) => part.isNotEmpty).toList(); + // Append any numbers that were moved from the first token + result.addAll(numbersToAppend); + + return result.join(); } -/// Processes parts into a normalized name. -String processPartsIntoName(List parts) { - if (parts.isEmpty) return ''; - - final processedParts = []; - - // Process first part - final firstResult = processPart(parts.first, isFirstPart: true); - if (firstResult.processed.isNotEmpty) { - processedParts.add(firstResult.processed); - if (firstResult.number != null) { - processedParts.add(firstResult.number!); - } +/// Normalizes the casing of a text token. +String _normalizeCasing(String text, {required bool isFirst}) { + if (text.isEmpty) return text; + + final isAllCaps = text == text.toUpperCase() && text != text.toLowerCase(); + + // Special handling for keywords - keep them lowercase for first part only + if (isFirst && allKeywords.contains(text.toLowerCase())) { + return text.toLowerCase(); } - - // Process remaining parts - for (var i = 1; i < parts.length; i++) { - final result = processPart(parts[i], isFirstPart: false); - if (result.processed.isNotEmpty) { - processedParts.add(result.processed); - if (result.number != null) { - processedParts.add(result.number!); - } - } + + if (isFirst) { + return isAllCaps ? text.toLowerCase() : text.toCamelCase(); + } else { + return isAllCaps ? text.toPascalCase() : text.toPascalCase(); } - - return processedParts.join(); } + + /// Normalizes a single name to follow Dart guidelines. String normalizeSingle(String name, {bool preserveNumbers = false}) { - // Handle empty or underscore-only strings if (name.isEmpty || RegExp(r'^_+$').hasMatch(name)) { return ''; } @@ -237,25 +268,16 @@ String normalizeSingle(String name, {bool preserveNumbers = false}) { var processedName = name.replaceAll(RegExp('^_+'), ''); if (processedName.isEmpty) return ''; - // If we need to preserve numbers and the name is just a number, return it + // If preserving numbers and it's just a number, return as-is if (preserveNumbers && RegExp(r'^\d+$').hasMatch(processedName)) { return processedName; } - final parts = splitIntoParts(processedName); - processedName = processPartsIntoName(parts); - - // If preserving numbers, ensure we don't lose them in the normalization - if (preserveNumbers) { - final originalNumber = RegExp(r'\d+$').firstMatch(name)?.group(0); - final processedNumber = RegExp(r'\d+$').firstMatch(processedName)?.group(0); - if (originalNumber != null && processedNumber != originalNumber) { - // Remove any trailing numbers and append the original number - final baseProcessed = processedName.replaceAll(RegExp(r'\d+$'), ''); - processedName = '$baseProcessed$originalNumber'; - } - } - + processedName = _normalizeText( + processedName, + preserveNumbers: preserveNumbers, + ); + return ensureNotKeyword(processedName); } @@ -265,8 +287,8 @@ String normalizeEnumValueName(String value) { if (RegExp(r'^-?\d+$').hasMatch(value)) { final number = int.parse(value); final words = number < 0 - ? 'minus ${NumberToWordsEnglish.convert(number.abs())}' - : NumberToWordsEnglish.convert(number); + ? 'minus ${_numberToWords(number.abs())}' + : _numberToWords(number); final normalized = normalizeSingle(words); return normalized.isEmpty ? defaultEnumPrefix diff --git a/packages/tonik_generate/pubspec.yaml b/packages/tonik_generate/pubspec.yaml index fcbd649..d7c644a 100644 --- a/packages/tonik_generate/pubspec.yaml +++ b/packages/tonik_generate/pubspec.yaml @@ -14,7 +14,6 @@ dependencies: dart_style: ^3.0.1 logging: ^1.3.0 meta: ^1.16.0 - number_to_words_english: ^2.0.2 path: ^1.9.1 tonik_core: ^0.0.6 diff --git a/packages/tonik_generate/test/src/naming/name_utils_test.dart b/packages/tonik_generate/test/src/naming/name_utils_test.dart index 336b77c..1109322 100644 --- a/packages/tonik_generate/test/src/naming/name_utils_test.dart +++ b/packages/tonik_generate/test/src/naming/name_utils_test.dart @@ -33,6 +33,39 @@ void main() { expect(normalizeEnumValueName('-999'), 'minusNineHundredNinetyNine'); }); + test('converts millions to exact expected output', () { + expect(normalizeEnumValueName('1000000'), 'oneMillion'); + expect(normalizeEnumValueName('2000000'), 'twoMillion'); + expect(normalizeEnumValueName('5000000'), 'fiveMillion'); + expect(normalizeEnumValueName('1500000'), + 'oneMillionFiveHundredThousand'); + }); + + test('converts billions to exact expected output', () { + expect(normalizeEnumValueName('1000000000'), 'oneBillion'); + expect(normalizeEnumValueName('3000000000'), 'threeBillion'); + expect(normalizeEnumValueName('7000000000'), 'sevenBillion'); + expect(normalizeEnumValueName('1500000000'), + 'oneBillionFiveHundredMillion'); + }); + + test('converts trillions to exact expected output', () { + expect(normalizeEnumValueName('1000000000000'), 'oneTrillion'); + expect(normalizeEnumValueName('5000000000000'), 'fiveTrillion'); + expect(normalizeEnumValueName('9000000000000'), 'nineTrillion'); + expect(normalizeEnumValueName('1500000000000'), + 'oneTrillionFiveHundredBillion'); + }); + + test('handles complex large numbers', () { + expect(normalizeEnumValueName('1234567890'), + 'oneBillionTwoHundredThirtyFourMillion' + 'FiveHundredSixtySevenThousandEightHundredNinety'); + expect(normalizeEnumValueName('999999999999'), + 'nineHundredNinetyNineBillionNineHundredNinetyNineMillion' + 'NineHundredNinetyNineThousandNineHundredNinetyNine'); + }); + test('produces camelCase identifiers', () { // Based on existing test expectations expect(normalizeEnumValueName('1'), 'one'); diff --git a/pubspec.yaml b/pubspec.yaml index ac6c8cb..4625da4 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -15,7 +15,6 @@ dependencies: dart_style: ^3.0.1 logging: ^1.3.0 meta: ^1.16.0 - number_to_words_english: ^2.0.2 path: ^1.9.1 tonik: ^0.0.4 tonik_core: ^0.0.4 From f6e9760259e8d34f5d2d2f22b53aee9197e3d963 Mon Sep 17 00:00:00 2001 From: Tobias Ottenweller Date: Sun, 20 Jul 2025 23:09:04 +0200 Subject: [PATCH 31/37] chore: cleanup comments --- packages/tonik_generate/lib/src/naming/name_utils.dart | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/tonik_generate/lib/src/naming/name_utils.dart b/packages/tonik_generate/lib/src/naming/name_utils.dart index 70871a0..1b8a13c 100644 --- a/packages/tonik_generate/lib/src/naming/name_utils.dart +++ b/packages/tonik_generate/lib/src/naming/name_utils.dart @@ -1,12 +1,9 @@ import 'package:change_case/change_case.dart'; -/// Default prefix used for empty or invalid enum values. const defaultEnumPrefix = 'value'; -/// Default prefix used for empty or invalid field names. const defaultFieldPrefix = 'field'; -/// Reserved Dart keywords that cannot be used as identifiers. const dartKeywords = { 'abstract', 'as', From 000ab0e16a39006db72d970072ec1181b042bd29 Mon Sep 17 00:00:00 2001 From: Tobias Ottenweller Date: Sun, 20 Jul 2025 23:16:21 +0200 Subject: [PATCH 32/37] chore(release): publish packages - tonik_util@0.0.7 --- CHANGELOG.md | 23 +++++++++++++++++++++++ packages/tonik_util/CHANGELOG.md | 6 ++++++ packages/tonik_util/pubspec.yaml | 2 +- 3 files changed, 30 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2bb3ab4..e072451 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,29 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## 2025-07-20 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`tonik_util` - `v0.0.7`](#tonik_util---v007) + +--- + +#### `tonik_util` - `v0.0.7` + + - **FEAT**: Uri property encoding and decoding. + - **FEAT**: time zone aware date time parsing. + - **FEAT**: time zone aware encoding of date time objects. + + ## 2025-06-15 ### Changes diff --git a/packages/tonik_util/CHANGELOG.md b/packages/tonik_util/CHANGELOG.md index 8a18a94..b15a987 100644 --- a/packages/tonik_util/CHANGELOG.md +++ b/packages/tonik_util/CHANGELOG.md @@ -1,3 +1,9 @@ +## 0.0.7 + + - **FEAT**: Uri property encoding and decoding. + - **FEAT**: time zone aware date time parsing. + - **FEAT**: time zone aware encoding of date time objects. + ## 0.0.6 - **FIX**: proper handle dates. diff --git a/packages/tonik_util/pubspec.yaml b/packages/tonik_util/pubspec.yaml index ac77faa..74d2a3f 100644 --- a/packages/tonik_util/pubspec.yaml +++ b/packages/tonik_util/pubspec.yaml @@ -1,6 +1,6 @@ name: tonik_util description: Runtime tools for packages generated by Tonik. -version: 0.0.6 +version: 0.0.7 repository: https://github.com/t-unit/tonik resolution: workspace From 2cce4444820f1c4bc170185698f5655e28534f0f Mon Sep 17 00:00:00 2001 From: Tobias Ottenweller Date: Sun, 20 Jul 2025 23:26:21 +0200 Subject: [PATCH 33/37] chore: bump deps --- integration_test/gov/gov_test/pubspec.yaml | 6 +++--- .../music_streaming_test/pubspec.lock | 20 +++++++++++++++++-- .../music_streaming_test/pubspec.yaml | 6 +++--- .../petstore/petstore_test/pubspec.yaml | 7 +++---- .../lib/src/pubspec_generator.dart | 2 +- pubspec.yaml | 10 +++++----- 6 files changed, 33 insertions(+), 18 deletions(-) diff --git a/integration_test/gov/gov_test/pubspec.yaml b/integration_test/gov/gov_test/pubspec.yaml index b4c67f7..0a631ab 100644 --- a/integration_test/gov/gov_test/pubspec.yaml +++ b/integration_test/gov/gov_test/pubspec.yaml @@ -10,10 +10,10 @@ dependencies: dio: ^5.8.0 gov_api: path: ../gov_api - path: ^1.8.3 - tonik_util: ^0.0.6 + path: ^1.9.1 + tonik_util: ^0.0.7 dev_dependencies: - test: ^1.24.0 + test: ^1.25.15 very_good_analysis: ^9.0.0 diff --git a/integration_test/music_streaming/music_streaming_test/pubspec.lock b/integration_test/music_streaming/music_streaming_test/pubspec.lock index 8e8b3d4..924596e 100644 --- a/integration_test/music_streaming/music_streaming_test/pubspec.lock +++ b/integration_test/music_streaming/music_streaming_test/pubspec.lock @@ -137,6 +137,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.3" + http: + dependency: transitive + description: + name: http + sha256: "2c11f3f94c687ee9bad77c171151672986360b2b001d109814ee7140b2cf261b" + url: "https://pub.dev" + source: hosted + version: "1.4.0" http_multi_server: dependency: transitive description: @@ -384,14 +392,22 @@ packages: url: "https://pub.dev" source: hosted version: "0.6.11" + timezone: + dependency: transitive + description: + name: timezone + sha256: dd14a3b83cfd7cb19e7888f1cbc20f258b8d71b54c06f79ac585f14093a287d1 + url: "https://pub.dev" + source: hosted + version: "0.10.1" tonik_util: dependency: "direct main" description: name: tonik_util - sha256: d1fc175e99d440d654d4be8b78f008d2d0614820fb8d9f40507cd3e5ee34fcca + sha256: "4b86da571a6a3ce18d89bc0e0a489aa1eebf8d43a8a883d85a69a870df3c69e8" url: "https://pub.dev" source: hosted - version: "0.0.6" + version: "0.0.7" typed_data: dependency: transitive description: diff --git a/integration_test/music_streaming/music_streaming_test/pubspec.yaml b/integration_test/music_streaming/music_streaming_test/pubspec.yaml index 27ed7d8..ff338c0 100644 --- a/integration_test/music_streaming/music_streaming_test/pubspec.yaml +++ b/integration_test/music_streaming/music_streaming_test/pubspec.yaml @@ -10,9 +10,9 @@ dependencies: dio: ^5.8.0 music_streaming_api: path: ../music_streaming_api - path: ^1.8.3 - tonik_util: ^0.0.2 + path: ^1.9.1 + tonik_util: ^0.0.7 dev_dependencies: - test: ^1.24.0 + test: ^1.25.15 very_good_analysis: ^9.0.0 diff --git a/integration_test/petstore/petstore_test/pubspec.yaml b/integration_test/petstore/petstore_test/pubspec.yaml index 6e483f5..5cea358 100644 --- a/integration_test/petstore/petstore_test/pubspec.yaml +++ b/integration_test/petstore/petstore_test/pubspec.yaml @@ -8,13 +8,12 @@ environment: dependencies: dio: ^5.8.0 - path: ^1.8.3 + path: ^1.9.1 petstore_api: path: ../petstore_api - tonik_util: ^0.0.2 - + tonik_util: ^0.0.7 dev_dependencies: - test: ^1.24.0 + test: ^1.25.15 very_good_analysis: ^9.0.0 diff --git a/packages/tonik_generate/lib/src/pubspec_generator.dart b/packages/tonik_generate/lib/src/pubspec_generator.dart index 7bb991e..dcd2c9b 100644 --- a/packages/tonik_generate/lib/src/pubspec_generator.dart +++ b/packages/tonik_generate/lib/src/pubspec_generator.dart @@ -27,7 +27,7 @@ dependencies: dio: ^5.8.0+1 lints: ^6.0.0 meta: ^1.16.0 - tonik_util: ^0.0.6 + tonik_util: ^0.0.7 '''; pubspecFile.writeAsStringSync(content); diff --git a/pubspec.yaml b/pubspec.yaml index 4625da4..387898b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,11 +16,11 @@ dependencies: logging: ^1.3.0 meta: ^1.16.0 path: ^1.9.1 - tonik: ^0.0.4 - tonik_core: ^0.0.4 - tonik_generate: ^0.0.4 - tonik_parse: ^0.0.4 - tonik_util: ^0.0.4 + tonik: ^0.0.6 + tonik_core: ^0.0.6 + tonik_generate: ^0.0.6 + tonik_parse: ^0.0.6 + tonik_util: ^0.0.7 dev_dependencies: melos: ^7.0.0-dev.9 From 0f7af7f583d9fb75d90714129e9c5b6249ac35ec Mon Sep 17 00:00:00 2001 From: Tobias Ottenweller Date: Sun, 27 Jul 2025 12:57:59 +0200 Subject: [PATCH 34/37] feat: parse time zones agnostic of locations --- .vscode/settings.json | 3 +- docs/data_types.md | 30 +- .../test/model/path_parameter_test.dart | 24 +- .../decoding/datetime_decoding_extension.dart | 286 ------ .../lib/src/decoding/json_decoder.dart | 4 +- .../lib/src/decoding/simple_decoder.dart | 4 +- .../tonik_util/lib/src/offset_date_time.dart | 364 +++++++ packages/tonik_util/lib/tonik_util.dart | 1 + packages/tonik_util/pubspec.yaml | 2 +- .../datetime_decoding_extension_test.dart | 548 ----------- .../test/src/decoding/json_decoder_test.dart | 62 +- .../src/decoding/simple_decoder_test.dart | 49 +- .../test/src/offset_date_time_test.dart | 893 ++++++++++++++++++ 13 files changed, 1377 insertions(+), 893 deletions(-) delete mode 100644 packages/tonik_util/lib/src/decoding/datetime_decoding_extension.dart create mode 100644 packages/tonik_util/lib/src/offset_date_time.dart delete mode 100644 packages/tonik_util/test/src/decoding/datetime_decoding_extension_test.dart create mode 100644 packages/tonik_util/test/src/offset_date_time_test.dart diff --git a/.vscode/settings.json b/.vscode/settings.json index 944a233..9331346 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,6 +1,7 @@ { "dart.flutterSdkPath": ".fvm/versions/3.32.0", "cSpell.words": [ - "Pubspec" + "Pubspec", + "tonik" ] } \ No newline at end of file diff --git a/docs/data_types.md b/docs/data_types.md index 680e7e2..54df516 100644 --- a/docs/data_types.md +++ b/docs/data_types.md @@ -7,7 +7,7 @@ This document provides information about how Tonik is mapping data types in Open | OAS Type | OAS Format | Dart Type | Dart Package | Comment | |----------|------------|-----------|--------------|---------| -| `string` | `date-time` | `DateTime` | `dart:core` | See [Timezone-Aware DateTime Parsing](#timezone-aware-datetime-parsing) | +| `string` | `date-time` | `DateTime` / `OffsetDateTime` | `dart:core` / `tonik_util` | 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 | @@ -22,27 +22,25 @@ This document provides information about how Tonik is mapping data types in Open ### Timezone-Aware DateTime Parsing -Tonik provides intelligent timezone-aware parsing for `date-time` format strings. The parsing behavior depends on the timezone information present in the input: +Tonik provides intelligent timezone-aware parsing for `date-time` format strings using the `OffsetDateTime` class. The parsing behavior depends on the timezone information present in the input: -> **⚠️ Important:** Before using timezone-aware parsing features, you must initialize the timezone database by calling `tz.initializeTimeZones()` from the `timezone` package. This is typically done in your application's setup code. - -All generated code will always expose Dart `DateTime` objects. However, standard Dart `DateTime` objects do not preserve timezone information, which is why Tonik uses `TZDateTime` internally during parsing to maintain timezone location data. During parsing, Tonik selects the most appropriate type to represent the date and time value: +All generated code will always expose Dart `DateTime` objects through the standard decoder methods. However, internally Tonik uses `OffsetDateTime.parse()` to provide consistent timezone handling. The `OffsetDateTime` class extends Dart's `DateTime` interface while preserving timezone offset information: | Input Format | Return Type | Example | Description | |--------------|-------------|---------|-------------| -| UTC (with Z) | `DateTime` (UTC) | `2023-12-25T15:30:45Z` | Standard Dart DateTime in UTC | -| Local (no timezone) | `DateTime` (local) | `2023-12-25T15:30:45` | Standard Dart DateTime in local timezone | -| Timezone offset | `TZDateTime` | `2023-12-25T15:30:45+05:00` | Timezone-aware DateTime with proper location | - +| UTC (with Z) | `OffsetDateTime` (UTC) | `2023-12-25T15:30:45Z` | OffsetDateTime with zero offset (UTC) | +| Local (no timezone) | `OffsetDateTime` (system timezone) | `2023-12-25T15:30:45` | OffsetDateTime with system timezone offset | +| Timezone offset | `OffsetDateTime` (specified offset) | `2023-12-25T15:30:45+05:00` | OffsetDateTime with the specified offset | +#### OffsetDateTime Benefits -#### Timezone Location Selection +The `OffsetDateTime` class provides several advantages over standard `DateTime` objects: -For strings with timezone offsets (e.g., `+05:00`), Tonik intelligently selects the best matching timezone location: +- **Preserves timezone offset information**: Unlike `DateTime`, `OffsetDateTime` retains the original timezone offset +- **Consistent API**: Implements the complete `DateTime` interface, so it can be used as a drop-in replacement +- **Fixed offset semantics**: Uses fixed timezone offsets rather than location-based timezones, avoiding DST ambiguity +- **Auto-generated timezone names**: Provides human-readable timezone names like `UTC+05:30`, `UTC-08:00`, or `UTC` -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. **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 +#### Local Time Preservation +For strings without timezone information (e.g., `2023-12-25T15:30:45`), `OffsetDateTime.parse()` preserves the local timezone behavior by using the system's timezone offset for that specific date and time. This ensures consistency with Dart's `DateTime.parse()` while providing the additional timezone offset information. diff --git a/packages/tonik_core/test/model/path_parameter_test.dart b/packages/tonik_core/test/model/path_parameter_test.dart index 3cf7b44..6e6a2fc 100644 --- a/packages/tonik_core/test/model/path_parameter_test.dart +++ b/packages/tonik_core/test/model/path_parameter_test.dart @@ -22,16 +22,16 @@ void main() { final resolved = param.resolve(name: 'newName'); - expect(resolved.name, equals('newName')); - expect(resolved.rawName, equals('originalRawName')); - expect(resolved.description, equals('description')); + expect(resolved.name, 'newName'); + expect(resolved.rawName, 'originalRawName'); + expect(resolved.description, 'description'); expect(resolved.isRequired, isTrue); expect(resolved.isDeprecated, isFalse); expect(resolved.allowEmptyValue, isFalse); expect(resolved.explode, isFalse); - expect(resolved.model, equals(model)); - expect(resolved.encoding, equals(PathParameterEncoding.simple)); - expect(resolved.context, equals(context)); + expect(resolved.model, model); + expect(resolved.encoding, PathParameterEncoding.simple); + expect(resolved.context, context); }); test('resolve preserves original name when no new name provided', () { @@ -53,7 +53,7 @@ void main() { final resolved = param.resolve(); - expect(resolved.name, equals('originalName')); + expect(resolved.name, 'originalName'); }); test('PathParameterAlias.resolve resolves with alias name', () { @@ -81,8 +81,8 @@ void main() { final resolved = alias.resolve(); - expect(resolved.name, equals('aliasName')); - expect(resolved.rawName, equals('originalRawName')); + expect(resolved.name, 'aliasName'); + expect(resolved.rawName, 'originalRawName'); }); test( @@ -112,7 +112,7 @@ void main() { final resolved = alias.resolve(name: 'overrideName'); - expect(resolved.name, equals('overrideName')); + expect(resolved.name, 'overrideName'); }, ); @@ -147,8 +147,8 @@ void main() { final resolved = secondAlias.resolve(); - expect(resolved.name, equals('secondAliasName')); - expect(resolved.rawName, equals('originalRawName')); + expect(resolved.name, 'secondAliasName'); + expect(resolved.rawName, 'originalRawName'); }); }); } diff --git a/packages/tonik_util/lib/src/decoding/datetime_decoding_extension.dart b/packages/tonik_util/lib/src/decoding/datetime_decoding_extension.dart deleted file mode 100644 index adde16d..0000000 --- a/packages/tonik_util/lib/src/decoding/datetime_decoding_extension.dart +++ /dev/null @@ -1,286 +0,0 @@ -import 'package:timezone/timezone.dart' as tz; -import 'package:tonik_util/src/decoding/decoding_exception.dart'; - -/// Extension on DateTime to provide timezone-aware parsing. -/// -/// This extension handles timezone information correctly: -/// - UTC strings return DateTime.utc objects -/// - Strings without timezone info return local DateTime objects -/// - Strings with timezone offsets return TZDateTime objects -extension DateTimeParsingExtension on DateTime { - /// Parses an ISO8601 datetime string with proper timezone handling. - /// - /// Returns: - /// - [DateTime] (UTC) for strings ending with 'Z' - /// - [DateTime] (local) for strings without timezone information - /// - [tz.TZDateTime] for strings with timezone offset information - /// - /// Throws [DecodingException] if the string is not a valid ISO8601 format. - static DateTime parseWithTimeZone(String input) { - if (input.isEmpty) { - throw const InvalidFormatException( - value: '', - format: 'ISO8601 datetime string', - ); - } - - // Handle different separator formats (T or space) - final normalizedInput = input.replaceFirst(' ', 'T'); - - // Check if it has timezone offset (±HH:MM or ±HHMM) - final timezoneRegex = RegExp(r'[+-]\d{2}:?\d{2}$'); - final timezoneMatch = timezoneRegex.firstMatch(normalizedInput); - - if (timezoneMatch != null) { - return _parseWithTimezoneOffset(normalizedInput, timezoneMatch); - } - - // Parse as UTC (ends with Z) or local time (no timezone info) - try { - return DateTime.parse(normalizedInput); - } on FormatException { - throw InvalidFormatException( - value: normalizedInput, - format: 'ISO8601 datetime format', - ); - } - } - - /// Parses a datetime string with timezone offset. - static tz.TZDateTime _parseWithTimezoneOffset( - String input, - RegExpMatch timezoneMatch, - ) { - final offsetString = timezoneMatch.group(0)!; - final datetimeString = input.substring( - 0, - input.length - offsetString.length, - ); - - final offset = _parseTimezoneOffset(offsetString); - final localDateTime = DateTime.parse(datetimeString); - final location = _findLocationForOffset(offset, localDateTime); - - // For standard offsets that have proper timezone locations, use them - if (location.name != 'UTC' || offset == Duration.zero) { - final utcDateTime = localDateTime.subtract(offset); - - final utcTz = tz.TZDateTime.utc( - utcDateTime.year, - utcDateTime.month, - utcDateTime.day, - utcDateTime.hour, - utcDateTime.minute, - utcDateTime.second, - utcDateTime.millisecond, - utcDateTime.microsecond, - ); - - return tz.TZDateTime.from(utcTz, location); - } - - // For unusual offsets that don't have proper timezone locations, - // fall back to UTC and convert the time correctly - final utcDateTime = localDateTime.subtract(offset); - - return tz.TZDateTime.utc( - utcDateTime.year, - utcDateTime.month, - utcDateTime.day, - utcDateTime.hour, - utcDateTime.minute, - utcDateTime.second, - utcDateTime.millisecond, - utcDateTime.microsecond, - ); - } - - /// Finds the best timezone location for a given offset at a - /// specific datetime. - /// - /// This leverages the timezone package's comprehensive database to find - /// locations that match the offset, taking into account DST changes. - static tz.Location _findLocationForOffset( - Duration offset, - DateTime dateTime, - ) { - final offsetMinutes = offset.inMinutes; - final timestamp = dateTime.millisecondsSinceEpoch; - - for (final locationName in _commonLocations) { - try { - final location = tz.getLocation(locationName); - final timeZone = location.timeZone(timestamp); - if (timeZone.offset == offsetMinutes * 60 * 1000) { - return location; - } - } on tz.LocationNotFoundException { - // Location doesn't exist, continue - } - } - - final matchingLocations = []; - for (final location in tz.timeZoneDatabase.locations.values) { - final timeZone = location.timeZone(timestamp); - if (timeZone.offset == offsetMinutes * 60 * 1000) { - matchingLocations.add(location); - } - } - - if (matchingLocations.isNotEmpty) { - // Prefer locations that don't use deprecated prefixes - final preferredMatches = - matchingLocations - .where( - (loc) => - !loc.name.startsWith('US/') && - !loc.name.startsWith('Etc/') && - !loc.name.contains('GMT'), - ) - .toList(); - - if (preferredMatches.isNotEmpty) { - return preferredMatches.first; - } - - return matchingLocations.first; - } - - return _createFixedOffsetLocation(offset); - } - - /// Creates a location with a fixed offset when no matching timezone is found. - static tz.Location _createFixedOffsetLocation(Duration offset) { - final offsetMinutes = offset.inMinutes; - - // For standard hour offsets, try to use Etc/GMT locations - if (offsetMinutes % 60 == 0) { - final offsetHours = offsetMinutes ~/ 60; - // Use Etc/GMT format which is supported by the timezone database - // Note: Etc/GMT offsets are inverted (Etc/GMT+5 is actually GMT-5) - final etcName = - offset.isNegative - ? 'Etc/GMT+${offsetHours.abs()}' - : 'Etc/GMT-${offsetHours.abs()}'; - - try { - return tz.getLocation(etcName); - } on tz.LocationNotFoundException { - // Fall through to UTC - } - } - - // For non-standard offsets, fall back to UTC - // This is a limitation - the timezone package doesn't easily support - // arbitrary fixed offsets, so we use UTC as fallback - return tz.getLocation('UTC'); - } - - /// Parses timezone offset string (±HH:MM or ±HHMM) into Duration. - static Duration _parseTimezoneOffset(String offsetString) { - // Remove optional colon for compact format - final normalized = offsetString.replaceAll(':', ''); - - if (normalized.length != 5) { - throw InvalidFormatException( - value: offsetString, - format: '±HHMM or ±HH:MM timezone offset', - ); - } - - final sign = normalized[0] == '+' ? 1 : -1; - final hoursStr = normalized.substring(1, 3); - final minutesStr = normalized.substring(3, 5); - - final hours = int.parse(hoursStr); - final minutes = int.parse(minutesStr); - - // Let Duration handle any overflow gracefully - return Duration(hours: sign * hours, minutes: sign * minutes); - } -} - -/// Commonly used timezone locations, prioritized for offset matching. -/// Based on major cities and avoiding deprecated location names. -const _commonLocations = [ - // Europe - 'Europe/London', - 'Europe/Paris', - 'Europe/Berlin', - 'Europe/Rome', - 'Europe/Madrid', - 'Europe/Amsterdam', - 'Europe/Brussels', - 'Europe/Vienna', - 'Europe/Zurich', - 'Europe/Stockholm', - 'Europe/Oslo', - 'Europe/Copenhagen', - 'Europe/Helsinki', - 'Europe/Warsaw', - 'Europe/Prague', - 'Europe/Budapest', - 'Europe/Athens', - 'Europe/Istanbul', - 'Europe/Moscow', - - // Americas - 'America/New_York', - 'America/Chicago', - 'America/Denver', - 'America/Los_Angeles', - 'America/Toronto', - 'America/Vancouver', - 'America/Montreal', - 'America/Mexico_City', - 'America/Sao_Paulo', - 'America/Buenos_Aires', - 'America/Lima', - 'America/Bogota', - 'America/Caracas', - 'America/Santiago', - 'America/Montevideo', - - // Asia - 'Asia/Tokyo', - 'Asia/Seoul', - 'Asia/Shanghai', - 'Asia/Hong_Kong', - 'Asia/Singapore', - 'Asia/Bangkok', - 'Asia/Jakarta', - 'Asia/Manila', - 'Asia/Kuala_Lumpur', - 'Asia/Kolkata', - 'Asia/Mumbai', - 'Asia/Karachi', - 'Asia/Dubai', - 'Asia/Riyadh', - 'Asia/Baghdad', - 'Asia/Tehran', - 'Asia/Kabul', - 'Asia/Tashkent', - 'Asia/Almaty', - - // Australia & Pacific - 'Australia/Sydney', - 'Australia/Melbourne', - 'Australia/Brisbane', - 'Australia/Perth', - 'Australia/Adelaide', - 'Pacific/Auckland', - 'Pacific/Honolulu', - 'Pacific/Fiji', - - // Africa - 'Africa/Cairo', - 'Africa/Johannesburg', - 'Africa/Lagos', - 'Africa/Nairobi', - 'Africa/Casablanca', - 'Africa/Tunis', - 'Africa/Algiers', - - // UTC - 'UTC', -]; diff --git a/packages/tonik_util/lib/src/decoding/json_decoder.dart b/packages/tonik_util/lib/src/decoding/json_decoder.dart index 0630df7..29e11cc 100644 --- a/packages/tonik_util/lib/src/decoding/json_decoder.dart +++ b/packages/tonik_util/lib/src/decoding/json_decoder.dart @@ -1,7 +1,7 @@ import 'package:big_decimal/big_decimal.dart'; import 'package:tonik_util/src/date.dart'; -import 'package:tonik_util/src/decoding/datetime_decoding_extension.dart'; import 'package:tonik_util/src/decoding/decoding_exception.dart'; +import 'package:tonik_util/src/offset_date_time.dart'; /// Extensions for decoding JSON values. extension JsonDecoder on Object? { @@ -26,7 +26,7 @@ extension JsonDecoder on Object? { ); } try { - return DateTimeParsingExtension.parseWithTimeZone(this! as String); + return OffsetDateTime.parse(this! as String); } on FormatException catch (e) { throw InvalidTypeException( value: this! as String, diff --git a/packages/tonik_util/lib/src/decoding/simple_decoder.dart b/packages/tonik_util/lib/src/decoding/simple_decoder.dart index 930c665..3b61583 100644 --- a/packages/tonik_util/lib/src/decoding/simple_decoder.dart +++ b/packages/tonik_util/lib/src/decoding/simple_decoder.dart @@ -1,7 +1,7 @@ import 'package:big_decimal/big_decimal.dart'; import 'package:tonik_util/src/date.dart'; -import 'package:tonik_util/src/decoding/datetime_decoding_extension.dart'; import 'package:tonik_util/src/decoding/decoding_exception.dart'; +import 'package:tonik_util/src/offset_date_time.dart'; /// Extensions for decoding simple form values from strings. extension SimpleDecoder on String? { @@ -146,7 +146,7 @@ extension SimpleDecoder on String? { ); } try { - return DateTimeParsingExtension.parseWithTimeZone(this!); + return OffsetDateTime.parse(this!); } on Object { throw InvalidTypeException( value: this!, diff --git a/packages/tonik_util/lib/src/offset_date_time.dart b/packages/tonik_util/lib/src/offset_date_time.dart new file mode 100644 index 0000000..53c0332 --- /dev/null +++ b/packages/tonik_util/lib/src/offset_date_time.dart @@ -0,0 +1,364 @@ +import 'dart:core'; + +import 'package:meta/meta.dart'; +import 'package:tonik_util/src/decoding/decoding_exception.dart'; + +/// A DateTime implementation that supports fixed timezone offsets. +/// +/// This class provides timezone-aware DateTime functionality with a fixed +/// offset from UTC. +/// It implements the DateTime interface by delegating to an internal UTC +/// DateTime object and applying offset adjustments for local time operations. +@immutable +class OffsetDateTime implements DateTime { + /// Creates an [OffsetDateTime] from an existing [DateTime] with the + /// specified offset. + /// + /// The [dateTime] is interpreted as being in the timezone specified + /// by [offset]. The resulting [OffsetDateTime] will represent the same + /// moment in time, but with the specified offset. + OffsetDateTime.from( + DateTime dateTime, { + required this.offset, + String? timeZoneName, + }) : timeZoneName = timeZoneName ?? _generateTimeZoneName(offset), + _utcDateTime = + dateTime.isUtc ? dateTime : _toUtcDateTime(dateTime, offset); + + const OffsetDateTime._fromUtc( + this._utcDateTime, { + required this.offset, + required this.timeZoneName, + }); + + /// Parses a datetime string with timezone offset. + factory OffsetDateTime._parseWithTimezoneOffset( + String input, + RegExpMatch timezoneMatch, + ) { + final offsetString = timezoneMatch.group(0)!; + final datetimeString = input.substring( + 0, + input.length - offsetString.length, + ); + + final offset = _parseTimezoneOffset(offsetString); + + // Parse the datetime part (without timezone) as local time + final localDateTime = DateTime.parse(datetimeString); + + // Create OffsetDateTime from the local time and offset + return OffsetDateTime.from( + localDateTime, + offset: offset, + ); + } + + /// Parses an ISO8601 datetime string with timezone support. + /// + /// Always returns an [OffsetDateTime] object: + /// - For strings ending with 'Z': OffsetDateTime with zero offset (UTC) + /// - For strings without timezone: OffsetDateTime with system timezone offset + /// - For strings with timezone offset: OffsetDateTime with the specified + /// offset + /// + /// Throws [DecodingException] if the string is not a valid ISO8601 format. + /// + /// Examples: + /// ```dart + /// OffsetDateTime.parse('2023-12-25T15:30:45Z'); // OffsetDateTime (UTC) + /// OffsetDateTime.parse('2023-12-25T15:30:45'); // OffsetDateTime (system timezone) + /// OffsetDateTime.parse('2023-12-25T15:30:45+05:30'); // OffsetDateTime (+05:30) + /// ``` + static OffsetDateTime parse(String input) { + if (input.isEmpty) { + throw const InvalidFormatException( + value: '', + format: 'ISO8601 datetime string', + ); + } + + // Handle different separator formats (T or space) + final normalizedInput = input.replaceFirst(' ', 'T'); + + // Check if it has timezone offset (±HH:MM or ±HHMM) + final timezoneRegex = RegExp(r'[+-]\d{2}:?\d{2}$'); + final timezoneMatch = timezoneRegex.firstMatch(normalizedInput); + + if (timezoneMatch != null) { + return OffsetDateTime._parseWithTimezoneOffset( + normalizedInput, + timezoneMatch, + ); + } + + // Parse as UTC (ends with Z) or local time (no timezone info) + try { + final dateTime = DateTime.parse(normalizedInput); + + // Create OffsetDateTime from the parsed DateTime + if (dateTime.isUtc) { + // UTC datetime - create with zero offset + return OffsetDateTime.from(dateTime, offset: Duration.zero); + } else { + // Local datetime - preserve the system timezone offset + return OffsetDateTime.from(dateTime, offset: dateTime.timeZoneOffset); + } + } on FormatException { + throw InvalidFormatException( + value: normalizedInput, + format: 'ISO8601 datetime format', + ); + } + } + + /// Parses timezone offset string (±HH:MM or ±HHMM) into Duration. + static Duration _parseTimezoneOffset(String offsetString) { + // Remove optional colon for compact format + final normalized = offsetString.replaceAll(':', ''); + + if (normalized.length != 5) { + throw InvalidFormatException( + value: offsetString, + format: '±HHMM or ±HH:MM timezone offset', + ); + } + + final sign = normalized[0] == '+' ? 1 : -1; + final hoursStr = normalized.substring(1, 3); + final minutesStr = normalized.substring(3, 5); + + final hours = int.parse(hoursStr); + final minutes = int.parse(minutesStr); + + if (hours < 0 || hours > 23) { + throw InvalidFormatException( + value: offsetString, + format: 'timezone offset hours must be between 00 and 23', + ); + } + + if (minutes < 0 || minutes > 59) { + throw InvalidFormatException( + value: offsetString, + format: 'timezone offset minutes must be between 00 and 59', + ); + } + + return Duration(hours: sign * hours, minutes: sign * minutes); + } + + /// The canonical UTC representation of this datetime. + /// + /// This represents the same moment in time as this [OffsetDateTime], + /// but in UTC time zone. + final DateTime _utcDateTime; + + /// The timezone offset from UTC. + /// + /// Positive values are east of UTC, negative values are west of UTC. + /// For example, an offset of +5 hours would be Duration(hours: 5). + final Duration offset; + + @override + final String timeZoneName; + + /// Converts a local DateTime with an offset to UTC DateTime. + static DateTime _toUtcDateTime(DateTime localDateTime, Duration offset) { + // Calculate the UTC moment by subtracting the offset + final utcMoment = localDateTime.subtract(offset); + // Return a proper UTC DateTime with isUtc = true + return DateTime.utc( + utcMoment.year, + utcMoment.month, + utcMoment.day, + utcMoment.hour, + utcMoment.minute, + utcMoment.second, + utcMoment.millisecond, + utcMoment.microsecond, + ); + } + + /// Generates a timezone name from an offset. + /// + /// Returns 'UTC' for zero offset, otherwise returns a UTC-based formatted + /// offset like 'UTC+05:30' or 'UTC-08:00'. + static String _generateTimeZoneName(Duration offset) { + if (offset == Duration.zero) { + return 'UTC'; + } + + final hours = offset.inHours; + final minutes = offset.inMinutes.abs() % 60; + final sign = hours < 0 || (hours == 0 && offset.isNegative) ? '-' : '+'; + final absHours = hours.abs(); + + final hourPart = absHours.toString().padLeft(2, '0'); + final minutePart = minutes.toString().padLeft(2, '0'); + return 'UTC$sign$hourPart:$minutePart'; + } + + @override + OffsetDateTime toUtc() { + if (offset == Duration.zero) { + return this; + } + return OffsetDateTime._fromUtc( + _utcDateTime, + offset: Duration.zero, + timeZoneName: 'UTC', + ); + } + + @override + DateTime toLocal() { + return DateTime.fromMicrosecondsSinceEpoch( + microsecondsSinceEpoch, + ); + } + + @override + int get millisecondsSinceEpoch => _utcDateTime.millisecondsSinceEpoch; + + @override + int get microsecondsSinceEpoch => _utcDateTime.microsecondsSinceEpoch; + + @override + bool get isUtc => offset == Duration.zero; + + @override + OffsetDateTime add(Duration duration) { + final newUtcDateTime = _utcDateTime.add(duration); + return OffsetDateTime._fromUtc( + newUtcDateTime, + offset: offset, + timeZoneName: timeZoneName, + ); + } + + @override + OffsetDateTime subtract(Duration duration) { + final newUtcDateTime = _utcDateTime.subtract(duration); + return OffsetDateTime._fromUtc( + newUtcDateTime, + offset: offset, + timeZoneName: timeZoneName, + ); + } + + @override + Duration difference(DateTime other) => + _utcDateTime.difference(_toNative(other)); + + @override + bool isBefore(DateTime other) => _utcDateTime.isBefore(_toNative(other)); + + @override + bool isAfter(DateTime other) => _utcDateTime.isAfter(_toNative(other)); + + @override + bool isAtSameMomentAs(DateTime other) => + _utcDateTime.isAtSameMomentAs(_toNative(other)); + + @override + int compareTo(DateTime other) => _utcDateTime.compareTo(_toNative(other)); + + @override + bool operator ==(Object other) { + return identical(this, other) || + other is OffsetDateTime && + _utcDateTime.isAtSameMomentAs(other._utcDateTime); + } + + @override + int get hashCode => _utcDateTime.hashCode; + + @override + Duration get timeZoneOffset => offset; + + DateTime get _localDateTime => _utcDateTime.add(offset); + + @override + int get year => _localDateTime.year; + + @override + int get month => _localDateTime.month; + + @override + int get day => _localDateTime.day; + + @override + int get hour => _localDateTime.hour; + + @override + int get minute => _localDateTime.minute; + + @override + int get second => _localDateTime.second; + + @override + int get millisecond => _localDateTime.millisecond; + + @override + int get microsecond => _localDateTime.microsecond; + + @override + int get weekday => _localDateTime.weekday; + + @override + String toString() => _toString(iso8601: false); + + @override + String toIso8601String() => _toString(); + + String _toString({bool iso8601 = true}) { + final local = _localDateTime; + final y = _fourDigits(local.year); + final m = _twoDigits(local.month); + final d = _twoDigits(local.day); + final sep = iso8601 ? 'T' : ' '; + final h = _twoDigits(local.hour); + final min = _twoDigits(local.minute); + final sec = _twoDigits(local.second); + final ms = _threeDigits(local.millisecond); + final us = local.microsecond == 0 ? '' : _threeDigits(local.microsecond); + + if (isUtc || offset == Duration.zero) { + return '$y-$m-$d$sep$h:$min:$sec.$ms${us}Z'; + } else { + final offsetSign = offset.isNegative ? '-' : '+'; + final offsetAbs = offset.abs(); + final offsetHours = offsetAbs.inHours; + final offsetMinutes = offsetAbs.inMinutes % 60; + final offH = _twoDigits(offsetHours); + final offM = _twoDigits(offsetMinutes); + + return '$y-$m-$d$sep$h:$min:$sec.$ms$us$offsetSign$offH$offM'; + } + } + + static String _fourDigits(int n) { + final absN = n.abs(); + final sign = n < 0 ? '-' : ''; + if (absN >= 1000) return '$n'; + if (absN >= 100) return '${sign}0$absN'; + if (absN >= 10) return '${sign}00$absN'; + return '${sign}000$absN'; + } + + static String _threeDigits(int n) { + if (n >= 100) return '$n'; + if (n >= 10) return '0$n'; + return '00$n'; + } + + static String _twoDigits(int n) { + if (n >= 10) return '$n'; + return '0$n'; + } + + /// Returns the native [DateTime] object. + static DateTime _toNative(DateTime t) => + t is OffsetDateTime ? t._utcDateTime : t; +} diff --git a/packages/tonik_util/lib/tonik_util.dart b/packages/tonik_util/lib/tonik_util.dart index 98bbe84..40cc418 100644 --- a/packages/tonik_util/lib/tonik_util.dart +++ b/packages/tonik_util/lib/tonik_util.dart @@ -15,4 +15,5 @@ export 'src/encoding/label_encoder.dart'; export 'src/encoding/matrix_encoder.dart'; export 'src/encoding/parameter_entry.dart'; export 'src/encoding/simple_encoder.dart'; +export 'src/offset_date_time.dart'; export 'src/tonik_result.dart'; diff --git a/packages/tonik_util/pubspec.yaml b/packages/tonik_util/pubspec.yaml index 74d2a3f..61df51f 100644 --- a/packages/tonik_util/pubspec.yaml +++ b/packages/tonik_util/pubspec.yaml @@ -12,7 +12,7 @@ dependencies: collection: ^1.19.1 dio: ^5.0.0 meta: ^1.16.0 - timezone: ^0.10.1 dev_dependencies: test: ^1.24.0 + timezone: ^0.10.1 diff --git a/packages/tonik_util/test/src/decoding/datetime_decoding_extension_test.dart b/packages/tonik_util/test/src/decoding/datetime_decoding_extension_test.dart deleted file mode 100644 index ed6b868..0000000 --- a/packages/tonik_util/test/src/decoding/datetime_decoding_extension_test.dart +++ /dev/null @@ -1,548 +0,0 @@ -import 'package:test/test.dart'; -import 'package:timezone/data/latest.dart' as tz; -import 'package:tonik_util/src/decoding/datetime_decoding_extension.dart'; -import 'package:tonik_util/src/decoding/decoding_exception.dart'; - -void main() { - setUpAll(tz.initializeTimeZones); - - group('DateTimeParsingExtension', () { - group('parseWithTimeZone', () { - group('UTC parsing', () { - test('parses UTC datetime with Z suffix', () { - const input = '2023-12-25T15:30:45Z'; - final result = DateTimeParsingExtension.parseWithTimeZone(input); - - expect(result.isUtc, isTrue); - expect(result.year, 2023); - expect(result.month, 12); - expect(result.day, 25); - expect(result.hour, 15); - expect(result.minute, 30); - expect(result.second, 45); - expect(result.millisecond, 0); - expect(result.microsecond, 0); - expect(result.timeZoneOffset, Duration.zero); - }); - - test('parses UTC datetime with milliseconds', () { - const input = '2023-12-25T15:30:45.123Z'; - final result = DateTimeParsingExtension.parseWithTimeZone(input); - - expect(result.isUtc, isTrue); - expect(result.year, 2023); - expect(result.month, 12); - expect(result.day, 25); - expect(result.hour, 15); - expect(result.minute, 30); - expect(result.second, 45); - expect(result.millisecond, 123); - expect(result.microsecond, 0); - expect(result.timeZoneOffset, Duration.zero); - }); - - test('parses UTC datetime with microseconds', () { - const input = '2023-12-25T15:30:45.123456Z'; - final result = DateTimeParsingExtension.parseWithTimeZone(input); - - expect(result.isUtc, isTrue); - expect(result.year, 2023); - expect(result.month, 12); - expect(result.day, 25); - expect(result.hour, 15); - expect(result.minute, 30); - expect(result.second, 45); - expect(result.millisecond, 123); - expect(result.microsecond, 456); - expect(result.timeZoneOffset, Duration.zero); - }); - - test('parses UTC datetime at midnight', () { - const input = '2023-12-25T00:00:00Z'; - final result = DateTimeParsingExtension.parseWithTimeZone(input); - - expect(result.isUtc, isTrue); - expect(result.year, 2023); - expect(result.month, 12); - expect(result.day, 25); - expect(result.hour, 0); - expect(result.minute, 0); - expect(result.second, 0); - expect(result.millisecond, 0); - expect(result.microsecond, 0); - expect(result.timeZoneOffset, Duration.zero); - }); - - test('parses UTC datetime at end of day', () { - const input = '2023-12-25T23:59:59Z'; - final result = DateTimeParsingExtension.parseWithTimeZone(input); - - expect(result.isUtc, isTrue); - expect(result.year, 2023); - expect(result.month, 12); - expect(result.day, 25); - expect(result.hour, 23); - expect(result.minute, 59); - expect(result.second, 59); - expect(result.millisecond, 0); - expect(result.microsecond, 0); - expect(result.timeZoneOffset, Duration.zero); - }); - }); - - group('local time parsing (no timezone info)', () { - test('parses datetime without timezone as local time', () { - const input = '2023-12-25T15:30:45'; - final result = DateTimeParsingExtension.parseWithTimeZone(input); - final expected = DateTime(2023, 12, 25, 15, 30, 45); - - expect(result.isUtc, isFalse); - expect(result.year, 2023); - expect(result.month, 12); - expect(result.day, 25); - expect(result.hour, 15); - expect(result.minute, 30); - expect(result.second, 45); - expect(result.millisecond, 0); - expect(result.microsecond, 0); - expect(result.timeZoneOffset, expected.timeZoneOffset); - }); - - test('parses datetime with milliseconds as local time', () { - const input = '2023-12-25T15:30:45.123'; - final result = DateTimeParsingExtension.parseWithTimeZone(input); - final expected = DateTime(2023, 12, 25, 15, 30, 45, 123); - - expect(result.isUtc, isFalse); - expect(result.year, 2023); - expect(result.month, 12); - expect(result.day, 25); - expect(result.hour, 15); - expect(result.minute, 30); - expect(result.second, 45); - expect(result.millisecond, 123); - expect(result.microsecond, 0); - expect(result.timeZoneOffset, expected.timeZoneOffset); - }); - - test('parses datetime with microseconds as local time', () { - const input = '2023-12-25T15:30:45.123456'; - final result = DateTimeParsingExtension.parseWithTimeZone(input); - final expected = DateTime(2023, 12, 25, 15, 30, 45, 123, 456); - - expect(result.isUtc, isFalse); - expect(result.year, 2023); - expect(result.month, 12); - expect(result.day, 25); - expect(result.hour, 15); - expect(result.minute, 30); - expect(result.second, 45); - expect(result.millisecond, 123); - expect(result.microsecond, 456); - expect(result.timeZoneOffset, expected.timeZoneOffset); - }); - - test('parses date-only format as local midnight', () { - const input = '2023-12-25'; - final result = DateTimeParsingExtension.parseWithTimeZone(input); - final expected = DateTime(2023, 12, 25); - - expect(result.isUtc, isFalse); - expect(result.year, 2023); - expect(result.month, 12); - expect(result.day, 25); - expect(result.hour, 0); - expect(result.minute, 0); - expect(result.second, 0); - expect(result.millisecond, 0); - expect(result.microsecond, 0); - expect(result.timeZoneOffset, expected.timeZoneOffset); - }); - }); - - group('timezone offset parsing', () { - test('parses positive timezone offset (+05:00)', () { - const input = '2023-12-25T15:30:45+05:00'; - final result = DateTimeParsingExtension.parseWithTimeZone(input); - - expect(result.year, 2023); - expect(result.month, 12); - expect(result.day, 25); - expect(result.hour, 15); - expect(result.minute, 30); - expect(result.second, 45); - expect(result.millisecond, 0); - expect(result.microsecond, 0); - expect(result.timeZoneOffset.inHours, 5); - }); - - test('parses negative timezone offset (-08:00)', () { - const input = '2023-12-25T15:30:45-08:00'; - final result = DateTimeParsingExtension.parseWithTimeZone(input); - - expect(result.year, 2023); - expect(result.month, 12); - expect(result.day, 25); - expect(result.hour, 15); - expect(result.minute, 30); - expect(result.second, 45); - expect(result.millisecond, 0); - expect(result.microsecond, 0); - expect(result.timeZoneOffset.inHours, -8); - }); - - test('parses timezone offset with 30-minute offset (+05:30)', () { - const input = '2023-12-25T15:30:45+05:30'; - final result = DateTimeParsingExtension.parseWithTimeZone(input); - - expect(result.year, 2023); - expect(result.month, 12); - expect(result.day, 25); - expect(result.hour, 15); - expect(result.minute, 30); - expect(result.second, 45); - expect(result.millisecond, 0); - expect(result.microsecond, 0); - expect(result.timeZoneOffset.inMinutes, 330); // 5.5 hours - }); - - test('parses timezone offset with 45-minute offset (+05:45)', () { - const input = '2023-12-25T15:30:45+05:45'; - final result = DateTimeParsingExtension.parseWithTimeZone(input); - - expect(result.year, 2023); - expect(result.month, 12); - expect(result.day, 25); - expect(result.hour, 15); - expect(result.minute, 30); - expect(result.second, 45); - expect(result.millisecond, 0); - expect(result.microsecond, 0); - expect(result.timeZoneOffset.inMinutes, 345); // 5.75 hours - }); - - test('parses timezone offset with milliseconds', () { - const input = '2023-12-25T15:30:45.123+05:00'; - final result = DateTimeParsingExtension.parseWithTimeZone(input); - - expect(result.year, 2023); - expect(result.month, 12); - expect(result.day, 25); - expect(result.hour, 15); - expect(result.minute, 30); - expect(result.second, 45); - expect(result.millisecond, 123); - expect(result.microsecond, 0); - expect(result.timeZoneOffset.inHours, 5); - }); - - test('parses timezone offset with microseconds', () { - const input = '2023-12-25T15:30:45.123456+05:00'; - final result = DateTimeParsingExtension.parseWithTimeZone(input); - - expect(result.year, 2023); - expect(result.month, 12); - expect(result.day, 25); - expect(result.hour, 15); - expect(result.minute, 30); - expect(result.second, 45); - expect(result.millisecond, 123); - expect(result.microsecond, 456); - expect(result.timeZoneOffset.inHours, 5); - }); - - test('parses compact timezone offset format (+0500)', () { - const input = '2023-12-25T15:30:45+0500'; - final result = DateTimeParsingExtension.parseWithTimeZone(input); - - expect(result.year, 2023); - expect(result.month, 12); - expect(result.day, 25); - expect(result.hour, 15); - expect(result.minute, 30); - expect(result.second, 45); - expect(result.millisecond, 0); - expect(result.microsecond, 0); - expect(result.timeZoneOffset.inHours, 5); - }); - - test('parses compact negative timezone offset format (-0800)', () { - const input = '2023-12-25T15:30:45-0800'; - final result = DateTimeParsingExtension.parseWithTimeZone(input); - - expect(result.year, 2023); - expect(result.month, 12); - expect(result.day, 25); - expect(result.hour, 15); - expect(result.minute, 30); - expect(result.second, 45); - expect(result.millisecond, 0); - expect(result.microsecond, 0); - expect(result.timeZoneOffset.inHours, -8); - }); - }); - - group('timezone location matching', () { - test('maps common European timezone offset to CET', () { - const input = '2023-12-25T15:30:45+01:00'; // CET (winter time) - final result = DateTimeParsingExtension.parseWithTimeZone(input); - - expect(result.timeZoneName, 'CET'); - expect(result.timeZoneOffset.inHours, 1); - }); - - test('maps summer time European offset to CEST', () { - const input = '2023-07-25T15:30:45+02:00'; // CEST (summer time) - final result = DateTimeParsingExtension.parseWithTimeZone(input); - - expect(result.timeZoneName, 'CEST'); - expect(result.timeZoneOffset.inHours, 2); - }); - - test('maps US Eastern timezone offset to EST', () { - const input = '2023-12-25T15:30:45-05:00'; // EST (winter time) - final result = DateTimeParsingExtension.parseWithTimeZone(input); - - expect(result.timeZoneName, 'EST'); - expect(result.timeZoneOffset.inHours, -5); - }); - - test('maps US Eastern summer time offset to EDT', () { - const input = '2023-07-25T15:30:45-04:00'; // EDT (summer time) - final result = DateTimeParsingExtension.parseWithTimeZone(input); - - expect(result.timeZoneName, 'EDT'); - expect(result.timeZoneOffset.inHours, -4); - }); - - test('maps India Standard Time offset to IST', () { - const input = '2023-12-25T15:30:45+05:30'; - final result = DateTimeParsingExtension.parseWithTimeZone(input); - expect(result.timeZoneName, 'IST'); - expect(result.timeZoneOffset.inMinutes, 330); // 5.5 hours - }); - - test('maps Japan Standard Time offset to JST', () { - const input = '2023-12-25T15:30:45+09:00'; - final result = DateTimeParsingExtension.parseWithTimeZone(input); - - expect(result.timeZoneName, 'JST'); - expect(result.timeZoneOffset.inHours, 9); - }); - - test( - 'handles unusual timezone offset by falling back to UTC', - () { - const input = '2023-12-25T15:30:45+03:17'; // Unusual offset - final result = DateTimeParsingExtension.parseWithTimeZone(input); - - // For unusual offsets, falls back to UTC due to - // timezone package limitations - expect(result.timeZoneOffset.inMinutes, 0); // UTC - expect(result.timeZoneName, 'UTC'); - - // But the parsed time should still be correctly converted - // Original: 15:30:45+03:17 should convert to UTC time - expect(result.year, 2023); - expect(result.month, 12); - expect(result.day, 25); - expect(result.hour, 12); // 15:30 - 3:17 = 12:13 - expect(result.minute, 13); - expect(result.second, 45); - }, - ); - }); - - group('edge cases', () { - test('parses leap year date (UTC)', () { - const input = '2024-02-29T12:00:00Z'; - final result = DateTimeParsingExtension.parseWithTimeZone(input); - - expect(result.year, 2024); - expect(result.month, 2); - expect(result.day, 29); - }); - - test('parses leap year date (local)', () { - const input = '2024-02-29T12:00:00'; - final result = DateTimeParsingExtension.parseWithTimeZone(input); - - expect(result.year, 2024); - expect(result.month, 2); - expect(result.day, 29); - }); - - test('parses leap year date (timezone offset)', () { - const input = '2024-02-29T12:00:00+03:00'; - final result = DateTimeParsingExtension.parseWithTimeZone(input); - - expect(result.year, 2024); - expect(result.month, 2); - expect(result.day, 29); - }); - - test('parses year boundaries correctly (UTC)', () { - const input = '2023-12-31T23:59:59Z'; - final result = DateTimeParsingExtension.parseWithTimeZone(input); - - expect(result.year, 2023); - expect(result.month, 12); - expect(result.day, 31); - }); - - test('parses year boundaries correctly (local)', () { - const input = '2023-12-31T23:59:59'; - final result = DateTimeParsingExtension.parseWithTimeZone(input); - - expect(result.year, 2023); - expect(result.month, 12); - expect(result.day, 31); - }); - - test('parses year boundaries correctly (timezone offset)', () { - const input = '2023-12-31T23:59:59-05:00'; - final result = DateTimeParsingExtension.parseWithTimeZone(input); - - expect(result.year, 2023); - expect(result.month, 12); - expect(result.day, 31); - }); - - test('parses new year correctly (UTC)', () { - const input = '2024-01-01T00:00:00Z'; - final result = DateTimeParsingExtension.parseWithTimeZone(input); - - expect(result.year, 2024); - expect(result.month, 1); - expect(result.day, 1); - }); - - test('parses new year correctly (local)', () { - const input = '2024-01-01T00:00:00'; - final result = DateTimeParsingExtension.parseWithTimeZone(input); - - expect(result.year, 2024); - expect(result.month, 1); - expect(result.day, 1); - }); - - test('parses new year correctly (timezone offset)', () { - const input = '2024-01-01T00:00:00+09:00'; - final result = DateTimeParsingExtension.parseWithTimeZone(input); - - expect(result.year, 2024); - expect(result.month, 1); - expect(result.day, 1); - }); - - test('handles single digit milliseconds (UTC)', () { - const input = '2023-12-25T15:30:45.1Z'; - final result = DateTimeParsingExtension.parseWithTimeZone(input); - - expect(result.millisecond, 100); - }); - - test('handles single digit milliseconds (local)', () { - const input = '2023-12-25T15:30:45.1'; - final result = DateTimeParsingExtension.parseWithTimeZone(input); - - expect(result.millisecond, 100); - }); - - test('handles single digit milliseconds (timezone offset)', () { - const input = '2023-12-25T15:30:45.1+02:00'; - final result = DateTimeParsingExtension.parseWithTimeZone(input); - - expect(result.millisecond, 100); - }); - - test('handles two digit milliseconds (UTC)', () { - const input = '2023-12-25T15:30:45.12Z'; - final result = DateTimeParsingExtension.parseWithTimeZone(input); - - expect(result.millisecond, 120); - }); - - test('handles two digit milliseconds (local)', () { - const input = '2023-12-25T15:30:45.12'; - final result = DateTimeParsingExtension.parseWithTimeZone(input); - - expect(result.millisecond, 120); - }); - - test('handles two digit milliseconds (timezone offset)', () { - const input = '2023-12-25T15:30:45.12-07:00'; - final result = DateTimeParsingExtension.parseWithTimeZone(input); - - expect(result.millisecond, 120); - }); - }); - - group('error handling', () { - test('throws InvalidFormatException for invalid format', () { - const input = 'invalid-date-format'; - expect( - () => DateTimeParsingExtension.parseWithTimeZone(input), - throwsA(isA()), - ); - }); - - test('throws InvalidFormatException for incomplete date', () { - const input = '2023-12'; - expect( - () => DateTimeParsingExtension.parseWithTimeZone(input), - throwsA(isA()), - ); - }); - }); - - group('RFC3339 compliance', () { - test('parses full RFC3339 format with T separator', () { - const input = '2023-12-25T15:30:45.123456+05:00'; - final result = DateTimeParsingExtension.parseWithTimeZone(input); - - expect(result.year, 2023); - expect(result.month, 12); - expect(result.day, 25); - expect(result.hour, 15); - expect(result.minute, 30); - expect(result.second, 45); - expect(result.millisecond, 123); - expect(result.microsecond, 456); - expect(result.timeZoneOffset.inHours, 5); - }); - - test('parses RFC3339 format with space separator', () { - const input = '2023-12-25 15:30:45.123+05:00'; - final result = DateTimeParsingExtension.parseWithTimeZone(input); - - expect(result.year, 2023); - expect(result.month, 12); - expect(result.day, 25); - expect(result.hour, 15); - expect(result.minute, 30); - expect(result.second, 45); - expect(result.millisecond, 123); - expect(result.microsecond, 0); - expect(result.timeZoneOffset.inHours, 5); - }); - - test('parses minimum required RFC3339 format', () { - const input = '2023-12-25T15:30:45Z'; - final result = DateTimeParsingExtension.parseWithTimeZone(input); - - expect(result.isUtc, isTrue); - expect(result.year, 2023); - expect(result.month, 12); - expect(result.day, 25); - expect(result.hour, 15); - expect(result.minute, 30); - expect(result.second, 45); - expect(result.millisecond, 0); - expect(result.microsecond, 0); - expect(result.timeZoneOffset, Duration.zero); - }); - }); - }); - }); -} 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 d8b2057..8bb1ae8 100644 --- a/packages/tonik_util/test/src/decoding/json_decoder_test.dart +++ b/packages/tonik_util/test/src/decoding/json_decoder_test.dart @@ -2,34 +2,62 @@ import 'dart:convert'; import 'package:big_decimal/big_decimal.dart'; import 'package:test/test.dart'; -import 'package:timezone/data/latest.dart' as tz; -import 'package:timezone/timezone.dart' as tz; import 'package:tonik_util/src/date.dart'; import 'package:tonik_util/src/decoding/decoding_exception.dart'; import 'package:tonik_util/src/decoding/json_decoder.dart'; void main() { - setUpAll(tz.initializeTimeZones); group('JsonDecoder', () { group('DateTime', () { test('decodes DateTime values with timezone awareness', () { // Test UTC parsing const utcString = '2024-03-14T10:30:45Z'; final utcResult = utcString.decodeJsonDateTime(); - expect(utcResult.isUtc, isTrue); - expect(utcResult, DateTime.utc(2024, 3, 14, 10, 30, 45)); - - // Test local time parsing + expect(utcResult.year, 2024); + expect(utcResult.month, 3); + expect(utcResult.day, 14); + expect(utcResult.hour, 10); + expect(utcResult.minute, 30); + expect(utcResult.second, 45); + expect(utcResult.timeZoneOffset, Duration.zero); + + // Test local time parsing (no timezone offset) const localString = '2024-03-14T10:30:45'; final localResult = localString.decodeJsonDateTime(); - expect(localResult.isUtc, isFalse); - expect(localResult, DateTime(2024, 3, 14, 10, 30, 45)); + expect(localResult.year, 2024); + expect(localResult.month, 3); + expect(localResult.day, 14); + expect(localResult.hour, 10); + expect(localResult.minute, 30); + expect(localResult.second, 45); + // Local datetime uses system timezone + // should match same date in local timezone + final expectedLocalTime = DateTime(2024, 3, 14, 10, 30, 45); + expect(localResult.timeZoneOffset, expectedLocalTime.timeZoneOffset); // Test timezone offset parsing const offsetString = '2024-03-14T10:30:45+05:00'; final offsetResult = offsetString.decodeJsonDateTime(); - expect(offsetResult, isA()); + expect(offsetResult.year, 2024); + expect(offsetResult.month, 3); + expect(offsetResult.day, 14); + expect(offsetResult.hour, 10); + expect(offsetResult.minute, 30); + expect(offsetResult.second, 45); expect(offsetResult.timeZoneOffset.inHours, 5); + expect(offsetResult.timeZoneOffset.inMinutes, 5 * 60); + + // Test negative timezone offset + const negativeOffsetString = '2024-03-14T10:30:45-08:00'; + final negativeOffsetResult = negativeOffsetString.decodeJsonDateTime(); + expect(negativeOffsetResult.year, 2024); + expect(negativeOffsetResult.month, 3); + expect(negativeOffsetResult.day, 14); + expect(negativeOffsetResult.hour, 10); + expect(negativeOffsetResult.minute, 30); + expect(negativeOffsetResult.second, 45); + expect(negativeOffsetResult.timeZoneOffset.inHours, -8); + expect(negativeOffsetResult.timeZoneOffset.inMinutes, -8 * 60); // Test error cases expect( @@ -43,10 +71,16 @@ void main() { }); test('decodes nullable DateTime values with timezone awareness', () { - expect( - '2024-03-14T10:30:45Z'.decodeJsonNullableDateTime(), - DateTime.utc(2024, 3, 14, 10, 30, 45), - ); + final result = '2024-03-14T10:30:45Z'.decodeJsonNullableDateTime(); + expect(result, isNotNull); + expect(result!.year, 2024); + expect(result.month, 3); + expect(result.day, 14); + expect(result.hour, 10); + expect(result.minute, 30); + expect(result.second, 45); + expect(result.timeZoneOffset, Duration.zero); + expect(null.decodeJsonNullableDateTime(), isNull); expect(''.decodeJsonNullableDateTime(), isNull); expect( 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 6524bd4..3e1d6c4 100644 --- a/packages/tonik_util/test/src/decoding/simple_decoder_test.dart +++ b/packages/tonik_util/test/src/decoding/simple_decoder_test.dart @@ -1,13 +1,10 @@ import 'package:big_decimal/big_decimal.dart'; import 'package:test/test.dart'; -import 'package:timezone/data/latest.dart' as tz; -import 'package:timezone/timezone.dart' as tz; import 'package:tonik_util/src/date.dart'; import 'package:tonik_util/src/decoding/decoding_exception.dart'; import 'package:tonik_util/src/decoding/simple_decoder.dart'; void main() { - setUpAll(tz.initializeTimeZones); group('SimpleDecoder', () { group('Simple Values', () { test('decodes integer values', () { @@ -51,24 +48,54 @@ void main() { test('decodes DateTime values with timezone awareness', () { // Test UTC parsing - final utcDate = DateTime.utc(2024, 3, 14, 10, 30, 45); const utcString = '2024-03-14T10:30:45Z'; final utcResult = utcString.decodeSimpleDateTime(); - expect(utcResult.isUtc, isTrue); - expect(utcResult, utcDate); + expect(utcResult.year, 2024); + expect(utcResult.month, 3); + expect(utcResult.day, 14); + expect(utcResult.hour, 10); + expect(utcResult.minute, 30); + expect(utcResult.second, 45); + expect(utcResult.timeZoneOffset, Duration.zero); - // Test local time parsing + // Test local time parsing (no timezone offset) const localString = '2024-03-14T10:30:45'; final localResult = localString.decodeSimpleDateTime(); - final expectedLocal = DateTime(2024, 3, 14, 10, 30, 45); - expect(localResult.isUtc, isFalse); - expect(localResult, expectedLocal); + expect(localResult.year, 2024); + expect(localResult.month, 3); + expect(localResult.day, 14); + expect(localResult.hour, 10); + expect(localResult.minute, 30); + expect(localResult.second, 45); + // Local datetime uses system timezone + // should match same date in local timezone + final expectedLocalTime = DateTime(2024, 3, 14, 10, 30, 45); + expect(localResult.timeZoneOffset, expectedLocalTime.timeZoneOffset); // Test timezone offset parsing const offsetString = '2024-03-14T10:30:45+05:00'; final offsetResult = offsetString.decodeSimpleDateTime(); - expect(offsetResult, isA()); + expect(offsetResult.year, 2024); + expect(offsetResult.month, 3); + expect(offsetResult.day, 14); + expect(offsetResult.hour, 10); + expect(offsetResult.minute, 30); + expect(offsetResult.second, 45); expect(offsetResult.timeZoneOffset.inHours, 5); + expect(offsetResult.timeZoneOffset.inMinutes, 5 * 60); + + // Test negative timezone offset + const negativeOffsetString = '2024-03-14T10:30:45-08:00'; + final negativeOffsetResult = + negativeOffsetString.decodeSimpleDateTime(); + expect(negativeOffsetResult.year, 2024); + expect(negativeOffsetResult.month, 3); + expect(negativeOffsetResult.day, 14); + expect(negativeOffsetResult.hour, 10); + expect(negativeOffsetResult.minute, 30); + expect(negativeOffsetResult.second, 45); + expect(negativeOffsetResult.timeZoneOffset.inHours, -8); + expect(negativeOffsetResult.timeZoneOffset.inMinutes, -8 * 60); // Test error cases expect( diff --git a/packages/tonik_util/test/src/offset_date_time_test.dart b/packages/tonik_util/test/src/offset_date_time_test.dart new file mode 100644 index 0000000..961369a --- /dev/null +++ b/packages/tonik_util/test/src/offset_date_time_test.dart @@ -0,0 +1,893 @@ +import 'package:test/test.dart'; +import 'package:tonik_util/tonik_util.dart'; + +void main() { + group('OffsetDateTime', () { + group('constructor', () { + test('should create OffsetDateTime with explicit timezone name', () { + // Arrange + final dateTime = DateTime(2023, 1, 15, 12, 30, 45); + const offset = Duration(hours: 5, minutes: 30); + const timeZoneName = 'Asia/Kolkata'; + + // Act + final offsetDateTime = OffsetDateTime.from( + dateTime, + offset: offset, + timeZoneName: timeZoneName, + ); + + // Assert + expect(offsetDateTime.offset, offset); + expect(offsetDateTime.timeZoneName, timeZoneName); + expect(offsetDateTime.year, 2023); + expect(offsetDateTime.month, 1); + expect(offsetDateTime.day, 15); + expect(offsetDateTime.hour, 12); + expect(offsetDateTime.minute, 30); + expect(offsetDateTime.second, 45); + }); + + test('should auto-generate timezone name when not provided', () { + // Arrange + final dateTime = DateTime(2023, 1, 15, 12); + const offset = Duration(hours: 5, minutes: 30); + + // Act + final offsetDateTime = OffsetDateTime.from( + dateTime, + offset: offset, + ); + + // Assert + expect(offsetDateTime.timeZoneName, 'UTC+05:30'); + }); + + test('should use UTC for zero offset', () { + // Arrange + final dateTime = DateTime.utc(2023, 1, 15, 12); + + // Act + final offsetDateTime = OffsetDateTime.from( + dateTime, + offset: Duration.zero, + ); + + // Assert + expect(offsetDateTime.timeZoneName, 'UTC'); + expect(offsetDateTime.isUtc, isTrue); + }); + }); + + group('timezone name generation', () { + test('should generate UTC for zero offset', () { + // Arrange & Act + final offsetDateTime = OffsetDateTime.from( + DateTime.utc(2023, 1, 15, 12), + offset: Duration.zero, + ); + + // Assert + expect(offsetDateTime.timeZoneName, 'UTC'); + }); + + test('should generate positive offset names', () { + // Arrange & Act + final offsetDateTime = OffsetDateTime.from( + DateTime(2023, 1, 15, 17), + offset: const Duration(hours: 5, minutes: 30), + ); + + // Assert + expect(offsetDateTime.timeZoneName, 'UTC+05:30'); + }); + + test('should generate negative offset names', () { + // Arrange & Act + final offsetDateTime = OffsetDateTime.from( + DateTime(2023, 1, 15, 4), + offset: const Duration(hours: -8), + ); + + // Assert + expect(offsetDateTime.timeZoneName, 'UTC-08:00'); + }); + + test('should generate names for unusual offsets', () { + // Arrange & Act + final offsetDateTime = OffsetDateTime.from( + DateTime(2023, 1, 15, 4), + offset: const Duration(hours: 9, minutes: 45), + ); + + // Assert + expect(offsetDateTime.timeZoneName, 'UTC+09:45'); + }); + + test('should generate names for negative offsets with minutes', () { + // Arrange & Act + final offsetDateTime = OffsetDateTime.from( + DateTime(2023, 1, 15, 4), + offset: const Duration(hours: -3, minutes: -30), + ); + + // Assert + expect(offsetDateTime.timeZoneName, 'UTC-03:30'); + }); + + test( + 'should override auto-generated name when explicit name provided', + () { + // Arrange & Act + final offsetDateTime = OffsetDateTime.from( + DateTime(2023, 1, 15, 17), + offset: const Duration(hours: 5, minutes: 30), + timeZoneName: 'Asia/Kolkata', + ); + + // Assert + expect(offsetDateTime.timeZoneName, 'Asia/Kolkata'); + }, + ); + }); + + group('toLocal()', () { + test('should convert to local system time', () { + // Arrange + final utcTime = DateTime.utc(2023, 1, 15, 12); + final offsetDateTime = OffsetDateTime.from( + utcTime, + offset: Duration.zero, + timeZoneName: 'UTC', + ); + + // Act + final localDateTime = offsetDateTime.toLocal(); + + // Assert + expect(localDateTime.isUtc, isFalse); + expect( + localDateTime.microsecondsSinceEpoch, + offsetDateTime.microsecondsSinceEpoch, + ); + }); + + test('should preserve exact moment in time when converting to local', () { + // Arrange + final offsetDateTime = OffsetDateTime.from( + DateTime(2023, 1, 15, 17, 30, 45), + offset: const Duration(hours: 2), + ); + + // Act + final localDateTime = offsetDateTime.toLocal(); + + // Assert + expect(localDateTime.isUtc, isFalse); + expect( + localDateTime.microsecondsSinceEpoch, + offsetDateTime.microsecondsSinceEpoch, + ); + }); + }); + + group('toUtc()', () { + test('should convert to UTC when offset is not zero', () { + // Arrange + final offsetDateTime = OffsetDateTime.from( + DateTime(2023, 1, 15, 17, 30), + offset: const Duration(hours: 5, minutes: 30), + ); + + // Act + final utcDateTime = offsetDateTime.toUtc(); + + // Assert + expect(utcDateTime.isUtc, isTrue); + expect(utcDateTime.timeZoneName, 'UTC'); + expect(utcDateTime.offset, Duration.zero); + expect( + utcDateTime.microsecondsSinceEpoch, + offsetDateTime.microsecondsSinceEpoch, + ); + }); + + test('should return same instance for UTC offset', () { + // Arrange + final offsetDateTime = OffsetDateTime.from( + DateTime.utc(2023, 1, 15, 12), + offset: Duration.zero, + ); + + // Act + final utcDateTime = offsetDateTime.toUtc(); + + // Assert + expect(identical(utcDateTime, offsetDateTime), isTrue); + }); + }); + + group('date and time components', () { + test('should return correct local date components', () { + // Arrange: UTC midnight + 5:30 offset = 5:30 AM local + final offsetDateTime = OffsetDateTime.from( + DateTime.utc(2023, 6, 15), + offset: const Duration(hours: 5, minutes: 30), + ); + + // Act & Assert + expect(offsetDateTime.year, 2023); + expect(offsetDateTime.month, 6); + expect(offsetDateTime.day, 15); + expect(offsetDateTime.hour, 5); + expect(offsetDateTime.minute, 30); + expect(offsetDateTime.second, 0); + expect(offsetDateTime.millisecond, 0); + expect(offsetDateTime.microsecond, 0); + }); + + test('should handle day boundary crossing', () { + // Arrange: UTC 23:00 + 2 hours = 01:00 next day local + final offsetDateTime = OffsetDateTime.from( + DateTime.utc(2023, 6, 15, 23), + offset: const Duration(hours: 2), + ); + + // Act & Assert + expect(offsetDateTime.year, 2023); + expect(offsetDateTime.month, 6); + expect(offsetDateTime.day, 16); // Next day + expect(offsetDateTime.hour, 1); + }); + + test('should return correct weekday', () { + // Arrange: June 15, 2023 is a Thursday (weekday 4) + final offsetDateTime = OffsetDateTime.from( + DateTime(2023, 6, 15, 12), + offset: const Duration(hours: 2), + ); + + // Act & Assert + expect(offsetDateTime.weekday, 4); // Thursday + }); + }); + + group('epoch time methods', () { + test('should return correct milliseconds since epoch', () { + // Arrange + final baseDateTime = DateTime.utc(2023, 1, 1, 12); + final offsetDateTime = OffsetDateTime.from( + baseDateTime, + offset: const Duration(hours: 5), + ); + + // Act & Assert + // The epoch time should match the input UTC time + expect( + offsetDateTime.millisecondsSinceEpoch, + baseDateTime.millisecondsSinceEpoch, + ); + }); + + test('should return correct microseconds since epoch', () { + // Arrange + final baseDateTime = DateTime.utc(2023, 1, 1, 12); + final offsetDateTime = OffsetDateTime.from( + baseDateTime, + offset: const Duration(hours: 5), + ); + + // Act & Assert + // The epoch time should match the input UTC time + expect( + offsetDateTime.microsecondsSinceEpoch, + baseDateTime.microsecondsSinceEpoch, + ); + }); + }); + + group('arithmetic operations', () { + test('should add duration correctly', () { + // Arrange: Create from UTC to avoid system timezone issues + final offsetDateTime = OffsetDateTime.from( + DateTime.utc( + 2023, + 1, + 15, + 7, + ), // UTC 07:00 + 5 hour offset = 12:00 local + offset: const Duration(hours: 5), + ); + + // Act + final result = offsetDateTime.add(const Duration(hours: 2)); + + // Assert: Local time should be 12:00 + 2 = 14:00 + expect(result.hour, 14); + expect(result.offset, offsetDateTime.offset); + expect(result.timeZoneName, offsetDateTime.timeZoneName); + }); + + test('should subtract duration correctly', () { + // Arrange: Create from UTC to avoid system timezone issues + final offsetDateTime = OffsetDateTime.from( + DateTime.utc( + 2023, + 1, + 15, + 7, + ), // UTC 07:00 + 5 hour offset = 12:00 local + offset: const Duration(hours: 5), + ); + + // Act + final result = offsetDateTime.subtract(const Duration(hours: 2)); + + // Assert: Local time should be 12:00 - 2 = 10:00 + expect(result.hour, 10); + expect(result.offset, offsetDateTime.offset); + expect(result.timeZoneName, offsetDateTime.timeZoneName); + }); + + test('should calculate difference between OffsetDateTime instances', () { + // Arrange: Both should represent the same UTC moment + final offsetDateTime1 = OffsetDateTime.from( + DateTime.utc(2023, 1, 15, 12), // UTC 12:00 + offset: Duration.zero, + ); + final offsetDateTime2 = OffsetDateTime.from( + DateTime.utc(2023, 1, 15, 12), // Same UTC 12:00 + offset: const Duration(hours: 5), + ); + + // Act + final difference = offsetDateTime2.difference(offsetDateTime1); + + // Assert + expect(difference, Duration.zero); // Same UTC time + }); + + test('should calculate difference with regular DateTime', () { + // Arrange: Both should represent the same UTC moment + final offsetDateTime = OffsetDateTime.from( + DateTime.utc(2023, 1, 15, 12), // UTC 12:00 + offset: const Duration(hours: 5), + ); + final regularDateTime = DateTime.utc(2023, 1, 15, 12); + + // Act + final difference = offsetDateTime.difference(regularDateTime); + + // Assert + expect(difference, Duration.zero); // Same UTC time + }); + }); + + group('comparison methods', () { + late OffsetDateTime offsetDateTime1; + late OffsetDateTime offsetDateTime2; + late OffsetDateTime offsetDateTime3; + + setUp(() { + // All represent the same UTC moment: 2023-01-15 12:00 UTC + offsetDateTime1 = OffsetDateTime.from( + DateTime.utc(2023, 1, 15, 12), + offset: Duration.zero, + ); + // Create from a local time: if offset is +5, + // then local 17:00 should equal UTC 12:00 + // But to avoid system timezone issues, we'll create directly from UTC + offsetDateTime2 = OffsetDateTime.from( + DateTime.utc(2023, 1, 15, 12), // Same UTC base + offset: const Duration(hours: 5), + ); + // Different UTC moment: one hour later + offsetDateTime3 = OffsetDateTime.from( + DateTime.utc(2023, 1, 15, 13), + offset: Duration.zero, + ); + }); + + test('should correctly identify same moments', () { + expect(offsetDateTime1.isAtSameMomentAs(offsetDateTime2), isTrue); + expect(offsetDateTime1.isAtSameMomentAs(offsetDateTime3), isFalse); + }); + + test('should correctly compare before', () { + expect(offsetDateTime1.isBefore(offsetDateTime3), isTrue); + expect(offsetDateTime3.isBefore(offsetDateTime1), isFalse); + expect(offsetDateTime1.isBefore(offsetDateTime2), isFalse); + }); + + test('should correctly compare after', () { + expect(offsetDateTime3.isAfter(offsetDateTime1), isTrue); + expect(offsetDateTime1.isAfter(offsetDateTime3), isFalse); + expect(offsetDateTime1.isAfter(offsetDateTime2), isFalse); + }); + + test('should correctly compare with compareTo', () { + expect(offsetDateTime1.compareTo(offsetDateTime2), 0); + expect(offsetDateTime1.compareTo(offsetDateTime3), lessThan(0)); + expect(offsetDateTime3.compareTo(offsetDateTime1), greaterThan(0)); + }); + }); + + group('equality and hashCode', () { + test('should be equal when representing same UTC moment', () { + // Arrange: Both should represent the same UTC moment + final offsetDateTime1 = OffsetDateTime.from( + DateTime.utc(2023, 1, 15, 12), + offset: Duration.zero, + ); + final offsetDateTime2 = OffsetDateTime.from( + DateTime.utc(2023, 1, 15, 12), // Same UTC moment + offset: const Duration(hours: 5), + ); + + // Act & Assert + expect(offsetDateTime1 == offsetDateTime2, isTrue); + expect(offsetDateTime1.hashCode, offsetDateTime2.hashCode); + }); + + test('should not be equal when representing different UTC moments', () { + // Arrange + final offsetDateTime1 = OffsetDateTime.from( + DateTime.utc(2023, 1, 15, 12), + offset: Duration.zero, + ); + final offsetDateTime2 = OffsetDateTime.from( + DateTime.utc(2023, 1, 15, 13), + offset: Duration.zero, + ); + + // Act & Assert + expect(offsetDateTime1 == offsetDateTime2, isFalse); + }); + + test('should be identical to itself', () { + // Arrange + final offsetDateTime = OffsetDateTime.from( + DateTime.utc(2023, 1, 15, 12), + offset: Duration.zero, + ); + + // Act & Assert + expect(offsetDateTime == offsetDateTime, isTrue); + expect(identical(offsetDateTime, offsetDateTime), isTrue); + }); + }); + + group('timeZoneOffset property', () { + test('should return the offset', () { + // Arrange + const offset = Duration(hours: 5, minutes: 30); + final offsetDateTime = OffsetDateTime.from( + DateTime(2023, 1, 15, 12), + offset: offset, + ); + + // Act & Assert + expect(offsetDateTime.timeZoneOffset, offset); + }); + }); + + group('string representation', () { + test('should format UTC time with Z suffix', () { + // Arrange + final offsetDateTime = OffsetDateTime.from( + DateTime.utc(2023, 1, 15, 12, 30, 45, 123), + offset: Duration.zero, + ); + + // Act + final isoString = offsetDateTime.toIso8601String(); + final toString = offsetDateTime.toString(); + + // Assert + expect(isoString, '2023-01-15T12:30:45.123Z'); + expect(toString, '2023-01-15 12:30:45.123Z'); + }); + + test('should format offset time with offset suffix', () { + // Arrange + final offsetDateTime = OffsetDateTime.from( + DateTime(2023, 1, 15, 12, 30, 45, 123), + offset: const Duration(hours: 5, minutes: 30), + ); + + // Act + final isoString = offsetDateTime.toIso8601String(); + final toString = offsetDateTime.toString(); + + // Assert + expect(isoString, '2023-01-15T12:30:45.123+0530'); + expect(toString, '2023-01-15 12:30:45.123+0530'); + }); + + test('should format negative offset correctly', () { + // Arrange + final offsetDateTime = OffsetDateTime.from( + DateTime(2023, 1, 15, 12, 30, 45, 123), + offset: const Duration(hours: -8), + ); + + // Act + final isoString = offsetDateTime.toIso8601String(); + + // Assert + expect(isoString, '2023-01-15T12:30:45.123-0800'); + }); + + test('should handle microseconds in string representation', () { + // Arrange + final offsetDateTime = OffsetDateTime.from( + DateTime(2023, 1, 15, 12, 30, 45, 123, 456), + offset: const Duration(hours: 2), + ); + + // Act + final isoString = offsetDateTime.toIso8601String(); + + // Assert + expect(isoString, '2023-01-15T12:30:45.123456+0200'); + }); + }); + + group('edge cases', () { + test('should handle maximum positive offset', () { + // Arrange + final offsetDateTime = OffsetDateTime.from( + DateTime(2023, 1, 15, 12), + offset: const Duration(hours: 14), + ); + + // Act & Assert + expect(offsetDateTime.timeZoneName, 'UTC+14:00'); + }); + + test('should handle maximum negative offset', () { + // Arrange + final offsetDateTime = OffsetDateTime.from( + DateTime(2023, 1, 15, 12), + offset: const Duration(hours: -12), + ); + + // Act & Assert + expect(offsetDateTime.timeZoneName, 'UTC-12:00'); + }); + + test('should handle leap year dates', () { + // Arrange + final offsetDateTime = OffsetDateTime.from( + DateTime(2024, 2, 29, 12), // Leap year + offset: const Duration(hours: 3), + ); + + // Act & Assert + expect(offsetDateTime.year, 2024); + expect(offsetDateTime.month, 2); + expect(offsetDateTime.day, 29); + }); + + test('should handle year boundary crossing', () { + // Arrange: New Year's Eve UTC + positive offset = New Year's Day local + final offsetDateTime = OffsetDateTime.from( + DateTime.utc(2023, 12, 31, 23), + offset: const Duration(hours: 2), + ); + + // Act & Assert + expect(offsetDateTime.year, 2024); + expect(offsetDateTime.month, 1); + expect(offsetDateTime.day, 1); + expect(offsetDateTime.hour, 1); + }); + + test('should handle minute-level offsets', () { + // Arrange + final offsetDateTime = OffsetDateTime.from( + DateTime(2023, 1, 15, 12), + offset: const Duration(minutes: 30), + ); + + // Act & Assert + expect(offsetDateTime.timeZoneName, 'UTC+00:30'); + }); + }); + }); + + group('OffsetDateTime.parse', () { + group('UTC parsing', () { + test('should parse UTC datetime with Z suffix', () { + // Arrange & Act + const input = '2023-12-25T15:30:45Z'; + final result = OffsetDateTime.parse(input); + + // Assert + expect(result, isA()); + expect(result.isUtc, isTrue); + expect(result.year, 2023); + expect(result.month, 12); + expect(result.day, 25); + expect(result.hour, 15); + expect(result.minute, 30); + expect(result.second, 45); + expect(result.millisecond, 0); + expect(result.microsecond, 0); + expect(result.timeZoneOffset, Duration.zero); + expect(result.timeZoneName, 'UTC'); + }); + + test('should parse UTC datetime with milliseconds', () { + // Arrange & Act + const input = '2023-12-25T15:30:45.123Z'; + final result = OffsetDateTime.parse(input); + + // Assert + expect(result, isA()); + expect(result.isUtc, isTrue); + expect(result.year, 2023); + expect(result.month, 12); + expect(result.day, 25); + expect(result.hour, 15); + expect(result.minute, 30); + expect(result.second, 45); + expect(result.millisecond, 123); + expect(result.microsecond, 0); + expect(result.timeZoneName, 'UTC'); + }); + + test('should parse UTC datetime with microseconds', () { + // Arrange & Act + const input = '2023-12-25T15:30:45.123456Z'; + final result = OffsetDateTime.parse(input); + + // Assert + expect(result, isA()); + expect(result.isUtc, isTrue); + expect(result.year, 2023); + expect(result.month, 12); + expect(result.day, 25); + expect(result.hour, 15); + expect(result.minute, 30); + expect(result.second, 45); + expect(result.millisecond, 123); + expect(result.microsecond, 456); + expect(result.timeZoneName, 'UTC'); + }); + }); + + group('local datetime parsing', () { + test('should parse local datetime without timezone', () { + const input = '2023-12-25T15:30:45'; + final result = OffsetDateTime.parse(input); + + expect(result, isA()); + expect(result.year, 2023); + expect(result.month, 12); + expect(result.day, 25); + expect(result.hour, 15); + expect(result.minute, 30); + expect(result.second, 45); + + // Should match system timezone, not be treated as UTC + final expectedLocalTime = DateTime.parse(input); + expect(result.isUtc, expectedLocalTime.isUtc); + expect(result.timeZoneOffset, expectedLocalTime.timeZoneOffset); + }); + + test('should parse local datetime with space separator', () { + const input = '2023-12-25 15:30:45'; + final result = OffsetDateTime.parse(input); + + expect(result, isA()); + expect(result.year, 2023); + expect(result.month, 12); + expect(result.day, 25); + expect(result.hour, 15); + expect(result.minute, 30); + expect(result.second, 45); + + // Should match system timezone for space-separated format + final expectedLocalTime = DateTime.parse(input); + expect(result.timeZoneOffset, expectedLocalTime.timeZoneOffset); + }); + + test('should parse local datetime with milliseconds', () { + const input = '2023-12-25T15:30:45.789'; + final result = OffsetDateTime.parse(input); + + expect(result, isA()); + expect(result.year, 2023); + expect(result.month, 12); + expect(result.day, 25); + expect(result.hour, 15); + expect(result.minute, 30); + expect(result.second, 45); + expect(result.millisecond, 789); + + // Should match system timezone for milliseconds format + final expectedLocalTime = DateTime.parse(input); + expect(result.timeZoneOffset, expectedLocalTime.timeZoneOffset); + }); + }); + + group('timezone offset parsing', () { + test('should parse positive timezone offset with colon', () { + // Arrange & Act + const input = '2023-12-25T15:30:45+05:30'; + final result = OffsetDateTime.parse(input); + + // Assert + expect(result, isA()); + expect(result.timeZoneName, 'UTC+05:30'); + expect(result.timeZoneOffset.inMinutes, 330); // 5.5 hours + expect(result.year, 2023); + expect(result.month, 12); + expect(result.day, 25); + expect(result.hour, 15); + expect(result.minute, 30); + expect(result.second, 45); + }); + + test('should parse negative timezone offset with colon', () { + // Arrange & Act + const input = '2023-12-25T15:30:45-03:15'; + final result = OffsetDateTime.parse(input); + + // Assert + expect(result, isA()); + expect(result.timeZoneName, 'UTC-03:15'); + expect(result.timeZoneOffset.inMinutes, -195); // -3.25 hours + }); + + test('should parse timezone offset without colon', () { + // Arrange & Act + const input = '2023-12-25T15:30:45+0800'; + final result = OffsetDateTime.parse(input); + + // Assert + expect(result, isA()); + expect(result.timeZoneName, 'UTC+08:00'); + expect(result.timeZoneOffset.inHours, 8); + }); + + test('should parse zero timezone offset', () { + // Arrange & Act + const input = '2023-12-25T15:30:45+00:00'; + final result = OffsetDateTime.parse(input); + + // Assert + expect(result, isA()); + expect(result.timeZoneName, 'UTC'); + expect(result.timeZoneOffset.inMinutes, 0); + }); + + test('should parse datetime with offset and milliseconds', () { + // Arrange & Act + const input = '2023-12-25T15:30:45.123+02:00'; + final result = OffsetDateTime.parse(input); + + // Assert + expect(result, isA()); + expect(result.timeZoneName, 'UTC+02:00'); + expect(result.timeZoneOffset.inHours, 2); + expect(result.millisecond, 123); + }); + + test('should parse datetime with offset and microseconds', () { + // Arrange & Act + const input = '2023-12-25T15:30:45.123456-07:00'; + final result = OffsetDateTime.parse(input); + + // Assert + expect(result, isA()); + expect(result.timeZoneName, 'UTC-07:00'); + expect(result.timeZoneOffset.inHours, -7); + expect(result.millisecond, 123); + expect(result.microsecond, 456); + }); + }); + + group('error handling', () { + test('should throw InvalidFormatException for empty string', () { + // Act & Assert + expect( + () => OffsetDateTime.parse(''), + throwsA(isA()), + ); + }); + + test('should throw InvalidFormatException for invalid format', () { + // Act & Assert + expect( + () => OffsetDateTime.parse('not-a-date'), + throwsA(isA()), + ); + }); + + test( + 'should throw InvalidFormatException for invalid timezone offset', + () { + // Act & Assert + expect( + () => OffsetDateTime.parse('2023-12-25T15:30:45+25:00'), + // Invalid hour + throwsA(isA()), + ); + }, + ); + + test( + 'should throw InvalidFormatException for invalid timezone format', + () { + // Act & Assert + expect( + () => OffsetDateTime.parse('2023-12-25T15:30:45+5:30'), + // Missing leading zero + throwsA(isA()), + ); + }, + ); + + test('should throw InvalidFormatException for invalid minutes', () { + // Act & Assert + expect( + () => OffsetDateTime.parse('2023-12-25T15:30:45+05:60'), + // Invalid minutes + throwsA(isA()), + ); + }); + }); + }); + + group('OffsetDateTime.parse local timezone behavior', () { + test('should preserve local timezone for strings ' + 'without timezone info', () { + const localString = '2024-03-14T10:30:45'; + final result = OffsetDateTime.parse(localString); + + final expectedDateTime = DateTime.parse(localString); + + expect(result.year, 2024); + expect(result.month, 3); + expect(result.day, 14); + expect(result.hour, 10); + expect(result.minute, 30); + expect(result.second, 45); + + expect(result.timeZoneOffset, expectedDateTime.timeZoneOffset); + expect(result.timeZoneName, isNot('UTC')); + }); + + test('should handle local timezone vs UTC timezone correctly', () { + const timeString = '2024-03-14T10:30:45'; + + final localResult = OffsetDateTime.parse(timeString); + final utcResult = OffsetDateTime.parse('${timeString}Z'); + + expect(utcResult.timeZoneOffset, Duration.zero); + expect(utcResult.timeZoneName, 'UTC'); + + final expectedLocalTime = DateTime.parse(timeString); + expect(localResult.timeZoneOffset, expectedLocalTime.timeZoneOffset); + + expect(localResult.hour, 10); + expect(localResult.minute, 30); + expect(utcResult.hour, 10); + expect(utcResult.minute, 30); + }); + + test('should preserve local time values correctly', () { + const localString = '2024-03-14T15:30:45'; + final result = OffsetDateTime.parse(localString); + + expect(result.hour, 15); + expect(result.minute, 30); + expect(result.second, 45); + + final expectedLocalTime = DateTime.parse(localString); + expect(result.timeZoneOffset, expectedLocalTime.timeZoneOffset); + }); + }); +} From b6e0829cfc3c220bcea6604e0a23e6ed29cb6e19 Mon Sep 17 00:00:00 2001 From: Tobias Ottenweller Date: Sun, 27 Jul 2025 16:13:17 +0200 Subject: [PATCH 35/37] chore: improved integration tests --- .github/workflows/test.yml | 16 +++++++++++-- integration_test/gov/gov_test/pubspec.yaml | 3 +++ .../music_streaming_test/pubspec.lock | 23 +++--------------- .../music_streaming_test/pubspec.yaml | 4 ++++ .../petstore/petstore_test/pubspec.yaml | 3 +++ integration_test/setup.sh | 24 ++++++++++++++++++- 6 files changed, 50 insertions(+), 23 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c99217e..58246e0 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -35,8 +35,20 @@ jobs: - name: Run tests run: melos run test - - name: Run integration tests + - name: Run petstore integration tests run: | cd integration_test/petstore/petstore_test dart pub get - dart test --concurrency=1 \ No newline at end of file + dart test --concurrency=1 + + - name: Run music streaming integration tests + run: | + cd integration_test/music_streaming/music_streaming_test + dart pub get + dart test --concurrency=1 + + - name: Run gov integration tests + run: | + cd integration_test/gov/gov_test + dart pub get + dart test --concurrency=1 diff --git a/integration_test/gov/gov_test/pubspec.yaml b/integration_test/gov/gov_test/pubspec.yaml index 0a631ab..b8f463d 100644 --- a/integration_test/gov/gov_test/pubspec.yaml +++ b/integration_test/gov/gov_test/pubspec.yaml @@ -17,3 +17,6 @@ dev_dependencies: test: ^1.25.15 very_good_analysis: ^9.0.0 +dependency_overrides: + tonik_util: + path: ../../../packages/tonik_util \ No newline at end of file diff --git a/integration_test/music_streaming/music_streaming_test/pubspec.lock b/integration_test/music_streaming/music_streaming_test/pubspec.lock index 924596e..4514a8c 100644 --- a/integration_test/music_streaming/music_streaming_test/pubspec.lock +++ b/integration_test/music_streaming/music_streaming_test/pubspec.lock @@ -137,14 +137,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.3" - http: - dependency: transitive - description: - name: http - sha256: "2c11f3f94c687ee9bad77c171151672986360b2b001d109814ee7140b2cf261b" - url: "https://pub.dev" - source: hosted - version: "1.4.0" http_multi_server: dependency: transitive description: @@ -392,21 +384,12 @@ packages: url: "https://pub.dev" source: hosted version: "0.6.11" - timezone: - dependency: transitive - description: - name: timezone - sha256: dd14a3b83cfd7cb19e7888f1cbc20f258b8d71b54c06f79ac585f14093a287d1 - url: "https://pub.dev" - source: hosted - version: "0.10.1" tonik_util: dependency: "direct main" description: - name: tonik_util - sha256: "4b86da571a6a3ce18d89bc0e0a489aa1eebf8d43a8a883d85a69a870df3c69e8" - url: "https://pub.dev" - source: hosted + path: "../../../packages/tonik_util" + relative: true + source: path version: "0.0.7" typed_data: dependency: transitive diff --git a/integration_test/music_streaming/music_streaming_test/pubspec.yaml b/integration_test/music_streaming/music_streaming_test/pubspec.yaml index ff338c0..2376cb5 100644 --- a/integration_test/music_streaming/music_streaming_test/pubspec.yaml +++ b/integration_test/music_streaming/music_streaming_test/pubspec.yaml @@ -16,3 +16,7 @@ dependencies: dev_dependencies: test: ^1.25.15 very_good_analysis: ^9.0.0 + +dependency_overrides: + tonik_util: + path: ../../../packages/tonik_util \ No newline at end of file diff --git a/integration_test/petstore/petstore_test/pubspec.yaml b/integration_test/petstore/petstore_test/pubspec.yaml index 5cea358..10d0913 100644 --- a/integration_test/petstore/petstore_test/pubspec.yaml +++ b/integration_test/petstore/petstore_test/pubspec.yaml @@ -17,3 +17,6 @@ dev_dependencies: test: ^1.25.15 very_good_analysis: ^9.0.0 +dependency_overrides: + tonik_util: + path: ../../../packages/tonik_util \ No newline at end of file diff --git a/integration_test/setup.sh b/integration_test/setup.sh index 472b9c6..5864392 100755 --- a/integration_test/setup.sh +++ b/integration_test/setup.sh @@ -14,14 +14,36 @@ if [[ $(echo "$JAVA_VERSION" | cut -d. -f1) -lt 11 ]]; then exit 1 fi -# Generate API code +# Function to add dependency overrides to generated packages +add_dependency_overrides() { + local pubspec_file="$1" + + if [ -f "$pubspec_file" ]; then + echo "Adding dependency overrides to $pubspec_file" + + # Add dependency_overrides section if it doesn't exist + if ! grep -q "dependency_overrides:" "$pubspec_file"; then + echo "" >> "$pubspec_file" + echo "dependency_overrides:" >> "$pubspec_file" + echo " tonik_util:" >> "$pubspec_file" + echo " path: ../../../packages/tonik_util" >> "$pubspec_file" + fi + else + echo "Warning: $pubspec_file not found" + fi +} + +# Generate API code with automatic dependency overrides for local tonik_util dart run ../packages/tonik/bin/tonik.dart -p petstore_api -s petstore/openapi.yaml -o petstore --log-level verbose +add_dependency_overrides "petstore/petstore_api/pubspec.yaml" cd petstore/petstore_api && dart pub get && cd ../.. dart run ../packages/tonik/bin/tonik.dart -p music_streaming_api -s music_streaming/openapi.yaml -o music_streaming --log-level verbose +add_dependency_overrides "music_streaming/music_streaming_api/pubspec.yaml" cd music_streaming/music_streaming_api && dart pub get && cd ../.. dart run ../packages/tonik/bin/tonik.dart -p gov_api -s gov/openapi.yaml -o gov --log-level verbose +add_dependency_overrides "gov/gov_api/pubspec.yaml" cd gov/gov_api && dart pub get && cd ../.. # Download Imposter JAR only if it doesn't exist From 3994fcc1ba48674a5b9f1370f244664deec3b36d Mon Sep 17 00:00:00 2001 From: Tobias Ottenweller Date: Sun, 27 Jul 2025 16:21:41 +0200 Subject: [PATCH 36/37] chore: fix test --- .../test/src/offset_date_time_test.dart | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/packages/tonik_util/test/src/offset_date_time_test.dart b/packages/tonik_util/test/src/offset_date_time_test.dart index 961369a..3aef2a5 100644 --- a/packages/tonik_util/test/src/offset_date_time_test.dart +++ b/packages/tonik_util/test/src/offset_date_time_test.dart @@ -669,10 +669,9 @@ void main() { expect(result.hour, 15); expect(result.minute, 30); expect(result.second, 45); - - // Should match system timezone, not be treated as UTC + + // Should match system timezone offset final expectedLocalTime = DateTime.parse(input); - expect(result.isUtc, expectedLocalTime.isUtc); expect(result.timeZoneOffset, expectedLocalTime.timeZoneOffset); }); @@ -842,22 +841,20 @@ void main() { }); group('OffsetDateTime.parse local timezone behavior', () { - test('should preserve local timezone for strings ' - 'without timezone info', () { + test('should preserve local timezone for strings without timezone info', () { const localString = '2024-03-14T10:30:45'; final result = OffsetDateTime.parse(localString); - + final expectedDateTime = DateTime.parse(localString); - + expect(result.year, 2024); expect(result.month, 3); expect(result.day, 14); expect(result.hour, 10); expect(result.minute, 30); expect(result.second, 45); - + expect(result.timeZoneOffset, expectedDateTime.timeZoneOffset); - expect(result.timeZoneName, isNot('UTC')); }); test('should handle local timezone vs UTC timezone correctly', () { From faa40ccda496f3f653a099f98c395fec558fcbfe Mon Sep 17 00:00:00 2001 From: Tobias Ottenweller Date: Sun, 27 Jul 2025 16:29:51 +0200 Subject: [PATCH 37/37] chore: cleanup --- docs/data_types.md | 17 ++--------------- 1 file changed, 2 insertions(+), 15 deletions(-) diff --git a/docs/data_types.md b/docs/data_types.md index 54df516..6360699 100644 --- a/docs/data_types.md +++ b/docs/data_types.md @@ -22,25 +22,12 @@ This document provides information about how Tonik is mapping data types in Open ### Timezone-Aware DateTime Parsing -Tonik provides intelligent timezone-aware parsing for `date-time` format strings using the `OffsetDateTime` class. The parsing behavior depends on the timezone information present in the input: +Tonik provides timezone-aware parsing for `date-time` format strings using the `OffsetDateTime` class. The parsing behavior depends on the timezone information present in the input: -All generated code will always expose Dart `DateTime` objects through the standard decoder methods. However, internally Tonik uses `OffsetDateTime.parse()` to provide consistent timezone handling. The `OffsetDateTime` class extends Dart's `DateTime` interface while preserving timezone offset information: +All generated code will always expose Dart `DateTime` objects in the genreated code. However, internally Tonik uses `OffsetDateTime.parse()` to provide consistent timezone handling. The `OffsetDateTime` class extends Dart's `DateTime` interface while preserving timezone offset information: | Input Format | Return Type | Example | Description | |--------------|-------------|---------|-------------| | UTC (with Z) | `OffsetDateTime` (UTC) | `2023-12-25T15:30:45Z` | OffsetDateTime with zero offset (UTC) | | Local (no timezone) | `OffsetDateTime` (system timezone) | `2023-12-25T15:30:45` | OffsetDateTime with system timezone offset | | Timezone offset | `OffsetDateTime` (specified offset) | `2023-12-25T15:30:45+05:00` | OffsetDateTime with the specified offset | - -#### OffsetDateTime Benefits - -The `OffsetDateTime` class provides several advantages over standard `DateTime` objects: - -- **Preserves timezone offset information**: Unlike `DateTime`, `OffsetDateTime` retains the original timezone offset -- **Consistent API**: Implements the complete `DateTime` interface, so it can be used as a drop-in replacement -- **Fixed offset semantics**: Uses fixed timezone offsets rather than location-based timezones, avoiding DST ambiguity -- **Auto-generated timezone names**: Provides human-readable timezone names like `UTC+05:30`, `UTC-08:00`, or `UTC` - -#### Local Time Preservation - -For strings without timezone information (e.g., `2023-12-25T15:30:45`), `OffsetDateTime.parse()` preserves the local timezone behavior by using the system's timezone offset for that specific date and time. This ensures consistency with Dart's `DateTime.parse()` while providing the additional timezone offset information.