Skip to content

Commit c7b9894

Browse files
authored
feat: handle additional properties (#38)
1 parent 78b9e47 commit c7b9894

6 files changed

Lines changed: 268 additions & 9 deletions

File tree

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
package generator
2+
3+
import (
4+
"context"
5+
"os"
6+
"path/filepath"
7+
"strings"
8+
"testing"
9+
)
10+
11+
func TestGenerateModelWithPropertiesAndAdditionalProperties(t *testing.T) {
12+
t.Parallel()
13+
14+
tmp := t.TempDir()
15+
specPath := filepath.Join(tmp, "openapi.json")
16+
outputDir := filepath.Join(tmp, "src", "main", "java")
17+
resourceDir := filepath.Join(tmp, "src", "main", "resources")
18+
19+
spec := `{
20+
"openapi": "3.0.3",
21+
"info": {
22+
"title": "test",
23+
"version": "1.0.0"
24+
},
25+
"paths": {},
26+
"components": {
27+
"schemas": {
28+
"Problem": {
29+
"type": "object",
30+
"properties": {
31+
"type": { "type": "string" },
32+
"title": { "type": "string" }
33+
},
34+
"required": ["type"],
35+
"additionalProperties": true
36+
}
37+
}
38+
}
39+
}`
40+
if err := os.WriteFile(specPath, []byte(spec), 0o644); err != nil {
41+
t.Fatalf("write spec: %v", err)
42+
}
43+
44+
params := Params{
45+
SpecPath: specPath,
46+
OutputDir: outputDir,
47+
ResourceDir: resourceDir,
48+
BasePackage: "com.test.sdk",
49+
}
50+
if err := Run(context.Background(), params); err != nil {
51+
t.Fatalf("run generator: %v", err)
52+
}
53+
54+
modelPath := filepath.Join(outputDir, "com", "test", "sdk", "models", "Problem.java")
55+
content, err := os.ReadFile(modelPath)
56+
if err != nil {
57+
t.Fatalf("read generated model: %v", err)
58+
}
59+
generated := string(content)
60+
61+
assertContains(t, generated, "@JsonDeserialize(builder = Problem.Builder.class)")
62+
assertContains(t, generated, "java.util.Map<String, Object> additionalProperties")
63+
assertContains(t, generated, "@JsonAnyGetter")
64+
assertContains(t, generated, "@JsonAnySetter")
65+
assertContains(t, generated, "public Builder additionalProperty(String name, Object value)")
66+
}
67+
68+
func assertContains(t *testing.T, content, want string) {
69+
t.Helper()
70+
if !strings.Contains(content, want) {
71+
t.Fatalf("expected generated output to contain %q", want)
72+
}
73+
}

codegen/internal/generator/model.go

Lines changed: 111 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ type schemaModel struct {
8686
Package string
8787
DescriptionLines []string
8888
Fields []schemaField
89+
AdditionalProps *additionalPropertiesModel
8990
Imports []string
9091
HasRequired bool
9192
IsEnum bool
@@ -101,6 +102,15 @@ type schemaField struct {
101102
ReadOnly bool
102103
}
103104

105+
// additionalPropertiesModel describes synthetic map storage used when object
106+
// schemas define both fixed properties and additionalProperties.
107+
type additionalPropertiesModel struct {
108+
FieldName string
109+
Type string
110+
ValueType string
111+
DescriptionLines []string
112+
}
113+
104114
// enumValueModel captures a single enum constant and its wire value.
105115
type enumValueModel struct {
106116
Name string
@@ -533,7 +543,10 @@ func buildSchemas(doc *v3.Document, params Params, resolver *typeResolver) []sch
533543
})
534544
continue
535545
}
536-
fields, imports, hasRequired := buildSchemaFields(name, ref, resolver)
546+
fields, additionalProps, imports, hasRequired := buildSchemaFields(name, ref, resolver)
547+
if additionalProps != nil {
548+
imports = withAdditionalPropertiesImports(imports)
549+
}
537550
if hasRequired {
538551
imports = uniqueStrings(append(imports, "java.util.Objects"))
539552
}
@@ -543,6 +556,7 @@ func buildSchemas(doc *v3.Document, params Params, resolver *typeResolver) []sch
543556
Package: params.modelPackage(),
544557
DescriptionLines: splitComment(description),
545558
Fields: fields,
559+
AdditionalProps: additionalProps,
546560
Imports: imports,
547561
HasRequired: hasRequired,
548562
}
@@ -553,23 +567,24 @@ func buildSchemas(doc *v3.Document, params Params, resolver *typeResolver) []sch
553567

554568
// buildSchemaFields inspects a schema proxy and returns the fields, required
555569
// imports, and a flag indicating whether any required properties exist.
556-
func buildSchemaFields(name string, ref *base.SchemaProxy, resolver *typeResolver) ([]schemaField, []string, bool) {
570+
func buildSchemaFields(name string, ref *base.SchemaProxy, resolver *typeResolver) ([]schemaField, *additionalPropertiesModel, []string, bool) {
557571
imports := map[string]struct{}{}
558572
var fields []schemaField
573+
var additionalProps *additionalPropertiesModel
559574
hasRequired := false
560575

561576
if ref == nil {
562-
return []schemaField{{Name: "value", Type: "Object"}}, nil, false
577+
return []schemaField{{Name: "value", Type: "Object"}}, nil, nil, false
563578
}
564579

565580
schema := ref.Schema()
566581
if schema == nil {
567-
return []schemaField{{Name: "value", Type: "Object"}}, nil, false
582+
return []schemaField{{Name: "value", Type: "Object"}}, nil, nil, false
568583
}
569584
if schemaHasType(schema, "object") {
570585
props := collectProperties(schema)
571586
if len(props) == 0 {
572-
return []schemaField{{Name: "value", Type: "java.util.Map<String, Object>"}}, []string{"java.util.Map"}, false
587+
return []schemaField{{Name: "value", Type: "java.util.Map<String, Object>"}}, nil, []string{"java.util.Map"}, false
573588
}
574589
required := collectRequired(schema)
575590
fields = make([]schemaField, 0, len(props))
@@ -600,6 +615,23 @@ func buildSchemaFields(name string, ref *base.SchemaProxy, resolver *typeResolve
600615
hasRequired = true
601616
}
602617
}
618+
additionalProps = resolveAdditionalProperties(schema, resolver, name)
619+
if additionalProps != nil {
620+
imports["java.util.Map"] = struct{}{}
621+
valueTypeRef := additionalPropertiesTypedSchema(schema)
622+
if valueTypeRef != nil {
623+
valueJavaType := resolver.javaType(valueTypeRef, name, "AdditionalProperty")
624+
for _, imp := range valueJavaType.Imports {
625+
imports[imp] = struct{}{}
626+
}
627+
}
628+
fields = append(fields, schemaField{
629+
Name: additionalProps.FieldName,
630+
Type: additionalProps.Type,
631+
DescriptionLines: additionalProps.DescriptionLines,
632+
Required: false,
633+
})
634+
}
603635
} else {
604636
javaType := resolver.javaType(ref, name)
605637
for _, imp := range javaType.Imports {
@@ -611,7 +643,68 @@ func buildSchemaFields(name string, ref *base.SchemaProxy, resolver *typeResolve
611643
}}
612644
}
613645

614-
return fields, sortedImports(imports), hasRequired
646+
return fields, additionalProps, sortedImports(imports), hasRequired
647+
}
648+
649+
// resolveAdditionalProperties returns metadata for schemas that allow
650+
// additional fields alongside declared properties.
651+
func resolveAdditionalProperties(schema *base.Schema, resolver *typeResolver, context ...string) *additionalPropertiesModel {
652+
valueType, ok := additionalPropertiesValueType(schema, resolver, context...)
653+
if !ok {
654+
return nil
655+
}
656+
return &additionalPropertiesModel{
657+
FieldName: "additionalProperties",
658+
Type: fmt.Sprintf("java.util.Map<String, %s>", valueType),
659+
ValueType: valueType,
660+
DescriptionLines: splitComment("Additional fields not described by the fixed schema properties."),
661+
}
662+
}
663+
664+
// additionalPropertiesValueType reports whether additionalProperties is enabled
665+
// and, when enabled, the expected Java value type.
666+
func additionalPropertiesValueType(schema *base.Schema, resolver *typeResolver, context ...string) (string, bool) {
667+
if schema == nil {
668+
return "", false
669+
}
670+
if schema.AdditionalProperties != nil {
671+
if schema.AdditionalProperties.IsA() && schema.AdditionalProperties.A != nil {
672+
valueJavaType := resolver.javaType(schema.AdditionalProperties.A, append(context, "AdditionalProperty")...)
673+
return valueJavaType.Name, true
674+
}
675+
if schema.AdditionalProperties.IsB() && schema.AdditionalProperties.B {
676+
return "Object", true
677+
}
678+
}
679+
for _, item := range schema.AllOf {
680+
if item == nil {
681+
continue
682+
}
683+
if valueType, ok := additionalPropertiesValueType(item.Schema(), resolver, context...); ok {
684+
return valueType, true
685+
}
686+
}
687+
return "", false
688+
}
689+
690+
// additionalPropertiesTypedSchema returns a schema reference when
691+
// additionalProperties defines a concrete value schema.
692+
func additionalPropertiesTypedSchema(schema *base.Schema) *base.SchemaProxy {
693+
if schema == nil {
694+
return nil
695+
}
696+
if schema.AdditionalProperties != nil && schema.AdditionalProperties.IsA() && schema.AdditionalProperties.A != nil {
697+
return schema.AdditionalProperties.A
698+
}
699+
for _, item := range schema.AllOf {
700+
if item == nil {
701+
continue
702+
}
703+
if nested := additionalPropertiesTypedSchema(item.Schema()); nested != nil {
704+
return nested
705+
}
706+
}
707+
return nil
615708
}
616709

617710
// enumValuesForSchema extracts enum values for string schemas.
@@ -759,3 +852,15 @@ func sortedImports(set map[string]struct{}) []string {
759852
sort.Strings(values)
760853
return values
761854
}
855+
856+
// withAdditionalPropertiesImports ensures generated models that capture extra
857+
// JSON fields include the Jackson and collection types used by the template.
858+
func withAdditionalPropertiesImports(imports []string) []string {
859+
return uniqueStrings(append(imports,
860+
"com.fasterxml.jackson.annotation.JsonAnyGetter",
861+
"com.fasterxml.jackson.annotation.JsonAnySetter",
862+
"com.fasterxml.jackson.databind.annotation.JsonDeserialize",
863+
"com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder",
864+
"java.util.LinkedHashMap",
865+
))
866+
}

codegen/internal/generator/render.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -369,6 +369,7 @@ func prepareModelTemplateData(schema schemaModel) map[string]any {
369369
"Imports": schema.Imports,
370370
"DescriptionLines": schema.DescriptionLines,
371371
"Fields": schema.Fields,
372+
"AdditionalProps": schema.AdditionalProps,
372373
"HasRequired": schema.HasRequired,
373374
"IsEnum": schema.IsEnum,
374375
"EnumValues": schema.EnumValues,

codegen/internal/generator/templates/model.tmpl

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,9 @@ public enum {{ .ClassName }} {
4747
}
4848
}
4949
{{- else }}
50+
{{- if .AdditionalProps }}
51+
@JsonDeserialize(builder = {{ .ClassName }}.Builder.class)
52+
{{- end }}
5053
public record {{ .ClassName }}(
5154
{{- if .Fields }}
5255
{{- range $index, $field := .Fields }}{{ if $index }},
@@ -62,6 +65,19 @@ public record {{ .ClassName }}(
6265
Object value
6366
{{- end }}
6467
) {
68+
{{- if .AdditionalProps }}
69+
public {{ .ClassName }} {
70+
additionalProperties = additionalProperties == null
71+
? java.util.Map.of()
72+
: java.util.Map.copyOf(additionalProperties);
73+
}
74+
75+
@JsonAnyGetter
76+
public {{ .AdditionalProps.Type }} additionalProperties() {
77+
return additionalProperties;
78+
}
79+
80+
{{- end }}
6581
/**
6682
* Creates a builder for {{ .ClassName }}.
6783
*
@@ -74,13 +90,20 @@ public record {{ .ClassName }}(
7490
/**
7591
* Builder for {{ .ClassName }} instances.
7692
*/
93+
{{- if .AdditionalProps }}
94+
@JsonPOJOBuilder(withPrefix = "")
95+
{{- end }}
7796
public static final class Builder {
7897
{{- if .Fields }}
7998
{{- range .Fields }}
8099
{{- if not .ReadOnly }}
100+
{{- if and $.AdditionalProps (eq .Name $.AdditionalProps.FieldName) }}
101+
private {{ .Type }} {{ .Name }} = new java.util.LinkedHashMap<>();
102+
{{- else }}
81103
private {{ .Type }} {{ .Name }};
82104
{{- end }}
83105
{{- end }}
106+
{{- end }}
84107
{{- else }}
85108
private Object value;
86109
{{- end }}
@@ -114,6 +137,14 @@ public record {{ .ClassName }}(
114137
return this;
115138
}
116139
{{- end }}
140+
{{- if .AdditionalProps }}
141+
142+
@JsonAnySetter
143+
public Builder additionalProperty(String name, {{ .AdditionalProps.ValueType }} value) {
144+
this.additionalProperties.put(name, value);
145+
return this;
146+
}
147+
{{- end }}
117148

118149
/**
119150
* Builds an immutable {{ $.ClassName }} instance.

codegen/internal/generator/types.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -345,7 +345,10 @@ func (r *typeResolver) inlineSchemaModels(params Params) []schemaModel {
345345
added = true
346346
continue
347347
}
348-
fields, imports, hasRequired := buildSchemaFields(info.className, base.CreateSchemaProxy(info.schema), r)
348+
fields, additionalProps, imports, hasRequired := buildSchemaFields(info.className, base.CreateSchemaProxy(info.schema), r)
349+
if additionalProps != nil {
350+
imports = withAdditionalPropertiesImports(imports)
351+
}
349352
if hasRequired {
350353
imports = uniqueStrings(append(imports, "java.util.Objects"))
351354
}
@@ -355,6 +358,7 @@ func (r *typeResolver) inlineSchemaModels(params Params) []schemaModel {
355358
Package: params.modelPackage(),
356359
DescriptionLines: splitComment(info.description),
357360
Fields: fields,
361+
AdditionalProps: additionalProps,
358362
Imports: imports,
359363
HasRequired: hasRequired,
360364
})

0 commit comments

Comments
 (0)