Skip to content
This repository was archived by the owner on Apr 14, 2026. It is now read-only.

Commit d1dd1f2

Browse files
mromaszewiczclaude
andcommitted
Fix form-urlencoded request body code generation
Operations with application/x-www-form-urlencoded request bodies produced uncompilable code: the SimpleClient referenced undefined typed body structs and methods because the code generator only recognized application/json content types. This change introduces a config-driven ContentTypeMatcher that determines which content types get typed request body methods, replacing the hard-coded IsJSON checks. form-urlencoded is now included in the default content-type list alongside JSON. For form-encoded bodies, serialization uses a new marshalForm helper (reflection-based, using json struct tags) instead of json.Marshal, avoiding a JSON round-trip that would produce incorrect form encoding. Key changes: - Replace RequestBodyDescriptor.IsJSON with GenerateTyped (config-driven) and IsFormEncoded (content-type flag) - Add ContentTypeMatcher to operationGatherer and all Gather*Operations call sites - Add DefaultTypedBody/HasTypedBody to OperationDescriptor so the SimpleClient template picks the correct typed body - Add marshalForm/marshalFormImpl helper template for struct-to-url.Values serialization - Update client, initiator, and simple-client templates to branch on IsFormEncoded vs JSON for body serialization Closes #2 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 8d75d5c commit d1dd1f2

17 files changed

Lines changed: 321 additions & 42 deletions

