From a237aa21bf43d7e38612d54ecfdc405a2c93f3d8 Mon Sep 17 00:00:00 2001 From: samzong Date: Fri, 26 Jun 2026 12:16:45 -0400 Subject: [PATCH] fix(runtime): honor request body media types Signed-off-by: samzong --- internal/codegen/backends/openapi3/backend.go | 1 + .../request-body-non-json.golden.json | 1 + pkg/runtime/build.go | 15 +++++ pkg/runtime/build_test.go | 66 +++++++++++++++++++ 4 files changed, 83 insertions(+) diff --git a/internal/codegen/backends/openapi3/backend.go b/internal/codegen/backends/openapi3/backend.go index a9f1a77..f5270e4 100644 --- a/internal/codegen/backends/openapi3/backend.go +++ b/internal/codegen/backends/openapi3/backend.go @@ -290,6 +290,7 @@ func convertOp(op *operation, method, path string, pathParams []parameter, globa mediaTypes = append(mediaTypes, ct) } sort.Strings(mediaTypes) + out.RequestBody.MediaType = mediaTypes[0] out.RequestBody.Schema = convertSchema(op.RequestBody.Content[mediaTypes[0]].Schema) } } diff --git a/internal/codegen/backends/openapi3/testdata/request-body-non-json.golden.json b/internal/codegen/backends/openapi3/testdata/request-body-non-json.golden.json index 3457d85..c66bfba 100644 --- a/internal/codegen/backends/openapi3/testdata/request-body-non-json.golden.json +++ b/internal/codegen/backends/openapi3/testdata/request-body-non-json.golden.json @@ -11,6 +11,7 @@ "Parameters": null, "RequestBody": { "Required": true, + "MediaType": "application/xml", "Schema": { "Ref": "", "Type": "object", diff --git a/pkg/runtime/build.go b/pkg/runtime/build.go index 5c1c915..d4d8b56 100644 --- a/pkg/runtime/build.go +++ b/pkg/runtime/build.go @@ -231,6 +231,9 @@ func buildCmd(s CommandSpec) *cobra.Command { } else if s.RequestBody != nil { switch { case cmd.Flags().Changed("set") || cmd.Flags().Changed("set-str"): + if !supportsJSONBodyBuilder(s.RequestBody.MediaType) { + return fmt.Errorf("request body media type %s requires --file; --set and --set-str only support JSON request bodies", s.RequestBody.MediaType) + } raw, berr := buildBodyFromSet(bodySets, bodyStringSets) if berr != nil { return berr @@ -243,12 +246,18 @@ func buildCmd(s CommandSpec) *cobra.Command { } body = raw case s.RequestBody.Required: + if !supportsJSONBodyBuilder(s.RequestBody.MediaType) { + return fmt.Errorf("request body media type %s requires --file", s.RequestBody.MediaType) + } return fmt.Errorf("request body required: pass --file, --set, or --set-str") } } if err := validateRequiredVariableParams(s, body); err != nil { return err } + if body != nil && s.RequestBody != nil && s.RequestBody.MediaType != "" { + hdrs["Content-Type"] = s.RequestBody.MediaType + } if v, err := cmd.Root().PersistentFlags().GetBool("debug"); err == nil && v { clientOpts.Debug = true @@ -389,6 +398,12 @@ func buildCmd(s CommandSpec) *cobra.Command { return cmd } +func supportsJSONBodyBuilder(mediaType string) bool { + mt, _, _ := strings.Cut(strings.ToLower(strings.TrimSpace(mediaType)), ";") + mt = strings.TrimSpace(mt) + return mt == "" || mt == "application/json" || strings.HasSuffix(mt, "+json") +} + func addSafeInputFlags(cmd *cobra.Command, p ParamSpec) { if !isSensitiveStringParam(p) { return diff --git a/pkg/runtime/build_test.go b/pkg/runtime/build_test.go index 542284a..be89a74 100644 --- a/pkg/runtime/build_test.go +++ b/pkg/runtime/build_test.go @@ -243,6 +243,72 @@ func TestBuild_SetStrSendsStringBodyFields(t *testing.T) { } } +func TestBuild_FileSendsRequestBodyMediaType(t *testing.T) { + bindTestManifest(t, "myctl", "MYCTL_HOST") + t.Setenv("MYCTL_CONFIG_DIR", t.TempDir()) + + var gotContentType string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotContentType = r.Header.Get("Content-Type") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{}`)) + })) + defer srv.Close() + + bodyFile := t.TempDir() + "/body.txt" + if err := os.WriteFile(bodyFile, []byte("hello"), 0600); err != nil { + t.Fatalf("write body file: %v", err) + } + + root := newRootWithModuleGroup() + root.PersistentFlags().String("hostname", "", "") + root.PersistentFlags().StringP("output", "o", "raw", "") + Build(root, "demo", []CommandSpec{{ + Group: "Exports", + Use: "create-export", + Method: "POST", + PathTpl: "/exports", + RequestBody: &RequestBody{Required: true, MediaType: "text/plain"}, + Security: &SecurityHint{Public: true}, + }}) + root.SetArgs([]string{"--hostname", srv.URL, "demo", "exports", "create-export", "--file", bodyFile}) + + if err := root.Execute(); err != nil { + t.Fatalf("Execute: %v", err) + } + if gotContentType != "text/plain" { + t.Errorf("Content-Type = %q, want text/plain", gotContentType) + } +} + +func TestBuild_NonJSONRequestBodyRequiresFile(t *testing.T) { + for _, args := range [][]string{ + {"--set", "id=1"}, + nil, + } { + bindTestManifest(t, "myctl", "MYCTL_HOST") + t.Setenv("MYCTL_CONFIG_DIR", t.TempDir()) + + root := newRootWithModuleGroup() + root.PersistentFlags().String("hostname", "", "") + root.PersistentFlags().StringP("output", "o", "raw", "") + Build(root, "demo", []CommandSpec{{ + Group: "Exports", + Use: "create-export", + Method: "POST", + PathTpl: "/exports", + RequestBody: &RequestBody{Required: true, MediaType: "text/plain"}, + Security: &SecurityHint{Public: true}, + }}) + root.SetArgs(append([]string{"--hostname", "http://127.0.0.1:1", "demo", "exports", "create-export"}, args...)) + + err := root.Execute() + if err == nil || !strings.Contains(err.Error(), "requires --file") { + t.Fatalf("Execute error = %v, want requires --file", err) + } + } +} + func TestBuild_VariableFlagsMergeIntoEnvelope(t *testing.T) { root, url, recorded := newRecordingGraphQLRoot(t, createAppSpec()) root.SetArgs([]string{"--hostname", url, "demo", "apps", "create-app", "--input-name", "demo"})