Skip to content
Merged
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/1791385151-7d8442f0.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
id: 1791385151-7d8442f0
features:
- cliCommands
targets:
- cli
type: feat
bump: patch
description: report selected stream metadata on stderr
author: ThomasRooney
date: "2026-10-07"
61 changes: 58 additions & 3 deletions internal/extensions/cli_commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"strconv"
"strings"
"time"
"unicode"

"github.com/speakeasy-api/openapi-generation/v2/internal/document"
"github.com/speakeasy-api/openapi-generation/v2/pkg/errors"
Expand Down Expand Up @@ -214,14 +215,21 @@ type CLICommandArtifact struct {
explicitBindings map[string]bool
}

type CLICommandStreamMetadata struct {
Select string `json:"select" yaml:"select"`
Pointer string `json:"pointer" yaml:"pointer"`
Label string `json:"label" yaml:"label"`
}

// CLICommandStreamProjection selects the field of each streamed event whose
// string value is written raw to stdout as the event arrives (stream mode
// only). Select is the authored singular JSONPath from the event root as the
// CLI sees each event (the same root a per-event --jq filter sees); Pointer
// is its RFC 6901 lowering, which the generated runtime evaluates.
type CLICommandStreamProjection struct {
Select string `json:"select" yaml:"select"`
Pointer string `json:"pointer" yaml:"pointer"`
Metadata *CLICommandStreamMetadata `json:"metadata,omitempty" yaml:"metadata,omitempty"`
Select string `json:"select" yaml:"select"`
Pointer string `json:"pointer" yaml:"pointer"`
}

// CLICommandOutput groups output behavior for a declared command.
Expand Down Expand Up @@ -515,7 +523,7 @@ var cliCommandKeys = []string{

var cliOutputKeys = []string{"artifact", "stream"}

var cliOutputStreamKeys = []string{"select"}
var cliOutputStreamKeys = []string{"select", "metadata"}

var cliOperationKeys = []string{"output", "flags"}

Expand Down Expand Up @@ -2225,6 +2233,13 @@ func (d *cliManifestDecoder) decodeStreamProjection(cmdKey string, node *yaml.No
}
stream.Select = raw
stream.Pointer = cliSegmentsToPointer(segments)
case "metadata":
metadata, err := d.decodeStreamMetadata(entry.Value)
if err != nil {
return nil, fmt.Errorf("command %q: %w", cmdKey, err)
}
stream.Metadata = metadata

default:
return nil, fmt.Errorf("line %d: command %q output.stream has unknown key %q%s", entry.Key.Line, cmdKey, entry.Key.Value, cliDidYouMean(entry.Key.Value, cliOutputStreamKeys))
}
Expand Down Expand Up @@ -2443,3 +2458,43 @@ func cliMapKeys(m map[string]bool) []string {
}
return keys
}