experimental/internal/codegen/clientgen.go

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,9 @@ func clientFuncs(schemaIndex map[string]*SchemaDescriptor, modelsPackage *Models
6161
"isSimpleOperation": isSimpleOperation,
6262
"simpleOperationSuccessResponse": simpleOperationSuccessResponse,
6363
"errorResponseForOperation": errorResponseForOperation,
64+
"defaultTypedBody": func(op *OperationDescriptor) *RequestBodyDescriptor {
65+
return op.DefaultTypedBody()
66+
},
6467
"goTypeForContent": func(content *ResponseContentDescriptor) string {
6568
return goTypeForContent(content, schemaIndex, modelsPackage)
6669
},
@@ -96,6 +99,11 @@ func isSimpleOperation(op *OperationDescriptor) bool {
9699
return false
97100
}
98101

102+
// If the operation has a body, it must have a typed body for the simple client
103+
if op.HasBody && !op.HasTypedBody() {
104+
return false
105+
}
106+
99107
// Count success responses (2xx or default that could be success)
100108
var successResponses []*ResponseDescriptor
101109
for _, r := range op.Responses {
@@ -261,7 +269,7 @@ func (g *ClientGenerator) GenerateRequestBodyTypes(ops []*OperationDescriptor) s
261269

262270
for _, op := range ops {
263271
for _, body := range op.Bodies {
264-
if !body.IsJSON {
272+
if !body.GenerateTyped {
265273
continue
266274
}
267275
// Get the underlying type for this request body

experimental/internal/codegen/clientgen_test.go

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ func TestClientGenerator(t *testing.T) {
3838
paramTracker := NewParamUsageTracker()
3939

4040
// Gather operations
41-
ops, err := GatherOperations(doc, paramTracker)
41+
ops, err := GatherOperations(doc, paramTracker, NewContentTypeMatcher(DefaultContentTypes()))
4242
require.NoError(t, err, "Failed to gather operations")
4343
require.Len(t, ops, 4, "Expected 4 operations")
4444

@@ -80,6 +80,68 @@ func TestClientGenerator(t *testing.T) {
8080
require.Contains(t, clientCode, "NewSimpleClient")
8181
}
8282

83+
func TestClientGenerator_FormEncoded(t *testing.T) {
84+
// Read the comprehensive spec which includes form-encoded bodies
85+
specPath := "test/files/comprehensive.yaml"
86+
specData, err := os.ReadFile(specPath)
87+
require.NoError(t, err, "Failed to read comprehensive spec")
88+
89+
doc, err := libopenapi.NewDocument(specData)
90+
require.NoError(t, err, "Failed to parse comprehensive spec")
91+
92+
contentTypeMatcher := NewContentTypeMatcher(DefaultContentTypes())
93+
schemas, err := GatherSchemas(doc, contentTypeMatcher)
94+
require.NoError(t, err, "Failed to gather schemas")
95+
96+
converter := NewNameConverter(NameMangling{}, NameSubstitutions{})
97+
contentTypeNamer := NewContentTypeShortNamer(DefaultContentTypeShortNames())
98+
ComputeSchemaNames(schemas, converter, contentTypeNamer)
99+
100+
schemaIndex := make(map[string]*SchemaDescriptor)
101+
for _, s := range schemas {
102+
schemaIndex[s.Path.String()] = s
103+
}
104+
105+
paramTracker := NewParamUsageTracker()
106+
ops, err := GatherOperations(doc, paramTracker, contentTypeMatcher)
107+
require.NoError(t, err, "Failed to gather operations")
108+
109+
// Verify we have an operation with a form-encoded body
110+
var hasFormBody bool
111+
for _, op := range ops {
112+
for _, body := range op.Bodies {
113+
if body.IsFormEncoded && body.GenerateTyped {
114+
hasFormBody = true
115+
break
116+
}
117+
}
118+
}
119+
require.True(t, hasFormBody, "Expected at least one operation with a form-encoded typed body")
120+
121+
// Generate client code
122+
gen, err := NewClientGenerator(schemaIndex, true, nil)
123+
require.NoError(t, err, "Failed to create client generator")
124+
125+
clientCode, err := gen.GenerateClient(ops)
126+
require.NoError(t, err, "Failed to generate client code")
127+
128+
t.Logf("Generated client code:\n%s", clientCode)
129+
130+
// Verify form-encoded body methods reference marshalForm
131+
require.Contains(t, clientCode, "marshalForm(body)")
132+
133+
// Verify we generate the form helper when needed
134+
formHelper, err := generateFormHelper(ops)
135+
require.NoError(t, err, "Failed to generate form helper")
136+
require.NotEmpty(t, formHelper, "Form helper should be generated when form-encoded bodies exist")
137+
require.Contains(t, formHelper, "func marshalForm(")
138+
require.Contains(t, formHelper, "func marshalFormImpl(")
139+
require.Contains(t, formHelper, "reflect.Value")
140+
141+
// Verify it generates WithFormdataBody method
142+
require.Contains(t, clientCode, "WithFormdataBody")
143+
}
144+
83145
func TestIsSimpleOperation(t *testing.T) {
84146
tests := []struct {
85147
name string

experimental/internal/codegen/codegen.go

Lines changed: 79 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ func Generate(doc libopenapi.Document, specData []byte, cfg Configuration) (stri
100100
paramTracker := NewParamUsageTracker()
101101

102102
// Gather operations
103-
ops, err := GatherOperations(doc, paramTracker)
103+
ops, err := GatherOperations(doc, paramTracker, contentTypeMatcher)
104104
if err != nil {
105105
return "", fmt.Errorf("gathering operations: %w", err)
106106
}
@@ -145,6 +145,18 @@ func Generate(doc libopenapi.Document, specData []byte, cfg Configuration) (stri
145145
for _, imp := range paramTracker.GetRequiredImports() {
146146
output.AddImport(imp.Path, imp.Alias)
147147
}
148+
149+
// Generate form helper if any operation has form-encoded bodies
150+
formHelper, err := generateFormHelper(ops)
151+
if err != nil {
152+
return "", fmt.Errorf("generating form helper: %w", err)
153+
}
154+
if formHelper != "" {
155+
output.AddType(formHelper)
156+
for _, imp := range templates.MarshalFormHelperTemplate.Imports {
157+
output.AddImport(imp.Path, imp.Alias)
158+
}
159+
}
148160
}
149161

150162
// Track whether shared error types have been generated to avoid duplication.
@@ -159,7 +171,7 @@ func Generate(doc libopenapi.Document, specData []byte, cfg Configuration) (stri
159171
paramTracker := NewParamUsageTracker()
160172

161173
// Gather operations
162-
ops, err := GatherOperations(doc, paramTracker)
174+
ops, err := GatherOperations(doc, paramTracker, contentTypeMatcher)
163175
if err != nil {
164176
return "", fmt.Errorf("gathering operations: %w", err)
165177
}
@@ -216,7 +228,7 @@ func Generate(doc libopenapi.Document, specData []byte, cfg Configuration) (stri
216228
if cfg.Generation.WebhookInitiator {
217229
paramTracker := NewParamUsageTracker()
218230

219-
webhookOps, err := GatherWebhookOperations(doc, paramTracker)
231+
webhookOps, err := GatherWebhookOperations(doc, paramTracker, contentTypeMatcher)
220232
if err != nil {
221233
return "", fmt.Errorf("gathering webhook operations: %w", err)
222234
}
@@ -253,14 +265,26 @@ func Generate(doc libopenapi.Document, specData []byte, cfg Configuration) (stri
253265
for _, imp := range paramTracker.GetRequiredImports() {
254266
output.AddImport(imp.Path, imp.Alias)
255267
}
268+
269+
// Generate form helper if any webhook operation has form-encoded bodies
270+
formHelper, err := generateFormHelper(webhookOps)
271+
if err != nil {
272+
return "", fmt.Errorf("generating form helper: %w", err)
273+
}
274+
if formHelper != "" {
275+
output.AddType(formHelper)
276+
for _, imp := range templates.MarshalFormHelperTemplate.Imports {
277+
output.AddImport(imp.Path, imp.Alias)
278+
}
279+
}
256280
}
257281
}
258282

259283
// Generate callback initiator code if requested
260284
if cfg.Generation.CallbackInitiator {
261285
paramTracker := NewParamUsageTracker()
262286

263-
callbackOps, err := GatherCallbackOperations(doc, paramTracker)
287+
callbackOps, err := GatherCallbackOperations(doc, paramTracker, contentTypeMatcher)
264288
if err != nil {
265289
return "", fmt.Errorf("gathering callback operations: %w", err)
266290
}
@@ -297,6 +321,18 @@ func Generate(doc libopenapi.Document, specData []byte, cfg Configuration) (stri
297321
for _, imp := range paramTracker.GetRequiredImports() {
298322
output.AddImport(imp.Path, imp.Alias)
299323
}
324+
325+
// Generate form helper if any callback operation has form-encoded bodies
326+
formHelper, err := generateFormHelper(callbackOps)
327+
if err != nil {
328+
return "", fmt.Errorf("generating form helper: %w", err)
329+
}
330+
if formHelper != "" {
331+
output.AddType(formHelper)
332+
for _, imp := range templates.MarshalFormHelperTemplate.Imports {
333+
output.AddImport(imp.Path, imp.Alias)
334+
}
335+
}
300336
}
301337
}
302338

@@ -308,7 +344,7 @@ func Generate(doc libopenapi.Document, specData []byte, cfg Configuration) (stri
308344

309345
paramTracker := NewParamUsageTracker()
310346

311-
webhookOps, err := GatherWebhookOperations(doc, paramTracker)
347+
webhookOps, err := GatherWebhookOperations(doc, paramTracker, contentTypeMatcher)
312348
if err != nil {
313349
return "", fmt.Errorf("gathering webhook operations: %w", err)
314350
}
@@ -378,7 +414,7 @@ func Generate(doc libopenapi.Document, specData []byte, cfg Configuration) (stri
378414

379415
paramTracker := NewParamUsageTracker()
380416

381-
callbackOps, err := GatherCallbackOperations(doc, paramTracker)
417+
callbackOps, err := GatherCallbackOperations(doc, paramTracker, contentTypeMatcher)
382418
if err != nil {
383419
return "", fmt.Errorf("gathering callback operations: %w", err)
384420
}
@@ -442,6 +478,43 @@ func Generate(doc libopenapi.Document, specData []byte, cfg Configuration) (stri
442478
return output.Format()
443479
}
444480

481+
// hasFormEncodedBodies returns true if any operation has a form-encoded typed request body.
482+
func hasFormEncodedBodies(ops []*OperationDescriptor) bool {
483+
for _, op := range ops {
484+
for _, body := range op.Bodies {
485+
if body.IsFormEncoded && body.GenerateTyped {
486+
return true
487+
}
488+
}
489+
}
490+
return false
491+
}
492+
493+
// generateFormHelper generates the marshalForm helper function if needed.
494+
func generateFormHelper(ops []*OperationDescriptor) (string, error) {
495+
if !hasFormEncodedBodies(ops) {
496+
return "", nil
497+
}
498+
499+
tmplInfo := templates.MarshalFormHelperTemplate
500+
content, err := templates.TemplateFS.ReadFile("files/" + tmplInfo.Template)
501+
if err != nil {
502+
return "", fmt.Errorf("reading form helper template: %w", err)
503+
}
504+
505+
tmpl, err := template.New(tmplInfo.Name).Parse(string(content))
506+
if err != nil {
507+
return "", fmt.Errorf("parsing form helper template: %w", err)
508+
}
509+
510+
var result strings.Builder
511+
if err := tmpl.Execute(&result, nil); err != nil {
512+
return "", fmt.Errorf("executing form helper template: %w", err)
513+
}
514+
515+
return result.String(), nil
516+
}
517+
445518
// generateParamFunctionsFromTracker generates the parameter styling/binding functions based on usage.
446519
func generateParamFunctionsFromTracker(tracker *ParamUsageTracker) (string, error) {
447520
if !tracker.HasAnyUsage() {

experimental/internal/codegen/configuration.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,7 @@ func DefaultContentTypes() []string {
152152
return []string{
153153
`^application/json$`,
154154
`^application/.*\+json$`,
155+
`^application/x-www-form-urlencoded$`,
155156
}
156157
}
157158

experimental/internal/codegen/filter_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ func gatherTestOps(t *testing.T) []*OperationDescriptor {
8585
require.NoError(t, err)
8686

8787
tracker := NewParamUsageTracker()
88-
ops, err := GatherOperations(doc, tracker)
88+
ops, err := GatherOperations(doc, tracker, NewContentTypeMatcher(DefaultContentTypes()))
8989
require.NoError(t, err)
9090
return ops
9191
}

experimental/internal/codegen/gather_operations.go

Lines changed: 23 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ import (
1111
)
1212

1313
// GatherOperations traverses an OpenAPI document and collects all operations.
14-
func GatherOperations(doc libopenapi.Document, paramTracker *ParamUsageTracker) ([]*OperationDescriptor, error) {
14+
// contentTypeMatcher determines which content types get typed request body methods.
15+
func GatherOperations(doc libopenapi.Document, paramTracker *ParamUsageTracker, contentTypeMatcher *ContentTypeMatcher) ([]*OperationDescriptor, error) {
1516
model, err := doc.BuildV3Model()
1617
if err != nil {
1718
return nil, fmt.Errorf("building v3 model: %w", err)
@@ -21,14 +22,16 @@ func GatherOperations(doc libopenapi.Document, paramTracker *ParamUsageTracker)
2122
}
2223

2324
g := &operationGatherer{
24-
paramTracker: paramTracker,
25+
paramTracker: paramTracker,
26+
contentTypeMatcher: contentTypeMatcher,
2527
}
2628

2729
return g.gatherFromDocument(&model.Model)
2830
}
2931

3032
type operationGatherer struct {
31-
paramTracker *ParamUsageTracker
33+
paramTracker *ParamUsageTracker
34+
contentTypeMatcher *ContentTypeMatcher
3235
}
3336

3437
func (g *operationGatherer) gatherFromDocument(doc *v3.Document) ([]*OperationDescriptor, error) {
@@ -317,16 +320,22 @@ func (g *operationGatherer) gatherRequestBodies(operationID string, bodyRef *v3.
317320
bodyRequired = *bodyRef.Required
318321
}
319322

323+
generateTyped := false
324+
if g.contentTypeMatcher != nil {
325+
generateTyped = g.contentTypeMatcher.Matches(contentType)
326+
}
327+
320328
desc := &RequestBodyDescriptor{
321329
ContentType: contentType,
322330
Required: bodyRequired,
323331
Schema: schemaDesc,
324332

325-
NameTag: nameTag,
326-
GoTypeName: goTypeName,
327-
FuncSuffix: funcSuffix,
328-
IsDefault: isDefault,
329-
IsJSON: IsMediaTypeJSON(contentType),
333+
NameTag: nameTag,
334+
GoTypeName: goTypeName,
335+
FuncSuffix: funcSuffix,
336+
IsDefault: isDefault,
337+
IsFormEncoded: contentType == "application/x-www-form-urlencoded",
338+
GenerateTyped: generateTyped,
330339
}
331340

332341
// Gather encoding options for form data
@@ -568,7 +577,7 @@ func sortPathParamsByPath(path string, params []*ParameterDescriptor) ([]*Parame
568577
}
569578

570579
// GatherWebhookOperations traverses an OpenAPI document and collects operations from webhooks.
571-
func GatherWebhookOperations(doc libopenapi.Document, paramTracker *ParamUsageTracker) ([]*OperationDescriptor, error) {
580+
func GatherWebhookOperations(doc libopenapi.Document, paramTracker *ParamUsageTracker, contentTypeMatcher *ContentTypeMatcher) ([]*OperationDescriptor, error) {
572581
model, err := doc.BuildV3Model()
573582
if err != nil {
574583
return nil, fmt.Errorf("building v3 model: %w", err)
@@ -578,14 +587,15 @@ func GatherWebhookOperations(doc libopenapi.Document, paramTracker *ParamUsageTr
578587
}
579588

580589
g := &operationGatherer{
581-
paramTracker: paramTracker,
590+
paramTracker: paramTracker,
591+
contentTypeMatcher: contentTypeMatcher,
582592
}
583593

584594
return g.gatherWebhooks(&model.Model)
585595
}
586596

587597
// GatherCallbackOperations traverses an OpenAPI document and collects operations from callbacks.
588-
func GatherCallbackOperations(doc libopenapi.Document, paramTracker *ParamUsageTracker) ([]*OperationDescriptor, error) {
598+
func GatherCallbackOperations(doc libopenapi.Document, paramTracker *ParamUsageTracker, contentTypeMatcher *ContentTypeMatcher) ([]*OperationDescriptor, error) {
589599
model, err := doc.BuildV3Model()
590600
if err != nil {
591601
return nil, fmt.Errorf("building v3 model: %w", err)
@@ -595,7 +605,8 @@ func GatherCallbackOperations(doc libopenapi.Document, paramTracker *ParamUsageT
595605
}
596606

597607
g := &operationGatherer{
598-
paramTracker: paramTracker,
608+
paramTracker: paramTracker,
609+
contentTypeMatcher: contentTypeMatcher,
599610
}
600611

601612
return g.gatherCallbacks(&model.Model)

experimental/internal/codegen/initiatorgen.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,7 @@ func (g *InitiatorGenerator) GenerateRequestBodyTypes(ops []*OperationDescriptor
132132

133133
for _, op := range ops {
134134
for _, body := range op.Bodies {
135-
if !body.IsJSON {
135+
if !body.GenerateTyped {
136136
continue
137137
}
138138
var targetType string

0 commit comments

Comments
 (0)