diff --git a/internal/test/aggregates/allof/allof.gen.go b/internal/test/aggregates/allof/allof.gen.go index c962e1eb4..3c83b63fc 100644 --- a/internal/test/aggregates/allof/allof.gen.go +++ b/internal/test/aggregates/allof/allof.gen.go @@ -6,6 +6,8 @@ package aggregatesallof import ( "encoding/json" "fmt" + + "github.com/oapi-codegen/runtime" ) // DefaultAdditional1 defines model for DefaultAdditional1. @@ -147,6 +149,22 @@ type MergeWithoutWithout struct { FieldB *string `json:"fieldB,omitempty"` } +// NestedOneOfInAllOf defines model for NestedOneOfInAllOf. +type NestedOneOfInAllOf struct { + AFoo *string `json:"a_foo,omitempty"` + union json.RawMessage +} + +// NestedOneOfInAllOf0 defines model for NestedOneOfInAllOf.0. +type NestedOneOfInAllOf0 struct { + AFooOneOf0 *string `json:"a_foo_one_of_0,omitempty"` +} + +// NestedOneOfInAllOf1 defines model for NestedOneOfInAllOf.1. +type NestedOneOfInAllOf1 struct { + AFooOneOf1 *string `json:"a_foo_one_of_1,omitempty"` +} + // PersonAllOfAdditional defines model for PersonAllOfAdditional. type PersonAllOfAdditional struct { Age *float32 `json:"age,omitempty"` @@ -1413,3 +1431,99 @@ func (a WithStringAdditional2) MarshalJSON() ([]byte, error) { } return json.Marshal(object) } + +// AsNestedOneOfInAllOf0 returns the union data inside the NestedOneOfInAllOf as a NestedOneOfInAllOf0 +func (t NestedOneOfInAllOf) AsNestedOneOfInAllOf0() (NestedOneOfInAllOf0, error) { + var body NestedOneOfInAllOf0 + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromNestedOneOfInAllOf0 overwrites any union data inside the NestedOneOfInAllOf as the provided NestedOneOfInAllOf0 +func (t *NestedOneOfInAllOf) FromNestedOneOfInAllOf0(v NestedOneOfInAllOf0) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeNestedOneOfInAllOf0 performs a merge with any union data inside the NestedOneOfInAllOf, using the provided NestedOneOfInAllOf0 +func (t *NestedOneOfInAllOf) MergeNestedOneOfInAllOf0(v NestedOneOfInAllOf0) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +// AsNestedOneOfInAllOf1 returns the union data inside the NestedOneOfInAllOf as a NestedOneOfInAllOf1 +func (t NestedOneOfInAllOf) AsNestedOneOfInAllOf1() (NestedOneOfInAllOf1, error) { + var body NestedOneOfInAllOf1 + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromNestedOneOfInAllOf1 overwrites any union data inside the NestedOneOfInAllOf as the provided NestedOneOfInAllOf1 +func (t *NestedOneOfInAllOf) FromNestedOneOfInAllOf1(v NestedOneOfInAllOf1) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeNestedOneOfInAllOf1 performs a merge with any union data inside the NestedOneOfInAllOf, using the provided NestedOneOfInAllOf1 +func (t *NestedOneOfInAllOf) MergeNestedOneOfInAllOf1(v NestedOneOfInAllOf1) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +func (t NestedOneOfInAllOf) MarshalJSON() ([]byte, error) { + b, err := t.union.MarshalJSON() + if err != nil { + return nil, err + } + object := make(map[string]json.RawMessage) + if t.union != nil { + err = json.Unmarshal(b, &object) + if err != nil { + return nil, err + } + } + + if t.AFoo != nil { + object["a_foo"], err = json.Marshal(t.AFoo) + if err != nil { + return nil, fmt.Errorf("error marshaling 'a_foo': %w", err) + } + } + b, err = json.Marshal(object) + return b, err +} + +func (t *NestedOneOfInAllOf) UnmarshalJSON(b []byte) error { + err := t.union.UnmarshalJSON(b) + if err != nil { + return err + } + object := make(map[string]json.RawMessage) + err = json.Unmarshal(b, &object) + if err != nil { + return err + } + + if raw, found := object["a_foo"]; found { + err = json.Unmarshal(raw, &t.AFoo) + if err != nil { + return fmt.Errorf("error reading 'a_foo': %w", err) + } + } + + return err +} diff --git a/internal/test/aggregates/allof/allof_test.go b/internal/test/aggregates/allof/allof_test.go index 8ae19be5c..8c3171d8f 100644 --- a/internal/test/aggregates/allof/allof_test.go +++ b/internal/test/aggregates/allof/allof_test.go @@ -7,6 +7,24 @@ import ( "github.com/stretchr/testify/assert" ) +// issue #1905: a oneOf nested inside allOf-of-allOf must survive the merge. +// The generated struct must have a union field, and the two variant types must exist. +func TestIssue1905(t *testing.T) { + typ := reflect.TypeOf(NestedOneOfInAllOf{}) + + // The merged struct must carry the flat property from the outer allOf member. + _, hasAFoo := typ.FieldByName("AFoo") + assert.True(t, hasAFoo, "NestedOneOfInAllOf should have AFoo field") + + // The union field signals that the oneOf was preserved. + _, hasUnion := typ.FieldByName("union") + assert.True(t, hasUnion, "NestedOneOfInAllOf should have a union field (oneOf was dropped)") + + // The two oneOf variant types must be generated. + _ = NestedOneOfInAllOf0{} + _ = NestedOneOfInAllOf1{} +} + // issue #1219: test additionalProperties merge-precedence rules in allOf. // In oapi-codegen, an unspecified additionalProperties is treated as false // (unlike the OpenAPI specification default of true), so "default" and diff --git a/internal/test/aggregates/allof/spec.yaml b/internal/test/aggregates/allof/spec.yaml index afa9efbad..24ce8e163 100644 --- a/internal/test/aggregates/allof/spec.yaml +++ b/internal/test/aggregates/allof/spec.yaml @@ -2,11 +2,13 @@ # Category: aggregates/allof # Tests: allOf composition with additionalProperties on constituent schemas; # additionalProperties merge-precedence across all combinations of -# true/false/schema/default in allOf members. +# true/false/schema/default in allOf members; +# deeply nested oneOf inside allOf-of-allOf (issue #1905). # # Folds in (provenance): # - issues/issue193 (allOf + additionalProperties) # - issues/issue-1219 (additionalProperties merge precedence) +# - issues/issue-1905 (nested oneOf lost in allOf-of-allOf merge) # ========================================================================== openapi: "3.0.1" info: @@ -174,3 +176,23 @@ components: allOf: - $ref: '#/components/schemas/DefaultAdditional1' - $ref: '#/components/schemas/DefaultAdditional2' + + # --- issue #1905: deeply nested oneOf inside allOf-of-allOf --- + # The outer allOf merges two members: an inner allOf (which itself wraps a + # oneOf with two variants) and a plain object with a_foo. The bug: the + # merge walk stops after one level, so the oneOf inside the inner allOf is + # silently dropped and neither the union field nor the variant types appear + # in the generated output. + NestedOneOfInAllOf: + allOf: + - allOf: + - oneOf: + - properties: + a_foo_one_of_0: + type: string + - properties: + a_foo_one_of_1: + type: string + - properties: + a_foo: + type: string diff --git a/pkg/codegen/merge_schemas.go b/pkg/codegen/merge_schemas.go index 8d8025bbc..537737913 100644 --- a/pkg/codegen/merge_schemas.go +++ b/pkg/codegen/merge_schemas.go @@ -258,7 +258,11 @@ func mergeOpenapiSchemas(s1, s2 openapi3.Schema, allOf bool, seenSchemaRef map[s // TODO: Check for collisions maps.Copy(result.Extensions, s2.Extensions) - result.OneOf = append(s1.OneOf, s2.OneOf...) + // Capture top-level OneOf/AnyOf before overwriting s1/s2 with transitive + // AllOf merges. The merges may surface additional OneOf/AnyOf members from + // nested allOf members (issue #1905), so we accumulate from both sources. + oneOf := append(s1.OneOf, s2.OneOf...) + anyOf := append(s1.AnyOf, s2.AnyOf...) // We are going to make AllOf transitive, so that merging an AllOf that // contains AllOf's will result in a flat object. @@ -269,6 +273,8 @@ func mergeOpenapiSchemas(s1, s2 openapi3.Schema, allOf bool, seenSchemaRef map[s if err != nil { return openapi3.Schema{}, fmt.Errorf("error transitive merging AllOf on schema 1") } + oneOf = append(oneOf, merged.OneOf...) + anyOf = append(anyOf, merged.AnyOf...) s1 = merged } if s2.AllOf != nil { @@ -277,9 +283,13 @@ func mergeOpenapiSchemas(s1, s2 openapi3.Schema, allOf bool, seenSchemaRef map[s if err != nil { return openapi3.Schema{}, fmt.Errorf("error transitive merging AllOf on schema 2") } + oneOf = append(oneOf, merged.OneOf...) + anyOf = append(anyOf, merged.AnyOf...) s2 = merged } + result.OneOf = oneOf + result.AnyOf = anyOf result.AllOf = append(s1.AllOf, s2.AllOf...) if s1.Type.Slice() != nil && s2.Type.Slice() != nil && !equalTypes(s1.Type, s2.Type) {