func (d *cliManifestDecoder) decodeStreamMetadata(node *yaml.Node) (*CLICommandStreamMetadata, error) {
entries, err := cliMapEntries(node, "output.stream.metadata")
if err != nil {
return nil, err
}
metadata := &CLICommandStreamMetadata{}
for _, entry := range entries {
switch entry.Key.Value {
case "select":
value, err := cliScalarString(entry.Value, "output.stream.metadata.select")
if err != nil {
return nil, err
}
segments, err := cliParseSingularPath(value)
if err != nil {
return nil, fmt.Errorf("line %d: output.stream.metadata.select: %w", entry.Value.Line, err)
}
metadata.Select = value
metadata.Pointer = cliSegmentsToPointer(segments)
case "label":
value, err := cliScalarString(entry.Value, "output.stream.metadata.label")
if err != nil {
return nil, err
}
for _, r := range value {
if !unicode.IsPrint(r) {
return nil, fmt.Errorf("line %d: output.stream.metadata.label must contain only printable characters", entry.Value.Line)
}
}
metadata.Label = value
default:
return nil, fmt.Errorf("line %d: output.stream.metadata has unknown key %q", entry.Key.Line, entry.Key.Value)
}
}
if metadata.Select == "" || strings.TrimSpace(metadata.Label) == "" {
return nil, fmt.Errorf("line %d: output.stream.metadata requires nonempty select and label", node.Line)
}
return metadata, nil
}
33 changes: 24 additions & 9 deletions internal/extensions/cli_commands_link.go
Original file line number Diff line number Diff line change
Expand Up @@ -1745,12 +1745,22 @@ func (d *cliManifestDecoder) linkStreamProjection(owner string, stream *CLIComma
if !opInfo.streamMediaSeen {
return fmt.Errorf("%s declares output.stream, but operation %q has no streaming (text/event-stream or JSONL) success response; the CLI only streams events for operations that declare one", owner, opID)
}
segments, err := cliParseSingularPath(stream.Select)
if err := d.linkStreamSelection(owner, "output.stream.select", stream.Select, opInfo); err != nil {
return err
}
if stream.Metadata != nil {
return d.linkStreamSelection(owner, "output.stream.metadata.select", stream.Metadata.Select, opInfo)
}
return nil
}

