Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changesets/1790922409-830b3652.yaml
Original file line number Diff line number Diff line change
@@ -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, omit redundant empty JSON/form body flags, and retain required body validation
author: TristanSpeakEasy
date: "2026-10-02"
269 changes: 269 additions & 0 deletions pkg/generate/snapshots/cli_empty_body_go_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,269 @@
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}/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/{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
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
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]*)"`)
Comment thread
TristanSpeakEasy marked this conversation as resolved.
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", "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|formoptional|formrequired)":\s*"(.*)"`)
usageSchemas := usagePattern.FindAllStringSubmatch(string(usageSource), -1)
require.Len(t, usageSchemas, 5)
for _, schema := range usageSchemas {
require.Contains(t, schema[2], "--body <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])
require.Contains(t, command, `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])
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
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")
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} {
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\"}'`)
},
})
})
}
}
2 changes: 2 additions & 0 deletions templates/templates/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1248,6 +1248,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 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.

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 <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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -662,8 +662,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)
Expand Down Expand Up @@ -723,6 +731,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 {
Expand Down
16 changes: 12 additions & 4 deletions templates/templates/cli/includes/descriptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 && !bodyField.Nullable) {
if (isEmptyRequestBodyClass(bodyField)) {
pushPart(`--${flagName} '{}'`);
} else {
pushPart(`--${flagName} '{"key": "value"}'`, true);
}
}
}
} else if (op.Request.RequestBody) {
Expand Down Expand Up @@ -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 && !bodyField.Nullable) {
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
Expand Down
Loading
Loading