diff --git a/internal/analyzer/aliascycles.go b/internal/analyzer/aliascycles.go
new file mode 100644
index 0000000..456ebbc
--- /dev/null
+++ b/internal/analyzer/aliascycles.go
@@ -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)
+ }
+ }
+}
diff --git a/internal/analyzer/analyzer.go b/internal/analyzer/analyzer.go
index 0818532..d0eab7a 100644
--- a/internal/analyzer/analyzer.go
+++ b/internal/analyzer/analyzer.go
@@ -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.
@@ -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),
}
}
@@ -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
@@ -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)
@@ -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 {
diff --git a/internal/analyzer/enumnames.go b/internal/analyzer/enumnames.go
new file mode 100644
index 0000000..ba4c066
--- /dev/null
+++ b/internal/analyzer/enumnames.go
@@ -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()
+}
diff --git a/internal/analyzer/enumnames_test.go b/internal/analyzer/enumnames_test.go
new file mode 100644
index 0000000..ee2b430
--- /dev/null
+++ b/internal/analyzer/enumnames_test.go
@@ -0,0 +1,98 @@
+package analyzer
+
+import "testing"
+
+// TestEnumConstName pins how an enum value becomes a Go constant name. The
+// punctuation rows are the ones that used to collapse onto a single identifier
+// (issue #15): every member of a comparison-operator enum sanitized to the same
+// name, so the generated const block did not compile.
+func TestEnumConstName(t *testing.T) {
+ tests := []struct {
+ name string
+ typeName string
+ raw string
+ want string
+ }{
+ // The reported case: an enum of relational operators.
+ {"equal", "RelationalOperator", "=", "RelationalOperatorEqual"},
+ {"angle not equal", "RelationalOperator", "<>", "RelationalOperatorNotEqual"},
+ {"greater", "RelationalOperator", ">", "RelationalOperatorGreaterThan"},
+ {"less", "RelationalOperator", "<", "RelationalOperatorLessThan"},
+ {"greater or equal", "RelationalOperator", ">=", "RelationalOperatorGreaterThanOrEqual"},
+ {"less or equal", "RelationalOperator", "<=", "RelationalOperatorLessThanOrEqual"},
+ {"bang not equal", "RelationalOperator", "!=", "RelationalOperatorNotEqual"},
+ {"double equal", "RelationalOperator", "==", "RelationalOperatorEqual"},
+
+ // Punctuation with no operator spelling falls back to rune names.
+ {"star", "Wildcard", "*", "WildcardStar"},
+ {"slash", "Sep", "/", "SepSlash"},
+ {"double colon", "Sep", "::", "SepColonColon"},
+ {"arrow", "Dir", "->", "DirMinusGreater"},
+ {"empty", "Blank", "", "BlankEmpty"},
+
+ // Alphanumeric values keep the ordinary naming; punctuation inside them is
+ // still just a word separator.
+ {"word", "Status", "active", "StatusActive"},
+ {"hyphenated", "Status", "in-progress", "StatusInProgress"},
+ {"mixed", "Status", "n/a", "StatusNA"},
+ {"initialism", "Format", "json", "FormatJSON"},
+ {"numeric value", "Version", "2", "Version2"},
+
+ // A value made of runes with no name still yields something; the caller's
+ // uniquing scope resolves any collision.
+ {"unnameable", "Sym", "€", "Sym"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := enumConstName(tt.typeName, tt.raw); got != tt.want {
+ t.Errorf("enumConstName(%q, %q) = %q, want %q", tt.typeName, tt.raw, got, tt.want)
+ }
+ })
+ }
+}
+
+// TestConvertEnum_SymbolicValuesGetDistinctNames checks the whole enum path: the
+// six operator values must produce six distinct, non-numeric constant names.
+func TestConvertEnum_SymbolicValuesGetDistinctNames(t *testing.T) {
+ _, typeMap := analyzeSpec(t, `openapi: 3.1.0
+info: { title: t, version: "1" }
+paths: {}
+components:
+ schemas:
+ RelationalOperator:
+ type: string
+ enum: ["=", "<>", ">", "<", ">=", "<="]
+`)
+
+ td := typeMap["RelationalOperator"]
+ if td == nil {
+ t.Fatal("RelationalOperator type not found")
+ }
+ if len(td.EnumValues) != 6 {
+ t.Fatalf("enum values = %d, want 6", len(td.EnumValues))
+ }
+
+ want := map[string]string{
+ "RelationalOperatorEqual": `"="`,
+ "RelationalOperatorNotEqual": `"<>"`,
+ "RelationalOperatorGreaterThan": `">"`,
+ "RelationalOperatorLessThan": `"<"`,
+ "RelationalOperatorGreaterThanOrEqual": `">="`,
+ "RelationalOperatorLessThanOrEqual": `"<="`,
+ }
+ seen := make(map[string]bool, len(td.EnumValues))
+ for _, ev := range td.EnumValues {
+ if seen[ev.Name] {
+ t.Errorf("duplicate constant name %q", ev.Name)
+ }
+ seen[ev.Name] = true
+ literal, ok := want[ev.Name]
+ if !ok {
+ t.Errorf("unexpected constant %q = %s", ev.Name, ev.Literal)
+ continue
+ }
+ if ev.Literal != literal {
+ t.Errorf("%s = %s, want %s", ev.Name, ev.Literal, literal)
+ }
+ }
+}
diff --git a/internal/analyzer/operations.go b/internal/analyzer/operations.go
index 31516b6..1e887f3 100644
--- a/internal/analyzer/operations.go
+++ b/internal/analyzer/operations.go
@@ -2,10 +2,12 @@ package analyzer
import (
"fmt"
+ "slices"
"strings"
highbase "github.com/pb33f/libopenapi/datamodel/high/base"
v3high "github.com/pb33f/libopenapi/datamodel/high/v3"
+ "github.com/pb33f/libopenapi/orderedmap"
naming "github.com/giraffesyo/openapi-go-naming"
"github.com/parallelworks/openapi-client-generator/internal/ir"
@@ -18,24 +20,7 @@ func (a *Analyzer) analyzeOperations(pkg *ir.Package) error {
}
for path, pathItem := range a.model.Paths.PathItems.FromOldest() {
- methods := []struct {
- method string
- op *v3high.Operation
- }{
- {"GET", pathItem.Get},
- {"POST", pathItem.Post},
- {"PUT", pathItem.Put},
- {"DELETE", pathItem.Delete},
- {"PATCH", pathItem.Patch},
- {"HEAD", pathItem.Head},
- {"OPTIONS", pathItem.Options},
- }
-
- for _, m := range methods {
- if m.op == nil {
- continue
- }
-
+ for _, m := range pathOperations(pathItem) {
opDef, err := a.convertOperation(m.method, path, pathItem, m.op)
if err != nil {
return fmt.Errorf("converting %s %s: %w", m.method, path, err)
@@ -47,6 +32,140 @@ func (a *Analyzer) analyzeOperations(pkg *ir.Package) error {
return nil
}
+type pathOperation struct {
+ method string
+ op *v3high.Operation
+}
+
+// pathOperations returns the operations of a path item that get a generated
+// method. Everything that reasons about operations ahead of analyzeOperations
+// walks this same set, so the two can't disagree about what exists.
+func pathOperations(pathItem *v3high.PathItem) []pathOperation {
+ all := []pathOperation{
+ {"GET", pathItem.Get},
+ {"POST", pathItem.Post},
+ {"PUT", pathItem.Put},
+ {"DELETE", pathItem.Delete},
+ {"PATCH", pathItem.Patch},
+ {"HEAD", pathItem.Head},
+ {"OPTIONS", pathItem.Options},
+ {"TRACE", pathItem.Trace},
+ }
+ return slices.DeleteFunc(all, func(m pathOperation) bool { return m.op == nil })
+}
+
+// collectMultipartBodySchemas returns the component schema names that a request
+// body sends as multipart form data. Only the content type convertRequestBody
+// would pick counts: a schema also offered as JSON is encoded as JSON, so its
+// binary properties must stay byte slices.
+func (a *Analyzer) collectMultipartBodySchemas() map[string]bool {
+ names := make(map[string]bool)
+ if a.model.Paths == nil || a.model.Paths.PathItems == nil {
+ return names
+ }
+
+ for _, pathItem := range a.model.Paths.PathItems.FromOldest() {
+ for _, m := range pathOperations(pathItem) {
+ if m.op.RequestBody == nil {
+ continue
+ }
+ contentType, mediaType := preferredContent(m.op.RequestBody.Content)
+ if !strings.HasPrefix(contentType, "multipart/") || mediaType == nil || mediaType.Schema == nil {
+ continue
+ }
+ a.markMultipartSchema(names, refToSchemaName(mediaType.Schema.GetReference()), 0)
+ }
+ }
+ return names
+}
+
+// reserveDerivedNames keeps a schema off the identifiers the templates build out
+// of an operation or an error body, which share the one package scope with it.
+func (a *Analyzer) reserveDerivedNames() {
+ if a.model.Paths == nil || a.model.Paths.PathItems == nil {
+ return
+ }
+
+ for path, pathItem := range a.model.Paths.PathItems.FromOldest() {
+ for _, m := range pathOperations(pathItem) {
+ a.namer.Reserve(a.operationName(m.method, path, m.op) + "Params")
+
+ if m.op.Responses == nil || m.op.Responses.Codes == nil {
+ continue
+ }
+ for code, resp := range m.op.Responses.Codes.FromOldest() {
+ if isErrorCode(code) {
+ a.reserveErrorResponseName(resp)
+ }
+ }
+ a.reserveErrorResponseName(m.op.Responses.Default)
+ }
+ }
+}
+
+// reserveErrorResponseName reserves the wrapper type errors.go declares for an
+// error body.
+func (a *Analyzer) reserveErrorResponseName(resp *v3high.Response) {
+ if resp == nil || resp.Content == nil {
+ return
+ }
+ for _, mediaType := range resp.Content.FromOldest() {
+ if mediaType == nil || mediaType.Schema == nil {
+ continue
+ }
+ if refName := refToSchemaName(mediaType.Schema.GetReference()); refName != "" {
+ a.namer.Reserve(naming.Exported(refName) + "Response")
+ }
+ }
+}
+
+// markMultipartSchema marks a schema and everything it composes with allOf, so a
+// binary property inherited through composition is still generated as a file.
+func (a *Analyzer) markMultipartSchema(names map[string]bool, refName string, depth int) {
+ if refName == "" || names[refName] || depth > maxSchemaDepth {
+ return
+ }
+ names[refName] = true
+
+ if a.model.Components == nil || a.model.Components.Schemas == nil {
+ return
+ }
+ proxy, ok := a.model.Components.Schemas.Get(refName)
+ if !ok || proxy == nil {
+ return
+ }
+ schema, err := proxy.BuildSchema()
+ if err != nil || schema == nil {
+ return
+ }
+ for _, entry := range schema.AllOf {
+ a.markMultipartSchema(names, refToSchemaName(entry.GetReference()), depth+1)
+ }
+}
+
+// maxSchemaDepth bounds a walk over schemas that may refer to one another.
+const maxSchemaDepth = 32
+
+// preferredContent picks the media type a request body is sent as: JSON when the
+// spec offers a choice, otherwise the first one it lists.
+func preferredContent(content *orderedmap.Map[string, *v3high.MediaType]) (string, *v3high.MediaType) {
+ if content == nil {
+ return "", nil
+ }
+ var name string
+ var chosen *v3high.MediaType
+ for contentType, mediaType := range content.FromOldest() {
+ isJSON := strings.Contains(contentType, "json")
+ if name == "" || isJSON {
+ name, chosen = contentType, mediaType
+ }
+ if isJSON {
+ break
+ }
+ }
+ return name, chosen
+}
+
// convertOperation converts a single OpenAPI operation into an ir.OperationDef.
func (a *Analyzer) convertOperation(httpMethod, path string, pathItem *v3high.PathItem, op *v3high.Operation) (*ir.OperationDef, error) {
name := a.operationName(httpMethod, path, op)
@@ -259,40 +378,81 @@ func effectiveStyleExplode(param *v3high.Parameter) (string, bool) {
// convertRequestBody converts an OpenAPI request body to an ir.RequestBodyDef.
func (a *Analyzer) convertRequestBody(rb *v3high.RequestBody, nameHint string) (*ir.RequestBodyDef, error) {
- def := &ir.RequestBodyDef{
+ // The chosen content type decides how the body is encoded on the wire.
+ contentType, mediaType := preferredContent(rb.Content)
+ if contentType == "" {
+ // The spec declares a body but no content to put in it, so there is
+ // nothing for the caller to pass and no type to pass it as.
+ return nil, nil
+ }
+
+ return &ir.RequestBodyDef{
Required: rb.Required != nil && *rb.Required,
Description: rb.Description,
+ ContentType: contentType,
+ TypeName: bodyGoType(contentType, a.resolveMediaTypeSchema(mediaType, nameHint), mediaTypeSchema(mediaType)),
+ }, nil
+}
+
+// formEncodedContentType reports whether a body is sent as form data, whose
+// encoders walk the value property by property.
+func formEncodedContentType(contentType string) bool {
+ return strings.HasPrefix(contentType, "multipart/") ||
+ strings.HasPrefix(contentType, "application/x-www-form-urlencoded")
+}
+
+// mediaTypeSchema builds a media type's schema, resolving a reference to the
+// schema it names.
+func mediaTypeSchema(mt *v3high.MediaType) *highbase.Schema {
+ if mt == nil || mt.Schema == nil {
+ return nil
+ }
+ schema, err := mt.Schema.BuildSchema()
+ if err != nil {
+ return nil
}
+ return schema
+}
- if rb.Content == nil {
- return def, nil
+// isObjectLike reports whether a schema describes something with properties to
+// walk rather than a scalar or a list.
+func isObjectLike(schema *highbase.Schema) bool {
+ if schema == nil {
+ return false
}
+ if primaryType(schema) == "object" || len(schema.AllOf) > 0 {
+ return true
+ }
+ return schema.Properties != nil && schema.Properties.Len() > 0
+}
- // Prefer application/json content type.
- for contentType, mediaType := range rb.Content.FromOldest() {
- if strings.Contains(contentType, "json") {
- def.ContentType = contentType
- def.TypeName = a.resolveMediaTypeSchema(mediaType, nameHint)
- break
+// bodyGoType is the Go type a request body is accepted as. A body the client
+// cannot structurally encode — XML, say — is taken as the bytes or text it
+// already is rather than as a struct there would be no encoder for, and a body
+// the spec declares without a schema still needs some type to be passed as.
+func bodyGoType(contentType, typeName string, schema *highbase.Schema) string {
+ switch {
+ case strings.Contains(contentType, "json"):
+ if typeName == "" {
+ return "any"
}
- if strings.Contains(contentType, "multipart") {
- def.ContentType = contentType
- def.IsMultipart = true
- def.TypeName = a.resolveMediaTypeSchema(mediaType, nameHint)
- break
+ return typeName
+ case formEncodedContentType(contentType):
+ // The form encoders build parts and pairs out of an object's properties,
+ // so a body that is not an object gives them nothing to work from.
+ if isObjectLike(schema) {
+ return typeName
}
+ return "map[string]any"
}
-
- // If no JSON or multipart found, take the first content type.
- if def.ContentType == "" {
- for contentType, mediaType := range rb.Content.FromOldest() {
- def.ContentType = contentType
- def.TypeName = a.resolveMediaTypeSchema(mediaType, nameHint)
- break
- }
+ switch typeName {
+ case "string", "[]byte":
+ return typeName
}
-
- return def, nil
+ if strings.HasPrefix(contentType, "text/") {
+ return "string"
+ }
+ return "[]byte"
}
// convertResponses converts operation responses into the OperationDef fields.
@@ -367,15 +527,8 @@ func (a *Analyzer) resolveMediaTypeSchema(mt *v3high.MediaType, nameHint string)
}
// Check for a $ref first.
- ref := mt.Schema.GetReference()
- if ref != "" {
- refName := refToSchemaName(ref)
- if refName != "" {
- if td, ok := a.typesBySchema[refName]; ok {
- return td.Name
- }
- return naming.Exported(refName)
- }
+ if goType := a.goTypeForRef(mt.Schema.GetReference()); goType != "" {
+ return goType
}
schema, err := mt.Schema.BuildSchema()
diff --git a/internal/analyzer/schemas.go b/internal/analyzer/schemas.go
index 9a2e638..80499ab 100644
--- a/internal/analyzer/schemas.go
+++ b/internal/analyzer/schemas.go
@@ -23,22 +23,38 @@ func (a *Analyzer) convertSchema(goName, specName string, schema *highbase.Schem
return a.convertEnum(goName, schema, nullable)
}
- // Composition types: allOf, oneOf, anyOf.
- if len(schema.AllOf) > 0 {
- return a.convertAllOf(goName, schema, nullable)
- }
- if len(schema.OneOf) > 0 {
- return a.convertOneOf(goName, schema, nullable)
+ if isPureUnion(schema) {
+ // A oneOf/anyOf whose only other member is `type: null` is how OpenAPI 3.1
+ // spells "nullable T"; it offers no choice to model, so generate T itself.
+ if variant, ok := nullableUnionVariant(schema); ok {
+ return a.convertNullableUnion(goName, specName, schema, variant)
+ }
+ if goType, ok := a.uniformUnionGoType(schema, goName); ok {
+ return &ir.TypeDef{
+ Name: goName,
+ Description: schema.Description,
+ Kind: ir.TypeKindAlias,
+ GoType: goType,
+ IsNullable: nullable,
+ }, nil
+ }
+ if len(schema.OneOf) > 0 {
+ return a.convertOneOf(goName, schema, nullable)
+ }
+ if len(schema.AnyOf) > 0 {
+ return a.convertAnyOf(goName, schema, nullable)
+ }
}
- if len(schema.AnyOf) > 0 {
- return a.convertAnyOf(goName, schema, nullable)
+
+ if len(schema.AllOf) > 0 {
+ return a.convertAllOf(goName, schema, nullable, a.multipartBodies[specName])
}
primaryType := primaryType(schema)
switch primaryType {
case "object":
- return a.convertObject(goName, schema, nullable)
+ return a.convertObject(goName, schema, nullable, a.multipartBodies[specName])
case "array":
return a.convertArray(goName, schema, nullable)
case "string", "integer", "number", "boolean":
@@ -94,7 +110,7 @@ func (a *Analyzer) convertEnum(goName string, schema *highbase.Schema, nullable
// Unique keeps the const unique against package types/other consts —
// two values that sanitize to the same identifier ("a-b"/"a b"), or a const
// that matches a schema-named type, would otherwise fail to compile.
- constName := a.namer.Unique(naming.Exported(goName + " " + raw))
+ constName := a.namer.Unique(enumConstName(goName, raw))
td.EnumValues = append(td.EnumValues, &ir.EnumVal{
Name: constName,
Literal: literal,
@@ -155,7 +171,7 @@ func enumConstLiteral(goType, raw string) (string, bool) {
// convertAllOf creates a struct TypeDef from an allOf composition.
// $ref entries become embedded fields; inline schemas have their properties merged.
-func (a *Analyzer) convertAllOf(goName string, schema *highbase.Schema, nullable bool) (*ir.TypeDef, error) {
+func (a *Analyzer) convertAllOf(goName string, schema *highbase.Schema, nullable, multipartBody bool) (*ir.TypeDef, error) {
td := &ir.TypeDef{
Name: goName,
Description: schema.Description,
@@ -175,10 +191,7 @@ func (a *Analyzer) convertAllOf(goName string, schema *highbase.Schema, nullable
if refName != "" {
// $ref to a known component schema: add as embedded field.
- goTypeName := naming.Exported(refName)
- if td, ok := a.typesBySchema[refName]; ok {
- goTypeName = td.Name
- }
+ goTypeName := a.goTypeForSchemaName(refName)
td.Fields = append(td.Fields, &ir.Field{
Name: goTypeName,
Type: goTypeName,
@@ -214,32 +227,59 @@ func (a *Analyzer) convertAllOf(goName string, schema *highbase.Schema, nullable
continue
}
- required := requiredSet[propName]
- propNullable := isNullable(propSchema)
- goType := a.resolveGoType(propSchema, goName+naming.Exported(propName))
- isPointer := !required || propNullable
+ td.Fields = append(td.Fields, a.convertProperty(goName, propName, propSchema, requiredSet[propName], multipartBody))
+ }
+ }
- if isPointer && goType != "any" && !isSliceType(goType) && !isMapType(goType) {
- goType = "*" + goType
- }
+ a.addCatchAllField(td, schema, goName)
- td.Fields = append(td.Fields, &ir.Field{
- Name: naming.Exported(propName),
- JSONName: propName,
- Type: goType,
- Description: propSchema.Description,
- Required: required,
- IsPointer: isPointer,
- OmitEmpty: !required,
- Deprecated: propSchema.Deprecated != nil && *propSchema.Deprecated,
- ReadOnly: propSchema.ReadOnly != nil && *propSchema.ReadOnly,
- WriteOnly: propSchema.WriteOnly != nil && *propSchema.WriteOnly,
- PrimaryErrorMessage: isPrimaryErrorMessage(propSchema),
- })
+ return td, nil
+}
+
+// addCatchAllField gives a struct the synthetic field that holds whatever the
+// schema does not declare, when the schema admits such properties at all.
+func (a *Analyzer) addCatchAllField(td *ir.TypeDef, schema *highbase.Schema, goName string) {
+ if !allowsAdditionalProperties(schema) {
+ return
+ }
+ td.Fields = append(td.Fields, &ir.Field{
+ Name: catchAllFieldName(td.Fields),
+ JSONName: "-",
+ Type: "map[string]" + a.resolveAdditionalPropertiesType(schema, goName),
+ Description: "Properties not defined by the schema.",
+ CatchAll: true,
+ })
+}
+
+// convertProperty converts one object property into a struct field. multipartBody
+// marks a schema sent as multipart form data, whose binary properties are file
+// parts rather than byte slices.
+func (a *Analyzer) convertProperty(goName, propName string, propSchema *highbase.Schema, required, multipartBody bool) *ir.Field {
+ goType := a.resolveGoType(propSchema, goName+naming.Exported(propName))
+ if multipartBody {
+ if fileType, ok := formFileType(propSchema); ok {
+ goType = fileType
}
}
- return td, nil
+ isPointer := !required || isNullable(propSchema)
+ if isPointer && goType != "any" && !isSliceType(goType) && !isMapType(goType) {
+ goType = "*" + goType
+ }
+
+ return &ir.Field{
+ Name: naming.Exported(propName),
+ JSONName: propName,
+ Type: goType,
+ Description: propSchema.Description,
+ Required: required,
+ IsPointer: isPointer,
+ OmitEmpty: !required,
+ Deprecated: propSchema.Deprecated != nil && *propSchema.Deprecated,
+ ReadOnly: propSchema.ReadOnly != nil && *propSchema.ReadOnly,
+ WriteOnly: propSchema.WriteOnly != nil && *propSchema.WriteOnly,
+ PrimaryErrorMessage: isPrimaryErrorMessage(propSchema),
+ }
}
// isPrimaryErrorMessage reports whether a property schema carries Kiota's
@@ -292,12 +332,7 @@ func (a *Analyzer) convertUnion(goName string, schema *highbase.Schema, variants
discMapping = make(map[string]string)
for k, v := range schema.Discriminator.Mapping.FromOldest() {
// v is a $ref like "#/components/schemas/Circle"
- refName := refToSchemaName(v)
- goTypeName := naming.Exported(refName)
- if existing, ok := a.typesBySchema[refName]; ok {
- goTypeName = existing.Name
- }
- td.Discriminator.Mapping[k] = goTypeName
+ td.Discriminator.Mapping[k] = a.goTypeForSchemaName(refToSchemaName(v))
discMapping[v] = k
}
}
@@ -312,29 +347,29 @@ func (a *Analyzer) convertUnion(goName string, schema *highbase.Schema, variants
if refName == "" {
continue
}
- goTypeName := naming.Exported(refName)
- if existing, ok := a.typesBySchema[refName]; ok {
- goTypeName = existing.Name
- }
- td.Discriminator.Mapping[refName] = goTypeName
+ td.Discriminator.Mapping[refName] = a.goTypeForSchemaName(refName)
discMapping[ref] = refName
}
}
}
for _, proxy := range variants {
+ // A `type: null` member says the union is nullable; it is not one of the
+ // shapes the value can take, so it gets no variant of its own.
+ if isNullVariant(proxy) {
+ continue
+ }
+
ref := proxy.GetReference()
refName := refToSchemaName(ref)
- var typeName string
+ typeName := "any"
if refName != "" {
- typeName = naming.Exported(refName)
- if existing, ok := a.typesBySchema[refName]; ok {
- typeName = existing.Name
- }
- } else {
- // Inline variant: use "any" as the type.
- typeName = "any"
+ typeName = a.goTypeForSchemaName(refName)
+ } else if variantSchema, err := proxy.BuildSchema(); err == nil && variantSchema != nil {
+ // An inline variant still has a Go type; without one it would decode
+ // into nothing and the payloads it covers would fail to unmarshal.
+ typeName = a.resolveGoType(variantSchema, suffixHint(goName, "Variant"))
}
variant := &ir.UnionVariant{
@@ -353,7 +388,7 @@ func (a *Analyzer) convertUnion(goName string, schema *highbase.Schema, variants
}
// convertObject creates a struct TypeDef from an object schema.
-func (a *Analyzer) convertObject(goName string, schema *highbase.Schema, nullable bool) (*ir.TypeDef, error) {
+func (a *Analyzer) convertObject(goName string, schema *highbase.Schema, nullable, multipartBody bool) (*ir.TypeDef, error) {
// If no defined properties and additionalProperties is set, generate a map alias.
hasProperties := schema.Properties != nil && schema.Properties.Len() > 0
if !hasProperties && allowsAdditionalProperties(schema) {
@@ -385,43 +420,12 @@ func (a *Analyzer) convertObject(goName string, schema *highbase.Schema, nullabl
continue
}
- required := requiredSet[propName]
- propNullable := isNullable(propSchema)
- goType := a.resolveGoType(propSchema, goName+naming.Exported(propName))
- isPointer := !required || propNullable
-
- if isPointer && goType != "any" && !isSliceType(goType) && !isMapType(goType) {
- goType = "*" + goType
- }
-
- td.Fields = append(td.Fields, &ir.Field{
- Name: naming.Exported(propName),
- JSONName: propName,
- Type: goType,
- Description: propSchema.Description,
- Required: required,
- IsPointer: isPointer,
- OmitEmpty: !required,
- Deprecated: propSchema.Deprecated != nil && *propSchema.Deprecated,
- ReadOnly: propSchema.ReadOnly != nil && *propSchema.ReadOnly,
- WriteOnly: propSchema.WriteOnly != nil && *propSchema.WriteOnly,
- PrimaryErrorMessage: isPrimaryErrorMessage(propSchema),
- })
+ td.Fields = append(td.Fields, a.convertProperty(goName, propName, propSchema, requiredSet[propName], multipartBody))
}
- // If the object has both properties and additionalProperties, add an extra field.
- // The generator gives such structs MarshalJSON/UnmarshalJSON so the map is
- // inlined into the object rather than nested under a key of its own.
- if allowsAdditionalProperties(schema) {
- mapValueType := a.resolveAdditionalPropertiesType(schema, goName)
- td.Fields = append(td.Fields, &ir.Field{
- Name: catchAllFieldName(td.Fields),
- JSONName: "-",
- Type: "map[string]" + mapValueType,
- Description: "Properties not defined by the schema.",
- CatchAll: true,
- })
- }
+ // The generator gives a struct with a catch-all MarshalJSON/UnmarshalJSON so
+ // the map is inlined into the object rather than nested under a key of its own.
+ a.addCatchAllField(td, schema, goName)
return td, nil
}
@@ -450,6 +454,29 @@ func catchAllFieldName(fields []*ir.Field) string {
return name
}
+// formFileType returns the Go type for a multipart property carrying file content.
+func formFileType(schema *highbase.Schema) (string, bool) {
+ if variant, ok := nullableUnionVariant(schema); ok {
+ return formFileType(variant)
+ }
+ if isBinarySchema(schema) {
+ return "FormFile", true
+ }
+ if primaryType(schema) == "array" && schema.Items != nil && schema.Items.IsA() {
+ items, err := schema.Items.A.BuildSchema()
+ if err == nil && items != nil && isBinarySchema(items) {
+ return "[]FormFile", true
+ }
+ }
+ return "", false
+}
+
+// isBinarySchema reports whether a schema is `type: string, format: binary`,
+// which inside a multipart body means file content rather than text.
+func isBinarySchema(schema *highbase.Schema) bool {
+ return primaryType(schema) == "string" && schema.Format == "binary"
+}
+
// convertAdditionalPropertiesMap creates a map alias when an object has
// additionalProperties but no defined properties.
func (a *Analyzer) convertAdditionalPropertiesMap(goName string, schema *highbase.Schema, nullable bool) (*ir.TypeDef, error) {
@@ -524,18 +551,17 @@ func (a *Analyzer) convertPrimitive(goName, primaryType string, schema *highbase
// pass "" when no context is available.
func (a *Analyzer) resolveGoType(schema *highbase.Schema, nameHint string) string {
// Check if this schema is a $ref pointing to a known component schema.
- if schema.ParentProxy != nil {
- ref := schema.ParentProxy.GetReference()
- if ref != "" {
- refName := refToSchemaName(ref)
- if refName != "" {
- if td, ok := a.typesBySchema[refName]; ok {
- return td.Name
- }
- // Not yet converted, use the Go name directly.
- return naming.Exported(refName)
- }
- }
+ if goType := a.refGoType(schema); goType != "" {
+ return goType
+ }
+
+ // "nullable T" spelled as a union resolves to T; the pointer that carries the
+ // null comes from the field or parameter being optional/nullable.
+ if variant, ok := nullableUnionVariant(schema); ok {
+ return a.resolveGoType(variant, nameHint)
+ }
+ if goType, ok := a.uniformUnionGoType(schema, nameHint); ok {
+ return goType
}
// Inline oneOf/anyOf: synthesize a named union so $ref variants stay typed.
@@ -597,20 +623,34 @@ func (a *Analyzer) synthesizeInlineUnion(schema *highbase.Schema, nameHint strin
return "", false
}
- refs := make([]string, 0, len(variants))
- hasRef := false
+ // Key on what each member resolves to rather than on its $ref, so two inline
+ // unions of different shapes don't collapse onto one synthesized type.
+ members := make([]string, 0, len(variants))
+ typed := false
for _, proxy := range variants {
- ref := proxy.GetReference()
- if ref != "" {
- hasRef = true
+ if isNullVariant(proxy) {
+ continue
}
- refs = append(refs, ref)
+ if ref := proxy.GetReference(); ref != "" {
+ members = append(members, ref)
+ typed = true
+ continue
+ }
+ variantSchema, err := proxy.BuildSchema()
+ if err != nil || variantSchema == nil {
+ return "", false
+ }
+ goType := a.resolveGoType(variantSchema, suffixHint(nameHint, "Variant"))
+ members = append(members, goType)
+ typed = typed || goType != "any"
}
- if !hasRef {
+ // A union whose members all decode into any is just any; naming it would add a
+ // type that carries no more information than the bare interface.
+ if !typed || len(members) < 2 {
return "", false
}
- key := kind + "|" + strings.Join(refs, ",")
+ key := kind + "|" + strings.Join(members, ",")
if schema.Discriminator != nil {
key += "|" + schema.Discriminator.PropertyName
}
@@ -649,12 +689,160 @@ func primaryType(schema *highbase.Schema) string {
}
// isNullable checks whether a schema is nullable. In OpenAPI 3.1, this is
-// indicated by type: ["string", "null"]. In 3.0, it's nullable: true.
+// indicated by type: ["string", "null"] or a {"type": "null"} member of a
+// oneOf/anyOf. In 3.0, it's nullable: true.
func isNullable(schema *highbase.Schema) bool {
if schema.Nullable != nil && *schema.Nullable {
return true
}
- return slices.Contains(schema.Type, "null")
+ if slices.Contains(schema.Type, "null") {
+ return true
+ }
+ return slices.ContainsFunc(unionVariants(schema), isNullVariant)
+}
+
+// isPureUnion reports whether a schema's oneOf/anyOf is the whole of what it is.
+// A schema that also composes or declares properties uses the union to constrain
+// the object the rest of it describes, so collapsing to a member would throw that
+// away.
+func isPureUnion(schema *highbase.Schema) bool {
+ return len(schema.AllOf) == 0 && (schema.Properties == nil || schema.Properties.Len() == 0)
+}
+
+// unionVariants returns a schema's oneOf variants, or its anyOf variants when it
+// has no oneOf.
+func unionVariants(schema *highbase.Schema) []*highbase.SchemaProxy {
+ if len(schema.OneOf) > 0 {
+ return schema.OneOf
+ }
+ return schema.AnyOf
+}
+
+// isNullVariant reports whether a union member is the bare {"type": "null"}
+// schema that makes the union nullable.
+func isNullVariant(proxy *highbase.SchemaProxy) bool {
+ schema, err := proxy.BuildSchema()
+ if err != nil || schema == nil {
+ return false
+ }
+ return primaryType(schema) == "" && slices.Contains(schema.Type, "null")
+}
+
+// nullableUnionVariant returns the sole non-null member of a oneOf/anyOf — the
+// OpenAPI 3.1 spelling of "nullable T"; a real choice returns ok=false.
+func nullableUnionVariant(schema *highbase.Schema) (*highbase.Schema, bool) {
+ variants := unionVariants(schema)
+ if len(variants) == 0 {
+ return nil, false
+ }
+
+ var only *highbase.Schema
+ for _, proxy := range variants {
+ if isNullVariant(proxy) {
+ continue
+ }
+ if only != nil {
+ return nil, false
+ }
+ variant, err := proxy.BuildSchema()
+ if err != nil || variant == nil {
+ return nil, false
+ }
+ only = variant
+ }
+ if only == nil {
+ return nil, false
+ }
+ return only, true
+}
+
+// uniformUnionGoType returns the Go type of a oneOf/anyOf whose non-null members
+// all resolve to it — several refinements of one type are that type, not a choice.
+func (a *Analyzer) uniformUnionGoType(schema *highbase.Schema, nameHint string) (string, bool) {
+ variants := unionVariants(schema)
+ if len(variants) < 2 {
+ return "", false
+ }
+
+ // Resolving a member can synthesize a type for it, so use the same hint the
+ // union path would: probing must not let a member claim the name the union
+ // itself will need when the members turn out to disagree.
+ memberHint := suffixHint(nameHint, "Variant")
+
+ goType := ""
+ for _, proxy := range variants {
+ if isNullVariant(proxy) {
+ continue
+ }
+ variant, err := proxy.BuildSchema()
+ if err != nil || variant == nil {
+ return "", false
+ }
+ resolved := a.resolveGoType(variant, memberHint)
+ if resolved == "any" || (goType != "" && resolved != goType) {
+ return "", false
+ }
+ goType = resolved
+ }
+ return goType, goType != ""
+}
+
+// convertNullableUnion converts the collapsed "nullable T" union at goName; a
+// $ref variant aliases the type it points at.
+func (a *Analyzer) convertNullableUnion(goName, specName string, schema, variant *highbase.Schema) (*ir.TypeDef, error) {
+ nullable := isNullable(schema)
+ if goType := a.refGoType(variant); goType != "" {
+ return &ir.TypeDef{
+ Name: goName,
+ Description: schema.Description,
+ Kind: ir.TypeKindAlias,
+ GoType: goType,
+ IsNullable: nullable,
+ }, nil
+ }
+
+ td, err := a.convertSchema(goName, specName, variant)
+ if err != nil {
+ return nil, err
+ }
+ td.IsNullable = nullable
+ if td.Description == "" {
+ td.Description = schema.Description
+ }
+ return td, nil
+}
+
+// refGoType returns the Go type name a $ref schema resolves to, or "" when the
+// schema is not a reference to a component schema.
+func (a *Analyzer) refGoType(schema *highbase.Schema) string {
+ if schema.ParentProxy == nil {
+ return ""
+ }
+ return a.goTypeForRef(schema.ParentProxy.GetReference())
+}
+
+// goTypeForRef returns the Go type name a "#/components/schemas/..." reference
+// resolves to, or "" when it points elsewhere.
+func (a *Analyzer) goTypeForRef(ref string) string {
+ refName := refToSchemaName(ref)
+ if refName == "" {
+ return ""
+ }
+ return a.goTypeForSchemaName(refName)
+}
+
+// goTypeForSchemaName returns the Go type name of a component schema, falling
+// back to its exported spelling when the schema is not one of the components.
+func (a *Analyzer) goTypeForSchemaName(refName string) string {
+ if td, ok := a.typesBySchema[refName]; ok {
+ return td.Name
+ }
+ // Not converted yet: the name it was assigned, which a renamed schema needs
+ // for the reference to land on the right type.
+ if goName, ok := a.goNameBySchema[refName]; ok {
+ return goName
+ }
+ return naming.Exported(refName)
}
// goTypeForPrimitive maps an OpenAPI type + format to a Go type.
diff --git a/internal/analyzer/schemas_nullable_union_test.go b/internal/analyzer/schemas_nullable_union_test.go
new file mode 100644
index 0000000..50d22bb
--- /dev/null
+++ b/internal/analyzer/schemas_nullable_union_test.go
@@ -0,0 +1,500 @@
+package analyzer
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/parallelworks/openapi-client-generator/internal/ir"
+ "github.com/parallelworks/openapi-client-generator/internal/parser"
+)
+
+// analyzeSpec analyzes an inline spec and returns the package alongside its
+// types keyed by Go name.
+func analyzeSpec(t *testing.T, spec string) (*ir.Package, map[string]*ir.TypeDef) {
+ t.Helper()
+ specPath := filepath.Join(t.TempDir(), "spec.yaml")
+ if err := os.WriteFile(specPath, []byte(spec), 0o644); err != nil {
+ t.Fatalf("writing spec: %v", err)
+ }
+ result, err := parser.Parse(specPath, parser.Config{})
+ if err != nil {
+ t.Fatalf("Parse: %v", err)
+ }
+ pkg, err := New(result.Model).Analyze("test")
+ if err != nil {
+ t.Fatalf("Analyze: %v", err)
+ }
+ typeMap := make(map[string]*ir.TypeDef, len(pkg.Types))
+ for _, td := range pkg.Types {
+ typeMap[td.Name] = td
+ }
+ return pkg, typeMap
+}
+
+const nullableUnionSpec = `openapi: 3.1.0
+info: { title: t, version: "1" }
+paths:
+ /recipes:
+ get:
+ operationId: listRecipes
+ parameters:
+ - name: search
+ in: query
+ required: false
+ schema:
+ anyOf: [{ type: string }, { type: "null" }]
+ title: Search
+ - name: limit
+ in: query
+ required: true
+ schema:
+ anyOf: [{ type: integer, format: int32 }, { type: "null" }]
+ - name: owner
+ in: query
+ required: false
+ schema:
+ anyOf: [{ $ref: "#/components/schemas/Person" }, { type: "null" }]
+ - name: either
+ in: query
+ required: false
+ schema:
+ anyOf: [{ type: string }, { type: integer }, { type: "null" }]
+ - name: cookbook
+ in: query
+ required: false
+ schema:
+ anyOf:
+ - { type: string, format: uuid4 }
+ - { type: string }
+ - { type: "null" }
+ - name: categories
+ in: query
+ required: false
+ schema:
+ anyOf:
+ - type: array
+ items:
+ anyOf: [{ type: string, format: uuid4 }, { type: string }]
+ - { type: "null" }
+ responses:
+ "200": { description: ok }
+components:
+ schemas:
+ Person:
+ type: object
+ properties:
+ name: { type: string }
+ MaybeName:
+ anyOf: [{ type: string }, { type: "null" }]
+ MaybePerson:
+ oneOf: [{ $ref: "#/components/schemas/Person" }, { type: "null" }]
+ Recipe:
+ type: object
+ required: [name]
+ properties:
+ name:
+ anyOf: [{ type: string }, { type: "null" }]
+ tags:
+ anyOf: [{ type: array, items: { type: string } }, { type: "null" }]
+ cook:
+ anyOf: [{ $ref: "#/components/schemas/Person" }, { type: "null" }]
+`
+
+// TestRequestBodyContentType checks which media type an operation sends its body
+// as, now that the choice decides the encoding rather than just a header.
+func TestRequestBodyContentType(t *testing.T) {
+ pkg, _ := analyzeSpec(t, `openapi: 3.1.0
+info: { title: t, version: "1" }
+paths:
+ /upload:
+ post:
+ operationId: upload
+ requestBody:
+ content:
+ multipart/form-data:
+ schema: { type: object }
+ responses: { "204": { description: ok } }
+ /both:
+ post:
+ operationId: both
+ requestBody:
+ content:
+ multipart/form-data:
+ schema: { type: object }
+ application/json:
+ schema: { type: object }
+ responses: { "204": { description: ok } }
+ /raw:
+ post:
+ operationId: raw
+ requestBody:
+ content:
+ application/octet-stream:
+ schema: { type: string, format: binary }
+ responses: { "204": { description: ok } }
+`)
+
+ byName := make(map[string]*ir.OperationDef, len(pkg.Operations))
+ for _, op := range pkg.Operations {
+ byName[op.Name] = op
+ }
+
+ tests := []struct {
+ op string
+ want string
+ }{
+ {"Upload", "multipart/form-data"},
+ // JSON wins when the spec offers a choice, whatever order it lists them in.
+ {"Both", "application/json"},
+ {"Raw", "application/octet-stream"},
+ }
+ for _, tt := range tests {
+ op := byName[tt.op]
+ if op == nil {
+ t.Errorf("operation %q not found", tt.op)
+ continue
+ }
+ if op.RequestBody == nil {
+ t.Errorf("%s has no request body", tt.op)
+ continue
+ }
+ if op.RequestBody.ContentType != tt.want {
+ t.Errorf("%s content type = %q, want %q", tt.op, op.RequestBody.ContentType, tt.want)
+ }
+ }
+}
+
+// TestNullableUnion_QueryParamsResolveToTheVariantType covers issue #16: a query
+// parameter typed `anyOf: [{type: string}, {type: "null"}]` used to land in the
+// params struct as *any, which callers had no way to construct a value for.
+func TestNullableUnion_QueryParamsResolveToTheVariantType(t *testing.T) {
+ pkg, _ := analyzeSpec(t, nullableUnionSpec)
+
+ if len(pkg.Operations) != 1 {
+ t.Fatalf("operations = %d, want 1", len(pkg.Operations))
+ }
+ params := make(map[string]*ir.ParamDef)
+ for _, p := range pkg.Operations[0].QueryParams {
+ params[p.OrigName] = p
+ }
+
+ tests := []struct {
+ param string
+ want string
+ }{
+ {"search", "string"},
+ {"limit", "int32"},
+ {"owner", "Person"},
+ // Variants that are refinements of one Go type collapse to that type.
+ {"cookbook", "string"},
+ {"categories", "[]string"},
+ // A union with a real choice keeps its union handling.
+ {"either", "any"},
+ }
+ for _, tt := range tests {
+ p := params[tt.param]
+ if p == nil {
+ t.Errorf("query param %q not found", tt.param)
+ continue
+ }
+ if p.Type != tt.want {
+ t.Errorf("param %q type = %q, want %q", tt.param, p.Type, tt.want)
+ }
+ }
+}
+
+// TestNullableUnion_ComponentSchemasCollapse checks the same collapse when the
+// union is a named component schema rather than an inline parameter schema.
+func TestNullableUnion_ComponentSchemasCollapse(t *testing.T) {
+ _, typeMap := analyzeSpec(t, nullableUnionSpec)
+
+ name := typeMap["MaybeName"]
+ if name == nil {
+ t.Fatal("MaybeName type not found")
+ }
+ if name.Kind != ir.TypeKindAlias || name.GoType != "string" {
+ t.Errorf("MaybeName = %v %q, want alias string", name.Kind, name.GoType)
+ }
+ if !name.IsNullable {
+ t.Error("MaybeName.IsNullable = false, want true")
+ }
+
+ // A $ref variant aliases the type it points at instead of restating it.
+ person := typeMap["MaybePerson"]
+ if person == nil {
+ t.Fatal("MaybePerson type not found")
+ }
+ if person.Kind != ir.TypeKindAlias || person.GoType != "Person" {
+ t.Errorf("MaybePerson = %v %q, want alias Person", person.Kind, person.GoType)
+ }
+}
+
+// TestNullableUnion_StructFieldsArePointersToTheVariantType checks that a
+// collapsed property still carries the null through a pointer, including when
+// the property is required.
+func TestNullableUnion_StructFieldsArePointersToTheVariantType(t *testing.T) {
+ _, typeMap := analyzeSpec(t, nullableUnionSpec)
+
+ recipe := typeMap["Recipe"]
+ if recipe == nil {
+ t.Fatal("Recipe type not found")
+ }
+ fields := make(map[string]*ir.Field, len(recipe.Fields))
+ for _, f := range recipe.Fields {
+ fields[f.JSONName] = f
+ }
+
+ tests := []struct {
+ field string
+ want string
+ }{
+ // Required, but nullable through the union, so still a pointer.
+ {"name", "*string"},
+ {"tags", "[]string"},
+ {"cook", "*Person"},
+ }
+ for _, tt := range tests {
+ f := fields[tt.field]
+ if f == nil {
+ t.Errorf("Recipe field %q not found", tt.field)
+ continue
+ }
+ if f.Type != tt.want {
+ t.Errorf("Recipe.%s type = %q, want %q", tt.field, f.Type, tt.want)
+ }
+ }
+}
+
+// TestUnion_InlineVariantsGetNamedTypes covers the rest of what issue #16 asked
+// for: a union whose members are inline schemas used to degrade to a bare any,
+// which callers could neither construct nor decode into.
+func TestUnion_InlineVariantsGetNamedTypes(t *testing.T) {
+ _, typeMap := analyzeSpec(t, `openapi: 3.1.0
+info: { title: t, version: "1" }
+paths: {}
+components:
+ schemas:
+ Person:
+ type: object
+ properties:
+ name: { type: string }
+ Filter:
+ type: object
+ properties:
+ value:
+ anyOf: [{ type: string }, { type: array, items: { type: string } }, { type: "null" }]
+ title: Value
+ loc:
+ type: array
+ items:
+ anyOf: [{ type: string }, { type: integer }]
+ who:
+ anyOf: [{ $ref: "#/components/schemas/Person" }, { type: string }]
+`)
+
+ filter := typeMap["Filter"]
+ if filter == nil {
+ t.Fatal("Filter type not found")
+ }
+ fields := make(map[string]*ir.Field, len(filter.Fields))
+ for _, f := range filter.Fields {
+ fields[f.JSONName] = f
+ }
+
+ for _, tt := range []struct{ field, want string }{
+ {"value", "*Value"},
+ {"loc", "[]FilterLocItem"},
+ {"who", "*FilterWho"},
+ } {
+ f := fields[tt.field]
+ if f == nil {
+ t.Errorf("Filter field %q not found", tt.field)
+ continue
+ }
+ if f.Type != tt.want {
+ t.Errorf("Filter.%s type = %q, want %q", tt.field, f.Type, tt.want)
+ }
+ }
+
+ // The `type: null` member says the union is nullable; it is not a shape the
+ // value can take, so it gets no variant.
+ value := typeMap["Value"]
+ if value == nil {
+ t.Fatal("synthesized union Value not found")
+ }
+ if value.Kind != ir.TypeKindUnion {
+ t.Fatalf("Value kind = %v, want union", value.Kind)
+ }
+ got := make([]string, 0, len(value.UnionTypes))
+ for _, v := range value.UnionTypes {
+ got = append(got, v.TypeName)
+ }
+ if len(got) != 2 || got[0] != "string" || got[1] != "[]string" {
+ t.Errorf("Value variants = %v, want [string []string]", got)
+ }
+}
+
+// TestMultipartBody_BinaryPropertiesAreFiles checks that a binary property of a
+// multipart body is generated as a file the caller can name, while the same
+// format elsewhere stays a byte slice.
+func TestMultipartBody_BinaryPropertiesAreFiles(t *testing.T) {
+ _, typeMap := analyzeSpec(t, `openapi: 3.1.0
+info: { title: t, version: "1" }
+paths:
+ /image:
+ put:
+ operationId: putImage
+ requestBody:
+ required: true
+ content:
+ multipart/form-data:
+ schema: { $ref: "#/components/schemas/ImageUpload" }
+ responses: { "204": { description: ok } }
+ /doc:
+ put:
+ operationId: putDoc
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema: { $ref: "#/components/schemas/JSONUpload" }
+ responses: { "204": { description: ok } }
+components:
+ schemas:
+ ImageUpload:
+ type: object
+ required: [image]
+ properties:
+ image: { type: string, format: binary }
+ thumbnail:
+ anyOf: [{ type: string, format: binary }, { type: "null" }]
+ attachments: { type: array, items: { type: string, format: binary } }
+ extension: { type: string }
+ JSONUpload:
+ type: object
+ required: [blob]
+ properties:
+ blob: { type: string, format: binary }
+`)
+
+ upload := typeMap["ImageUpload"]
+ if upload == nil {
+ t.Fatal("ImageUpload type not found")
+ }
+ fields := make(map[string]*ir.Field, len(upload.Fields))
+ for _, f := range upload.Fields {
+ fields[f.JSONName] = f
+ }
+ for _, tt := range []struct{ field, want string }{
+ {"image", "FormFile"},
+ {"thumbnail", "*FormFile"},
+ {"attachments", "[]FormFile"},
+ {"extension", "*string"},
+ } {
+ f := fields[tt.field]
+ if f == nil {
+ t.Errorf("ImageUpload field %q not found", tt.field)
+ continue
+ }
+ if f.Type != tt.want {
+ t.Errorf("ImageUpload.%s type = %q, want %q", tt.field, f.Type, tt.want)
+ }
+ }
+
+ // Outside a multipart body, binary is still a byte slice.
+ jsonUpload := typeMap["JSONUpload"]
+ if jsonUpload == nil {
+ t.Fatal("JSONUpload type not found")
+ }
+ if got := jsonUpload.Fields[0].Type; got != "[]byte" {
+ t.Errorf("JSONUpload.blob type = %q, want []byte", got)
+ }
+}
+
+// TestNullableUnion_SelfReferentialAliasesCompile pins that a schema whose only
+// non-null member refers back to itself does not emit `type A = B; type B = A`,
+// which Go rejects as an invalid recursive type.
+func TestNullableUnion_SelfReferentialAliasesCompile(t *testing.T) {
+ _, typeMap := analyzeSpec(t, `openapi: 3.1.0
+info: { title: t, version: "1" }
+paths: {}
+components:
+ schemas:
+ Loop:
+ anyOf: [{ $ref: "#/components/schemas/Loop" }, { type: "null" }]
+ A:
+ anyOf: [{ $ref: "#/components/schemas/B" }, { type: "null" }]
+ B:
+ anyOf: [{ $ref: "#/components/schemas/A" }, { type: "null" }]
+ C:
+ anyOf: [{ $ref: "#/components/schemas/D" }, { type: "null" }]
+ D:
+ anyOf: [{ type: array, items: { $ref: "#/components/schemas/C" } }, { type: "null" }]
+`)
+
+ // Follow every alias chain; none may return to a name already on it.
+ for _, start := range []string{"Loop", "A", "B", "C", "D"} {
+ td := typeMap[start]
+ if td == nil {
+ t.Errorf("%s type not found", start)
+ continue
+ }
+ seen := map[string]bool{start: true}
+ for td != nil && td.Kind == ir.TypeKindAlias {
+ next := typeMap[aliasTarget(td.GoType)]
+ if next == nil {
+ break
+ }
+ if seen[next.Name] {
+ t.Errorf("alias chain from %s cycles back to %s", start, next.Name)
+ break
+ }
+ seen[next.Name] = true
+ td = next
+ }
+ }
+}
+
+// TestUnion_AlongsideCompositionKeepsTheObject pins that a oneOf/anyOf used to
+// constrain an object -- "exactly one of these is required", or a refinement of
+// an allOf -- does not collapse the schema onto one of its members, throwing the
+// declared properties and the composition away.
+func TestUnion_AlongsideCompositionKeepsTheObject(t *testing.T) {
+ _, typeMap := analyzeSpec(t, `openapi: 3.1.0
+info: { title: t, version: "1" }
+paths: {}
+components:
+ schemas:
+ Wrapper:
+ type: object
+ properties: { w: { type: string } }
+ ConstrainedObject:
+ type: object
+ properties:
+ a: { type: string }
+ b: { type: string }
+ oneOf:
+ - required: [a]
+ - required: [b]
+ ComposedWithUnion:
+ allOf: [{ $ref: "#/components/schemas/Wrapper" }]
+ oneOf: [{ type: string }, { type: string, format: date }]
+`)
+
+ obj := typeMap["ConstrainedObject"]
+ if obj == nil || obj.Kind != ir.TypeKindStruct {
+ t.Fatalf("ConstrainedObject = %+v, want a struct", obj)
+ }
+ if len(obj.Fields) != 2 {
+ t.Errorf("ConstrainedObject has %d fields, want its two declared properties", len(obj.Fields))
+ }
+
+ composed := typeMap["ComposedWithUnion"]
+ if composed == nil || composed.Kind != ir.TypeKindStruct {
+ t.Fatalf("ComposedWithUnion = %+v, want a struct", composed)
+ }
+ if len(composed.Fields) != 1 || !composed.Fields[0].Embedded || composed.Fields[0].Type != "Wrapper" {
+ t.Errorf("ComposedWithUnion fields = %+v, want the embedded Wrapper", composed.Fields)
+ }
+}
diff --git a/internal/generator/e2e_additional_properties_test.go b/internal/generator/e2e_additional_properties_test.go
index 5915b82..4c02984 100644
--- a/internal/generator/e2e_additional_properties_test.go
+++ b/internal/generator/e2e_additional_properties_test.go
@@ -38,6 +38,26 @@ components:
properties:
only: { type: string }
additionalProperties: false
+ Base:
+ type: object
+ properties:
+ id: { type: string }
+ Composed:
+ allOf:
+ - $ref: "#/components/schemas/Base"
+ - type: object
+ properties:
+ name: { type: string }
+ additionalProperties: true
+ NullableBase:
+ anyOf: [{ $ref: "#/components/schemas/Base" }, { type: "null" }]
+ ComposedThroughAlias:
+ allOf:
+ - $ref: "#/components/schemas/NullableBase"
+ - type: object
+ properties:
+ note: { type: string }
+ additionalProperties: true
`
const additionalPropertiesRuntimeTest = `package petsapi
@@ -160,6 +180,82 @@ func TestEmptyAdditionalPropertiesOmitsNothingExtra(t *testing.T) {
t.Errorf("marshal = %s, want {\"name\":\"rex\"}", out)
}
}
+
+// A composed schema collects undeclared properties just like a plain one, and
+// the properties it inherits from the schema it embeds are not re-collected.
+func TestComposedSchemaKeepsUnknownKeys(t *testing.T) {
+ var c Composed
+ if err := json.Unmarshal([]byte(` + "`" + `{"id":"x","name":"n","extra":"kept"}` + "`" + `), &c); err != nil {
+ t.Fatalf("unmarshal: %v", err)
+ }
+ if c.ID == nil || *c.ID != "x" {
+ t.Errorf("inherited property lost: %v", c.ID)
+ }
+ if c.Name == nil || *c.Name != "n" {
+ t.Errorf("declared property lost: %v", c.Name)
+ }
+ if got := c.AdditionalProperties["extra"]; got != "kept" {
+ t.Errorf("AdditionalProperties[extra] = %v, want kept", got)
+ }
+ if _, ok := c.AdditionalProperties["id"]; ok {
+ t.Error("a property inherited from the embedded schema landed in the catch-all")
+ }
+
+ out, err := json.Marshal(c)
+ if err != nil {
+ t.Fatalf("marshal: %v", err)
+ }
+ if n := strings.Count(string(out), ` + "`" + `"id"` + "`" + `); n != 1 {
+ t.Errorf("id emitted %d times: %s", n, out)
+ }
+ var got map[string]any
+ if err := json.Unmarshal(out, &got); err != nil {
+ t.Fatalf("re-unmarshal: %v", err)
+ }
+ for key, want := range map[string]any{"id": "x", "name": "n", "extra": "kept"} {
+ if got[key] != want {
+ t.Errorf("round trip[%s] = %v, want %v", key, got[key], want)
+ }
+ }
+}
+
+// One undeclared property of the wrong type must not cost the caller the whole
+// response; the declared fields are what consumers depend on.
+func TestOffTypeExtraDoesNotFailTheDecode(t *testing.T) {
+ var l Labels
+ if err := json.Unmarshal([]byte(` + "`" + `{"owner":"me","count":3,"env":"prod"}` + "`" + `), &l); err != nil {
+ t.Fatalf("one off-type extra failed the whole decode: %v", err)
+ }
+ if l.Owner == nil || *l.Owner != "me" {
+ t.Errorf("Owner = %v, want me", l.Owner)
+ }
+ if l.AdditionalProperties["env"] != "prod" {
+ t.Errorf("AdditionalProperties[env] = %q, want prod", l.AdditionalProperties["env"])
+ }
+ if _, ok := l.AdditionalProperties["count"]; ok {
+ t.Error("a property that does not match the declared value type was kept anyway")
+ }
+}
+
+// The embedded schema is reached through an alias, so the catch-all still has to
+// recognize the properties that alias promotes as already declared.
+func TestPropertiesInheritedThroughAnAliasAreNotRecollected(t *testing.T) {
+ var c ComposedThroughAlias
+ if err := json.Unmarshal([]byte(` + "`" + `{"id":"x","note":"n","extra":"kept"}` + "`" + `), &c); err != nil {
+ t.Fatalf("unmarshal: %v", err)
+ }
+ if _, ok := c.AdditionalProperties["id"]; ok {
+ t.Error("a property promoted through an aliased embed landed in the catch-all")
+ }
+
+ out, err := json.Marshal(c)
+ if err != nil {
+ t.Fatalf("marshal: %v", err)
+ }
+ if n := strings.Count(string(out), ` + "`" + `"id"` + "`" + `); n != 1 {
+ t.Errorf("id emitted %d times: %s", n, out)
+ }
+}
`
// TestE2E_AdditionalPropertiesRoundTrip generates a client for schemas that mix
diff --git a/internal/generator/e2e_inline_union_test.go b/internal/generator/e2e_inline_union_test.go
index 1f646b6..f86c05f 100644
--- a/internal/generator/e2e_inline_union_test.go
+++ b/internal/generator/e2e_inline_union_test.go
@@ -145,6 +145,45 @@ func TestMissingDiscriminatorIsAnError(t *testing.T) {
}
}
+// A union must stay comparable: it is an ordinary field of the structs that hold
+// it, so storing the preserved raw payload in a slice would make every one of
+// those structs uncomparable too -- a compile error for consumers.
+func TestUnionIsComparable(t *testing.T) {
+ var a, b ShapeCollectionShapesValue
+ if a != b {
+ t.Error("zero unions should be equal")
+ }
+ if !map[ShapeCollectionShapesValue]bool{a: true}[b] {
+ t.Error("a union should be usable as a map key")
+ }
+}
+
+func TestUnknownVariantRawIsACopy(t *testing.T) {
+ payload := []byte("{\"shapeType\":\"hexagon\",\"sides\":6}")
+ var v ShapeCollectionShapesValue
+ if err := json.Unmarshal(payload, &v); err != nil {
+ t.Fatalf("unmarshal: %v", err)
+ }
+ raw := v.Raw()
+ if len(raw) == 0 {
+ t.Fatal("Raw() lost the unrecognized payload")
+ }
+ raw[0] = 'X'
+ if again := v.Raw(); again[0] == 'X' {
+ t.Error("Raw() aliases the union's own buffer")
+ }
+}
+
+func TestKnownVariantHasNoRaw(t *testing.T) {
+ var v ShapeCollectionShapesValue
+ if err := json.Unmarshal([]byte("{\"shapeType\":\"circle\",\"radius\":1}"), &v); err != nil {
+ t.Fatalf("unmarshal: %v", err)
+ }
+ if v.Raw() != nil {
+ t.Errorf("Raw() = %s, want nil for a recognized variant", v.Raw())
+ }
+}
+
func TestNullUnionDecodesToTheZeroValue(t *testing.T) {
var v ShapeCollectionShapesValue
if err := json.Unmarshal([]byte("null"), &v); err != nil {
@@ -167,3 +206,51 @@ func TestNullUnionDecodesToTheZeroValue(t *testing.T) {
}
t.Logf("runtime dispatch test passed:\n%s", string(output))
}
+
+const untypedVariantSpec = `openapi: 3.1.0
+info: { title: t, version: "1" }
+paths: {}
+components:
+ schemas:
+ Mixed:
+ oneOf:
+ - { type: string }
+ - type: object
+ properties:
+ q: { type: string }
+`
+
+// TestE2E_UntypedUnionVariantStillDecodes covers a union member the analyzer
+// cannot name: it still covers payloads the spec calls valid, and rejecting them
+// would fail the whole response they arrive in.
+func TestE2E_UntypedUnionVariantStillDecodes(t *testing.T) {
+ files, _ := generateFromSpec(t, untypedVariantSpec, "mixedapi")
+ runGeneratedWireTest(t, files, "untypedvariant", `package mixedapi
+
+import (
+ "encoding/json"
+ "testing"
+)
+
+func TestUntypedVariantDecodes(t *testing.T) {
+ var m Mixed
+ if err := json.Unmarshal([]byte(`+"`"+`{"q":"x"}`+"`"+`), &m); err != nil {
+ t.Fatalf("a payload matching the inline object variant failed to decode: %v", err)
+ }
+ obj, ok := m.Value.(map[string]any)
+ if !ok || obj["q"] != "x" {
+ t.Errorf("Value = %#v, want the decoded object", m.Value)
+ }
+}
+
+func TestTypedVariantStillWins(t *testing.T) {
+ var m Mixed
+ if err := json.Unmarshal([]byte(`+"`"+`"plain"`+"`"+`), &m); err != nil {
+ t.Fatalf("unmarshal: %v", err)
+ }
+ if m.Value != "plain" {
+ t.Errorf("Value = %#v, want the string variant", m.Value)
+ }
+}
+`)
+}
diff --git a/internal/generator/e2e_name_collision_test.go b/internal/generator/e2e_name_collision_test.go
new file mode 100644
index 0000000..5854460
--- /dev/null
+++ b/internal/generator/e2e_name_collision_test.go
@@ -0,0 +1,134 @@
+package generator
+
+import (
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/parallelworks/openapi-client-generator/internal/analyzer"
+ "github.com/parallelworks/openapi-client-generator/internal/parser"
+ "github.com/parallelworks/openapi-client-generator/internal/templates"
+)
+
+// generateAndBuild generates a client for spec, builds it, and returns the
+// compiler output ("" when it built) alongside the generated files by name.
+func generateAndBuild(t *testing.T, spec string) (buildOutput string, generated map[string]string) {
+ t.Helper()
+
+ specPath := filepath.Join(t.TempDir(), "spec.yaml")
+ if err := os.WriteFile(specPath, []byte(spec), 0o644); err != nil {
+ t.Fatalf("writing spec: %v", err)
+ }
+ result, err := parser.Parse(specPath, parser.Config{})
+ if err != nil {
+ t.Fatalf("Parse: %v", err)
+ }
+ pkg, err := analyzer.New(result.Model).Analyze("probe")
+ if err != nil {
+ t.Fatalf("Analyze: %v", err)
+ }
+ gen, err := New(pkg)
+ if err != nil {
+ t.Fatalf("New generator: %v", err)
+ }
+ files, err := gen.Generate()
+ if err != nil {
+ t.Fatalf("Generate: %v", err)
+ }
+
+ dir := t.TempDir()
+ if err := os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module probe\n\ngo 1.25.5\n"), 0o644); err != nil {
+ t.Fatalf("writing go.mod: %v", err)
+ }
+ if err := WriteFiles(dir, files); err != nil {
+ t.Fatalf("WriteFiles: %v", err)
+ }
+
+ cmd := exec.Command("go", "build", "./...")
+ cmd.Dir = dir
+ out, err := cmd.CombinedOutput()
+ if err == nil {
+ out = nil
+ }
+ generated = make(map[string]string, len(files))
+ for _, f := range files {
+ generated[f.Name] = string(f.Content)
+ }
+ return string(out), generated
+}
+
+// TestE2E_SchemaNamedLikeGeneratedType checks that a schema whose name matches
+// one of the identifiers the templates always declare is renamed rather than
+// redeclared. Go has one package scope, so the collision would not compile.
+func TestE2E_SchemaNamedLikeGeneratedType(t *testing.T) {
+ for _, name := range templates.ReservedIdentifiers {
+ t.Run(name, func(t *testing.T) {
+ build, files := generateAndBuild(t, `openapi: 3.1.0
+info: { title: t, version: "1" }
+paths:
+ /u:
+ post:
+ operationId: upload
+ requestBody:
+ required: true
+ content:
+ multipart/form-data:
+ schema: { $ref: "#/components/schemas/Upload" }
+ responses: { "204": { description: ok } }
+components:
+ schemas:
+ Upload:
+ type: object
+ required: [file]
+ properties:
+ file: { type: string, format: binary }
+ `+name+`:
+ type: object
+ properties:
+ x: { type: string }
+`)
+ if build != "" {
+ t.Errorf("a schema named %q does not compile:\n%s", name, build)
+ }
+ if types := files["types.go"]; !strings.Contains(types, "type "+name+"2 struct") {
+ t.Errorf("schema %q was not renamed out of the way:\n%s", name, types)
+ }
+ })
+ }
+}
+
+// TestE2E_ForwardReferenceUsesTheRenamedType checks that a reference to a schema
+// converted later still resolves to the name that schema ends up with. Two
+// schemas that differ only in punctuation share one exported spelling, so the
+// second is renamed — and a forward reference used to silently point at the first.
+func TestE2E_ForwardReferenceUsesTheRenamedType(t *testing.T) {
+ build, files := generateAndBuild(t, `openapi: 3.1.0
+info: { title: t, version: "1" }
+paths: {}
+components:
+ schemas:
+ Holder:
+ type: object
+ properties:
+ a: { $ref: "#/components/schemas/foo-bar" }
+ b: { $ref: "#/components/schemas/foo_bar" }
+ foo-bar:
+ type: object
+ properties: { p: { type: string } }
+ foo_bar:
+ type: object
+ properties: { q: { type: integer } }
+`)
+ if build != "" {
+ t.Fatalf("generated client does not compile:\n%s", build)
+ }
+ types := files["types.go"]
+ if !strings.Contains(types, "A *FooBar `") {
+ t.Errorf("Holder.a does not reference FooBar:\n%s", types)
+ }
+ if !strings.Contains(types, "B *FooBar2 `") {
+ t.Errorf("Holder.b does not reference the renamed FooBar2:\n%s", types)
+ }
+}
diff --git a/internal/generator/e2e_recursive_test.go b/internal/generator/e2e_recursive_test.go
new file mode 100644
index 0000000..bbb8362
--- /dev/null
+++ b/internal/generator/e2e_recursive_test.go
@@ -0,0 +1,143 @@
+package generator
+
+import (
+ "strings"
+ "testing"
+)
+
+// TestE2E_RecursiveSchemasCompile covers the shapes a spec can use to define a
+// type in terms of itself. Go allows that only through an indirection, so each
+// of these used to generate an "invalid recursive type".
+func TestE2E_RecursiveSchemasCompile(t *testing.T) {
+ tests := []struct {
+ name string
+ spec string
+ expect string
+ }{
+ {
+ // A tree node: the most common recursive shape there is.
+ name: "required self reference",
+ spec: `
+ Node:
+ type: object
+ required: [child, label]
+ properties:
+ label: { type: string }
+ child: { $ref: "#/components/schemas/Node" }`,
+ expect: "Child *Node `",
+ },
+ {
+ name: "mutual reference",
+ spec: `
+ Parent:
+ type: object
+ required: [kid]
+ properties:
+ kid: { $ref: "#/components/schemas/Kid" }
+ Kid:
+ type: object
+ required: [parent]
+ properties:
+ parent: { $ref: "#/components/schemas/Parent" }`,
+ expect: "Parent *Parent `",
+ },
+ {
+ // An alias between the two ends still closes the loop.
+ name: "self reference through an alias",
+ spec: `
+ Wrapper:
+ type: object
+ required: [inner]
+ properties:
+ inner: { $ref: "#/components/schemas/AliasToWrapper" }
+ AliasToWrapper:
+ anyOf: [{ $ref: "#/components/schemas/Wrapper" }, { type: "null" }]`,
+ expect: "Inner *AliasToWrapper `",
+ },
+ {
+ // A slice already breaks the recursion, so nothing should change.
+ name: "self reference through a slice stays a value",
+ spec: `
+ Branch:
+ type: object
+ required: [children]
+ properties:
+ children:
+ type: array
+ items: { $ref: "#/components/schemas/Branch" }`,
+ expect: "Children []Branch `",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ build, files := generateAndBuild(t, `openapi: 3.1.0
+info: { title: t, version: "1" }
+paths: {}
+components:
+ schemas:`+tt.spec+"\n")
+ if build != "" {
+ t.Fatalf("generated client does not compile:\n%s", build)
+ }
+ if types := files["types.go"]; !strings.Contains(types, tt.expect) {
+ t.Errorf("types.go missing %q:\n%s", tt.expect, types)
+ }
+ })
+ }
+}
+
+// TestE2E_RequestBodyWithoutSchemaCompiles covers a body the spec declares
+// without saying what goes in it. The operation still needs a parameter type, or
+// the method signature is a syntax error.
+func TestE2E_RequestBodyWithoutSchemaCompiles(t *testing.T) {
+ tests := []struct {
+ name string
+ body string
+ expect string
+ }{
+ {
+ name: "no schema under a raw media type",
+ body: " content:\n application/xml: {}",
+ expect: "func (c *Client) Send(ctx context.Context, body []byte) error",
+ },
+ {
+ name: "no schema under a text media type",
+ body: " content:\n text/plain: {}",
+ expect: "func (c *Client) Send(ctx context.Context, body string) error",
+ },
+ {
+ name: "no schema under json",
+ body: " content:\n application/json: {}",
+ expect: "func (c *Client) Send(ctx context.Context, body any) error",
+ },
+ {
+ // Nothing to send at all, so the method takes no body.
+ name: "no content at all",
+ body: " description: nothing",
+ expect: "func (c *Client) Send(ctx context.Context) error",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ build, files := generateAndBuild(t, `openapi: 3.1.0
+info: { title: t, version: "1" }
+paths:
+ /s:
+ post:
+ operationId: send
+ requestBody:
+ required: true
+`+tt.body+`
+ responses: { "204": { description: ok } }
+`)
+ if build != "" {
+ t.Fatalf("generated client does not compile:\n%s", build)
+ }
+ ops := files["operations.go"]
+ if !strings.Contains(ops, tt.expect) {
+ t.Errorf("operations.go missing %q:\n%s", tt.expect, ops)
+ }
+ })
+ }
+}
diff --git a/internal/generator/e2e_request_body_test.go b/internal/generator/e2e_request_body_test.go
new file mode 100644
index 0000000..fc0afb9
--- /dev/null
+++ b/internal/generator/e2e_request_body_test.go
@@ -0,0 +1,483 @@
+package generator
+
+import (
+ "os"
+ "os/exec"
+ "path/filepath"
+ "testing"
+
+ "github.com/parallelworks/openapi-client-generator/internal/analyzer"
+ "github.com/parallelworks/openapi-client-generator/internal/parser"
+)
+
+const requestBodySpec = `openapi: 3.1.0
+info: { title: t, version: "1" }
+paths:
+ /recipes/{slug}/image:
+ put:
+ operationId: updateRecipeImage
+ parameters: [{ name: slug, in: path, required: true, schema: { type: string } }]
+ requestBody:
+ required: true
+ content:
+ multipart/form-data:
+ schema: { $ref: "#/components/schemas/ImageUpload" }
+ responses:
+ "200":
+ description: ok
+ content:
+ application/json:
+ schema: { $ref: "#/components/schemas/UploadResult" }
+ /token:
+ post:
+ operationId: createToken
+ requestBody:
+ required: true
+ content:
+ application/x-www-form-urlencoded:
+ schema: { $ref: "#/components/schemas/TokenRequest" }
+ responses:
+ "200":
+ description: ok
+ content:
+ application/json:
+ schema: { $ref: "#/components/schemas/UploadResult" }
+ /recipes:
+ post:
+ operationId: createRecipe
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema: { $ref: "#/components/schemas/TokenRequest" }
+ responses:
+ "200":
+ description: ok
+ content:
+ application/json:
+ schema: { $ref: "#/components/schemas/UploadResult" }
+ /notes:
+ post:
+ operationId: createNote
+ requestBody:
+ required: true
+ content:
+ application/xml:
+ schema: { $ref: "#/components/schemas/ImageMeta" }
+ responses:
+ "200":
+ description: ok
+ content:
+ application/json:
+ schema: { $ref: "#/components/schemas/UploadResult" }
+ /labels:
+ post:
+ operationId: createLabel
+ requestBody:
+ required: true
+ content:
+ multipart/form-data:
+ schema: { $ref: "#/components/schemas/Label" }
+ responses:
+ "200":
+ description: ok
+ content:
+ application/json:
+ schema: { $ref: "#/components/schemas/UploadResult" }
+components:
+ schemas:
+ ImageUpload:
+ type: object
+ required: [image, extension]
+ properties:
+ image: { type: string, format: binary }
+ extension: { type: string }
+ attempt: { type: integer, format: int32 }
+ tags: { type: array, items: { type: string } }
+ meta: { $ref: "#/components/schemas/ImageMeta" }
+ attachments: { type: array, items: { type: string, format: binary } }
+ signature: { type: string, format: byte }
+ ImageMeta:
+ type: object
+ required: [source]
+ properties:
+ source: { type: string }
+ TokenRequest:
+ type: object
+ required: [username, password]
+ properties:
+ username: { type: string }
+ password: { type: string }
+ scopes: { type: array, items: { type: string } }
+ client: { $ref: "#/components/schemas/ImageMeta" }
+ Label:
+ type: object
+ required: [name]
+ properties:
+ name: { type: string }
+ additionalProperties: { type: string }
+ UploadResult:
+ type: object
+ properties:
+ ok: { type: boolean }
+`
+
+// TestE2E_RequestBodyContentTypes covers issue #17: an operation whose spec
+// declares multipart/form-data used to marshal its body as JSON and send it with
+// a Content-Type of application/json. The generated client is compiled and RUN
+// against a real server that parses the request, so a template that encodes the
+// wrong thing fails here rather than at a user's API.
+func TestE2E_RequestBodyContentTypes(t *testing.T) {
+ specDir := t.TempDir()
+ specPath := filepath.Join(specDir, "spec.yaml")
+ if err := os.WriteFile(specPath, []byte(requestBodySpec), 0o644); err != nil {
+ t.Fatalf("writing spec: %v", err)
+ }
+
+ result, err := parser.Parse(specPath, parser.Config{})
+ if err != nil {
+ t.Fatalf("Parse: %v", err)
+ }
+
+ pkg, err := analyzer.New(result.Model).Analyze("uploads")
+ if err != nil {
+ t.Fatalf("Analyze: %v", err)
+ }
+
+ gen, err := New(pkg)
+ if err != nil {
+ t.Fatalf("New generator: %v", err)
+ }
+ files, err := gen.Generate()
+ if err != nil {
+ t.Fatalf("Generate: %v", err)
+ }
+
+ tmpDir := t.TempDir()
+ goMod := []byte("module requestbody-e2e-test\n\ngo 1.25.5\n")
+ if err := os.WriteFile(filepath.Join(tmpDir, "go.mod"), goMod, 0o644); err != nil {
+ t.Fatalf("writing go.mod: %v", err)
+ }
+ if err := WriteFiles(tmpDir, files); err != nil {
+ t.Fatalf("WriteFiles: %v", err)
+ }
+
+ runtimeTest := []byte(`package uploads
+
+import (
+ "encoding/json"
+ "io"
+ "mime"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strings"
+ "testing"
+)
+
+type capture struct {
+ contentType string
+ body []byte
+}
+
+// serve records the one request the client makes and answers it with an empty
+// JSON object.
+func serve(t *testing.T, got *capture) *httptest.Server {
+ t.Helper()
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ body, err := io.ReadAll(r.Body)
+ if err != nil {
+ t.Errorf("reading request body: %v", err)
+ }
+ got.contentType = r.Header.Get("Content-Type")
+ got.body = body
+ w.Header().Set("Content-Type", "application/json")
+ w.Write([]byte(` + "`" + `{"ok":true}` + "`" + `))
+ }))
+ t.Cleanup(srv.Close)
+ return srv
+}
+
+func TestMultipartBodyIsSentAsMultipart(t *testing.T) {
+ var got capture
+ srv := serve(t, &got)
+
+ client := NewClient(srv.URL)
+ res, err := client.UpdateRecipeImage(t.Context(), "carrot-cake", ImageUpload{
+ Image: FormFile{
+ Filename: "carrot cake.png",
+ ContentType: "image/png",
+ Content: []byte("\x89PNG\r\n binary \x00 payload"),
+ },
+ Extension: "png",
+ Attempt: ptr(int32(2)),
+ Tags: []string{"dinner", "quick"},
+ Meta: &ImageMeta{Source: "phone"},
+ Attachments: []FormFile{{Filename: "notes.txt", Content: []byte("first")}, {Content: []byte("second")}},
+ Signature: []byte("sig"),
+ })
+ if err != nil {
+ t.Fatalf("UpdateRecipeImage: %v", err)
+ }
+ if res == nil || res.Ok == nil || !*res.Ok {
+ t.Errorf("response = %+v, want ok=true", res)
+ }
+
+ mediaType, params, err := mime.ParseMediaType(got.contentType)
+ if err != nil {
+ t.Fatalf("parsing Content-Type %q: %v", got.contentType, err)
+ }
+ if mediaType != "multipart/form-data" {
+ t.Fatalf("Content-Type = %q, want multipart/form-data", mediaType)
+ }
+ if params["boundary"] == "" {
+ t.Fatal("Content-Type carries no boundary")
+ }
+
+ // Parse the body the way a real server would.
+ req := httptest.NewRequest("PUT", "/", strings.NewReader(string(got.body)))
+ req.Header.Set("Content-Type", got.contentType)
+ if err := req.ParseMultipartForm(1 << 20); err != nil {
+ t.Fatalf("ParseMultipartForm: %v", err)
+ }
+
+ fileHeaders := req.MultipartForm.File["image"]
+ if len(fileHeaders) != 1 {
+ t.Fatalf("image file parts = %d, want 1", len(fileHeaders))
+ }
+ if name := fileHeaders[0].Filename; name != "carrot cake.png" {
+ t.Errorf("image filename = %q, want carrot cake.png", name)
+ }
+ if ct := fileHeaders[0].Header.Get("Content-Type"); ct != "image/png" {
+ t.Errorf("image part Content-Type = %q, want image/png", ct)
+ }
+ f, err := fileHeaders[0].Open()
+ if err != nil {
+ t.Fatalf("opening image part: %v", err)
+ }
+ defer f.Close()
+ content, err := io.ReadAll(f)
+ if err != nil {
+ t.Fatalf("reading image part: %v", err)
+ }
+ if string(content) != "\x89PNG\r\n binary \x00 payload" {
+ t.Errorf("image part = %q, want the raw bytes verbatim", content)
+ }
+
+ values := req.MultipartForm.Value
+ if got := values["extension"]; len(got) != 1 || got[0] != "png" {
+ t.Errorf("extension part = %v, want [png]", got)
+ }
+ if got := values["attempt"]; len(got) != 1 || got[0] != "2" {
+ t.Errorf("attempt part = %v, want [2]", got)
+ }
+ if got := values["tags"]; len(got) != 2 || got[0] != "dinner" || got[1] != "quick" {
+ t.Errorf("tags parts = %v, want one part per element", got)
+ }
+ if got := values["meta"]; len(got) != 1 || got[0] != ` + "`" + `{"source":"phone"}` + "`" + ` {
+ t.Errorf("meta part = %v, want the object as JSON", got)
+ }
+
+ // format: byte is base64 text, not an upload, so it is a value part.
+ if got := values["signature"]; len(got) != 1 || got[0] != "c2ln" {
+ t.Errorf("signature part = %v, want the base64 text [c2ln]", got)
+ }
+ if _, ok := req.MultipartForm.File["signature"]; ok {
+ t.Error("format: byte was sent as a file part")
+ }
+
+ // An array of files becomes one file part per element, and a file with no
+ // name of its own falls back to the property name.
+ attachments := req.MultipartForm.File["attachments"]
+ if len(attachments) != 2 {
+ t.Fatalf("attachment parts = %d, want 2", len(attachments))
+ }
+ if attachments[0].Filename != "notes.txt" {
+ t.Errorf("attachment[0] filename = %q, want notes.txt", attachments[0].Filename)
+ }
+ if attachments[1].Filename != "attachments" {
+ t.Errorf("attachment[1] filename = %q, want the property name", attachments[1].Filename)
+ }
+ if ct := attachments[1].Header.Get("Content-Type"); ct != "application/octet-stream" {
+ t.Errorf("attachment[1] Content-Type = %q, want application/octet-stream", ct)
+ }
+}
+
+func TestOptionalMultipartFieldsAreOmitted(t *testing.T) {
+ var got capture
+ srv := serve(t, &got)
+
+ client := NewClient(srv.URL)
+ if _, err := client.UpdateRecipeImage(t.Context(), "carrot-cake", ImageUpload{
+ Image: FormFile{Content: []byte("data")},
+ Extension: "png",
+ }); err != nil {
+ t.Fatalf("UpdateRecipeImage: %v", err)
+ }
+
+ req := httptest.NewRequest("PUT", "/", strings.NewReader(string(got.body)))
+ req.Header.Set("Content-Type", got.contentType)
+ if err := req.ParseMultipartForm(1 << 20); err != nil {
+ t.Fatalf("ParseMultipartForm: %v", err)
+ }
+ for _, unset := range []string{"attempt", "meta"} {
+ if _, ok := req.MultipartForm.Value[unset]; ok {
+ t.Errorf("unset optional field %q was sent", unset)
+ }
+ }
+ if _, ok := req.MultipartForm.File["attachments"]; ok {
+ t.Error("unset optional file field was sent")
+ }
+ // A file with no name of its own is still a file part, not a text part.
+ if files := req.MultipartForm.File["image"]; len(files) != 1 || files[0].Filename != "image" {
+ t.Errorf("image parts = %v, want one named after the property", files)
+ }
+}
+
+func TestFormURLEncodedBodyIsSentAsFormValues(t *testing.T) {
+ var got capture
+ srv := serve(t, &got)
+
+ client := NewClient(srv.URL)
+ if _, err := client.CreateToken(t.Context(), TokenRequest{
+ Username: "ada",
+ Password: "a b&c=d",
+ Scopes: []string{"read", "write"},
+ Client: &ImageMeta{Source: "cli"},
+ }); err != nil {
+ t.Fatalf("CreateToken: %v", err)
+ }
+
+ if mediaType, _, err := mime.ParseMediaType(got.contentType); err != nil || mediaType != "application/x-www-form-urlencoded" {
+ t.Fatalf("Content-Type = %q, want application/x-www-form-urlencoded", got.contentType)
+ }
+ values, err := url.ParseQuery(string(got.body))
+ if err != nil {
+ t.Fatalf("parsing form body %q: %v", got.body, err)
+ }
+ if values.Get("username") != "ada" {
+ t.Errorf("username = %q, want ada", values.Get("username"))
+ }
+ if values.Get("password") != "a b&c=d" {
+ t.Errorf("password = %q, want the value escaped, not split", values.Get("password"))
+ }
+ // An array is one pair per element (form/explode, the OpenAPI default), not a
+ // single comma-joined value.
+ if got := values["scopes"]; len(got) != 2 || got[0] != "read" || got[1] != "write" {
+ t.Errorf("scopes = %v, want one pair per element", got)
+ }
+ if got := values.Get("client"); got != ` + "`" + `{"source":"cli"}` + "`" + ` {
+ t.Errorf("client = %q, want the object as JSON", got)
+ }
+ // A space is '+' in x-www-form-urlencoded, not the query string's %20.
+ if !strings.Contains(string(got.body), "password=a+b") {
+ t.Errorf("body = %q, want a space encoded as '+'", got.body)
+ }
+}
+
+// TestSharedBodySchemaSentBothWays covers a schema a spec offers as both JSON and
+// multipart: the generated code has to compile and each operation has to send the
+// media type it declared.
+func TestSharedBodySchemaSentBothWays(t *testing.T) {
+ var got capture
+ srv := serve(t, &got)
+
+ client := NewClient(srv.URL)
+ if _, err := client.CreateRecipe(t.Context(), TokenRequest{Username: "ada", Password: "p"}); err != nil {
+ t.Fatalf("CreateRecipe: %v", err)
+ }
+ if mediaType, _, _ := mime.ParseMediaType(got.contentType); mediaType != "application/json" {
+ t.Errorf("Content-Type = %q, want application/json", got.contentType)
+ }
+}
+
+func TestJSONBodyStillSentAsJSON(t *testing.T) {
+ var got capture
+ srv := serve(t, &got)
+
+ client := NewClient(srv.URL)
+ if _, err := client.CreateRecipe(t.Context(), TokenRequest{
+ Username: "ada",
+ Password: "secret",
+ }); err != nil {
+ t.Fatalf("CreateRecipe: %v", err)
+ }
+
+ if mediaType, _, err := mime.ParseMediaType(got.contentType); err != nil || mediaType != "application/json" {
+ t.Fatalf("Content-Type = %q, want application/json", got.contentType)
+ }
+ var decoded map[string]any
+ if err := json.Unmarshal(got.body, &decoded); err != nil {
+ t.Fatalf("body %q is not JSON: %v", got.body, err)
+ }
+ if decoded["username"] != "ada" || decoded["password"] != "secret" {
+ t.Errorf("body = %v, want the JSON object", decoded)
+ }
+}
+
+// TestUnencodableMediaTypeTakesRawBytes covers a body whose media type the client
+// cannot build from the schema: it must take raw bytes and send them verbatim
+// rather than accept a struct and marshal it as JSON under an XML Content-Type.
+func TestUnencodableMediaTypeTakesRawBytes(t *testing.T) {
+ var got capture
+ srv := serve(t, &got)
+
+ client := NewClient(srv.URL)
+ if _, err := client.CreateNote(t.Context(), []byte("phone")); err != nil {
+ t.Fatalf("CreateNote: %v", err)
+ }
+ if mediaType, _, _ := mime.ParseMediaType(got.contentType); mediaType != "application/xml" {
+ t.Errorf("Content-Type = %q, want application/xml", got.contentType)
+ }
+ if string(got.body) != "phone" {
+ t.Errorf("body = %q, want the bytes verbatim", got.body)
+ }
+}
+
+// TestCatchAllPropertiesReachTheWire covers additionalProperties on a multipart
+// body: the catch-all map is tagged json:"-" so that encoding/json inlines it by
+// hand, and the form encoders have to inline it too rather than drop it.
+func TestCatchAllPropertiesReachTheWire(t *testing.T) {
+ var got capture
+ srv := serve(t, &got)
+
+ client := NewClient(srv.URL)
+ if _, err := client.CreateLabel(t.Context(), Label{
+ Name: "ada",
+ AdditionalProperties: map[string]string{"team": "core"},
+ }); err != nil {
+ t.Fatalf("CreateLabel: %v", err)
+ }
+
+ req := httptest.NewRequest("POST", "/", strings.NewReader(string(got.body)))
+ req.Header.Set("Content-Type", got.contentType)
+ if err := req.ParseMultipartForm(1 << 20); err != nil {
+ t.Fatalf("ParseMultipartForm: %v", err)
+ }
+ if v := req.MultipartForm.Value["team"]; len(v) != 1 || v[0] != "core" {
+ t.Errorf("team = %v, want [core]: an undeclared property was dropped", v)
+ }
+ if _, ok := req.MultipartForm.Value["-"]; ok {
+ t.Error("the catch-all map was sent under a literal \"-\" part name")
+ }
+}
+
+func ptr[T any](v T) *T { return &v }
+`)
+ if err := os.WriteFile(filepath.Join(tmpDir, "request_body_test.go"), runtimeTest, 0o644); err != nil {
+ t.Fatalf("writing runtime test: %v", err)
+ }
+
+ cmd := exec.Command("go", "test", "./...")
+ cmd.Dir = tmpDir
+ output, err := cmd.CombinedOutput()
+ if err != nil {
+ for _, f := range files {
+ if f.Name == "helpers.go" || f.Name == "operations.go" || f.Name == "types.go" {
+ t.Logf("=== %s ===\n%s", f.Name, string(f.Content))
+ }
+ }
+ t.Fatalf("go test on generated code failed: %v\n%s", err, string(output))
+ }
+ t.Logf("request body encoding test passed:\n%s", string(output))
+}
diff --git a/internal/generator/e2e_reserved_names_test.go b/internal/generator/e2e_reserved_names_test.go
new file mode 100644
index 0000000..7f1a52b
--- /dev/null
+++ b/internal/generator/e2e_reserved_names_test.go
@@ -0,0 +1,181 @@
+package generator
+
+import (
+ "go/ast"
+ "go/parser"
+ "go/token"
+ "slices"
+ "strings"
+ "testing"
+
+ "github.com/parallelworks/openapi-client-generator/internal/templates"
+)
+
+// reservedNamesSpec declares no operations and no error bodies, so every
+// exported package-level name in the output but types.go is one the templates
+// always declare, with nothing derived mixed in.
+const reservedNamesSpec = `openapi: 3.1.0
+info: { title: t, version: "1" }
+paths: {}
+components:
+ securitySchemes:
+ bearer: { type: http, scheme: bearer }
+ apiKey: { type: apiKey, name: X-Key, in: header }
+ basic: { type: http, scheme: basic }
+ schemas:
+ Thing:
+ type: object
+ properties:
+ name: { type: string }
+`
+
+// TestReservedIdentifiersCoversWhatTheTemplatesDeclare parses the generated
+// files and asserts every exported package-level name is reserved. Asserting
+// against templates.ReservedIdentifiers by iterating it can only confirm the
+// entries already there; this asks the output what it actually declares, so a
+// name added to a template without being reserved fails here.
+func TestReservedIdentifiersCoversWhatTheTemplatesDeclare(t *testing.T) {
+ _, files := generateAndBuild(t, reservedNamesSpec)
+
+ fset := token.NewFileSet()
+ for name, src := range files {
+ // types.go holds the schemas themselves, which are meant to be spec-named.
+ if name == "types.go" {
+ continue
+ }
+ file, err := parser.ParseFile(fset, name, src, 0)
+ if err != nil {
+ t.Fatalf("parsing generated %s: %v", name, err)
+ }
+ for _, declared := range exportedPackageNames(file) {
+ if !slices.Contains(templates.ReservedIdentifiers, declared) {
+ t.Errorf("%s declares %q at package scope but it is not in templates.ReservedIdentifiers, "+
+ "so a schema of that name would redeclare it", name, declared)
+ }
+ }
+ }
+}
+
+// exportedPackageNames returns the exported types, funcs, vars, and consts a
+// file declares at package scope. Methods take no package-scope name.
+func exportedPackageNames(file *ast.File) []string {
+ var names []string
+ add := func(name string) {
+ if ast.IsExported(name) {
+ names = append(names, name)
+ }
+ }
+ for _, decl := range file.Decls {
+ switch d := decl.(type) {
+ case *ast.FuncDecl:
+ if d.Recv == nil {
+ add(d.Name.Name)
+ }
+ case *ast.GenDecl:
+ for _, spec := range d.Specs {
+ switch s := spec.(type) {
+ case *ast.TypeSpec:
+ add(s.Name.Name)
+ case *ast.ValueSpec:
+ for _, ident := range s.Names {
+ add(ident.Name)
+ }
+ }
+ }
+ }
+ }
+ return names
+}
+
+// TestE2E_SchemaNamedLikeADerivedType covers the identifiers the templates build
+// at render time rather than always declaring: a params struct is named after
+// its operation and an error wrapper after the body it wraps, so neither can sit
+// in a static list.
+func TestE2E_SchemaNamedLikeADerivedType(t *testing.T) {
+ build, files := generateAndBuild(t, `openapi: 3.1.0
+info: { title: t, version: "1" }
+paths:
+ /u:
+ get:
+ operationId: listUsers
+ parameters: [{ name: q, in: query, schema: { type: string } }]
+ responses:
+ "200": { description: ok }
+ "404":
+ description: nf
+ content:
+ application/json:
+ schema: { $ref: "#/components/schemas/Error" }
+components:
+ schemas:
+ Error:
+ type: object
+ properties: { message: { type: string } }
+ ErrorResponse:
+ type: object
+ properties: { y: { type: string } }
+ ListUsersParams:
+ type: object
+ properties: { z: { type: string } }
+`)
+ if build != "" {
+ t.Fatalf("generated client does not compile:\n%s", build)
+ }
+ types := files["types.go"]
+ for _, want := range []string{"type ErrorResponse2 struct", "type ListUsersParams2 struct"} {
+ if !strings.Contains(types, want) {
+ t.Errorf("types.go missing %q — the schema was not renamed off the derived name:\n%s", want, types)
+ }
+ }
+}
+
+// TestE2E_TraceOperationIsGenerated covers the one HTTP method the path-item
+// walk used to skip.
+func TestE2E_TraceOperationIsGenerated(t *testing.T) {
+ build, files := generateAndBuild(t, `openapi: 3.1.0
+info: { title: t, version: "1" }
+paths:
+ /u:
+ trace:
+ operationId: traceUsers
+ responses: { "204": { description: ok } }
+`)
+ if build != "" {
+ t.Fatalf("generated client does not compile:\n%s", build)
+ }
+ if ops := files["operations.go"]; !strings.Contains(ops, "func (c *Client) TraceUsers(") {
+ t.Errorf("operations.go has no method for the trace operation:\n%s", ops)
+ }
+}
+
+// TestE2E_FormBodyIsAlwaysEncodable covers a form body whose schema is not an
+// object: the encoders walk properties, so a scalar would compile and then fail
+// on every call.
+func TestE2E_FormBodyIsAlwaysEncodable(t *testing.T) {
+ for _, tt := range []struct{ name, contentType string }{
+ {"urlencoded", "application/x-www-form-urlencoded"},
+ {"multipart", "multipart/form-data"},
+ } {
+ t.Run(tt.name, func(t *testing.T) {
+ build, files := generateAndBuild(t, `openapi: 3.1.0
+info: { title: t, version: "1" }
+paths:
+ /f:
+ post:
+ operationId: postForm
+ requestBody:
+ content:
+ `+tt.contentType+`:
+ schema: { type: string }
+ responses: { "204": { description: ok } }
+`)
+ if build != "" {
+ t.Fatalf("generated client does not compile:\n%s", build)
+ }
+ ops := files["operations.go"]
+ if !strings.Contains(ops, "body *map[string]any") {
+ t.Errorf("a %s body that is not an object should still take something the encoder accepts:\n%s", tt.contentType, ops)
+ }
+ })
+ }
+}
diff --git a/internal/generator/funcmap.go b/internal/generator/funcmap.go
index f0a1a98..58d81d8 100644
--- a/internal/generator/funcmap.go
+++ b/internal/generator/funcmap.go
@@ -19,6 +19,7 @@ func FuncMap() template.FuncMap {
"paramDocComment": paramDocComment,
"indent": indent,
"jsonTag": jsonTag,
+ "fieldTag": fieldTag,
"hasOperations": hasOperations,
"successType": successType,
"hasBody": hasBody,
@@ -27,6 +28,7 @@ func FuncMap() template.FuncMap {
"hasRequiredCookieParams": hasRequiredCookieParams,
"paramType": paramType,
"hasUnions": hasUnions,
+ "hasUntypedVariant": hasUntypedVariant,
"catchAllField": catchAllField,
"catchAllValueType": catchAllValueType,
"declaredJSONNames": declaredJSONNames,
@@ -40,6 +42,8 @@ func FuncMap() template.FuncMap {
"errorMessageField": errorMessageField,
"errorType": errorType,
"successContentType": successContentType,
+ "requestContentType": requestContentType,
+ "hasNonJSONBody": hasNonJSONBody,
}
}
@@ -177,6 +181,17 @@ func indent(s string) string {
return strings.Join(lines, "\n")
}
+// fieldTag returns a struct field's full tag. The catch-all carries a marker
+// because its json tag is "-": the body encoders have no other way to tell it
+// apart from a field the schema genuinely excludes.
+func fieldTag(f *ir.Field) string {
+ tag := `json:"` + jsonTag(f) + `"`
+ if f.CatchAll {
+ tag += ` openapi:"additionalProperties"`
+ }
+ return tag
+}
+
// jsonTag returns the JSON struct tag value for a field.
// It returns "fieldName,omitempty" for optional fields and "fieldName" for required ones.
func jsonTag(f *ir.Field) string {
@@ -208,14 +223,35 @@ func catchAllValueType(f *ir.Field) string {
return strings.TrimPrefix(f.Type, "map[string]")
}
-func declaredJSONNames(td *ir.TypeDef) []string {
+// declaredJSONNames returns the wire names a struct already consumes into
+// fields, an embedded type's included: those are promoted onto the struct, so a
+// catch-all that re-collected them would emit each one twice.
+func declaredJSONNames(pkg *ir.Package, td *ir.TypeDef) []string {
+ byName := ir.TypesByName(pkg.Types)
+
var names []string
- for _, f := range td.Fields {
- if f.CatchAll || f.Embedded || f.JSONName == "" || f.JSONName == "-" {
- continue
+ visited := make(map[string]bool)
+
+ var walk func(td *ir.TypeDef)
+ walk = func(td *ir.TypeDef) {
+ if td == nil || visited[td.Name] {
+ return
+ }
+ visited[td.Name] = true
+ for _, f := range td.Fields {
+ switch {
+ case f.CatchAll:
+ case f.Embedded:
+ // The type may be written as a pointer where an indirection broke a
+ // reference cycle, and may be an alias standing for the struct.
+ walk(ir.StructNamed(byName, strings.TrimPrefix(f.Type, "*")))
+ case f.JSONName == "" || f.JSONName == "-":
+ default:
+ names = append(names, f.JSONName)
+ }
}
- names = append(names, f.JSONName)
}
+ walk(td)
return names
}
@@ -278,6 +314,12 @@ func hasUnions(types []*ir.TypeDef) bool {
return false
}
+// hasUntypedVariant reports whether a union has a variant no Go type could be
+// derived for, whose payloads nothing but an any decode accepts.
+func hasUntypedVariant(td *ir.TypeDef) bool {
+ return slices.ContainsFunc(td.UnionTypes, func(v *ir.UnionVariant) bool { return v.TypeName == "any" })
+}
+
// discriminatorFieldName converts a JSON property name to a Go field name
// for use in the discriminator struct in UnmarshalJSON.
func discriminatorFieldName(propertyName string) string {
@@ -366,3 +408,22 @@ func successContentType(op *ir.OperationDef) string {
}
return op.SuccessResponse.ContentType
}
+
+// requestContentType returns the media type an operation sends its request body
+// as, or "" when it has no body.
+func requestContentType(op *ir.OperationDef) string {
+ if op.RequestBody == nil {
+ return ""
+ }
+ return op.RequestBody.ContentType
+}
+
+// hasNonJSONBody reports whether any operation sends a request body in a media
+// type other than JSON, which is what pulls the extra body encoders into the
+// generated helpers.
+func hasNonJSONBody(pkg *ir.Package) bool {
+ return slices.ContainsFunc(pkg.Operations, func(op *ir.OperationDef) bool {
+ ct := requestContentType(op)
+ return ct != "" && !strings.Contains(ct, "json")
+ })
+}
diff --git a/internal/generator/generator_test.go b/internal/generator/generator_test.go
index 25a0cf4..27c8cfe 100644
--- a/internal/generator/generator_test.go
+++ b/internal/generator/generator_test.go
@@ -296,11 +296,13 @@ func TestGenerate_UnionType(t *testing.T) {
if !strings.Contains(content, "func (u *PetOrError) UnmarshalJSON(data []byte)") {
t.Error("output missing UnmarshalJSON method")
}
- // Without discriminator, should try each variant.
- if !strings.Contains(content, "valPet") {
+ // Without discriminator, should try each variant. The locals are numbered
+ // rather than named after the type, so a variant whose Go type is not an
+ // identifier (say []string) still declares a legal one.
+ if !strings.Contains(content, "var variant0 Pet") {
t.Error("output missing try-each-variant logic for Pet")
}
- if !strings.Contains(content, "valError") {
+ if !strings.Contains(content, "var variant1 Error") {
t.Error("output missing try-each-variant logic for Error")
}
diff --git a/internal/ir/operations.go b/internal/ir/operations.go
index 198a7b8..23c86a1 100644
--- a/internal/ir/operations.go
+++ b/internal/ir/operations.go
@@ -41,7 +41,6 @@ type RequestBodyDef struct {
Description string
ContentType string // Primary content type (e.g., "application/json")
TypeName string // Go type for the body
- IsMultipart bool
}
// ResponseDef describes one response.
diff --git a/internal/ir/types.go b/internal/ir/types.go
index 58bbdbf..564b6c0 100644
--- a/internal/ir/types.go
+++ b/internal/ir/types.go
@@ -1,5 +1,48 @@
package ir
+import "strings"
+
+// TypesByName indexes type definitions by the Go name they declare.
+func TypesByName(types []*TypeDef) map[string]*TypeDef {
+ byName := make(map[string]*TypeDef, len(types))
+ for _, td := range types {
+ if td != nil {
+ byName[td.Name] = td
+ }
+ }
+ return byName
+}
+
+// NamedType returns goType when it is a bare type name rather than a builtin or a
+// composite with no single referent.
+func NamedType(goType string) string {
+ if goType == "" || goType == "any" || strings.ContainsAny(goType, ".[]*{} ") {
+ return ""
+ }
+ return goType
+}
+
+// StructNamed returns the struct goType ultimately denotes, following the aliases
+// that may stand between the two. It returns nil for anything that does not end at
+// a generated struct.
+func StructNamed(byName map[string]*TypeDef, goType string) *TypeDef {
+ for range len(byName) + 1 {
+ td := byName[NamedType(goType)]
+ if td == nil {
+ return nil
+ }
+ switch td.Kind {
+ case TypeKindStruct:
+ return td
+ case TypeKindAlias:
+ goType = td.GoType
+ default:
+ return nil
+ }
+ }
+ return nil
+}
+
// TypeKind represents the kind of Go type to generate.
type TypeKind int
diff --git a/internal/templates/client.go.tmpl b/internal/templates/client.go.tmpl
index b9f00d5..82c4009 100644
--- a/internal/templates/client.go.tmpl
+++ b/internal/templates/client.go.tmpl
@@ -36,16 +36,17 @@ func NewClient(baseURL string, opts ...ClientOption) *Client {
return c
}
-// do executes an HTTP request and decodes the response.
-func (c *Client) do(ctx context.Context, method string, path string, body any, result any, accept string, headers ...http.Header) error {
+// do executes an HTTP request and decodes the response. contentType selects the
+// request body encoding and is sent as the Content-Type header.
+func (c *Client) do(ctx context.Context, method string, path string, body any, contentType string, result any, accept string, headers ...http.Header) error {
fullURL := c.baseURL + path
- var jsonBody []byte
+ var payload []byte
if body != nil {
var err error
- jsonBody, err = json.Marshal(body)
+ payload, contentType, err = encodeRequestBody(body, contentType)
if err != nil {
- return fmt.Errorf("encoding request body: %w", err)
+ return err
}
}
@@ -75,8 +76,8 @@ func (c *Client) do(ctx context.Context, method string, path string, body any, r
}
var bodyReader io.Reader
- if jsonBody != nil {
- bodyReader = bytes.NewReader(jsonBody)
+ if payload != nil {
+ bodyReader = bytes.NewReader(payload)
}
req, err := http.NewRequestWithContext(ctx, method, fullURL, bodyReader)
@@ -84,10 +85,10 @@ func (c *Client) do(ctx context.Context, method string, path string, body any, r
return fmt.Errorf("creating request: %w", err)
}
- if jsonBody != nil {
- req.Header.Set("Content-Type", "application/json")
+ if payload != nil {
+ req.Header.Set("Content-Type", contentType)
// Set GetBody so request body can be re-read on retries.
- bodyBytes := jsonBody
+ bodyBytes := payload
req.GetBody = func() (io.ReadCloser, error) {
return io.NopCloser(bytes.NewReader(bodyBytes)), nil
}
diff --git a/internal/templates/helpers.go.tmpl b/internal/templates/helpers.go.tmpl
index 4ded978..cefe2a4 100644
--- a/internal/templates/helpers.go.tmpl
+++ b/internal/templates/helpers.go.tmpl
@@ -4,6 +4,7 @@ package {{ .Name }}
import (
"encoding/base64"
+ "encoding/json"
"fmt"
"net/http"
"net/url"
@@ -12,6 +13,13 @@ import (
"strconv"
"strings"
"time"
+{{- if hasNonJSONBody . }}
+ "bytes"
+ "encoding"
+ "io"
+ "mime/multipart"
+ "net/textproto"
+{{- end }}
)
// pathReplace substitutes a {param} placeholder in a URL path, encoding the value
@@ -44,7 +52,13 @@ func derefParam(value any) (reflect.Value, bool) {
// isSlice reports whether rv is a multi-value slice; a []byte is a scalar
// (formatScalar renders it as base64), not a list of bytes.
func isSlice(rv reflect.Value) bool {
- return rv.Kind() == reflect.Slice && rv.Type().Elem().Kind() != reflect.Uint8
+ return rv.Kind() == reflect.Slice && !isByteSlice(rv)
+}
+
+// isByteSlice reports whether rv is a []byte, the Go type of an OpenAPI
+// format: byte or format: binary value.
+func isByteSlice(rv reflect.Value) bool {
+ return rv.Kind() == reflect.Slice && rv.Type().Elem().Kind() == reflect.Uint8
}
// isObject reports whether rv is a struct or map to serialize property-by-property;
@@ -71,7 +85,7 @@ func formatScalar(rv reflect.Value) string {
}
switch rv.Kind() {
case reflect.Slice:
- if rv.Type().Elem().Kind() == reflect.Uint8 {
+ if isByteSlice(rv) {
return base64.StdEncoding.EncodeToString(rv.Bytes())
}
case reflect.Float32:
@@ -115,11 +129,18 @@ func sliceValues(rv reflect.Value) []string {
return out
}
-// objectPairs returns an object's (property, value) pairs in a stable order:
-// struct fields in declaration order keyed by their json tag (an embedded field's
-// properties are flattened in), map keys sorted. A nil pointer property is skipped.
-func objectPairs(rv reflect.Value) [][2]string {
- var pairs [][2]string
+// namedValue is one property of an object: its wire name and its value, with any
+// pointer already unwrapped.
+type namedValue struct {
+ name string
+ value reflect.Value
+}
+
+// objectValues returns an object's properties in a stable order: struct fields in
+// declaration order keyed by their json tag (an embedded field's properties are
+// flattened in), map keys sorted. A nil pointer property is skipped.
+func objectValues(rv reflect.Value) []namedValue {
+ var values []namedValue
switch rv.Kind() {
case reflect.Struct:
t := rv.Type()
@@ -132,8 +153,10 @@ func objectPairs(rv reflect.Value) [][2]string {
if !ok {
continue
}
- if f.Anonymous && isObject(fv) {
- pairs = append(pairs, objectPairs(fv)...)
+ // An embedded struct and the additionalProperties catch-all both hold
+ // properties of this object, not properties of their own.
+ if (f.Anonymous || f.Tag.Get("openapi") == "additionalProperties") && isObject(fv) {
+ values = append(values, objectValues(fv)...)
continue
}
name, _, _ := strings.Cut(f.Tag.Get("json"), ",")
@@ -143,7 +166,7 @@ func objectPairs(rv reflect.Value) [][2]string {
if name == "-" {
continue
}
- pairs = append(pairs, [2]string{name, formatValue(fv)})
+ values = append(values, namedValue{name, fv})
}
case reflect.Map:
keys := make([]string, 0, rv.Len())
@@ -156,10 +179,23 @@ func objectPairs(rv reflect.Value) [][2]string {
sort.Strings(keys)
for _, k := range keys {
if fv, ok := derefParam(byKey[k].Interface()); ok {
- pairs = append(pairs, [2]string{k, formatValue(fv)})
+ values = append(values, namedValue{k, fv})
}
}
}
+ return values
+}
+
+// objectPairs renders an object's properties as flat (property, value) pairs.
+func objectPairs(rv reflect.Value) [][2]string {
+ values := objectValues(rv)
+ if len(values) == 0 {
+ return nil
+ }
+ pairs := make([][2]string, 0, len(values))
+ for _, v := range values {
+ pairs = append(pairs, [2]string{v.name, formatValue(v.value)})
+ }
return pairs
}
@@ -338,6 +374,241 @@ func setHeader(headers http.Header, name string, explode bool, value any) {
headers.Set(name, encodeSimple(rv, explode))
}
+// encodeRequestBody renders a request body under the media type its operation
+// declares, returning the payload and the Content-Type to send it with.
+func encodeRequestBody(body any, contentType string) ([]byte, string, error) {
+ if contentType == "" {
+ contentType = "application/json"
+ }
+{{- if hasNonJSONBody . }}
+ if !strings.Contains(contentType, "json") {
+ return encodeNonJSONBody(body, contentType)
+ }
+{{- end }}
+ data, err := json.Marshal(body)
+ if err != nil {
+ return nil, "", fmt.Errorf("encoding request body: %w", err)
+ }
+ return data, contentType, nil
+}
+{{- if hasNonJSONBody . }}
+
+// encodeNonJSONBody renders a body whose media type is not JSON.
+func encodeNonJSONBody(body any, contentType string) ([]byte, string, error) {
+ switch {
+ case strings.HasPrefix(contentType, "multipart/"):
+ return encodeMultipart(body, contentType)
+ case strings.HasPrefix(contentType, "application/x-www-form-urlencoded"):
+ return encodeFormValues(body, contentType)
+ }
+
+ rv, ok := derefParam(body)
+ if !ok {
+ return nil, contentType, nil
+ }
+ if m, ok := rv.Interface().(encoding.TextMarshaler); ok {
+ data, err := m.MarshalText()
+ if err != nil {
+ return nil, "", fmt.Errorf("encoding %s body: %w", contentType, err)
+ }
+ return data, contentType, nil
+ }
+ switch {
+ case isByteSlice(rv):
+ return rv.Bytes(), contentType, nil
+ case !isObject(rv) && !isSlice(rv):
+ return []byte(formatScalar(rv)), contentType, nil
+ }
+ // Marshaling the value as JSON here would send JSON under a Content-Type that
+ // promises something else; the schema alone does not say how to encode it.
+ return nil, "", fmt.Errorf("encoding %s body: no encoder for this media type, pass a []byte or string body", contentType)
+}
+
+// encodeFormValues renders a body as url-encoded form data.
+func encodeFormValues(body any, contentType string) ([]byte, string, error) {
+ rv, ok := derefParam(body)
+ if !ok || !isObject(rv) {
+ return nil, "", fmt.Errorf("encoding %s body: want an object, got %T", contentType, body)
+ }
+ values := url.Values{}
+ for _, v := range objectValues(rv) {
+ if err := addFormValue(values, v.name, v.value); err != nil {
+ return nil, "", fmt.Errorf("encoding form field %q: %w", v.name, err)
+ }
+ }
+ // Encode (unlike encodeQuery) spells a space '+', which is what
+ // x-www-form-urlencoded defines; the RFC 3986 rewrite applies to query strings.
+ return []byte(values.Encode()), contentType, nil
+}
+
+// addFormValue adds one property of a url-encoded body: an array one pair per
+// element, an object as JSON, everything else as a scalar.
+func addFormValue(values url.Values, name string, rv reflect.Value) error {
+ if rv.Kind() == reflect.Interface {
+ if rv.IsNil() {
+ return nil
+ }
+ rv = rv.Elem()
+ }
+ switch {
+ case isSlice(rv):
+ for i := 0; i < rv.Len(); i++ {
+ if err := addFormValue(values, name, rv.Index(i)); err != nil {
+ return err
+ }
+ }
+ case isObject(rv):
+ data, err := json.Marshal(rv.Interface())
+ if err != nil {
+ return err
+ }
+ values.Add(name, string(data))
+ default:
+ values.Add(name, formatScalar(rv))
+ }
+ return nil
+}
+
+// FormFile is one file in a multipart request body. Filename defaults to the
+// property name and ContentType to application/octet-stream when left empty.
+type FormFile struct {
+ Filename string
+ ContentType string
+ Content []byte
+}
+
+// MarshalJSON encodes a FormFile the way the []byte it stands in for would be,
+// so a schema shared between a multipart body and a JSON one still round-trips.
+func (f FormFile) MarshalJSON() ([]byte, error) {
+ return json.Marshal(f.Content)
+}
+
+// UnmarshalJSON decodes a base64 JSON string into the file's content.
+func (f *FormFile) UnmarshalJSON(data []byte) error {
+ return json.Unmarshal(data, &f.Content)
+}
+
+// encodeMultipart renders a body as multipart form data. A FormFile property
+// (OpenAPI format: binary) becomes a file part; every other property becomes a
+// text part, an array one part per element.
+func encodeMultipart(body any, contentType string) ([]byte, string, error) {
+ rv, ok := derefParam(body)
+ if !ok || !isObject(rv) {
+ return nil, "", fmt.Errorf("encoding %s body: want an object, got %T", contentType, body)
+ }
+
+ var buf bytes.Buffer
+ w := multipart.NewWriter(&buf)
+ for _, v := range objectValues(rv) {
+ if err := writeMultipartField(w, v.name, v.value); err != nil {
+ return nil, "", fmt.Errorf("encoding multipart field %q: %w", v.name, err)
+ }
+ }
+ if err := w.Close(); err != nil {
+ return nil, "", fmt.Errorf("encoding multipart body: %w", err)
+ }
+ return buf.Bytes(), contentType + "; boundary=" + w.Boundary(), nil
+}
+
+// writeMultipartField writes one property of a multipart body.
+func writeMultipartField(w *multipart.Writer, name string, rv reflect.Value) error {
+ if rv.Kind() == reflect.Interface {
+ if rv.IsNil() {
+ return nil
+ }
+ rv = rv.Elem()
+ }
+
+ // A FormFile is a struct, so it has to be recognized before isObject would
+ // send it through as a JSON part.
+ if file, ok := rv.Interface().(FormFile); ok {
+ return writeFilePart(w, name, file)
+ }
+
+ switch {
+ case isByteSlice(rv):
+ // Files arrive as a FormFile, so a byte slice here is OpenAPI's
+ // format: byte — base64 text, not an upload. A nil one is unset.
+ if rv.IsNil() {
+ return nil
+ }
+ return w.WriteField(name, formatScalar(rv))
+ case isSlice(rv):
+ for i := 0; i < rv.Len(); i++ {
+ if err := writeMultipartField(w, name, rv.Index(i)); err != nil {
+ return err
+ }
+ }
+ return nil
+ case isObject(rv):
+ // OpenAPI encodes an object-valued part as JSON unless the spec says otherwise.
+ data, err := json.Marshal(rv.Interface())
+ if err != nil {
+ return err
+ }
+ part, err := createPart(w, name, "", "application/json")
+ if err != nil {
+ return err
+ }
+ _, err = part.Write(data)
+ return err
+ default:
+ part, err := createPart(w, name, "", "")
+ if err != nil {
+ return err
+ }
+ _, err = io.WriteString(part, formatScalar(rv))
+ return err
+ }
+}
+
+// writeFilePart writes a file part; a server that keys on Content-Disposition's
+// filename will not treat a part without one as an upload at all.
+func writeFilePart(w *multipart.Writer, name string, file FormFile) error {
+ filename := file.Filename
+ if filename == "" {
+ filename = name
+ }
+ contentType := file.ContentType
+ if contentType == "" {
+ contentType = "application/octet-stream"
+ }
+ part, err := createPart(w, name, filename, contentType)
+ if err != nil {
+ return err
+ }
+ _, err = part.Write(file.Content)
+ return err
+}
+
+// createPart starts a multipart part with the headers that apply to it.
+func createPart(w *multipart.Writer, name, filename, contentType string) (io.Writer, error) {
+ disposition := `form-data; name="` + escapePartName(name) + `"`
+ if filename != "" {
+ disposition += `; filename="` + escapePartName(filename) + `"`
+ }
+ header := make(textproto.MIMEHeader, 2)
+ header.Set("Content-Disposition", disposition)
+ if contentType != "" {
+ header.Set("Content-Type", stripHeaderBreaks.Replace(contentType))
+ }
+ return w.CreatePart(header)
+}
+
+// stripHeaderBreaks drops the line breaks that would otherwise let a value chosen
+// at runtime — a map key, a caller's content type — inject headers of its own;
+// multipart.Writer writes part headers verbatim.
+var stripHeaderBreaks = strings.NewReplacer("\r", "", "\n", "")
+
+// partNameEscaper additionally quotes the characters that would end a
+// Content-Disposition parameter early.
+var partNameEscaper = strings.NewReplacer(`\`, `\\`, `"`, `\"`, "\r", "", "\n", "")
+
+func escapePartName(name string) string {
+ return partNameEscaper.Replace(name)
+}
+{{- end }}
+
// addCookieHeader appends a cookie to the Cookie header, skipping nil optional
// values. A Cookie header is a single name=value, so arrays and objects are
// flattened (form style, unexploded); http.Cookie sanitizes invalid octets.
diff --git a/internal/templates/operations.go.tmpl b/internal/templates/operations.go.tmpl
index 25798fa..58281b2 100644
--- a/internal/templates/operations.go.tmpl
+++ b/internal/templates/operations.go.tmpl
@@ -45,11 +45,11 @@ type {{ .Name }}Params struct {
{{ end }}{{ end }}
{{- $errType := errorType . -}}
{{ if successType . }} var result {{ successType . }}
- if err := c.do(ctx, "{{ .HTTPMethod }}", path, {{ if hasBody . }}body{{ else }}nil{{ end }}, &result, "{{ successContentType . }}"{{ if $needHeaders }}, headers{{ end }}); err != nil {
+ if err := c.do(ctx, "{{ .HTTPMethod }}", path, {{ if hasBody . }}body{{ else }}nil{{ end }}, {{ printf "%q" (requestContentType .) }}, &result, {{ printf "%q" (successContentType .) }}{{ if $needHeaders }}, headers{{ end }}); err != nil {
return nil, {{ if $errType }}parse{{ $errType }}Response(err){{ else }}err{{ end }}
}
return &result, nil
-{{ else }} if err := c.do(ctx, "{{ .HTTPMethod }}", path, {{ if hasBody . }}body{{ else }}nil{{ end }}, nil, "{{ successContentType . }}"{{ if $needHeaders }}, headers{{ end }}); err != nil {
+{{ else }} if err := c.do(ctx, "{{ .HTTPMethod }}", path, {{ if hasBody . }}body{{ else }}nil{{ end }}, {{ printf "%q" (requestContentType .) }}, nil, {{ printf "%q" (successContentType .) }}{{ if $needHeaders }}, headers{{ end }}); err != nil {
return {{ if $errType }}parse{{ $errType }}Response(err){{ else }}err{{ end }}
}
return nil
diff --git a/internal/templates/reserved.go b/internal/templates/reserved.go
new file mode 100644
index 0000000..db273b7
--- /dev/null
+++ b/internal/templates/reserved.go
@@ -0,0 +1,40 @@
+package templates
+
+// ReservedIdentifiers are the exported package-level names the templates always
+// declare. A schema whose name lands on one of these is renamed, because Go has
+// a single package scope and the collision would not compile. Unexported helpers
+// need no entry: a generated type name is always exported.
+//
+// Keep in sync with the templates.
+var ReservedIdentifiers = []string{
+ "APIError",
+ "APIKeyAuth",
+ "AuthProvider",
+ "BasicAuth",
+ "BearerAuth",
+ "Client",
+ "ClientOption",
+ "DefaultRetryConfig",
+ "ErrBadGateway",
+ "ErrBadRequest",
+ "ErrConflict",
+ "ErrForbidden",
+ "ErrGatewayTimeout",
+ "ErrInternalServerError",
+ "ErrNotFound",
+ "ErrServiceUnavailable",
+ "ErrTooManyRequests",
+ "ErrUnauthorized",
+ "FormFile",
+ "Middleware",
+ "NewClient",
+ "PageIterator",
+ "RetryConfig",
+ "RoundTripFunc",
+ "WithAuth",
+ "WithDefaultRetry",
+ "WithHTTPClient",
+ "WithMiddleware",
+ "WithRetry",
+ "WithUserAgent",
+}
diff --git a/internal/templates/types.go.tmpl b/internal/templates/types.go.tmpl
index dcefa51..a9f22fe 100644
--- a/internal/templates/types.go.tmpl
+++ b/internal/templates/types.go.tmpl
@@ -34,7 +34,7 @@ func deleteDeclaredProperties(obj map[string]json.RawMessage, declared []string)
{{ end }}type {{ .Name }} struct {
{{ range .Fields }}{{ $fDoc := fieldDocComment . }}{{ if $fDoc }}{{ indent $fDoc }}
{{ end }}{{ if .Embedded }} {{ .Type }}
-{{ else }} {{ .Name }} {{ .Type }} `json:"{{ jsonTag . }}"`
+{{ else }} {{ .Name }} {{ .Type }} `{{ fieldTag . }}`
{{ end }}{{ end }}}
{{ with catchAllField . }}
// MarshalJSON implements json.Marshaler for {{ $typeName }}, inlining
@@ -57,7 +57,7 @@ func (t {{ $typeName }}) MarshalJSON() ([]byte, error) {
}
extra[key] = raw
}
- deleteDeclaredProperties(extra, []string{ {{ range $i, $n := declaredJSONNames $td }}{{ if $i }}, {{ end }}{{ printf "%q" $n }}{{ end }} })
+ deleteDeclaredProperties(extra, []string{ {{ range $i, $n := declaredJSONNames $ $td }}{{ if $i }}, {{ end }}{{ printf "%q" $n }}{{ end }} })
if len(extra) == 0 {
return data, nil
}
@@ -89,18 +89,24 @@ func (t *{{ $typeName }}) UnmarshalJSON(data []byte) error {
if err := json.Unmarshal(data, &obj); err != nil {
return err
}
- deleteDeclaredProperties(obj, []string{ {{ range $i, $n := declaredJSONNames $td }}{{ if $i }}, {{ end }}{{ printf "%q" $n }}{{ end }} })
+ deleteDeclaredProperties(obj, []string{ {{ range $i, $n := declaredJSONNames $ $td }}{{ if $i }}, {{ end }}{{ printf "%q" $n }}{{ end }} })
if len(obj) == 0 {
return nil
}
t.{{ .Name }} = make({{ .Type }}, len(obj))
for key, raw := range obj {
var value {{ catchAllValueType . }}
+ // A property that is both undeclared and not of the type the schema gives
+ // additionalProperties is the least useful thing in the payload, so it is
+ // dropped rather than failing the decode of everything alongside it.
if err := json.Unmarshal(raw, &value); err != nil {
- return fmt.Errorf("unmarshaling additional property %q: %w", key, err)
+ continue
}
t.{{ .Name }}[key] = value
}
+ if len(t.{{ .Name }}) == 0 {
+ t.{{ .Name }} = nil
+ }
return nil
}
{{ end }}
@@ -121,7 +127,7 @@ type {{ .Name }} struct {
{{- if .Discriminator }}
unknownDiscriminator string
- raw json.RawMessage
+ raw string
{{- end }}
}
{{ if .Discriminator }}
@@ -140,14 +146,17 @@ func (u {{ .Name }}) UnknownDiscriminator() string {
// Raw returns the original JSON of an unrecognized variant, or nil.
func (u {{ .Name }}) Raw() json.RawMessage {
- return u.raw
+ if u.raw == "" {
+ return nil
+ }
+ return json.RawMessage(u.raw)
}
{{ end }}
// MarshalJSON implements json.Marshaler for {{ .Name }}.
func (u {{ .Name }}) MarshalJSON() ([]byte, error) {
{{- if .Discriminator }}
if u.IsUnknownVariant() {
- return u.raw, nil
+ return []byte(u.raw), nil
}
{{- end }}
return json.Marshal(u.Value)
@@ -185,22 +194,33 @@ func (u *{{ $typeName }}) UnmarshalJSON(data []byte) error {
// of the whole payload it happens to appear in.
*u = {{ $typeName }}{
unknownDiscriminator: disc.{{ discriminatorFieldName .Discriminator.PropertyName }},
- raw: append(json.RawMessage(nil), data...),
+ raw: string(data),
}
return nil
}
{{- else }}
var errors []error
-{{- range .UnionTypes }}
-{{- if ne .TypeName "any" }}
- var val{{ .TypeName }} {{ .TypeName }}
- if err := json.Unmarshal(data, &val{{ .TypeName }}); err == nil {
- u.Value = val{{ .TypeName }}
+{{- range $i, $v := .UnionTypes }}
+{{- if ne $v.TypeName "any" }}
+ var variant{{ $i }} {{ $v.TypeName }}
+ if err := json.Unmarshal(data, &variant{{ $i }}); err == nil {
+ u.Value = variant{{ $i }}
return nil
} else {
errors = append(errors, err)
}
{{- end }}
+{{- end }}
+{{- if hasUntypedVariant . }}
+ // A variant with no Go type of its own still covers payloads the spec says are
+ // valid, so they decode into any rather than failing as an unmatched variant.
+ var untyped any
+ if err := json.Unmarshal(data, &untyped); err == nil {
+ u.Value = untyped
+ return nil
+ } else {
+ errors = append(errors, err)
+ }
{{- end }}
return fmt.Errorf("data did not match any variant of {{ $typeName }}: %v", errors)
{{- end }}