From 6333288136812aabdf906f494576af047a3843f3 Mon Sep 17 00:00:00 2001 From: Michael McQuade Date: Fri, 21 Aug 2026 13:46:58 -0500 Subject: [PATCH 1/4] feat(analyzer): allOf is honored wherever it appears, not only on named schemas allOf is JSON Schema 2020-12 composition, which 3.1 uses, and it is valid anywhere a schema is. convertSchema composes it for a named component schema and resolveGoType, which types every inline property, never looked at it, so a property that composed anything resolved to any. In a 3.1 spec today: allOf: [$ref, {inline object}] -> any allOf: [$ref] -> any $ref with sibling keywords -> the referenced type anyOf: [$ref, {type: null}] -> the referenced type The first two are the inconsistency: the same composition on a named schema builds a struct. A lone $ref inside an allOf says the value must match that schema, so it resolves to it; anything more composes a shape of its own and is named like other inline schemas, keeping both what it embeds and what it adds. The two idioms 3.1 offers instead already worked, and now have a test that keeps them working. --- internal/analyzer/schemas.go | 60 ++++++++ .../analyzer/schemas_allof_property_test.go | 142 ++++++++++++++++++ internal/generator/e2e_allof_property_test.go | 98 ++++++++++++ 3 files changed, 300 insertions(+) create mode 100644 internal/analyzer/schemas_allof_property_test.go create mode 100644 internal/generator/e2e_allof_property_test.go diff --git a/internal/analyzer/schemas.go b/internal/analyzer/schemas.go index 63fea63..700e9fc 100644 --- a/internal/analyzer/schemas.go +++ b/internal/analyzer/schemas.go @@ -629,6 +629,21 @@ func (a *Analyzer) resolveGoType(schema *highbase.Schema, nameHint string) strin return name } + // An allOf composes other schemas. A lone $ref inside one is how a 3.0 spec + // hangs a description or nullable on a reference, since keywords beside a + // $ref are ignored, and it means the type it references. Anything else + // composes a shape of its own and is named like any other inline schema. + if len(schema.AllOf) > 0 { + if len(schema.AllOf) == 1 { + if goType := a.goTypeForRef(schema.AllOf[0].GetReference()); goType != "" { + return goType + } + } + if name, ok := a.synthesizeInlineAllOf(schema, nameHint); ok { + return name + } + } + // Enum type referenced inline -- use the primary type. primaryType := primaryType(schema) @@ -769,6 +784,51 @@ func (a *Analyzer) synthesizeInlineObject(schema *highbase.Schema, nameHint stri return goName, true } +// synthesizeInlineAllOf declares a named struct for a composition written inline, +// so what it composes stays typed instead of collapsing into any. +func (a *Analyzer) synthesizeInlineAllOf(schema *highbase.Schema, nameHint string) (string, bool) { + if schema.Title != "" { + nameHint = schema.Title + } + if nameHint == "" { + return "", false + } + + key := a.inlineAllOfKey(schema, nameHint) + if existing, ok := a.synthesizedByKey[key]; ok { + return existing.Name, true + } + + goName := a.namer.Unique(naming.Exported(nameHint)) + td, err := a.convertAllOf(goName, schema, false, false) + if err != nil || td == nil { + return "", false + } + a.synthesizedByKey[key] = td + a.synthesized = append(a.synthesized, td) + return goName, true +} + +// inlineAllOfKey identifies a composition by what it composes, so the same one +// written twice lands on one type. +func (a *Analyzer) inlineAllOfKey(schema *highbase.Schema, nameHint string) string { + var b strings.Builder + b.WriteString("allOf") + for i, proxy := range schema.AllOf { + if ref := proxy.GetReference(); ref != "" { + b.WriteString("|" + ref) + continue + } + entry, err := proxy.BuildSchema() + if err != nil || entry == nil { + b.WriteString("|?") + continue + } + b.WriteString("|" + a.inlineObjectKey(entry, nameHint+"Part"+strconv.Itoa(i))) + } + return b.String() +} + // inlineObjectKey identifies an inline object by the Go it would generate, so two // properties declaring the same shape land on one type rather than two names for // it. diff --git a/internal/analyzer/schemas_allof_property_test.go b/internal/analyzer/schemas_allof_property_test.go new file mode 100644 index 0000000..2bcd4d2 --- /dev/null +++ b/internal/analyzer/schemas_allof_property_test.go @@ -0,0 +1,142 @@ +package analyzer + +import "testing" + +const allOfPropertySpec = `openapi: 3.1.0 +info: { title: t, version: "1" } +paths: {} +components: + schemas: + View: + type: object + properties: + creator: + allOf: + - $ref: "#/components/schemas/User" + author: + readOnly: true + description: The author. + allOf: + - $ref: "#/components/schemas/User" + same: + allOf: + - $ref: "#/components/schemas/User" + composed: + description: A user with a role beside it. + allOf: + - $ref: "#/components/schemas/User" + - type: object + properties: + role: { type: string } + inline: + allOf: + - type: object + properties: + only: { type: string } + User: + type: object + properties: + login: { type: string } + required: [login] +` + +// allOf is JSON Schema composition, and a lone $ref inside one says the value +// must match that schema, which is to say it is of that type. +func TestAllOfProperty_LoneRefResolvesToIt(t *testing.T) { + _, typeMap := analyzeSpec(t, allOfPropertySpec) + + view := typeMap["View"] + if view == nil { + t.Fatal("View not found") + } + byJSON := map[string]string{} + for _, f := range view.Fields { + byJSON[f.JSONName] = f.Type + } + + if byJSON["creator"] != "*User" { + t.Errorf("creator = %q, want *User", byJSON["creator"]) + } + // Keywords beside the allOf are handled where they belong, so the + // composition still resolves to what it composes. + if byJSON["author"] != "*User" { + t.Errorf("author = %q, want *User", byJSON["author"]) + } + if byJSON["same"] != "*User" { + t.Errorf("same = %q, want *User", byJSON["same"]) + } +} + +// A composition of more than a reference is a shape of its own, and gets a name +// like any other inline schema. +func TestAllOfProperty_CompositionIsNamed(t *testing.T) { + _, typeMap := analyzeSpec(t, allOfPropertySpec) + + view := typeMap["View"] + byJSON := map[string]string{} + for _, f := range view.Fields { + byJSON[f.JSONName] = f.Type + } + + if byJSON["composed"] != "*ViewComposed" { + t.Fatalf("composed = %q, want *ViewComposed", byJSON["composed"]) + } + composed := typeMap["ViewComposed"] + if composed == nil { + t.Fatal("ViewComposed not synthesized") + } + if len(composed.Fields) != 2 { + t.Fatalf("ViewComposed fields = %+v, want the embed and the property", composed.Fields) + } + if !composed.Fields[0].Embedded || composed.Fields[0].Type != "User" { + t.Errorf("ViewComposed field 0 = %+v, want an embedded User", composed.Fields[0]) + } + if composed.Fields[1].JSONName != "role" { + t.Errorf("ViewComposed field 1 = %+v, want role", composed.Fields[1]) + } + + // A single inline entry composes just as much as several do. + if byJSON["inline"] != "*ViewInline" { + t.Errorf("inline = %q, want *ViewInline", byJSON["inline"]) + } +} + +const refIdiomSpec = `openapi: 3.1.0 +info: { title: t, version: "1" } +paths: {} +components: + schemas: + Advisory: + type: object + properties: + siblings: + $ref: "#/components/schemas/User" + description: 3.1 allows keywords beside a reference. + readOnly: true + nullableRef: + anyOf: + - $ref: "#/components/schemas/User" + - type: "null" + User: + type: object + properties: { login: { type: string } } + required: [login] +` + +// The ways 3.1 says the same things without allOf: keywords beside a $ref, and a +// null member for a nullable one. Both resolve to the referenced type, and this +// keeps them that way. +func TestRefIdioms_3_1(t *testing.T) { + _, typeMap := analyzeSpec(t, refIdiomSpec) + + byJSON := map[string]string{} + for _, f := range typeMap["Advisory"].Fields { + byJSON[f.JSONName] = f.Type + } + if byJSON["siblings"] != "*User" { + t.Errorf("a $ref with sibling keywords = %q, want *User", byJSON["siblings"]) + } + if byJSON["nullableRef"] != "*User" { + t.Errorf("anyOf with a null member = %q, want *User", byJSON["nullableRef"]) + } +} diff --git a/internal/generator/e2e_allof_property_test.go b/internal/generator/e2e_allof_property_test.go new file mode 100644 index 0000000..0fa6a6d --- /dev/null +++ b/internal/generator/e2e_allof_property_test.go @@ -0,0 +1,98 @@ +package generator + +import "testing" + +const allOfPropertyAPISpec = `openapi: 3.1.0 +info: { title: advisories, version: "1" } +paths: + /advisories/{id}: + get: + operationId: getAdvisory + parameters: + - { name: id, in: path, required: true, schema: { type: string } } + responses: + "200": + description: ok + content: + application/json: { schema: { $ref: "#/components/schemas/Advisory" } } +components: + schemas: + Advisory: + type: object + properties: + id: { type: string } + author: + description: The author of the advisory. + allOf: + - $ref: "#/components/schemas/User" + - type: "null" + reviewer: + description: Who reviewed it, with their verdict. + allOf: + - $ref: "#/components/schemas/User" + - type: object + properties: + verdict: { type: string } + required: [id] + User: + type: object + properties: + login: { type: string } + required: [login] +` + +// TestE2E_AllOfPropertiesAreTyped covers allOf where a property is typed. The +// analyzer composed it for a named schema and not for an inline one, so a +// property resolved to any and a caller had to assert their way into a schema +// the spec names. +func TestE2E_AllOfPropertiesAreTyped(t *testing.T) { + files, _ := generateFromSpec(t, allOfPropertyAPISpec, "advapi") + + runGeneratedWireTest(t, files, "allofproperty", `package advapi + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestWrappedRefDecodesAsItsType(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`+"`"+`{"id":"a-1","author":{"login":"ada"},"reviewer":{"login":"bo","verdict":"ok"}}`+"`"+`)) + })) + defer srv.Close() + + adv, err := NewClient(srv.URL).GetAdvisory(t.Context(), "a-1") + if err != nil { + t.Fatalf("GetAdvisory: %v", err) + } + + // A plain read rather than an assertion into map[string]any. + if adv.Author == nil || adv.Author.Login != "ada" { + t.Errorf("author = %+v, want the referenced User", adv.Author) + } + + // A composition keeps both halves: what it references and what it adds. + if adv.Reviewer == nil || adv.Reviewer.Login != "bo" { + t.Errorf("reviewer = %+v, want the embedded User", adv.Reviewer) + } + if adv.Reviewer.Verdict == nil || *adv.Reviewer.Verdict != "ok" { + t.Errorf("verdict = %v, want ok", adv.Reviewer.Verdict) + } +} + +// The pointer carries absence, so a null author stays distinguishable from one +// that is present and empty. +func TestNullWrappedRefIsNil(t *testing.T) { + var adv Advisory + if err := json.Unmarshal([]byte(`+"`"+`{"id":"a-2","author":null}`+"`"+`), &adv); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if adv.Author != nil { + t.Errorf("author = %+v, want nil", adv.Author) + } +} +`) +} From 31e3c38c04d64f45dec15a3faf2373f87269ab50 Mon Sep 17 00:00:00 2001 From: Michael McQuade Date: Fri, 21 Aug 2026 14:32:13 -0500 Subject: [PATCH 2/4] fix(analyzer): a multipart body composed through allOf lost its file parts The composition path this PR adds converted its schema as if it were sent as JSON, so a multipart body written as allOf typed its binary properties as byte slices and the encoder sent them as base64 text in ordinary fields, the same failure #90 fixed for a body written as an object. Multipart is a property of where a schema is used, so the composition path consults it the same way the object path does. --- internal/analyzer/schemas.go | 7 ++- .../analyzer/schemas_allof_property_test.go | 51 +++++++++++++++++++ 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/internal/analyzer/schemas.go b/internal/analyzer/schemas.go index 700e9fc..2e42b5b 100644 --- a/internal/analyzer/schemas.go +++ b/internal/analyzer/schemas.go @@ -787,6 +787,11 @@ func (a *Analyzer) synthesizeInlineObject(schema *highbase.Schema, nameHint stri // synthesizeInlineAllOf declares a named struct for a composition written inline, // so what it composes stays typed instead of collapsing into any. func (a *Analyzer) synthesizeInlineAllOf(schema *highbase.Schema, nameHint string) (string, bool) { + // Multipart is a property of where the schema is used, and a body composed + // through allOf is used the same way one written as an object is: its binary + // properties are file parts, not base64 text. + multipartBody := a.inlineMultipartBodies[nameHint] + if schema.Title != "" { nameHint = schema.Title } @@ -800,7 +805,7 @@ func (a *Analyzer) synthesizeInlineAllOf(schema *highbase.Schema, nameHint strin } goName := a.namer.Unique(naming.Exported(nameHint)) - td, err := a.convertAllOf(goName, schema, false, false) + td, err := a.convertAllOf(goName, schema, false, multipartBody) if err != nil || td == nil { return "", false } diff --git a/internal/analyzer/schemas_allof_property_test.go b/internal/analyzer/schemas_allof_property_test.go index 2bcd4d2..4aee7b9 100644 --- a/internal/analyzer/schemas_allof_property_test.go +++ b/internal/analyzer/schemas_allof_property_test.go @@ -140,3 +140,54 @@ func TestRefIdioms_3_1(t *testing.T) { t.Errorf("anyOf with a null member = %q, want *User", byJSON["nullableRef"]) } } + +const allOfMultipartSpec = `openapi: 3.1.0 +info: { title: t, version: "1" } +paths: + /upload: + post: + operationId: upload + requestBody: + required: true + content: + multipart/form-data: + schema: + allOf: + - $ref: "#/components/schemas/Meta" + - type: object + properties: + file: { type: string, format: binary } + required: [file] + responses: + "204": { description: ok } +components: + schemas: + Meta: + type: object + properties: + label: { type: string } + required: [label] +` + +// Multipart is a property of where a schema is used, and a body composed through +// allOf is used the same way one written as an object is: its binary properties +// are file parts rather than base64 text in an ordinary field. +func TestAllOfProperty_MultipartBodyKeepsItsFileParts(t *testing.T) { + _, typeMap := analyzeSpec(t, allOfMultipartSpec) + + body := typeMap["UploadBody"] + if body == nil { + t.Fatal("UploadBody not found") + } + byJSON := map[string]string{} + for _, f := range body.Fields { + byJSON[f.JSONName] = f.Type + } + if byJSON["file"] != "FormFile" { + t.Errorf("file = %q, want FormFile", byJSON["file"]) + } + // What it composes comes along. + if len(body.Fields) != 2 || !body.Fields[0].Embedded || body.Fields[0].Type != "Meta" { + t.Errorf("fields = %+v, want the embedded Meta beside the file", body.Fields) + } +} From 50feac9ff0bed2441e49d04e57758b098de7dc55 Mon Sep 17 00:00:00 2001 From: Michael McQuade Date: Fri, 21 Aug 2026 14:29:03 -0500 Subject: [PATCH 3/4] feat(analyzer): a const says what type a property has (#118) --- README.md | 1 + internal/analyzer/analyzer.go | 2 + internal/analyzer/schemas.go | 28 +++++++++ internal/analyzer/schemas_const_test.go | 75 +++++++++++++++++++++++++ 4 files changed, 106 insertions(+) create mode 100644 internal/analyzer/schemas_const_test.go diff --git a/README.md b/README.md index 0a9378d..74a965e 100644 --- a/README.md +++ b/README.md @@ -342,6 +342,7 @@ time rather than passing silently: |---|---| | `prefixItems` | The array stays a slice of one element type. A tuple has no Go shape a slice can hold. | | `dependentSchemas` | Not enforced. A property whose shape depends on another is a validation rule, not a type. | +| `if` / `then` / `else` | Not enforced, for the same reason: a shape that depends on a value is not a type. | | `patternProperties` with several patterns | The map takes an `any` value type, since the patterns disagree about what a key holds. One pattern types the map. | | `links` | Read and not used. Following a link is a decision for the caller, not a generated method. | | `mutualTLS` security scheme | No auth provider. The certificate is configured on the `http.Client`. | diff --git a/internal/analyzer/analyzer.go b/internal/analyzer/analyzer.go index e5f99c6..371118d 100644 --- a/internal/analyzer/analyzer.go +++ b/internal/analyzer/analyzer.go @@ -41,6 +41,7 @@ type Analyzer struct { prefixItemsSeen bool dependentSchemasSeen bool manyPatternPropertiesSeen bool + conditionalSchemasSeen bool // warnings collects what the generator had to skip, for the CLI to report. warnings []string // linksSeen records that the spec describes response links, which the @@ -123,6 +124,7 @@ func (a *Analyzer) Analyze(packageName string) (*ir.Package, error) { {a.prefixItemsSeen, "prefixItems describes a tuple, which has no Go shape a struct can hold, so those arrays stay slices of one element type"}, {a.dependentSchemasSeen, "dependentSchemas makes a property's shape conditional, which a Go struct cannot express, so it is not enforced"}, {a.manyPatternPropertiesSeen, "patternProperties with more than one pattern disagrees about what a key holds, so those maps take an any value type"}, + {a.conditionalSchemasSeen, "if/then/else makes a shape depend on a value, which a Go struct cannot express, so it is not enforced"}, } { if note.seen { pkg.Warnings = append(pkg.Warnings, note.text) diff --git a/internal/analyzer/schemas.go b/internal/analyzer/schemas.go index 2e42b5b..3bb8396 100644 --- a/internal/analyzer/schemas.go +++ b/internal/analyzer/schemas.go @@ -595,6 +595,25 @@ func (a *Analyzer) convertPrimitive(goName, primaryType string, schema *highbase // noteUnsupportedKeywords records the JSON Schema keywords the generator reads // and cannot express in a Go type, so the spec's author hears about it once // rather than discovering it in the output. +// constGoType returns the Go type a const value implies, for a schema that +// states none of its own. +func constGoType(schema *highbase.Schema) (string, bool) { + if schema.Const == nil { + return "", false + } + switch schema.Const.Tag { + case "!!str": + return "string", true + case "!!int": + return "int64", true + case "!!float": + return "float64", true + case "!!bool": + return "bool", true + } + return "", false +} + func (a *Analyzer) noteUnsupportedKeywords(schema *highbase.Schema) { if len(schema.PrefixItems) > 0 { a.prefixItemsSeen = true @@ -602,6 +621,9 @@ func (a *Analyzer) noteUnsupportedKeywords(schema *highbase.Schema) { if schema.DependentSchemas != nil && schema.DependentSchemas.Len() > 0 { a.dependentSchemasSeen = true } + if schema.If != nil || schema.Then != nil || schema.Else != nil { + a.conditionalSchemasSeen = true + } if schema.PatternProperties != nil && schema.PatternProperties.Len() > 1 { a.manyPatternPropertiesSeen = true } @@ -646,6 +668,12 @@ func (a *Analyzer) resolveGoType(schema *highbase.Schema, nameHint string) strin // Enum type referenced inline -- use the primary type. primaryType := primaryType(schema) + if primaryType == "" { + // A const says what the value is, which says what type it has. + if goType, ok := constGoType(schema); ok { + return goType + } + } switch primaryType { case "object": diff --git a/internal/analyzer/schemas_const_test.go b/internal/analyzer/schemas_const_test.go new file mode 100644 index 0000000..c1510c9 --- /dev/null +++ b/internal/analyzer/schemas_const_test.go @@ -0,0 +1,75 @@ +package analyzer + +import ( + "strings" + "testing" +) + +const constSpec = `openapi: 3.1.0 +info: { title: t, version: "1" } +paths: {} +components: + schemas: + Thing: + type: object + properties: + kind: { const: dog } + count: { const: 3 } + ratio: { const: 1.5 } + enabled: { const: true } + stated: { type: string, const: v2 } + shaped: + const: { a: 1 } + conditional: + type: object + properties: { a: { type: string } } + if: { properties: { a: { const: x } } } + then: { required: [b] } +` + +// A const says what the value is, which says what type it has. 3.1 specs pin a +// discriminator this way, and the property was resolving to any. +func TestConst_ImpliesTheType(t *testing.T) { + _, typeMap := analyzeSpec(t, constSpec) + + byJSON := map[string]string{} + for _, f := range typeMap["Thing"].Fields { + byJSON[f.JSONName] = f.Type + } + + // Optional properties are pointers, as everywhere else. + for prop, want := range map[string]string{ + "kind": "*string", + "count": "*int64", + "ratio": "*float64", + "enabled": "*bool", + // A schema that states its own type keeps it. + "stated": "*string", + } { + if byJSON[prop] != want { + t.Errorf("%s = %q, want %q", prop, byJSON[prop], want) + } + } + + // A const that is not a scalar says nothing a Go type can carry on its own. + if byJSON["shaped"] != "any" { + t.Errorf("shaped = %q, want any", byJSON["shaped"]) + } +} + +// if/then/else makes a shape depend on a value, which is a validation rule +// rather than a type, and was dropped without a word while its sibling keyword +// dependentSchemas warned. +func TestConditionalSchemas_Warn(t *testing.T) { + pkg, _ := analyzeSpec(t, constSpec) + + var found int + for _, w := range pkg.Warnings { + if strings.Contains(w, "if/then/else") { + found++ + } + } + if found != 1 { + t.Errorf("if/then/else warnings = %d, want 1: %v", found, pkg.Warnings) + } +} From c24ece2980960a081cbf168dd8475e980a9b04f5 Mon Sep 17 00:00:00 2001 From: Michael McQuade Date: Fri, 21 Aug 2026 14:31:23 -0500 Subject: [PATCH 4/4] test(generator): a spec that crosses features, generated, compiled, and vetted The tests around this one each drive a single feature through a spec written for it. What none of them catch is a feature that quietly stops happening in the presence of another, or one that disappears while the package still builds. Every bug found by generating real specs by hand was invisible to this suite: iterators generated for no operation, a page parameter that is not a number, a file part that turned back into base64 text. testdata/combinations.yaml crosses them deliberately: three pagination styles in one package, one of them iterating a union, a multipart body that composes a schema, webhooks beside callbacks, headers and properties whose names normalize together, schemas named after identifiers the templates declare, and an operation that opts out of the document's security. The assertions are features that have each regressed at least once, and the generated package is compiled and vetted rather than only compiled: vet catches what builds and is still wrong. Writing it found one more, fixed in the commit before this: a multipart body composed through allOf lost its file parts. --- internal/generator/e2e_combinations_test.go | 124 ++++++++++++ testdata/combinations.yaml | 209 ++++++++++++++++++++ 2 files changed, 333 insertions(+) create mode 100644 internal/generator/e2e_combinations_test.go create mode 100644 testdata/combinations.yaml diff --git a/internal/generator/e2e_combinations_test.go b/internal/generator/e2e_combinations_test.go new file mode 100644 index 0000000..2077198 --- /dev/null +++ b/internal/generator/e2e_combinations_test.go @@ -0,0 +1,124 @@ +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" +) + +// TestE2E_Combinations generates from a spec that crosses features rather than +// exercising them one at a time, then compiles and vets the result. +// +// The per-feature tests around it each drive one thing through a spec written +// for it. What they cannot catch is a feature that quietly stops happening in +// the presence of another, or one that disappears entirely while the package +// still builds: iterators generated for no operation, a union base that stops +// being found, a file part that turns back into base64 text. Every assertion +// here is a feature that has broken that way at least once. +func TestE2E_Combinations(t *testing.T) { + specPath := filepath.Join(projectRoot(), "testdata", "combinations.yaml") + + result, err := parser.Parse(specPath, parser.Config{}) + if err != nil { + t.Fatalf("Parse: %v", err) + } + pkg, err := analyzer.New(result.Model).Analyze("combinations") + if err != nil { + t.Fatalf("Analyze: %v", err) + } + gen, err := New(pkg) + if err != nil { + t.Fatalf("New: %v", err) + } + files, err := gen.Generate() + if err != nil { + t.Fatalf("Generate: %v", err) + } + + byName := make(map[string]string, len(files)) + for _, f := range files { + byName[f.Name] = string(f.Content) + } + + for _, want := range []struct{ file, decl, why string }{ + // One iterator per pagination style, in one package. + {"pagination.go", "func (c *Client) ListThingsIter", "cursor pagination"}, + {"pagination.go", "func (c *Client) ListAlertsIter", "offset pagination over a bare array"}, + {"pagination.go", "func (c *Client) ListReportsIter", "page pagination named perPage"}, + {"pagination.go", "*PageIterator[Thing]", "an iterator over a union"}, + + // A discriminated union whose variants compose a base and pin their tag + // with const. + {"types.go", "func (u Thing) Base() *ThingBase", "the base every variant composes"}, + {"types.go", "Kind string `json:\"kind\"`", "a const tag typed as a string"}, + + // Multipart whose body composes a schema through allOf. + {"types.go", "File FormFile", "a file part in a composed multipart body"}, + {"types.go", "Meta", "the schema the multipart body composes"}, + {"operations.go", "body UploadThingBody", "a typed multipart body"}, + + // Names the templates declare, and names that normalize together. + {"types.go", "type Client2 struct", "a schema renamed off a reserved name"}, + {"types.go", "type DefaultBaseURL2 struct", "a schema renamed off a server constant"}, + {"types.go", "UserID2 *string `json:\"user_id,omitempty\"`", "properties that normalize together"}, + {"responses.go", "XTrace2", "response headers that normalize together"}, + + // The rest of the surface, each of which has regressed once. + {"client.go", "const DefaultBaseURL", "the server URL the spec declares"}, + {"client.go", "func ServerURL(region string, basePath string) string", "a templated server"}, + {"webhooks.go", "func ParseThingCreatedWebhook", "a webhook payload"}, + {"webhooks.go", "func ParseUploadThingOnStoredCallback", "a callback payload"}, + {"responses.go", "XRateLimitRemaining *int64", "a typed response header"}, + {"operations.go", "addContentQueryParam", "a parameter serialized as its media type"}, + {"operations.go", "encodeQueryAllowingReserved", "allowReserved on a query parameter"}, + {"operations.go", "params.Either", "a union-typed parameter"}, + {"errors.go", "func (e *ProblemResponse) Error() string", "a typed error wrapper"}, + {"errors.go", "e.Detail.Detail", "a message field found by its conventional name"}, + } { + if !containsCollapsed(byName[want.file], want.decl) { + t.Errorf("%s is missing %s (%s)", want.file, want.why, want.decl) + } + } + + // The public operation opts out of the credential the document requires. + if !containsCollapsed(byName["operations.go"], `"application/json", false`) { + t.Error("operations.go: the operation declaring security: [] should not authenticate") + } + + buildAndVet(t, files, "combinations") +} + +// containsCollapsed reports whether haystack holds needle once the runs of +// whitespace in both are flattened. The generated files reach this test before +// goimports aligns them, so an assertion written the way the output looks would +// depend on alignment that has not happened yet. +func containsCollapsed(haystack, needle string) bool { + return strings.Contains(strings.Join(strings.Fields(haystack), " "), strings.Join(strings.Fields(needle), " ")) +} + +// buildAndVet writes the generated files to a module and runs build and vet over +// them. vet catches what compiles and is still wrong: a shadowed error, a +// printf verb that does not match, a lost struct tag. +func buildAndVet(t *testing.T, files []GeneratedFile, module string) { + t.Helper() + dir := t.TempDir() + goMod := []byte("module " + module + "\n\ngo 1.25.5\n") + if err := os.WriteFile(filepath.Join(dir, "go.mod"), goMod, 0o644); err != nil { + t.Fatalf("writing go.mod: %v", err) + } + if err := WriteFiles(dir, files); err != nil { + t.Fatalf("WriteFiles: %v", err) + } + for _, args := range [][]string{{"build", "./..."}, {"vet", "./..."}} { + cmd := exec.Command("go", args...) + cmd.Dir = dir + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("go %s on the generated package: %v\n%s", strings.Join(args, " "), err, out) + } + } +} diff --git a/testdata/combinations.yaml b/testdata/combinations.yaml new file mode 100644 index 0000000..d2bd8fa --- /dev/null +++ b/testdata/combinations.yaml @@ -0,0 +1,209 @@ +# A 3.1 spec that crosses features rather than exercising them one at a time. +# Every generated file has to hold together at once: iterators over unions, +# composition under multipart, colliding names beside reserved ones. +openapi: 3.1.0 +info: + title: Combinations + version: "1" +servers: + - url: https://{region}.api.example.com/{basePath} + variables: + region: { default: us-east-1, enum: [us-east-1, eu-west-1] } + basePath: { default: v2 } +security: + - bearer: [] +webhooks: + thing-created: + post: + description: Sent when a thing appears. + requestBody: + content: + application/json: + schema: { $ref: "#/components/schemas/Thing" } + thing_created: + post: + requestBody: + content: + application/json: + schema: + type: object + properties: + at: { type: string, format: date-time } + required: [at] +paths: + /things: + get: + operationId: listThings + parameters: + - { name: cursor, in: query, schema: { type: string } } + - name: filter + in: query + content: + application/json: + schema: { $ref: "#/components/schemas/Filter" } + - { name: ref, in: query, allowReserved: true, schema: { type: string } } + - name: either + in: query + schema: + anyOf: [{ type: string }, { type: integer }] + responses: + "200": + description: A page of things, each of which is a union. + headers: + x-trace: { schema: { type: string } } + X_Trace: { schema: { type: string } } + X-Rate-Limit-Remaining: { schema: { type: integer } } + content: + application/json: { schema: { $ref: "#/components/schemas/ThingPage" } } + default: + description: error + content: + application/json: { schema: { $ref: "#/components/schemas/Problem" } } + post: + operationId: uploadThing + requestBody: + required: true + content: + multipart/form-data: + schema: + allOf: + - $ref: "#/components/schemas/Meta" + - type: object + properties: + file: { type: string, format: binary } + required: [file] + responses: + "204": { description: stored } + callbacks: + onStored: + "{$request.body#/callbackUrl}": + post: + requestBody: + content: + application/json: + schema: { $ref: "#/components/schemas/Thing" } + /alerts: + get: + operationId: listAlerts + security: [] + parameters: + - { name: skip, in: query, schema: { type: integer } } + - { name: limit, in: query, schema: { type: integer } } + responses: + "200": + description: The page is the response. + content: + application/json: + schema: { type: array, items: { $ref: "#/components/schemas/Alert" } } + /reports: + get: + operationId: listReports + parameters: + - { name: page, in: query, schema: { type: integer } } + - { name: perPage, in: query, schema: { type: integer } } + responses: + "200": + description: ok + content: + application/json: + schema: + type: object + properties: + results: { type: array, items: { $ref: "#/components/schemas/Alert" } } + total: { type: integer } + /files/{path}: + get: + operationId: download + parameters: + - { name: path, in: path, required: true, schema: { type: string } } + responses: + "200": + description: bytes + content: + application/octet-stream: + schema: { type: string, format: binary } +components: + securitySchemes: + bearer: { type: http, scheme: Bearer } + oidc: + type: openIdConnect + openIdConnectUrl: https://issuer.example.com/.well-known/openid-configuration + schemas: + # A name the templates also declare, and two that normalize together. + Client: + type: object + properties: + user-id: { type: string } + user_id: { type: string } + DefaultBaseURL: + type: object + properties: { v: { type: string } } + Meta: + type: object + properties: + label: { type: string } + required: [label] + Filter: + type: object + properties: + field: { type: string } + values: { type: array, items: { type: string } } + required: [field] + ThingPage: + type: object + properties: + items: { type: array, items: { $ref: "#/components/schemas/Thing" } } + nextCursor: { type: string } + # A discriminated union whose variants pin their tag the 3.1 way and share + # a base by composing it. + Thing: + oneOf: + - $ref: "#/components/schemas/Widget" + - $ref: "#/components/schemas/Gadget" + discriminator: + propertyName: kind + mapping: + widget: "#/components/schemas/Widget" + gadget: "#/components/schemas/Gadget" + ThingBase: + type: object + properties: + id: { type: string } + name: { type: string } + required: [id, name] + Widget: + allOf: + - $ref: "#/components/schemas/ThingBase" + - type: object + properties: + kind: { const: widget } + spins: { type: boolean } + required: [kind, spins] + Gadget: + allOf: + - $ref: "#/components/schemas/ThingBase" + - type: object + properties: + kind: { const: gadget } + gears: { type: integer } + required: [kind, gears] + Alert: + type: object + properties: + id: { type: string } + parent: + anyOf: + - $ref: "#/components/schemas/Alert" + - type: "null" + extensions: + type: object + patternProperties: + "^x-": { type: string } + required: [id] + Problem: + type: object + properties: + type: { type: string } + title: { type: string } + detail: { type: string } + additionalProperties: true