From 2d4ffb2fd21c8199317da18f35b608cd41e8752f Mon Sep 17 00:00:00 2001 From: Andrey Sorochinskiy Date: Thu, 13 Aug 2026 17:41:25 +0300 Subject: [PATCH 1/2] feat(generator/golang): fold 3.1 const-based scalar oneOf/anyOf into enums OpenAPI 3.1 expresses string/number/boolean enums as `oneOf` of scalar `const` values (e.g. oneOf: [ {const: available}, {const: pending} ]). The model generator treated those as raw json.RawMessage unions, discarding the values, so enum constants were never emitted. Recognize a union whose non-null variants are all scalar consts and build it as a KindEnum instead (honouring null-of variants as nullable and mixed value types as `any`), so `WithEnumConstants` can render typed aliases and constants. Object/array/union/disambiguation variants that carry consts as properties are left as unions. Adds coverage for const enums, anyOf const, integer const enums, nullable const enums, mixed const types, and guards the object union from being collapsed into an enum. --- generator/golang/from_openapi.go | 58 +++++++++++ generator/golang/jsonschema_fidelity_test.go | 102 +++++++++++++++++++ 2 files changed, 160 insertions(+) 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/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 From 0ee2731653a7ae164bf340b33ffac37e02dda743 Mon Sep 17 00:00:00 2001 From: Andrey Sorochinskiy Date: Fri, 14 Aug 2026 11:59:21 +0300 Subject: [PATCH 2/2] test(generator/golang): cover constScalarEnumFromVariants rejection paths Add a table-driven unit test that hits every veto branch of constScalarEnumFromVariants: nil/empty variant lists, a nil member, a variant without a const, declared object/array types, , nested composition keywords, explicit enum, properties/patternProperties, items, prefixItems, and a null-only list. Raises the function to 100% coverage so the codecov patch gate stays green. --- generator/golang/from_openapi_test.go | 126 ++++++++++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 generator/golang/from_openapi_test.go 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