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

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 9 additions & 0 deletions internal/test/extensions/allof-merge-extensions/config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# yaml-language-server: $schema=../../../../configuration-schema.json
package: allofmergeextensions
generate:
models: true
output: allof_merge_extensions.gen.go
output-options:
skip-prune: true
overlay:
path: overlay.yaml
3 changes: 3 additions & 0 deletions internal/test/extensions/allof-merge-extensions/generate.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package allofmergeextensions

//go:generate go run github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen --config=config.yaml spec.yaml
14 changes: 14 additions & 0 deletions internal/test/extensions/allof-merge-extensions/overlay.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
overlay: 1.0.0
info:
title: "Example to indicate how to use the OpenAPI Overlay specification (https://github.com/OAI/Overlay-Specification)"
version: 1.0.0
actions:
- target: $.components.schemas.Client
update:
x-go-type: OverlayClient
- target: $.components.schemas.ClientWithId
update:
x-go-type: OverlayClientWithId
- target: $.components.schemas.BaseOnly
update:
x-go-type: OverlayBaseOnly
20 changes: 20 additions & 0 deletions internal/test/extensions/allof-merge-extensions/overlays.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package allofmergeextensions

// OverlayClient defines model for OverlayClient.
type OverlayClient struct {
Name string `json:"name"`
}

// OverlayClientWithId defines model for OverlayClientWithId.
type OverlayClientWithId struct {
Id int `json:"id"`
Name string `json:"name"`
}

// OverlayBaseOnly is the user-provided override for the BaseOnly schema.
// DerivedNoOverride composes BaseOnly via allOf and must NOT be aliased
// to this type — it has to remain its own struct so the Extra field is
// preserved.
type OverlayBaseOnly struct {
Name string `json:"name"`
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package allofmergeextensions

import "testing"

func TestAllOfOverlay(t *testing.T) {
var inner any = Client{}
_, ok := inner.(OverlayClient)
if !ok {
t.Errorf("expected Client to be of type OverlayClient")
}

var outer any = ClientWithId{}
_, ok = outer.(OverlayClientWithId)
if !ok {
t.Errorf("expected ClientWithId to be of type OverlayClientWithId")
}
}

// TestBaseOnlyOverlay covers the harder regression path: when only the
// base schema has x-go-type (via overlay) and the derived allOf schema
// has no override of its own, the derived schema must still be emitted
// as a distinct struct containing all composed fields. The previous
// bug leaked BaseOnly's x-go-type up through the allOf merge, producing
// `type DerivedNoOverride = OverlayBaseOnly` and silently dropping the
// Extra field. The struct literal below would fail to compile under
// the buggy codegen because OverlayBaseOnly has no Extra field.
func TestBaseOnlyOverlay(t *testing.T) {
d := DerivedNoOverride{Name: "x", Extra: "y"}

var asAny any = d
if _, ok := asAny.(OverlayBaseOnly); ok {
t.Error("DerivedNoOverride must not be aliased to OverlayBaseOnly; composition would drop the Extra field")
}

if d.Name != "x" || d.Extra != "y" {
t.Errorf("field values not preserved through composed struct: %+v", d)
}
}
43 changes: 43 additions & 0 deletions internal/test/extensions/allof-merge-extensions/spec.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
openapi: "3.0.0"
info:
version: 1.0.0
title: Regression test for allOf + x-go-type interaction (issue #2335)
components:
schemas:
Client:
type: object
required:
- name
properties:
name:
type: string
ClientWithId:
allOf:
- $ref: '#/components/schemas/Client'
- properties:
id:
type: integer
required:
- id

# Second scenario: only the BASE schema receives an x-go-type overlay;
# the derived allOf schema has no override of its own. It must still
# be emitted as a distinct struct (with both Name and Extra), not
# aliased to the base's overlay target. Regression test for the case
# where the codegen used to leak the base's x-go-type up through the
# allOf merge, silently dropping the derived schema's extra fields.
BaseOnly:
type: object
required:
- name
properties:
name:
type: string
DerivedNoOverride:
allOf:
- $ref: '#/components/schemas/BaseOnly'
- properties:
extra:
type: string
required:
- extra
87 changes: 87 additions & 0 deletions pkg/codegen/merge_schemas.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"errors"
"fmt"
"maps"
"reflect"
"strings"

"github.com/getkin/kin-openapi/openapi3"
Expand All @@ -27,6 +28,35 @@ func mergeSchemas(allOf []*openapi3.SchemaRef, path []string) (Schema, error) {
return GenerateGoSchema(allOf[0], path)
}

// Distinguish two uses of allOf:
//
// 1. Decorator idiom — at least one INLINE member (Ref == "") is
// "extension-only" (carries no structural content). This is a
// workaround for OpenAPI 3.0's $ref-sibling restriction: users
// wrap a $ref in allOf to attach extensions like
// x-go-type-skip-optional-pointer (see issue #1957). Here
// extensions are meant to flow through to the result.
//
// 2. Real composition — every member either contributes structural
// content or is a $ref contributing the referenced schema. The
// result is a NEW distinct type, and extensions like x-go-type on
// a source schema do NOT transfer (see issue #2335: Client has
// x-go-type=OverlayClient, but allOf[Client, {properties:{id}}]
// is ClientWithId — a different shape, not OverlayClient).
//
// A $ref member is excluded from the decorator check because it is by
// construction delivering the referenced schema, not "decorating"
// siblings — even if the referenced schema happens to carry only
// extensions, that's a property of the target, not an intent on this
// composition.
decoratorIdiom := false
for _, m := range allOf {
if m.Ref == "" && isExtensionOnlySchema(m.Value) {
decoratorIdiom = true
break
}
}
Comment thread
mromaszewicz marked this conversation as resolved.

schema, err := valueWithPropagatedRef(allOf[0])
if err != nil {
return Schema{}, err
Expand Down Expand Up @@ -59,9 +89,66 @@ func mergeSchemas(allOf []*openapi3.SchemaRef, path []string) (Schema, error) {
return Schema{}, fmt.Errorf("error merging schemas for AllOf: %w", err)
}
}

if !decoratorIdiom {
// Drop only the type-identity directives. Other extensions
// (user-defined x-* metadata, etc.) are preserved — we only
// have concrete evidence that the identity-bound ones cause
// incorrect aliasing across composition.
//
// Clone before mutating: the current merge path always
// reallocates schema.Extensions in mergeOpenapiSchemas before
// we reach here, so the delete is safe today — but the
// defensive copy keeps this correct if that invariant changes
// (e.g. an allocation-skipping optimization). Cost is a small
// map copy on a single code path.
ext := maps.Clone(schema.Extensions)
delete(ext, extPropGoType)
delete(ext, extGoTypeName)
delete(ext, extPropGoImport)
schema.Extensions = ext
}
Comment thread
mromaszewicz marked this conversation as resolved.

return GenerateGoSchema(openapi3.NewSchemaRef("", &schema), path)
}

// isExtensionOnlySchema reports whether a schema carries only extensions,
// with no structural or constraint-bearing content. Used to detect the
// "$ref + sibling extension" idiom: allOf wrappers whose purpose is
// attaching extensions to a $ref (since OpenAPI 3.0 disallows sibling
// keys next to $ref).
//
// Implementation: zero out every field that doesn't affect the generated
// Go type, then compare to the zero Schema. Anything left over — a Type,
// Properties, Pattern, MinLength, etc. — disqualifies the schema from
// being treated as a pure decorator. This formulation defaults to safe
// behavior if kin-openapi gains new structural fields: they'd be non-zero
// by default and correctly disqualify.
func isExtensionOnlySchema(s *openapi3.Schema) bool {
if s == nil || len(s.Extensions) == 0 {
return false
}
tmp := *s
tmp.Extensions = nil
// Source-tracking metadata from kin-openapi; always non-nil for
// schemas parsed from a file.
tmp.Origin = nil
// Purely documentary / metadata fields. These don't affect the
// generated Go type, so a schema carrying only these plus extensions
// still behaves as a decorator.
tmp.Title = ""
tmp.Description = ""
tmp.Default = nil
tmp.Example = nil
tmp.ExternalDocs = nil
tmp.Deprecated = false
tmp.ReadOnly = false
tmp.WriteOnly = false
tmp.AllowEmptyValue = false
tmp.XML = nil
return reflect.DeepEqual(tmp, openapi3.Schema{})
}
Comment thread
mromaszewicz marked this conversation as resolved.

// valueWithPropagatedRef returns a copy of ref schema with its Properties refs
// updated if ref itself is external. Otherwise, return ref.Value as-is.
func valueWithPropagatedRef(ref *openapi3.SchemaRef) (openapi3.Schema, error) {
Expand Down
27 changes: 14 additions & 13 deletions pkg/codegen/schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,20 @@ func GenerateGoSchema(sref *openapi3.SchemaRef, path []string) (Schema, error) {
SkipOptionalPointer: skipOptionalPointer,
}

// Check x-go-type, which will completely override the definition of this
// schema with the provided type. This must be checked before AllOf so
// that an override on the outer schema wins over allOf composition.
if extension, ok := schema.Extensions[extPropGoType]; ok {
typeName, err := extTypeName(extension)
if err != nil {
return outSchema, fmt.Errorf("invalid value for %q: %w", extPropGoType, err)
}
outSchema.GoType = typeName
outSchema.DefineViaAlias = true

return outSchema, nil
}

// AllOf is interesting, and useful. It's the union of a number of other
// schemas. A common usage is to create a union of an object with an ID,
// so that in a RESTful paradigm, the Create operation can return
Expand All @@ -341,19 +355,6 @@ func GenerateGoSchema(sref *openapi3.SchemaRef, path []string) (Schema, error) {
return mergedSchema, nil
}

// Check x-go-type, which will completely override the definition of this
// schema with the provided type.
if extension, ok := schema.Extensions[extPropGoType]; ok {
typeName, err := extTypeName(extension)
if err != nil {
return outSchema, fmt.Errorf("invalid value for %q: %w", extPropGoType, err)
}
outSchema.GoType = typeName
outSchema.DefineViaAlias = true

return outSchema, nil
}

// Schema type and format, eg. string / binary
t := schema.Type
// Handle objects and empty schemas first as a special case
Expand Down
Loading