Skip to content

Commit daf6302

Browse files
committed
register BuilderFactory for nested additionalProperties shapes
For a property like `{ additionalProperties: { type: array, items: { $ref: ... } } }` the generator emits `Map<String, BuiltList<X>>` in the Dart class but never registers the `BuiltList<X>` 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<X>` 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<String, List<X>>`, `List<Map<String, X>>`, `Map<String, Map<String, X>>`, `List<List<X>>`, `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<String, BuiltList<X>>` (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<String, List<$ref>>` shape, and a new test `DartDioClientCodegenTest.testNestedAdditionalPropertiesGetBuilderFactories` asserts both the inner `BuiltList<WatchProviderEntry>` and the outer `BuiltMap<String, BuiltList<WatchProviderEntry>>` factories appear in the generated `serializers.dart`. Full Dart suite: 115 tests, 0 failures, 0 regressions.
1 parent 8a65919 commit daf6302

4 files changed

Lines changed: 252 additions & 0 deletions

File tree

modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/DartDioClientCodegen.java

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -660,10 +660,121 @@ public void postProcessModelProperty(CodegenModel model, CodegenProperty propert
660660
items.getAdditionalProperties().dataType
661661
));
662662
}
663+
664+
// Recursively register builder factories for every nested
665+
// container reachable from this property. Without this step,
666+
// shapes like Map<String, List<X>> (e.g. an OpenAPI object
667+
// with `additionalProperties: { type: array, items: ...}`)
668+
// never get a `BuiltList<X>` factory registered, and
669+
// built_value throws "No builder factory for BuiltList<X>"
670+
// at deserialization time.
671+
registerNestedBuilderFactories(property);
663672
}
664673
}
665674
}
666675

676+
/**
677+
* Walk the CodegenProperty tree and register a built_value
678+
* BuilderFactory for every container node, including the top one.
679+
* Handles arbitrary nesting like {@code Map<String, List<X>>},
680+
* {@code List<Map<String, X>>}, {@code List<List<X>>}, etc.
681+
*/
682+
private void registerNestedBuilderFactories(CodegenProperty prop) {
683+
if (prop == null || !prop.isContainer || prop.items == null) {
684+
return;
685+
}
686+
// Recurse first so deeper containers are registered too. Order
687+
// doesn't matter for correctness (built_value resolves factories
688+
// by FullType lookup), but it keeps the emitted list intuitive.
689+
registerNestedBuilderFactories(prop.items);
690+
691+
BuilderFactoryExpr expr = renderBuilderFactory(prop);
692+
if (expr != null) {
693+
addBuiltValueSerializer(BuiltValueSerializer.composite(
694+
expr.fullTypeArgs,
695+
expr.builderInstantiation));
696+
}
697+
}
698+
699+
/**
700+
* Render the FullType argument list and the matching Builder
701+
* instantiation for a container CodegenProperty.
702+
*
703+
* @return null if {@code prop} is not a container we can render.
704+
*/
705+
private BuilderFactoryExpr renderBuilderFactory(CodegenProperty prop) {
706+
if (prop == null || !prop.isContainer || prop.items == null) {
707+
return null;
708+
}
709+
String innerFullType = renderInnerFullType(prop.items);
710+
String innerDart = renderDartType(prop.items);
711+
712+
if (prop.isArray) {
713+
String collection = prop.getUniqueItems() ? "BuiltSet" : "BuiltList";
714+
String builder = prop.getUniqueItems() ? "SetBuilder" : "ListBuilder";
715+
return new BuilderFactoryExpr(
716+
collection + ", [FullType(" + innerFullType + ")]",
717+
builder + "<" + innerDart + ">");
718+
}
719+
if (prop.isMap) {
720+
return new BuilderFactoryExpr(
721+
"BuiltMap, [FullType(String), FullType(" + innerFullType + ")]",
722+
"MapBuilder<String, " + innerDart + ">");
723+
}
724+
return null;
725+
}
726+
727+
/**
728+
* What goes inside {@code FullType(...)} for this property:
729+
* a leaf type name like {@code "Foo"}, or a nested expression like
730+
* {@code "BuiltList, [FullType(Foo)]"}.
731+
*/
732+
private String renderInnerFullType(CodegenProperty prop) {
733+
if (prop == null) return "dynamic";
734+
if (!prop.isContainer || prop.items == null) {
735+
return prop.dataType;
736+
}
737+
String inner = renderInnerFullType(prop.items);
738+
if (prop.isArray) {
739+
String collection = prop.getUniqueItems() ? "BuiltSet" : "BuiltList";
740+
return collection + ", [FullType(" + inner + ")]";
741+
}
742+
if (prop.isMap) {
743+
return "BuiltMap, [FullType(String), FullType(" + inner + ")]";
744+
}
745+
return prop.dataType;
746+
}
747+
748+
/**
749+
* Render the Dart type literal (e.g. {@code BuiltMap<String, BuiltList<Foo>>})
750+
* used inside the {@code () => XBuilder<...>()} lambda.
751+
*/
752+
private String renderDartType(CodegenProperty prop) {
753+
if (prop == null) return "dynamic";
754+
if (!prop.isContainer || prop.items == null) {
755+
return prop.dataType;
756+
}
757+
String inner = renderDartType(prop.items);
758+
if (prop.isArray) {
759+
String collection = prop.getUniqueItems() ? "BuiltSet" : "BuiltList";
760+
return collection + "<" + inner + ">";
761+
}
762+
if (prop.isMap) {
763+
return "BuiltMap<String, " + inner + ">";
764+
}
765+
return prop.dataType;
766+
}
767+
768+
private static final class BuilderFactoryExpr {
769+
final String fullTypeArgs;
770+
final String builderInstantiation;
771+
772+
BuilderFactoryExpr(String fullTypeArgs, String builderInstantiation) {
773+
this.fullTypeArgs = fullTypeArgs;
774+
this.builderInstantiation = builderInstantiation;
775+
}
776+
}
777+
667778
@Override
668779
public OperationsMap postProcessOperationsWithModels(OperationsMap objs, List<ModelMap> allModels) {
669780
super.postProcessOperationsWithModels(objs, allModels);
@@ -817,12 +928,45 @@ static class BuiltValueSerializer {
817928

818929
@Getter final String dataType;
819930

931+
// When non-null, the serializer is rendered verbatim from these
932+
// pre-computed strings instead of being dispatched through the
933+
// isArray/isMap branches in serializers.mustache. Used for
934+
// arbitrarily nested container types where dataType alone can't
935+
// express the FullType expression (e.g. Map<String, List<X>>).
936+
@Getter final String fullTypeArgs;
937+
938+
@Getter final String builderInstantiation;
939+
820940
private BuiltValueSerializer(boolean isArray, boolean uniqueItems, boolean isMap, boolean isNullable, String dataType) {
821941
this.isArray = isArray;
822942
this.uniqueItems = uniqueItems;
823943
this.isMap = isMap;
824944
this.isNullable = isNullable;
825945
this.dataType = dataType;
946+
this.fullTypeArgs = null;
947+
this.builderInstantiation = null;
948+
}
949+
950+
private BuiltValueSerializer(String fullTypeArgs, String builderInstantiation) {
951+
this.isArray = false;
952+
this.uniqueItems = false;
953+
this.isMap = false;
954+
this.isNullable = false;
955+
this.dataType = "";
956+
this.fullTypeArgs = fullTypeArgs;
957+
this.builderInstantiation = builderInstantiation;
958+
}
959+
960+
/**
961+
* Build a serializer for a nested-container BuilderFactory whose
962+
* type signature can't be expressed by the simple
963+
* (isArray, isMap, dataType) form. {@code fullTypeArgs} is the
964+
* argument list for {@code FullType(...)} (without the wrapping
965+
* call) and {@code builderInstantiation} is the inside of
966+
* {@code () => ...()}.
967+
*/
968+
public static BuiltValueSerializer composite(String fullTypeArgs, String builderInstantiation) {
969+
return new BuiltValueSerializer(fullTypeArgs, builderInstantiation);
826970
}
827971

828972
public boolean isArray() {
@@ -842,11 +986,18 @@ public boolean equals(Object o) {
842986
if (this == o) return true;
843987
if (o == null || getClass() != o.getClass()) return false;
844988
BuiltValueSerializer that = (BuiltValueSerializer) o;
989+
if (fullTypeArgs != null || that.fullTypeArgs != null) {
990+
return Objects.equals(fullTypeArgs, that.fullTypeArgs)
991+
&& Objects.equals(builderInstantiation, that.builderInstantiation);
992+
}
845993
return isArray == that.isArray && uniqueItems == that.uniqueItems && isMap == that.isMap && isNullable == that.isNullable && dataType.equals(that.dataType);
846994
}
847995

848996
@Override
849997
public int hashCode() {
998+
if (fullTypeArgs != null) {
999+
return Objects.hash(fullTypeArgs, builderInstantiation);
1000+
}
8501001
return Objects.hash(isArray, uniqueItems, isMap, isNullable, dataType);
8511002
}
8521003
}

modules/openapi-generator/src/main/resources/dart/libraries/dio/serialization/built_value/serializers.mustache

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,11 @@ part 'serializers.g.dart';
2323
])
2424
Serializers serializers = (_$serializers.toBuilder(){{#builtValueSerializers}}
2525
..addBuilderFactory(
26+
{{#fullTypeArgs}}
27+
const FullType({{{fullTypeArgs}}}),
28+
() => {{{builderInstantiation}}}(),
29+
{{/fullTypeArgs}}
30+
{{^fullTypeArgs}}
2631
{{#isArray}}
2732
const FullType(Built{{#uniqueItems}}Set{{/uniqueItems}}{{^uniqueItems}}List{{/uniqueItems}}, [FullType{{#isNullable}}.nullable{{/isNullable}}({{dataType}})]),
2833
() => {{#uniqueItems}}Set{{/uniqueItems}}{{^uniqueItems}}List{{/uniqueItems}}Builder<{{dataType}}>(),
@@ -31,6 +36,7 @@ Serializers serializers = (_$serializers.toBuilder(){{#builtValueSerializers}}
3136
const FullType(BuiltMap, [FullType(String), FullType{{#isNullable}}.nullable{{/isNullable}}({{dataType}})]),
3237
() => MapBuilder<String, {{dataType}}{{#isNullable}}?{{/isNullable}}>(),
3338
{{/isMap}}
39+
{{/fullTypeArgs}}
3440
){{/builtValueSerializers}}
3541
{{#models}}{{#model}}{{#vendorExtensions.x-is-parent}}..add({{classname}}.serializer)
3642
{{/vendorExtensions.x-is-parent}}{{/model}}{{/models}}..add(const OneOfSerializer())

modules/openapi-generator/src/test/java/org/openapitools/codegen/dart/dio/DartDioClientCodegenTest.java

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,50 @@ public void testImportMappingsInSerializersAndBarrelFile() throws IOException {
142142
"package:my_api/src/model/custom_address.dart");
143143
}
144144

145+
/**
146+
* Regression test for missing BuilderFactory entries on container
147+
* types reachable only via {@code additionalProperties}.
148+
*
149+
* Before the fix, a property like
150+
* {@code Map<String, List<Widget>>} (an object schema with
151+
* {@code additionalProperties: { type: array, items: ... }}) ended
152+
* up in the generated Dart class but no
153+
* {@code addBuilderFactory(BuiltList<Widget>, ...)} call was
154+
* emitted in {@code serializers.dart}. built_value then failed at
155+
* runtime with
156+
* {@code Bad state: No builder factory for BuiltList<Widget>}.
157+
*
158+
* The fix walks every model property's container tree and registers
159+
* a factory for each nested layer.
160+
*/
161+
@Test
162+
public void testNestedAdditionalPropertiesGetBuilderFactories() throws IOException {
163+
File output = Files.createTempDirectory("test").toFile();
164+
output.deleteOnExit();
165+
166+
final CodegenConfigurator configurator = new CodegenConfigurator()
167+
.setGeneratorName("dart-dio")
168+
.setInputSpec("src/test/resources/3_0/dart-dio/built_value_additional_properties_factory.yaml")
169+
.setOutputDir(output.getAbsolutePath().replace("\\", "/"));
170+
171+
ClientOptInput opts = configurator.toClientOptInput();
172+
Generator generator = new DefaultGenerator().opts(opts);
173+
List<File> files = generator.generate();
174+
files.forEach(File::deleteOnExit);
175+
176+
Path serializers = output.toPath().resolve("lib/src/serializers.dart");
177+
178+
// Inner container: List<Widget>.
179+
TestUtils.assertFileContains(serializers,
180+
"const FullType(BuiltList, [FullType(Widget)]),",
181+
"() => ListBuilder<Widget>(),");
182+
183+
// Outer container: Map<String, List<Widget>>.
184+
TestUtils.assertFileContains(serializers,
185+
"const FullType(BuiltMap, [FullType(String), FullType(BuiltList, [FullType(Widget)])]),",
186+
"() => MapBuilder<String, BuiltList<Widget>>(),");
187+
}
188+
145189
@Test
146190
public void verifyDartDioGeneratorRuns() throws IOException {
147191
File output = Files.createTempDirectory("test").toFile();
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
openapi: "3.0.0"
2+
info:
3+
title: Built-value additionalProperties BuilderFactory test
4+
version: "1.0.0"
5+
paths:
6+
/catalog/{id}:
7+
get:
8+
operationId: getCatalog
9+
parameters:
10+
- name: id
11+
in: path
12+
required: true
13+
schema: { type: integer }
14+
responses:
15+
"200":
16+
description: OK
17+
content:
18+
application/json:
19+
schema:
20+
$ref: "#/components/schemas/Catalog"
21+
components:
22+
schemas:
23+
Widget:
24+
type: object
25+
required:
26+
- id
27+
- name
28+
properties:
29+
id:
30+
type: integer
31+
name:
32+
type: string
33+
# `additionalProperties: { type: array, items: ... }` is the canonical
34+
# "map of array of $ref" shape. Without the BuilderFactory walk,
35+
# deserialization throws
36+
# Bad state: No builder factory for BuiltList<Widget>
37+
WidgetsByCategory:
38+
type: object
39+
additionalProperties:
40+
type: array
41+
items:
42+
$ref: "#/components/schemas/Widget"
43+
Catalog:
44+
type: object
45+
required:
46+
- id
47+
properties:
48+
id:
49+
type: integer
50+
widgetsByCategory:
51+
$ref: "#/components/schemas/WidgetsByCategory"

0 commit comments

Comments
 (0)