Skip to content
Open
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
58 changes: 58 additions & 0 deletions generator/golang/from_openapi.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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
Expand Down
126 changes: 126 additions & 0 deletions generator/golang/from_openapi_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
102 changes: 102 additions & 0 deletions generator/golang/jsonschema_fidelity_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading