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..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 @@ -660,10 +660,134 @@ 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. + registerNestedBuilderFactories(prop.items); + + 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)); + } + } + + /** + * 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 +941,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 +999,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" 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())