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
8 changes: 8 additions & 0 deletions Configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 3 additions & 1 deletion codegen/internal/codegen.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
7 changes: 7 additions & 0 deletions codegen/internal/configuration.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
66 changes: 66 additions & 0 deletions codegen/internal/enum_oneof.go
Original file line number Diff line number Diff line change
@@ -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
}
147 changes: 147 additions & 0 deletions codegen/internal/enum_oneof_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
4 changes: 4 additions & 0 deletions codegen/internal/enumresolution.go
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
40 changes: 34 additions & 6 deletions codegen/internal/gather.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand All @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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)
}
}
}

Expand Down
6 changes: 6 additions & 0 deletions codegen/internal/output.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
6 changes: 6 additions & 0 deletions codegen/internal/schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 5 additions & 0 deletions codegen/internal/test/components/enum_via_oneof/config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package: output
output: output/types.gen.go
generation:
runtime-package:
path: github.com/oapi-codegen/oapi-codegen-exp/runtime
6 changes: 6 additions & 0 deletions codegen/internal/test/components/enum_via_oneof/doc.go
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading