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/.gitignore b/.gitignore
index 466097b..2d4b255 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,10 +1,14 @@
.DS_Store
-/.dart_tool
+.dart_tool
# FVM Version Cache
.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/.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/CHANGELOG.md b/CHANGELOG.md
index d181de6..e072451 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,6 +3,67 @@
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
+
+---
+
+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/docs/data_types.md b/docs/data_types.md
new file mode 100644
index 0000000..6360699
--- /dev/null
+++ b/docs/data_types.md
@@ -0,0 +1,33 @@
+# Data Types
+
+This document provides information about how Tonik is mapping data types in OpenAPI into Dart.
+
+
+## Primitive Types
+
+| OAS Type | OAS Format | Dart Type | Dart Package | Comment |
+|----------|------------|-----------|--------------|---------|
+| `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 |
+| `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 |
+| `number` | (default) | `num` | `dart:core` | Generic number type |
+| `integer` | `enum` | `enum` | Generated | Custom enum type |
+| `integer` | (default) | `int` | `dart:core` | 64-bit integer |
+| `boolean` | (any) | `bool` | `dart:core` | Boolean type |
+| `array` | (any) | `List Use VA Form 10-10EZ if you’re a Veteran and want to apply for VA health care. You must be enrolled in... Use VA Form 10-10EZ if you’re a Veteran and want to apply for VA health care. You must be enrolled in...>());
+
+ final formIndex = body.data.first;
+ expect(formIndex.attributes?.benefitCategories, isA
>());
+ expect(formIndex.id, isA
?>());
+ expect(attributes?.sha256, isA
>());
+ expect(albumBase.externalUrls, isA
>());
+ expect(albumBase.name, isA
?>());
+ final artist = albumObject.artists?.first;
+ expect(artist?.externalUrls, isA
>(),
+ );
+ final trackItem = track?.pagingSimplifiedTrackObjectModel.items?.first;
+ expect(trackItem?.artists, isA
?>());
+ expect(trackItem?.availableMarkets, isA
?>());
+ expect(trackItem?.discNumber, isA
?>());
+ final copyright = albumObject.copyrights?.first;
+ expect(copyright?.text, isA
?>());
+ expect(albumObject.label, isA
-
+
+
+
[
Code("final map = json.decodeMap(context: '$className');"),
];
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/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/lib/src/naming/name_manager.dart b/packages/tonik_generate/lib/src/naming/name_manager.dart
index aadbe2d..a3c7671 100644
--- a/packages/tonik_generate/lib/src/naming/name_manager.dart
+++ b/packages/tonik_generate/lib/src/naming/name_manager.dart
@@ -58,7 +58,16 @@ class NameManager {
_logServerName(entry.value, entry.key);
}
- for (final model in models) {
+ for (final model in models.where(
+ (m) => m is NamedModel && m.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);
}
diff --git a/packages/tonik_generate/lib/src/naming/name_utils.dart b/packages/tonik_generate/lib/src/naming/name_utils.dart
index 56e3e22..1b8a13c 100644
--- a/packages/tonik_generate/lib/src/naming/name_utils.dart
+++ b/packages/tonik_generate/lib/src/naming/name_utils.dart
@@ -1,13 +1,9 @@
import 'package:change_case/change_case.dart';
-import 'package:spell_out_numbers/spell_out_numbers.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',
@@ -89,6 +85,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,80 +160,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);
-
- // Handle numbers differently for first part vs subsequent parts
- if (isFirstPart) {
- final numberMatch = RegExp(r'^(\d+)(.+)$').firstMatch(processedPart);
+/// 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;
+
+ // Extract numbers from token
+ final numberMatch =
+ RegExp(r'^(\d+)(.*)$|^(.+?)(\d+)$').firstMatch(token);
+
+ String textPart;
+ String? numberPart;
+
if (numberMatch != null) {
- final number = numberMatch.group(1)!;
- final rest = numberMatch.group(2)!;
- return (processed: rest.toCamelCase(), number: number);
+ if (numberMatch.group(1) != null) {
+ // Leading number: 123abc
+ numberPart = numberMatch.group(1);
+ textPart = numberMatch.group(2) ?? '';
+ } else {
+ // Trailing number: abc123
+ textPart = numberMatch.group(3) ?? '';
+ numberPart = numberMatch.group(4);
+ }
+ } else if (RegExp(r'^\d+$').hasMatch(token)) {
+ // Pure number
+ numberPart = token;
+ textPart = '';
+ } else {
+ // No numbers
+ textPart = token;
+ numberPart = null;
}
- return (processed: processedPart.toCamelCase(), 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: leadingRest.toPascalCase(), number: leadingNumber);
- } else if (trailingBase != null && trailingNumber != null) {
- return (processed: trailingBase.toPascalCase(), number: trailingNumber);
+
+ // Process text part
+ if (textPart.isNotEmpty) {
+ final normalized = _normalizeCasing(textPart, isFirst: isFirst);
+ result.add(normalized);
+ }
+
+ // 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: processedPart.toPascalCase(), number: null);
}
+
+ // Append any numbers that were moved from the first token
+ result.addAll(numbersToAppend);
+
+ return result.join();
}
-/// Splits a string into parts based on common separators and case boundaries.
-List splitIntoParts(String value) =>
- value.split(RegExp(r'[_\- ]|(?=[A-Z])'));
-
-/// 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 '';
}
@@ -180,40 +265,43 @@ 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);
}
/// 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 ${_numberToWords(number.abs())}'
+ : _numberToWords(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/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/pubspec_generator.dart b/packages/tonik_generate/lib/src/pubspec_generator.dart
index ef578fb..dcd2c9b 100644
--- a/packages/tonik_generate/lib/src/pubspec_generator.dart
+++ b/packages/tonik_generate/lib/src/pubspec_generator.dart
@@ -25,8 +25,9 @@ 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
+ tonik_util: ^0.0.7
''';
pubspecFile.writeAsStringSync(content);
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/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/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/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/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/lib/src/util/to_json_value_expression_generator.dart b/packages/tonik_generate/lib/src/util/to_json_value_expression_generator.dart
index eaf0927..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
@@ -47,8 +47,10 @@ String? _getSerializationSuffix(Model model, bool isNullable) {
isNullable || (model is EnumModel && model.isNullable) ? '?' : '';
return switch (model) {
- DateTimeModel() || DateModel() => '$nullablePart.toIso8601String()',
+ DateTimeModel() => '$nullablePart.toTimeZonedIso8601String()',
DecimalModel() => '$nullablePart.toString()',
+ UriModel() => '$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..4af221a 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(
@@ -82,13 +82,19 @@ 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
..symbol = nameManager.modelName(m)
..url = package
- ..isNullable =
- isNullableOverride,
+ ..isNullable = isNullableOverride,
),
};
}
diff --git a/packages/tonik_generate/pubspec.yaml b/packages/tonik_generate/pubspec.yaml
index 0ab9a31..d7c644a 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
@@ -15,9 +15,7 @@ dependencies:
logging: ^1.3.0
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
- very_good_analysis: ^8.0.0
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/all_of_generator_test.dart b/packages/tonik_generate/test/src/model/all_of_generator_test.dart
index 4c9030e..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
@@ -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]);
}
''';
@@ -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/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_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/model/class_json_generator_test.dart b/packages/tonik_generate/test/src/model/class_json_generator_test.dart
index 2b391bb..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
@@ -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(),
};
''';
@@ -408,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', () {
@@ -597,5 +769,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)),
+ );
+ });
});
}
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..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
@@ -232,7 +232,7 @@ void main() {
);
}
''';
-
+
expect(
collapseWhitespace(classCode),
contains(collapseWhitespace(expectedMethod)),
@@ -355,7 +355,7 @@ void main() {
);
}
''';
-
+
expect(
collapseWhitespace(classCode),
contains(collapseWhitespace(expectedMethod)),
@@ -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 fe4f88c..6c2d7a3 100644
--- a/packages/tonik_generate/test/src/model/typedef_generator_test.dart
+++ b/packages/tonik_generate/test/src/model/typedef_generator_test.dart
@@ -113,8 +113,9 @@ 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'),
+ (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_generate/test/src/naming/name_generator_test.dart b/packages/tonik_generate/test/src/naming/name_generator_test.dart
index b2c3858..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,18 +1075,21 @@ 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', () {
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),
];
@@ -1097,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(
@@ -1117,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');
},
);
@@ -1137,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(
@@ -1153,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');
},
);
@@ -1177,8 +1180,9 @@ 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');
+ },
+ );
test('uses default names on invalid URLs', () {
final generator = NameGenerator();
@@ -1195,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/naming/name_manager_test.dart b/packages/tonik_generate/test/src/naming/name_manager_test.dart
index 4102156..2eb923e 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,70 @@ void main() {
expect(baseName2, baseName);
expect(identical(implementationNames, implementationNames2), isTrue);
});
+
+ group('model naming behavior', () {
+ late Context userContext;
+
+ setUp(() {
+ userContext = Context.initial().pushAll([
+ 'components',
+ 'schemas',
+ 'user',
+ ]);
+ });
+
+ 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 +690,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 +701,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 +738,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);
});
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..1109322
--- /dev/null
+++ b/packages/tonik_generate/test/src/naming/name_utils_test.dart
@@ -0,0 +1,154 @@
+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('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');
+ 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/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/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/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/response/response_class_generator_test.dart b/packages/tonik_generate/test/src/response/response_class_generator_test.dart
index ca46a52..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
@@ -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/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 8b1b600..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
@@ -100,19 +100,19 @@ 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'));
-
+ expect(actualFileName, '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'));
@@ -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('sealed class Server'));
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..97e912a 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);
@@ -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',
@@ -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);
@@ -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',
@@ -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);
@@ -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',
@@ -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_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),
+ ]);
}
''';
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..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()',
);
});
@@ -67,7 +67,7 @@ void main() {
);
expect(
buildToJsonPropertyExpression('dueDate', property),
- 'dueDate?.toIso8601String()',
+ 'dueDate?.toJson()',
);
});
@@ -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_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/lib/src/model_importer.dart b/packages/tonik_parse/lib/src/model_importer.dart
index 12bb191..e79bc6b 100644
--- a/packages/tonik_parse/lib/src/model_importer.dart
+++ b/packages/tonik_parse/lib/src/model_importer.dart
@@ -119,8 +119,16 @@ 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.format == 'uri' || schema.format == 'url' =>
+ UriModel(context: context),
'string' when schema.enumerated != null => _parseEnum(
name,
schema.enumerated!,
diff --git a/packages/tonik_parse/pubspec.yaml b/packages/tonik_parse/pubspec.yaml
index 6f1e610..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,10 +11,9 @@ 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
json_serializable: ^6.8.0
test: ^1.24.0
- very_good_analysis: ^8.0.0
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_parse/test/model/model_property_test.dart b/packages/tonik_parse/test/model/model_property_test.dart
index ae141b0..8155e53 100644
--- a/packages/tonik_parse/test/model/model_property_test.dart
+++ b/packages/tonik_parse/test/model/model_property_test.dart
@@ -20,10 +20,14 @@ void main() {
'float': {'type': 'number', 'format': 'float'},
'double': {'type': 'number', 'format': 'double'},
'decimal': {'type': 'string', 'format': 'decimal'},
- 'decimal-alt': {'type': 'string', 'format': 'currency'},
+ 'currency': {'type': 'string', 'format': 'currency'},
+ 'money': {'type': 'string', 'format': 'money'},
+ 'numberString': {'type': 'string', 'format': 'number'},
'boolean': {'type': 'boolean'},
'date': {'type': 'string', 'format': 'date'},
'dateTime': {'type': 'string', 'format': 'date-time'},
+ 'uri': {'type': 'string', 'format': 'uri'},
+ 'url': {'type': 'string', 'format': 'url'},
},
},
},
@@ -88,17 +92,38 @@ void main() {
expect(double.model, isA());
});
- test('imports decimal', () {
+ test('imports decimal format', () {
final api = Importer().import(fileContent);
final model = api.models.first as ClassModel;
final decimal = model.properties.firstWhere((p) => p.name == 'decimal');
expect(decimal.model, isA());
+ });
+
+ test('imports currency format', () {
+ final api = Importer().import(fileContent);
+
+ final model = api.models.first as ClassModel;
+ final currency = model.properties.firstWhere((p) => p.name == 'currency');
+ expect(currency.model, isA());
+ });
+
+ test('imports money format', () {
+ final api = Importer().import(fileContent);
+
+ final model = api.models.first as ClassModel;
+ final money = model.properties.firstWhere((p) => p.name == 'money');
+ expect(money.model, isA());
+ });
+
+ test('imports number string format', () {
+ final api = Importer().import(fileContent);
- final decimalAlt = model.properties.firstWhere(
- (p) => p.name == 'decimal-alt',
+ final model = api.models.first as ClassModel;
+ final numberString = model.properties.firstWhere(
+ (p) => p.name == 'numberString',
);
- expect(decimalAlt.model, isA());
+ expect(numberString.model, isA());
});
test('imports boolean', () {
@@ -124,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/CHANGELOG.md b/packages/tonik_util/CHANGELOG.md
index b0c37af..b15a987 100644
--- a/packages/tonik_util/CHANGELOG.md
+++ b/packages/tonik_util/CHANGELOG.md
@@ -1,3 +1,13 @@
+## 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.
+
## 0.0.5
## 0.0.4
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..29e11cc 100644
--- a/packages/tonik_util/lib/src/decoding/json_decoder.dart
+++ b/packages/tonik_util/lib/src/decoding/json_decoder.dart
@@ -1,9 +1,11 @@
import 'package:big_decimal/big_decimal.dart';
+import 'package:tonik_util/src/date.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? {
- /// 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
@@ -24,7 +26,7 @@ extension JsonDecoder on Object? {
);
}
try {
- return DateTime.parse(this! as String);
+ return OffsetDateTime.parse(this! as String);
} on FormatException catch (e) {
throw InvalidTypeException(
value: this! as String,
@@ -348,4 +350,88 @@ 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.
+ /// Throws [InvalidTypeException] if the value is not a valid date string.
+ Date? decodeJsonNullableDate({String? context}) {
+ 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 c5f7957..3b61583 100644
--- a/packages/tonik_util/lib/src/decoding/simple_decoder.dart
+++ b/packages/tonik_util/lib/src/decoding/simple_decoder.dart
@@ -1,5 +1,7 @@
import 'package:big_decimal/big_decimal.dart';
+import 'package:tonik_util/src/date.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? {
@@ -130,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
@@ -144,7 +146,7 @@ extension SimpleDecoder on String? {
);
}
try {
- return DateTime.parse(this!);
+ return OffsetDateTime.parse(this!);
} on Object {
throw InvalidTypeException(
value: this!,
@@ -253,4 +255,89 @@ 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);
+ }
+
+ /// 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/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!;
}
}
-}
+}
diff --git a/packages/tonik_util/lib/src/encoding/base_encoder.dart b/packages/tonik_util/lib/src/encoding/base_encoder.dart
index 145cefa..49ca516 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.
///
@@ -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) {
@@ -84,7 +93,11 @@ abstract class BaseEncoder {
}
if (value is DateTime) {
- return value.toIso8601String();
+ 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/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/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/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 936c273..40cc418 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';
@@ -14,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 00b6106..61df51f 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.7
repository: https://github.com/t-unit/tonik
resolution: workspace
@@ -15,4 +15,4 @@ dependencies:
dev_dependencies:
test: ^1.24.0
- very_good_analysis: ^8.0.0
+ timezone: ^0.10.1
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..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,15 +2,64 @@ 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';
void main() {
group('JsonDecoder', () {
group('DateTime', () {
- test('decodes DateTime values', () {
- final date = DateTime.utc(2024, 3, 14);
- expect(date.toIso8601String().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.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.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.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(
() => 123.decodeJsonDateTime(),
throwsA(isA()),
@@ -21,9 +70,17 @@ void main() {
);
});
- test('decodes nullable DateTime values', () {
- final date = DateTime.utc(2024, 3, 14);
- expect(date.toIso8601String().decodeJsonNullableDateTime(), date);
+ test('decodes nullable DateTime values with timezone awareness', () {
+ 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(
@@ -196,6 +253,118 @@ 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(),
+ throwsA(isA()),
+ );
+ 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('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', () {
@@ -250,7 +419,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/decoding/simple_decoder_test.dart b/packages/tonik_util/test/src/decoding/simple_decoder_test.dart
index 85818cc..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,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';
@@ -45,9 +46,58 @@ void main() {
);
});
- test('decodes DateTime values', () {
- final date = DateTime.utc(2024, 3, 14);
- expect(date.toIso8601String().decodeSimpleDateTime(), date);
+ test('decodes DateTime values with timezone awareness', () {
+ // Test UTC parsing
+ const utcString = '2024-03-14T10:30:45Z';
+ final utcResult = utcString.decodeSimpleDateTime();
+ 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.decodeSimpleDateTime();
+ 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.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(
() => 'not-a-date'.decodeSimpleDateTime(),
throwsA(isA()),
@@ -70,6 +120,60 @@ 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()),
+ );
+ });
+
+ 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', () {
@@ -79,12 +183,16 @@ void main() {
expect(''.decodeSimpleNullableBool(), isNull);
expect(''.decodeSimpleNullableDateTime(), isNull);
expect(''.decodeSimpleNullableBigDecimal(), isNull);
+ expect(''.decodeSimpleNullableDate(), isNull);
+ expect(''.decodeSimpleNullableUri(), 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);
+ expect(null.decodeSimpleNullableUri(), isNull);
});
test('decodes non-empty strings for nullable types', () {
@@ -92,13 +200,21 @@ 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),
+ '2024-03-14T10:30:45Z'.decodeSimpleNullableDateTime(),
+ DateTime.utc(2024, 3, 14, 10, 30, 45),
);
expect(
'3.14'.decodeSimpleNullableBigDecimal(),
BigDecimal.parse('3.14'),
);
+ expect(
+ '2024-03-15'.decodeSimpleNullableDate(),
+ Date(2024, 3, 15),
+ );
+ expect(
+ 'https://example.com'.decodeSimpleNullableUri(),
+ Uri.parse('https://example.com'),
+ );
});
});
@@ -124,6 +240,14 @@ void main() {
() => ''.decodeSimpleBigDecimal(),
throwsA(isA()),
);
+ expect(
+ () => ''.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
new file mode 100644
index 0000000..0490711
--- /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, utcDateTime.toIso8601String());
+ expect(result, '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, '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, '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, '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, '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, '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, '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, '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, '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 1182669..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
@@ -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'),
@@ -30,11 +38,48 @@ 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', {
- '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 +98,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 +113,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 +131,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 +152,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 +245,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 +264,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 +286,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 +315,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'),
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',
+ );
+ });
+ });
+ });
});
}
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..3aef2a5
--- /dev/null
+++ b/packages/tonik_util/test/src/offset_date_time_test.dart
@@ -0,0 +1,890 @@
+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 offset
+ final expectedLocalTime = DateTime.parse(input);
+ 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);
+ });
+
+ 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);
+ });
+ });
+}
diff --git a/pubspec.yaml b/pubspec.yaml
index 403f4d7..387898b 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -16,17 +16,16 @@ dependencies:
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
+ 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
test: ^1.25.15
- very_good_analysis: ^8.0.0
+ very_good_analysis: ^9.0.0
melos:
scripts: