diff --git a/Configuration.md b/Configuration.md index f2ac0ff..5501bf8 100644 --- a/Configuration.md +++ b/Configuration.md @@ -71,6 +71,14 @@ generation: runtime-package: path: github.com/org/project/runtime + # Disable detection of the OpenAPI 3.1 enum-via-oneOf idiom. When a schema + # declares a scalar `type` with `oneOf` branches that each carry `const` and + # `title`, oapi-codegen emits a Go enum with named constants (from `title`) + # and per-value doc comments (from `description`) instead of a oneOf union. + # Set to true to keep the legacy union output for those schemas. + # Default: false + skip-enum-via-oneof: false + # Output options: control which operations and schemas are included. output-options: # Only include operations tagged with one of these tags. Ignored when empty. diff --git a/README.md b/README.md index 00d1af8..3b18127 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,26 @@ about people coming and going. Any number of clients may subscribe to this event The [callback example](examples/callback), creates a little server that pretends to plant trees. Each tree planting request contains a callback to be notified when tree planting is complete. We invoke those in a random order via delays, and the client prints out callbacks as they happen. Please see [doc.go](examples/callback/doc.go) for usage. +#### Enum via `oneOf` + `const` + +OpenAPI 3.1 lets you express a named enum with per-value documentation by putting each variant in a `oneOf` branch with `const` and `title`: + +```yaml +Severity: + type: integer + oneOf: + - title: HIGH + const: 2 + description: An urgent problem + - title: MEDIUM + const: 1 + - title: LOW + const: 0 + description: Can wait forever +``` + +V3 detects this idiom and emits a regular Go enum (`type Severity int` with `HIGH`, `MEDIUM`, `LOW` constants) — with the `description` rendered as a per-value doc comment — instead of a `oneOf` union. All branches must carry both `const` and `title`, and the outer schema must declare a scalar `type` (`string` or `integer`); otherwise the schema falls through to the standard union generator. Set `generation.skip-enum-via-oneof: true` to disable detection. + ### Flexible Configuration oapi-codegen V3 tries to make no assumptions about which initialisms, struct tags, or name mangling that is correct for you. A very [flexible configuration file](Configuration.md) allows you to override anything. diff --git a/codegen/internal/codegen.go b/codegen/internal/codegen.go index 31a9117..cfe32a8 100644 --- a/codegen/internal/codegen.go +++ b/codegen/internal/codegen.go @@ -54,7 +54,9 @@ func Generate(doc libopenapi.Document, specData []byte, cfg Configuration) (stri // Pass 1: Gather all schemas that need types. // Operation filters (include/exclude tags, operation IDs) are applied during // gathering so that schemas from excluded operations are never collected. - schemas, err := GatherSchemas(v3Doc, contentTypeMatcher, cfg.OutputOptions) + schemas, err := GatherSchemasWithOptions(v3Doc, contentTypeMatcher, cfg.OutputOptions, GatherOptions{ + SkipEnumViaOneOf: cfg.Generation.SkipEnumViaOneOf, + }) if err != nil { return "", fmt.Errorf("gathering schemas: %w", err) } diff --git a/codegen/internal/configuration.go b/codegen/internal/configuration.go index 768c2b0..f2c594c 100644 --- a/codegen/internal/configuration.go +++ b/codegen/internal/configuration.go @@ -143,6 +143,13 @@ type GenerationOptions struct { // instead, generated code imports and references them from this package. // Generate the runtime package with --generate-runtime or GenerateRuntime(). RuntimePackage *RuntimePackageConfig `yaml:"runtime-package,omitempty"` + + // SkipEnumViaOneOf disables detection of the OpenAPI 3.1 enum-via-oneOf + // idiom (a schema with `type: string|integer` + `oneOf:` members that each + // carry `const` + `title`). When false (default), such schemas are emitted + // as Go enums with named constants. When true, they fall through to the + // standard union-type generator. + SkipEnumViaOneOf bool `yaml:"skip-enum-via-oneof,omitempty"` } // ServerType constants for supported server frameworks. diff --git a/codegen/internal/enum_oneof.go b/codegen/internal/enum_oneof.go new file mode 100644 index 0000000..a9c218b --- /dev/null +++ b/codegen/internal/enum_oneof.go @@ -0,0 +1,66 @@ +package codegen + +import ( + "github.com/pb33f/libopenapi/datamodel/high/base" +) + +// constOneOfItem is one branch of an OpenAPI 3.1 enum-via-oneOf schema. +// It captures the per-value name (from `title`), the raw enum value +// (stringified from `const`), and the doc comment (from `description`). +type constOneOfItem struct { + Title string + Value string + Doc string +} + +// isConstOneOfEnum reports whether a schema matches the OpenAPI 3.1 +// enum-via-oneOf idiom: +// +// type: integer|string +// oneOf: +// - { title: NAME, const: VALUE, description?: TEXT } +// - ... +// +// All members must carry both `title` and `const`, and no member may itself +// be a composition (oneOf/allOf/anyOf) or declare properties. The outer +// schema's primary type must be a scalar (string or integer). +// +// When the idiom matches, the per-branch values are returned in declaration +// order. Otherwise returns (nil, false). +func isConstOneOfEnum(schema *base.Schema) ([]constOneOfItem, bool) { + if schema == nil || len(schema.OneOf) == 0 { + return nil, false + } + + primary := getPrimaryType(schema) + if primary != "string" && primary != "integer" { + return nil, false + } + + items := make([]constOneOfItem, 0, len(schema.OneOf)) + for _, proxy := range schema.OneOf { + if proxy == nil { + return nil, false + } + m := proxy.Schema() + if m == nil { + return nil, false + } + if m.Title == "" || m.Const == nil { + return nil, false + } + // Members must be simple scalar-const schemas, not nested composition. + if len(m.OneOf) > 0 || len(m.AllOf) > 0 || len(m.AnyOf) > 0 { + return nil, false + } + if m.Properties != nil && m.Properties.Len() > 0 { + return nil, false + } + items = append(items, constOneOfItem{ + Title: m.Title, + Value: m.Const.Value, + Doc: m.Description, + }) + } + return items, true +} diff --git a/codegen/internal/enum_oneof_test.go b/codegen/internal/enum_oneof_test.go new file mode 100644 index 0000000..42a7f23 --- /dev/null +++ b/codegen/internal/enum_oneof_test.go @@ -0,0 +1,147 @@ +package codegen + +import ( + "testing" + + "github.com/pb33f/libopenapi" + "github.com/pb33f/libopenapi/datamodel/high/base" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// targetSchema parses a minimal OpenAPI 3.1 spec with a single component +// schema named "Target" and returns the resolved high-level schema. +// The input is the YAML body of the Target schema (indented by 6 spaces to +// sit under `components.schemas.Target`). +func targetSchema(t *testing.T, targetYAML string) *base.Schema { + t.Helper() + const preamble = "openapi: 3.1.0\n" + + "info:\n" + + " title: t\n" + + " version: \"1\"\n" + + "paths: {}\n" + + "components:\n" + + " schemas:\n" + + " Target:\n" + doc, err := libopenapi.NewDocument([]byte(preamble + targetYAML)) + require.NoError(t, err) + model, errs := doc.BuildV3Model() + require.Empty(t, errs, "BuildV3Model errors") + require.NotNil(t, model) + proxy := model.Model.Components.Schemas.GetOrZero("Target") + require.NotNil(t, proxy) + sch := proxy.Schema() + require.NotNil(t, sch) + return sch +} + +func TestIsConstOneOfEnum_Integer(t *testing.T) { + sch := targetSchema(t, ` type: integer + oneOf: + - title: HIGH + const: 2 + description: An urgent problem + - title: MEDIUM + const: 1 + - title: LOW + const: 0 + description: Can wait forever +`) + + items, ok := isConstOneOfEnum(sch) + require.True(t, ok) + require.Len(t, items, 3) + assert.Equal(t, "HIGH", items[0].Title) + assert.Equal(t, "2", items[0].Value) + assert.Equal(t, "An urgent problem", items[0].Doc) + assert.Equal(t, "MEDIUM", items[1].Title) + assert.Equal(t, "1", items[1].Value) + assert.Equal(t, "", items[1].Doc) + assert.Equal(t, "LOW", items[2].Title) + assert.Equal(t, "0", items[2].Value) + assert.Equal(t, "Can wait forever", items[2].Doc) +} + +func TestIsConstOneOfEnum_String(t *testing.T) { + sch := targetSchema(t, ` type: string + oneOf: + - title: Red + const: r + - title: Green + const: g + - title: Blue + const: b +`) + + items, ok := isConstOneOfEnum(sch) + require.True(t, ok) + require.Len(t, items, 3) + assert.Equal(t, "Red", items[0].Title) + assert.Equal(t, "r", items[0].Value) +} + +func TestIsConstOneOfEnum_MissingTitle(t *testing.T) { + sch := targetSchema(t, ` type: integer + oneOf: + - title: HIGH + const: 2 + - const: 1 +`) + + _, ok := isConstOneOfEnum(sch) + assert.False(t, ok, "missing title on one branch must disqualify the idiom") +} + +func TestIsConstOneOfEnum_MissingConst(t *testing.T) { + sch := targetSchema(t, ` type: integer + oneOf: + - title: HIGH + const: 2 + - title: MEDIUM +`) + + _, ok := isConstOneOfEnum(sch) + assert.False(t, ok, "missing const on one branch must disqualify the idiom") +} + +func TestIsConstOneOfEnum_NonScalarOuterType(t *testing.T) { + sch := targetSchema(t, ` type: object + oneOf: + - title: HIGH + const: 2 + - title: LOW + const: 0 +`) + + _, ok := isConstOneOfEnum(sch) + assert.False(t, ok, "object outer type must disqualify the idiom") +} + +func TestIsConstOneOfEnum_EmptyOneOf(t *testing.T) { + sch := targetSchema(t, ` type: integer +`) + + _, ok := isConstOneOfEnum(sch) + assert.False(t, ok, "no oneOf means no idiom") +} + +func TestIsConstOneOfEnum_NestedComposition(t *testing.T) { + sch := targetSchema(t, ` type: integer + oneOf: + - title: HIGH + const: 2 + oneOf: + - const: 3 + - const: 4 + - title: LOW + const: 0 +`) + + _, ok := isConstOneOfEnum(sch) + assert.False(t, ok, "a branch with nested composition must disqualify the idiom") +} + +func TestIsConstOneOfEnum_NilSchema(t *testing.T) { + _, ok := isConstOneOfEnum(nil) + assert.False(t, ok) +} diff --git a/codegen/internal/enumresolution.go b/codegen/internal/enumresolution.go index 3367b4e..884ec4d 100644 --- a/codegen/internal/enumresolution.go +++ b/codegen/internal/enumresolution.go @@ -14,6 +14,10 @@ type EnumInfo struct { // CustomNames are user-provided constant names from x-oapi-codegen-enum-var-names. // May be nil or shorter than Values. CustomNames []string + // ValueDocs is the per-constant documentation string (e.g. from the + // `description` field on each oneOf+const enum branch). May be nil; + // individual entries may be empty. Indexed in parallel with Values. + ValueDocs []string // Doc is the enum's documentation string. Doc string // SchemaPath is the key used to look up this EnumInfo (schema path string). diff --git a/codegen/internal/gather.go b/codegen/internal/gather.go index d11a154..0df18f1 100644 --- a/codegen/internal/gather.go +++ b/codegen/internal/gather.go @@ -13,6 +13,20 @@ import ( // When outputOpts contains operation filters (include/exclude tags or operation IDs), // schemas from excluded operations are not gathered. func GatherSchemas(doc *v3.Document, contentTypeMatcher *ContentTypeMatcher, outputOpts OutputOptions) ([]*SchemaDescriptor, error) { + return GatherSchemasWithOptions(doc, contentTypeMatcher, outputOpts, GatherOptions{}) +} + +// GatherOptions carries non-output-filter toggles that gather needs to honour. +type GatherOptions struct { + // SkipEnumViaOneOf disables detection of the 3.1 enum-via-oneOf idiom. + // When true, schemas that would otherwise be recognised as enums are + // gathered as ordinary oneOf unions. + SkipEnumViaOneOf bool +} + +// GatherSchemasWithOptions is the same as GatherSchemas but accepts +// non-output-filter toggles such as SkipEnumViaOneOf. +func GatherSchemasWithOptions(doc *v3.Document, contentTypeMatcher *ContentTypeMatcher, outputOpts OutputOptions, gatherOpts GatherOptions) ([]*SchemaDescriptor, error) { if doc == nil { return nil, fmt.Errorf("nil v3 document") } @@ -21,6 +35,7 @@ func GatherSchemas(doc *v3.Document, contentTypeMatcher *ContentTypeMatcher, out schemas: make([]*SchemaDescriptor, 0), contentTypeMatcher: contentTypeMatcher, outputOpts: outputOpts, + gatherOpts: gatherOpts, } g.gatherFromDocument(doc) @@ -31,6 +46,7 @@ type gatherer struct { schemas []*SchemaDescriptor contentTypeMatcher *ContentTypeMatcher outputOpts OutputOptions + gatherOpts GatherOptions // Context for the current operation being gathered (for nicer naming) currentOperationID string currentContentType string @@ -611,12 +627,24 @@ func (g *gatherer) gatherFromSchema(schema *base.Schema, basePath SchemaPath, pa } } - // OneOf - for i, proxy := range schema.OneOf { - oneOfPath := basePath.Append("oneOf", fmt.Sprintf("%d", i)) - oneOfDesc := g.gatherFromSchemaProxy(proxy, oneOfPath, parent) - if parent != nil && oneOfDesc != nil { - parent.OneOf = append(parent.OneOf, oneOfDesc) + // OneOf — the 3.1 enum-via-const idiom shortcuts member recursion. + // For such a schema the members are pure const+title branches that + // don't warrant their own Go types, so we detect the idiom once, cache + // the extracted items on the parent, and skip the per-member recursion. + oneOfIsConstEnum := false + if !g.gatherOpts.SkipEnumViaOneOf && parent != nil { + if items, ok := isConstOneOfEnum(schema); ok { + parent.ConstOneOfItems = items + oneOfIsConstEnum = true + } + } + if !oneOfIsConstEnum { + for i, proxy := range schema.OneOf { + oneOfPath := basePath.Append("oneOf", fmt.Sprintf("%d", i)) + oneOfDesc := g.gatherFromSchemaProxy(proxy, oneOfPath, parent) + if parent != nil && oneOfDesc != nil { + parent.OneOf = append(parent.OneOf, oneOfDesc) + } } } diff --git a/codegen/internal/output.go b/codegen/internal/output.go index f053d6d..6c88e29 100644 --- a/codegen/internal/output.go +++ b/codegen/internal/output.go @@ -284,6 +284,12 @@ func GenerateEnumFromInfo(info *EnumInfo) string { constName = info.SanitizedNames[i] } + if i < len(info.ValueDocs) && info.ValueDocs[i] != "" { + for _, line := range strings.Split(info.ValueDocs[i], "\n") { + b.Line("// %s", line) + } + } + if info.BaseType == "string" { b.Line("%s %s = %q", constName, info.TypeName, v) } else { diff --git a/codegen/internal/schema.go b/codegen/internal/schema.go index a9473ed..f0e2f37 100644 --- a/codegen/internal/schema.go +++ b/codegen/internal/schema.go @@ -84,6 +84,12 @@ type SchemaDescriptor struct { AnyOf []*SchemaDescriptor OneOf []*SchemaDescriptor AdditionalProps *SchemaDescriptor + + // ConstOneOfItems holds the extracted items when this schema matches the + // OpenAPI 3.1 enum-via-oneOf idiom (type: string|integer + oneOf of + // const+title branches). Populated during gather; nil when the idiom does + // not apply or detection is disabled. Presence signals KindEnum. + ConstOneOfItems []constOneOfItem } // DiscriminatorInfo holds discriminator metadata extracted from the OpenAPI spec. diff --git a/codegen/internal/test/components/enum_via_oneof/config.yaml b/codegen/internal/test/components/enum_via_oneof/config.yaml new file mode 100644 index 0000000..d4b564f --- /dev/null +++ b/codegen/internal/test/components/enum_via_oneof/config.yaml @@ -0,0 +1,5 @@ +package: output +output: output/types.gen.go +generation: + runtime-package: + path: github.com/oapi-codegen/oapi-codegen-exp/runtime diff --git a/codegen/internal/test/components/enum_via_oneof/doc.go b/codegen/internal/test/components/enum_via_oneof/doc.go new file mode 100644 index 0000000..9ea98a5 --- /dev/null +++ b/codegen/internal/test/components/enum_via_oneof/doc.go @@ -0,0 +1,6 @@ +// Package enum_via_oneof tests the OpenAPI 3.1 enum-via-oneOf idiom: +// a scalar `type` with `oneOf` branches that each carry `const` + `title` +// is generated as a Go enum with named constants and per-value doc comments. +package enum_via_oneof + +//go:generate go run ../../../../../cmd/oapi-codegen -config config.yaml spec.yaml diff --git a/codegen/internal/test/components/enum_via_oneof/output/types.gen.go b/codegen/internal/test/components/enum_via_oneof/output/types.gen.go new file mode 100644 index 0000000..8bfc2e5 --- /dev/null +++ b/codegen/internal/test/components/enum_via_oneof/output/types.gen.go @@ -0,0 +1,160 @@ +// Code generated by oapi-codegen; DO NOT EDIT. + +package output + +import ( + "bytes" + "compress/gzip" + "encoding/base64" + "encoding/json" + "fmt" + "strings" + "sync" + + oapiCodegenHelpersPkg "github.com/oapi-codegen/oapi-codegen-exp/runtime/helpers" +) + +// #/components/schemas/Severity +// How urgent a problem is. +type Severity int + +const ( + // An urgent problem + HIGH Severity = 2 + MEDIUM Severity = 1 + // Can wait forever + LOW Severity = 0 +) + +// #/components/schemas/Color +type Color string + +const ( + // Warm, high-energy + Red Color = "r" + Green Color = "g" + // Cool, calm + Blue Color = "b" +) + +// #/components/schemas/MixedOneOf + +type MixedOneOf struct { + union json.RawMessage +} + +// AsAny0 returns the union data inside the MixedOneOf as a any. +func (t MixedOneOf) AsAny0() (any, error) { + var body any + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromAny0 overwrites any union data inside the MixedOneOf as the provided any. +func (t *MixedOneOf) FromAny0(v any) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeAny0 performs a merge with any union data inside the MixedOneOf, using the provided any. +func (t *MixedOneOf) MergeAny0(v any) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + merged, err := oapiCodegenHelpersPkg.JSONMerge(t.union, b) + t.union = merged + return err +} + +// AsAny1 returns the union data inside the MixedOneOf as a any. +func (t MixedOneOf) AsAny1() (any, error) { + var body any + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromAny1 overwrites any union data inside the MixedOneOf as the provided any. +func (t *MixedOneOf) FromAny1(v any) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeAny1 performs a merge with any union data inside the MixedOneOf, using the provided any. +func (t *MixedOneOf) MergeAny1(v any) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + merged, err := oapiCodegenHelpersPkg.JSONMerge(t.union, b) + t.union = merged + return err +} + +func (t MixedOneOf) MarshalJSON() ([]byte, error) { + b, err := t.union.MarshalJSON() + return b, err +} + +func (t *MixedOneOf) UnmarshalJSON(b []byte) error { + err := t.union.UnmarshalJSON(b) + return err +} + +// ApplyDefaults sets default values for fields that are nil. +func (t *MixedOneOf) ApplyDefaults() { +} + +// Base64-encoded, gzip-compressed OpenAPI spec. +var openAPISpecJSON = []string{ + "H4sIAAAAAAAC/5RTwWrcShC86ysK9vIe8S7r5Kab4xjbEGdDQvBVvVKvZshMt5hprbOEQD4iX5gvCZJ2", + "jY0wITfNUFPVVepa4Er6uNx7WqrwZof/Nh3LxcdbvFmd/1+CkGvHkfDgzY0nCpRQ2aHjCl3oM6rxYVUs", + "sE0kteMMc2Rgqh1qSumAqlbJVuEVKvMWuEJ22ocGW0bLwomMG1AG4VqLBVj6OCkKRW4wPiexDJIGHafl", + "nkLPaLRGrTGyWF4V2rFQ58th9NW68LLTsgBGxXL0ib0nTD6NsxXAnlP2KiXOV+vVuujIXC7x/UdRa+xU", + "BuKBYwph/AQ+856Tt8N0AhrOdfKdjTw3+oA+tSwGQpd0GzjC59URO+RWwotxy+l4Nw50IgOWp4lvbq9v", + "Hm8xhVDi9ZOrZ8oXchI+ys4Z767e3X65m3Oez6HvN/dz3Pol7UsSPJA37DQN6RQj8FKDpvKZ82zJS/sX", + "45+4mWunl7TvKcUzON+65bBK7WFOeJ2YZU7ZzpFvQ89z4PZF36rhDDWFODle4AO3ZH7PGFapHCwea4FA", + "9dd8KsAZssIcwzdeI2KfDaKGSFY7/P7560hnzudTBXcUwtCtpH3rYNP7xG0/NHLa6l68yqlSmqatu/Pf", + "uNk8zfof/sVF6BzNE6EnyMeQ/gQAAP//fcizEU0EAAA=", +} + +// decodeOpenAPISpec decodes and decompresses the embedded spec. +func decodeOpenAPISpec() ([]byte, error) { + joined := strings.Join(openAPISpecJSON, "") + raw, err := base64.StdEncoding.DecodeString(joined) + if err != nil { + return nil, fmt.Errorf("decoding base64: %w", err) + } + r, err := gzip.NewReader(bytes.NewReader(raw)) + if err != nil { + return nil, fmt.Errorf("creating gzip reader: %w", err) + } + defer r.Close() + var out bytes.Buffer + if _, err := out.ReadFrom(r); err != nil { + return nil, fmt.Errorf("decompressing: %w", err) + } + return out.Bytes(), nil +} + +// decodeOpenAPISpecCached returns a closure that caches the decoded spec. +func decodeOpenAPISpecCached() func() ([]byte, error) { + var cached []byte + var cachedErr error + var once sync.Once + return func() ([]byte, error) { + once.Do(func() { + cached, cachedErr = decodeOpenAPISpec() + }) + return cached, cachedErr + } +} + +var openAPISpec = decodeOpenAPISpecCached() + +// GetOpenAPISpecJSON returns the raw OpenAPI spec as JSON bytes. +func GetOpenAPISpecJSON() ([]byte, error) { + return openAPISpec() +} diff --git a/codegen/internal/test/components/enum_via_oneof/output/types_test.go b/codegen/internal/test/components/enum_via_oneof/output/types_test.go new file mode 100644 index 0000000..e95fb49 --- /dev/null +++ b/codegen/internal/test/components/enum_via_oneof/output/types_test.go @@ -0,0 +1,61 @@ +package output + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestSeverityConstants verifies that the integer enum-via-oneOf idiom +// produces a `type Severity int` with titled constants and the right values. +func TestSeverityConstants(t *testing.T) { + assert.Equal(t, 2, int(HIGH)) + assert.Equal(t, 1, int(MEDIUM)) + assert.Equal(t, 0, int(LOW)) +} + +// TestSeverityJSONRoundTrip verifies Severity marshals as its integer value. +func TestSeverityJSONRoundTrip(t *testing.T) { + data, err := json.Marshal(HIGH) + require.NoError(t, err) + assert.JSONEq(t, `2`, string(data)) + + var got Severity + require.NoError(t, json.Unmarshal([]byte(`1`), &got)) + assert.Equal(t, MEDIUM, got) +} + +// TestColorConstants verifies that the string enum-via-oneOf idiom produces +// a `type Color string` with titled constants and the right string values. +func TestColorConstants(t *testing.T) { + assert.Equal(t, "r", string(Red)) + assert.Equal(t, "g", string(Green)) + assert.Equal(t, "b", string(Blue)) +} + +// TestColorJSONRoundTrip verifies Color marshals as its string value. +func TestColorJSONRoundTrip(t *testing.T) { + data, err := json.Marshal(Red) + require.NoError(t, err) + assert.JSONEq(t, `"r"`, string(data)) + + var got Color + require.NoError(t, json.Unmarshal([]byte(`"b"`), &got)) + assert.Equal(t, Blue, got) +} + +// TestMixedOneOfStillUnion verifies that a oneOf whose branches do NOT all +// have `title` + `const` (here, the second branch lacks `title`) falls +// through to the standard union generator rather than becoming an enum. +// If MixedOneOf were mis-detected as an enum, this file would fail to +// compile (the union methods would not exist). +func TestMixedOneOfStillUnion(t *testing.T) { + var m MixedOneOf + require.NoError(t, m.FromAny0("a")) + + data, err := json.Marshal(m) + require.NoError(t, err) + assert.JSONEq(t, `"a"`, string(data)) +} diff --git a/codegen/internal/test/components/enum_via_oneof/spec.yaml b/codegen/internal/test/components/enum_via_oneof/spec.yaml new file mode 100644 index 0000000..fb72d35 --- /dev/null +++ b/codegen/internal/test/components/enum_via_oneof/spec.yaml @@ -0,0 +1,43 @@ +# Enum-via-oneOf (OpenAPI 3.1): a schema with a scalar `type` plus `oneOf` +# branches that each carry `const` + `title` should be generated as a Go +# enum with named constants and per-value doc comments. +openapi: 3.1.0 +info: + title: Enum via oneOf test + version: 1.0.0 +paths: {} +components: + schemas: + Severity: + description: How urgent a problem is. + type: integer + oneOf: + - title: HIGH + const: 2 + description: An urgent problem + - title: MEDIUM + const: 1 + - title: LOW + const: 0 + description: Can wait forever + + Color: + type: string + oneOf: + - title: Red + const: r + description: Warm, high-energy + - title: Green + const: g + - title: Blue + const: b + description: Cool, calm + + # Negative path: one branch lacks `title`, so the idiom must not match — + # this schema falls through to the regular oneOf union generator. + MixedOneOf: + type: string + oneOf: + - title: Alpha + const: a + - const: b diff --git a/codegen/internal/typegen.go b/codegen/internal/typegen.go index 057bfc0..fe3cbc6 100644 --- a/codegen/internal/typegen.go +++ b/codegen/internal/typegen.go @@ -109,9 +109,11 @@ func (g *TypeGenerator) resolveEnumNames(schemas []*SchemaDescriptor, alwaysPref } // buildEnumInfo creates an EnumInfo from a schema descriptor. +// Handles both plain `enum: [...]` schemas and the OpenAPI 3.1 +// enum-via-oneOf idiom (detected during gather and cached on the descriptor). func (g *TypeGenerator) buildEnumInfo(desc *SchemaDescriptor) *EnumInfo { schema := desc.Schema - if schema == nil || len(schema.Enum) == 0 { + if schema == nil { return nil } @@ -122,13 +124,34 @@ func (g *TypeGenerator) buildEnumInfo(desc *SchemaDescriptor) *EnumInfo { } var values []string - for _, v := range schema.Enum { - values = append(values, fmt.Sprintf("%v", v.Value)) - } - var customNames []string - if desc.Extensions != nil && len(desc.Extensions.EnumVarNames) > 0 { - customNames = desc.Extensions.EnumVarNames + var valueDocs []string + + switch { + case len(schema.Enum) > 0: + for _, v := range schema.Enum { + values = append(values, fmt.Sprintf("%v", v.Value)) + } + if desc.Extensions != nil && len(desc.Extensions.EnumVarNames) > 0 { + customNames = desc.Extensions.EnumVarNames + } + + case len(desc.ConstOneOfItems) > 0: + // User-supplied titles may not be valid Go identifiers (e.g. + // "High severity"). Run them through the mangler so the + // CustomNames-wins path in computeEnumConstantNames still yields + // valid identifiers. + customNames = make([]string, len(desc.ConstOneOfItems)) + values = make([]string, len(desc.ConstOneOfItems)) + valueDocs = make([]string, len(desc.ConstOneOfItems)) + for i, item := range desc.ConstOneOfItems { + customNames[i] = g.converter.ToEnumValueName(item.Title, "string") + values[i] = item.Value + valueDocs[i] = item.Doc + } + + default: + return nil } return &EnumInfo{ @@ -136,6 +159,7 @@ func (g *TypeGenerator) buildEnumInfo(desc *SchemaDescriptor) *EnumInfo { BaseType: baseType, Values: values, CustomNames: customNames, + ValueDocs: valueDocs, Doc: extractDescription(schema), SchemaPath: desc.Path.String(), } @@ -245,6 +269,11 @@ func (g *TypeGenerator) goTypeForSchema(schema *base.Schema, desc *SchemaDescrip return g.anyOfType(desc) } if len(schema.OneOf) > 0 { + // If the oneOf matches the 3.1 enum-via-const idiom, render as the + // generated enum type name rather than the union form. + if desc != nil && len(desc.ConstOneOfItems) > 0 && desc.ShortName != "" { + return desc.ShortName + } return g.oneOfType(desc) } @@ -941,8 +970,9 @@ func GetSchemaKind(desc *SchemaDescriptor) SchemaKind { return KindAlias } - // Enum check first - if len(schema.Enum) > 0 { + // Enum check first (plain `enum: [...]` or the 3.1 oneOf+const idiom + // detected and cached during gather). + if len(schema.Enum) > 0 || len(desc.ConstOneOfItems) > 0 { return KindEnum }