From 595d6e26a783c194e85049853be4a28a197282e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eirik=20Brandtz=C3=A6g?= Date: Fri, 14 Aug 2026 08:46:12 +0200 Subject: [PATCH 1/2] fix(dart): do not convert format: date fields to UTC before formatting _dateFormatter is DateFormat('yyyy-MM-dd'), which formats the y/m/d the DateTime already carries and performs no timezone conversion. Calling toUtc() first therefore does nothing except roll the clock back past midnight in UTC+X zones, so the formatter prints the previous day. Combined with mapDateTime parsing the bare wire value "2026-09-12" as local midnight, the round trip in Europe/Oslo is "2026-09-12" -> "2026-09-11". It is stable in UTC, which is why this went unnoticed. Removing toUtc() is safe in both directions: toUtc() returns this when the DateTime is already UTC, so UTC callers are unaffected, and local callers stop being shifted. The neighbouring isDateTime branch keeps toUtc() - an instant needs a zone, a calendar date does not. dart-dio already models this correctly with its own Date class. fix #24703 --- .../native/native_class.mustache | 8 +++---- .../codegen/dart/DartClientCodegenTest.java | 23 +++++++++++++++++++ .../dart-native-deserialization-bugs.yaml | 11 +++++++++ .../lib/model/format_test.dart | 2 +- .../lib/model/nullable_class.dart | 2 +- 5 files changed, 40 insertions(+), 6 deletions(-) diff --git a/modules/openapi-generator/src/main/resources/dart2/serialization/native/native_class.mustache b/modules/openapi-generator/src/main/resources/dart2/serialization/native/native_class.mustache index ecae5ef6e026..73b3e486cf88 100644 --- a/modules/openapi-generator/src/main/resources/dart2/serialization/native/native_class.mustache +++ b/modules/openapi-generator/src/main/resources/dart2/serialization/native/native_class.mustache @@ -82,10 +82,10 @@ class {{{classname}}} { {{#pattern}} json[r'{{{baseName}}}'] = value == null ? null : (_isEpochMarker(r'{{{pattern}}}') ? value.millisecondsSinceEpoch - : _dateFormatter.format(value.toUtc())); + : _dateFormatter.format(value)); {{/pattern}} {{^pattern}} - json[r'{{{baseName}}}'] = value == null ? null : _dateFormatter.format(value.toUtc()); + json[r'{{{baseName}}}'] = value == null ? null : _dateFormatter.format(value); {{/pattern}} {{/isDate}} {{^isDateTime}} @@ -120,10 +120,10 @@ class {{{classname}}} { {{#pattern}} json[r'{{{baseName}}}'] = _isEpochMarker(r'{{{pattern}}}') ? this.{{{name}}}{{#isNullable}}!{{/isNullable}}{{^isNullable}}{{^required}}{{^defaultValue}}!{{/defaultValue}}{{/required}}{{/isNullable}}.millisecondsSinceEpoch - : _dateFormatter.format(this.{{{name}}}{{#isNullable}}!{{/isNullable}}{{^isNullable}}{{^required}}{{^defaultValue}}!{{/defaultValue}}{{/required}}{{/isNullable}}.toUtc()); + : _dateFormatter.format(this.{{{name}}}{{#isNullable}}!{{/isNullable}}{{^isNullable}}{{^required}}{{^defaultValue}}!{{/defaultValue}}{{/required}}{{/isNullable}}); {{/pattern}} {{^pattern}} - json[r'{{{baseName}}}'] = _dateFormatter.format(this.{{{name}}}{{#isNullable}}!{{/isNullable}}{{^isNullable}}{{^required}}{{^defaultValue}}!{{/defaultValue}}{{/required}}{{/isNullable}}.toUtc()); + json[r'{{{baseName}}}'] = _dateFormatter.format(this.{{{name}}}{{#isNullable}}!{{/isNullable}}{{^isNullable}}{{^required}}{{^defaultValue}}!{{/defaultValue}}{{/required}}{{/isNullable}}); {{/pattern}} {{/isDate}} {{^isDateTime}} diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/dart/DartClientCodegenTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/dart/DartClientCodegenTest.java index 357f6a9a72f5..0930a1a2dcb8 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/dart/DartClientCodegenTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/dart/DartClientCodegenTest.java @@ -199,4 +199,27 @@ public void testNullableNestedComplexArraysPreserveNullEntries() throws Exceptio TestUtils.assertFileContains(modelFile.toPath(), "e == null ? null : NullableRequiredModel.listFromJson(e)"); } + + @Test(description = "format: date must not be converted to UTC before formatting") + public void testDateOnlyFieldsAreNotConvertedToUtc() throws Exception { + List files = generateDartNativeFromSpec( + "src/test/resources/3_0/dart/dart-native-deserialization-bugs.yaml"); + + File modelFile = files.stream() + .filter(f -> f.getName().equals("date_only_model.dart")) + .findFirst() + .orElseThrow(() -> new AssertionError("date_only_model.dart not found in generated files")); + + // _dateFormatter is DateFormat('yyyy-MM-dd'), which formats the y/m/d the + // DateTime already carries and performs no timezone conversion. Calling + // toUtc() first therefore does nothing except roll the clock back past + // midnight in UTC+X zones, so the formatter prints the previous day. + // This model has no date-time properties, so no toUtc() belongs in it. + TestUtils.assertFileNotContains(modelFile.toPath(), ".toUtc()"); + + TestUtils.assertFileContains(modelFile.toPath(), + "json[r'requiredDate'] = _dateFormatter.format(this.requiredDate);"); + TestUtils.assertFileContains(modelFile.toPath(), + "json[r'optionalDate'] = _dateFormatter.format(this.optionalDate!);"); + } } diff --git a/modules/openapi-generator/src/test/resources/3_0/dart/dart-native-deserialization-bugs.yaml b/modules/openapi-generator/src/test/resources/3_0/dart/dart-native-deserialization-bugs.yaml index 53f2172adee4..ce6d09fe40af 100644 --- a/modules/openapi-generator/src/test/resources/3_0/dart/dart-native-deserialization-bugs.yaml +++ b/modules/openapi-generator/src/test/resources/3_0/dart/dart-native-deserialization-bugs.yaml @@ -47,3 +47,14 @@ components: nullable: true items: $ref: '#/components/schemas/NullableRequiredModel' + DateOnlyModel: + type: object + required: + - requiredDate + properties: + requiredDate: + type: string + format: date + optionalDate: + type: string + format: date diff --git a/samples/openapi3/client/petstore/dart2/petstore_client_lib_fake/lib/model/format_test.dart b/samples/openapi3/client/petstore/dart2/petstore_client_lib_fake/lib/model/format_test.dart index 5c22fff27825..3a5506f6d561 100644 --- a/samples/openapi3/client/petstore/dart2/petstore_client_lib_fake/lib/model/format_test.dart +++ b/samples/openapi3/client/petstore/dart2/petstore_client_lib_fake/lib/model/format_test.dart @@ -233,7 +233,7 @@ class FormatTest { } else { json[r'binary'] = null; } - json[r'date'] = _dateFormatter.format(this.date.toUtc()); + json[r'date'] = _dateFormatter.format(this.date); if (this.dateTime != null) { json[r'dateTime'] = this.dateTime!.toUtc().toIso8601String(); } else { diff --git a/samples/openapi3/client/petstore/dart2/petstore_client_lib_fake/lib/model/nullable_class.dart b/samples/openapi3/client/petstore/dart2/petstore_client_lib_fake/lib/model/nullable_class.dart index 7dc16ebaf5bc..8742cbe2ccd8 100644 --- a/samples/openapi3/client/petstore/dart2/petstore_client_lib_fake/lib/model/nullable_class.dart +++ b/samples/openapi3/client/petstore/dart2/petstore_client_lib_fake/lib/model/nullable_class.dart @@ -108,7 +108,7 @@ class NullableClass { json[r'string_prop'] = null; } if (this.dateProp != null) { - json[r'date_prop'] = _dateFormatter.format(this.dateProp!.toUtc()); + json[r'date_prop'] = _dateFormatter.format(this.dateProp!); } else { json[r'date_prop'] = null; } From e27bf96274998bd446dd264183aad17e98e4e05d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eirik=20Brandtz=C3=A6g?= Date: Fri, 14 Aug 2026 09:45:58 +0200 Subject: [PATCH 2/2] test(dart): cover all four format: date branches The first test only exercised the plain, no-pattern branch, so three of the four changed template lines were unguarded. Adding a patterned date to the fixture plus a second test that generates with useOptional=true pins all four: plain / no pattern requiredDate, optionalDate plain / pattern patternedDate Optional / no pattern optionalDate (useOptional=true) Optional / pattern patternedDate (useOptional=true) Replace the blanket assertFileNotContains(".toUtc()") with assertions on the specific emitted lines. The blanket one would break if an unrelated date-time property were ever added to the shared fixture. Also correct the comment: toUtc() shifts the date in both directions, back a day east of UTC and forward a day west of it, not only backwards. --- .../codegen/dart/DartClientCodegenTest.java | 36 +++++++++++++++---- .../dart-native-deserialization-bugs.yaml | 4 +++ 2 files changed, 33 insertions(+), 7 deletions(-) diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/dart/DartClientCodegenTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/dart/DartClientCodegenTest.java index 0930a1a2dcb8..2215908d3d65 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/dart/DartClientCodegenTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/dart/DartClientCodegenTest.java @@ -32,6 +32,7 @@ import java.util.ArrayList; import java.util.List; import java.util.Locale; +import java.util.Map; public class DartClientCodegenTest { @@ -101,6 +102,10 @@ public void testEnumPropertyWithQuotes() { } private List generateDartNativeFromSpec(String specPath) throws Exception { + return generateDartNativeFromSpec(specPath, Map.of()); + } + + private List generateDartNativeFromSpec(String specPath, Map additionalProperties) throws Exception { File output = Files.createTempDirectory("dart-native-test").toFile(); output.deleteOnExit(); @@ -108,6 +113,7 @@ private List generateDartNativeFromSpec(String specPath) throws Exception .setGeneratorName("dart") .setInputSpec(specPath) .setOutputDir(output.getAbsolutePath().replace("\\", "/")); + additionalProperties.forEach(configurator::addAdditionalProperty); ClientOptInput opts = configurator.toClientOptInput(); DefaultGenerator generator = new DefaultGenerator(); @@ -200,6 +206,9 @@ public void testNullableNestedComplexArraysPreserveNullEntries() throws Exceptio "e == null ? null : NullableRequiredModel.listFromJson(e)"); } + // DateFormat('yyyy-MM-dd') does no timezone conversion, it just formats the y/m/d the + // DateTime already carries. So toUtc() only shifts the value across a day boundary and + // the date comes out off by one - back a day east of UTC, forward a day west of it. @Test(description = "format: date must not be converted to UTC before formatting") public void testDateOnlyFieldsAreNotConvertedToUtc() throws Exception { List files = generateDartNativeFromSpec( @@ -210,16 +219,29 @@ public void testDateOnlyFieldsAreNotConvertedToUtc() throws Exception { .findFirst() .orElseThrow(() -> new AssertionError("date_only_model.dart not found in generated files")); - // _dateFormatter is DateFormat('yyyy-MM-dd'), which formats the y/m/d the - // DateTime already carries and performs no timezone conversion. Calling - // toUtc() first therefore does nothing except roll the clock back past - // midnight in UTC+X zones, so the formatter prints the previous day. - // This model has no date-time properties, so no toUtc() belongs in it. - TestUtils.assertFileNotContains(modelFile.toPath(), ".toUtc()"); - TestUtils.assertFileContains(modelFile.toPath(), "json[r'requiredDate'] = _dateFormatter.format(this.requiredDate);"); TestUtils.assertFileContains(modelFile.toPath(), "json[r'optionalDate'] = _dateFormatter.format(this.optionalDate!);"); + // A pattern routes it through the _isEpochMarker ternary, non-epoch side formats the same way + TestUtils.assertFileContains(modelFile.toPath(), + ": _dateFormatter.format(this.patternedDate!);"); + } + + @Test(description = "format: date must not be converted to UTC in the Optional path either") + public void testDateOnlyFieldsAreNotConvertedToUtcWithUseOptional() throws Exception { + List files = generateDartNativeFromSpec( + "src/test/resources/3_0/dart/dart-native-deserialization-bugs.yaml", + Map.of("useOptional", true)); + + File modelFile = files.stream() + .filter(f -> f.getName().equals("date_only_model.dart")) + .findFirst() + .orElseThrow(() -> new AssertionError("date_only_model.dart not found in generated files")); + + TestUtils.assertFileContains(modelFile.toPath(), + "json[r'optionalDate'] = value == null ? null : _dateFormatter.format(value);"); + TestUtils.assertFileContains(modelFile.toPath(), + ": _dateFormatter.format(value));"); } } diff --git a/modules/openapi-generator/src/test/resources/3_0/dart/dart-native-deserialization-bugs.yaml b/modules/openapi-generator/src/test/resources/3_0/dart/dart-native-deserialization-bugs.yaml index ce6d09fe40af..218af8dfe017 100644 --- a/modules/openapi-generator/src/test/resources/3_0/dart/dart-native-deserialization-bugs.yaml +++ b/modules/openapi-generator/src/test/resources/3_0/dart/dart-native-deserialization-bugs.yaml @@ -58,3 +58,7 @@ components: optionalDate: type: string format: date + patternedDate: + type: string + format: date + pattern: '^\d{4}-\d{2}-\d{2}$'