From daf630233a6cedac30065da4218fafb7dece7b27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antoine=20Le=20D=C3=BB?= Date: Thu, 30 Apr 2026 07:11:25 +0200 Subject: [PATCH 1/2] register BuilderFactory for nested additionalProperties shapes For a property like `{ additionalProperties: { type: array, items: { $ref: ... } } }` the generator emits `Map>` in the Dart class but never registers the `BuiltList` BuilderFactory. The only factory-registration path that ran for additionalProperties looked at `items.getAdditionalProperties()`, which is null for this very common shape (a region map of arrays of $ref). built_value then fails at runtime with `Bad state: No builder factory for BuiltList` on the first deserialization that touches the property. Fix: - `postProcessModelProperty` now also calls `registerNestedBuilderFactories`, which walks the property's `items` tree top-down and registers a factory for every container layer. Three small helpers (`renderInnerFullType`, `renderDartType`, `renderBuilderFactory`) compute the corresponding `FullType(...)` argument list and the matching `XBuilder<...>` instantiation for arbitrary nesting: `Map>`, `List>`, `Map>`, `List>`, `Set<...>`, etc. - `BuiltValueSerializer` gets a new `composite(fullTypeArgs, builderInstantiation)` constructor that carries the pre-rendered expressions. The existing `(isArray, uniqueItems, isMap, isNullable, dataType)` form is unchanged -- needed because the original model can't represent something like `BuiltMap>` (the `FullType` argument is recursive and isn't expressible with a single `dataType` string). `equals`/`hashCode` are extended so composite serializers dedup on `(fullTypeArgs, builderInstantiation)` and never collide with simple ones. - `serializers.mustache` gets a new branch that emits the composite fields verbatim when present; otherwise the existing `isArray`/`isMap` dispatch runs unchanged. Existing simple cases (direct return / parameter container types, single-level additionalProperties already handled by the prior branch) keep producing byte-identical output. A new fixture `built_value_additional_properties_factory.yaml` exercises the canonical `Map>` shape, and a new test `DartDioClientCodegenTest.testNestedAdditionalPropertiesGetBuilderFactories` asserts both the inner `BuiltList` and the outer `BuiltMap>` factories appear in the generated `serializers.dart`. Full Dart suite: 115 tests, 0 failures, 0 regressions. --- .../languages/DartDioClientCodegen.java | 151 ++++++++++++++++++ .../built_value/serializers.mustache | 6 + .../dart/dio/DartDioClientCodegenTest.java | 44 +++++ ...t_value_additional_properties_factory.yaml | 51 ++++++ 4 files changed, 252 insertions(+) create mode 100644 modules/openapi-generator/src/test/resources/3_0/dart-dio/built_value_additional_properties_factory.yaml diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/DartDioClientCodegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/DartDioClientCodegen.java index 077f79904a69..e0200395bdee 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/DartDioClientCodegen.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/DartDioClientCodegen.java @@ -660,10 +660,121 @@ public void postProcessModelProperty(CodegenModel model, CodegenProperty propert items.getAdditionalProperties().dataType )); } + + // Recursively register builder factories for every nested + // container reachable from this property. Without this step, + // shapes like Map> (e.g. an OpenAPI object + // with `additionalProperties: { type: array, items: ...}`) + // never get a `BuiltList` factory registered, and + // built_value throws "No builder factory for BuiltList" + // at deserialization time. + registerNestedBuilderFactories(property); } } } + /** + * Walk the CodegenProperty tree and register a built_value + * BuilderFactory for every container node, including the top one. + * Handles arbitrary nesting like {@code Map>}, + * {@code List>}, {@code List>}, etc. + */ + private void registerNestedBuilderFactories(CodegenProperty prop) { + if (prop == null || !prop.isContainer || prop.items == null) { + return; + } + // Recurse first so deeper containers are registered too. Order + // doesn't matter for correctness (built_value resolves factories + // by FullType lookup), but it keeps the emitted list intuitive. + registerNestedBuilderFactories(prop.items); + + BuilderFactoryExpr expr = renderBuilderFactory(prop); + if (expr != null) { + addBuiltValueSerializer(BuiltValueSerializer.composite( + expr.fullTypeArgs, + expr.builderInstantiation)); + } + } + + /** + * Render the FullType argument list and the matching Builder + * instantiation for a container CodegenProperty. + * + * @return null if {@code prop} is not a container we can render. + */ + private BuilderFactoryExpr renderBuilderFactory(CodegenProperty prop) { + if (prop == null || !prop.isContainer || prop.items == null) { + return null; + } + String innerFullType = renderInnerFullType(prop.items); + String innerDart = renderDartType(prop.items); + + if (prop.isArray) { + String collection = prop.getUniqueItems() ? "BuiltSet" : "BuiltList"; + String builder = prop.getUniqueItems() ? "SetBuilder" : "ListBuilder"; + return new BuilderFactoryExpr( + collection + ", [FullType(" + innerFullType + ")]", + builder + "<" + innerDart + ">"); + } + if (prop.isMap) { + return new BuilderFactoryExpr( + "BuiltMap, [FullType(String), FullType(" + innerFullType + ")]", + "MapBuilder"); + } + return null; + } + + /** + * What goes inside {@code FullType(...)} for this property: + * a leaf type name like {@code "Foo"}, or a nested expression like + * {@code "BuiltList, [FullType(Foo)]"}. + */ + private String renderInnerFullType(CodegenProperty prop) { + if (prop == null) return "dynamic"; + if (!prop.isContainer || prop.items == null) { + return prop.dataType; + } + String inner = renderInnerFullType(prop.items); + if (prop.isArray) { + String collection = prop.getUniqueItems() ? "BuiltSet" : "BuiltList"; + return collection + ", [FullType(" + inner + ")]"; + } + if (prop.isMap) { + return "BuiltMap, [FullType(String), FullType(" + inner + ")]"; + } + return prop.dataType; + } + + /** + * Render the Dart type literal (e.g. {@code BuiltMap>}) + * used inside the {@code () => XBuilder<...>()} lambda. + */ + private String renderDartType(CodegenProperty prop) { + if (prop == null) return "dynamic"; + if (!prop.isContainer || prop.items == null) { + return prop.dataType; + } + String inner = renderDartType(prop.items); + if (prop.isArray) { + String collection = prop.getUniqueItems() ? "BuiltSet" : "BuiltList"; + return collection + "<" + inner + ">"; + } + if (prop.isMap) { + return "BuiltMap"; + } + return prop.dataType; + } + + private static final class BuilderFactoryExpr { + final String fullTypeArgs; + final String builderInstantiation; + + BuilderFactoryExpr(String fullTypeArgs, String builderInstantiation) { + this.fullTypeArgs = fullTypeArgs; + this.builderInstantiation = builderInstantiation; + } + } + @Override public OperationsMap postProcessOperationsWithModels(OperationsMap objs, List allModels) { super.postProcessOperationsWithModels(objs, allModels); @@ -817,12 +928,45 @@ static class BuiltValueSerializer { @Getter final String dataType; + // When non-null, the serializer is rendered verbatim from these + // pre-computed strings instead of being dispatched through the + // isArray/isMap branches in serializers.mustache. Used for + // arbitrarily nested container types where dataType alone can't + // express the FullType expression (e.g. Map>). + @Getter final String fullTypeArgs; + + @Getter final String builderInstantiation; + private BuiltValueSerializer(boolean isArray, boolean uniqueItems, boolean isMap, boolean isNullable, String dataType) { this.isArray = isArray; this.uniqueItems = uniqueItems; this.isMap = isMap; this.isNullable = isNullable; this.dataType = dataType; + this.fullTypeArgs = null; + this.builderInstantiation = null; + } + + private BuiltValueSerializer(String fullTypeArgs, String builderInstantiation) { + this.isArray = false; + this.uniqueItems = false; + this.isMap = false; + this.isNullable = false; + this.dataType = ""; + this.fullTypeArgs = fullTypeArgs; + this.builderInstantiation = builderInstantiation; + } + + /** + * Build a serializer for a nested-container BuilderFactory whose + * type signature can't be expressed by the simple + * (isArray, isMap, dataType) form. {@code fullTypeArgs} is the + * argument list for {@code FullType(...)} (without the wrapping + * call) and {@code builderInstantiation} is the inside of + * {@code () => ...()}. + */ + public static BuiltValueSerializer composite(String fullTypeArgs, String builderInstantiation) { + return new BuiltValueSerializer(fullTypeArgs, builderInstantiation); } public boolean isArray() { @@ -842,11 +986,18 @@ public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; BuiltValueSerializer that = (BuiltValueSerializer) o; + if (fullTypeArgs != null || that.fullTypeArgs != null) { + return Objects.equals(fullTypeArgs, that.fullTypeArgs) + && Objects.equals(builderInstantiation, that.builderInstantiation); + } return isArray == that.isArray && uniqueItems == that.uniqueItems && isMap == that.isMap && isNullable == that.isNullable && dataType.equals(that.dataType); } @Override public int hashCode() { + if (fullTypeArgs != null) { + return Objects.hash(fullTypeArgs, builderInstantiation); + } return Objects.hash(isArray, uniqueItems, isMap, isNullable, dataType); } } diff --git a/modules/openapi-generator/src/main/resources/dart/libraries/dio/serialization/built_value/serializers.mustache b/modules/openapi-generator/src/main/resources/dart/libraries/dio/serialization/built_value/serializers.mustache index 0ae5dc8af49e..67ff3ea18cda 100644 --- a/modules/openapi-generator/src/main/resources/dart/libraries/dio/serialization/built_value/serializers.mustache +++ b/modules/openapi-generator/src/main/resources/dart/libraries/dio/serialization/built_value/serializers.mustache @@ -23,6 +23,11 @@ part 'serializers.g.dart'; ]) Serializers serializers = (_$serializers.toBuilder(){{#builtValueSerializers}} ..addBuilderFactory( +{{#fullTypeArgs}} + const FullType({{{fullTypeArgs}}}), + () => {{{builderInstantiation}}}(), +{{/fullTypeArgs}} +{{^fullTypeArgs}} {{#isArray}} const FullType(Built{{#uniqueItems}}Set{{/uniqueItems}}{{^uniqueItems}}List{{/uniqueItems}}, [FullType{{#isNullable}}.nullable{{/isNullable}}({{dataType}})]), () => {{#uniqueItems}}Set{{/uniqueItems}}{{^uniqueItems}}List{{/uniqueItems}}Builder<{{dataType}}>(), @@ -31,6 +36,7 @@ Serializers serializers = (_$serializers.toBuilder(){{#builtValueSerializers}} const FullType(BuiltMap, [FullType(String), FullType{{#isNullable}}.nullable{{/isNullable}}({{dataType}})]), () => MapBuilder(), {{/isMap}} +{{/fullTypeArgs}} ){{/builtValueSerializers}} {{#models}}{{#model}}{{#vendorExtensions.x-is-parent}}..add({{classname}}.serializer) {{/vendorExtensions.x-is-parent}}{{/model}}{{/models}}..add(const OneOfSerializer()) diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/dart/dio/DartDioClientCodegenTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/dart/dio/DartDioClientCodegenTest.java index e19857605731..fe9a045cebbd 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/dart/dio/DartDioClientCodegenTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/dart/dio/DartDioClientCodegenTest.java @@ -142,6 +142,50 @@ public void testImportMappingsInSerializersAndBarrelFile() throws IOException { "package:my_api/src/model/custom_address.dart"); } + /** + * Regression test for missing BuilderFactory entries on container + * types reachable only via {@code additionalProperties}. + * + * Before the fix, a property like + * {@code Map>} (an object schema with + * {@code additionalProperties: { type: array, items: ... }}) ended + * up in the generated Dart class but no + * {@code addBuilderFactory(BuiltList, ...)} call was + * emitted in {@code serializers.dart}. built_value then failed at + * runtime with + * {@code Bad state: No builder factory for BuiltList}. + * + * The fix walks every model property's container tree and registers + * a factory for each nested layer. + */ + @Test + public void testNestedAdditionalPropertiesGetBuilderFactories() throws IOException { + File output = Files.createTempDirectory("test").toFile(); + output.deleteOnExit(); + + final CodegenConfigurator configurator = new CodegenConfigurator() + .setGeneratorName("dart-dio") + .setInputSpec("src/test/resources/3_0/dart-dio/built_value_additional_properties_factory.yaml") + .setOutputDir(output.getAbsolutePath().replace("\\", "/")); + + ClientOptInput opts = configurator.toClientOptInput(); + Generator generator = new DefaultGenerator().opts(opts); + List files = generator.generate(); + files.forEach(File::deleteOnExit); + + Path serializers = output.toPath().resolve("lib/src/serializers.dart"); + + // Inner container: List. + TestUtils.assertFileContains(serializers, + "const FullType(BuiltList, [FullType(Widget)]),", + "() => ListBuilder(),"); + + // Outer container: Map>. + TestUtils.assertFileContains(serializers, + "const FullType(BuiltMap, [FullType(String), FullType(BuiltList, [FullType(Widget)])]),", + "() => MapBuilder>(),"); + } + @Test public void verifyDartDioGeneratorRuns() throws IOException { File output = Files.createTempDirectory("test").toFile(); diff --git a/modules/openapi-generator/src/test/resources/3_0/dart-dio/built_value_additional_properties_factory.yaml b/modules/openapi-generator/src/test/resources/3_0/dart-dio/built_value_additional_properties_factory.yaml new file mode 100644 index 000000000000..9ada6354eefd --- /dev/null +++ b/modules/openapi-generator/src/test/resources/3_0/dart-dio/built_value_additional_properties_factory.yaml @@ -0,0 +1,51 @@ +openapi: "3.0.0" +info: + title: Built-value additionalProperties BuilderFactory test + version: "1.0.0" +paths: + /catalog/{id}: + get: + operationId: getCatalog + parameters: + - name: id + in: path + required: true + schema: { type: integer } + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/Catalog" +components: + schemas: + Widget: + type: object + required: + - id + - name + properties: + id: + type: integer + name: + type: string + # `additionalProperties: { type: array, items: ... }` is the canonical + # "map of array of $ref" shape. Without the BuilderFactory walk, + # deserialization throws + # Bad state: No builder factory for BuiltList + WidgetsByCategory: + type: object + additionalProperties: + type: array + items: + $ref: "#/components/schemas/Widget" + Catalog: + type: object + required: + - id + properties: + id: + type: integer + widgetsByCategory: + $ref: "#/components/schemas/WidgetsByCategory" From 70521cdf1d5477a17b9475fabaa5d1623f1905e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antoine=20Le=20D=C3=BB?= Date: Thu, 30 Apr 2026 08:09:17 +0200 Subject: [PATCH 2/2] fix duplicate builder factories and regenerate petstore sample --- .../languages/DartDioClientCodegen.java | 29 +++++-- .../lib/src/serializers.dart | 76 +++++++++++++++++-- 2 files changed, 91 insertions(+), 14 deletions(-) diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/DartDioClientCodegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/DartDioClientCodegen.java index e0200395bdee..8daf4c9cc23e 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/DartDioClientCodegen.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/DartDioClientCodegen.java @@ -683,16 +683,29 @@ private void registerNestedBuilderFactories(CodegenProperty prop) { if (prop == null || !prop.isContainer || prop.items == null) { return; } - // Recurse first so deeper containers are registered too. Order - // doesn't matter for correctness (built_value resolves factories - // by FullType lookup), but it keeps the emitted list intuitive. + // Recurse first so deeper containers are registered too. registerNestedBuilderFactories(prop.items); - BuilderFactoryExpr expr = renderBuilderFactory(prop); - if (expr != null) { - addBuiltValueSerializer(BuiltValueSerializer.composite( - expr.fullTypeArgs, - expr.builderInstantiation)); + if (prop.items.isContainer) { + // Truly nested container (e.g. Map>): + // must use composite form because the simple constructor + // cannot express the nested FullType. + BuilderFactoryExpr expr = renderBuilderFactory(prop); + if (expr != null) { + addBuiltValueSerializer(BuiltValueSerializer.composite( + expr.fullTypeArgs, + expr.builderInstantiation)); + } + } else { + // Leaf container (e.g. List, Map): use the + // same simple constructor the rest of the codegen uses so + // the Set deduplicates correctly. + addBuiltValueSerializer(new BuiltValueSerializer( + prop.isArray, + prop.getUniqueItems(), + prop.isMap, + prop.items.isNullable, + prop.items.dataType)); } } diff --git a/samples/openapi3/client/petstore/dart-dio/petstore_client_lib_fake/lib/src/serializers.dart b/samples/openapi3/client/petstore/dart-dio/petstore_client_lib_fake/lib/src/serializers.dart index f55ca699ac78..4a1884a3eca2 100644 --- a/samples/openapi3/client/petstore/dart-dio/petstore_client_lib_fake/lib/src/serializers.dart +++ b/samples/openapi3/client/petstore/dart-dio/petstore_client_lib_fake/lib/src/serializers.dart @@ -136,6 +136,42 @@ Serializers serializers = (_$serializers.toBuilder() const FullType(BuiltMap, [FullType(String), FullType(String)]), () => MapBuilder(), ) + ..addBuilderFactory( + const FullType(BuiltList, [FullType(BuiltList, [FullType(int)])]), + () => ListBuilder>(), + ) + ..addBuilderFactory( + const FullType(BuiltMap, [FullType(String), FullType(num)]), + () => MapBuilder(), + ) + ..addBuilderFactory( + const FullType(BuiltMap, [FullType(String), FullType(Animal)]), + () => MapBuilder(), + ) + ..addBuilderFactory( + const FullType(BuiltList, [FullType(ReadOnlyFirst)]), + () => ListBuilder(), + ) + ..addBuilderFactory( + const FullType(BuiltList, [FullType(BuiltList, [FullType(ReadOnlyFirst)])]), + () => ListBuilder>(), + ) + ..addBuilderFactory( + const FullType(BuiltMap, [FullType(String), FullType(BuiltMap, [FullType(String), FullType(String)])]), + () => MapBuilder>(), + ) + ..addBuilderFactory( + const FullType(BuiltMap, [FullType(String), FullType(int)]), + () => MapBuilder(), + ) + ..addBuilderFactory( + const FullType(BuiltList, [FullType(BuiltList, [FullType(num)])]), + () => ListBuilder>(), + ) + ..addBuilderFactory( + const FullType(BuiltList, [FullType(num)]), + () => ListBuilder(), + ) ..addBuilderFactory( const FullType(BuiltList, [FullType(User)]), () => ListBuilder(), @@ -144,6 +180,10 @@ Serializers serializers = (_$serializers.toBuilder() const FullType(BuiltSet, [FullType(String)]), () => SetBuilder(), ) + ..addBuilderFactory( + const FullType(BuiltMap, [FullType(String), FullType(BuiltList, [FullType(int)])]), + () => MapBuilder>(), + ) ..addBuilderFactory( const FullType(BuiltSet, [FullType(Pet)]), () => SetBuilder(), @@ -153,21 +193,45 @@ Serializers serializers = (_$serializers.toBuilder() () => ListBuilder(), ) ..addBuilderFactory( - const FullType(BuiltMap, [FullType(String), FullType.nullable(JsonObject)]), - () => MapBuilder(), - ) - ..addBuilderFactory( - const FullType(BuiltMap, [FullType(String), FullType(int)]), - () => MapBuilder(), + const FullType(BuiltList, [FullType(JsonObject)]), + () => ListBuilder(), ) ..addBuilderFactory( const FullType(BuiltList, [FullType(ModelEnumClass)]), () => ListBuilder(), ) + ..addBuilderFactory( + const FullType(BuiltList, [FullType.nullable(JsonObject)]), + () => ListBuilder(), + ) + ..addBuilderFactory( + const FullType(BuiltList, [FullType(Tag)]), + () => ListBuilder(), + ) + ..addBuilderFactory( + const FullType(BuiltList, [FullType(ModelFile)]), + () => ListBuilder(), + ) + ..addBuilderFactory( + const FullType(BuiltList, [FullType(int)]), + () => ListBuilder(), + ) + ..addBuilderFactory( + const FullType(BuiltMap, [FullType(String), FullType.nullable(JsonObject)]), + () => MapBuilder(), + ) + ..addBuilderFactory( + const FullType(BuiltMap, [FullType(String), FullType(bool)]), + () => MapBuilder(), + ) ..addBuilderFactory( const FullType(BuiltList, [FullType(String)]), () => ListBuilder(), ) + ..addBuilderFactory( + const FullType(BuiltMap, [FullType(String), FullType(JsonObject)]), + () => MapBuilder(), + ) ..add(Animal.serializer) ..add(ParentWithNullable.serializer) ..add(const OneOfSerializer())