diff --git a/README.md b/README.md index b766e72..3516103 100644 --- a/README.md +++ b/README.md @@ -131,6 +131,30 @@ func main() { } ``` +### Discriminated unions + +A `oneOf`/`anyOf` with a `discriminator` generates a wrapper whose `Value` holds +the decoded variant. A discriminator value the client does not know is **not** an +error: `Value` stays nil, the original JSON is kept, and re-marshaling returns it +unchanged. Adding a variant server-side therefore stays backward compatible, and +one unrecognized element does not fail the payload it appears in. + +```go +for _, shape := range shapes { + if shape.IsUnknownVariant() { + log.Printf("skipping unsupported shape %q", shape.UnknownDiscriminator()) + continue // shape.Raw() still holds the original JSON + } + switch v := shape.Value.(type) { + case petstore.Circle: + ... + } +} +``` + +A union *without* a discriminator still fails when no variant matches, since there +is nothing to identify the payload by. + ## License [MIT](LICENSE) — Copyright (c) 2026 Parallel Works diff --git a/internal/generator/e2e_inline_union_test.go b/internal/generator/e2e_inline_union_test.go index 490436d..680afe7 100644 --- a/internal/generator/e2e_inline_union_test.go +++ b/internal/generator/e2e_inline_union_test.go @@ -93,9 +93,46 @@ func TestUnionValueTypeSwitch(t *testing.T) { t.Errorf("marshal lost the discriminator: %s", out) } - var bad ShapeCollectionShapesValue - if err := json.Unmarshal([]byte(` + "`" + `{"shapeType":"hexagon"}` + "`" + `), &bad); err == nil { - t.Fatal("expected an error for an unknown shapeType, got nil") + var unknown ShapeCollectionShapesValue + if err := json.Unmarshal([]byte(` + "`" + `{"shapeType":"hexagon","sides":6}` + "`" + `), &unknown); err != nil { + t.Fatalf("unknown shapeType should decode, got: %v", err) + } + if !unknown.IsUnknownVariant() { + t.Error("expected IsUnknownVariant for an unmapped shapeType") + } + if unknown.Value != nil { + t.Errorf("Value = %v, want nil for an unknown variant", unknown.Value) + } + if unknown.UnknownDiscriminator() != "hexagon" { + t.Errorf("UnknownDiscriminator() = %q, want hexagon", unknown.UnknownDiscriminator()) + } + + back, err := json.Marshal(unknown) + if err != nil { + t.Fatalf("marshal unknown: %v", err) + } + if !strings.Contains(string(back), ` + "`" + `"sides":6` + "`" + `) { + t.Errorf("re-marshal lost the unknown variant's payload: %s", back) + } +} + +func TestUnknownVariantDoesNotFailSiblings(t *testing.T) { + payload := []byte(` + "`" + `{ + "shapes": { + "a": {"shapeType": "circle", "radius": 2.5}, + "b": {"shapeType": "triangle", "base": 2, "height": 3} + } + }` + "`" + `) + + var sc ShapeCollection + if err := json.Unmarshal(payload, &sc); err != nil { + t.Fatalf("one unknown variant failed the whole decode: %v", err) + } + if _, ok := sc.Shapes["a"].Value.(Circle); !ok { + t.Fatalf("shapes[a].Value = %T, want Circle", sc.Shapes["a"].Value) + } + if !sc.Shapes["b"].IsUnknownVariant() { + t.Error("shapes[b] should be an unknown variant") } } `) diff --git a/internal/templates/types.go.tmpl b/internal/templates/types.go.tmpl index a2b65d6..964323c 100644 --- a/internal/templates/types.go.tmpl +++ b/internal/templates/types.go.tmpl @@ -31,10 +31,38 @@ const ( // Variants: {{ range $i, $v := .UnionTypes }}{{ if $i }}, {{ end }}{{ $v.TypeName }}{{ end }} type {{ .Name }} struct { Value any +{{- if .Discriminator }} + + unknownDiscriminator string + raw json.RawMessage +{{- end }} +} +{{ if .Discriminator }} +// IsUnknownVariant reports whether the payload carried a {{ .Discriminator.PropertyName }} +// this client does not know. Value is nil in that case; the original JSON is +// available from Raw and is re-marshaled unchanged. +func (u {{ .Name }}) IsUnknownVariant() bool { + return u.Value == nil && len(u.raw) > 0 } +// UnknownDiscriminator returns the unrecognized {{ .Discriminator.PropertyName }} value, +// or "" when the payload decoded into a known variant. +func (u {{ .Name }}) UnknownDiscriminator() string { + return u.unknownDiscriminator +} + +// Raw returns the original JSON of an unrecognized variant, or nil. +func (u {{ .Name }}) Raw() json.RawMessage { + return u.raw +} +{{ end }} // MarshalJSON implements json.Marshaler for {{ .Name }}. func (u {{ .Name }}) MarshalJSON() ([]byte, error) { +{{- if .Discriminator }} + if u.IsUnknownVariant() { + return u.raw, nil + } +{{- end }} return json.Marshal(u.Value) } @@ -54,11 +82,18 @@ func (u *{{ $typeName }}) UnmarshalJSON(data []byte) error { if err := json.Unmarshal(data, &v); err != nil { return err } - u.Value = v + *u = {{ $typeName }}{Value: v} return nil {{- end }} default: - return fmt.Errorf("unknown {{ .Discriminator.PropertyName }} value: %q", disc.{{ discriminatorFieldName .Discriminator.PropertyName }}) + // Adding a variant to a oneOf is meant to be a backward-compatible change, + // so an unrecognized one is preserved verbatim instead of failing the decode + // of the whole payload it happens to appear in. + *u = {{ $typeName }}{ + unknownDiscriminator: disc.{{ discriminatorFieldName .Discriminator.PropertyName }}, + raw: append(json.RawMessage(nil), data...), + } + return nil } {{- else }} var errors []error