From c9e10444319219841c999d083ea9336471e1fb8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matou=C5=A1=20Dzivjak?= Date: Thu, 19 Feb 2026 12:27:43 +0100 Subject: [PATCH] feat: handle additional properties --- .../generator/additional_properties_test.go | 73 +++++++++++ codegen/internal/generator/model.go | 117 +++++++++++++++++- codegen/internal/generator/render.go | 1 + .../internal/generator/templates/model.tmpl | 31 +++++ codegen/internal/generator/types.go | 6 +- .../java/com/sumup/sdk/models/Problem.java | 49 +++++++- 6 files changed, 268 insertions(+), 9 deletions(-) create mode 100644 codegen/internal/generator/additional_properties_test.go diff --git a/codegen/internal/generator/additional_properties_test.go b/codegen/internal/generator/additional_properties_test.go new file mode 100644 index 0000000..a9637f5 --- /dev/null +++ b/codegen/internal/generator/additional_properties_test.go @@ -0,0 +1,73 @@ +package generator + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestGenerateModelWithPropertiesAndAdditionalProperties(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + specPath := filepath.Join(tmp, "openapi.json") + outputDir := filepath.Join(tmp, "src", "main", "java") + resourceDir := filepath.Join(tmp, "src", "main", "resources") + + spec := `{ + "openapi": "3.0.3", + "info": { + "title": "test", + "version": "1.0.0" + }, + "paths": {}, + "components": { + "schemas": { + "Problem": { + "type": "object", + "properties": { + "type": { "type": "string" }, + "title": { "type": "string" } + }, + "required": ["type"], + "additionalProperties": true + } + } + } +}` + if err := os.WriteFile(specPath, []byte(spec), 0o644); err != nil { + t.Fatalf("write spec: %v", err) + } + + params := Params{ + SpecPath: specPath, + OutputDir: outputDir, + ResourceDir: resourceDir, + BasePackage: "com.test.sdk", + } + if err := Run(context.Background(), params); err != nil { + t.Fatalf("run generator: %v", err) + } + + modelPath := filepath.Join(outputDir, "com", "test", "sdk", "models", "Problem.java") + content, err := os.ReadFile(modelPath) + if err != nil { + t.Fatalf("read generated model: %v", err) + } + generated := string(content) + + assertContains(t, generated, "@JsonDeserialize(builder = Problem.Builder.class)") + assertContains(t, generated, "java.util.Map additionalProperties") + assertContains(t, generated, "@JsonAnyGetter") + assertContains(t, generated, "@JsonAnySetter") + assertContains(t, generated, "public Builder additionalProperty(String name, Object value)") +} + +func assertContains(t *testing.T, content, want string) { + t.Helper() + if !strings.Contains(content, want) { + t.Fatalf("expected generated output to contain %q", want) + } +} diff --git a/codegen/internal/generator/model.go b/codegen/internal/generator/model.go index 74f43ea..773e61c 100644 --- a/codegen/internal/generator/model.go +++ b/codegen/internal/generator/model.go @@ -86,6 +86,7 @@ type schemaModel struct { Package string DescriptionLines []string Fields []schemaField + AdditionalProps *additionalPropertiesModel Imports []string HasRequired bool IsEnum bool @@ -101,6 +102,15 @@ type schemaField struct { ReadOnly bool } +// additionalPropertiesModel describes synthetic map storage used when object +// schemas define both fixed properties and additionalProperties. +type additionalPropertiesModel struct { + FieldName string + Type string + ValueType string + DescriptionLines []string +} + // enumValueModel captures a single enum constant and its wire value. type enumValueModel struct { Name string @@ -533,7 +543,10 @@ func buildSchemas(doc *v3.Document, params Params, resolver *typeResolver) []sch }) continue } - fields, imports, hasRequired := buildSchemaFields(name, ref, resolver) + fields, additionalProps, imports, hasRequired := buildSchemaFields(name, ref, resolver) + if additionalProps != nil { + imports = withAdditionalPropertiesImports(imports) + } if hasRequired { imports = uniqueStrings(append(imports, "java.util.Objects")) } @@ -543,6 +556,7 @@ func buildSchemas(doc *v3.Document, params Params, resolver *typeResolver) []sch Package: params.modelPackage(), DescriptionLines: splitComment(description), Fields: fields, + AdditionalProps: additionalProps, Imports: imports, HasRequired: hasRequired, } @@ -553,23 +567,24 @@ func buildSchemas(doc *v3.Document, params Params, resolver *typeResolver) []sch // buildSchemaFields inspects a schema proxy and returns the fields, required // imports, and a flag indicating whether any required properties exist. -func buildSchemaFields(name string, ref *base.SchemaProxy, resolver *typeResolver) ([]schemaField, []string, bool) { +func buildSchemaFields(name string, ref *base.SchemaProxy, resolver *typeResolver) ([]schemaField, *additionalPropertiesModel, []string, bool) { imports := map[string]struct{}{} var fields []schemaField + var additionalProps *additionalPropertiesModel hasRequired := false if ref == nil { - return []schemaField{{Name: "value", Type: "Object"}}, nil, false + return []schemaField{{Name: "value", Type: "Object"}}, nil, nil, false } schema := ref.Schema() if schema == nil { - return []schemaField{{Name: "value", Type: "Object"}}, nil, false + return []schemaField{{Name: "value", Type: "Object"}}, nil, nil, false } if schemaHasType(schema, "object") { props := collectProperties(schema) if len(props) == 0 { - return []schemaField{{Name: "value", Type: "java.util.Map"}}, []string{"java.util.Map"}, false + return []schemaField{{Name: "value", Type: "java.util.Map"}}, nil, []string{"java.util.Map"}, false } required := collectRequired(schema) fields = make([]schemaField, 0, len(props)) @@ -600,6 +615,23 @@ func buildSchemaFields(name string, ref *base.SchemaProxy, resolver *typeResolve hasRequired = true } } + additionalProps = resolveAdditionalProperties(schema, resolver, name) + if additionalProps != nil { + imports["java.util.Map"] = struct{}{} + valueTypeRef := additionalPropertiesTypedSchema(schema) + if valueTypeRef != nil { + valueJavaType := resolver.javaType(valueTypeRef, name, "AdditionalProperty") + for _, imp := range valueJavaType.Imports { + imports[imp] = struct{}{} + } + } + fields = append(fields, schemaField{ + Name: additionalProps.FieldName, + Type: additionalProps.Type, + DescriptionLines: additionalProps.DescriptionLines, + Required: false, + }) + } } else { javaType := resolver.javaType(ref, name) for _, imp := range javaType.Imports { @@ -611,7 +643,68 @@ func buildSchemaFields(name string, ref *base.SchemaProxy, resolver *typeResolve }} } - return fields, sortedImports(imports), hasRequired + return fields, additionalProps, sortedImports(imports), hasRequired +} + +// resolveAdditionalProperties returns metadata for schemas that allow +// additional fields alongside declared properties. +func resolveAdditionalProperties(schema *base.Schema, resolver *typeResolver, context ...string) *additionalPropertiesModel { + valueType, ok := additionalPropertiesValueType(schema, resolver, context...) + if !ok { + return nil + } + return &additionalPropertiesModel{ + FieldName: "additionalProperties", + Type: fmt.Sprintf("java.util.Map", valueType), + ValueType: valueType, + DescriptionLines: splitComment("Additional fields not described by the fixed schema properties."), + } +} + +// additionalPropertiesValueType reports whether additionalProperties is enabled +// and, when enabled, the expected Java value type. +func additionalPropertiesValueType(schema *base.Schema, resolver *typeResolver, context ...string) (string, bool) { + if schema == nil { + return "", false + } + if schema.AdditionalProperties != nil { + if schema.AdditionalProperties.IsA() && schema.AdditionalProperties.A != nil { + valueJavaType := resolver.javaType(schema.AdditionalProperties.A, append(context, "AdditionalProperty")...) + return valueJavaType.Name, true + } + if schema.AdditionalProperties.IsB() && schema.AdditionalProperties.B { + return "Object", true + } + } + for _, item := range schema.AllOf { + if item == nil { + continue + } + if valueType, ok := additionalPropertiesValueType(item.Schema(), resolver, context...); ok { + return valueType, true + } + } + return "", false +} + +// additionalPropertiesTypedSchema returns a schema reference when +// additionalProperties defines a concrete value schema. +func additionalPropertiesTypedSchema(schema *base.Schema) *base.SchemaProxy { + if schema == nil { + return nil + } + if schema.AdditionalProperties != nil && schema.AdditionalProperties.IsA() && schema.AdditionalProperties.A != nil { + return schema.AdditionalProperties.A + } + for _, item := range schema.AllOf { + if item == nil { + continue + } + if nested := additionalPropertiesTypedSchema(item.Schema()); nested != nil { + return nested + } + } + return nil } // enumValuesForSchema extracts enum values for string schemas. @@ -759,3 +852,15 @@ func sortedImports(set map[string]struct{}) []string { sort.Strings(values) return values } + +// withAdditionalPropertiesImports ensures generated models that capture extra +// JSON fields include the Jackson and collection types used by the template. +func withAdditionalPropertiesImports(imports []string) []string { + return uniqueStrings(append(imports, + "com.fasterxml.jackson.annotation.JsonAnyGetter", + "com.fasterxml.jackson.annotation.JsonAnySetter", + "com.fasterxml.jackson.databind.annotation.JsonDeserialize", + "com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder", + "java.util.LinkedHashMap", + )) +} diff --git a/codegen/internal/generator/render.go b/codegen/internal/generator/render.go index b783f39..0e7428b 100644 --- a/codegen/internal/generator/render.go +++ b/codegen/internal/generator/render.go @@ -369,6 +369,7 @@ func prepareModelTemplateData(schema schemaModel) map[string]any { "Imports": schema.Imports, "DescriptionLines": schema.DescriptionLines, "Fields": schema.Fields, + "AdditionalProps": schema.AdditionalProps, "HasRequired": schema.HasRequired, "IsEnum": schema.IsEnum, "EnumValues": schema.EnumValues, diff --git a/codegen/internal/generator/templates/model.tmpl b/codegen/internal/generator/templates/model.tmpl index 7c81fcb..f6f639e 100644 --- a/codegen/internal/generator/templates/model.tmpl +++ b/codegen/internal/generator/templates/model.tmpl @@ -47,6 +47,9 @@ public enum {{ .ClassName }} { } } {{- else }} +{{- if .AdditionalProps }} +@JsonDeserialize(builder = {{ .ClassName }}.Builder.class) +{{- end }} public record {{ .ClassName }}( {{- if .Fields }} {{- range $index, $field := .Fields }}{{ if $index }}, @@ -62,6 +65,19 @@ public record {{ .ClassName }}( Object value {{- end }} ) { +{{- if .AdditionalProps }} + public {{ .ClassName }} { + additionalProperties = additionalProperties == null + ? java.util.Map.of() + : java.util.Map.copyOf(additionalProperties); + } + + @JsonAnyGetter + public {{ .AdditionalProps.Type }} additionalProperties() { + return additionalProperties; + } + +{{- end }} /** * Creates a builder for {{ .ClassName }}. * @@ -74,13 +90,20 @@ public record {{ .ClassName }}( /** * Builder for {{ .ClassName }} instances. */ +{{- if .AdditionalProps }} + @JsonPOJOBuilder(withPrefix = "") +{{- end }} public static final class Builder { {{- if .Fields }} {{- range .Fields }} {{- if not .ReadOnly }} +{{- if and $.AdditionalProps (eq .Name $.AdditionalProps.FieldName) }} + private {{ .Type }} {{ .Name }} = new java.util.LinkedHashMap<>(); +{{- else }} private {{ .Type }} {{ .Name }}; {{- end }} {{- end }} +{{- end }} {{- else }} private Object value; {{- end }} @@ -114,6 +137,14 @@ public record {{ .ClassName }}( return this; } {{- end }} +{{- if .AdditionalProps }} + + @JsonAnySetter + public Builder additionalProperty(String name, {{ .AdditionalProps.ValueType }} value) { + this.additionalProperties.put(name, value); + return this; + } +{{- end }} /** * Builds an immutable {{ $.ClassName }} instance. diff --git a/codegen/internal/generator/types.go b/codegen/internal/generator/types.go index f366e2b..59816ab 100644 --- a/codegen/internal/generator/types.go +++ b/codegen/internal/generator/types.go @@ -345,7 +345,10 @@ func (r *typeResolver) inlineSchemaModels(params Params) []schemaModel { added = true continue } - fields, imports, hasRequired := buildSchemaFields(info.className, base.CreateSchemaProxy(info.schema), r) + fields, additionalProps, imports, hasRequired := buildSchemaFields(info.className, base.CreateSchemaProxy(info.schema), r) + if additionalProps != nil { + imports = withAdditionalPropertiesImports(imports) + } if hasRequired { imports = uniqueStrings(append(imports, "java.util.Objects")) } @@ -355,6 +358,7 @@ func (r *typeResolver) inlineSchemaModels(params Params) []schemaModel { Package: params.modelPackage(), DescriptionLines: splitComment(info.description), Fields: fields, + AdditionalProps: additionalProps, Imports: imports, HasRequired: hasRequired, }) diff --git a/src/main/java/com/sumup/sdk/models/Problem.java b/src/main/java/com/sumup/sdk/models/Problem.java index c29a6f9..820a6f9 100644 --- a/src/main/java/com/sumup/sdk/models/Problem.java +++ b/src/main/java/com/sumup/sdk/models/Problem.java @@ -1,12 +1,17 @@ // Code generated by sumup-java/codegen. DO NOT EDIT. package com.sumup.sdk.models; +import com.fasterxml.jackson.annotation.JsonAnyGetter; +import com.fasterxml.jackson.annotation.JsonAnySetter; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder; import java.util.Objects; /** * A RFC 9457 problem details object. Additional properties specific to the problem type may be * present. */ +@JsonDeserialize(builder = Problem.Builder.class) public record Problem( /** A human-readable explanation specific to this occurrence of the problem. */ String detail, @@ -21,7 +26,22 @@ public record Problem( String title, /** A URI reference that identifies the problem type. */ - String type) { + String type, + + /** Additional fields not described by the fixed schema properties. */ + java.util.Map additionalProperties) { + public Problem { + additionalProperties = + additionalProperties == null + ? java.util.Map.of() + : java.util.Map.copyOf(additionalProperties); + } + + @JsonAnyGetter + public java.util.Map additionalProperties() { + return additionalProperties; + } + /** * Creates a builder for Problem. * @@ -32,12 +52,14 @@ public static Builder builder() { } /** Builder for Problem instances. */ + @JsonPOJOBuilder(withPrefix = "") public static final class Builder { private String detail; private String instance; private Long status; private String title; private String type; + private java.util.Map additionalProperties = new java.util.LinkedHashMap<>(); private Builder() {} @@ -97,13 +119,36 @@ public Builder type(String type) { return this; } + /** + * Sets the value for {@code additionalProperties}. + * + * @param additionalProperties Additional fields not described by the fixed schema properties. + * @return This builder instance. + */ + public Builder additionalProperties(java.util.Map additionalProperties) { + this.additionalProperties = additionalProperties; + return this; + } + + @JsonAnySetter + public Builder additionalProperty(String name, Object value) { + this.additionalProperties.put(name, value); + return this; + } + /** * Builds an immutable Problem instance. * * @return Immutable Problem. */ public Problem build() { - return new Problem(detail, instance, status, title, Objects.requireNonNull(type, "type")); + return new Problem( + detail, + instance, + status, + title, + Objects.requireNonNull(type, "type"), + additionalProperties); } } }