← back to Cli Printing Press
fix(cli): emit form-encoded request bodies (#947)
48cc2a31b172328d6f6a314014553c3f8e981eb3 · 2026-05-10 12:12:08 -0700 · Trevin Chow
* fix(cli): emit form-encoded request bodies (closes #921)
Generator now branches request-body emission on Endpoint.RequestContentType so
endpoints declaring application/x-www-form-urlencoded route through new
PostForm/PutForm/PatchForm helpers instead of c.Post (which JSON-marshaled).
This unblocks OAuth 2.0 token endpoints (which RFC 6749 mandates form-encode)
and reverse-engineered APIs that mirror Laravel/Rails-style backends.
The new method declarations and helper types are gated on .HasFormRequest so
specs without form-encoded endpoints generate byte-identical output, verified
by `scripts/golden.sh verify` across all 16 cases. Multipart and form share
the contentType variable in client do() so both compose without conflict.
Per-field json-string encoding (Resy struct_data) was listed as optional in
the issue and is not implemented; the body-map helper does validate JSON for
object/array/json-string fields before writing them to the form.
* fix(cli): address Greptile review feedback
- Rename isComplexMultipartField → isComplexBodyField; the predicate is
shared by multipart and form body-emission paths, so the multipart-
specific name no longer matches its callers.
- Note the intentional HeaderOverrides omission on the promoted form
branch via a Go-template comment so the gap is visible to future
template editors without leaking into generated output.
Files touched
A internal/generator/form_test.goM internal/generator/generator.goM internal/generator/templates/client.go.tmplM internal/generator/templates/command_endpoint.go.tmplM internal/generator/templates/command_promoted.go.tmplM internal/generator/templates/mcp_tools.go.tmplM internal/openapi/parser_test.go
Diff
commit 48cc2a31b172328d6f6a314014553c3f8e981eb3
Author: Trevin Chow <trevin@trevinchow.com>
Date: Sun May 10 12:12:08 2026 -0700
fix(cli): emit form-encoded request bodies (#947)
* fix(cli): emit form-encoded request bodies (closes #921)
Generator now branches request-body emission on Endpoint.RequestContentType so
endpoints declaring application/x-www-form-urlencoded route through new
PostForm/PutForm/PatchForm helpers instead of c.Post (which JSON-marshaled).
This unblocks OAuth 2.0 token endpoints (which RFC 6749 mandates form-encode)
and reverse-engineered APIs that mirror Laravel/Rails-style backends.
The new method declarations and helper types are gated on .HasFormRequest so
specs without form-encoded endpoints generate byte-identical output, verified
by `scripts/golden.sh verify` across all 16 cases. Multipart and form share
the contentType variable in client do() so both compose without conflict.
Per-field json-string encoding (Resy struct_data) was listed as optional in
the issue and is not implemented; the body-map helper does validate JSON for
object/array/json-string fields before writing them to the form.
* fix(cli): address Greptile review feedback
- Rename isComplexMultipartField → isComplexBodyField; the predicate is
shared by multipart and form body-emission paths, so the multipart-
specific name no longer matches its callers.
- Note the intentional HeaderOverrides omission on the promoted form
branch via a Go-template comment so the gap is visible to future
template editors without leaking into generated output.
---
internal/generator/form_test.go | 117 +++++++++++++++++++++
internal/generator/generator.go | 58 +++++++++-
internal/generator/templates/client.go.tmpl | 63 +++++++++--
.../generator/templates/command_endpoint.go.tmpl | 36 ++++++-
.../generator/templates/command_promoted.go.tmpl | 8 ++
internal/generator/templates/mcp_tools.go.tmpl | 55 +++++++++-
internal/openapi/parser_test.go | 94 +++++++++++++++++
7 files changed, 415 insertions(+), 16 deletions(-)
diff --git a/internal/generator/form_test.go b/internal/generator/form_test.go
new file mode 100644
index 00000000..9d0f6e36
--- /dev/null
+++ b/internal/generator/form_test.go
@@ -0,0 +1,117 @@
+package generator
+
+import (
+ "path/filepath"
+ "testing"
+
+ "github.com/mvanhorn/cli-printing-press/v4/internal/naming"
+ "github.com/mvanhorn/cli-printing-press/v4/internal/spec"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// TestGenerateFormRequestBodyUsesFormClient validates that endpoints declaring
+// application/x-www-form-urlencoded request bodies are routed through the
+// new c.PostForm helper rather than c.Post — covers the OAuth and Resy bug
+// described in #921.
+func TestGenerateFormRequestBodyUsesFormClient(t *testing.T) {
+ t.Parallel()
+
+ apiSpec := minimalSpec("formapi")
+ apiSpec.Resources = map[string]spec.Resource{
+ "oauth": {
+ Description: "OAuth token operations",
+ Endpoints: map[string]spec.Endpoint{
+ "list": {
+ Method: "GET",
+ Path: "/oauth/clients",
+ Description: "List OAuth clients",
+ },
+ "token": {
+ Method: "POST",
+ Path: "/oauth/token",
+ Description: "Exchange OAuth token",
+ RequestContentType: "application/x-www-form-urlencoded",
+ Body: []spec.Param{
+ {Name: "grant_type", Type: "string", Required: true, Description: "Grant type"},
+ {Name: "client_id", Type: "string", Required: true, Description: "Client id"},
+ {Name: "client_secret", Type: "string", Description: "Client secret"},
+ },
+ },
+ },
+ },
+ "venues": {
+ Description: "Venue operations",
+ Endpoints: map[string]spec.Endpoint{
+ "search": {
+ Method: "POST",
+ Path: "/venues/search",
+ Description: "Search venues",
+ RequestContentType: "application/x-www-form-urlencoded",
+ Body: []spec.Param{
+ {Name: "struct_data", Type: "string", Format: "json", Required: true, Description: "JSON-encoded query payload"},
+ },
+ },
+ },
+ },
+ }
+
+ outputDir := filepath.Join(t.TempDir(), naming.CLI(apiSpec.Name))
+ require.NoError(t, New(apiSpec, outputDir).Generate())
+
+ clientSrc := readGeneratedFile(t, outputDir, "internal", "client", "client.go")
+ assert.Contains(t, clientSrc, `func (c *Client) PostForm(path string, fields url.Values) (json.RawMessage, int, error)`)
+ assert.Contains(t, clientSrc, `func (c *Client) PostFormWithHeaders(path string, fields url.Values, headers map[string]string) (json.RawMessage, int, error)`)
+ assert.Contains(t, clientSrc, `type formRequestBody struct {`)
+ assert.Contains(t, clientSrc, `func encodeFormBody(body formRequestBody) ([]byte, string, error)`)
+ assert.Contains(t, clientSrc, `body.Fields.Encode()`)
+ assert.Contains(t, clientSrc, `req.Header.Set("Content-Type", contentType)`)
+
+ endpointSrc := readGeneratedFile(t, outputDir, "internal", "cli", "oauth_token.go")
+ assert.Contains(t, endpointSrc, `"net/url"`)
+ assert.Contains(t, endpointSrc, `fields := url.Values{}`)
+ assert.Contains(t, endpointSrc, `fields.Set("grant_type", bodyGrantType)`)
+ assert.Contains(t, endpointSrc, `fields.Set("client_id", bodyClientId)`)
+ assert.Contains(t, endpointSrc, `c.PostForm(path, fields)`)
+ assert.NotContains(t, endpointSrc, `var stdinBody bool`)
+ assert.NotContains(t, endpointSrc, `c.Post(path, body)`)
+ // Required-flag check should fire at top-level, not inside `if !stdinBody`.
+ assert.Contains(t, endpointSrc, `return fmt.Errorf("required flag \"%s\" not set", "grant-type")`)
+ assert.Contains(t, endpointSrc, `return fmt.Errorf("required flag \"%s\" not set", "client-id")`)
+
+ // JSON-string body field validates as JSON before sending. Single-endpoint
+ // resource collapses to the promoted form rather than a per-endpoint file.
+ venuesSrc := readGeneratedFile(t, outputDir, "internal", "cli", "promoted_venues.go")
+ assert.Contains(t, venuesSrc, `if !json.Valid([]byte(bodyStructData))`)
+ assert.Contains(t, venuesSrc, `fields.Set("struct_data", bodyStructData)`)
+ assert.Contains(t, venuesSrc, `c.PostForm(path, fields)`)
+
+ mcpSrc := readGeneratedFile(t, outputDir, "internal", "mcp", "tools.go")
+ assert.Contains(t, mcpSrc, `RequestContentType: "application/x-www-form-urlencoded"`)
+ assert.Contains(t, mcpSrc, `formFields := url.Values{}`)
+ assert.Contains(t, mcpSrc, `data, _, err = c.PostForm(path, formFields)`)
+
+ runGoCommand(t, outputDir, "mod", "tidy")
+ runGoCommand(t, outputDir, "build", "./...")
+}
+
+// TestGenerateNonFormSpecOmitsFormHelpers asserts the negative case: a spec
+// with no form-encoded endpoints generates client.go without any form-specific
+// imports, types, or methods. Guards the byte-identical-output criterion in
+// #921's acceptance criteria.
+func TestGenerateNonFormSpecOmitsFormHelpers(t *testing.T) {
+ t.Parallel()
+
+ apiSpec := minimalSpec("plainapi")
+ outputDir := filepath.Join(t.TempDir(), naming.CLI(apiSpec.Name))
+ require.NoError(t, New(apiSpec, outputDir).Generate())
+
+ clientSrc := readGeneratedFile(t, outputDir, "internal", "client", "client.go")
+ // Form-helper method declarations must be absent (HTTPClient.PostForm
+ // from net/http is fine — only the *Client receiver method is gated).
+ assert.NotContains(t, clientSrc, `func (c *Client) PostForm`)
+ assert.NotContains(t, clientSrc, `func (c *Client) PutForm`)
+ assert.NotContains(t, clientSrc, `func (c *Client) PatchForm`)
+ assert.NotContains(t, clientSrc, "formRequestBody")
+ assert.NotContains(t, clientSrc, "encodeFormBody")
+}
diff --git a/internal/generator/generator.go b/internal/generator/generator.go
index bef9af5c..77c2edff 100644
--- a/internal/generator/generator.go
+++ b/internal/generator/generator.go
@@ -311,6 +311,9 @@ func New(s *spec.APISpec, outputDir string) *Generator {
"multipartBodyMaps": multipartBodyMaps,
"endpointUsesMultipart": endpointUsesMultipart,
"hasMultipartRequest": hasMultipartRequest,
+ "formBodyMaps": formBodyMaps,
+ "endpointUsesForm": endpointUsesForm,
+ "hasFormRequest": hasFormRequest,
"publicFlagName": publicFlagName,
"publicFlagAliases": publicFlagAliases,
"flagChangedExpr": flagChangedExpr,
@@ -646,6 +649,7 @@ type clientTemplateData struct {
*spec.APISpec
HasGraphQLPersistedQueries bool
HasMultipartRequest bool
+ HasFormRequest bool
// Populated by Generator.shouldEmitAuth() so this template gate stays in
// sync with auth.go emission, root.go registration, and scoreAuth.
HasAuthCommand bool
@@ -1435,6 +1439,7 @@ func (g *Generator) renderSingleFiles() error {
APISpec: g.Spec,
HasGraphQLPersistedQueries: g.hasTrafficAnalysisHint("graphql_persisted_query"),
HasMultipartRequest: hasMultipartRequest(g.Spec),
+ HasFormRequest: hasFormRequest(g.Spec),
HasAuthCommand: g.shouldEmitAuth(),
}
case "config.go.tmpl":
@@ -3040,7 +3045,7 @@ func flagChangedExpr(p spec.Param) string {
func mcpParamBindings(endpoint spec.Endpoint, pathTemplate string) []mcpParamBinding {
bindings := make([]mcpParamBinding, 0, len(endpoint.Params)+len(endpoint.Body))
requestContentType := ""
- if endpointUsesMultipart(endpoint) {
+ if endpointUsesMultipart(endpoint) || endpointUsesForm(endpoint) {
requestContentType = endpoint.RequestContentType
}
for _, p := range endpoint.Params {
@@ -3156,7 +3161,7 @@ func multipartBodyMaps(body []spec.Param, indent string) string {
id := paramIdent(p)
ident := toCamel(id)
flag := publicFlagName(p)
- if isComplexMultipartField(p) || isJSONStringParam(p) {
+ if isComplexBodyField(p) || isJSONStringParam(p) {
fmt.Fprintf(&b, "%sif body%s != \"\" {\n", indent, ident)
fmt.Fprintf(&b, "%s\tif !json.Valid([]byte(body%s)) {\n", indent, ident)
fmt.Fprintf(&b, "%s\t\treturn fmt.Errorf(\"parsing --%s JSON: invalid JSON\")\n", indent, flag)
@@ -3189,6 +3194,18 @@ func endpointUsesMultipart(endpoint spec.Endpoint) bool {
}
func hasMultipartRequest(apiSpec *spec.APISpec) bool {
+ return anyEndpointMatches(apiSpec, endpointUsesMultipart)
+}
+
+func endpointUsesForm(endpoint spec.Endpoint) bool {
+ return strings.EqualFold(strings.TrimSpace(endpoint.RequestContentType), "application/x-www-form-urlencoded")
+}
+
+func hasFormRequest(apiSpec *spec.APISpec) bool {
+ return anyEndpointMatches(apiSpec, endpointUsesForm)
+}
+
+func anyEndpointMatches(apiSpec *spec.APISpec, predicate func(spec.Endpoint) bool) bool {
if apiSpec == nil {
return false
}
@@ -3196,7 +3213,7 @@ func hasMultipartRequest(apiSpec *spec.APISpec) bool {
walk = func(resources map[string]spec.Resource) bool {
for _, resource := range resources {
for _, endpoint := range resource.Endpoints {
- if endpointUsesMultipart(endpoint) {
+ if predicate(endpoint) {
return true
}
}
@@ -3209,11 +3226,44 @@ func hasMultipartRequest(apiSpec *spec.APISpec) bool {
return walk(apiSpec.Resources)
}
+// formBodyMaps renders per-flag form-field assignments for endpoints that send
+// application/x-www-form-urlencoded request bodies. Object/array/JSON-string
+// fields are validated as JSON then sent as a single string field (matching
+// the JSON-shaped struct_data convention used by reverse-engineered APIs);
+// scalar fields are formatted with %v.
+func formBodyMaps(body []spec.Param, indent string) string {
+ var b strings.Builder
+ for _, p := range body {
+ id := paramIdent(p)
+ ident := toCamel(id)
+ flag := publicFlagName(p)
+ if isComplexBodyField(p) || isJSONStringParam(p) {
+ fmt.Fprintf(&b, "%sif body%s != \"\" {\n", indent, ident)
+ fmt.Fprintf(&b, "%s\tif !json.Valid([]byte(body%s)) {\n", indent, ident)
+ fmt.Fprintf(&b, "%s\t\treturn fmt.Errorf(\"parsing --%s JSON: invalid JSON\")\n", indent, flag)
+ fmt.Fprintf(&b, "%s\t}\n", indent)
+ fmt.Fprintf(&b, "%s\tfields.Set(%q, body%s)\n", indent, p.Name, ident)
+ fmt.Fprintf(&b, "%s}\n", indent)
+ continue
+ }
+ if p.Type == "string" {
+ fmt.Fprintf(&b, "%sif body%s != \"\" {\n", indent, ident)
+ fmt.Fprintf(&b, "%s\tfields.Set(%q, body%s)\n", indent, p.Name, ident)
+ fmt.Fprintf(&b, "%s}\n", indent)
+ continue
+ }
+ fmt.Fprintf(&b, "%sif body%s != %s {\n", indent, ident, zeroVal(p.Type))
+ fmt.Fprintf(&b, "%s\tfields.Set(%q, fmt.Sprintf(\"%%v\", body%s))\n", indent, p.Name, ident)
+ fmt.Fprintf(&b, "%s}\n", indent)
+ }
+ return b.String()
+}
+
func isBinaryParam(p spec.Param) bool {
return strings.EqualFold(strings.TrimSpace(p.Format), "binary")
}
-func isComplexMultipartField(p spec.Param) bool {
+func isComplexBodyField(p spec.Param) bool {
return p.Type == "object" || p.Type == "array"
}
diff --git a/internal/generator/templates/client.go.tmpl b/internal/generator/templates/client.go.tmpl
index f3f06812..818d1685 100644
--- a/internal/generator/templates/client.go.tmpl
+++ b/internal/generator/templates/client.go.tmpl
@@ -18,7 +18,7 @@ import (
"mime/multipart"
{{- end}}
"net/http"
-{{- if .HasAuthCommand}}
+{{- if or .HasAuthCommand .HasFormRequest}}
"net/url"
{{- end}}
"os"
@@ -463,6 +463,16 @@ func (c *Client) PostMultipartWithHeaders(path string, fields map[string]string,
return c.do("POST", path, nil, multipartRequestBody{Fields: fields, FileFields: fileFields}, headers)
}
+{{ end }}
+{{- if .HasFormRequest }}
+func (c *Client) PostForm(path string, fields url.Values) (json.RawMessage, int, error) {
+ return c.do("POST", path, nil, formRequestBody{Fields: fields}, nil)
+}
+
+func (c *Client) PostFormWithHeaders(path string, fields url.Values, headers map[string]string) (json.RawMessage, int, error) {
+ return c.do("POST", path, nil, formRequestBody{Fields: fields}, headers)
+}
+
{{ end }}
func (c *Client) Delete(path string) (json.RawMessage, int, error) {
@@ -490,6 +500,16 @@ func (c *Client) PutMultipartWithHeaders(path string, fields map[string]string,
return c.do("PUT", path, nil, multipartRequestBody{Fields: fields, FileFields: fileFields}, headers)
}
+{{ end }}
+{{- if .HasFormRequest }}
+func (c *Client) PutForm(path string, fields url.Values) (json.RawMessage, int, error) {
+ return c.do("PUT", path, nil, formRequestBody{Fields: fields}, nil)
+}
+
+func (c *Client) PutFormWithHeaders(path string, fields url.Values, headers map[string]string) (json.RawMessage, int, error) {
+ return c.do("PUT", path, nil, formRequestBody{Fields: fields}, headers)
+}
+
{{ end }}
func (c *Client) Patch(path string, body any) (json.RawMessage, int, error) {
@@ -509,6 +529,19 @@ func (c *Client) PatchMultipartWithHeaders(path string, fields map[string]string
return c.do("PATCH", path, nil, multipartRequestBody{Fields: fields, FileFields: fileFields}, headers)
}
+{{ end }}
+{{- if .HasFormRequest }}
+func (c *Client) PatchForm(path string, fields url.Values) (json.RawMessage, int, error) {
+ return c.do("PATCH", path, nil, formRequestBody{Fields: fields}, nil)
+}
+
+func (c *Client) PatchFormWithHeaders(path string, fields url.Values, headers map[string]string) (json.RawMessage, int, error) {
+ return c.do("PATCH", path, nil, formRequestBody{Fields: fields}, headers)
+}
+
+{{ end }}
+
+{{- if .HasMultipartRequest }}
type multipartRequestBody struct {
Fields map[string]string
FileFields map[string]string
@@ -553,6 +586,17 @@ func encodeMultipartBody(body multipartRequestBody) ([]byte, string, error) {
{{ end }}
+{{- if .HasFormRequest }}
+type formRequestBody struct {
+ Fields url.Values
+}
+
+func encodeFormBody(body formRequestBody) ([]byte, string, error) {
+ return []byte(body.Fields.Encode()), "application/x-www-form-urlencoded", nil
+}
+
+{{ end }}
+
// do executes an HTTP request. headerOverrides, when non-nil, override global
// RequiredHeaders for this specific request (used for per-endpoint API versioning).
func (c *Client) do(method, path string, params map[string]string, body any, headerOverrides map[string]string) (json.RawMessage, int, error) {
@@ -621,7 +665,7 @@ func (c *Client) do(method, path string, params map[string]string, body any, hea
{{- end}}
var bodyBytes []byte
-{{- if .HasMultipartRequest}}
+{{- if or .HasMultipartRequest .HasFormRequest}}
var contentType string
{{- end}}
if body != nil {
@@ -633,17 +677,22 @@ func (c *Client) do(method, path string, params map[string]string, body any, hea
}
bodyBytes = b
contentType = ct
- } else {
+ } else {{end}}{{- if .HasFormRequest}}if formBody, ok := body.(formRequestBody); ok {
+ b, ct, err := encodeFormBody(formBody)
+ if err != nil {
+ return nil, 0, err
+ }
+ bodyBytes = b
+ contentType = ct
+ } else {{end}}{{- if or .HasMultipartRequest .HasFormRequest}}{
{{- end}}
b, err := json.Marshal(body)
if err != nil {
return nil, 0, fmt.Errorf("marshaling body: %w", err)
}
bodyBytes = b
-{{- if .HasMultipartRequest}}
+{{- if or .HasMultipartRequest .HasFormRequest}}
contentType = "application/json"
-{{- end}}
-{{- if .HasMultipartRequest}}
}
{{- end}}
}
@@ -740,7 +789,7 @@ func (c *Client) do(method, path string, params map[string]string, body any, hea
return nil, 0, fmt.Errorf("creating request: %w", err)
}
if bodyBytes != nil {
-{{- if .HasMultipartRequest}}
+{{- if or .HasMultipartRequest .HasFormRequest}}
req.Header.Set("Content-Type", contentType)
{{- else}}
req.Header.Set("Content-Type", "application/json")
diff --git a/internal/generator/templates/command_endpoint.go.tmpl b/internal/generator/templates/command_endpoint.go.tmpl
index 8fe455fc..1d81ae4a 100644
--- a/internal/generator/templates/command_endpoint.go.tmpl
+++ b/internal/generator/templates/command_endpoint.go.tmpl
@@ -2,6 +2,7 @@
// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
{{- $isGraphQLEndpoint := and (isGraphQL .APISpec) (eq .Endpoint.Path "/graphql") (or (eq .EndpointName "list") (eq .EndpointName "get"))}}
{{- $isMultipart := endpointUsesMultipart .Endpoint}}
+{{- $isForm := endpointUsesForm .Endpoint}}
package cli
@@ -11,8 +12,11 @@ import (
{{- end}}
"encoding/json"
"fmt"
-{{- if and (or (eq .Endpoint.Method "POST") (eq .Endpoint.Method "PUT") (eq .Endpoint.Method "PATCH")) (not $isMultipart)}}
+{{- if and (or (eq .Endpoint.Method "POST") (eq .Endpoint.Method "PUT") (eq .Endpoint.Method "PATCH")) (not $isMultipart) (not $isForm)}}
"io"
+{{- end}}
+{{- if and (or (eq .Endpoint.Method "POST") (eq .Endpoint.Method "PUT") (eq .Endpoint.Method "PATCH")) $isForm}}
+ "net/url"
{{- end}}
"os"
{{- if .IsAsync}}
@@ -37,7 +41,7 @@ func new{{camel .FuncPrefix}}{{camel .EndpointName}}Cmd(flags *rootFlags) *cobra
{{- if .Endpoint.Pagination}}
var flagAll bool
{{- end}}
-{{- if and (or (eq .Endpoint.Method "POST") (eq .Endpoint.Method "PUT") (eq .Endpoint.Method "PATCH")) (not $isMultipart)}}
+{{- if and (or (eq .Endpoint.Method "POST") (eq .Endpoint.Method "PUT") (eq .Endpoint.Method "PATCH")) (not $isMultipart) (not $isForm)}}
var stdinBody bool
{{- end}}
{{- if .IsAsync}}
@@ -103,7 +107,7 @@ func new{{camel .FuncPrefix}}{{camel .EndpointName}}Cmd(flags *rootFlags) *cobra
{{- end}}
{{- end}}
{{- if or (eq .Endpoint.Method "POST") (eq .Endpoint.Method "PUT") (eq .Endpoint.Method "PATCH")}}
-{{- if $isMultipart}}
+{{- if or $isMultipart $isForm}}
{{- range .Endpoint.Body}}
{{- if and .Required (not .Default)}}
if !{{flagChangedExpr .}} && !flags.dryRun {
@@ -272,6 +276,14 @@ func new{{camel .FuncPrefix}}{{camel .EndpointName}}Cmd(flags *rootFlags) *cobra
{{- else}}
data, statusCode, err := c.PostMultipart(path, fields, fileFields)
{{- end}}
+{{- else if $isForm}}
+ fields := url.Values{}
+{{formBodyMaps .Endpoint.Body "\t\t\t"}}
+{{- if .Endpoint.HeaderOverrides}}
+ data, statusCode, err := c.PostFormWithHeaders(path, fields, headerOverrides)
+{{- else}}
+ data, statusCode, err := c.PostForm(path, fields)
+{{- end}}
{{- else}}
var body map[string]any
if stdinBody {
@@ -309,6 +321,14 @@ func new{{camel .FuncPrefix}}{{camel .EndpointName}}Cmd(flags *rootFlags) *cobra
{{- else}}
data, statusCode, err := c.PutMultipart(path, fields, fileFields)
{{- end}}
+{{- else if $isForm}}
+ fields := url.Values{}
+{{formBodyMaps .Endpoint.Body "\t\t\t"}}
+{{- if .Endpoint.HeaderOverrides}}
+ data, statusCode, err := c.PutFormWithHeaders(path, fields, headerOverrides)
+{{- else}}
+ data, statusCode, err := c.PutForm(path, fields)
+{{- end}}
{{- else}}
var body map[string]any
if stdinBody {
@@ -340,6 +360,14 @@ func new{{camel .FuncPrefix}}{{camel .EndpointName}}Cmd(flags *rootFlags) *cobra
{{- else}}
data, statusCode, err := c.PatchMultipart(path, fields, fileFields)
{{- end}}
+{{- else if $isForm}}
+ fields := url.Values{}
+{{formBodyMaps .Endpoint.Body "\t\t\t"}}
+{{- if .Endpoint.HeaderOverrides}}
+ data, statusCode, err := c.PatchFormWithHeaders(path, fields, headerOverrides)
+{{- else}}
+ data, statusCode, err := c.PatchForm(path, fields)
+{{- end}}
{{- else}}
var body map[string]any
if stdinBody {
@@ -566,7 +594,7 @@ func new{{camel .FuncPrefix}}{{camel .EndpointName}}Cmd(flags *rootFlags) *cobra
{{- if .Endpoint.Pagination}}
cmd.Flags().BoolVar(&flagAll, "all", false, "Fetch all pages")
{{- end}}
-{{- if and (or (eq .Endpoint.Method "POST") (eq .Endpoint.Method "PUT") (eq .Endpoint.Method "PATCH")) (not $isMultipart)}}
+{{- if and (or (eq .Endpoint.Method "POST") (eq .Endpoint.Method "PUT") (eq .Endpoint.Method "PATCH")) (not $isMultipart) (not $isForm)}}
cmd.Flags().BoolVar(&stdinBody, "stdin", false, "Read request body as JSON from stdin")
{{- end}}
{{- if .IsAsync}}
diff --git a/internal/generator/templates/command_promoted.go.tmpl b/internal/generator/templates/command_promoted.go.tmpl
index 1aebe721..6aecf4b3 100644
--- a/internal/generator/templates/command_promoted.go.tmpl
+++ b/internal/generator/templates/command_promoted.go.tmpl
@@ -1,12 +1,16 @@
// Copyright {{currentYear}} {{.Owner}}. Licensed under Apache-2.0. See LICENSE.
// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
{{- $isMultipart := endpointUsesMultipart .Endpoint}}
+{{- $isForm := endpointUsesForm .Endpoint}}
package cli
import (
"encoding/json"
"fmt"
+{{- if and (or (eq (upper .Endpoint.Method) "POST") (eq (upper .Endpoint.Method) "PUT") (eq (upper .Endpoint.Method) "PATCH")) $isForm}}
+ "net/url"
+{{- end}}
"os"
"github.com/spf13/cobra"
@@ -180,6 +184,10 @@ func new{{camel .PromotedName}}PromotedCmd(flags *rootFlags) *cobra.Command {
fileFields := map[string]string{}
{{multipartBodyMaps .Endpoint.Body "\t\t\t"}}
data, _, err := c.{{pascal (lower .Endpoint.Method)}}Multipart(path, fields, fileFields)
+{{- else if $isForm}}{{/* HeaderOverrides intentionally omitted on promoted form/multipart/JSON paths — parity with the existing multipart promoted branch above. */}}
+ fields := url.Values{}
+{{formBodyMaps .Endpoint.Body "\t\t\t"}}
+ data, _, err := c.{{pascal (lower .Endpoint.Method)}}Form(path, fields)
{{- else}}
// HasStore + non-GET falls through to a live API call here
// rather than through resolveRead (GET-only internally); a
diff --git a/internal/generator/templates/mcp_tools.go.tmpl b/internal/generator/templates/mcp_tools.go.tmpl
index 1e68eecd..db8d5932 100644
--- a/internal/generator/templates/mcp_tools.go.tmpl
+++ b/internal/generator/templates/mcp_tools.go.tmpl
@@ -2,6 +2,7 @@
// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
{{- $canonicalEnvVar := .Auth.CanonicalEnvVar}}
{{- $hasMultipartRequest := hasMultipartRequest .APISpec}}
+{{- $hasFormRequest := hasFormRequest .APISpec}}
package mcp
@@ -9,6 +10,9 @@ import (
"context"
"encoding/json"
"fmt"
+{{- if $hasFormRequest}}
+ "net/url"
+{{- end}}
"os"
"path/filepath"
"strings"
@@ -180,7 +184,7 @@ type mcpParamBinding struct {
PublicName string
WireName string
Location string
-{{- if $hasMultipartRequest}}
+{{- if or $hasMultipartRequest $hasFormRequest}}
Format string
RequestContentType string
{{- end}}
@@ -198,6 +202,18 @@ func mcpMultipartFieldValue(v any) string {
}
{{- end}}
+{{- if $hasFormRequest}}
+func mcpFormFieldValue(v any) string {
+ if s, ok := v.(string); ok {
+ return s
+ }
+ if data, err := json.Marshal(v); err == nil {
+ return string(data)
+ }
+ return fmt.Sprintf("%v", v)
+}
+{{- end}}
+
// makeAPIHandler creates a generic MCP tool handler for an API endpoint.
{{- if .HasTierRouting}}
func makeAPIHandler(method, pathTemplate, tier string, bindings []mcpParamBinding, positionalParams []string) server.ToolHandlerFunc {
@@ -230,6 +246,10 @@ func makeAPIHandler(method, pathTemplate string, bindings []mcpParamBinding, pos
multipartFields := make(map[string]string)
multipartFileFields := make(map[string]string)
multipart := false
+{{- end}}
+{{- if $hasFormRequest}}
+ formFields := url.Values{}
+ formEncoded := false
{{- end}}
for _, binding := range bindings {
knownArgs[binding.PublicName] = true
@@ -237,6 +257,11 @@ func makeAPIHandler(method, pathTemplate string, bindings []mcpParamBinding, pos
if strings.EqualFold(binding.RequestContentType, "multipart/form-data") {
multipart = true
}
+{{- end}}
+{{- if $hasFormRequest}}
+ if strings.EqualFold(binding.RequestContentType, "application/x-www-form-urlencoded") {
+ formEncoded = true
+ }
{{- end}}
v, ok := args[binding.PublicName]
if !ok {
@@ -257,6 +282,11 @@ func makeAPIHandler(method, pathTemplate string, bindings []mcpParamBinding, pos
multipartFields[binding.WireName] = mcpMultipartFieldValue(v)
}
}
+{{- end}}
+{{- if $hasFormRequest}}
+ if formEncoded {
+ formFields.Set(binding.WireName, mcpFormFieldValue(v))
+ }
{{- end}}
default:
params[binding.WireName] = fmt.Sprintf("%v", v)
@@ -284,6 +314,11 @@ func makeAPIHandler(method, pathTemplate string, bindings []mcpParamBinding, pos
if multipart {
multipartFields[k] = mcpMultipartFieldValue(v)
}
+{{- end}}
+{{- if $hasFormRequest}}
+ if formEncoded {
+ formFields.Set(k, mcpFormFieldValue(v))
+ }
{{- end}}
default:
params[k] = fmt.Sprintf("%v", v)
@@ -300,6 +335,12 @@ func makeAPIHandler(method, pathTemplate string, bindings []mcpParamBinding, pos
data, _, err = c.PostMultipart(path, multipartFields, multipartFileFields)
break
}
+{{- end}}
+{{- if $hasFormRequest}}
+ if formEncoded {
+ data, _, err = c.PostForm(path, formFields)
+ break
+ }
{{- end}}
body, _ := json.Marshal(bodyArgs)
data, _, err = c.Post(path, body)
@@ -309,6 +350,12 @@ func makeAPIHandler(method, pathTemplate string, bindings []mcpParamBinding, pos
data, _, err = c.PutMultipart(path, multipartFields, multipartFileFields)
break
}
+{{- end}}
+{{- if $hasFormRequest}}
+ if formEncoded {
+ data, _, err = c.PutForm(path, formFields)
+ break
+ }
{{- end}}
body, _ := json.Marshal(bodyArgs)
data, _, err = c.Put(path, body)
@@ -318,6 +365,12 @@ func makeAPIHandler(method, pathTemplate string, bindings []mcpParamBinding, pos
data, _, err = c.PatchMultipart(path, multipartFields, multipartFileFields)
break
}
+{{- end}}
+{{- if $hasFormRequest}}
+ if formEncoded {
+ data, _, err = c.PatchForm(path, formFields)
+ break
+ }
{{- end}}
body, _ := json.Marshal(bodyArgs)
data, _, err = c.Patch(path, body)
diff --git a/internal/openapi/parser_test.go b/internal/openapi/parser_test.go
index e99256e6..f76d1d79 100644
--- a/internal/openapi/parser_test.go
+++ b/internal/openapi/parser_test.go
@@ -4572,6 +4572,100 @@ paths:
assert.True(t, byName["filename"].Required)
}
+func TestParseFormUrlencodedRequestBodyPreservesContentType(t *testing.T) {
+ t.Parallel()
+ data := []byte(`
+openapi: 3.0.3
+info:
+ title: OAuth API
+ version: 1.0.0
+servers:
+ - url: https://api.example.com
+paths:
+ /oauth/token:
+ post:
+ operationId: exchangeToken
+ summary: Exchange OAuth token
+ requestBody:
+ required: true
+ content:
+ application/x-www-form-urlencoded:
+ schema:
+ type: object
+ required: [grant_type, client_id]
+ properties:
+ grant_type:
+ type: string
+ client_id:
+ type: string
+ client_secret:
+ type: string
+ refresh_token:
+ type: string
+ responses:
+ "200":
+ description: ok
+`)
+
+ parsed, err := Parse(data)
+ require.NoError(t, err)
+
+ endpoint := findParsedEndpointByPath(t, parsed, "POST", "/oauth/token")
+ assert.Equal(t, "application/x-www-form-urlencoded", endpoint.RequestContentType)
+ require.Len(t, endpoint.Body, 4)
+ byName := map[string]spec.Param{}
+ for _, param := range endpoint.Body {
+ byName[param.Name] = param
+ }
+ assert.True(t, byName["grant_type"].Required)
+ assert.True(t, byName["client_id"].Required)
+ assert.False(t, byName["client_secret"].Required)
+}
+
+// TestParseJSONPreferredOverFormUrlencoded asserts the parser still picks
+// application/json when the spec offers both content types — keeping JSON-
+// declared specs byte-identical and letting form-only OAuth/legacy endpoints
+// surface their wire shape.
+func TestParseJSONPreferredOverFormUrlencoded(t *testing.T) {
+ t.Parallel()
+ data := []byte(`
+openapi: 3.0.3
+info:
+ title: Multi Content API
+ version: 1.0.0
+servers:
+ - url: https://api.example.com
+paths:
+ /items:
+ post:
+ operationId: createItem
+ requestBody:
+ required: true
+ content:
+ application/x-www-form-urlencoded:
+ schema:
+ type: object
+ properties:
+ name:
+ type: string
+ application/json:
+ schema:
+ type: object
+ properties:
+ name:
+ type: string
+ responses:
+ "201":
+ description: created
+`)
+
+ parsed, err := Parse(data)
+ require.NoError(t, err)
+
+ endpoint := findParsedEndpointByPath(t, parsed, "POST", "/items")
+ assert.Equal(t, "application/json", endpoint.RequestContentType)
+}
+
func findParsedEndpointByPath(t *testing.T, parsed *spec.APISpec, method, path string) spec.Endpoint {
t.Helper()
for _, resource := range parsed.Resources {
← 6cd57cc2 fix(cli): infer resource-prefixed IDField from item-schema p
·
back to Cli Printing Press
·
fix(cli): isolate generic resources by type (#901) ff755316 →