From 0e291e2e7b1928dd88aad2eb5be3fed26966b5b7 Mon Sep 17 00:00:00 2001 From: Igor Hittrich Date: Mon, 27 Apr 2026 18:01:07 +0200 Subject: [PATCH] Add support for if/then/else conditional schemas --- README.md | 2 +- docs/union-types.md | 83 +++++- examples/ifthenelse/basic/api.yaml | 42 +++ examples/ifthenelse/basic/cfg.yaml | 3 + examples/ifthenelse/basic/gen.go | 119 ++++++++ examples/ifthenelse/basic/generate.go | 3 + examples/ifthenelse/inside-allof/api.yaml | 48 ++++ examples/ifthenelse/inside-allof/cfg.yaml | 3 + examples/ifthenelse/inside-allof/gen.go | 193 +++++++++++++ examples/ifthenelse/inside-allof/generate.go | 3 + examples/ifthenelse/nested/api.yaml | 47 ++++ examples/ifthenelse/nested/cfg.yaml | 3 + examples/ifthenelse/nested/gen.go | 118 ++++++++ examples/ifthenelse/nested/generate.go | 3 + examples/ifthenelse/then-only/api.yaml | 35 +++ examples/ifthenelse/then-only/cfg.yaml | 3 + examples/ifthenelse/then-only/gen.go | 23 ++ examples/ifthenelse/then-only/generate.go | 3 + examples/ifthenelse/with-refs/api.yaml | 52 ++++ examples/ifthenelse/with-refs/cfg.yaml | 3 + examples/ifthenelse/with-refs/gen.go | 121 ++++++++ examples/ifthenelse/with-refs/generate.go | 3 + pkg/codegen/prune.go | 4 +- pkg/codegen/schema.go | 8 +- pkg/codegen/schema_merge.go | 106 ++++++- pkg/codegen/schema_merge_test.go | 68 +++++ pkg/codegen/templates/union.tmpl | 31 ++- pkg/codegen/testdata/if-then-else.yml | 147 ++++++++++ pkg/runtime/conditional.go | 157 +++++++++++ pkg/runtime/conditional_test.go | 277 +++++++++++++++++++ 30 files changed, 1704 insertions(+), 7 deletions(-) create mode 100644 examples/ifthenelse/basic/api.yaml create mode 100644 examples/ifthenelse/basic/cfg.yaml create mode 100644 examples/ifthenelse/basic/gen.go create mode 100644 examples/ifthenelse/basic/generate.go create mode 100644 examples/ifthenelse/inside-allof/api.yaml create mode 100644 examples/ifthenelse/inside-allof/cfg.yaml create mode 100644 examples/ifthenelse/inside-allof/gen.go create mode 100644 examples/ifthenelse/inside-allof/generate.go create mode 100644 examples/ifthenelse/nested/api.yaml create mode 100644 examples/ifthenelse/nested/cfg.yaml create mode 100644 examples/ifthenelse/nested/gen.go create mode 100644 examples/ifthenelse/nested/generate.go create mode 100644 examples/ifthenelse/then-only/api.yaml create mode 100644 examples/ifthenelse/then-only/cfg.yaml create mode 100644 examples/ifthenelse/then-only/gen.go create mode 100644 examples/ifthenelse/then-only/generate.go create mode 100644 examples/ifthenelse/with-refs/api.yaml create mode 100644 examples/ifthenelse/with-refs/cfg.yaml create mode 100644 examples/ifthenelse/with-refs/gen.go create mode 100644 examples/ifthenelse/with-refs/generate.go create mode 100644 pkg/codegen/testdata/if-then-else.yml create mode 100644 pkg/runtime/conditional.go create mode 100644 pkg/runtime/conditional_test.go diff --git a/README.md b/README.md index b32d63ec..b4c464a9 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ on the real value-add for your organization. - **Smart pruning** - Automatically removes unused types by default (configurable) ### Type System -- **Union types** - Full support for `oneOf`, `anyOf`, and `allOf` with intelligent type merging and `runtime.Either[A, B]` for two-element unions +- **Union types** - Full support for `oneOf`, `anyOf`, `allOf`, and `if`/`then`/`else` with intelligent type merging, `runtime.Either[A, B]` for two-element unions, and `runtime.Conditional[T, E]` for conditional schemas - **Additional properties** - Handle dynamic fields with `map[string]interface{}` or custom types - **Validation** - Built-in validation using [go-playground/validator](https://github.com/go-playground/validator) with `Validate()` methods on generated types - **Custom extensions** - for fine-grained control over code generation diff --git a/docs/union-types.md b/docs/union-types.md index 19365ad0..fe83eafa 100644 --- a/docs/union-types.md +++ b/docs/union-types.md @@ -1,12 +1,13 @@ # Union Types -Union types in OpenAPI allow schemas to accept multiple different types or structures. The `oapi-codegen` generator handles `allOf`, `anyOf`, and `oneOf` with intelligent type generation based on the number and nature of the variants. +Union types in OpenAPI allow schemas to accept multiple different types or structures. The `oapi-codegen` generator handles `allOf`, `anyOf`, `oneOf`, and `if`/`then`/`else` with intelligent type generation based on the number and nature of the variants. ## Overview - **`allOf`**: Merges all schemas into a single struct with all fields combined - **`anyOf`**: Can match any of the specified schemas - **`oneOf`**: Must match exactly one of the specified schemas +- **`if`/`then`/`else`**: Conditional schemas (OpenAPI 3.1) - uses `runtime.Conditional[T, E]` The generator applies smart optimizations based on the union structure: @@ -167,3 +168,83 @@ The `runtime.JSONMerge` function combines all the JSON parts into a single objec [View complex union example](https://github.com/doordash-oss/oapi-codegen-dd/tree/main/examples/union/allof-anyof-oneof/){:target="_blank"} +--- + +## Conditional Schemas (`if`/`then`/`else`) + +OpenAPI 3.1 supports JSON Schema conditional keywords. The generator +treats `then` and `else` as structural branches. The `if` schema is +ignored - it's a validation predicate (e.g. "if kind equals typeA") +with no structural properties. `if` is also a reserved keyword in Go. + +### Both Branches - `runtime.Conditional` + +When both `then` and `else` are present, the generator creates a +`runtime.Conditional[T, E]` wrapper with named variant types. Unlike +`runtime.Either` (`.A`/`.B`), `Conditional` uses `.Then`/`.Else` +fields and `.IsThen()`/`.IsElse()` methods. + +**OpenAPI Spec:** + +```yaml +--8<-- "ifthenelse/basic/api.yaml:20:42" +``` + +**Generated Go Code:** + +```go +--8<-- "ifthenelse/basic/gen.go:16:19" +``` + +The variant types use `_Then` and `_Else` suffixes: + +```go +--8<-- "ifthenelse/basic/gen.go:86:98" +``` + +Usage: + +```go +if resource.Resource_IfThenElse.IsThen() { + fmt.Println(resource.Resource_IfThenElse.Then.FieldA) +} +if resource.Resource_IfThenElse.IsElse() { + fmt.Println(resource.Resource_IfThenElse.Else.FieldB) +} +``` + +[View basic example](https://github.com/doordash-oss/oapi-codegen-dd/tree/main/examples/ifthenelse/basic/){:target="_blank"} + +### Single Branch - Flat Merge + +When only `then` or only `else` is present, the branch properties +are flat-merged into the parent struct. No wrapper type is created. + +**OpenAPI Spec:** + +```yaml +--8<-- "ifthenelse/then-only/api.yaml:20:35" +``` + +**Generated Go Code:** + +```go +--8<-- "ifthenelse/then-only/gen.go:12:16" +``` + +The `timeout` and `retries` fields from the `then` branch appear +directly on the `Config` struct. + +[View then-only example](https://github.com/doordash-oss/oapi-codegen-dd/tree/main/examples/ifthenelse/then-only/){:target="_blank"} + +### With `$ref` Branches + +When `then`/`else` reference component schemas, the generated +`Conditional` uses the referenced type names directly: + +```go +--8<-- "ifthenelse/with-refs/gen.go:98:100" +``` + +[View with-refs example](https://github.com/doordash-oss/oapi-codegen-dd/tree/main/examples/ifthenelse/with-refs/){:target="_blank"} + diff --git a/examples/ifthenelse/basic/api.yaml b/examples/ifthenelse/basic/api.yaml new file mode 100644 index 00000000..d0873f42 --- /dev/null +++ b/examples/ifthenelse/basic/api.yaml @@ -0,0 +1,42 @@ +openapi: "3.1.0" +info: + version: 1.0.0 + title: Basic if/then/else + description: Both then and else branches produce a union (Either) type +paths: + /resources: + get: + operationId: listResources + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Resource' + +components: + schemas: + Resource: + type: object + properties: + kind: + type: string + if: + properties: + kind: + const: "typeA" + then: + type: object + properties: + fieldA: + type: string + valueA: + type: integer + else: + type: object + properties: + fieldB: + type: boolean + valueB: + type: number diff --git a/examples/ifthenelse/basic/cfg.yaml b/examples/ifthenelse/basic/cfg.yaml new file mode 100644 index 00000000..a5328c61 --- /dev/null +++ b/examples/ifthenelse/basic/cfg.yaml @@ -0,0 +1,3 @@ +# yaml-language-server: $schema=../../configuration-schema.json +package: gen +skip-prune: true diff --git a/examples/ifthenelse/basic/gen.go b/examples/ifthenelse/basic/gen.go new file mode 100644 index 00000000..b415a0cd --- /dev/null +++ b/examples/ifthenelse/basic/gen.go @@ -0,0 +1,119 @@ +// Code generated by oapi-codegen. DO NOT EDIT. + +package gen + +import ( + "bytes" + "encoding/json" + "fmt" + + "github.com/doordash-oss/oapi-codegen-dd/v3/pkg/runtime" + "github.com/go-playground/validator/v10" +) + +type ListResourcesResponse = Resource + +type Resource struct { + Kind *string `json:"kind,omitempty"` + Resource_IfThenElse *Resource_IfThenElse `json:"-"` +} + +func (r Resource) Validate() error { + var errors runtime.ValidationErrors + if r.Resource_IfThenElse != nil { + if v, ok := any(r.Resource_IfThenElse).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Resource_IfThenElse", err) + } + } + } + if len(errors) == 0 { + return nil + } + return errors +} + +func (r Resource) MarshalJSON() ([]byte, error) { + var parts []json.RawMessage + + type _Alias_Resource Resource + baseJSON, err := json.Marshal((_Alias_Resource)(r)) + if err != nil { + return nil, err + } + parts = append(parts, baseJSON) + + { + b, err := runtime.MarshalJSON(r.Resource_IfThenElse) + if err != nil { + return nil, fmt.Errorf("Resource_IfThenElse marshal: %w", err) + } + parts = append(parts, b) + } + + return runtime.CoalesceOrMerge(parts...) +} + +func (r *Resource) UnmarshalJSON(data []byte) error { + trim := bytes.TrimSpace(data) + if bytes.Equal(trim, []byte("null")) { + return nil + } + if len(trim) == 0 { + return fmt.Errorf("empty JSON input") + } + + if len(trim) > 0 { + type _Alias_Resource Resource + var tmp _Alias_Resource + if err := json.Unmarshal(data, &tmp); err != nil { + return err + } + *r = Resource(tmp) + } + + if r.Resource_IfThenElse == nil { + r.Resource_IfThenElse = &Resource_IfThenElse{} + } + + if err := runtime.UnmarshalJSON(data, r.Resource_IfThenElse); err != nil { + return fmt.Errorf("Resource_IfThenElse unmarshal: %w", err) + } + + return nil +} + +type Resource_Then struct { + FieldA *string `json:"fieldA,omitempty"` + ValueA *int `json:"valueA,omitempty"` +} + +type Resource_Else struct { + FieldB *bool `json:"fieldB,omitempty"` + ValueB *float32 `json:"valueB,omitempty"` +} + +type Resource_IfThenElse struct { + runtime.Conditional[Resource_Then, Resource_Else] +} + +func (r *Resource_IfThenElse) Validate() error { + if r.IsThen() { + if v, ok := any(r.Then).(runtime.Validator); ok { + return v.Validate() + } + } + if r.IsElse() { + if v, ok := any(r.Else).(runtime.Validator); ok { + return v.Validate() + } + } + return nil +} + +var typesValidator *validator.Validate + +func init() { + typesValidator = validator.New(validator.WithRequiredStructEnabled()) + runtime.RegisterCustomTypeFunc(typesValidator) +} diff --git a/examples/ifthenelse/basic/generate.go b/examples/ifthenelse/basic/generate.go new file mode 100644 index 00000000..ede7b3d6 --- /dev/null +++ b/examples/ifthenelse/basic/generate.go @@ -0,0 +1,3 @@ +package gen + +//go:generate go run github.com/doordash-oss/oapi-codegen-dd/v3/cmd/oapi-codegen -config cfg.yaml api.yaml diff --git a/examples/ifthenelse/inside-allof/api.yaml b/examples/ifthenelse/inside-allof/api.yaml new file mode 100644 index 00000000..6d3af0bb --- /dev/null +++ b/examples/ifthenelse/inside-allof/api.yaml @@ -0,0 +1,48 @@ +openapi: "3.1.0" +info: + version: 1.0.0 + title: if/then/else inside allOf + description: Conditional branches nested inside an allOf composition +paths: + /resources: + get: + operationId: listResources + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/SpecialResource' + +components: + schemas: + BaseResource: + type: object + properties: + id: + type: string + name: + type: string + + SpecialResource: + allOf: + - $ref: '#/components/schemas/BaseResource' + - type: object + properties: + category: + type: string + if: + properties: + category: + const: "premium" + then: + type: object + properties: + premiumFeature: + type: string + else: + type: object + properties: + standardLimit: + type: integer diff --git a/examples/ifthenelse/inside-allof/cfg.yaml b/examples/ifthenelse/inside-allof/cfg.yaml new file mode 100644 index 00000000..a5328c61 --- /dev/null +++ b/examples/ifthenelse/inside-allof/cfg.yaml @@ -0,0 +1,3 @@ +# yaml-language-server: $schema=../../configuration-schema.json +package: gen +skip-prune: true diff --git a/examples/ifthenelse/inside-allof/gen.go b/examples/ifthenelse/inside-allof/gen.go new file mode 100644 index 00000000..29931c78 --- /dev/null +++ b/examples/ifthenelse/inside-allof/gen.go @@ -0,0 +1,193 @@ +// Code generated by oapi-codegen. DO NOT EDIT. + +package gen + +import ( + "bytes" + "encoding/json" + "fmt" + + "github.com/doordash-oss/oapi-codegen-dd/v3/pkg/runtime" + "github.com/go-playground/validator/v10" +) + +type ListResourcesResponse = SpecialResource + +type BaseResource struct { + ID *string `json:"id,omitempty"` + Name *string `json:"name,omitempty"` +} + +type SpecialResource struct { + BaseResource BaseResource `json:"-"` + SpecialResource_AllOf1 *SpecialResource_AllOf1 `json:"-"` +} + +func (s SpecialResource) Validate() error { + var errors runtime.ValidationErrors + if v, ok := any(s.BaseResource).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("BaseResource", err) + } + } + if s.SpecialResource_AllOf1 != nil { + if v, ok := any(s.SpecialResource_AllOf1).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("SpecialResource_AllOf1", err) + } + } + } + if len(errors) == 0 { + return nil + } + return errors +} + +func (s SpecialResource) MarshalJSON() ([]byte, error) { + var parts []json.RawMessage + + { + b, err := runtime.MarshalJSON(s.BaseResource) + if err != nil { + return nil, fmt.Errorf("BaseResource marshal: %w", err) + } + parts = append(parts, b) + } + + { + b, err := runtime.MarshalJSON(s.SpecialResource_AllOf1) + if err != nil { + return nil, fmt.Errorf("SpecialResource_AllOf1 marshal: %w", err) + } + parts = append(parts, b) + } + + return runtime.CoalesceOrMerge(parts...) +} + +func (s *SpecialResource) UnmarshalJSON(data []byte) error { + trim := bytes.TrimSpace(data) + if bytes.Equal(trim, []byte("null")) { + return nil + } + if len(trim) == 0 { + return fmt.Errorf("empty JSON input") + } + + if err := runtime.UnmarshalJSON(data, &s.BaseResource); err != nil { + return fmt.Errorf("BaseResource unmarshal: %w", err) + } + + if s.SpecialResource_AllOf1 == nil { + s.SpecialResource_AllOf1 = &SpecialResource_AllOf1{} + } + + if err := runtime.UnmarshalJSON(data, s.SpecialResource_AllOf1); err != nil { + return fmt.Errorf("SpecialResource_AllOf1 unmarshal: %w", err) + } + + return nil +} + +type SpecialResource_AllOf1 struct { + Category *string `json:"category,omitempty"` + SpecialResource_AllOf1_IfThenElse *SpecialResource_AllOf1_IfThenElse `json:"-"` +} + +func (s SpecialResource_AllOf1) Validate() error { + var errors runtime.ValidationErrors + if s.SpecialResource_AllOf1_IfThenElse != nil { + if v, ok := any(s.SpecialResource_AllOf1_IfThenElse).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("SpecialResource_AllOf1_IfThenElse", err) + } + } + } + if len(errors) == 0 { + return nil + } + return errors +} + +func (s SpecialResource_AllOf1) MarshalJSON() ([]byte, error) { + var parts []json.RawMessage + + type _Alias_SpecialResource_AllOf1 SpecialResource_AllOf1 + baseJSON, err := json.Marshal((_Alias_SpecialResource_AllOf1)(s)) + if err != nil { + return nil, err + } + parts = append(parts, baseJSON) + + { + b, err := runtime.MarshalJSON(s.SpecialResource_AllOf1_IfThenElse) + if err != nil { + return nil, fmt.Errorf("SpecialResource_AllOf1_IfThenElse marshal: %w", err) + } + parts = append(parts, b) + } + + return runtime.CoalesceOrMerge(parts...) +} + +func (s *SpecialResource_AllOf1) UnmarshalJSON(data []byte) error { + trim := bytes.TrimSpace(data) + if bytes.Equal(trim, []byte("null")) { + return nil + } + if len(trim) == 0 { + return fmt.Errorf("empty JSON input") + } + + if len(trim) > 0 { + type _Alias_SpecialResource_AllOf1 SpecialResource_AllOf1 + var tmp _Alias_SpecialResource_AllOf1 + if err := json.Unmarshal(data, &tmp); err != nil { + return err + } + *s = SpecialResource_AllOf1(tmp) + } + + if s.SpecialResource_AllOf1_IfThenElse == nil { + s.SpecialResource_AllOf1_IfThenElse = &SpecialResource_AllOf1_IfThenElse{} + } + + if err := runtime.UnmarshalJSON(data, s.SpecialResource_AllOf1_IfThenElse); err != nil { + return fmt.Errorf("SpecialResource_AllOf1_IfThenElse unmarshal: %w", err) + } + + return nil +} + +type SpecialResource_AllOf1_Then struct { + PremiumFeature *string `json:"premiumFeature,omitempty"` +} + +type SpecialResource_AllOf1_Else struct { + StandardLimit *int `json:"standardLimit,omitempty"` +} + +type SpecialResource_AllOf1_IfThenElse struct { + runtime.Conditional[SpecialResource_AllOf1_Then, SpecialResource_AllOf1_Else] +} + +func (s *SpecialResource_AllOf1_IfThenElse) Validate() error { + if s.IsThen() { + if v, ok := any(s.Then).(runtime.Validator); ok { + return v.Validate() + } + } + if s.IsElse() { + if v, ok := any(s.Else).(runtime.Validator); ok { + return v.Validate() + } + } + return nil +} + +var typesValidator *validator.Validate + +func init() { + typesValidator = validator.New(validator.WithRequiredStructEnabled()) + runtime.RegisterCustomTypeFunc(typesValidator) +} diff --git a/examples/ifthenelse/inside-allof/generate.go b/examples/ifthenelse/inside-allof/generate.go new file mode 100644 index 00000000..ede7b3d6 --- /dev/null +++ b/examples/ifthenelse/inside-allof/generate.go @@ -0,0 +1,3 @@ +package gen + +//go:generate go run github.com/doordash-oss/oapi-codegen-dd/v3/cmd/oapi-codegen -config cfg.yaml api.yaml diff --git a/examples/ifthenelse/nested/api.yaml b/examples/ifthenelse/nested/api.yaml new file mode 100644 index 00000000..e0bb9f65 --- /dev/null +++ b/examples/ifthenelse/nested/api.yaml @@ -0,0 +1,47 @@ +openapi: "3.1.0" +info: + version: 1.0.0 + title: Nested if/then/else + description: Conditional branches with nesting in the then branch +paths: + /items: + get: + operationId: listItems + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Item' + +components: + schemas: + Item: + type: object + properties: + level: + type: string + if: + properties: + level: + const: "outer" + then: + type: object + properties: + outerField: + type: string + if: + properties: + outerField: + const: "nested" + then: + type: object + properties: + nestedField: + type: integer + else: + type: object + properties: + fallbackField: + type: boolean diff --git a/examples/ifthenelse/nested/cfg.yaml b/examples/ifthenelse/nested/cfg.yaml new file mode 100644 index 00000000..a5328c61 --- /dev/null +++ b/examples/ifthenelse/nested/cfg.yaml @@ -0,0 +1,3 @@ +# yaml-language-server: $schema=../../configuration-schema.json +package: gen +skip-prune: true diff --git a/examples/ifthenelse/nested/gen.go b/examples/ifthenelse/nested/gen.go new file mode 100644 index 00000000..0330e8f1 --- /dev/null +++ b/examples/ifthenelse/nested/gen.go @@ -0,0 +1,118 @@ +// Code generated by oapi-codegen. DO NOT EDIT. + +package gen + +import ( + "bytes" + "encoding/json" + "fmt" + + "github.com/doordash-oss/oapi-codegen-dd/v3/pkg/runtime" + "github.com/go-playground/validator/v10" +) + +type ListItemsResponse = Item + +type Item struct { + Level *string `json:"level,omitempty"` + Item_IfThenElse *Item_IfThenElse `json:"-"` +} + +func (i Item) Validate() error { + var errors runtime.ValidationErrors + if i.Item_IfThenElse != nil { + if v, ok := any(i.Item_IfThenElse).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Item_IfThenElse", err) + } + } + } + if len(errors) == 0 { + return nil + } + return errors +} + +func (i Item) MarshalJSON() ([]byte, error) { + var parts []json.RawMessage + + type _Alias_Item Item + baseJSON, err := json.Marshal((_Alias_Item)(i)) + if err != nil { + return nil, err + } + parts = append(parts, baseJSON) + + { + b, err := runtime.MarshalJSON(i.Item_IfThenElse) + if err != nil { + return nil, fmt.Errorf("Item_IfThenElse marshal: %w", err) + } + parts = append(parts, b) + } + + return runtime.CoalesceOrMerge(parts...) +} + +func (i *Item) UnmarshalJSON(data []byte) error { + trim := bytes.TrimSpace(data) + if bytes.Equal(trim, []byte("null")) { + return nil + } + if len(trim) == 0 { + return fmt.Errorf("empty JSON input") + } + + if len(trim) > 0 { + type _Alias_Item Item + var tmp _Alias_Item + if err := json.Unmarshal(data, &tmp); err != nil { + return err + } + *i = Item(tmp) + } + + if i.Item_IfThenElse == nil { + i.Item_IfThenElse = &Item_IfThenElse{} + } + + if err := runtime.UnmarshalJSON(data, i.Item_IfThenElse); err != nil { + return fmt.Errorf("Item_IfThenElse unmarshal: %w", err) + } + + return nil +} + +type Item_Then struct { + OuterField *string `json:"outerField,omitempty"` + NestedField *int `json:"nestedField,omitempty"` +} + +type Item_Else struct { + FallbackField *bool `json:"fallbackField,omitempty"` +} + +type Item_IfThenElse struct { + runtime.Conditional[Item_Then, Item_Else] +} + +func (i *Item_IfThenElse) Validate() error { + if i.IsThen() { + if v, ok := any(i.Then).(runtime.Validator); ok { + return v.Validate() + } + } + if i.IsElse() { + if v, ok := any(i.Else).(runtime.Validator); ok { + return v.Validate() + } + } + return nil +} + +var typesValidator *validator.Validate + +func init() { + typesValidator = validator.New(validator.WithRequiredStructEnabled()) + runtime.RegisterCustomTypeFunc(typesValidator) +} diff --git a/examples/ifthenelse/nested/generate.go b/examples/ifthenelse/nested/generate.go new file mode 100644 index 00000000..ede7b3d6 --- /dev/null +++ b/examples/ifthenelse/nested/generate.go @@ -0,0 +1,3 @@ +package gen + +//go:generate go run github.com/doordash-oss/oapi-codegen-dd/v3/cmd/oapi-codegen -config cfg.yaml api.yaml diff --git a/examples/ifthenelse/then-only/api.yaml b/examples/ifthenelse/then-only/api.yaml new file mode 100644 index 00000000..7a77d52c --- /dev/null +++ b/examples/ifthenelse/then-only/api.yaml @@ -0,0 +1,35 @@ +openapi: "3.1.0" +info: + version: 1.0.0 + title: if/then only (no else) + description: Single branch flat merges into parent +paths: + /configs: + get: + operationId: listConfigs + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Config' + +components: + schemas: + Config: + type: object + properties: + enabled: + type: boolean + if: + properties: + enabled: + const: true + then: + type: object + properties: + timeout: + type: integer + retries: + type: integer diff --git a/examples/ifthenelse/then-only/cfg.yaml b/examples/ifthenelse/then-only/cfg.yaml new file mode 100644 index 00000000..a5328c61 --- /dev/null +++ b/examples/ifthenelse/then-only/cfg.yaml @@ -0,0 +1,3 @@ +# yaml-language-server: $schema=../../configuration-schema.json +package: gen +skip-prune: true diff --git a/examples/ifthenelse/then-only/gen.go b/examples/ifthenelse/then-only/gen.go new file mode 100644 index 00000000..1efe0fc0 --- /dev/null +++ b/examples/ifthenelse/then-only/gen.go @@ -0,0 +1,23 @@ +// Code generated by oapi-codegen. DO NOT EDIT. + +package gen + +import ( + "github.com/doordash-oss/oapi-codegen-dd/v3/pkg/runtime" + "github.com/go-playground/validator/v10" +) + +type ListConfigsResponse = Config + +type Config struct { + Enabled *bool `json:"enabled,omitempty"` + Timeout *int `json:"timeout,omitempty"` + Retries *int `json:"retries,omitempty"` +} + +var typesValidator *validator.Validate + +func init() { + typesValidator = validator.New(validator.WithRequiredStructEnabled()) + runtime.RegisterCustomTypeFunc(typesValidator) +} diff --git a/examples/ifthenelse/then-only/generate.go b/examples/ifthenelse/then-only/generate.go new file mode 100644 index 00000000..ede7b3d6 --- /dev/null +++ b/examples/ifthenelse/then-only/generate.go @@ -0,0 +1,3 @@ +package gen + +//go:generate go run github.com/doordash-oss/oapi-codegen-dd/v3/cmd/oapi-codegen -config cfg.yaml api.yaml diff --git a/examples/ifthenelse/with-refs/api.yaml b/examples/ifthenelse/with-refs/api.yaml new file mode 100644 index 00000000..f6713d0e --- /dev/null +++ b/examples/ifthenelse/with-refs/api.yaml @@ -0,0 +1,52 @@ +openapi: "3.1.0" +info: + version: 1.0.0 + title: if/then/else with $ref branches + description: Conditional branches that reference component schemas +paths: + /events: + get: + operationId: listEvents + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Event' + +components: + schemas: + ClickData: + type: object + properties: + elementId: + type: string + x: + type: integer + y: + type: integer + + PageViewData: + type: object + properties: + url: + type: string + duration: + type: integer + + Event: + type: object + properties: + type: + type: string + timestamp: + type: string + if: + properties: + type: + const: "click" + then: + $ref: '#/components/schemas/ClickData' + else: + $ref: '#/components/schemas/PageViewData' diff --git a/examples/ifthenelse/with-refs/cfg.yaml b/examples/ifthenelse/with-refs/cfg.yaml new file mode 100644 index 00000000..a5328c61 --- /dev/null +++ b/examples/ifthenelse/with-refs/cfg.yaml @@ -0,0 +1,3 @@ +# yaml-language-server: $schema=../../configuration-schema.json +package: gen +skip-prune: true diff --git a/examples/ifthenelse/with-refs/gen.go b/examples/ifthenelse/with-refs/gen.go new file mode 100644 index 00000000..375e2a6d --- /dev/null +++ b/examples/ifthenelse/with-refs/gen.go @@ -0,0 +1,121 @@ +// Code generated by oapi-codegen. DO NOT EDIT. + +package gen + +import ( + "bytes" + "encoding/json" + "fmt" + + "github.com/doordash-oss/oapi-codegen-dd/v3/pkg/runtime" + "github.com/go-playground/validator/v10" +) + +type ListEventsResponse = Event + +type ClickData struct { + ElementID *string `json:"elementId,omitempty"` + X *int `json:"x,omitempty"` + Y *int `json:"y,omitempty"` +} + +type PageViewData struct { + URL *string `json:"url,omitempty"` + Duration *int `json:"duration,omitempty"` +} + +type Event struct { + Type *string `json:"type,omitempty"` + Timestamp *string `json:"timestamp,omitempty"` + Event_IfThenElse *Event_IfThenElse `json:"-"` +} + +func (e Event) Validate() error { + var errors runtime.ValidationErrors + if e.Event_IfThenElse != nil { + if v, ok := any(e.Event_IfThenElse).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Event_IfThenElse", err) + } + } + } + if len(errors) == 0 { + return nil + } + return errors +} + +func (e Event) MarshalJSON() ([]byte, error) { + var parts []json.RawMessage + + type _Alias_Event Event + baseJSON, err := json.Marshal((_Alias_Event)(e)) + if err != nil { + return nil, err + } + parts = append(parts, baseJSON) + + { + b, err := runtime.MarshalJSON(e.Event_IfThenElse) + if err != nil { + return nil, fmt.Errorf("Event_IfThenElse marshal: %w", err) + } + parts = append(parts, b) + } + + return runtime.CoalesceOrMerge(parts...) +} + +func (e *Event) UnmarshalJSON(data []byte) error { + trim := bytes.TrimSpace(data) + if bytes.Equal(trim, []byte("null")) { + return nil + } + if len(trim) == 0 { + return fmt.Errorf("empty JSON input") + } + + if len(trim) > 0 { + type _Alias_Event Event + var tmp _Alias_Event + if err := json.Unmarshal(data, &tmp); err != nil { + return err + } + *e = Event(tmp) + } + + if e.Event_IfThenElse == nil { + e.Event_IfThenElse = &Event_IfThenElse{} + } + + if err := runtime.UnmarshalJSON(data, e.Event_IfThenElse); err != nil { + return fmt.Errorf("Event_IfThenElse unmarshal: %w", err) + } + + return nil +} + +type Event_IfThenElse struct { + runtime.Conditional[ClickData, PageViewData] +} + +func (e *Event_IfThenElse) Validate() error { + if e.IsThen() { + if v, ok := any(e.Then).(runtime.Validator); ok { + return v.Validate() + } + } + if e.IsElse() { + if v, ok := any(e.Else).(runtime.Validator); ok { + return v.Validate() + } + } + return nil +} + +var typesValidator *validator.Validate + +func init() { + typesValidator = validator.New(validator.WithRequiredStructEnabled()) + runtime.RegisterCustomTypeFunc(typesValidator) +} diff --git a/examples/ifthenelse/with-refs/generate.go b/examples/ifthenelse/with-refs/generate.go new file mode 100644 index 00000000..ede7b3d6 --- /dev/null +++ b/examples/ifthenelse/with-refs/generate.go @@ -0,0 +1,3 @@ +package gen + +//go:generate go run github.com/doordash-oss/oapi-codegen-dd/v3/cmd/oapi-codegen -config cfg.yaml api.yaml diff --git a/pkg/codegen/prune.go b/pkg/codegen/prune.go index 72dae6e7..ab03c1d0 100644 --- a/pkg/codegen/prune.go +++ b/pkg/codegen/prune.go @@ -308,8 +308,8 @@ func collectSchemaRefs(schema *base.Schema, refSet map[string]bool, model *v3hig collectSchemaRefs(ap.A.Schema(), refSet, model) } - // allOf / oneOf / anyOf / not - for _, group := range [][]*base.SchemaProxy{schema.AllOf, schema.OneOf, schema.AnyOf, {schema.Not}} { + // allOf / oneOf / anyOf / not / if / then / else + for _, group := range [][]*base.SchemaProxy{schema.AllOf, schema.OneOf, schema.AnyOf, {schema.Not}, {schema.If}, {schema.Then}, {schema.Else}} { for _, sp := range group { if sp == nil { continue diff --git a/pkg/codegen/schema.go b/pkg/codegen/schema.go index 989c734a..613611ca 100644 --- a/pkg/codegen/schema.go +++ b/pkg/codegen/schema.go @@ -52,6 +52,10 @@ type GoSchema struct { // True if this schema is a struct wrapper around a union (embedded Either or union field) IsUnionWrapper bool + // True if this union was generated from an if/then/else schema. + // Uses runtime.Conditional instead of runtime.Either. + IsConditional bool + DefineViaAlias bool IsPrimitiveAlias bool OpenAPISchema *base.Schema @@ -260,7 +264,9 @@ func (s GoSchema) createGoStruct(fields []string) string { ) } - if len(s.UnionElements) == 2 { + if len(s.UnionElements) == 2 && s.IsConditional { + objectParts = append(objectParts, fmt.Sprintf("runtime.Conditional[%s, %s]", s.UnionElements[0], s.UnionElements[1])) + } else if len(s.UnionElements) == 2 { objectParts = append(objectParts, fmt.Sprintf("runtime.Either[%s, %s]", s.UnionElements[0], s.UnionElements[1])) } else if len(s.UnionElements) > 0 { objectParts = append(objectParts, "union json.RawMessage") diff --git a/pkg/codegen/schema_merge.go b/pkg/codegen/schema_merge.go index c1755d06..616ff2d3 100644 --- a/pkg/codegen/schema_merge.go +++ b/pkg/codegen/schema_merge.go @@ -30,8 +30,9 @@ func createFromCombinator(schema *base.Schema, options ParseOptions) (GoSchema, hasAllOf := len(schema.AllOf) > 0 hasAnyOf := len(schema.AnyOf) > 0 hasOneOf := len(schema.OneOf) > 0 + hasIfThenElse := schema.Then != nil || schema.Else != nil - if !hasAllOf && !hasAnyOf && !hasOneOf { + if !hasAllOf && !hasAnyOf && !hasOneOf && !hasIfThenElse { return GoSchema{}, nil } @@ -162,6 +163,106 @@ func createFromCombinator(schema *base.Schema, options ParseOptions) (GoSchema, }) } + if hasIfThenElse { + // The if schema is a validation concern and does not contribute + // structural properties. We process then/else as union variants + // with named path segments so the generated types are called + // _Then/_Else instead of opaque _0/_1. + type branchDef struct { + name string + proxy *base.SchemaProxy + } + var branchList []branchDef + if schema.Then != nil { + branchList = append(branchList, branchDef{"then", schema.Then}) + } + if schema.Else != nil { + branchList = append(branchList, branchDef{"else", schema.Else}) + } + + // Single branch (only then or only else): flat merge into parent + // via single-element generateUnion optimization. + if len(branchList) == 1 { + branch := branchList[0] + branchPath := append(path, branch.name) + branchSchema, err := generateUnion( + []*base.SchemaProxy{branch.proxy}, nil, options.WithPath(branchPath), + ) + if err != nil { + return GoSchema{}, fmt.Errorf("error resolving if/%s: %w", branch.name, err) + } + if !hasAllOf && !hasAnyOf && !hasOneOf { + return branchSchema, nil + } + out.Properties = append(out.Properties, branchSchema.Properties...) + additionalTypes = append(additionalTypes, branchSchema.AdditionalTypes...) + } else { + // Both branches: resolve each independently with its own named + // path, then assemble a union wrapper. This gives types like + // Resource_Then and Resource_Else instead of _0 and _1. + var ifThenElseSchema GoSchema + for _, branch := range branchList { + branchPath := append(path, branch.name) + ref := branch.proxy.GoLow().GetReference() + resolved, err := GenerateGoSchema(branch.proxy, options.WithReference(ref).WithPath(branchPath)) + if err != nil { + return GoSchema{}, fmt.Errorf("error resolving if/%s: %w", branch.name, err) + } + + typeName := resolved.GoType + // For non-primitive inline types, register a named type definition + if ref == "" && !isPrimitiveType(typeName) { + elementName := pathToTypeName(branchPath) + td := TypeDefinition{ + Schema: resolved, + Name: elementName, + SpecLocation: SpecLocationUnion, + JsonName: "-", + NeedsMarshaler: needsMarshaler(resolved), + } + options.typeTracker.register(td, "") + ifThenElseSchema.AdditionalTypes = append(ifThenElseSchema.AdditionalTypes, td) + ifThenElseSchema.AdditionalTypes = append(ifThenElseSchema.AdditionalTypes, resolved.AdditionalTypes...) + typeName = elementName + } else if ref != "" && !isPrimitiveType(typeName) { + ifThenElseSchema.AdditionalTypes = append(ifThenElseSchema.AdditionalTypes, resolved.AdditionalTypes...) + } + + if typeName != "struct{}" { + ifThenElseSchema.UnionElements = append(ifThenElseSchema.UnionElements, UnionElement{ + TypeName: typeName, + Schema: resolved, + }) + } + } + + ifThenElseSchema.IsConditional = true + ifThenElseFields := genFieldsFromProperties(ifThenElseSchema.Properties, options) + ifThenElseSchema.GoType = ifThenElseSchema.createGoStruct(ifThenElseFields) + ifThenElseSchema.IsUnionWrapper = len(ifThenElseSchema.UnionElements) > 0 + + ifThenElsePath := append(path, "ifThenElse") + ifThenElseName := pathToTypeName(ifThenElsePath) + td := TypeDefinition{ + Name: ifThenElseName, + Schema: ifThenElseSchema, + SpecLocation: SpecLocationUnion, + JsonName: "-", + NeedsMarshaler: needsMarshaler(ifThenElseSchema), + HasSensitiveData: hasSensitiveData(ifThenElseSchema), + } + additionalTypes = append(additionalTypes, td) + additionalTypes = append(additionalTypes, ifThenElseSchema.AdditionalTypes...) + options.typeTracker.register(td, "") + + out.Properties = append(out.Properties, Property{ + GoName: ifThenElseName, + Schema: GoSchema{RefType: ifThenElseName}, + Constraints: Constraints{Nullable: ptr(true)}, + }) + } + } + fields := genFieldsFromProperties(out.Properties, options) out.GoType = out.createGoStruct(fields) out.AdditionalTypes = append(out.AdditionalTypes, additionalTypes...) @@ -195,6 +296,9 @@ func isMetadataOnlySchema(schema *base.Schema) bool { if schema.Not != nil { return false } + if schema.Then != nil || schema.Else != nil { + return false + } // It only has metadata fields like description, title, examples, etc. return true diff --git a/pkg/codegen/schema_merge_test.go b/pkg/codegen/schema_merge_test.go index 177ad251..3794b555 100644 --- a/pkg/codegen/schema_merge_test.go +++ b/pkg/codegen/schema_merge_test.go @@ -780,3 +780,71 @@ components: assert.False(t, result) }) } + +func TestIfThenElse(t *testing.T) { + contents, err := os.ReadFile("testdata/if-then-else.yml") + require.NoError(t, err) + + opts := Configuration{ + PackageName: "testpkg", + Output: &Output{ + UseSingleFile: true, + }, + } + + code, err := Generate(contents, opts) + require.NoError(t, err) + combined := code.GetCombined() + + t.Run("basic if/then/else generates union with named variants", func(t *testing.T) { + // BasicIfThenElse should have its own properties plus a union wrapper + assert.Contains(t, combined, "type BasicIfThenElse struct") + assert.Contains(t, combined, "Kind") + + // Union wrapper type with named variants (_Then/_Else, not _0/_1) + assert.Contains(t, combined, "BasicIfThenElse_IfThenElse") + assert.Contains(t, combined, "type BasicIfThenElse_Then struct") + assert.Contains(t, combined, "type BasicIfThenElse_Else struct") + assert.Contains(t, combined, "Conditional[BasicIfThenElse_Then, BasicIfThenElse_Else]") + + // Properties from then branch + assert.Contains(t, combined, "FieldA") + assert.Contains(t, combined, "ValueA") + + // Properties from else branch + assert.Contains(t, combined, "FieldB") + assert.Contains(t, combined, "ValueB") + }) + + t.Run("then only flat merges", func(t *testing.T) { + // ThenOnly should have properties from both the base schema and the then branch + assert.Contains(t, combined, "type ThenOnly struct") + assert.Contains(t, combined, "Enabled") + + // With a single branch, the then properties should be merged into the parent, + // so we should see Config and Timeout as fields + assert.Contains(t, combined, "Config") + assert.Contains(t, combined, "Timeout") + }) + + t.Run("if/then/else inside allOf", func(t *testing.T) { + // InsideAllOf should inherit from BaseResource and handle the conditional + assert.Contains(t, combined, "InsideAllOf") + assert.Contains(t, combined, "Category") + }) + + t.Run("nested if/then/else", func(t *testing.T) { + // Nested should handle the outer if/then/else + assert.Contains(t, combined, "Nested") + assert.Contains(t, combined, "Level") + }) + + t.Run("validation only if/then does not break generation", func(t *testing.T) { + // ValidationOnly should generate without errors even when then only adds required. + // Since the then branch only adds "required" with no new properties or type, + // it resolves as an empty/zero schema and is effectively a no-op for code + // generation. The parent properties (mode, requiredInStrict) are still present + // but the then branch contributes no structural types. + assert.Contains(t, combined, "ValidationOnly") + }) +} diff --git a/pkg/codegen/templates/union.tmpl b/pkg/codegen/templates/union.tmpl index 3d0d2e81..75c68d6e 100644 --- a/pkg/codegen/templates/union.tmpl +++ b/pkg/codegen/templates/union.tmpl @@ -28,10 +28,39 @@ limitations under the License. {{$properties := .Schema.Properties -}} {{ $eitherType := eq (len .Schema.UnionElements) 2}} + {{ $conditionalType := .Schema.IsConditional }} {{/* Add Validate method for union types */}} func ({{$alias}} *{{$typeName}}) Validate() error { - {{- if $eitherType }} + {{- if and $eitherType $conditionalType }} + {{- $elementThen := index .Schema.UnionElements 0 }} + {{- $elementElse := index .Schema.UnionElements 1 }} + {{- $tagsThen := filterOmitEmpty $elementThen.Schema.Constraints.ValidationTags }} + {{- $tagsElse := filterOmitEmpty $elementElse.Schema.Constraints.ValidationTags }} + if {{$alias}}.IsThen() { + {{- if gt (len $tagsThen) 0 }} + if err := typesValidator.Var({{$alias}}.Then, "{{join "," $tagsThen}}"); err != nil { + return err + } + {{- else }} + if v, ok := any({{$alias}}.Then).(runtime.Validator); ok { + return v.Validate() + } + {{- end }} + } + if {{$alias}}.IsElse() { + {{- if gt (len $tagsElse) 0 }} + if err := typesValidator.Var({{$alias}}.Else, "{{join "," $tagsElse}}"); err != nil { + return err + } + {{- else }} + if v, ok := any({{$alias}}.Else).(runtime.Validator); ok { + return v.Validate() + } + {{- end }} + } + return nil + {{- else if $eitherType }} {{- $elementA := index .Schema.UnionElements 0 }} {{- $elementB := index .Schema.UnionElements 1 }} {{- $tagsA := filterOmitEmpty $elementA.Schema.Constraints.ValidationTags }} diff --git a/pkg/codegen/testdata/if-then-else.yml b/pkg/codegen/testdata/if-then-else.yml new file mode 100644 index 00000000..16b3ebdc --- /dev/null +++ b/pkg/codegen/testdata/if-then-else.yml @@ -0,0 +1,147 @@ +openapi: "3.1.0" +info: + version: 1.0.0 + title: If-Then-Else Test +paths: + /resources: + get: + operationId: listResources + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + basic: + $ref: '#/components/schemas/BasicIfThenElse' + thenOnly: + $ref: '#/components/schemas/ThenOnly' + insideAllOf: + $ref: '#/components/schemas/InsideAllOf' + nested: + $ref: '#/components/schemas/Nested' + validationOnly: + $ref: '#/components/schemas/ValidationOnly' +components: + schemas: + # Case 1: Basic if/then/else with both branches having different properties + BasicIfThenElse: + type: object + properties: + kind: + type: string + if: + properties: + kind: + const: "typeA" + then: + type: object + properties: + fieldA: + type: string + valueA: + type: integer + else: + type: object + properties: + fieldB: + type: boolean + valueB: + type: number + + # Case 2: if/then only (no else) - should flat merge into parent + ThenOnly: + type: object + properties: + enabled: + type: boolean + if: + properties: + enabled: + const: true + then: + type: object + properties: + config: + type: string + timeout: + type: integer + + # Case 3: if/then/else inside allOf + BaseResource: + type: object + properties: + id: + type: string + name: + type: string + + InsideAllOf: + allOf: + - $ref: '#/components/schemas/BaseResource' + - type: object + properties: + category: + type: string + if: + properties: + category: + const: "special" + then: + type: object + properties: + specialField: + type: string + else: + type: object + properties: + normalField: + type: integer + + # Case 4: Nested if/then/else + Nested: + type: object + properties: + level: + type: string + if: + properties: + level: + const: "outer" + then: + type: object + properties: + outerField: + type: string + if: + properties: + outerField: + const: "nested" + then: + type: object + properties: + nestedField: + type: integer + else: + type: object + properties: + fallbackField: + type: boolean + + # Case 5: if/then/else that only adds required (validation only, no new properties) + ValidationOnly: + type: object + properties: + mode: + type: string + requiredInStrict: + type: string + if: + properties: + mode: + const: "strict" + then: + required: + - requiredInStrict diff --git a/pkg/runtime/conditional.go b/pkg/runtime/conditional.go new file mode 100644 index 00000000..ab80c2be --- /dev/null +++ b/pkg/runtime/conditional.go @@ -0,0 +1,157 @@ +// Copyright 2026 DoorDash, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + +package runtime + +import ( + "bytes" + "encoding/json" +) + +// Conditional represents a JSON Schema if/then/else result. +// At most one of Then or Else is populated, indicated by N. +// N == 1 means Then matched, N == 2 means Else matched, N == 0 means neither. +type Conditional[T, E any] struct { + Then T `validate:"-"` + Else E `validate:"-"` + + N int +} + +func NewConditionalFromThen[T any, E any](then T) Conditional[T, E] { + var e E + return Conditional[T, E]{Then: then, Else: e, N: 1} +} + +func NewConditionalFromElse[T any, E any](els E) Conditional[T, E] { + var t T + return Conditional[T, E]{Then: t, Else: els, N: 2} +} + +func (c *Conditional[T, E]) IsThen() bool { + return c.N == 1 +} + +func (c *Conditional[T, E]) IsElse() bool { + return c.N == 2 +} + +func (c *Conditional[T, E]) Value() any { + if c.IsThen() { + return c.Then + } + if c.IsElse() { + return c.Else + } + return nil +} + +// MarshalJSON implements json.Marshaler interface +func (c Conditional[T, E]) MarshalJSON() ([]byte, error) { + switch c.N { + case 1: + return json.Marshal(c.Then) + case 2: + return json.Marshal(c.Else) + default: + return []byte("null"), nil + } +} + +func (c *Conditional[T, E]) UnmarshalJSON(data []byte) error { + trim := bytes.TrimSpace(data) + if len(trim) == 0 || bytes.Equal(trim, []byte("null")) { + var zeroT T + var zeroE E + c.Then, c.Else, c.N = zeroT, zeroE, 0 + return nil + } + + var t T + errT := json.Unmarshal(data, &t) + + var e E + errE := json.Unmarshal(data, &e) + + switch { + case errT == nil && errE != nil: + var zeroE E + c.Then, c.Else, c.N = t, zeroE, 1 + return nil + + case errE == nil && errT != nil: + var zeroT T + c.Then, c.Else, c.N = zeroT, e, 2 + return nil + + case errT == nil: + // Both decoded; try validation first to disambiguate + var errValidateT, errValidateE error + if v, ok := any(t).(Validator); ok { + errValidateT = v.Validate() + } + if v, ok := any(e).(Validator); ok { + errValidateE = v.Validate() + } + + if errValidateT == nil && errValidateE != nil { + var zeroE E + c.Then, c.Else, c.N = t, zeroE, 1 + return nil + } + if errValidateE == nil && errValidateT != nil { + var zeroT T + c.Then, c.Else, c.N = zeroT, e, 2 + return nil + } + + // Apply zero/meaningfulness heuristics, then tie-break to Then. + nt := isNonZero(t) + ne := isNonZero(e) + + if nt && !ne { + var zeroE E + c.Then, c.Else, c.N = t, zeroE, 1 + return nil + } + if ne && !nt { + var zeroT T + c.Then, c.Else, c.N = zeroT, e, 2 + return nil + } + + // Tie: pick Then + { + var zeroE E + c.Then, c.Else, c.N = t, zeroE, 1 + return nil + } + default: + return ErrFailedToUnmarshalAsAOrB + } +} + +func (c *Conditional[T, E]) Validate() error { + if c.IsThen() { + if v, ok := any(c.Then).(Validator); ok { + return v.Validate() + } + return nil + } + + if c.IsElse() { + if v, ok := any(c.Else).(Validator); ok { + return v.Validate() + } + return nil + } + + return nil +} diff --git a/pkg/runtime/conditional_test.go b/pkg/runtime/conditional_test.go new file mode 100644 index 00000000..339cbfb9 --- /dev/null +++ b/pkg/runtime/conditional_test.go @@ -0,0 +1,277 @@ +// Copyright 2026 DoorDash, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + +package runtime + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestNewConditionalFromThen(t *testing.T) { + res := NewConditionalFromThen[string, int]("test") + + assert.True(t, res.IsThen()) + assert.Equal(t, "test", res.Then) + assert.False(t, res.IsElse()) + assert.Equal(t, 1, res.N) + assert.Equal(t, 0, res.Else) +} + +func TestNewConditionalFromElse(t *testing.T) { + res := NewConditionalFromElse[string, int](10) + + assert.False(t, res.IsThen()) + assert.Equal(t, "", res.Then) + assert.True(t, res.IsElse()) + assert.Equal(t, 2, res.N) + assert.Equal(t, 10, res.Else) +} + +func TestConditional_Value(t *testing.T) { + t.Run("returns Then when IsThen", func(t *testing.T) { + res := NewConditionalFromThen[string, int]("test") + assert.Equal(t, "test", res.Value()) + }) + + t.Run("returns Else when IsElse", func(t *testing.T) { + res := NewConditionalFromElse[string, int](10) + assert.Equal(t, 10, res.Value()) + }) + + t.Run("returns nil when neither", func(t *testing.T) { + var res Conditional[string, int] + assert.Nil(t, res.Value()) + }) +} + +func TestConditional_Unmarshal(t *testing.T) { + tests := []struct { + name string + input []byte + expected Conditional[string, int] + }{ + { + name: "string matches Then", + input: []byte(`"test"`), + expected: NewConditionalFromThen[string, int]("test"), + }, + { + name: "int matches Else", + input: []byte(`10`), + expected: NewConditionalFromElse[string, int](10), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var res Conditional[string, int] + err := res.UnmarshalJSON(test.input) + assert.NoError(t, err) + assert.Equal(t, test.expected, res) + }) + } +} + +func TestConditional_MarshalJSON(t *testing.T) { + t.Run("marshals Then variant", func(t *testing.T) { + c := NewConditionalFromThen[string, int]("hello") + data, err := c.MarshalJSON() + assert.NoError(t, err) + assert.JSONEq(t, `"hello"`, string(data)) + }) + + t.Run("marshals Else variant", func(t *testing.T) { + c := NewConditionalFromElse[string, int](42) + data, err := c.MarshalJSON() + assert.NoError(t, err) + assert.JSONEq(t, `42`, string(data)) + }) + + t.Run("marshals null when neither", func(t *testing.T) { + var c Conditional[string, int] + data, err := c.MarshalJSON() + assert.NoError(t, err) + assert.Equal(t, "null", string(data)) + }) +} + +func TestConditional_UnmarshalJSON_Null(t *testing.T) { + t.Run("null input resets to zero", func(t *testing.T) { + c := NewConditionalFromThen[string, int]("existing") + err := c.UnmarshalJSON([]byte(`null`)) + assert.NoError(t, err) + assert.Equal(t, 0, c.N) + assert.Equal(t, "", c.Then) + assert.Equal(t, 0, c.Else) + }) + + t.Run("empty input resets to zero", func(t *testing.T) { + c := NewConditionalFromElse[string, int](5) + err := c.UnmarshalJSON([]byte(``)) + assert.NoError(t, err) + assert.Equal(t, 0, c.N) + }) +} + +// Wrapper type mimicking generated code +type ThenBranch struct { + FieldA string `json:"fieldA"` +} + +type ElseBranch struct { + FieldB int `json:"fieldB"` +} + +type ResourceCondition struct { + Conditional[ThenBranch, ElseBranch] +} + +func TestConditional_MarshalJSON_WithWrapper(t *testing.T) { + tests := []struct { + name string + input ResourceCondition + expected string + }{ + { + name: "then branch", + input: ResourceCondition{Conditional: NewConditionalFromThen[ThenBranch, ElseBranch](ThenBranch{FieldA: "hello"})}, + expected: `{"fieldA":"hello"}`, + }, + { + name: "else branch", + input: ResourceCondition{Conditional: NewConditionalFromElse[ThenBranch, ElseBranch](ElseBranch{FieldB: 42})}, + expected: `{"fieldB":42}`, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + res, err := test.input.MarshalJSON() + assert.NoError(t, err) + assert.JSONEq(t, test.expected, string(res)) + }) + } +} + +func TestConditional_UnmarshalJSON_WithWrapper(t *testing.T) { + tests := []struct { + name string + input string + expected ResourceCondition + }{ + { + name: "then branch", + input: `{"fieldA":"hello"}`, + expected: ResourceCondition{Conditional: NewConditionalFromThen[ThenBranch, ElseBranch](ThenBranch{FieldA: "hello"})}, + }, + { + name: "else branch", + input: `{"fieldB":42}`, + expected: ResourceCondition{Conditional: NewConditionalFromElse[ThenBranch, ElseBranch](ElseBranch{FieldB: 42})}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var res ResourceCondition + err := res.UnmarshalJSON([]byte(test.input)) + assert.NoError(t, err) + assert.Equal(t, test.expected, res) + }) + } +} + +// Validatable types for disambiguation tests +type StrictConfig struct { + Mode string `json:"mode" validate:"required"` + Timeout int `json:"timeout" validate:"required,min=1"` +} + +func (s StrictConfig) Validate() error { + if s.Mode == "" || s.Timeout < 1 { + return assert.AnError + } + return nil +} + +type LaxConfig struct { + Mode string `json:"mode"` + Timeout int `json:"timeout"` +} + +func (l LaxConfig) Validate() error { + return nil +} + +func TestConditional_Validate(t *testing.T) { + t.Run("validates Then variant when active", func(t *testing.T) { + valid := StrictConfig{Mode: "fast", Timeout: 30} + c := NewConditionalFromThen[StrictConfig, string](valid) + assert.NoError(t, c.Validate()) + }) + + t.Run("validates Else variant when active", func(t *testing.T) { + c := NewConditionalFromElse[StrictConfig, string]("fallback") + assert.NoError(t, c.Validate()) + }) + + t.Run("fails validation for invalid Then variant", func(t *testing.T) { + invalid := StrictConfig{Mode: "", Timeout: 0} + c := NewConditionalFromThen[StrictConfig, string](invalid) + assert.Error(t, c.Validate()) + }) + + t.Run("does not validate inactive Else variant", func(t *testing.T) { + valid := StrictConfig{Mode: "fast", Timeout: 30} + c := NewConditionalFromThen[StrictConfig, string](valid) + assert.NoError(t, c.Validate()) + }) + + t.Run("returns nil when neither is active", func(t *testing.T) { + var c Conditional[StrictConfig, string] + assert.NoError(t, c.Validate()) + }) +} + +func TestConditional_UnmarshalJSON_Disambiguation(t *testing.T) { + t.Run("prefers type that validates when both unmarshal", func(t *testing.T) { + // Empty mode/timeout - StrictConfig fails validation, LaxConfig passes + data := []byte(`{"mode":"","timeout":0}`) + var c Conditional[StrictConfig, LaxConfig] + + err := c.UnmarshalJSON(data) + assert.NoError(t, err) + assert.True(t, c.IsElse(), "should choose Else (LaxConfig) because it validates") + assert.Equal(t, "", c.Else.Mode) + assert.Equal(t, 0, c.Else.Timeout) + }) + + t.Run("defaults to Then when both validate", func(t *testing.T) { + data := []byte(`{"mode":"fast","timeout":30}`) + var c Conditional[StrictConfig, LaxConfig] + + err := c.UnmarshalJSON(data) + assert.NoError(t, err) + assert.True(t, c.IsThen(), "should default to Then when both validate") + assert.Equal(t, "fast", c.Then.Mode) + assert.Equal(t, 30, c.Then.Timeout) + }) +} + +func TestConditional_UnmarshalJSON_FailsBoth(t *testing.T) { + // Neither string nor int can unmarshal an object with a nested array + data := []byte(`[1,2,3]`) + var c Conditional[string, int] + + err := c.UnmarshalJSON(data) + assert.Error(t, err) +}