← back to Cli Printing Press
fix(cli): emit multipart requests for upload endpoints (#904)
fc291cae1938593d86e4abac349a9a2117ced0a0 · 2026-05-10 10:47:16 -0700 · Trevin Chow
* fix(cli): emit multipart requests for upload endpoints
* fix(cli): address multipart review feedback
Files touched
M internal/generator/generator.goA internal/generator/multipart_test.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.goM internal/openapi/parser_test.goM internal/spec/spec.go
Diff
commit fc291cae1938593d86e4abac349a9a2117ced0a0
Author: Trevin Chow <trevin@trevinchow.com>
Date: Sun May 10 10:47:16 2026 -0700
fix(cli): emit multipart requests for upload endpoints (#904)
* fix(cli): emit multipart requests for upload endpoints
* fix(cli): address multipart review feedback
---
internal/generator/generator.go | 122 ++++++++++++++++++---
internal/generator/multipart_test.go | 89 +++++++++++++++
internal/generator/templates/client.go.tmpl | 101 +++++++++++++++++
.../generator/templates/command_endpoint.go.tmpl | 50 ++++++++-
.../generator/templates/command_promoted.go.tmpl | 8 ++
internal/generator/templates/mcp_tools.go.tmpl | 67 ++++++++++-
internal/openapi/parser.go | 83 +++++++-------
internal/openapi/parser_test.go | 49 +++++++++
internal/spec/spec.go | 31 +++---
9 files changed, 524 insertions(+), 76 deletions(-)
diff --git a/internal/generator/generator.go b/internal/generator/generator.go
index b9f8bc95..bef9af5c 100644
--- a/internal/generator/generator.go
+++ b/internal/generator/generator.go
@@ -305,14 +305,17 @@ func New(s *spec.APISpec, outputDir string) *Generator {
}
return " (one of: " + strings.Join(values, ", ") + ")"
},
- "jsonStringParam": isJSONStringParam,
- "jsonEnumSuggestion": jsonEnumSuggestion,
- "bodyMap": bodyMap,
- "publicFlagName": publicFlagName,
- "publicFlagAliases": publicFlagAliases,
- "flagChangedExpr": flagChangedExpr,
- "mcpInputName": mcpInputName,
- "mcpParamBindings": mcpParamBindings,
+ "jsonStringParam": isJSONStringParam,
+ "jsonEnumSuggestion": jsonEnumSuggestion,
+ "bodyMap": bodyMap,
+ "multipartBodyMaps": multipartBodyMaps,
+ "endpointUsesMultipart": endpointUsesMultipart,
+ "hasMultipartRequest": hasMultipartRequest,
+ "publicFlagName": publicFlagName,
+ "publicFlagAliases": publicFlagAliases,
+ "flagChangedExpr": flagChangedExpr,
+ "mcpInputName": mcpInputName,
+ "mcpParamBindings": mcpParamBindings,
// endpointNeedsClientLimit reports whether a list endpoint needs
// client-side truncation. True when the endpoint has a `limit`-named
// param AND no Pagination block — the spec author asked for a
@@ -642,6 +645,7 @@ type authTemplateData struct {
type clientTemplateData struct {
*spec.APISpec
HasGraphQLPersistedQueries bool
+ HasMultipartRequest bool
// Populated by Generator.shouldEmitAuth() so this template gate stays in
// sync with auth.go emission, root.go registration, and scoreAuth.
HasAuthCommand bool
@@ -1430,6 +1434,7 @@ func (g *Generator) renderSingleFiles() error {
data = &clientTemplateData{
APISpec: g.Spec,
HasGraphQLPersistedQueries: g.hasTrafficAnalysisHint("graphql_persisted_query"),
+ HasMultipartRequest: hasMultipartRequest(g.Spec),
HasAuthCommand: g.shouldEmitAuth(),
}
case "config.go.tmpl":
@@ -3013,9 +3018,11 @@ type jsonFlagSuggestion struct {
}
type mcpParamBinding struct {
- PublicName string
- WireName string
- Location string
+ PublicName string
+ WireName string
+ Location string
+ Format string
+ RequestContentType string
}
func flagChangedExpr(p spec.Param) string {
@@ -3032,27 +3039,41 @@ 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) {
+ requestContentType = endpoint.RequestContentType
+ }
for _, p := range endpoint.Params {
loc := "query"
if strings.Contains(pathTemplate, "{"+p.Name+"}") {
loc = "path"
}
bindings = append(bindings, mcpParamBinding{
- PublicName: p.PublicInputName(),
- WireName: p.Name,
- Location: loc,
+ PublicName: p.PublicInputName(),
+ WireName: p.Name,
+ Location: loc,
+ RequestContentType: requestContentType,
})
}
for _, p := range endpoint.Body {
bindings = append(bindings, mcpParamBinding{
- PublicName: p.PublicInputName(),
- WireName: p.Name,
- Location: "body",
+ PublicName: p.PublicInputName(),
+ WireName: p.Name,
+ Location: "body",
+ Format: multipartBindingFormat(endpoint, p),
+ RequestContentType: requestContentType,
})
}
return bindings
}
+func multipartBindingFormat(endpoint spec.Endpoint, p spec.Param) string {
+ if !endpointUsesMultipart(endpoint) {
+ return ""
+ }
+ return p.Format
+}
+
func mcpInputName(p spec.Param) string {
return p.PublicInputName()
}
@@ -3129,6 +3150,73 @@ func bodyMap(body []spec.Param, indent string) string {
return b.String()
}
+func multipartBodyMaps(body []spec.Param, indent string) string {
+ var b strings.Builder
+ for _, p := range body {
+ id := paramIdent(p)
+ ident := toCamel(id)
+ flag := publicFlagName(p)
+ if isComplexMultipartField(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[%q] = body%s\n", indent, p.Name, ident)
+ fmt.Fprintf(&b, "%s}\n", indent)
+ continue
+ }
+ if isBinaryParam(p) {
+ fmt.Fprintf(&b, "%sif body%s != \"\" {\n", indent, ident)
+ fmt.Fprintf(&b, "%s\tfileFields[%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[%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[%q] = fmt.Sprintf(\"%%v\", body%s)\n", indent, p.Name, ident)
+ fmt.Fprintf(&b, "%s}\n", indent)
+ }
+ return b.String()
+}
+
+func endpointUsesMultipart(endpoint spec.Endpoint) bool {
+ return strings.EqualFold(strings.TrimSpace(endpoint.RequestContentType), "multipart/form-data")
+}
+
+func hasMultipartRequest(apiSpec *spec.APISpec) bool {
+ if apiSpec == nil {
+ return false
+ }
+ var walk func(resources map[string]spec.Resource) bool
+ walk = func(resources map[string]spec.Resource) bool {
+ for _, resource := range resources {
+ for _, endpoint := range resource.Endpoints {
+ if endpointUsesMultipart(endpoint) {
+ return true
+ }
+ }
+ if walk(resource.SubResources) {
+ return true
+ }
+ }
+ return false
+ }
+ return walk(apiSpec.Resources)
+}
+
+func isBinaryParam(p spec.Param) bool {
+ return strings.EqualFold(strings.TrimSpace(p.Format), "binary")
+}
+
+func isComplexMultipartField(p spec.Param) bool {
+ return p.Type == "object" || p.Type == "array"
+}
+
func isJSONStringParam(p spec.Param) bool {
if p.Type != "string" {
return false
diff --git a/internal/generator/multipart_test.go b/internal/generator/multipart_test.go
new file mode 100644
index 00000000..d0544fb1
--- /dev/null
+++ b/internal/generator/multipart_test.go
@@ -0,0 +1,89 @@
+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"
+)
+
+func TestGenerateMultipartRequestBodyUsesMultipartClient(t *testing.T) {
+ t.Parallel()
+
+ apiSpec := minimalSpec("uploadapi")
+ apiSpec.Resources = map[string]spec.Resource{
+ "assets": {
+ Description: "Manage assets",
+ Endpoints: map[string]spec.Endpoint{
+ "list": {
+ Method: "GET",
+ Path: "/assets",
+ Description: "List assets",
+ },
+ "upload": {
+ Method: "POST",
+ Path: "/assets",
+ Description: "Upload an asset",
+ RequestContentType: "multipart/form-data",
+ Body: []spec.Param{
+ {Name: "assetData", Type: "string", Format: "binary", Required: true, Description: "Asset file"},
+ {Name: "filename", Type: "string", Required: true, Description: "File name"},
+ {Name: "metadata", Type: "object", Description: "Metadata as JSON"},
+ },
+ },
+ },
+ },
+ "avatars": {
+ Description: "Manage avatars",
+ Endpoints: map[string]spec.Endpoint{
+ "upload": {
+ Method: "POST",
+ Path: "/avatars",
+ Description: "Upload an avatar",
+ RequestContentType: "multipart/form-data",
+ Body: []spec.Param{
+ {Name: "image", Type: "string", Format: "binary", Required: true, Description: "Avatar image"},
+ {Name: "label", Type: "string", Description: "Label"},
+ },
+ },
+ },
+ },
+ }
+
+ 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) PostMultipart(path string, fields map[string]string, fileFields map[string]string) (json.RawMessage, int, error)`)
+ assert.Contains(t, clientSrc, `writer.CreateFormFile(fieldName, filepath.Base(filePath))`)
+ assert.Contains(t, clientSrc, `req.Header.Set("Content-Type", contentType)`)
+
+ endpointSrc := readGeneratedFile(t, outputDir, "internal", "cli", "assets_upload.go")
+ assert.Contains(t, endpointSrc, `return fmt.Errorf("required flag \"%s\" not set", "asset-data")`)
+ assert.Contains(t, endpointSrc, `return fmt.Errorf("required flag \"%s\" not set", "filename")`)
+ assert.Contains(t, endpointSrc, `fileFields["assetData"] = bodyAssetData`)
+ assert.Contains(t, endpointSrc, `fields["filename"] = bodyFilename`)
+ assert.Contains(t, endpointSrc, `fields["metadata"] = bodyMetadata`)
+ assert.Contains(t, endpointSrc, `c.PostMultipart(path, fields, fileFields)`)
+ assert.NotContains(t, endpointSrc, `"stdin"`)
+
+ promotedSrc := readGeneratedFile(t, outputDir, "internal", "cli", "promoted_avatars.go")
+ assert.Contains(t, promotedSrc, `return fmt.Errorf("required flag \"%s\" not set", "image")`)
+ assert.Contains(t, promotedSrc, `fileFields["image"] = bodyImage`)
+ assert.Contains(t, promotedSrc, `fields["label"] = bodyLabel`)
+ assert.Contains(t, promotedSrc, `c.PostMultipart(path, fields, fileFields)`)
+ assert.NotContains(t, promotedSrc, `"stdin"`)
+
+ mcpSrc := readGeneratedFile(t, outputDir, "internal", "mcp", "tools.go")
+ assert.Contains(t, mcpSrc, `makeAPIHandler("POST", "/assets", []mcpParamBinding`)
+ assert.Contains(t, mcpSrc, `Format: "binary"`)
+ assert.Contains(t, mcpSrc, `RequestContentType: "multipart/form-data"`)
+ assert.Contains(t, mcpSrc, `multipartFileFields[binding.WireName] = fmt.Sprintf("%v", v)`)
+ assert.Contains(t, mcpSrc, `data, _, err = c.PostMultipart(path, multipartFields, multipartFileFields)`)
+
+ runGoCommand(t, outputDir, "mod", "tidy")
+ runGoCommand(t, outputDir, "build", "./...")
+}
diff --git a/internal/generator/templates/client.go.tmpl b/internal/generator/templates/client.go.tmpl
index 79d40acd..f3f06812 100644
--- a/internal/generator/templates/client.go.tmpl
+++ b/internal/generator/templates/client.go.tmpl
@@ -14,6 +14,9 @@ import (
"fmt"
"io"
"math"
+{{- if .HasMultipartRequest}}
+ "mime/multipart"
+{{- end}}
"net/http"
{{- if .HasAuthCommand}}
"net/url"
@@ -451,6 +454,17 @@ func (c *Client) PostWithHeaders(path string, body any, headers map[string]strin
return c.do("POST", path, nil, body, headers)
}
+{{- if .HasMultipartRequest }}
+func (c *Client) PostMultipart(path string, fields map[string]string, fileFields map[string]string) (json.RawMessage, int, error) {
+ return c.do("POST", path, nil, multipartRequestBody{Fields: fields, FileFields: fileFields}, nil)
+}
+
+func (c *Client) PostMultipartWithHeaders(path string, fields map[string]string, fileFields map[string]string, headers map[string]string) (json.RawMessage, int, error) {
+ return c.do("POST", path, nil, multipartRequestBody{Fields: fields, FileFields: fileFields}, headers)
+}
+
+{{ end }}
+
func (c *Client) Delete(path string) (json.RawMessage, int, error) {
return c.do("DELETE", path, nil, nil, nil)
}
@@ -467,6 +481,17 @@ func (c *Client) PutWithHeaders(path string, body any, headers map[string]string
return c.do("PUT", path, nil, body, headers)
}
+{{- if .HasMultipartRequest }}
+func (c *Client) PutMultipart(path string, fields map[string]string, fileFields map[string]string) (json.RawMessage, int, error) {
+ return c.do("PUT", path, nil, multipartRequestBody{Fields: fields, FileFields: fileFields}, nil)
+}
+
+func (c *Client) PutMultipartWithHeaders(path string, fields map[string]string, fileFields map[string]string, headers map[string]string) (json.RawMessage, int, error) {
+ return c.do("PUT", path, nil, multipartRequestBody{Fields: fields, FileFields: fileFields}, headers)
+}
+
+{{ end }}
+
func (c *Client) Patch(path string, body any) (json.RawMessage, int, error) {
return c.do("PATCH", path, nil, body, nil)
}
@@ -475,6 +500,59 @@ func (c *Client) PatchWithHeaders(path string, body any, headers map[string]stri
return c.do("PATCH", path, nil, body, headers)
}
+{{- if .HasMultipartRequest }}
+func (c *Client) PatchMultipart(path string, fields map[string]string, fileFields map[string]string) (json.RawMessage, int, error) {
+ return c.do("PATCH", path, nil, multipartRequestBody{Fields: fields, FileFields: fileFields}, nil)
+}
+
+func (c *Client) PatchMultipartWithHeaders(path string, fields map[string]string, fileFields map[string]string, headers map[string]string) (json.RawMessage, int, error) {
+ return c.do("PATCH", path, nil, multipartRequestBody{Fields: fields, FileFields: fileFields}, headers)
+}
+
+type multipartRequestBody struct {
+ Fields map[string]string
+ FileFields map[string]string
+}
+
+func encodeMultipartBody(body multipartRequestBody) ([]byte, string, error) {
+ var buf bytes.Buffer
+ writer := multipart.NewWriter(&buf)
+ for fieldName, value := range body.Fields {
+ if err := writer.WriteField(fieldName, value); err != nil {
+ _ = writer.Close()
+ return nil, "", fmt.Errorf("writing multipart field %q: %w", fieldName, err)
+ }
+ }
+ for fieldName, filePath := range body.FileFields {
+ file, err := os.Open(filePath)
+ if err != nil {
+ _ = writer.Close()
+ return nil, "", fmt.Errorf("opening multipart file field %q (%q): %w", fieldName, filePath, err)
+ }
+ part, err := writer.CreateFormFile(fieldName, filepath.Base(filePath))
+ if err != nil {
+ _ = file.Close()
+ _ = writer.Close()
+ return nil, "", fmt.Errorf("creating multipart file field %q (%q): %w", fieldName, filePath, err)
+ }
+ if _, err := io.Copy(part, file); err != nil {
+ _ = file.Close()
+ _ = writer.Close()
+ return nil, "", fmt.Errorf("copying multipart file field %q (%q): %w", fieldName, filePath, err)
+ }
+ if err := file.Close(); err != nil {
+ _ = writer.Close()
+ return nil, "", fmt.Errorf("closing multipart file field %q (%q): %w", fieldName, filePath, err)
+ }
+ }
+ if err := writer.Close(); err != nil {
+ return nil, "", fmt.Errorf("finalizing multipart body: %w", err)
+ }
+ return buf.Bytes(), writer.FormDataContentType(), 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) {
@@ -543,12 +621,31 @@ func (c *Client) do(method, path string, params map[string]string, body any, hea
{{- end}}
var bodyBytes []byte
+{{- if .HasMultipartRequest}}
+ var contentType string
+{{- end}}
if body != nil {
+{{- if .HasMultipartRequest}}
+ if multipartBody, ok := body.(multipartRequestBody); ok {
+ b, ct, err := encodeMultipartBody(multipartBody)
+ if err != nil {
+ return nil, 0, err
+ }
+ bodyBytes = b
+ contentType = ct
+ } else {
+{{- end}}
b, err := json.Marshal(body)
if err != nil {
return nil, 0, fmt.Errorf("marshaling body: %w", err)
}
bodyBytes = b
+{{- if .HasMultipartRequest}}
+ contentType = "application/json"
+{{- end}}
+{{- if .HasMultipartRequest}}
+ }
+{{- end}}
}
{{- if .HasGraphQLPersistedQueries}}
bodyBytes = c.applyPersistedQueryBodyOverrides(bodyBytes)
@@ -643,7 +740,11 @@ 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}}
+ req.Header.Set("Content-Type", contentType)
+{{- else}}
req.Header.Set("Content-Type", "application/json")
+{{- end}}
}
if params != nil {
diff --git a/internal/generator/templates/command_endpoint.go.tmpl b/internal/generator/templates/command_endpoint.go.tmpl
index 27e5ccb5..8fe455fc 100644
--- a/internal/generator/templates/command_endpoint.go.tmpl
+++ b/internal/generator/templates/command_endpoint.go.tmpl
@@ -1,6 +1,7 @@
// 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.
{{- $isGraphQLEndpoint := and (isGraphQL .APISpec) (eq .Endpoint.Path "/graphql") (or (eq .EndpointName "list") (eq .EndpointName "get"))}}
+{{- $isMultipart := endpointUsesMultipart .Endpoint}}
package cli
@@ -10,7 +11,7 @@ import (
{{- end}}
"encoding/json"
"fmt"
-{{- if or (eq .Endpoint.Method "POST") (eq .Endpoint.Method "PUT") (eq .Endpoint.Method "PATCH")}}
+{{- if and (or (eq .Endpoint.Method "POST") (eq .Endpoint.Method "PUT") (eq .Endpoint.Method "PATCH")) (not $isMultipart)}}
"io"
{{- end}}
"os"
@@ -36,7 +37,7 @@ func new{{camel .FuncPrefix}}{{camel .EndpointName}}Cmd(flags *rootFlags) *cobra
{{- if .Endpoint.Pagination}}
var flagAll bool
{{- end}}
-{{- if or (eq .Endpoint.Method "POST") (eq .Endpoint.Method "PUT") (eq .Endpoint.Method "PATCH")}}
+{{- if and (or (eq .Endpoint.Method "POST") (eq .Endpoint.Method "PUT") (eq .Endpoint.Method "PATCH")) (not $isMultipart)}}
var stdinBody bool
{{- end}}
{{- if .IsAsync}}
@@ -102,6 +103,15 @@ 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}}
+{{- range .Endpoint.Body}}
+{{- if and .Required (not .Default)}}
+ if !{{flagChangedExpr .}} && !flags.dryRun {
+ return fmt.Errorf("required flag \"%s\" not set", "{{publicFlagName .}}")
+ }
+{{- end}}
+{{- end}}
+{{- else}}
if !stdinBody {
{{- range .Endpoint.Body}}
{{- if and .Required (not .Default)}}
@@ -111,6 +121,7 @@ func new{{camel .FuncPrefix}}{{camel .EndpointName}}Cmd(flags *rootFlags) *cobra
{{- end}}
{{- end}}
}
+{{- end}}
{{- end}}
c, err := flags.newClient()
if err != nil {
@@ -252,6 +263,16 @@ func new{{camel .FuncPrefix}}{{camel .EndpointName}}Cmd(flags *rootFlags) *cobra
{{- end}}
{{- end}}
{{- else if eq .Endpoint.Method "POST"}}
+{{- if $isMultipart}}
+ fields := map[string]string{}
+ fileFields := map[string]string{}
+{{multipartBodyMaps .Endpoint.Body "\t\t\t"}}
+{{- if .Endpoint.HeaderOverrides}}
+ data, statusCode, err := c.PostMultipartWithHeaders(path, fields, fileFields, headerOverrides)
+{{- else}}
+ data, statusCode, err := c.PostMultipart(path, fields, fileFields)
+{{- end}}
+{{- else}}
var body map[string]any
if stdinBody {
stdinData, err := io.ReadAll(os.Stdin)
@@ -271,6 +292,7 @@ func new{{camel .FuncPrefix}}{{camel .EndpointName}}Cmd(flags *rootFlags) *cobra
{{- else}}
data, statusCode, err := c.Post(path, body)
{{- end}}
+{{- end}}
{{- else if eq .Endpoint.Method "DELETE"}}
{{- if .Endpoint.HeaderOverrides}}
data, statusCode, err := c.DeleteWithHeaders(path, headerOverrides)
@@ -278,6 +300,16 @@ func new{{camel .FuncPrefix}}{{camel .EndpointName}}Cmd(flags *rootFlags) *cobra
data, statusCode, err := c.Delete(path)
{{- end}}
{{- else if eq .Endpoint.Method "PUT"}}
+{{- if $isMultipart}}
+ fields := map[string]string{}
+ fileFields := map[string]string{}
+{{multipartBodyMaps .Endpoint.Body "\t\t\t"}}
+{{- if .Endpoint.HeaderOverrides}}
+ data, statusCode, err := c.PutMultipartWithHeaders(path, fields, fileFields, headerOverrides)
+{{- else}}
+ data, statusCode, err := c.PutMultipart(path, fields, fileFields)
+{{- end}}
+{{- else}}
var body map[string]any
if stdinBody {
stdinData, err := io.ReadAll(os.Stdin)
@@ -297,7 +329,18 @@ func new{{camel .FuncPrefix}}{{camel .EndpointName}}Cmd(flags *rootFlags) *cobra
{{- else}}
data, statusCode, err := c.Put(path, body)
{{- end}}
+{{- end}}
{{- else if eq .Endpoint.Method "PATCH"}}
+{{- if $isMultipart}}
+ fields := map[string]string{}
+ fileFields := map[string]string{}
+{{multipartBodyMaps .Endpoint.Body "\t\t\t"}}
+{{- if .Endpoint.HeaderOverrides}}
+ data, statusCode, err := c.PatchMultipartWithHeaders(path, fields, fileFields, headerOverrides)
+{{- else}}
+ data, statusCode, err := c.PatchMultipart(path, fields, fileFields)
+{{- end}}
+{{- else}}
var body map[string]any
if stdinBody {
stdinData, err := io.ReadAll(os.Stdin)
@@ -318,6 +361,7 @@ func new{{camel .FuncPrefix}}{{camel .EndpointName}}Cmd(flags *rootFlags) *cobra
data, statusCode, err := c.Patch(path, body)
{{- end}}
{{- end}}
+{{- end}}
{{- if eq .Endpoint.Method "DELETE"}}
if err != nil {
return classifyDeleteError(err, flags)
@@ -522,7 +566,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 or (eq .Endpoint.Method "POST") (eq .Endpoint.Method "PUT") (eq .Endpoint.Method "PATCH")}}
+{{- if and (or (eq .Endpoint.Method "POST") (eq .Endpoint.Method "PUT") (eq .Endpoint.Method "PATCH")) (not $isMultipart)}}
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 3a9bab1e..1aebe721 100644
--- a/internal/generator/templates/command_promoted.go.tmpl
+++ b/internal/generator/templates/command_promoted.go.tmpl
@@ -1,5 +1,6 @@
// 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}}
package cli
@@ -174,12 +175,19 @@ func new{{camel .PromotedName}}PromotedCmd(flags *rootFlags) *cobra.Command {
{{- else if eq (upper .Endpoint.Method) "DELETE"}}
data, _, err := c.Delete(path)
{{- else if or (eq (upper .Endpoint.Method) "POST") (eq (upper .Endpoint.Method) "PUT") (eq (upper .Endpoint.Method) "PATCH")}}
+{{- if $isMultipart}}
+ fields := map[string]string{}
+ fileFields := map[string]string{}
+{{multipartBodyMaps .Endpoint.Body "\t\t\t"}}
+ data, _, err := c.{{pascal (lower .Endpoint.Method)}}Multipart(path, fields, fileFields)
+{{- else}}
// HasStore + non-GET falls through to a live API call here
// rather than through resolveRead (GET-only internally); a
// body-aware cached read helper is filed as #425 for when a
// second store-backed POST-search consumer ships.
body := map[string]any{}
{{bodyMap .Endpoint.Body "\t\t\t"}} data, _, err := c.{{pascal (lower .Endpoint.Method)}}(path, body)
+{{- end}}
{{- else}}
// HEAD/OPTIONS and unknown verbs fall back to GET so generation
// stays compileable. Spec authors who genuinely need these verbs
diff --git a/internal/generator/templates/mcp_tools.go.tmpl b/internal/generator/templates/mcp_tools.go.tmpl
index 831f8f2f..1e68eecd 100644
--- a/internal/generator/templates/mcp_tools.go.tmpl
+++ b/internal/generator/templates/mcp_tools.go.tmpl
@@ -1,6 +1,7 @@
// 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.
{{- $canonicalEnvVar := .Auth.CanonicalEnvVar}}
+{{- $hasMultipartRequest := hasMultipartRequest .APISpec}}
package mcp
@@ -73,9 +74,9 @@ func RegisterTools(s *server.MCPServer) {
{{- end}}
),
{{- if $.HasTierRouting}}
- makeAPIHandler({{printf "%q" (upper $endpoint.Method)}}, {{printf "%q" (effectiveEndpointPath $resource $endpoint)}}, {{printf "%q" (effectiveTier $.APISpec $resource $endpoint)}}, []mcpParamBinding{ {{- range mcpParamBindings $endpoint (effectiveEndpointPath $resource $endpoint)}}{PublicName: {{printf "%q" .PublicName}}, WireName: {{printf "%q" .WireName}}, Location: {{printf "%q" .Location}}},{{end}} }, []string{ {{- range $endpoint.Params}}{{if or .Positional .PathParam}}{{printf "%q" .Name}},{{end}}{{end}} }),
+ makeAPIHandler({{printf "%q" (upper $endpoint.Method)}}, {{printf "%q" (effectiveEndpointPath $resource $endpoint)}}, {{printf "%q" (effectiveTier $.APISpec $resource $endpoint)}}, []mcpParamBinding{ {{- range mcpParamBindings $endpoint (effectiveEndpointPath $resource $endpoint)}}{PublicName: {{printf "%q" .PublicName}}, WireName: {{printf "%q" .WireName}}, Location: {{printf "%q" .Location}}{{if .Format}}, Format: {{printf "%q" .Format}}{{end}}{{if .RequestContentType}}, RequestContentType: {{printf "%q" .RequestContentType}}{{end}}},{{end}} }, []string{ {{- range $endpoint.Params}}{{if or .Positional .PathParam}}{{printf "%q" .Name}},{{end}}{{end}} }),
{{- else}}
- makeAPIHandler({{printf "%q" (upper $endpoint.Method)}}, {{printf "%q" (effectiveEndpointPath $resource $endpoint)}}, []mcpParamBinding{ {{- range mcpParamBindings $endpoint (effectiveEndpointPath $resource $endpoint)}}{PublicName: {{printf "%q" .PublicName}}, WireName: {{printf "%q" .WireName}}, Location: {{printf "%q" .Location}}},{{end}} }, []string{ {{- range $endpoint.Params}}{{if or .Positional .PathParam}}{{printf "%q" .Name}},{{end}}{{end}} }),
+ makeAPIHandler({{printf "%q" (upper $endpoint.Method)}}, {{printf "%q" (effectiveEndpointPath $resource $endpoint)}}, []mcpParamBinding{ {{- range mcpParamBindings $endpoint (effectiveEndpointPath $resource $endpoint)}}{PublicName: {{printf "%q" .PublicName}}, WireName: {{printf "%q" .WireName}}, Location: {{printf "%q" .Location}}{{if .Format}}, Format: {{printf "%q" .Format}}{{end}}{{if .RequestContentType}}, RequestContentType: {{printf "%q" .RequestContentType}}{{end}}},{{end}} }, []string{ {{- range $endpoint.Params}}{{if or .Positional .PathParam}}{{printf "%q" .Name}},{{end}}{{end}} }),
{{- end}}
)
{{- end}}
@@ -117,9 +118,9 @@ func RegisterTools(s *server.MCPServer) {
{{- end}}
),
{{- if $.HasTierRouting}}
- makeAPIHandler({{printf "%q" (upper $endpoint.Method)}}, {{printf "%q" (effectiveSubEndpointPath $resource $subResource $endpoint)}}, {{printf "%q" (effectiveSubTier $.APISpec $resource $subResource $endpoint)}}, []mcpParamBinding{ {{- range mcpParamBindings $endpoint (effectiveSubEndpointPath $resource $subResource $endpoint)}}{PublicName: {{printf "%q" .PublicName}}, WireName: {{printf "%q" .WireName}}, Location: {{printf "%q" .Location}}},{{end}} }, []string{ {{- range $endpoint.Params}}{{if or .Positional .PathParam}}{{printf "%q" .Name}},{{end}}{{end}} }),
+ makeAPIHandler({{printf "%q" (upper $endpoint.Method)}}, {{printf "%q" (effectiveSubEndpointPath $resource $subResource $endpoint)}}, {{printf "%q" (effectiveSubTier $.APISpec $resource $subResource $endpoint)}}, []mcpParamBinding{ {{- range mcpParamBindings $endpoint (effectiveSubEndpointPath $resource $subResource $endpoint)}}{PublicName: {{printf "%q" .PublicName}}, WireName: {{printf "%q" .WireName}}, Location: {{printf "%q" .Location}}{{if .Format}}, Format: {{printf "%q" .Format}}{{end}}{{if .RequestContentType}}, RequestContentType: {{printf "%q" .RequestContentType}}{{end}}},{{end}} }, []string{ {{- range $endpoint.Params}}{{if or .Positional .PathParam}}{{printf "%q" .Name}},{{end}}{{end}} }),
{{- else}}
- makeAPIHandler({{printf "%q" (upper $endpoint.Method)}}, {{printf "%q" (effectiveSubEndpointPath $resource $subResource $endpoint)}}, []mcpParamBinding{ {{- range mcpParamBindings $endpoint (effectiveSubEndpointPath $resource $subResource $endpoint)}}{PublicName: {{printf "%q" .PublicName}}, WireName: {{printf "%q" .WireName}}, Location: {{printf "%q" .Location}}},{{end}} }, []string{ {{- range $endpoint.Params}}{{if or .Positional .PathParam}}{{printf "%q" .Name}},{{end}}{{end}} }),
+ makeAPIHandler({{printf "%q" (upper $endpoint.Method)}}, {{printf "%q" (effectiveSubEndpointPath $resource $subResource $endpoint)}}, []mcpParamBinding{ {{- range mcpParamBindings $endpoint (effectiveSubEndpointPath $resource $subResource $endpoint)}}{PublicName: {{printf "%q" .PublicName}}, WireName: {{printf "%q" .WireName}}, Location: {{printf "%q" .Location}}{{if .Format}}, Format: {{printf "%q" .Format}}{{end}}{{if .RequestContentType}}, RequestContentType: {{printf "%q" .RequestContentType}}{{end}}},{{end}} }, []string{ {{- range $endpoint.Params}}{{if or .Positional .PathParam}}{{printf "%q" .Name}},{{end}}{{end}} }),
{{- end}}
)
{{- end}}
@@ -179,7 +180,23 @@ type mcpParamBinding struct {
PublicName string
WireName string
Location string
+{{- if $hasMultipartRequest}}
+ Format string
+ RequestContentType string
+{{- end}}
+}
+
+{{- if $hasMultipartRequest}}
+func mcpMultipartFieldValue(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}}
@@ -209,8 +226,18 @@ func makeAPIHandler(method, pathTemplate string, bindings []mcpParamBinding, pos
pathParams := make(map[string]bool, len(positionalParams))
params := make(map[string]string)
bodyArgs := make(map[string]any)
+{{- if $hasMultipartRequest}}
+ multipartFields := make(map[string]string)
+ multipartFileFields := make(map[string]string)
+ multipart := false
+{{- end}}
for _, binding := range bindings {
knownArgs[binding.PublicName] = true
+{{- if $hasMultipartRequest}}
+ if strings.EqualFold(binding.RequestContentType, "multipart/form-data") {
+ multipart = true
+ }
+{{- end}}
v, ok := args[binding.PublicName]
if !ok {
continue
@@ -222,6 +249,15 @@ func makeAPIHandler(method, pathTemplate string, bindings []mcpParamBinding, pos
path = strings.Replace(path, placeholder, fmt.Sprintf("%v", v), 1)
case "body":
bodyArgs[binding.WireName] = v
+{{- if $hasMultipartRequest}}
+ if multipart {
+ if strings.EqualFold(binding.Format, "binary") {
+ multipartFileFields[binding.WireName] = fmt.Sprintf("%v", v)
+ } else {
+ multipartFields[binding.WireName] = mcpMultipartFieldValue(v)
+ }
+ }
+{{- end}}
default:
params[binding.WireName] = fmt.Sprintf("%v", v)
}
@@ -244,6 +280,11 @@ func makeAPIHandler(method, pathTemplate string, bindings []mcpParamBinding, pos
switch method {
case "POST", "PUT", "PATCH":
bodyArgs[k] = v
+{{- if $hasMultipartRequest}}
+ if multipart {
+ multipartFields[k] = mcpMultipartFieldValue(v)
+ }
+{{- end}}
default:
params[k] = fmt.Sprintf("%v", v)
}
@@ -254,12 +295,30 @@ func makeAPIHandler(method, pathTemplate string, bindings []mcpParamBinding, pos
case "GET":
data, err = c.Get(path, params)
case "POST":
+{{- if $hasMultipartRequest}}
+ if multipart {
+ data, _, err = c.PostMultipart(path, multipartFields, multipartFileFields)
+ break
+ }
+{{- end}}
body, _ := json.Marshal(bodyArgs)
data, _, err = c.Post(path, body)
case "PUT":
+{{- if $hasMultipartRequest}}
+ if multipart {
+ data, _, err = c.PutMultipart(path, multipartFields, multipartFileFields)
+ break
+ }
+{{- end}}
body, _ := json.Marshal(bodyArgs)
data, _, err = c.Put(path, body)
case "PATCH":
+{{- if $hasMultipartRequest}}
+ if multipart {
+ data, _, err = c.PatchMultipart(path, multipartFields, multipartFileFields)
+ break
+ }
+{{- end}}
body, _ := json.Marshal(bodyArgs)
data, _, err = c.Patch(path, body)
case "DELETE":
diff --git a/internal/openapi/parser.go b/internal/openapi/parser.go
index e7f75398..87759170 100644
--- a/internal/openapi/parser.go
+++ b/internal/openapi/parser.go
@@ -1651,7 +1651,7 @@ func mapResources(doc *openapi3.T, out *spec.APISpec, basePath string) {
}
params := mapParameters(pathItem, op)
- body := mapRequestBody(op.RequestBody, method, path)
+ body, requestContentType := mapRequestBody(op.RequestBody, method, path)
// Deduplicate body params that collide with query/path params by flag name
if len(body) > 0 && len(params) > 0 {
@@ -1669,12 +1669,13 @@ func mapResources(doc *openapi3.T, out *spec.APISpec, basePath string) {
}
endpoint := spec.Endpoint{
- Method: strings.ToUpper(method),
- Path: path,
- BaseURL: operationServerBaseURL(out.BaseURL, pathItem, op),
- Description: description,
- Params: params,
- Body: body,
+ Method: strings.ToUpper(method),
+ Path: path,
+ BaseURL: operationServerBaseURL(out.BaseURL, pathItem, op),
+ Description: description,
+ Params: params,
+ Body: body,
+ RequestContentType: requestContentType,
}
endpoint.Tier = readTierExtension(op.Extensions, fmt.Sprintf("%s %q", strings.ToUpper(method), path))
if endpoint.Tier == "" {
@@ -2376,29 +2377,26 @@ func mergeParameters(pathItem *openapi3.PathItem, op *openapi3.Operation) []*ope
return merged
}
-func mapRequestBody(requestBodyRef *openapi3.RequestBodyRef, method, path string) []spec.Param {
+func mapRequestBody(requestBodyRef *openapi3.RequestBodyRef, method, path string) ([]spec.Param, string) {
requestBody := requestBodyValue(requestBodyRef)
if requestBody == nil || requestBody.Content == nil {
- return nil
+ return nil, ""
}
- media := requestBody.Content.Get("application/json")
- if media == nil {
- media = firstJSONMediaType(requestBody.Content)
- }
+ requestContentType, media := requestBodyMediaType(requestBody.Content)
if media == nil || media.Schema == nil || media.Schema.Value == nil {
- return nil
+ return nil, ""
}
properties := map[string]*openapi3.SchemaRef{}
required := map[string]struct{}{}
if collectAllOfProperties(media.Schema, properties, required, map[*openapi3.Schema]struct{}{}) {
warnf("skipping request body for %s %q: contains oneOf/anyOf", strings.ToUpper(method), path)
- return nil
+ return nil, ""
}
if len(properties) == 0 {
- return nil
+ return nil, ""
}
names := make([]string, 0, len(properties))
@@ -2449,7 +2447,36 @@ func mapRequestBody(requestBodyRef *openapi3.RequestBodyRef, method, path string
body = append(body, param)
}
- return body
+ return body, requestContentType
+}
+
+func requestBodyMediaType(content openapi3.Content) (string, *openapi3.MediaType) {
+ if content == nil {
+ return "", nil
+ }
+ if media := content.Get("application/json"); media != nil {
+ return "application/json", media
+ }
+
+ contentTypes := sortedContentTypes(content)
+ for _, contentType := range contentTypes {
+ if strings.Contains(strings.ToLower(contentType), "json") {
+ return contentType, content[contentType]
+ }
+ }
+ for _, contentType := range contentTypes {
+ if strings.EqualFold(contentType, "multipart/form-data") {
+ return contentType, content[contentType]
+ }
+ }
+ for _, contentType := range contentTypes {
+ media := content[contentType]
+ if media != nil && media.Schema != nil {
+ return contentType, media
+ }
+ }
+
+ return "", nil
}
func bodyParamSchema(schema *openapi3.Schema) *openapi3.Schema {
@@ -3262,31 +3289,13 @@ func refComponentName(ref string) string {
return ref[i+1:]
}
-func firstJSONMediaType(content openapi3.Content) *openapi3.MediaType {
- if content == nil {
- return nil
- }
-
+func sortedContentTypes(content openapi3.Content) []string {
contentTypes := make([]string, 0, len(content))
for contentType := range content {
contentTypes = append(contentTypes, contentType)
}
sort.Strings(contentTypes)
-
- for _, contentType := range contentTypes {
- if strings.Contains(strings.ToLower(contentType), "json") {
- return content[contentType]
- }
- }
-
- for _, contentType := range contentTypes {
- media := content[contentType]
- if media != nil && media.Schema != nil {
- return media
- }
- }
-
- return nil
+ return contentTypes
}
func resourceAndSubFromPath(path, basePath string, commonPrefix []string) (string, string) {
diff --git a/internal/openapi/parser_test.go b/internal/openapi/parser_test.go
index 3c43e5b0..1fe4eaba 100644
--- a/internal/openapi/parser_test.go
+++ b/internal/openapi/parser_test.go
@@ -4363,6 +4363,55 @@ paths:
assert.Contains(t, err.Error(), "transport")
}
+func TestParseMultipartRequestBodyPreservesContentType(t *testing.T) {
+ t.Parallel()
+ data := []byte(`
+openapi: 3.0.3
+info:
+ title: Upload API
+ version: 1.0.0
+servers:
+ - url: https://api.example.com
+paths:
+ /assets:
+ post:
+ operationId: uploadAsset
+ summary: Upload asset
+ requestBody:
+ required: true
+ content:
+ multipart/form-data:
+ schema:
+ type: object
+ required: [assetData, filename]
+ properties:
+ assetData:
+ type: string
+ format: binary
+ description: Asset file
+ filename:
+ type: string
+ description: File name
+ responses:
+ "201":
+ description: created
+`)
+
+ parsed, err := Parse(data)
+ require.NoError(t, err)
+
+ endpoint := findParsedEndpointByPath(t, parsed, "POST", "/assets")
+ assert.Equal(t, "multipart/form-data", endpoint.RequestContentType)
+ require.Len(t, endpoint.Body, 2)
+ byName := map[string]spec.Param{}
+ for _, param := range endpoint.Body {
+ byName[param.Name] = param
+ }
+ assert.Equal(t, "binary", byName["assetData"].Format)
+ assert.True(t, byName["assetData"].Required)
+ assert.True(t, byName["filename"].Required)
+}
+
func findParsedEndpointByPath(t *testing.T, parsed *spec.APISpec, method, path string) spec.Endpoint {
t.Helper()
for _, resource := range parsed.Resources {
diff --git a/internal/spec/spec.go b/internal/spec/spec.go
index 8e0cd6ae..4cf8b3a9 100644
--- a/internal/spec/spec.go
+++ b/internal/spec/spec.go
@@ -916,21 +916,22 @@ func resourceHasBaseURLOverride(resource Resource) bool {
}
type Endpoint struct {
- Method string `yaml:"method" json:"method"`
- Path string `yaml:"path" json:"path"`
- BaseURL string `yaml:"base_url,omitempty" json:"base_url,omitempty"`
- Description string `yaml:"description" json:"description"`
- Params []Param `yaml:"params" json:"params"`
- Body []Param `yaml:"body" json:"body"`
- Response ResponseDef `yaml:"response" json:"response"`
- ResponseFormat string `yaml:"response_format,omitempty" json:"response_format,omitempty"` // json (default) or html
- HTMLExtract *HTMLExtract `yaml:"html_extract,omitempty" json:"html_extract,omitempty"` // extraction options when response_format is html
- Pagination *Pagination `yaml:"pagination" json:"pagination"`
- ResponsePath string `yaml:"response_path,omitempty" json:"response_path,omitempty"` // path to extract data array from response (e.g., "data", "results.items")
- Meta map[string]string `yaml:"meta,omitempty" json:"meta,omitempty"` // per-endpoint metadata (e.g., source_tier, source_count from crowd-sniff)
- HeaderOverrides []RequiredHeader `yaml:"header_overrides,omitempty" json:"header_overrides,omitempty"` // per-endpoint header overrides (e.g., different api-version)
- NoAuth bool `yaml:"no_auth,omitempty" json:"no_auth,omitempty"` // true when the endpoint does not require authentication
- Tier string `yaml:"tier,omitempty" json:"tier,omitempty"`
+ Method string `yaml:"method" json:"method"`
+ Path string `yaml:"path" json:"path"`
+ BaseURL string `yaml:"base_url,omitempty" json:"base_url,omitempty"`
+ Description string `yaml:"description" json:"description"`
+ Params []Param `yaml:"params" json:"params"`
+ Body []Param `yaml:"body" json:"body"`
+ RequestContentType string `yaml:"request_content_type,omitempty" json:"request_content_type,omitempty"`
+ Response ResponseDef `yaml:"response" json:"response"`
+ ResponseFormat string `yaml:"response_format,omitempty" json:"response_format,omitempty"` // json (default) or html
+ HTMLExtract *HTMLExtract `yaml:"html_extract,omitempty" json:"html_extract,omitempty"` // extraction options when response_format is html
+ Pagination *Pagination `yaml:"pagination" json:"pagination"`
+ ResponsePath string `yaml:"response_path,omitempty" json:"response_path,omitempty"` // path to extract data array from response (e.g., "data", "results.items")
+ Meta map[string]string `yaml:"meta,omitempty" json:"meta,omitempty"` // per-endpoint metadata (e.g., source_tier, source_count from crowd-sniff)
+ HeaderOverrides []RequiredHeader `yaml:"header_overrides,omitempty" json:"header_overrides,omitempty"` // per-endpoint header overrides (e.g., different api-version)
+ NoAuth bool `yaml:"no_auth,omitempty" json:"no_auth,omitempty"` // true when the endpoint does not require authentication
+ Tier string `yaml:"tier,omitempty" json:"tier,omitempty"`
// IDField is the resolved primary-key field name for items returned by this
// endpoint, populated either by a path-item-level `x-resource-id` extension
// or, for OpenAPI specs, by walking the response schema (id → name → first
← 8cf5459c fix(cli): block vendor-prefix secrets during publish (#852)
·
back to Cli Printing Press
·
fix(cli): handle empty sync pages (#903) d94d89dc →