From 866f8c508dbfa85326a956c5d8da50856943a427 Mon Sep 17 00:00:00 2001 From: Tristan Cartledge Date: Fri, 2 Oct 2026 16:48:17 +1000 Subject: [PATCH 1/4] fix(cli): correct empty request body examples and flags --- .changesets/1790922409-830b3652.yaml | 10 + .../snapshots/cli_empty_body_go_test.go | 221 ++++++++++++++++++ templates/templates/cli/README.md | 2 + .../templates/cli/includes/descriptions.ts | 16 +- templates/templates/cli/includes/metadata.ts | 18 +- templates/templates/cli/includes/usage.ts | 2 +- templates/templates/cli/includes/utils.ts | 8 + 7 files changed, 271 insertions(+), 6 deletions(-) create mode 100644 .changesets/1790922409-830b3652.yaml create mode 100644 pkg/generate/snapshots/cli_empty_body_go_test.go diff --git a/.changesets/1790922409-830b3652.yaml b/.changesets/1790922409-830b3652.yaml new file mode 100644 index 00000000..e9b3c7fc --- /dev/null +++ b/.changesets/1790922409-830b3652.yaml @@ -0,0 +1,10 @@ +id: 1790922409-830b3652 +features: + - examples +targets: + - cli +type: fix +bump: patch +description: preserve CLI examples for empty and optional request bodies and omit redundant empty JSON body flags +author: TristanSpeakEasy +date: "2026-10-02" diff --git a/pkg/generate/snapshots/cli_empty_body_go_test.go b/pkg/generate/snapshots/cli_empty_body_go_test.go new file mode 100644 index 00000000..57815497 --- /dev/null +++ b/pkg/generate/snapshots/cli_empty_body_go_test.go @@ -0,0 +1,221 @@ +package snapshots + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "testing" + + "github.com/speakeasy-api/openapi-generation/v2/pkg/generate/snapshots/snaptest" + "github.com/stretchr/testify/require" +) + +const cliEmptyBodySpec = `openapi: 3.0.3 +info: + title: Widget API + version: 1.0.0 +servers: + - url: https://api.example.com +x-test-operation: &mixedOperation + description: An optional empty object body accompanies a required path parameter. + tags: [widgets] + security: [] + parameters: + - name: id + in: path + required: true + example: widget_123 + schema: + type: string + requestBody: &emptyBody + content: + application/json: + schema: + $ref: '#/components/schemas/EmptyBody' + responses: &responses + '200': + description: Accepted +paths: + /widgets/{id}/optional: + post: + <<: *mixedOperation + operationId: optional + /widgets/{id}/optionalexample: + post: + <<: *mixedOperation + operationId: optionalexample + description: An explicit empty object example does not require an optional body. + requestBody: + content: + application/json: + example: {} + schema: + $ref: '#/components/schemas/EmptyBody' + /widgets/{id}/required: + post: + <<: *mixedOperation + operationId: required + description: A required empty object body accepts an empty object without invented properties. + requestBody: + <<: *emptyBody + required: true + /widgets/standaloneoptional: + post: + <<: *mixedOperation + operationId: standaloneoptional + description: An optional empty object body is the only operation input. + parameters: [] + /widgets/standalonerequired: + post: + <<: *mixedOperation + operationId: standalonerequired + description: A required empty object body is the only operation input. + parameters: [] + requestBody: + <<: *emptyBody + required: true + /widgets/{id}/formoptional: + post: + <<: *mixedOperation + operationId: formoptional + description: An optional empty form object accompanies a required path parameter. + requestBody: &emptyFormBody + content: + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/EmptyBody' + /widgets/{id}/formrequired: + post: + <<: *mixedOperation + operationId: formrequired + description: A required empty form object accepts an empty object without invented properties. + requestBody: + <<: *emptyFormBody + required: true + /widgets/{id}/scalaroptional: + post: + <<: *mixedOperation + operationId: scalaroptional + description: An optional scalar body without an object example does not need an invented object payload. + requestBody: + content: + application/json: + schema: + type: string + /widgets/{id}/maprequired: + post: + <<: *mixedOperation + operationId: maprequired + description: A map body retains its whole-body input flag. + requestBody: + required: true + content: + application/json: + schema: + type: object + additionalProperties: + type: string + /widgets/{id}/unionrequired: + post: + <<: *mixedOperation + operationId: unionrequired + description: A union body retains its whole-body input flag and explicit example. + requestBody: + required: true + content: + application/json: + example: + label: sample + schema: + oneOf: + - type: object + required: [label] + properties: + label: + type: string + - type: string + /widgets/parameter: + get: + operationId: parameter + description: An empty object query parameter remains a JSON input rather than a body wrapper. + tags: [widgets] + security: [] + parameters: + - name: filter + in: query + schema: + $ref: '#/components/schemas/EmptyBody' + responses: *responses +components: + schemas: + EmptyBody: + description: An object with no declared properties. + type: object + properties: {} +` + +func TestSnapCLIEmptyBodyExamplesAndFlags(t *testing.T) { + for _, style := range []string{"compact", "full"} { + t.Run(style, func(t *testing.T) { + shouldCompile := false + snaptest.DoTestSnapshot(t, snaptest.Options{ + Spec: cliEmptyBodySpec, + GenYaml: fmt.Sprintf(`cli: + packageName: github.com/example/widget-cli + cliName: widget-cli + envVarPrefix: WIDGET + helpStyle: %s +`, style), + ShouldCompile: &shouldCompile, + AfterGenerate: func(t *testing.T, outputDir string) { + t.Helper() + readCommand := func(name string) string { + t.Helper() + content, err := os.ReadFile(filepath.Join(outputDir, "internal", "cli", "widgets", name+".go")) + require.NoError(t, err) + return string(content) + } + examplePattern := regexp.MustCompile(`Example:\s*"([^"\n]*)"`) + for _, name := range []string{"optional", "optionalexample", "scalaroptional", "formoptional"} { + command := readCommand(name) + require.Regexp(t, examplePattern, command) + require.Equal(t, " widget-cli widgets "+name+" --id widget_123", examplePattern.FindStringSubmatch(command)[1]) + } + for _, name := range []string{"optional", "optionalexample", "required"} { + command := readCommand(name) + require.Contains(t, command, `cmd.Flags().String("body",`) + require.NotContains(t, command, `FlagName: "body-param"`) + } + usageSource, err := os.ReadFile(filepath.Join(outputDir, "internal", "usage", "schema.go")) + require.NoError(t, err) + usagePattern := regexp.MustCompile(`"widgets (optional|optionalexample|required)":\s*"(.*)"`) + usageSchemas := usagePattern.FindAllStringSubmatch(string(usageSource), -1) + require.Len(t, usageSchemas, 3) + for _, schema := range usageSchemas { + require.Contains(t, schema[2], "--body ") + require.NotContains(t, schema[2], "--body-param") + } + for _, name := range []string{"required", "formrequired"} { + command := readCommand(name) + require.Regexp(t, examplePattern, command) + require.Equal(t, " widget-cli widgets "+name+" --id widget_123 --body '{}'", examplePattern.FindStringSubmatch(command)[1]) + } + standaloneOptional := readCommand("standaloneoptional") + require.Equal(t, " widget-cli widgets standaloneoptional", examplePattern.FindStringSubmatch(standaloneOptional)[1]) + standaloneRequired := readCommand("standalonerequired") + require.Contains(t, standaloneRequired, "--empty-body '{}'") + for _, command := range []string{standaloneOptional, standaloneRequired} { + require.Contains(t, command, `cmd.Flags().String("empty-body",`) + require.Contains(t, command, "flagutil.BuildRequestBody[") + } + require.Contains(t, readCommand("parameter"), `FlagName: "filter"`) + require.Contains(t, readCommand("parameter"), "Kind: flagutil.FlagKindJSON") + require.Contains(t, readCommand("maprequired"), `FlagName: "body-param"`) + require.Contains(t, readCommand("unionrequired"), "Kind: flagutil.FlagKindUnion") + require.Contains(t, readCommand("unionrequired"), `--body '{\"label\":\"sample\"}'`) + }, + }) + }) + } +} diff --git a/templates/templates/cli/README.md b/templates/templates/cli/README.md index beabb74a..b2354c50 100644 --- a/templates/templates/cli/README.md +++ b/templates/templates/cli/README.md @@ -1242,6 +1242,8 @@ Declared commands may add a nested `help:` map with the closed keys `defaults`, Compact operation examples suppress only generator-synthesized invocations containing its own angle-bracket value placeholders or fallback `{"key": "value"}` body. Spec examples, manifest examples, and hand-written `Example` text are never filtered. An intent with no authored example gets a bare invocation only when it has no unresolved required positional or declared flag. +In both help styles, `includes/descriptions.ts` omits invented payloads for optional, unexpanded request bodies without usable body examples, preserving examples for required parameters. Empty JSON/form request-body classes use `'{}'` when required rather than an object with invented properties. Mixed parameter/body operations with an empty JSON body register only the existing `--body` flag for that body, not a redundant `--body-param` metadata flag. The metadata and `--usage` generators share the same field filter (`includes/metadata.ts`, `includes/usage.ts`). Body-only operations retain their model-named whole-body flag, and empty non-body classes, maps, unions, and nullable/optional wrappers retain their existing input flags. + The compact layout and `Just works:` heading are runtime help contracts. Cobra markdown generation keeps its existing `Examples` heading and operation-doc footer, and the explorer continues to read `Short`, `Long`, and `Example` without a heading rewrite. Because synthesized-operation filtering happens while `templateCmdExample` builds the command's `Example`, a rejected synthesized example is absent from docs and the explorer too; authored examples remain unchanged everywhere. Custom commands that currently append Defaults/Machine/Globals prose to `Example` should migrate that prose to the annotations above to avoid a duplicate footer, set `speakeasy_help_footer: "false"` while retaining their own footer, or choose `helpStyle: full` during migration. The KDL schema is built at generation time from the command tree in `includes/usage.ts` and emitted at runtime by `usage.EmitSchema()`. It includes command names, aliases, help text, flags, defaults, config metadata, and the config file location. Generated flags include the same auto-assigned short forms as the parser and `--help` (for example, `flag "-i --id "` when `--id` receives `-i`). Shorthand assignment still reserves inherited global letters and the pagination `-a`; route-dispatch intents expose only shorthands for the non-body flags they register. diff --git a/templates/templates/cli/includes/descriptions.ts b/templates/templates/cli/includes/descriptions.ts index 21e1f6e7..3b92ffea 100644 --- a/templates/templates/cli/includes/descriptions.ts +++ b/templates/templates/cli/includes/descriptions.ts @@ -438,8 +438,12 @@ function templateCmdExample(op: Operation): string { Value: `--${flagName} '${JSON.stringify(ex.Value)}'`, SynthesizedPlaceholder: ex.Synthesized, })); - } else { - pushPart(`--${flagName} '{"key": "value"}'`, true); + } else if (!bodyField.Optional) { + if (isEmptyRequestBodyClass(bodyField)) { + pushPart(`--${flagName} '{}'`); + } else { + pushPart(`--${flagName} '{"key": "value"}'`, true); + } } } } else if (op.Request.RequestBody) { @@ -478,8 +482,12 @@ function templateCmdExample(op: Operation): string { Value: `--${flagName} '${JSON.stringify(ex.Value)}'`, SynthesizedPlaceholder: ex.Synthesized, })); - } else { - pushPart(`--${flagName} '{"key": "value"}'`, true); + } else if (!bodyField.Optional) { + if (isEmptyRequestBodyClass(bodyField)) { + pushPart(`--${flagName} '{}'`); + } else { + pushPart(`--${flagName} '{"key": "value"}'`, true); + } } } else if (bodyField && isMultipartMixedOp(op)) { // Multipart bodies have no whole-body JSON flag. Keep examples runnable diff --git a/templates/templates/cli/includes/metadata.ts b/templates/templates/cli/includes/metadata.ts index 853865cf..66acc068 100644 --- a/templates/templates/cli/includes/metadata.ts +++ b/templates/templates/cli/includes/metadata.ts @@ -48,6 +48,17 @@ function operationHasFlagMetadata(op: Operation): boolean { } registerTemplateFunc("operationHasFlagMetadata", operationHasFlagMetadata); +function getMixedRequestMetadataFields(op: Operation): FieldDef[] { + const fields = op.Request.Field.Type.Fields || []; + if (!templateHasBodyFlag(op)) return fields; + return fields.filter( + (field: FieldDef) => + getInputClassType(field) !== "JSONRequestBody" || + !isEmptyRequestBodyClass(field) || + isNullableOptionalWrapped(field), + ); +} + // buildOperationMetadataEntries constructs the FlagMeta entry strings for an // operation — the single source for both the emitted metadata var and any // validation that must mirror it (e.g. auto-shorthand collision checks). @@ -78,7 +89,12 @@ function buildOperationMetadataEntries( } else { const fields = op.Request.Field.Type.Fields; hadCandidateFields = fields.some((f: FieldDef) => !f.Const); - collectMetadataFromFields(fields, "", "", entries); + collectMetadataFromFields( + getMixedRequestMetadataFields(op), + "", + "", + entries, + ); } return { entries, hadCandidateFields }; } diff --git a/templates/templates/cli/includes/usage.ts b/templates/templates/cli/includes/usage.ts index 50ddb1af..7ae3c8e1 100644 --- a/templates/templates/cli/includes/usage.ts +++ b/templates/templates/cli/includes/usage.ts @@ -500,7 +500,7 @@ function getOperationBodyFieldUsageFlags(op: Operation): UsageFlagDef[] { walkFields(op.Request.RequestBody.Type.Fields || [], ""); } } else { - walkFields(op.Request.Field.Type.Fields || [], ""); + walkFields(getMixedRequestMetadataFields(op), ""); } return withAutoShorthands(op, flags, false); diff --git a/templates/templates/cli/includes/utils.ts b/templates/templates/cli/includes/utils.ts index 40afe16c..a6822c0c 100644 --- a/templates/templates/cli/includes/utils.ts +++ b/templates/templates/cli/includes/utils.ts @@ -57,6 +57,14 @@ function getInputClassType(field: FieldDef): InputClassType { } registerTemplateFunc("getInputClassType", getInputClassType); +function isEmptyRequestBodyClass(field: FieldDef): boolean { + const inputType = getInputClassType(field); + return ( + (inputType === "JSONRequestBody" || inputType === "FormRequestBody") && + (field.Type.Fields || []).length === 0 + ); +} + function isNullableOptionalWrapped(field: FieldDef): boolean { return Boolean( field.Nullable && From ec3cd4fb500d8abb870a6c95ef4f5f3cd7081c43 Mon Sep 17 00:00:00 2001 From: Tristan Cartledge Date: Fri, 2 Oct 2026 17:28:48 +1000 Subject: [PATCH 2/4] fix(cli): preserve empty body validation and flag consistency --- .changesets/1790922409-830b3652.yaml | 2 +- .../snapshots/cli_empty_body_go_test.go | 43 +++++++++++++++++-- templates/templates/cli/README.md | 2 +- .../internal/flagutil/metadata.go.stmpl | 12 ++++++ .../templates/cli/includes/descriptions.ts | 4 +- templates/templates/cli/includes/metadata.ts | 38 ++++++++-------- .../templates/cli/includes/test-workflows.ts | 5 ++- templates/templates/cli/includes/tests.ts | 22 ++++++++++ templates/templates/cli/includes/utils.ts | 13 ++++-- templates/templates/cli/opcmd.go.stmpl | 2 +- .../cli/tests/primary/bodyinput_test.go.stmpl | 41 ++++++++++++++++++ 11 files changed, 153 insertions(+), 31 deletions(-) diff --git a/.changesets/1790922409-830b3652.yaml b/.changesets/1790922409-830b3652.yaml index e9b3c7fc..e4cef780 100644 --- a/.changesets/1790922409-830b3652.yaml +++ b/.changesets/1790922409-830b3652.yaml @@ -5,6 +5,6 @@ targets: - cli type: fix bump: patch -description: preserve CLI examples for empty and optional request bodies and omit redundant empty JSON body flags +description: preserve CLI examples for empty and optional request bodies, omit redundant empty JSON/form body flags, and retain required body validation author: TristanSpeakEasy date: "2026-10-02" diff --git a/pkg/generate/snapshots/cli_empty_body_go_test.go b/pkg/generate/snapshots/cli_empty_body_go_test.go index 57815497..4f1b13d6 100644 --- a/pkg/generate/snapshots/cli_empty_body_go_test.go +++ b/pkg/generate/snapshots/cli_empty_body_go_test.go @@ -103,6 +103,33 @@ paths: application/json: schema: type: string + /widgets/{id}/nullablerequired: + post: + <<: *mixedOperation + operationId: nullablerequired + description: A nullable empty object body does not need an invented payload. + requestBody: + required: true + content: + application/json: + schema: + type: object + nullable: true + properties: {} + /widgets/standalonenullable: + post: + <<: *mixedOperation + operationId: standalonenullable + description: A nullable empty object body is the only operation input. + parameters: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + nullable: true + properties: {} /widgets/{id}/maprequired: post: <<: *mixedOperation @@ -177,21 +204,21 @@ func TestSnapCLIEmptyBodyExamplesAndFlags(t *testing.T) { return string(content) } examplePattern := regexp.MustCompile(`Example:\s*"([^"\n]*)"`) - for _, name := range []string{"optional", "optionalexample", "scalaroptional", "formoptional"} { + for _, name := range []string{"optional", "optionalexample", "scalaroptional", "formoptional", "nullablerequired"} { command := readCommand(name) require.Regexp(t, examplePattern, command) require.Equal(t, " widget-cli widgets "+name+" --id widget_123", examplePattern.FindStringSubmatch(command)[1]) } - for _, name := range []string{"optional", "optionalexample", "required"} { + for _, name := range []string{"optional", "optionalexample", "required", "formoptional", "formrequired"} { command := readCommand(name) require.Contains(t, command, `cmd.Flags().String("body",`) require.NotContains(t, command, `FlagName: "body-param"`) } usageSource, err := os.ReadFile(filepath.Join(outputDir, "internal", "usage", "schema.go")) require.NoError(t, err) - usagePattern := regexp.MustCompile(`"widgets (optional|optionalexample|required)":\s*"(.*)"`) + usagePattern := regexp.MustCompile(`"widgets (optional|optionalexample|required|formoptional|formrequired)":\s*"(.*)"`) usageSchemas := usagePattern.FindAllStringSubmatch(string(usageSource), -1) - require.Len(t, usageSchemas, 3) + require.Len(t, usageSchemas, 5) for _, schema := range usageSchemas { require.Contains(t, schema[2], "--body ") require.NotContains(t, schema[2], "--body-param") @@ -201,8 +228,16 @@ func TestSnapCLIEmptyBodyExamplesAndFlags(t *testing.T) { require.Regexp(t, examplePattern, command) require.Equal(t, " widget-cli widgets "+name+" --id widget_123 --body '{}'", examplePattern.FindStringSubmatch(command)[1]) } + for _, name := range []string{"required", "formrequired"} { + require.Contains(t, readCommand(name), `PromptFlagSpec{Required: true, Kind: "json", BodyFlag: true}`) + } standaloneOptional := readCommand("standaloneoptional") + require.Regexp(t, examplePattern, standaloneOptional) require.Equal(t, " widget-cli widgets standaloneoptional", examplePattern.FindStringSubmatch(standaloneOptional)[1]) + standaloneNullable := readCommand("standalonenullable") + require.Regexp(t, examplePattern, standaloneNullable) + require.Equal(t, " widget-cli widgets standalonenullable", examplePattern.FindStringSubmatch(standaloneNullable)[1]) + require.NotContains(t, readCommand("optional"), "alternative to individual flags") standaloneRequired := readCommand("standalonerequired") require.Contains(t, standaloneRequired, "--empty-body '{}'") for _, command := range []string{standaloneOptional, standaloneRequired} { diff --git a/templates/templates/cli/README.md b/templates/templates/cli/README.md index b2354c50..ac1d0056 100644 --- a/templates/templates/cli/README.md +++ b/templates/templates/cli/README.md @@ -1242,7 +1242,7 @@ Declared commands may add a nested `help:` map with the closed keys `defaults`, Compact operation examples suppress only generator-synthesized invocations containing its own angle-bracket value placeholders or fallback `{"key": "value"}` body. Spec examples, manifest examples, and hand-written `Example` text are never filtered. An intent with no authored example gets a bare invocation only when it has no unresolved required positional or declared flag. -In both help styles, `includes/descriptions.ts` omits invented payloads for optional, unexpanded request bodies without usable body examples, preserving examples for required parameters. Empty JSON/form request-body classes use `'{}'` when required rather than an object with invented properties. Mixed parameter/body operations with an empty JSON body register only the existing `--body` flag for that body, not a redundant `--body-param` metadata flag. The metadata and `--usage` generators share the same field filter (`includes/metadata.ts`, `includes/usage.ts`). Body-only operations retain their model-named whole-body flag, and empty non-body classes, maps, unions, and nullable/optional wrappers retain their existing input flags. +In both help styles, `includes/descriptions.ts` omits invented payloads for optional or nullable, unexpanded request bodies without usable body examples, preserving examples for required parameters. Empty JSON/form request-body classes use `'{}'` when required rather than an object with invented properties. Mixed parameter/body operations with an empty JSON/form body register only the existing `--body` flag for that body, not a redundant `--body-param` metadata flag. Required empty bodies mark `--body` as required for prompting, and `BuildRequest` rejects omission or JSON `null` while accepting `'{}'` from the flag or stdin. The metadata, whole-body flag lookup, and `--usage` generators share the same field filter (`includes/metadata.ts`, `includes/usage.ts`). Body-only operations retain their whole-body flag, named from the type for shared/component models or from the request field for operation-specific models. Empty non-body classes, maps, unions, and nullable/optional wrappers retain their existing input flags. The compact layout and `Just works:` heading are runtime help contracts. Cobra markdown generation keeps its existing `Examples` heading and operation-doc footer, and the explorer continues to read `Short`, `Long`, and `Example` without a heading rewrite. Because synthesized-operation filtering happens while `templateCmdExample` builds the command's `Example`, a rejected synthesized example is absent from docs and the explorer too; authored examples remain unchanged everywhere. Custom commands that currently append Defaults/Machine/Globals prose to `Example` should migrate that prose to the annotations above to avoid a duplicate footer, set `speakeasy_help_footer: "false"` while retaining their own footer, or choose `helpStyle: full` during migration. diff --git a/templates/templates/cli/auxiliary/internal/flagutil/metadata.go.stmpl b/templates/templates/cli/auxiliary/internal/flagutil/metadata.go.stmpl index eb49855c..8ae8b6cc 100644 --- a/templates/templates/cli/auxiliary/internal/flagutil/metadata.go.stmpl +++ b/templates/templates/cli/auxiliary/internal/flagutil/metadata.go.stmpl @@ -657,8 +657,16 @@ func BuildRequest[T any](cmd *cobra.Command, meta []FlagMeta, bodyFieldPath stri v := reflect.ValueOf(&req).Elem() bodyPrePopulated := false hasRequestBody := bodyFieldPath != "" || bodyFlagName != "" || !isJSONSerialized(v.Type()) + bodyRequired := false + if flag := cmd.Flags().Lookup(bodyFlagName); flag != nil { + values := flag.Annotations[AnnotationRequired] + bodyRequired = len(values) > 0 && values[0] == "true" + } decodeBody := func(data []byte, source string) error { + if bodyRequired && bytes.Equal(bytes.TrimSpace(data), []byte("null")) { + return WithCLIValidation(fmt.Errorf("invalid value for --%s: null; the body is required", bodyFlagName)) + } u := bodyUnionMeta(meta, bodyFieldPath) if bodyFieldPath != "" { bodyField, err := navigateToField(v, bodyFieldPath) @@ -718,6 +726,10 @@ func BuildRequest[T any](cmd *cobra.Command, meta []FlagMeta, bodyFieldPath stri } } + if bodyRequired && !bodyPrePopulated { + return nil, &MissingRequiredFlagError{FlagName: bodyFlagName, Detail: "(or provide via stdin)"} + } + // When body provided via --body flag or stdin, relax Required checks for body fields // so builders don't error for fields already populated if bodyPrePopulated { diff --git a/templates/templates/cli/includes/descriptions.ts b/templates/templates/cli/includes/descriptions.ts index 3b92ffea..ed7a37c4 100644 --- a/templates/templates/cli/includes/descriptions.ts +++ b/templates/templates/cli/includes/descriptions.ts @@ -438,7 +438,7 @@ function templateCmdExample(op: Operation): string { Value: `--${flagName} '${JSON.stringify(ex.Value)}'`, SynthesizedPlaceholder: ex.Synthesized, })); - } else if (!bodyField.Optional) { + } else if (!bodyField.Optional && !bodyField.Nullable) { if (isEmptyRequestBodyClass(bodyField)) { pushPart(`--${flagName} '{}'`); } else { @@ -482,7 +482,7 @@ function templateCmdExample(op: Operation): string { Value: `--${flagName} '${JSON.stringify(ex.Value)}'`, SynthesizedPlaceholder: ex.Synthesized, })); - } else if (!bodyField.Optional) { + } else if (!bodyField.Optional && !bodyField.Nullable) { if (isEmptyRequestBodyClass(bodyField)) { pushPart(`--${flagName} '{}'`); } else { diff --git a/templates/templates/cli/includes/metadata.ts b/templates/templates/cli/includes/metadata.ts index 66acc068..519e1d5c 100644 --- a/templates/templates/cli/includes/metadata.ts +++ b/templates/templates/cli/includes/metadata.ts @@ -53,9 +53,7 @@ function getMixedRequestMetadataFields(op: Operation): FieldDef[] { if (!templateHasBodyFlag(op)) return fields; return fields.filter( (field: FieldDef) => - getInputClassType(field) !== "JSONRequestBody" || - !isEmptyRequestBodyClass(field) || - isNullableOptionalWrapped(field), + !isEmptyRequestBodyClass(field) || isNullableOptionalWrapped(field), ); } @@ -87,14 +85,9 @@ function buildOperationMetadataEntries( return null; // Complex JSON IsRequestBody uses BuildRequestBody, no metadata var } } else { - const fields = op.Request.Field.Type.Fields; + const fields = getMixedRequestMetadataFields(op); hadCandidateFields = fields.some((f: FieldDef) => !f.Const); - collectMetadataFromFields( - getMixedRequestMetadataFields(op), - "", - "", - entries, - ); + collectMetadataFromFields(fields, "", "", entries); } return { entries, hadCandidateFields }; } @@ -701,12 +694,7 @@ function wholeBodyFlagName(op: Operation): string { const bodyFieldPath = getBodyFieldPath(op); if (!bodyFieldPath) return ""; const entries: string[] = []; - collectMetadataFromFields( - op.Request.Field.Type.Fields || [], - "", - "", - entries, - ); + collectMetadataFromFields(getMixedRequestMetadataFields(op), "", "", entries); // Entries are Go literals that always open with the top-level FlagName and // FieldPath (buildMetaEntryForField / the union entry); match that prefix so // nested variant fields cannot be mistaken for the body field. @@ -894,12 +882,26 @@ function templateHasBodyFlag(op: Operation): boolean { } registerTemplateFunc("templateHasBodyFlag", templateHasBodyFlag); +function templateEmptyBodyRequired(op: Operation): boolean { + if (!op.Request || op.Request.IsRequestBody || !templateHasBodyFlag(op)) { + return false; + } + return (op.Request.Field.Type.Fields || []).some( + (field: FieldDef) => + isEmptyRequestBodyClass(field) && !field.Optional && !field.Nullable, + ); +} +registerTemplateFunc("templateEmptyBodyRequired", templateEmptyBodyRequired); + /** * Generate the description for the --body flag. */ function templateBodyFlagDescription(op: Operation): string { - let description = - "Request body as JSON (alternative to individual flags). Can also be provided via stdin; @path reads a file, @- reads stdin to EOF."; + const hasEmptyBody = + op.Request?.RequestBody && isEmptyRequestBodyClass(op.Request.RequestBody); + let description = hasEmptyBody + ? "Request body as JSON. Can also be provided via stdin; @path reads a file, @- reads stdin to EOF." + : "Request body as JSON (alternative to individual flags). Can also be provided via stdin; @path reads a file, @- reads stdin to EOF."; if (hasBodySchemaForOp(op)) { description += " Use --schema to print the exact JSON Schema."; } diff --git a/templates/templates/cli/includes/test-workflows.ts b/templates/templates/cli/includes/test-workflows.ts index e0178262..0c607c3e 100644 --- a/templates/templates/cli/includes/test-workflows.ts +++ b/templates/templates/cli/includes/test-workflows.ts @@ -264,7 +264,10 @@ function templateCLIStepCode(usageContext: UsageContext): string { if (operation.Request.RequestBody) { bodyHandled = true; const reqBodyField = operation.Request.RequestBody; - const flagName = getRequestBodyFlagName(reqBodyField); + const flagName = + !operation.Request.IsRequestBody && templateHasBodyFlag(operation) + ? "body" + : getRequestBodyFlagName(reqBodyField); const bodyIsExpandable = shouldExpandNestedField(reqBodyField); const example = findExampleWithFallback( operation.Request.Examples ?? [], diff --git a/templates/templates/cli/includes/tests.ts b/templates/templates/cli/includes/tests.ts index e04941d5..71e1e71c 100644 --- a/templates/templates/cli/includes/tests.ts +++ b/templates/templates/cli/includes/tests.ts @@ -1132,6 +1132,28 @@ function templateCLIArgs(usageContext: UsageContext): string { continue; } + if ( + isEmptyRequestBodyClass(field) && + templateHasBodyFlag(operation) && + !isNullableOptionalWrapped(field) + ) { + if ( + bodyExamplePayload !== undefined || + (!field.Optional && !field.Nullable) + ) { + pushFlagArg( + args, + "body", + goStringLiteral( + JSON.stringify( + bodyExamplePayload === undefined ? {} : bodyExamplePayload, + ), + ), + ); + } + continue; + } + // Check if this is a body struct field that gets expanded into nested flags // (no param annotation + class type + expanded by CLI command via shouldExpandNestedField) // Also handle multipart body fields which are expanded via collectMultipartMetadata diff --git a/templates/templates/cli/includes/utils.ts b/templates/templates/cli/includes/utils.ts index a6822c0c..882bcee9 100644 --- a/templates/templates/cli/includes/utils.ts +++ b/templates/templates/cli/includes/utils.ts @@ -58,10 +58,17 @@ function getInputClassType(field: FieldDef): InputClassType { registerTemplateFunc("getInputClassType", getInputClassType); function isEmptyRequestBodyClass(field: FieldDef): boolean { - const inputType = getInputClassType(field); + if ( + field.Type.Type.toString() !== "class" || + (field.Type.Fields || []).length !== 0 || + !field.Annotations?.Has("request") + ) { + return false; + } + const requestAnno = field.Annotations.Get("request") as RequestAnnotation; return ( - (inputType === "JSONRequestBody" || inputType === "FormRequestBody") && - (field.Type.Fields || []).length === 0 + matchContentType(requestAnno.MediaType, "application/json") || + matchContentType(requestAnno.MediaType, "application/x-www-form-urlencoded") ); } diff --git a/templates/templates/cli/opcmd.go.stmpl b/templates/templates/cli/opcmd.go.stmpl index 69680505..5c8717c9 100644 --- a/templates/templates/cli/opcmd.go.stmpl +++ b/templates/templates/cli/opcmd.go.stmpl @@ -92,7 +92,7 @@ func {{$initFuncName}}(parent *cobra.Command) error { {{- if templateHasBodyFlag $op}} cmd.Flags().String("body", "", "{{templateBodyFlagDescription $op}}") {{- addImport "fmt"}} - _ = flagutil.AnnotatePromptFlag(cmd, "body", flagutil.PromptFlagSpec{Kind: "json", BodyFlag: true}) + _ = flagutil.AnnotatePromptFlag(cmd, "body", flagutil.PromptFlagSpec{Required: {{templateEmptyBodyRequired $op}}, Kind: "json", BodyFlag: true}) cmd.Annotations[flagutil.AnnotationWholeBodyFlag] = "body" if err := flagutil.AnnotateBodyFields(cmd, {{templateMetaVarName $op}}, "{{templateBodyFieldPath $op}}", "body"); err != nil { return fmt.Errorf("annotate body fields for {{sanitizeCLICommand $op.GetID}}: %w", err) diff --git a/templates/templates/cli/tests/primary/bodyinput_test.go.stmpl b/templates/templates/cli/tests/primary/bodyinput_test.go.stmpl index 9d0bea75..fcd9a5d8 100644 --- a/templates/templates/cli/tests/primary/bodyinput_test.go.stmpl +++ b/templates/templates/cli/tests/primary/bodyinput_test.go.stmpl @@ -3,9 +3,11 @@ package tests import ( "os" "path/filepath" + "strings" "testing" "time" + "github.com/spf13/cobra" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -13,6 +15,45 @@ import ( "{{getGolangPackage}}/internal/output" ) +func TestRequiredEmptyMixedBody(t *testing.T) { + type request struct { + Body struct{} `json:"body"` + } + for _, tc := range []struct { + name string + body string + setBody bool + stdin string + required bool + wantError bool + }{ + {name: "required omitted", required: true, wantError: true}, + {name: "required flag", body: "{}", setBody: true, required: true}, + {name: "required stdin", stdin: "{}", required: true}, + {name: "required empty flag", setBody: true, required: true, wantError: true}, + {name: "required null flag", body: "null", setBody: true, required: true, wantError: true}, + {name: "required null stdin", stdin: "null", required: true, wantError: true}, + {name: "optional omitted"}, + } { + t.Run(tc.name, func(t *testing.T) { + cmd := &cobra.Command{} + cmd.SetIn(strings.NewReader(tc.stdin)) + cmd.Flags().String("body", "", "Request body as JSON") + require.NoError(t, flagutil.AnnotatePromptFlag(cmd, "body", flagutil.PromptFlagSpec{Required: tc.required, Kind: "json", BodyFlag: true})) + if tc.setBody { + require.NoError(t, cmd.Flags().Set("body", tc.body)) + } + _, err := flagutil.BuildRequest[request](cmd, nil, "Body", "body") + if tc.wantError { + require.Error(t, err) + assert.Contains(t, err.Error(), "--body") + } else { + require.NoError(t, err) + } + }) + } +} + // withSilentStdinPipe swaps os.Stdin for the read end of a pipe whose write // end stays open and never produces data — the shape agent runtimes and exec // wrappers hand a CLI — and restores it when the test ends. From f679fb233eeb43df10faeb38fbfe35716046ae0a Mon Sep 17 00:00:00 2001 From: Tristan Cartledge Date: Fri, 2 Oct 2026 17:55:12 +1000 Subject: [PATCH 3/4] fix(cli): retain wrapped empty body help alternatives --- .../snapshots/cli_empty_body_go_test.go | 19 ++++++++++++++++--- templates/templates/cli/README.md | 2 +- templates/templates/cli/includes/metadata.ts | 4 +++- 3 files changed, 20 insertions(+), 5 deletions(-) diff --git a/pkg/generate/snapshots/cli_empty_body_go_test.go b/pkg/generate/snapshots/cli_empty_body_go_test.go index 4f1b13d6..f6cf0e22 100644 --- a/pkg/generate/snapshots/cli_empty_body_go_test.go +++ b/pkg/generate/snapshots/cli_empty_body_go_test.go @@ -116,6 +116,18 @@ paths: type: object nullable: true properties: {} + /widgets/{id}/nullableoptional: + post: + <<: *mixedOperation + operationId: nullableoptional + description: An optional nullable empty object body preserves the distinction between omission and null. + requestBody: + content: + application/json: + schema: + type: object + nullable: true + properties: {} /widgets/standalonenullable: post: <<: *mixedOperation @@ -227,9 +239,7 @@ func TestSnapCLIEmptyBodyExamplesAndFlags(t *testing.T) { command := readCommand(name) require.Regexp(t, examplePattern, command) require.Equal(t, " widget-cli widgets "+name+" --id widget_123 --body '{}'", examplePattern.FindStringSubmatch(command)[1]) - } - for _, name := range []string{"required", "formrequired"} { - require.Contains(t, readCommand(name), `PromptFlagSpec{Required: true, Kind: "json", BodyFlag: true}`) + require.Contains(t, command, `PromptFlagSpec{Required: true, Kind: "json", BodyFlag: true}`) } standaloneOptional := readCommand("standaloneoptional") require.Regexp(t, examplePattern, standaloneOptional) @@ -238,6 +248,9 @@ func TestSnapCLIEmptyBodyExamplesAndFlags(t *testing.T) { require.Regexp(t, examplePattern, standaloneNullable) require.Equal(t, " widget-cli widgets standalonenullable", examplePattern.FindStringSubmatch(standaloneNullable)[1]) require.NotContains(t, readCommand("optional"), "alternative to individual flags") + nullableOptional := readCommand("nullableoptional") + require.Contains(t, nullableOptional, "Kind: flagutil.FlagKindJSON") + require.Contains(t, nullableOptional, "alternative to individual flags") standaloneRequired := readCommand("standalonerequired") require.Contains(t, standaloneRequired, "--empty-body '{}'") for _, command := range []string{standaloneOptional, standaloneRequired} { diff --git a/templates/templates/cli/README.md b/templates/templates/cli/README.md index ac1d0056..c8a99000 100644 --- a/templates/templates/cli/README.md +++ b/templates/templates/cli/README.md @@ -1242,7 +1242,7 @@ Declared commands may add a nested `help:` map with the closed keys `defaults`, Compact operation examples suppress only generator-synthesized invocations containing its own angle-bracket value placeholders or fallback `{"key": "value"}` body. Spec examples, manifest examples, and hand-written `Example` text are never filtered. An intent with no authored example gets a bare invocation only when it has no unresolved required positional or declared flag. -In both help styles, `includes/descriptions.ts` omits invented payloads for optional or nullable, unexpanded request bodies without usable body examples, preserving examples for required parameters. Empty JSON/form request-body classes use `'{}'` when required rather than an object with invented properties. Mixed parameter/body operations with an empty JSON/form body register only the existing `--body` flag for that body, not a redundant `--body-param` metadata flag. Required empty bodies mark `--body` as required for prompting, and `BuildRequest` rejects omission or JSON `null` while accepting `'{}'` from the flag or stdin. The metadata, whole-body flag lookup, and `--usage` generators share the same field filter (`includes/metadata.ts`, `includes/usage.ts`). Body-only operations retain their whole-body flag, named from the type for shared/component models or from the request field for operation-specific models. Empty non-body classes, maps, unions, and nullable/optional wrappers retain their existing input flags. +In both help styles, `includes/descriptions.ts` omits invented payloads for optional or nullable, unexpanded request bodies without usable body examples, preserving examples for required parameters. Empty JSON/form request-body classes use `'{}'` when required rather than an object with invented properties. Mixed parameter/body operations with an empty JSON/form body register only the existing `--body` flag for that body, not a redundant `--body-param` metadata flag, unless the body uses a nullable/optional wrapper. Wrapped bodies retain their individual JSON flag and the `--body` help describes that alternative. Required empty bodies mark `--body` as required for prompting, and `BuildRequest` rejects omission or JSON `null` while accepting `'{}'` from the flag or stdin. The metadata, whole-body flag lookup, and `--usage` generators share the same field filter (`includes/metadata.ts`, `includes/usage.ts`). Body-only operations retain their whole-body flag, named from the type for shared/component models or from the request field for operation-specific models. Empty non-body classes, maps, unions, and nullable/optional wrappers retain their existing input flags. The compact layout and `Just works:` heading are runtime help contracts. Cobra markdown generation keeps its existing `Examples` heading and operation-doc footer, and the explorer continues to read `Short`, `Long`, and `Example` without a heading rewrite. Because synthesized-operation filtering happens while `templateCmdExample` builds the command's `Example`, a rejected synthesized example is absent from docs and the explorer too; authored examples remain unchanged everywhere. Custom commands that currently append Defaults/Machine/Globals prose to `Example` should migrate that prose to the annotations above to avoid a duplicate footer, set `speakeasy_help_footer: "false"` while retaining their own footer, or choose `helpStyle: full` during migration. diff --git a/templates/templates/cli/includes/metadata.ts b/templates/templates/cli/includes/metadata.ts index 519e1d5c..fd662253 100644 --- a/templates/templates/cli/includes/metadata.ts +++ b/templates/templates/cli/includes/metadata.ts @@ -898,7 +898,9 @@ registerTemplateFunc("templateEmptyBodyRequired", templateEmptyBodyRequired); */ function templateBodyFlagDescription(op: Operation): string { const hasEmptyBody = - op.Request?.RequestBody && isEmptyRequestBodyClass(op.Request.RequestBody); + op.Request?.RequestBody && + isEmptyRequestBodyClass(op.Request.RequestBody) && + !isNullableOptionalWrapped(op.Request.RequestBody); let description = hasEmptyBody ? "Request body as JSON. Can also be provided via stdin; @path reads a file, @- reads stdin to EOF." : "Request body as JSON (alternative to individual flags). Can also be provided via stdin; @path reads a file, @- reads stdin to EOF."; From 7a0024cc814a4fdc723c5030bc6d89d9b212c795 Mon Sep 17 00:00:00 2001 From: Tristan Cartledge Date: Fri, 2 Oct 2026 18:35:51 +1000 Subject: [PATCH 4/4] test(cli): refresh generated review fixture --- zSDKs/sdk-cli/.speakeasy/gen.lock | 30 +++++++++---------- .../internal/cli/binaryandstringupload.go | 2 +- zSDKs/sdk-cli/internal/cli/createuser.go | 2 +- zSDKs/sdk-cli/internal/cli/createwithunion.go | 2 +- .../internal/cli/getfullyflattenedrequest.go | 2 +- .../conflicts/createnamespaceconflict.go | 2 +- .../conflicts/putnamespaceconflict.go | 2 +- .../singlefoo/createsinglenamespacefoopet.go | 2 +- .../internal/cli/parenthesesinpathallowed.go | 2 +- zSDKs/sdk-cli/internal/cli/renderasset.go | 2 +- zSDKs/sdk-cli/internal/cli/testendpoint.go | 2 +- zSDKs/sdk-cli/internal/cli/testenumformats.go | 2 +- .../internal/cli/testgroup/tag2/posttest.go | 2 +- .../internal/cli/testgroup/tag3/posttest.go | 2 +- zSDKs/sdk-cli/internal/cli/updateuser.go | 2 +- zSDKs/sdk-cli/internal/flagutil/metadata.go | 12 ++++++++ 16 files changed, 41 insertions(+), 29 deletions(-) diff --git a/zSDKs/sdk-cli/.speakeasy/gen.lock b/zSDKs/sdk-cli/.speakeasy/gen.lock index de116bed..9e978fba 100644 --- a/zSDKs/sdk-cli/.speakeasy/gen.lock +++ b/zSDKs/sdk-cli/.speakeasy/gen.lock @@ -62,15 +62,15 @@ trackedFiles: internal/cli/auth.go: last_write_checksum: sha1:6eb9d73f31d4922ceeea044a1d92e46821cc04eb internal/cli/binaryandstringupload.go: - last_write_checksum: sha1:cb903ffcbed829b74e7fca3df9c8265e14c227f4 + last_write_checksum: sha1:49917d0ac3988fa2e5e95831764b1d5b4e5a7fe6 internal/cli/chat.go: last_write_checksum: sha1:ebe5f262a8c4d55bebd8640be5a104553b067c4e internal/cli/configure.go: last_write_checksum: sha1:ad89cfd7fd30f6395fc006ee54d3f2e640160c21 internal/cli/createuser.go: - last_write_checksum: sha1:a7a16a4213223deeff1952400323aba9f038ce15 + last_write_checksum: sha1:fd8a61530ddd45685fbb750772c68542014f60b2 internal/cli/createwithunion.go: - last_write_checksum: sha1:184cede6aa7122a1f3fbdd2cc16e615fd93facda + last_write_checksum: sha1:c417a43017536d4269f165143e04d204e0da8e97 internal/cli/deleteuser.go: last_write_checksum: sha1:3ab7008b2264d603fc94d1da5cbb1094104f928e internal/cli/getasset.go: @@ -86,7 +86,7 @@ trackedFiles: internal/cli/geterroronlyexample.go: last_write_checksum: sha1:7ebeb77b2a0cf8542c7babe777583d0685f1905c internal/cli/getfullyflattenedrequest.go: - last_write_checksum: sha1:21cb273803496ec7d8ee44b58b1c8baff7e77729 + last_write_checksum: sha1:0376cb57929b775324aca719f6e6a19d185ecb9d internal/cli/getnamedprimitiveunion.go: last_write_checksum: sha1:288cae4480465c2c5891ed3d16cb95e119c3bf86 internal/cli/getnestedintegerstring.go: @@ -136,7 +136,7 @@ trackedFiles: internal/cli/masking.go: last_write_checksum: sha1:5a69fa055d628c1b8507cd123f3cd18309aa9a53 internal/cli/namespacetests/conflicts/createnamespaceconflict.go: - last_write_checksum: sha1:70896f0e4d86e0787a8a858b07279aa9f05120ff + last_write_checksum: sha1:afee24126825e32ac54a8a59d5bdda09bceb7d80 internal/cli/namespacetests/conflicts/getnamespaceconflict.go: last_write_checksum: sha1:924da745d8a8105a9108a549eafe0dbb3a595d59 internal/cli/namespacetests/conflicts/getpetowners.go: @@ -144,7 +144,7 @@ trackedFiles: internal/cli/namespacetests/conflicts/gettriplenamespaceconflict.go: last_write_checksum: sha1:ee2b6e3b0b6b4d8d5db0229b4d695119d6d8e3b8 internal/cli/namespacetests/conflicts/putnamespaceconflict.go: - last_write_checksum: sha1:5e704987526998d4a3eb16bc7e44e4700a4bfb07 + last_write_checksum: sha1:70befaf5ff63a1d8c2ca5ddd53b7b7a452a18cf2 internal/cli/namespacetests/conflicts/root.go: last_write_checksum: sha1:02a213614562bf56b1d4655435039c5932c35732 internal/cli/namespacetests/root.go: @@ -154,7 +154,7 @@ trackedFiles: internal/cli/namespacetests/singlebar/root.go: last_write_checksum: sha1:9588b75e31fc6974aa5c46bf6ab37200a37b2c8b internal/cli/namespacetests/singlefoo/createsinglenamespacefoopet.go: - last_write_checksum: sha1:7f1c10a4c431897d8a7974eaa64eacd5ed8f1d9e + last_write_checksum: sha1:9983ae623fb1c18f73ab5e22676bdb9525bda41a internal/cli/namespacetests/singlefoo/getsinglenamespacefoopet.go: last_write_checksum: sha1:2d25ac10559519252680b17bfdd7c390101533a3 internal/cli/namespacetests/singlefoo/root.go: @@ -176,11 +176,11 @@ trackedFiles: internal/cli/operationwithleadingandtrailingunderscores.go: last_write_checksum: sha1:f343717240957ac39a3d47dd61feb492815c972d internal/cli/parenthesesinpathallowed.go: - last_write_checksum: sha1:d10a165c5869a56d54d0bba45b80821407deb2f4 + last_write_checksum: sha1:29755c3a24393de07c4a588edc68121198925307 internal/cli/postfile.go: last_write_checksum: sha1:f67d5ddcaa3ea921513ee6f97fe5eb8db9580aae internal/cli/renderasset.go: - last_write_checksum: sha1:66703b4e61cf22941907d9a373dd4e75587f7e0e + last_write_checksum: sha1:7b64189912eaa0e2c11bdbc91f39d3d035bbc8b3 internal/cli/root.go: last_write_checksum: sha1:1d441678f8915123a6a0e276474fc39169a36e9c internal/cli/tag1/auth.go: @@ -196,21 +196,21 @@ trackedFiles: internal/cli/tag1/root.go: last_write_checksum: sha1:cfe60d600cb9609d9609056bfce8fb7c97134b41 internal/cli/testendpoint.go: - last_write_checksum: sha1:a43d106bc6ac070daaa2173d6e6b564c1feb868f + last_write_checksum: sha1:d3fa69266c0748f3c4baf267d2ffa172fa1779aa internal/cli/testenumformats.go: - last_write_checksum: sha1:e11eb49456f08b1c090c15fc1bb1087ef4649d92 + last_write_checksum: sha1:4df0a0d2d7ca95634fcca8d4ecdfb089331072d1 internal/cli/testgroup/root.go: last_write_checksum: sha1:e252ec79b7dccbf3cedb78d27ed2f3f7a4cd819f internal/cli/testgroup/tag2/posttest.go: - last_write_checksum: sha1:abb6d46c0e4289781aa96c6a0db570c7c635c930 + last_write_checksum: sha1:08828375cbb2b47e753bf0b2c44d381582132536 internal/cli/testgroup/tag2/root.go: last_write_checksum: sha1:333e0d1e2947411048be5e44359d41a779a8051d internal/cli/testgroup/tag3/posttest.go: - last_write_checksum: sha1:3dbd8196ea4635911d34647053a4f0681ea85fe7 + last_write_checksum: sha1:667b48c532ff3a59c4eeadfda46594b00852ed89 internal/cli/testgroup/tag3/root.go: last_write_checksum: sha1:544b5d37f18bcd87ed2b878deca97b3dba8da1e7 internal/cli/updateuser.go: - last_write_checksum: sha1:3772025313453ee26c1a633dfe9319a5d160b02a + last_write_checksum: sha1:61c7159ede87200a549f7771363ce0250863c503 internal/cli/urlvalidationstresstest.go: last_write_checksum: sha1:0c42b30be81531f215e7be41911e4262a3b70e82 internal/cli/validate.go: @@ -246,7 +246,7 @@ trackedFiles: internal/flagutil/flags.go: last_write_checksum: sha1:1d35d22e4beacf398104fd6883510f4c72323f91 internal/flagutil/metadata.go: - last_write_checksum: sha1:f047a1358a4412123c86e6714cb696fd9ab6fd9a + last_write_checksum: sha1:5d153af72b945fb53d976fdfb894790614386b1e internal/flagutil/preset.go: last_write_checksum: sha1:a580b7d4b00cf469415bc53d40f4981d60091423 internal/interactive/interactive.go: diff --git a/zSDKs/sdk-cli/internal/cli/binaryandstringupload.go b/zSDKs/sdk-cli/internal/cli/binaryandstringupload.go index 668da2b0..96bc87d5 100644 --- a/zSDKs/sdk-cli/internal/cli/binaryandstringupload.go +++ b/zSDKs/sdk-cli/internal/cli/binaryandstringupload.go @@ -37,7 +37,7 @@ func initBinaryAndStringUploadCmd(parent *cobra.Command) error { return fmt.Errorf("invalid metadata for binary-and-string-upload: %w", err) } cmd.Flags().String("body", "", "Request body as JSON (alternative to individual flags). Can also be provided via stdin; @path reads a file, @- reads stdin to EOF. Use --schema to print the exact JSON Schema.") - _ = flagutil.AnnotatePromptFlag(cmd, "body", flagutil.PromptFlagSpec{Kind: "json", BodyFlag: true}) + _ = flagutil.AnnotatePromptFlag(cmd, "body", flagutil.PromptFlagSpec{Required: false, Kind: "json", BodyFlag: true}) cmd.Annotations[flagutil.AnnotationWholeBodyFlag] = "body" if err := flagutil.AnnotateBodyFields(cmd, binaryAndStringUploadCmdMeta, "", "body"); err != nil { return fmt.Errorf("annotate body fields for binary-and-string-upload: %w", err) diff --git a/zSDKs/sdk-cli/internal/cli/createuser.go b/zSDKs/sdk-cli/internal/cli/createuser.go index 96177fd7..ae7ee4aa 100644 --- a/zSDKs/sdk-cli/internal/cli/createuser.go +++ b/zSDKs/sdk-cli/internal/cli/createuser.go @@ -46,7 +46,7 @@ func initCreateUserCmd(parent *cobra.Command) error { return fmt.Errorf("invalid metadata for create-user: %w", err) } cmd.Flags().String("body", "", "Request body as JSON (alternative to individual flags). Can also be provided via stdin; @path reads a file, @- reads stdin to EOF. Use --schema to print the exact JSON Schema.") - _ = flagutil.AnnotatePromptFlag(cmd, "body", flagutil.PromptFlagSpec{Kind: "json", BodyFlag: true}) + _ = flagutil.AnnotatePromptFlag(cmd, "body", flagutil.PromptFlagSpec{Required: false, Kind: "json", BodyFlag: true}) cmd.Annotations[flagutil.AnnotationWholeBodyFlag] = "body" if err := flagutil.AnnotateBodyFields(cmd, createUserCmdMeta, "", "body"); err != nil { return fmt.Errorf("annotate body fields for create-user: %w", err) diff --git a/zSDKs/sdk-cli/internal/cli/createwithunion.go b/zSDKs/sdk-cli/internal/cli/createwithunion.go index 8938b666..be2ee612 100644 --- a/zSDKs/sdk-cli/internal/cli/createwithunion.go +++ b/zSDKs/sdk-cli/internal/cli/createwithunion.go @@ -49,7 +49,7 @@ func initCreateWithUnionCmd(parent *cobra.Command) error { return fmt.Errorf("invalid metadata for create-with-union: %w", err) } cmd.Flags().String("body", "", "Request body as JSON (alternative to individual flags). Can also be provided via stdin; @path reads a file, @- reads stdin to EOF. Use --schema to print the exact JSON Schema.") - _ = flagutil.AnnotatePromptFlag(cmd, "body", flagutil.PromptFlagSpec{Kind: "json", BodyFlag: true}) + _ = flagutil.AnnotatePromptFlag(cmd, "body", flagutil.PromptFlagSpec{Required: false, Kind: "json", BodyFlag: true}) cmd.Annotations[flagutil.AnnotationWholeBodyFlag] = "body" if err := flagutil.AnnotateBodyFields(cmd, createWithUnionCmdMeta, "ShapeRequest", "body"); err != nil { return fmt.Errorf("annotate body fields for create-with-union: %w", err) diff --git a/zSDKs/sdk-cli/internal/cli/getfullyflattenedrequest.go b/zSDKs/sdk-cli/internal/cli/getfullyflattenedrequest.go index 73866097..76e28f41 100644 --- a/zSDKs/sdk-cli/internal/cli/getfullyflattenedrequest.go +++ b/zSDKs/sdk-cli/internal/cli/getfullyflattenedrequest.go @@ -42,7 +42,7 @@ func initGetFullyFlattenedRequestCmd(parent *cobra.Command) error { return fmt.Errorf("invalid metadata for get-fully-flattened-request: %w", err) } cmd.Flags().String("body", "", "Request body as JSON (alternative to individual flags). Can also be provided via stdin; @path reads a file, @- reads stdin to EOF. Use --schema to print the exact JSON Schema.") - _ = flagutil.AnnotatePromptFlag(cmd, "body", flagutil.PromptFlagSpec{Kind: "json", BodyFlag: true}) + _ = flagutil.AnnotatePromptFlag(cmd, "body", flagutil.PromptFlagSpec{Required: false, Kind: "json", BodyFlag: true}) cmd.Annotations[flagutil.AnnotationWholeBodyFlag] = "body" if err := flagutil.AnnotateBodyFields(cmd, getFullyFlattenedRequestCmdMeta, "RequestBody", "body"); err != nil { return fmt.Errorf("annotate body fields for get-fully-flattened-request: %w", err) diff --git a/zSDKs/sdk-cli/internal/cli/namespacetests/conflicts/createnamespaceconflict.go b/zSDKs/sdk-cli/internal/cli/namespacetests/conflicts/createnamespaceconflict.go index a51a036b..772ac248 100644 --- a/zSDKs/sdk-cli/internal/cli/namespacetests/conflicts/createnamespaceconflict.go +++ b/zSDKs/sdk-cli/internal/cli/namespacetests/conflicts/createnamespaceconflict.go @@ -44,7 +44,7 @@ func initCreateNamespaceConflictCmd(parent *cobra.Command) error { return fmt.Errorf("invalid metadata for create-namespace-conflict: %w", err) } cmd.Flags().String("body", "", "Request body as JSON (alternative to individual flags). Can also be provided via stdin; @path reads a file, @- reads stdin to EOF. Use --schema to print the exact JSON Schema.") - _ = flagutil.AnnotatePromptFlag(cmd, "body", flagutil.PromptFlagSpec{Kind: "json", BodyFlag: true}) + _ = flagutil.AnnotatePromptFlag(cmd, "body", flagutil.PromptFlagSpec{Required: false, Kind: "json", BodyFlag: true}) cmd.Annotations[flagutil.AnnotationWholeBodyFlag] = "body" if err := flagutil.AnnotateBodyFields(cmd, createNamespaceConflictCmdMeta, "", "body"); err != nil { return fmt.Errorf("annotate body fields for create-namespace-conflict: %w", err) diff --git a/zSDKs/sdk-cli/internal/cli/namespacetests/conflicts/putnamespaceconflict.go b/zSDKs/sdk-cli/internal/cli/namespacetests/conflicts/putnamespaceconflict.go index 078911da..2596566b 100644 --- a/zSDKs/sdk-cli/internal/cli/namespacetests/conflicts/putnamespaceconflict.go +++ b/zSDKs/sdk-cli/internal/cli/namespacetests/conflicts/putnamespaceconflict.go @@ -40,7 +40,7 @@ func initPutNamespaceConflictCmd(parent *cobra.Command) error { return fmt.Errorf("invalid metadata for put-namespace-conflict: %w", err) } cmd.Flags().String("body", "", "Request body as JSON (alternative to individual flags). Can also be provided via stdin; @path reads a file, @- reads stdin to EOF. Use --schema to print the exact JSON Schema.") - _ = flagutil.AnnotatePromptFlag(cmd, "body", flagutil.PromptFlagSpec{Kind: "json", BodyFlag: true}) + _ = flagutil.AnnotatePromptFlag(cmd, "body", flagutil.PromptFlagSpec{Required: false, Kind: "json", BodyFlag: true}) cmd.Annotations[flagutil.AnnotationWholeBodyFlag] = "body" if err := flagutil.AnnotateBodyFields(cmd, putNamespaceConflictCmdMeta, "", "body"); err != nil { return fmt.Errorf("annotate body fields for put-namespace-conflict: %w", err) diff --git a/zSDKs/sdk-cli/internal/cli/namespacetests/singlefoo/createsinglenamespacefoopet.go b/zSDKs/sdk-cli/internal/cli/namespacetests/singlefoo/createsinglenamespacefoopet.go index e27c0e43..951a39f7 100644 --- a/zSDKs/sdk-cli/internal/cli/namespacetests/singlefoo/createsinglenamespacefoopet.go +++ b/zSDKs/sdk-cli/internal/cli/namespacetests/singlefoo/createsinglenamespacefoopet.go @@ -44,7 +44,7 @@ func initCreateSingleNamespaceFooPetCmd(parent *cobra.Command) error { return fmt.Errorf("invalid metadata for create-single-namespace-foo-pet: %w", err) } cmd.Flags().String("body", "", "Request body as JSON (alternative to individual flags). Can also be provided via stdin; @path reads a file, @- reads stdin to EOF. Use --schema to print the exact JSON Schema.") - _ = flagutil.AnnotatePromptFlag(cmd, "body", flagutil.PromptFlagSpec{Kind: "json", BodyFlag: true}) + _ = flagutil.AnnotatePromptFlag(cmd, "body", flagutil.PromptFlagSpec{Required: false, Kind: "json", BodyFlag: true}) cmd.Annotations[flagutil.AnnotationWholeBodyFlag] = "body" if err := flagutil.AnnotateBodyFields(cmd, createSingleNamespaceFooPetCmdMeta, "", "body"); err != nil { return fmt.Errorf("annotate body fields for create-single-namespace-foo-pet: %w", err) diff --git a/zSDKs/sdk-cli/internal/cli/parenthesesinpathallowed.go b/zSDKs/sdk-cli/internal/cli/parenthesesinpathallowed.go index 6c42ec55..5d54ebfc 100644 --- a/zSDKs/sdk-cli/internal/cli/parenthesesinpathallowed.go +++ b/zSDKs/sdk-cli/internal/cli/parenthesesinpathallowed.go @@ -41,7 +41,7 @@ func initParenthesesInPathAllowedCmd(parent *cobra.Command) error { return fmt.Errorf("invalid metadata for parentheses-in-path-allowed: %w", err) } cmd.Flags().String("body", "", "Request body as JSON (alternative to individual flags). Can also be provided via stdin; @path reads a file, @- reads stdin to EOF. Use --schema to print the exact JSON Schema.") - _ = flagutil.AnnotatePromptFlag(cmd, "body", flagutil.PromptFlagSpec{Kind: "json", BodyFlag: true}) + _ = flagutil.AnnotatePromptFlag(cmd, "body", flagutil.PromptFlagSpec{Required: false, Kind: "json", BodyFlag: true}) cmd.Annotations[flagutil.AnnotationWholeBodyFlag] = "body" if err := flagutil.AnnotateBodyFields(cmd, parenthesesInPathAllowedCmdMeta, "TemplateBracesTest", "body"); err != nil { return fmt.Errorf("annotate body fields for parentheses-in-path-allowed: %w", err) diff --git a/zSDKs/sdk-cli/internal/cli/renderasset.go b/zSDKs/sdk-cli/internal/cli/renderasset.go index 0861dc8c..ca496bb2 100644 --- a/zSDKs/sdk-cli/internal/cli/renderasset.go +++ b/zSDKs/sdk-cli/internal/cli/renderasset.go @@ -37,7 +37,7 @@ func initRenderAssetCmd(parent *cobra.Command) error { return fmt.Errorf("invalid metadata for render-asset: %w", err) } cmd.Flags().String("body", "", "Request body as JSON (alternative to individual flags). Can also be provided via stdin; @path reads a file, @- reads stdin to EOF. Use --schema to print the exact JSON Schema.") - _ = flagutil.AnnotatePromptFlag(cmd, "body", flagutil.PromptFlagSpec{Kind: "json", BodyFlag: true}) + _ = flagutil.AnnotatePromptFlag(cmd, "body", flagutil.PromptFlagSpec{Required: false, Kind: "json", BodyFlag: true}) cmd.Annotations[flagutil.AnnotationWholeBodyFlag] = "body" if err := flagutil.AnnotateBodyFields(cmd, renderAssetCmdMeta, "", "body"); err != nil { return fmt.Errorf("annotate body fields for render-asset: %w", err) diff --git a/zSDKs/sdk-cli/internal/cli/testendpoint.go b/zSDKs/sdk-cli/internal/cli/testendpoint.go index 44babe49..e6e1e10c 100644 --- a/zSDKs/sdk-cli/internal/cli/testendpoint.go +++ b/zSDKs/sdk-cli/internal/cli/testendpoint.go @@ -38,7 +38,7 @@ func initTestEndpointCmd(parent *cobra.Command) error { return fmt.Errorf("invalid metadata for test-endpoint: %w", err) } cmd.Flags().String("body", "", "Request body as JSON (alternative to individual flags). Can also be provided via stdin; @path reads a file, @- reads stdin to EOF. Use --schema to print the exact JSON Schema.") - _ = flagutil.AnnotatePromptFlag(cmd, "body", flagutil.PromptFlagSpec{Kind: "json", BodyFlag: true}) + _ = flagutil.AnnotatePromptFlag(cmd, "body", flagutil.PromptFlagSpec{Required: false, Kind: "json", BodyFlag: true}) cmd.Annotations[flagutil.AnnotationWholeBodyFlag] = "body" if err := flagutil.AnnotateBodyFields(cmd, testEndpointCmdMeta, "RequestBody", "body"); err != nil { return fmt.Errorf("annotate body fields for test-endpoint: %w", err) diff --git a/zSDKs/sdk-cli/internal/cli/testenumformats.go b/zSDKs/sdk-cli/internal/cli/testenumformats.go index 1aced588..31e8f0b2 100644 --- a/zSDKs/sdk-cli/internal/cli/testenumformats.go +++ b/zSDKs/sdk-cli/internal/cli/testenumformats.go @@ -40,7 +40,7 @@ func initTestEnumFormatsCmd(parent *cobra.Command) error { return fmt.Errorf("invalid metadata for test-enum-formats: %w", err) } cmd.Flags().String("body", "", "Request body as JSON (alternative to individual flags). Can also be provided via stdin; @path reads a file, @- reads stdin to EOF. Use --schema to print the exact JSON Schema.") - _ = flagutil.AnnotatePromptFlag(cmd, "body", flagutil.PromptFlagSpec{Kind: "json", BodyFlag: true}) + _ = flagutil.AnnotatePromptFlag(cmd, "body", flagutil.PromptFlagSpec{Required: false, Kind: "json", BodyFlag: true}) cmd.Annotations[flagutil.AnnotationWholeBodyFlag] = "body" if err := flagutil.AnnotateBodyFields(cmd, testEnumFormatsCmdMeta, "", "body"); err != nil { return fmt.Errorf("annotate body fields for test-enum-formats: %w", err) diff --git a/zSDKs/sdk-cli/internal/cli/testgroup/tag2/posttest.go b/zSDKs/sdk-cli/internal/cli/testgroup/tag2/posttest.go index 32f9ba1e..933e2b28 100644 --- a/zSDKs/sdk-cli/internal/cli/testgroup/tag2/posttest.go +++ b/zSDKs/sdk-cli/internal/cli/testgroup/tag2/posttest.go @@ -36,7 +36,7 @@ func initPostTestCmd(parent *cobra.Command) error { return fmt.Errorf("invalid metadata for post-test: %w", err) } cmd.Flags().String("body", "", "Request body as JSON (alternative to individual flags). Can also be provided via stdin; @path reads a file, @- reads stdin to EOF. Use --schema to print the exact JSON Schema.") - _ = flagutil.AnnotatePromptFlag(cmd, "body", flagutil.PromptFlagSpec{Kind: "json", BodyFlag: true}) + _ = flagutil.AnnotatePromptFlag(cmd, "body", flagutil.PromptFlagSpec{Required: false, Kind: "json", BodyFlag: true}) cmd.Annotations[flagutil.AnnotationWholeBodyFlag] = "body" if err := flagutil.AnnotateBodyFields(cmd, postTestCmdMeta, "Test2Request", "body"); err != nil { return fmt.Errorf("annotate body fields for post-test: %w", err) diff --git a/zSDKs/sdk-cli/internal/cli/testgroup/tag3/posttest.go b/zSDKs/sdk-cli/internal/cli/testgroup/tag3/posttest.go index a7ac8ee7..d946c06c 100644 --- a/zSDKs/sdk-cli/internal/cli/testgroup/tag3/posttest.go +++ b/zSDKs/sdk-cli/internal/cli/testgroup/tag3/posttest.go @@ -36,7 +36,7 @@ func initPostTestCmd(parent *cobra.Command) error { return fmt.Errorf("invalid metadata for post-test: %w", err) } cmd.Flags().String("body", "", "Request body as JSON (alternative to individual flags). Can also be provided via stdin; @path reads a file, @- reads stdin to EOF. Use --schema to print the exact JSON Schema.") - _ = flagutil.AnnotatePromptFlag(cmd, "body", flagutil.PromptFlagSpec{Kind: "json", BodyFlag: true}) + _ = flagutil.AnnotatePromptFlag(cmd, "body", flagutil.PromptFlagSpec{Required: false, Kind: "json", BodyFlag: true}) cmd.Annotations[flagutil.AnnotationWholeBodyFlag] = "body" if err := flagutil.AnnotateBodyFields(cmd, postTestCmdMeta, "Test2Request", "body"); err != nil { return fmt.Errorf("annotate body fields for post-test: %w", err) diff --git a/zSDKs/sdk-cli/internal/cli/updateuser.go b/zSDKs/sdk-cli/internal/cli/updateuser.go index 9a092519..2e93012d 100644 --- a/zSDKs/sdk-cli/internal/cli/updateuser.go +++ b/zSDKs/sdk-cli/internal/cli/updateuser.go @@ -47,7 +47,7 @@ func initUpdateUserCmd(parent *cobra.Command) error { return fmt.Errorf("invalid metadata for update-user: %w", err) } cmd.Flags().String("body", "", "Request body as JSON (alternative to individual flags). Can also be provided via stdin; @path reads a file, @- reads stdin to EOF. Use --schema to print the exact JSON Schema.") - _ = flagutil.AnnotatePromptFlag(cmd, "body", flagutil.PromptFlagSpec{Kind: "json", BodyFlag: true}) + _ = flagutil.AnnotatePromptFlag(cmd, "body", flagutil.PromptFlagSpec{Required: false, Kind: "json", BodyFlag: true}) cmd.Annotations[flagutil.AnnotationWholeBodyFlag] = "body" if err := flagutil.AnnotateBodyFields(cmd, updateUserCmdMeta, "User", "body"); err != nil { return fmt.Errorf("annotate body fields for update-user: %w", err) diff --git a/zSDKs/sdk-cli/internal/flagutil/metadata.go b/zSDKs/sdk-cli/internal/flagutil/metadata.go index fad9260d..205575bd 100644 --- a/zSDKs/sdk-cli/internal/flagutil/metadata.go +++ b/zSDKs/sdk-cli/internal/flagutil/metadata.go @@ -659,8 +659,16 @@ func BuildRequest[T any](cmd *cobra.Command, meta []FlagMeta, bodyFieldPath stri v := reflect.ValueOf(&req).Elem() bodyPrePopulated := false hasRequestBody := bodyFieldPath != "" || bodyFlagName != "" || !isJSONSerialized(v.Type()) + bodyRequired := false + if flag := cmd.Flags().Lookup(bodyFlagName); flag != nil { + values := flag.Annotations[AnnotationRequired] + bodyRequired = len(values) > 0 && values[0] == "true" + } decodeBody := func(data []byte, source string) error { + if bodyRequired && bytes.Equal(bytes.TrimSpace(data), []byte("null")) { + return WithCLIValidation(fmt.Errorf("invalid value for --%s: null; the body is required", bodyFlagName)) + } u := bodyUnionMeta(meta, bodyFieldPath) if bodyFieldPath != "" { bodyField, err := navigateToField(v, bodyFieldPath) @@ -720,6 +728,10 @@ func BuildRequest[T any](cmd *cobra.Command, meta []FlagMeta, bodyFieldPath stri } } + if bodyRequired && !bodyPrePopulated { + return nil, &MissingRequiredFlagError{FlagName: bodyFlagName, Detail: "(or provide via stdin)"} + } + // When body provided via --body flag or stdin, relax Required checks for body fields // so builders don't error for fields already populated if bodyPrePopulated {