Skip to content

Commit c413dcb

Browse files
authored
feat!: pass required query, header, and cookie params via a named Params struct (#26)
1 parent 8baed49 commit c413dcb

17 files changed

Lines changed: 2565 additions & 284 deletions

CLAUDE.md

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
# CLAUDE.md
2+
3+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4+
5+
## Overview
6+
7+
`openapi-client-generator` is a Go code generator that turns an OpenAPI 3.1 (or 3.0) spec into a complete, idiomatic Go HTTP client package with zero external runtime dependencies.
8+
9+
The main branch is `canary`. Releases are managed by release-please (conventional-commit driven).
10+
11+
## Pipeline
12+
13+
A spec flows through three stages, one per `internal/` package:
14+
15+
1. **parse** (`internal/parser`) — load and validate the spec with libopenapi, resolve `$ref`s.
16+
2. **analyze** (`internal/analyzer`) — lower the spec into the intermediate representation (`internal/ir`): operations, params, types, pagination. This is where naming, disambiguation, and pagination detection happen.
17+
3. **generate** (`internal/generator`) — render the IR through `text/template` files (`internal/templates/*.tmpl`, embedded via `embed.go`) and post-process each file with `goimports` (`WriteFiles`).
18+
19+
Supporting packages: `internal/naming` (OpenAPI identifier → Go identifier), `cmd/` (the Cobra CLI).
20+
21+
The generated code is the product. It must always compile — the e2e tests in `internal/generator` generate clients from specs and run `go build`/`go test` on the output.
22+
23+
## Commands
24+
25+
```bash
26+
go build ./... # build
27+
go test ./... # run all tests (incl. generate-and-compile e2e tests)
28+
go test ./internal/generator/ # one package
29+
gofmt -l . # list unformatted files
30+
go vet ./...
31+
32+
# Run the generator
33+
go run . generate --spec testdata/petstore.yaml --out ./gen/petstore
34+
```
35+
36+
Template changes don't need a build step — templates are `go:embed`ed and read at generate time.
37+
38+
## Code Standards
39+
40+
### Commits
41+
Conventional commits; PR titles become changelog entries.
42+
- `feat:` new feature · `fix:` bug fix · `docs:` · `chore:` build/tooling · `refactor:`
43+
- `!` for breaking changes (`feat!:`); scope for context (`fix(generator):`).
44+
- For `fix` titles, describe the **broken behavior** the user saw, not the code action. For `feat`, don't start with "add" — the title is the new capability.
45+
46+
### Comments
47+
Default to writing no comments. Only add one when the WHY is non-obvious: a hidden constraint, a subtle invariant, a workaround for a specific bug, behavior that would surprise a reader. If removing the comment wouldn't confuse a future reader, don't write it. Specifically avoid:
48+
- WHAT comments. Names and code already say what — `// hasRequiredQueryParams returns whether there are required query params` above a function of that name is noise.
49+
- Conventions that hold across the package (e.g. "wire names use OrigName" is true everywhere, so don't repeat it on each helper).
50+
- References to current task / fix / callers (`// so foo_test.go can exercise this`, `// matching how auth cookies are built`, `// added for the X flow`) — they rot. Such context belongs in the PR description.
51+
- Multi-paragraph docstrings. One short line max — if you need more, the function is doing too much.
52+
- Decorative section headers (`// ===== Helpers =====`). Use blank lines or split files.
53+
54+
Note: a generated client's exported types/methods are SDK documentation for its consumers, so a concise doc comment there is appropriate. Unexported template helpers (`addQueryParam`, etc.) are internal — apply the rules above.
55+
56+
### DRY
57+
When the same logic appears in multiple places, extract a shared helper. Before writing new code, check whether an existing helper already does it. When adding encoding/naming/forwarding that mirrors existing code, reuse it rather than duplicating.
58+
59+
### Go
60+
- Don't `panic`; return errors.
61+
- Use `errors.Is`/`errors.As` for error checks, not equality, so wrapped errors match.
62+
- Use `time.Time` for timestamps; let the JSON marshaler format them.
63+
- File names: lowercase, no separators (`funcmap.go`) or underscores (`api_key.go`) — no camelCase or hyphens. Test files end `_test.go`.
64+
- Package directories under `internal/`: lowercase, no separators.
65+
- All code must pass `gofmt` and `go vet`.
66+
67+
### Testing
68+
- **Test real code.** Tests import and call the actual functions under test — never reimplement the logic being tested. For the generator, the strongest tests generate a client from a spec and then compile/run it (`internal/generator/e2e_test.go`), so a broken template fails the test rather than a restated assertion.
69+
- Prefer `t.Context()` over `context.Background()` in tests.
70+
- When changing a template, add or extend an e2e test that compiles the affected operation shape.

internal/analyzer/analyzer.go

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package analyzer
33
import (
44
"fmt"
55

6+
highbase "github.com/pb33f/libopenapi/datamodel/high/base"
67
v3high "github.com/pb33f/libopenapi/datamodel/high/v3"
78

89
"github.com/parallelworks/openapi-client-generator/internal/ir"
@@ -83,26 +84,34 @@ func (a *Analyzer) analyzeComponentSchemas(pkg *ir.Package) error {
8384
return nil
8485
}
8586

87+
// Register every component's Go type name before converting any schema, so an
88+
// enum constant that sanitizes to a schema-named type (e.g. Color.red -> const
89+
// ColorRed vs a ColorRed schema) yields the numeric suffix to the const, not to
90+
// the user's public API type.
91+
type pendingSchema struct {
92+
name string
93+
goName string
94+
schema *highbase.Schema
95+
}
96+
var pending []pendingSchema
8697
for name, schemaProxy := range a.model.Components.Schemas.FromOldest() {
87-
if _, exists := a.typesBySchema[name]; exists {
88-
continue
89-
}
90-
9198
schema, err := schemaProxy.BuildSchema()
9299
if err != nil {
93100
return fmt.Errorf("building schema %q: %w", name, err)
94101
}
95102
if schema == nil {
96103
continue
97104
}
105+
pending = append(pending, pendingSchema{name, a.namer.RegisterName(naming.ToGoName(name)), schema})
106+
}
98107

99-
goName := a.namer.RegisterName(naming.ToGoName(name))
100-
td, err := a.convertSchema(goName, name, schema)
108+
for _, p := range pending {
109+
td, err := a.convertSchema(p.goName, p.name, p.schema)
101110
if err != nil {
102-
return fmt.Errorf("converting schema %q: %w", name, err)
111+
return fmt.Errorf("converting schema %q: %w", p.name, err)
103112
}
104113
if td != nil {
105-
a.typesBySchema[name] = td
114+
a.typesBySchema[p.name] = td
106115
}
107116
}
108117

internal/analyzer/analyzer_test.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -449,13 +449,13 @@ func TestAnalyzePetstore_FieldDetails(t *testing.T) {
449449
if petStatus.EnumGoType != "string" {
450450
t.Errorf("PetStatus.EnumGoType = %q, want string", petStatus.EnumGoType)
451451
}
452-
expectedEnumVals := []string{"available", "pending", "sold"}
452+
expectedEnumVals := []string{`"available"`, `"pending"`, `"sold"`}
453453
if len(petStatus.EnumValues) != 3 {
454454
t.Fatalf("PetStatus expected 3 enum values, got %d", len(petStatus.EnumValues))
455455
}
456456
for i, ev := range petStatus.EnumValues {
457-
if ev.Value != expectedEnumVals[i] {
458-
t.Errorf("PetStatus enum value %d = %q, want %q", i, ev.Value, expectedEnumVals[i])
457+
if ev.Literal != expectedEnumVals[i] {
458+
t.Errorf("PetStatus enum literal %d = %q, want %q", i, ev.Literal, expectedEnumVals[i])
459459
}
460460
}
461461
}

internal/analyzer/operations.go

Lines changed: 78 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -100,9 +100,64 @@ func (a *Analyzer) convertOperation(httpMethod, path string, pathItem *v3high.Pa
100100
opDef.SecurityReqs = convertSecurityReqs(op.Security)
101101
}
102102

103+
disambiguateParamNames(opDef)
104+
103105
return opDef, nil
104106
}
105107

108+
// disambiguateParamNames renames generated identifiers that would otherwise
109+
// collide, suffixing by kind; only Go identifiers change, never the wire OrigName.
110+
func disambiguateParamNames(opDef *ir.OperationDef) {
111+
// Reserved: the receiver, args, and method/iterator locals a path param could
112+
// shadow, the package identifiers the generated body references (e.g. a param
113+
// named `url` would shadow the net/url import in `url.Values{}`), and the
114+
// helper functions the method body calls (a param named `add_query_param`
115+
// becomes `addQueryParam`, shadowing the helper of that name).
116+
posUsed := map[string]bool{
117+
"c": true, "ctx": true, "path": true, "queryValues": true,
118+
"headers": true, "result": true, "err": true,
119+
"cursor": true, "p": true, "next": true,
120+
"context": true, "fmt": true, "http": true, "url": true,
121+
"pathReplace": true, "addQueryParam": true, "encodeQuery": true,
122+
"setHeader": true, "addCookieHeader": true,
123+
}
124+
if opDef.RequestBody != nil {
125+
posUsed["body"] = true
126+
}
127+
if len(opDef.QueryParams) > 0 || len(opDef.HeaderParams) > 0 || len(opDef.CookieParams) > 0 {
128+
posUsed["params"] = true
129+
posUsed["opts"] = true
130+
}
131+
for _, p := range opDef.PathParams {
132+
name := p.Name
133+
for posUsed[name] {
134+
name += "Path"
135+
}
136+
posUsed[name] = true
137+
p.Name = name
138+
}
139+
140+
// Query, header, and cookie params share one struct, so dedupe field names by location.
141+
fieldUsed := map[string]bool{}
142+
dedupeField := func(p *ir.ParamDef, suffix string) {
143+
name := p.FieldName
144+
for fieldUsed[name] {
145+
name += suffix
146+
}
147+
fieldUsed[name] = true
148+
p.FieldName = name
149+
}
150+
for _, p := range opDef.QueryParams {
151+
dedupeField(p, "Query")
152+
}
153+
for _, p := range opDef.HeaderParams {
154+
dedupeField(p, "Header")
155+
}
156+
for _, p := range opDef.CookieParams {
157+
dedupeField(p, "Cookie")
158+
}
159+
}
160+
106161
// operationName determines the Go method name for an operation.
107162
func (a *Analyzer) operationName(httpMethod, path string, op *v3high.Operation) string {
108163
if op.OperationId != "" {
@@ -165,7 +220,7 @@ func (a *Analyzer) convertParam(param *v3high.Parameter) (*ir.ParamDef, error) {
165220
}
166221

167222
required := param.Required != nil && *param.Required
168-
explode := param.Explode != nil && *param.Explode
223+
style, explode := effectiveStyleExplode(param)
169224

170225
return &ir.ParamDef{
171226
Name: naming.ToGoParamName(param.Name),
@@ -176,11 +231,32 @@ func (a *Analyzer) convertParam(param *v3high.Parameter) (*ir.ParamDef, error) {
176231
Required: required,
177232
Description: param.Description,
178233
Deprecated: param.Deprecated,
179-
Style: param.Style,
234+
Style: style,
180235
Explode: explode,
181236
}, nil
182237
}
183238

239+
// effectiveStyleExplode resolves the OpenAPI serialization defaults: style is
240+
// form for query/cookie and simple for path/header when unset; explode defaults
241+
// to true only for form. The raw param.Explode is false when omitted, which would
242+
// wrongly collapse an ordinary form array — so the default must be applied here.
243+
func effectiveStyleExplode(param *v3high.Parameter) (string, bool) {
244+
style := param.Style
245+
if style == "" {
246+
switch param.In {
247+
case "query", "cookie":
248+
style = "form"
249+
default:
250+
style = "simple"
251+
}
252+
}
253+
explode := style == "form"
254+
if param.Explode != nil {
255+
explode = *param.Explode
256+
}
257+
return style, explode
258+
}
259+
184260
// convertRequestBody converts an OpenAPI request body to an ir.RequestBodyDef.
185261
func (a *Analyzer) convertRequestBody(rb *v3high.RequestBody) (*ir.RequestBodyDef, error) {
186262
def := &ir.RequestBodyDef{

internal/analyzer/operations_test.go

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,37 @@ import (
1010
"github.com/parallelworks/openapi-client-generator/internal/parser"
1111
)
1212

13+
func TestEffectiveStyleExplode(t *testing.T) {
14+
b := func(v bool) *bool { return &v }
15+
tests := []struct {
16+
name string
17+
in string
18+
style string
19+
explode *bool
20+
wantStyle string
21+
wantExplode bool
22+
}{
23+
{"query default", "query", "", nil, "form", true},
24+
{"query explode=false", "query", "", b(false), "form", false},
25+
{"query spaceDelimited default explode", "query", "spaceDelimited", nil, "spaceDelimited", false},
26+
{"query pipeDelimited explode=true", "query", "pipeDelimited", b(true), "pipeDelimited", true},
27+
{"header default", "header", "", nil, "simple", false},
28+
{"header explode=true", "header", "", b(true), "simple", true},
29+
{"path default", "path", "", nil, "simple", false},
30+
{"cookie default", "cookie", "", nil, "form", true},
31+
}
32+
for _, tt := range tests {
33+
t.Run(tt.name, func(t *testing.T) {
34+
p := &v3high.Parameter{In: tt.in, Style: tt.style, Explode: tt.explode}
35+
style, explode := effectiveStyleExplode(p)
36+
if style != tt.wantStyle || explode != tt.wantExplode {
37+
t.Errorf("effectiveStyleExplode(in=%s style=%q explode=%v) = (%q, %v), want (%q, %v)",
38+
tt.in, tt.style, tt.explode, style, explode, tt.wantStyle, tt.wantExplode)
39+
}
40+
})
41+
}
42+
}
43+
1344
func TestAnalyzeOperations_Petstore(t *testing.T) {
1445
specPath := filepath.Join(projectRoot(), "testdata", "petstore.yaml")
1546

@@ -310,3 +341,85 @@ func TestPathToWords(t *testing.T) {
310341
}
311342
}
312343
}
344+
345+
// TestDisambiguateParamNames_PositionalArgs covers path params colliding with
346+
// the fixed positional arguments (ctx, body, and the params struct arg).
347+
func TestDisambiguateParamNames_PositionalArgs(t *testing.T) {
348+
op := &ir.OperationDef{
349+
RequestBody: &ir.RequestBodyDef{},
350+
PathParams: []*ir.ParamDef{
351+
// Collides with the params struct argument (op has query params).
352+
{Name: "params", OrigName: "params", Location: "path", Required: true},
353+
// Collides with the fixed "body" argument (request body present).
354+
{Name: "body", OrigName: "body", Location: "path", Required: true},
355+
// Collides with the "result" local the method body declares.
356+
{Name: "result", OrigName: "result", Location: "path", Required: true},
357+
},
358+
QueryParams: []*ir.ParamDef{
359+
{Name: "limit", FieldName: "Limit", OrigName: "limit", Location: "query", Required: false},
360+
},
361+
}
362+
363+
disambiguateParamNames(op)
364+
365+
if got := op.PathParams[0].Name; got != "paramsPath" {
366+
t.Errorf("path param colliding with params arg: Name = %q, want %q", got, "paramsPath")
367+
}
368+
if got := op.PathParams[1].Name; got != "bodyPath" {
369+
t.Errorf("path param colliding with body arg: Name = %q, want %q", got, "bodyPath")
370+
}
371+
if got := op.PathParams[2].Name; got != "resultPath" {
372+
t.Errorf("path param colliding with the 'result' method local: Name = %q, want %q", got, "resultPath")
373+
}
374+
// Wire names must never change — only the Go identifier is disambiguated.
375+
if got := op.PathParams[0].OrigName; got != "params" {
376+
t.Errorf("OrigName changed to %q, want %q", got, "params")
377+
}
378+
}
379+
380+
// TestDisambiguateParamNames_NoStructArg confirms "params"/"opts" are only
381+
// reserved when the op actually has a params struct argument.
382+
func TestDisambiguateParamNames_NoStructArg(t *testing.T) {
383+
op := &ir.OperationDef{
384+
PathParams: []*ir.ParamDef{
385+
{Name: "params", OrigName: "params", Location: "path", Required: true},
386+
},
387+
}
388+
disambiguateParamNames(op)
389+
if got := op.PathParams[0].Name; got != "params" {
390+
t.Errorf("path param Name = %q, want %q (no params struct arg to collide with)", got, "params")
391+
}
392+
}
393+
394+
// TestDisambiguateParamNames_StructFields covers query/header/cookie params
395+
// whose Go field names collide inside the shared params struct.
396+
func TestDisambiguateParamNames_StructFields(t *testing.T) {
397+
op := &ir.OperationDef{
398+
QueryParams: []*ir.ParamDef{
399+
{Name: "userId", FieldName: "UserId", OrigName: "user-id", Location: "query", Required: true},
400+
},
401+
HeaderParams: []*ir.ParamDef{
402+
// PascalCases to the same "UserId" as the query param above.
403+
{Name: "userId", FieldName: "UserId", OrigName: "user_id", Location: "header", Required: true},
404+
},
405+
CookieParams: []*ir.ParamDef{
406+
{Name: "userId", FieldName: "UserId", OrigName: "userId", Location: "cookie", Required: false},
407+
},
408+
}
409+
410+
disambiguateParamNames(op)
411+
412+
if got := op.QueryParams[0].FieldName; got != "UserId" {
413+
t.Errorf("first field = %q, want %q (first occurrence keeps its name)", got, "UserId")
414+
}
415+
if got := op.HeaderParams[0].FieldName; got != "UserIdHeader" {
416+
t.Errorf("colliding header field = %q, want %q", got, "UserIdHeader")
417+
}
418+
if got := op.CookieParams[0].FieldName; got != "UserIdCookie" {
419+
t.Errorf("colliding cookie field = %q, want %q", got, "UserIdCookie")
420+
}
421+
// Wire names are untouched, so encoding still uses the spec names.
422+
if got := op.HeaderParams[0].OrigName; got != "user_id" {
423+
t.Errorf("OrigName changed to %q, want %q", got, "user_id")
424+
}
425+
}

internal/analyzer/pagination.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,10 +36,11 @@ func (a *Analyzer) detectPagination(pkg *ir.Package) {
3636

3737
// detectCursorPagination checks if an operation uses cursor-based pagination.
3838
func (a *Analyzer) detectCursorPagination(op *ir.OperationDef, pkg *ir.Package) *ir.PaginationDef {
39-
// Find a cursor query parameter.
39+
// The cursor param must be optional: the iterator drives it as a pointer field
40+
// it sets each page, so a required (value) cursor can't be paginated.
4041
cursorParam := ""
4142
for _, p := range op.QueryParams {
42-
if containsCI(cursorParamNames, p.OrigName) {
43+
if !p.Required && containsCI(cursorParamNames, p.OrigName) {
4344
cursorParam = p.OrigName
4445
break
4546
}

0 commit comments

Comments
 (0)