func (d *cliManifestDecoder) linkStreamSelection(owner, field, selectPath string, opInfo *cliOperationInfo) error {
segments, err := cliParseSingularPath(selectPath)
if err != nil {
return fmt.Errorf("%s output.stream.select: %w", owner, err)
return fmt.Errorf("%s %s: %w", owner, field, err)
}

walker := &cliStreamPathWalker{d: d, owner: owner, selectPath: stream.Select}
walker := &cliStreamPathWalker{d: d, owner: owner, selectPath: selectPath, field: field}
for _, schema := range opInfo.streamSchemas {
if schema == nil {
walker.unverifiable("the streaming response declares no schema")
Expand All @@ -1763,16 +1773,16 @@ func (d *cliManifestDecoder) linkStreamProjection(owner string, stream *CLIComma
}
if walker.resolved == 0 {
if walker.unverified == 0 {
return fmt.Errorf("%s output.stream.select %s does not resolve in any event shape of the streaming response%s", owner, stream.Select, walker.missSuggestion())
return fmt.Errorf("%s %s %s does not resolve in any event shape of the streaming response%s", owner, field, selectPath, walker.missSuggestion())
}
d.warnf("%s output.stream.select %s could not be verified against the streaming response schema (%s); events without a string at that path are skipped at runtime", owner, stream.Select, walker.firstReason)
d.warnf("%s %s %s could not be verified against the streaming response schema (%s); events without a string at that path are skipped at runtime", owner, field, selectPath, walker.firstReason)
return nil
}
if err := d.lintProjection(owner, "output.stream.select", cliSingularPathToJQ(segments), "the streaming response declares no event schema", cliOperationProjectionSchema(opInfo), true); err != nil {
if err := d.lintProjection(owner, field, cliSingularPathToJQ(segments), "the streaming response declares no event schema", cliOperationProjectionSchema(opInfo), true); err != nil {
return err
}
if walker.unverified > 0 {
d.warnf("%s output.stream.select %s resolves in %d event shape(s) but could not be verified in every arm (%s); unverified events without a string at that path are skipped at runtime", owner, stream.Select, walker.resolved, walker.firstReason)
d.warnf("%s %s %s resolves in %d event shape(s) but could not be verified in every arm (%s); unverified events without a string at that path are skipped at runtime", owner, field, selectPath, walker.resolved, walker.firstReason)
}
return nil
}
Expand Down Expand Up @@ -1804,6 +1814,7 @@ type cliStreamPathWalker struct {
d *cliManifestDecoder
owner string
selectPath string
field string

resolved int // arms where the full path resolved to a string/null leaf
unverified int // arms the walk could not see through
Expand Down Expand Up @@ -1881,7 +1892,7 @@ func (w *cliStreamPathWalker) walk(schema any, segments []cliPathSegment, seen [
}
component, ok := w.d.schemaIndex.components[name]
if !ok {
w.err = fmt.Errorf("%s output.stream.select %s: referenced schema %q does not exist in components.schemas%s", w.owner, w.selectPath, name, cliDidYouMean(name, cliComponentNames(w.d.schemaIndex.components)))
w.err = fmt.Errorf("%s %s %s: referenced schema %q does not exist in components.schemas%s", w.owner, w.field, w.selectPath, name, cliDidYouMean(name, cliComponentNames(w.d.schemaIndex.components)))
return
}
w.walk(component, segments, append(seen, name))
Expand Down Expand Up @@ -2016,7 +2027,11 @@ func (w *cliStreamPathWalker) checkLeaf(schemaMap map[string]any, seen []string)
case leafString:
w.resolved++
case leafOther:
w.err = fmt.Errorf("%s output.stream.select %s resolves to %s in one event shape; the streamed projection writes string values raw, so select must address a string field", w.owner, w.selectPath, kind)
if w.field == "output.stream.metadata.select" {
w.err = fmt.Errorf("%s %s %s resolves to %s in one event shape; metadata must address a string field", w.owner, w.field, w.selectPath, kind)
} else {
w.err = fmt.Errorf("%s %s %s resolves to %s in one event shape; the streamed projection writes string values raw, so select must address a string field", w.owner, w.field, w.selectPath, kind)
}
default:
w.unverifiable("the selected field is untyped")
}
Expand Down
65 changes: 65 additions & 0 deletions internal/extensions/cli_commands_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5319,3 +5319,68 @@ commands:
assert.Empty(t, background.DefaultFrom)
assert.Nil(t, background.Default)
}

func TestCLICommands_StreamMetadata(t *testing.T) {
manifest, _, err := decodeCLITest(t, `
version: 1
commands:
say:
op: StreamTask
output:
stream:
select: $.data.delta.text
metadata:
select: $.data.error.message
label: Resource ID
`)
require.NoError(t, err)
metadata := manifest.Commands[0].Output.Stream.Metadata
require.NotNil(t, metadata)
assert.Equal(t, "$.data.error.message", metadata.Select)
assert.Equal(t, "/data/error/message", metadata.Pointer)
assert.Equal(t, "Resource ID", metadata.Label)
}

func TestCLICommands_StreamMetadataValidation(t *testing.T) {
for _, tc := range []struct{ name, metadata, want string }{
{"missing select", "{label: ID}", "requires nonempty select and label"},
{"missing label", "{select: $.data.error.message}", "requires nonempty select and label"},
{"empty label", "{select: $.data.error.message, label: ' '}", "requires nonempty select and label"},
{"label type", "{select: $.data.error.message, label: 12}", "metadata.label"},
{"select type", "{select: [], label: ID}", "metadata.select"},
{"unknown key", "{select: $.data.error.message, label: ID, labels: ID}", "metadata has unknown key"},
{"wildcard", "{select: '$.data[*]', label: ID}", "metadata.select"},
{"absent path", "{select: $.data.missing, label: ID}", "output.stream.metadata.select $.data.missing does not resolve"},
{"number", "{select: $.data.index, label: ID}", "output.stream.metadata.select $.data.index resolves to an integer"},
{"object", "{select: $.data.delta, label: ID}", "output.stream.metadata.select $.data.delta resolves to an object"},
{"newline label", `{select: $.data.error.message, label: "ID\nInjected"}`, "metadata.label must contain only printable"},
{"escape label", `{select: $.data.error.message, label: "ID\x1b"}`, "metadata.label must contain only printable"},
{"bidi label", `{select: $.data.error.message, label: "ID\u202e"}`, "metadata.label must contain only printable"},
{"separator label", `{select: $.data.error.message, label: "ID\u2028"}`, "metadata.label must contain only printable"},
} {
t.Run(tc.name, func(t *testing.T) {
requireDecodeError(t, "version: 1\ncommands:\n say:\n op: StreamTask\n output:\n stream:\n select: $.data.delta.text\n metadata: "+tc.metadata+"\n", tc.want)
})
}
}

func TestCLICommands_StreamMetadataOpenSchema(t *testing.T) {
manifest, warnings, err := decodeCLITest(t, `
version: 1
operations:
StreamTaskLines:
output:
stream:
select: $.text
metadata:
select: $['resource/id~key']
label: Resource ID
`)
require.NoError(t, err)
require.Len(t, manifest.Operations, 1)
require.NotNil(t, manifest.Operations[0].Output.Stream.Metadata)
assert.Equal(t, "/resource~1id~0key", manifest.Operations[0].Output.Stream.Metadata.Pointer)
require.Len(t, warnings, 2)
assert.Contains(t, warnings[1], "output.stream.metadata.select")
assert.Contains(t, warnings[1], "could not be verified")
}
16 changes: 15 additions & 1 deletion scripts/build-review-sdk.sh
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,13 @@ mkdir -p ./zSDKs/sdk-$TARGET/.speakeasy/testfiles
cp ./tests/tests/review/testfiles/postfiletest.txt ./zSDKs/sdk-$TARGET/.speakeasy/testfiles/postfiletest.txt

tmp_tests="$(mktemp "./zSDKs/sdk-$TARGET/.speakeasy/tests.arazzo.yaml.XXXXXX.tmp")"
tmp_spec_base=""
tmp_spec=""
cleanup() {
rm -f "$tmp_tests"
if [[ -n "$tmp_spec_base" ]]; then
rm -f "$tmp_spec_base" "${tmp_spec_base}.yaml"
fi
}
trap cleanup EXIT

Expand Down Expand Up @@ -75,7 +80,16 @@ rm -rf "./testprojects/$TARGET" || true
EXTRA_ARGS="${EXTRA_ARGS:-} ${*:2}"

print_msg_with_ctx "${BUILD_ICON} {green}{bold}Building SDK{reset}" "target=${TARGET}|variant=review|args=${EXTRA_ARGS}"
run_cmd go run cmd/generate/main.go -s ./tests/specs/review.yaml -o ./zSDKs/sdk-$TARGET -l $TARGET --validate-integrity ${EXTRA_ARGS}
review_spec="./tests/specs/review.yaml"
review_overlay="./tests/overlays/review/$TARGET/overlay.yaml"
if [[ -f "$review_overlay" ]]; then
# Keep the resolved spec beside its source so relative references still resolve.
tmp_spec_base="$(mktemp "./tests/specs/review.${TARGET}.XXXXXXXX")"
tmp_spec="${tmp_spec_base}.yaml"
go run ./cmd/overlay -s "$review_spec" -overlay "$review_overlay" -out "$tmp_spec"
review_spec="$tmp_spec"
fi
run_cmd go run cmd/generate/main.go -s "$review_spec" -o ./zSDKs/sdk-$TARGET -l $TARGET --validate-integrity ${EXTRA_ARGS}

# Copy gen.yaml back to source folder after successful build
cp ./zSDKs/sdk-$TARGET/.speakeasy/gen.yaml ./tests/config/review/$TARGET/.speakeasy/gen.yaml
1 change: 1 addition & 0 deletions templates/perms.go
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,7 @@ var fileMode = map[string]fs.FileMode{
"templates/cli/tests/primary/retries_test.go.stmpl": 0o644,
"templates/cli/tests/primary/security_ranking_test.go.stmpl": 0o644,
"templates/cli/tests/primary/servers_test.go.stmpl": 0o644,
"templates/cli/tests/primary/streammetadata_additional_test.go.stmpl": 0o644,
"templates/cli/tests/primary/stringconstraints_test.go.stmpl": 0o644,
"templates/cli/tests/primary/table_additional_test.go.stmpl": 0o644,
"templates/cli/tests/primary/telemetry_test.go.stmpl": 0o644,
Expand Down
Loading
Loading