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
164 changes: 164 additions & 0 deletions internal/analyzer/aliascycles.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
package analyzer

import (
"slices"
"strings"

"github.com/parallelworks/openapi-client-generator/internal/ir"
)

// breakAliasCycles degrades to any every alias that can reach itself. A generated
// alias is a true Go alias (`type A = B`), which the compiler expands eagerly, so
// a cycle through one is an "invalid recursive type" no matter how many slices,
// pointers, or maps sit between the two ends. Only aliases can form such a cycle:
// a struct, enum, or union names a real definition that terminates the chain.
func breakAliasCycles(types []*ir.TypeDef) {
aliases := make(map[string]*ir.TypeDef, len(types))
for _, td := range types {
if td != nil && td.Kind == ir.TypeKindAlias {
aliases[td.Name] = td
}
}

const (
visiting = 1
done = 2
)
state := make(map[string]int, len(aliases))

// Reports whether the alias named by name sits on a cycle, breaking the edge
// that closes one.
var walk func(name string) bool
walk = func(name string) bool {
td, ok := aliases[name]
if !ok {
return false
}
switch state[name] {
case visiting:
return true
case done:
return false
}

state[name] = visiting
if target := aliasTarget(td.GoType); target != "" && walk(target) {
// Only the cyclic referent has to go; the slice or map around it is
// still what the caller gets.
td.GoType = strings.TrimSuffix(td.GoType, target) + "any"
}
state[name] = done
return false
}

for _, td := range types {
if td != nil && td.Kind == ir.TypeKindAlias {
walk(td.Name)
}
}
}

// breakStructCycles turns into a pointer every struct field that would make its
// type contain itself by value, which Go rejects the same way as a recursive
// alias. A field that is already a pointer, slice, or map stops the recursion on
// its own; a required $ref to the enclosing type does not.
func breakStructCycles(types []*ir.TypeDef) {
byName := ir.TypesByName(types)

const (
visiting = 1
done = 2
)
state := make(map[string]int, len(byName))

var walk func(td *ir.TypeDef)
walk = func(td *ir.TypeDef) {
state[td.Name] = visiting
for _, f := range td.Fields {
// A field already written as a pointer, slice, or map stops the
// recursion on its own, and ir.StructNamed rejects all three.
next := ir.StructNamed(byName, f.Type)
if next == nil {
continue
}
if state[next.Name] == visiting {
// This field closes the cycle, so it is the one to indirect.
f.Type = "*" + f.Type
f.IsPointer = true
continue
}
if state[next.Name] != done {
walk(next)
}
}
state[td.Name] = done
}

for _, td := range types {
if td != nil && td.Kind == ir.TypeKindStruct && state[td.Name] == 0 {
walk(td)
}
}
}

// dropShadowedCatchAlls removes the catch-all from a struct that embeds one,
// whose promoted marshalers would otherwise win and emit only their own fields.
func dropShadowedCatchAlls(types []*ir.TypeDef) {
byName := ir.TypesByName(types)

hasCatchAll := func(td *ir.TypeDef) bool {
return slices.ContainsFunc(td.Fields, func(f *ir.Field) bool { return f.CatchAll })
}

// Reports whether td or anything it embeds carries a catch-all.
var embedsCatchAll func(td *ir.TypeDef, depth int) bool
embedsCatchAll = func(td *ir.TypeDef, depth int) bool {
if td == nil || depth > len(types) {
return false
}
for _, f := range td.Fields {
if !f.Embedded {
continue
}
embedded := ir.StructNamed(byName, strings.TrimPrefix(f.Type, "*"))
if embedded == nil {
continue
}
if hasCatchAll(embedded) || embedsCatchAll(embedded, depth+1) {
return true
}
}
return false
}

for _, td := range types {
if td == nil || td.Kind != ir.TypeKindStruct || !hasCatchAll(td) {
continue
}
if embedsCatchAll(td, 0) {
td.Fields = slices.DeleteFunc(td.Fields, func(f *ir.Field) bool { return f.CatchAll })
}
}
}

// aliasTarget returns the named type an alias's Go type expression refers to,
// peeling the slice, pointer, and map wrappers that do not stop a Go alias from
// expanding. It returns "" for a builtin or a composite with no single referent.
func aliasTarget(goType string) string {
for {
switch {
case strings.HasPrefix(goType, "[]"):
goType = goType[2:]
case strings.HasPrefix(goType, "*"):
goType = goType[1:]
case strings.HasPrefix(goType, "map["):
end := strings.Index(goType, "]")
if end < 0 {
return ""
}
goType = goType[end+1:]
default:
return ir.NamedType(goType)
}
}
}
29 changes: 27 additions & 2 deletions internal/analyzer/analyzer.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (

naming "github.com/giraffesyo/openapi-go-naming"
"github.com/parallelworks/openapi-client-generator/internal/ir"
"github.com/parallelworks/openapi-client-generator/internal/templates"
)

