Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions codegen/internal/generator/additional_properties_test.go
Original file line number Diff line number Diff line change
@@ -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<String, Object> 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)
}
}
117 changes: 111 additions & 6 deletions codegen/internal/generator/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ type schemaModel struct {
Package string
DescriptionLines []string
Fields []schemaField
AdditionalProps *additionalPropertiesModel
Imports []string
HasRequired bool
IsEnum bool
Expand All @@ -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
Expand Down Expand Up @@ -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"))
}
Expand All @@ -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,
}
Expand All @@ -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, Object>"}}, []string{"java.util.Map"}, false
return []schemaField{{Name: "value", Type: "java.util.Map<String, Object>"}}, nil, []string{"java.util.Map"}, false
}
required := collectRequired(schema)
fields = make([]schemaField, 0, len(props))
Expand Down Expand Up @@ -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 {
Expand All @@ -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<String, %s>", 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.
Expand Down Expand Up @@ -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",
))
}
1 change: 1 addition & 0 deletions codegen/internal/generator/render.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
31 changes: 31 additions & 0 deletions codegen/internal/generator/templates/model.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -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 }},
Expand All @@ -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 }}.
*
Expand All @@ -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 }}
Expand Down Expand Up @@ -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.
Expand Down
6 changes: 5 additions & 1 deletion codegen/internal/generator/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
}
Expand All @@ -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,
})
Expand Down
Loading
Loading