diff --git a/generator/golang/from_openapi.go b/generator/golang/from_openapi.go index 00b999ec..741eebcc 100644 --- a/generator/golang/from_openapi.go +++ b/generator/golang/from_openapi.go @@ -8,6 +8,7 @@ import ( highbase "github.com/pb33f/libopenapi/datamodel/high/base" "github.com/pb33f/libopenapi/orderedmap" + "go.yaml.in/yaml/v4" ) func (g *Generator) irFromOpenAPI(name string, proxy *highbase.SchemaProxy, path string) (*SchemaIR, error) { @@ -343,6 +344,17 @@ func (g *Generator) populateUnion(ir *SchemaIR, schema *highbase.Schema, path st } variants = append(variants, g.childIR(variantName, child, path+".union")) } + // A oneOf/anyOf whose non-null variants are all scalar consts is an + // OpenAPI 3.1 const-based enum, not a structural union: fold it before + // building the union so the IR stays consistent. + if nodes, nullable, ok := constScalarEnumFromVariants(variants); ok { + ir.Enum = nodes + if nullable { + ir.Nullable = true + } + ir.Kind = KindEnum + return + } nonNull := nonNullVariants(variants) if len(nonNull) == 1 && len(nonNull) != len(variants) { *ir = *nonNull[0] @@ -571,6 +583,52 @@ func nonNullVariants(variants []*SchemaIR) []*SchemaIR { return out } +// constScalarEnumFromVariants reports whether a oneOf/anyOf is best modeled +// as a scalar const enum (OpenAPI 3.1 style: oneOf of const values, e.g. +// "oneOf: [ {const: available}, {const: pending} ]"). When it is, it returns +// the const values in variant order, whether the union declares nullability +// through a null-of variant, and true. Object, array, union and +// dynamically-referenced variants are structural and never collapse to an +// enum. +func constScalarEnumFromVariants(variants []*SchemaIR) ([]*yaml.Node, bool, bool) { + if len(variants) == 0 { + return nil, false, false + } + nodes := make([]*yaml.Node, 0, len(variants)) + nullable := false + for _, v := range variants { + if v == nil { + return nil, false, false + } + if isNullOnlyIR(v) { + nullable = true + continue + } + if v.Const == nil { + return nil, false, false + } + if s := v.SourceSchema; s != nil { + for _, t := range s.Type { + if t == "object" || t == "array" { + return nil, false, false + } + } + if s.DynamicRef != "" || + len(s.AllOf) > 0 || len(s.OneOf) > 0 || len(s.AnyOf) > 0 || len(s.Enum) > 0 || + (s.Properties != nil && s.Properties.Len() > 0) || + (s.PatternProperties != nil && s.PatternProperties.Len() > 0) || + s.Items != nil || len(s.PrefixItems) > 0 { + return nil, false, false + } + } + nodes = append(nodes, v.Const) + } + if len(nodes) == 0 { + return nil, false, false + } + return nodes, nullable, true +} + func isNullOnlyIR(ir *SchemaIR) bool { if ir == nil { return false diff --git a/generator/golang/from_openapi_test.go b/generator/golang/from_openapi_test.go new file mode 100644 index 00000000..74efff2b --- /dev/null +++ b/generator/golang/from_openapi_test.go @@ -0,0 +1,126 @@ +// Copyright 2026 Princess B33f Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package golang + +import ( + "testing" + + highbase "github.com/pb33f/libopenapi/datamodel/high/base" + "github.com/pb33f/libopenapi/orderedmap" + "go.yaml.in/yaml/v4" +) + +// TestConstScalarEnumFromVariantsGuards exercises every rejection branch of +// constScalarEnumFromVariants so that a oneOf/anyOf is only ever folded into a +// KindEnum when each non-null variant is a bare scalar const. Any structural +// marker (a declared object/array type, a $dynamicRef, a nested composition +// keyword, properties/items, or a nil / null-only variant list) must veto the +// fold. +func TestConstScalarEnumFromVariantsGuards(t *testing.T) { + schemaProxy := func(s *highbase.Schema) *highbase.SchemaProxy { + return highbase.CreateSchemaProxy(s) + } + propMap := func() *orderedmap.Map[string, *highbase.SchemaProxy] { + m := orderedmap.New[string, *highbase.SchemaProxy]() + m.Set("p", schemaProxy(&highbase.Schema{Type: []string{"string"}})) + return m + } + + cases := map[string]struct { + variants []*SchemaIR + wantOK bool + wantNull bool + wantValues int + }{ + "empty variant list": { + variants: nil, + wantOK: false, + }, + "nil variant member": { + variants: []*SchemaIR{nil}, + wantOK: false, + }, + "variant without const": { + variants: []*SchemaIR{{}}, + wantOK: false, + }, + "declared object array type": { + variants: []*SchemaIR{ + {Const: stringNode("a"), SourceSchema: &highbase.Schema{Type: []string{"object"}}}, + {Const: stringNode("b"), SourceSchema: &highbase.Schema{Type: []string{"array"}}}, + }, + wantOK: false, + }, + "dynamic ref variant": { + variants: []*SchemaIR{ + {Const: stringNode("a"), SourceSchema: &highbase.Schema{DynamicRef: "#/components/schemas/X"}}, + }, + wantOK: false, + }, + "nested composition keyword variant": { + variants: []*SchemaIR{ + {Const: stringNode("a"), SourceSchema: &highbase.Schema{AllOf: []*highbase.SchemaProxy{schemaProxy(&highbase.Schema{})}}}, + }, + wantOK: false, + }, + "explicit enum keyword variant": { + variants: []*SchemaIR{ + {Const: stringNode("a"), SourceSchema: &highbase.Schema{Enum: []*yaml.Node{stringNode("x")}}}, + }, + wantOK: false, + }, + "properties variant": { + variants: []*SchemaIR{ + {Const: stringNode("a"), SourceSchema: &highbase.Schema{Properties: propMap()}}, + }, + wantOK: false, + }, + "pattern properties variant": { + variants: []*SchemaIR{ + {Const: stringNode("a"), SourceSchema: &highbase.Schema{PatternProperties: propMap()}}, + }, + wantOK: false, + }, + "items variant": { + variants: []*SchemaIR{ + {Const: stringNode("a"), SourceSchema: &highbase.Schema{Items: &highbase.DynamicValue[*highbase.SchemaProxy, bool]{A: schemaProxy(&highbase.Schema{})}}}, + }, + wantOK: false, + }, + "prefix items variant": { + variants: []*SchemaIR{ + {Const: stringNode("a"), SourceSchema: &highbase.Schema{PrefixItems: []*highbase.SchemaProxy{schemaProxy(&highbase.Schema{})}}}, + }, + wantOK: false, + }, + "null-only variant list": { + variants: []*SchemaIR{{Const: nullNode()}}, + wantOK: false, + }, + "scalar const plus null": { + variants: []*SchemaIR{{Const: nullNode()}, {Const: stringNode("a")}}, + wantOK: true, + wantNull: true, + wantValues: 1, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + values, nullable, ok := constScalarEnumFromVariants(tc.variants) + if ok != tc.wantOK { + t.Fatalf("ok = %v, want %v", ok, tc.wantOK) + } + if !ok { + return + } + if nullable != tc.wantNull { + t.Fatalf("nullable = %v, want %v", nullable, tc.wantNull) + } + if len(values) != tc.wantValues { + t.Fatalf("len(values) = %d, want %d", len(values), tc.wantValues) + } + }) + } +} \ No newline at end of file diff --git a/generator/golang/jsonschema_fidelity_test.go b/generator/golang/jsonschema_fidelity_test.go index e87509b8..c69c9f55 100644 --- a/generator/golang/jsonschema_fidelity_test.go +++ b/generator/golang/jsonschema_fidelity_test.go @@ -313,6 +313,108 @@ enum: assertParsesAndCompiles(t, file.Source) } +func TestJSONSchema202012ConstScalarEnumVariants(t *testing.T) { + schemas := orderedmap.New[string, *highbase.SchemaProxy]() + // OpenAPI 3.1 style const-based enum: oneOf of scalar const values. + schemas.Set("const string enum", schemaProxyFromYAML(t, ` +oneOf: + - const: available + title: Available + - const: pending + title: Pending + - const: sold + title: Sold +`)) + // const with an explicit scalar type on each variant. + schemas.Set("const typed enum", schemaProxyFromYAML(t, ` +oneOf: + - type: string + const: low + - type: string + const: high +`)) + // anyOf of const values is folded the same way. + schemas.Set("const anyOf enum", schemaProxyFromYAML(t, ` +anyOf: + - const: ready + - const: done +`)) + // integer const enum keeps the numeric base type. + schemas.Set("const int enum", schemaProxyFromYAML(t, ` +oneOf: + - const: 1 + - const: 2 + - const: 3 +`)) + // a null-of variant yields a nullable enum. + schemas.Set("const nullable enum", schemaProxyFromYAML(t, ` +oneOf: + - const: active + - const: paused + - type: "null" +`)) + // mixed const value types cannot form Go constants; fall back to any. + schemas.Set("const mixed enum", schemaProxyFromYAML(t, ` +oneOf: + - const: low + - const: 2 +`)) + + file, err := NewGenerator(WithEnumConstants(true)).RenderSchemas(schemas) + if err != nil { + t.Fatal(err) + } + src := strings.Join(strings.Fields(string(file.Source)), " ") + + assertContains(t, src, "type ConstStringEnum string") + assertContains(t, src, "ConstStringEnumAvailable ConstStringEnum = \"available\"") + assertContains(t, src, "ConstStringEnumSold ConstStringEnum = \"sold\"") + + assertContains(t, src, "type ConstTypedEnum string") + assertContains(t, src, "ConstTypedEnumLow ConstTypedEnum = \"low\"") + + assertContains(t, src, "type ConstAnyOfEnum string") + assertContains(t, src, "ConstAnyOfEnumReady ConstAnyOfEnum = \"ready\"") + + assertContains(t, src, "type ConstIntEnum int") + assertContains(t, src, "ConstIntEnumValue1 ConstIntEnum = 1") + + assertContains(t, src, "type ConstNullableEnum string") + assertContains(t, src, "ConstNullableEnumActive ConstNullableEnum = \"active\"") + + // Mixed-type const oneOf cannot produce typed constants. + assertContains(t, src, "type ConstMixedEnum any") + assertNotContains(t, src, "ConstMixedEnumLow") + + assertParsesAndCompiles(t, file.Source) +} + +// TestJSONSchemaSchemaObjectUnionNotEnum guards object oneOf variants (a real +// union, prototypical discriminated or plain) from being collapsed into an +// enum: the const-enum folding must only apply to whole-variant scalar consts. +func TestJSONSchemaSchemaObjectUnionNotEnum(t *testing.T) { + schemas := singleSchemaMap(t, "Shape", schemaProxyFromYAML(t, ` +oneOf: + - type: object + properties: + radius: + type: number + - type: object + properties: + width: + type: number + height: + type: number +`)) + file, err := NewGenerator(WithEnumConstants(true)).RenderSchemas(schemas) + if err != nil { + t.Fatal(err) + } + src := string(file.Source) + assertNotContains(t, src, "type Shape string") + assertParsesAndCompiles(t, file.Source) +} + func TestJSONSchema202012ClosedNestedObjectUsesStruct(t *testing.T) { source, err := RenderSchema("closed parent", schemaProxyFromYAML(t, ` type: object