// Analyzer walks a parsed OpenAPI 3.1 model and produces IR types.
Expand All @@ -20,15 +21,22 @@ type Analyzer struct {
// deduplicated on their variant set and discriminator.
synthesized []*ir.TypeDef
synthesizedByKey map[string]*ir.TypeDef
// multipartBodies holds the schema names a multipart request body refers to.
multipartBodies map[string]bool
// goNameBySchema maps every component schema to its Go type name, filled in
// before any conversion so a reference to a schema that has not been converted
// yet still resolves to the name it will end up with.
goNameBySchema map[string]string
}

// New creates an Analyzer for the given high-level OpenAPI model.
func New(model *v3high.Document) *Analyzer {
return &Analyzer{
model: model,
namer: naming.NewScope(),
namer: naming.NewScope(templates.ReservedIdentifiers...),
typesBySchema: make(map[string]*ir.TypeDef),
synthesizedByKey: make(map[string]*ir.TypeDef),
goNameBySchema: make(map[string]string),
}
}

Expand All @@ -54,6 +62,15 @@ func (a *Analyzer) Analyze(packageName string) (*ir.Package, error) {
}
}

// A multipart body's binary properties are generated as file parts rather
// than as byte slices, which has to be settled before the schemas holding
// them are converted.
a.multipartBodies = a.collectMultipartBodySchemas()

// Schema names are assigned next, and must avoid the identifiers the templates
// derive from operations and error bodies.
a.reserveDerivedNames()

// Analyze component schemas.
if err := a.analyzeComponentSchemas(pkg); err != nil {
return nil, err
Expand All @@ -72,6 +89,12 @@ func (a *Analyzer) Analyze(packageName string) (*ir.Package, error) {
// Append union types synthesized for inline oneOf/anyOf schemas.
pkg.Types = append(pkg.Types, a.synthesized...)

// A spec is free to define a type in terms of itself; Go aliases are not, and
// a struct may only do it through an indirection.
breakAliasCycles(pkg.Types)
breakStructCycles(pkg.Types)
dropShadowedCatchAlls(pkg.Types)

// Detect paginated operations.
a.detectPagination(pkg)

Expand Down Expand Up @@ -102,7 +125,9 @@ func (a *Analyzer) analyzeComponentSchemas(pkg *ir.Package) error {
if schema == nil {
continue
}
pending = append(pending, pendingSchema{name, a.namer.Unique(naming.Exported(name)), schema})
goName := a.namer.Unique(naming.Exported(name))
a.goNameBySchema[name] = goName
pending = append(pending, pendingSchema{name, goName, schema})
}

for _, p := range pending {
Expand Down
103 changes: 103 additions & 0 deletions internal/analyzer/enumnames.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
package analyzer

import (
"strings"
"unicode"

naming "github.com/giraffesyo/openapi-go-naming"
)

// operatorWords names the multi-character operators that turn up as enum values
// in filter and comparison DSLs, so they read as one idea rather than as their
// spelled-out parts.
var operatorWords = map[string]string{
"=": "Equal",
"==": "Equal",
"!=": "NotEqual",
"<>": "NotEqual",
"<": "LessThan",
"<=": "LessThanOrEqual",
">": "GreaterThan",
">=": "GreaterThanOrEqual",
"&&": "And",
"||": "Or",
}

// symbolWords names individual punctuation runes.
var symbolWords = map[rune]string{
' ': "Space",
'!': "Not",
'"': "Quote",
'#': "Hash",
'$': "Dollar",
'%': "Percent",
'&': "And",
'\'': "Apostrophe",
'(': "OpenParen",
')': "CloseParen",
'*': "Star",
'+': "Plus",
',': "Comma",
'-': "Minus",
'.': "Dot",
'/': "Slash",
':': "Colon",
';': "Semicolon",
'<': "Less",
'=': "Equal",
'>': "Greater",
'?': "Question",
'@': "At",
'[': "OpenBracket",
'\\': "Backslash",
']': "CloseBracket",
'^': "Caret",
'_': "Underscore",
'`': "Backtick",
'{': "OpenBrace",
'|': "Or",
'}': "CloseBrace",
'~': "Tilde",
}

// enumConstName builds the Go constant name for one member of an enum. Values
// made only of punctuation ("=", "<>") are spelled out, because sanitizing them
// leaves nothing to name the constant after and every member of such an enum
// would want the same identifier.
func enumConstName(typeName, raw string) string {
if raw == "" {
return naming.Exported(typeName + " Empty")
}
if !hasAlphanumeric(raw) {
if words := punctuationWords(raw); words != "" {
return naming.Exported(typeName + " " + words)
}
}
return naming.Exported(typeName + " " + raw)
}

// hasAlphanumeric reports whether s carries at least one rune that survives
// conversion to a Go identifier.
func hasAlphanumeric(s string) bool {
return strings.ContainsFunc(s, func(r rune) bool {
return unicode.IsLetter(r) || unicode.IsDigit(r)
})
}

// punctuationWords spells a punctuation-only value as words, returning "" when
// any rune has no name to spell it with.
func punctuationWords(raw string) string {
if words, ok := operatorWords[raw]; ok {
return words
}
var b strings.Builder
b.Grow(len(raw) * 8)
for _, r := range raw {
word, ok := symbolWords[r]
if !ok {
return ""
}
b.WriteString(word)
}
return b.String()
}
Loading
Loading