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
18 changes: 13 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,9 +152,9 @@ for _, shape := range shapes {
}
```

When every variant composes the same schema through `allOf`, the wrapper also
exposes it, so the fields all variants share are readable without a type switch
that has to be revisited whenever a variant is added:
When every variant carries the same properties, the wrapper also exposes them, so
the fields all variants share are readable without a type switch that has to be
revisited whenever a variant is added:

```go
for _, pet := range pets {
Expand All @@ -164,8 +164,16 @@ for _, pet := range pets {
}
```

`Base()` returns nil for an unknown variant, and is generated only when *every*
variant composes the *same* single base.
`Base()` returns nil for an unknown variant. Variants that compose a shared schema
through `allOf` name it directly, and there has to be exactly one such schema.
Variants that inline the same properties instead, which is all some producers
emit, get a `<Union>Base` struct synthesized from the properties every variant
declares identically: same name, same type, same required-ness. That type is
derived from the variants rather than declared by the spec, so it changes when
they do. The discriminator
is left out, since it is how the variants differ and a spec that spells the base
out keeps it out of the shared schema too. The result is a copy, so writing to it
does not change the variant the union holds.

A payload that carries no discriminator property at all is still an error — there
is nothing to identify it by — as is a union *without* a discriminator when no
Expand Down
6 changes: 3 additions & 3 deletions internal/analyzer/analyzer.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,9 +94,9 @@ func (a *Analyzer) Analyze(packageName string) (*ir.Package, error) {
breakAliasCycles(pkg.Types)
breakStructCycles(pkg.Types)

// A union whose variants all compose the same schema can expose it directly,
// which depends on the variants' final field shapes.
linkUnionBases(pkg.Types)
// A union whose variants all carry the same properties can expose them
// directly, which depends on the variants' final field shapes.
a.linkUnionBases(pkg)

// Detect paginated operations.
a.detectPagination(pkg)
Expand Down
3 changes: 3 additions & 0 deletions internal/analyzer/analyzer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -797,6 +797,9 @@ func TestComplexSchemas_AllTypesPresent(t *testing.T) {
"ShapeCollection", "ShapeCollectionShapesValue",
// Unions used directly as a request or response body.
"CreateShapeBody", "CreateShapeResponse", "NamedShape",
// Circle and Rectangle both declare shapeType, and the body union
// dispatches on kind, so the shared property becomes a base.
"CreateShapeBodyBase",
}

if len(pkg.Types) != len(expectedTypes) {
Expand Down
123 changes: 115 additions & 8 deletions internal/analyzer/unionbases.go
Original file line number Diff line number Diff line change
@@ -1,18 +1,44 @@
package analyzer

import "github.com/parallelworks/openapi-client-generator/internal/ir"
import (
naming "github.com/giraffesyo/openapi-go-naming"

// linkUnionBases records on every union the one type all of its variants embed,
// when there is exactly one. Reading a field the variants share is otherwise a
// type switch over every variant, which the compiler cannot keep exhaustive as
"github.com/parallelworks/openapi-client-generator/internal/ir"
)

// linkUnionBases gives every union the type holding the properties its variants
// all carry, when there is one. Reading such a property is otherwise a type
// switch over every variant, which the compiler cannot keep exhaustive as
// variants are added.
func linkUnionBases(types []*ir.TypeDef) {
byName := ir.TypesByName(types)
for _, td := range types {
//
// A variant set that composes a shared schema through allOf names it directly.
// One that inlines the same properties instead, as generators that flatten
// composition emit, gets a base synthesized from the properties they share.
func (a *Analyzer) linkUnionBases(pkg *ir.Package) {
byName := ir.TypesByName(pkg.Types)
for _, td := range pkg.Types {
if td == nil || td.Kind != ir.TypeKindUnion || len(td.UnionTypes) == 0 {
continue
}
td.BaseType = sharedEmbeddedType(byName, td.UnionTypes)
if base := sharedEmbeddedType(byName, td.UnionTypes); base != "" {
td.BaseType = base
td.BaseEmbedded = true
continue
}
fields := sharedFields(byName, td.UnionTypes, td.Discriminator)
if len(fields) == 0 {
continue
}
base := &ir.TypeDef{
Name: a.namer.Unique(naming.Exported(td.Name + "Base")),
Description: "The properties every variant of " + td.Name + " declares.\n" +
"Derived from the variants rather than declared by the spec, so it\n" +
"changes when they do.",
Kind: ir.TypeKindStruct,
Fields: fields,
}
pkg.Types = append(pkg.Types, base)
td.BaseType = base.Name
}
}

Expand All @@ -37,6 +63,87 @@ func sharedEmbeddedType(byName map[string]*ir.TypeDef, variants []*ir.UnionVaria
return shared[0]
}

// sharedFields returns copies of the fields every variant declares identically,
// in the first variant's order. It returns nil unless the union has at least two
// distinct struct variants: one variant shares everything with itself, which
// would make a base that only restates it.
func sharedFields(byName map[string]*ir.TypeDef, variants []*ir.UnionVariant, disc *ir.DiscriminatorDef) []*ir.Field {
var structs []*ir.TypeDef
seen := make(map[string]bool, len(variants))
for _, v := range variants {
if seen[v.TypeName] {
continue
}
seen[v.TypeName] = true
td := ir.StructNamed(byName, v.TypeName)
if td == nil {
return nil
}
structs = append(structs, td)
}
if len(structs) < 2 {
return nil
}

var shared []*ir.Field
for _, f := range structs[0].Fields {
if shareable(f, disc) {
shared = append(shared, f)
}
}
for _, td := range structs[1:] {
declared := make(map[string]bool, len(td.Fields))
for _, f := range td.Fields {
if shareable(f, disc) {
declared[fieldKey(f)] = true
}
}
var kept []*ir.Field
for _, f := range shared {
if declared[fieldKey(f)] {
kept = append(kept, f)
}
}
shared = kept
if len(shared) == 0 {
return nil
}
}

copies := make([]*ir.Field, len(shared))
for i, f := range shared {
field := *f
copies[i] = &field
}
return copies
}

// shareable reports whether a field can belong to a synthesized base: an embedded
// type is the other path's business, a catch-all holds what its own schema left
// undeclared rather than a property the variants agree on, and the discriminator
// is how the variants differ, which is also why a spec that spells the base out
// keeps it out of the shared schema.
func shareable(f *ir.Field, disc *ir.DiscriminatorDef) bool {
if f.Embedded || f.CatchAll {
return false
}
return disc == nil || f.JSONName != disc.PropertyName
}

// fieldKey identifies a field by everything that shapes the Go it generates, so
// two variants agree on a property only when they declare it the same way.
func fieldKey(f *ir.Field) string {
key := f.Name + "\x00" + f.JSONName + "\x00" + f.Type
for _, flag := range []bool{f.Required, f.IsPointer, f.OmitEmpty, f.ReadOnly, f.WriteOnly, f.Deprecated} {
if flag {
key += "1"
} else {
key += "0"
}
}
return key
}

// embeddedTypeNames returns the struct types goType embeds by value.
func embeddedTypeNames(byName map[string]*ir.TypeDef, goType string) []string {
td := ir.StructNamed(byName, goType)
Expand Down
Loading