这是indexloc提供的服务,不要输入任何密码
Skip to content

De- and encoding for none-utc date time objects #26

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Jul 20, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 26 additions & 1 deletion docs/data_types.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ This document provides information about how Tonik is mapping data types in Open

| OAS Type | OAS Format | Dart Type | Dart Package | Comment |
|----------|------------|-----------|--------------|---------|
| `string` | `date-time` | `DateTime` | `dart:core` | ISO 8601 datetime format |
| `string` | `date-time` | `DateTime` | `dart:core` | See [Timezone-Aware DateTime Parsing](#timezone-aware-datetime-parsing) |
| `string` | `date` | `Date` | `tonik_util` | RFC3339 date format (YYYY-MM-DD) |
| `string` | `decimal`, `currency`, `money`, `number` | `BigDecimal` | `big_decimal` | High-precision decimal numbers |
| `string` | `enum` | `enum` | Generated | Custom enum type |
Expand All @@ -19,3 +19,28 @@ This document provides information about how Tonik is mapping data types in Open
| `boolean` | (any) | `bool` | `dart:core` | Boolean type |
| `array` | (any) | `List<T>` | `dart:core` | List of specified type |

### Timezone-Aware DateTime Parsing

Tonik provides intelligent timezone-aware parsing for `date-time` format strings. The parsing behavior depends on the timezone information present in the input:

> **⚠️ Important:** Before using timezone-aware parsing features, you must initialize the timezone database by calling `tz.initializeTimeZones()` from the `timezone` package. This is typically done in your application's setup code.

All generated code will always expose Dart `DateTime` objects. However, standard Dart `DateTime` objects do not preserve timezone information, which is why Tonik uses `TZDateTime` internally during parsing to maintain timezone location data. During parsing, Tonik selects the most appropriate type to represent the date and time value:

| Input Format | Return Type | Example | Description |
|--------------|-------------|---------|-------------|
| UTC (with Z) | `DateTime` (UTC) | `2023-12-25T15:30:45Z` | Standard Dart DateTime in UTC |
| Local (no timezone) | `DateTime` (local) | `2023-12-25T15:30:45` | Standard Dart DateTime in local timezone |
| Timezone offset | `TZDateTime` | `2023-12-25T15:30:45+05:00` | Timezone-aware DateTime with proper location |



#### Timezone Location Selection

For strings with timezone offsets (e.g., `+05:00`), Tonik intelligently selects the best matching timezone location:

1. **Prefers common locations** from the timezone package's curated list of 535+ well-known timezones
2. **Accounts for DST changes** by checking the offset at the specific timestamp
3. **Avoids deprecated locations** (e.g., `US/Eastern` → `America/New_York`)
4. **Falls back to fixed offset** locations (`Etc/GMT±N`) when no match is found

24 changes: 12 additions & 12 deletions packages/tonik_core/test/model/query_parameter_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -23,17 +23,17 @@ void main() {

final resolved = param.resolve(name: 'newName');

expect(resolved.name, equals('newName'));
expect(resolved.rawName, equals('originalRawName'));
expect(resolved.description, equals('description'));
expect(resolved.name, 'newName');
expect(resolved.rawName, 'originalRawName');
expect(resolved.description, 'description');
expect(resolved.isRequired, isTrue);
expect(resolved.isDeprecated, isFalse);
expect(resolved.allowEmptyValue, isFalse);
expect(resolved.allowReserved, isFalse);
expect(resolved.explode, isFalse);
expect(resolved.model, equals(model));
expect(resolved.encoding, equals(QueryParameterEncoding.form));
expect(resolved.context, equals(context));
expect(resolved.model, model);
expect(resolved.encoding, QueryParameterEncoding.form);
expect(resolved.context, context);
});

test('resolve preserves original name when no new name provided', () {
Expand All @@ -56,7 +56,7 @@ void main() {

final resolved = param.resolve();

expect(resolved.name, equals('originalName'));
expect(resolved.name, 'originalName');
});

test('QueryParameterAlias.resolve resolves with alias name', () {
Expand Down Expand Up @@ -85,8 +85,8 @@ void main() {

final resolved = alias.resolve();

expect(resolved.name, equals('aliasName'));
expect(resolved.rawName, equals('originalRawName'));
expect(resolved.name, 'aliasName');
expect(resolved.rawName, 'originalRawName');
});

test(
Expand Down Expand Up @@ -117,7 +117,7 @@ void main() {

final resolved = alias.resolve(name: 'overrideName');

expect(resolved.name, equals('overrideName'));
expect(resolved.name, 'overrideName');
},
);

Expand Down Expand Up @@ -153,8 +153,8 @@ void main() {

final resolved = secondAlias.resolve();

expect(resolved.name, equals('secondAliasName'));
expect(resolved.rawName, equals('originalRawName'));
expect(resolved.name, 'secondAliasName');
expect(resolved.rawName, 'originalRawName');
});
});
}
24 changes: 12 additions & 12 deletions packages/tonik_core/test/model/request_header_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -22,16 +22,16 @@ void main() {

final resolved = header.resolve(name: 'newName');

expect(resolved.name, equals('newName'));
expect(resolved.rawName, equals('originalRawName'));
expect(resolved.description, equals('description'));
expect(resolved.name, 'newName');
expect(resolved.rawName, 'originalRawName');
expect(resolved.description, 'description');
expect(resolved.isRequired, isTrue);
expect(resolved.isDeprecated, isFalse);
expect(resolved.allowEmptyValue, isFalse);
expect(resolved.explode, isFalse);
expect(resolved.model, equals(model));
expect(resolved.encoding, equals(HeaderParameterEncoding.simple));
expect(resolved.context, equals(context));
expect(resolved.model, model);
expect(resolved.encoding, HeaderParameterEncoding.simple);
expect(resolved.context, context);
});

test('resolve preserves original name when no new name provided', () {
Expand All @@ -53,7 +53,7 @@ void main() {

final resolved = header.resolve();

expect(resolved.name, equals('originalName'));
expect(resolved.name, 'originalName');
});

test('RequestHeaderAlias.resolve resolves with alias name', () {
Expand Down Expand Up @@ -81,8 +81,8 @@ void main() {

final resolved = alias.resolve();

expect(resolved.name, equals('aliasName'));
expect(resolved.rawName, equals('originalRawName'));
expect(resolved.name, 'aliasName');
expect(resolved.rawName, 'originalRawName');
});

test(
Expand Down Expand Up @@ -112,7 +112,7 @@ void main() {

final resolved = alias.resolve(name: 'overrideName');

expect(resolved.name, equals('overrideName'));
expect(resolved.name, 'overrideName');
},
);

Expand Down Expand Up @@ -147,8 +147,8 @@ void main() {

final resolved = secondAlias.resolve();

expect(resolved.name, equals('secondAliasName'));
expect(resolved.rawName, equals('originalRawName'));
expect(resolved.name, 'secondAliasName');
expect(resolved.rawName, 'originalRawName');
});
});
}
22 changes: 11 additions & 11 deletions packages/tonik_core/test/model/response_header_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,14 @@ void main() {

final resolved = header.resolve(name: 'newName');

expect(resolved.name, equals('newName'));
expect(resolved.description, equals('description'));
expect(resolved.name, 'newName');
expect(resolved.description, 'description');
expect(resolved.isRequired, isTrue);
expect(resolved.isDeprecated, isFalse);
expect(resolved.explode, isFalse);
expect(resolved.model, equals(model));
expect(resolved.encoding, equals(ResponseHeaderEncoding.simple));
expect(resolved.context, equals(context));
expect(resolved.model, model);
expect(resolved.encoding, ResponseHeaderEncoding.simple);
expect(resolved.context, context);
});

test('resolve preserves original name when no new name provided', () {
Expand All @@ -47,7 +47,7 @@ void main() {

final resolved = header.resolve();

expect(resolved.name, equals('originalName'));
expect(resolved.name, 'originalName');
});

test('ResponseHeaderAlias.resolve resolves with alias name', () {
Expand All @@ -73,8 +73,8 @@ void main() {

final resolved = alias.resolve();

expect(resolved.name, equals('aliasName'));
expect(resolved.description, equals('description'));
expect(resolved.name, 'aliasName');
expect(resolved.description, 'description');
});

test(
Expand Down Expand Up @@ -102,7 +102,7 @@ void main() {

final resolved = alias.resolve(name: 'overrideName');

expect(resolved.name, equals('overrideName'));
expect(resolved.name, 'overrideName');
},
);

Expand Down Expand Up @@ -135,8 +135,8 @@ void main() {

final resolved = secondAlias.resolve();

expect(resolved.name, equals('secondAliasName'));
expect(resolved.description, equals('description'));
expect(resolved.name, 'secondAliasName');
expect(resolved.description, 'description');
});
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ String? _getSerializationSuffix(Model model, bool isNullable) {
isNullable || (model is EnumModel && model.isNullable) ? '?' : '';

return switch (model) {
DateTimeModel() => '$nullablePart.toIso8601String()',
DateTimeModel() => '$nullablePart.toTimeZonedIso8601String()',
DecimalModel() => '$nullablePart.toString()',
DateModel() ||
EnumModel() ||
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
};
Expand Down Expand Up @@ -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(),
};
''';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -367,7 +367,7 @@ void main() {
allowEmpty: false,
);
headers[r'X-Required-Date'] = headerEncoder.encode(
xRequiredDate.toIso8601String(),
xRequiredDate.toTimeZonedIso8601String(),
explode: false,
allowEmpty: true,
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 ='));
});

Expand Down Expand Up @@ -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'));
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ void main() {
final fileContent = File(generatedFile.path).readAsStringSync();

// Check file name
expect(actualFileName, equals('server.dart'));
expect(actualFileName, 'server.dart');

// Check file content
expect(fileContent, contains('sealed class Server'));
Expand Down
Loading
Loading