Skip to content

Commit c4b79e0

Browse files
feat: add stdin/stdout pipelining support for all CLI commands (#151)
* feat: add stdin support and fix stdout/stderr separation for Unix pipelining Enable reading OpenAPI specs from stdin using '-' as the file argument across all CLI commands. This allows standard Unix piping patterns like: cat spec.yaml | openapi spec validate - cat spec.yaml | openapi spec inline - | openapi spec clean - curl -s https://api.example.com/spec | openapi spec lint -f json - Changes: - Add IsStdin() helper and ReadFromStdin field to OpenAPIProcessor - Support '-' as stdin indicator in all commands: validate, lint, inline, bundle, upgrade, clean, sanitize, optimize, snip, explore, localize, arazzo validate - Move all status/info/warning messages to stderr so stdout is reserved for data output (document content, lint results) - enables clean piping - Guard incompatible flags with stdin: --write, --fix, --fix-interactive, interactive snip mode - Add comprehensive unit and integration tests for stdin/pipelining - Update all command help text to document stdin usage Closes #67 https://claude.ai/code/session_01E9drkMjTuKTfQsLhTLdZxX * feat: auto-detect piped stdin so '-' argument is optional When stdin is connected to a pipe (not a terminal), all commands now automatically read from stdin without requiring the '-' argument. The explicit '-' still works as an override. This means you can now write: cat spec.yaml | openapi spec validate cat spec.yaml | openapi spec inline | openapi spec clean curl -s https://api.example.com/spec | openapi spec lint -f json Instead of requiring: cat spec.yaml | openapi spec validate - Implementation: - Add StdinIsPiped() helper using os.Stdin.Stat() to detect pipe mode - Add stdinOrFileArgs() custom cobra validator that allows 0 args when stdin is piped, with clear error messages otherwise - Add inputFileFromArgs() that returns '-' when no args given - Update all commands (validate, lint, inline, bundle, upgrade, clean, sanitize, optimize, snip, explore, arazzo validate) to use the new helpers - When stdin is not piped and no file given, shows helpful error: "requires at least 1 arg(s), or pipe data to stdin" - Add tests for inputFileFromArgs and stdinOrFileArgs https://claude.ai/code/session_01E9drkMjTuKTfQsLhTLdZxX * feat: add stdin/pipelining support for swagger, overlay commands and deduplicate shared CLI utilities * fix * fix: address PR review feedback and add missing test coverage --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9ee28d3 commit c4b79e0

32 files changed

+1307
-274
lines changed

cmd/openapi/commands/arazzo/validate.go

Lines changed: 27 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,12 @@ import (
44
"context"
55
"errors"
66
"fmt"
7+
"io"
78
"os"
89
"path/filepath"
910

1011
"github.com/speakeasy-api/openapi/arazzo"
12+
"github.com/speakeasy-api/openapi/cmd/openapi/commands/cmdutil"
1113
"github.com/spf13/cobra"
1214
)
1315

@@ -21,14 +23,18 @@ This command will parse and validate the provided Arazzo document, checking for:
2123
- Required fields and proper data types
2224
- Workflow step dependencies and consistency
2325
- Runtime expression validation
24-
- Source description references`,
25-
Args: cobra.ExactArgs(1),
26+
- Source description references
27+
28+
Stdin is supported — either pipe data directly or use '-' explicitly:
29+
cat workflow.yaml | openapi arazzo validate
30+
cat workflow.yaml | openapi arazzo validate -`,
31+
Args: cmdutil.StdinOrFileArgs(1, 1),
2632
Run: runValidate,
2733
}
2834

2935
func runValidate(cmd *cobra.Command, args []string) {
3036
ctx := cmd.Context()
31-
file := args[0]
37+
file := cmdutil.InputFileFromArgs(args)
3238

3339
if err := validateArazzo(ctx, file); err != nil {
3440
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
@@ -37,29 +43,37 @@ func runValidate(cmd *cobra.Command, args []string) {
3743
}
3844

3945
func validateArazzo(ctx context.Context, file string) error {
40-
cleanFile := filepath.Clean(file)
41-
fmt.Printf("Validating Arazzo document: %s\n", cleanFile)
46+
var reader io.ReadCloser
4247

43-
f, err := os.Open(cleanFile)
44-
if err != nil {
45-
return fmt.Errorf("failed to open file: %w", err)
48+
if cmdutil.IsStdin(file) {
49+
fmt.Fprintf(os.Stderr, "Validating Arazzo document from stdin\n")
50+
reader = io.NopCloser(os.Stdin)
51+
} else {
52+
cleanFile := filepath.Clean(file)
53+
fmt.Fprintf(os.Stderr, "Validating Arazzo document: %s\n", cleanFile)
54+
55+
f, err := os.Open(cleanFile)
56+
if err != nil {
57+
return fmt.Errorf("failed to open file: %w", err)
58+
}
59+
reader = f
4660
}
47-
defer f.Close()
61+
defer reader.Close()
4862

49-
_, validationErrors, err := arazzo.Unmarshal(ctx, f)
63+
_, validationErrors, err := arazzo.Unmarshal(ctx, reader)
5064
if err != nil {
5165
return fmt.Errorf("failed to unmarshal file: %w", err)
5266
}
5367

5468
if len(validationErrors) == 0 {
55-
fmt.Printf("✅ Arazzo document is valid - 0 errors\n")
69+
fmt.Fprintf(os.Stderr, "✅ Arazzo document is valid - 0 errors\n")
5670
return nil
5771
}
5872

59-
fmt.Printf("❌ Arazzo document is invalid - %d errors:\n\n", len(validationErrors))
73+
fmt.Fprintf(os.Stderr, "❌ Arazzo document is invalid - %d errors:\n\n", len(validationErrors))
6074

6175
for i, validationErr := range validationErrors {
62-
fmt.Printf("%d. %s\n", i+1, validationErr.Error())
76+
fmt.Fprintf(os.Stderr, "%d. %s\n", i+1, validationErr.Error())
6377
}
6478

6579
return errors.New("arazzo document validation failed")
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
// Package cmdutil provides shared CLI utilities for all command groups.
2+
package cmdutil
3+
4+
import (
5+
"fmt"
6+
"os"
7+
8+
"github.com/spf13/cobra"
9+
)
10+
11+
// StdinIndicator is the conventional Unix indicator to read from stdin.
12+
const StdinIndicator = "-"
13+
14+
// IsStdin returns true if the given path indicates stdin should be used.
15+
func IsStdin(path string) bool {
16+
return path == StdinIndicator
17+
}
18+
19+
// StdinIsPiped returns true when stdin is connected to a pipe (not a terminal),
20+
// meaning data is being piped in from another command or a file redirect.
21+
func StdinIsPiped() bool {
22+
fi, err := os.Stdin.Stat()
23+
if err != nil {
24+
return false
25+
}
26+
return (fi.Mode() & os.ModeCharDevice) == 0
27+
}
28+
29+
// InputFileFromArgs returns the first positional arg as the input file path,
30+
// or the stdin indicator "-" when no args are provided.
31+
func InputFileFromArgs(args []string) string {
32+
if len(args) > 0 {
33+
return args[0]
34+
}
35+
return StdinIndicator
36+
}
37+
38+
// StdinOrFileArgs returns a cobra arg validator that accepts minArgs..maxArgs
39+
// when a file is given, but also allows zero args when stdin is piped.
40+
func StdinOrFileArgs(minArgs, maxArgs int) cobra.PositionalArgs {
41+
return func(cmd *cobra.Command, args []string) error {
42+
if len(args) < minArgs {
43+
if len(args) == 0 && StdinIsPiped() {
44+
return nil
45+
}
46+
return fmt.Errorf("requires at least %d arg(s), or pipe data to stdin", minArgs)
47+
}
48+
if maxArgs >= 0 && len(args) > maxArgs {
49+
return fmt.Errorf("accepts at most %d arg(s), received %d", maxArgs, len(args))
50+
}
51+
return nil
52+
}
53+
}
54+
55+
// ArgAt returns args[index] if it exists, or defaultVal otherwise.
56+
func ArgAt(args []string, index int, defaultVal string) string {
57+
if index >= 0 && index < len(args) {
58+
return args[index]
59+
}
60+
return defaultVal
61+
}
62+
63+
// Dief prints a formatted message to stderr and exits with code 1.
64+
func Dief(f string, args ...any) {
65+
fmt.Fprintf(os.Stderr, f+"\n", args...)
66+
os.Exit(1)
67+
}
68+
69+
// Die prints an error to stderr and exits with code 1.
70+
func Die(err error) {
71+
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
72+
os.Exit(1)
73+
}
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
package cmdutil
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/assert"
7+
)
8+
9+
func TestIsStdin(t *testing.T) {
10+
t.Parallel()
11+
12+
tests := []struct {
13+
name string
14+
path string
15+
expected bool
16+
}{
17+
{name: "dash is stdin", path: "-", expected: true},
18+
{name: "empty is not stdin", path: "", expected: false},
19+
{name: "file path is not stdin", path: "spec.yaml", expected: false},
20+
{name: "dash prefix is not stdin", path: "-file", expected: false},
21+
{name: "double dash is not stdin", path: "--", expected: false},
22+
}
23+
24+
for _, tt := range tests {
25+
t.Run(tt.name, func(t *testing.T) {
26+
t.Parallel()
27+
assert.Equal(t, tt.expected, IsStdin(tt.path))
28+
})
29+
}
30+
}
31+
32+
func TestInputFileFromArgs(t *testing.T) {
33+
t.Parallel()
34+
35+
tests := []struct {
36+
name string
37+
args []string
38+
expected string
39+
}{
40+
{name: "no args returns stdin indicator", args: []string{}, expected: StdinIndicator},
41+
{name: "nil args returns stdin indicator", args: nil, expected: StdinIndicator},
42+
{name: "single file arg", args: []string{"spec.yaml"}, expected: "spec.yaml"},
43+
{name: "explicit dash", args: []string{"-"}, expected: "-"},
44+
{name: "multiple args returns first", args: []string{"spec.yaml", "out.yaml"}, expected: "spec.yaml"},
45+
}
46+
47+
for _, tt := range tests {
48+
t.Run(tt.name, func(t *testing.T) {
49+
t.Parallel()
50+
assert.Equal(t, tt.expected, InputFileFromArgs(tt.args))
51+
})
52+
}
53+
}
54+
55+
func TestArgAt(t *testing.T) {
56+
t.Parallel()
57+
58+
tests := []struct {
59+
name string
60+
args []string
61+
index int
62+
defaultVal string
63+
expected string
64+
}{
65+
{name: "returns value at index", args: []string{"a", "b", "c"}, index: 1, defaultVal: "", expected: "b"},
66+
{name: "returns first element", args: []string{"a"}, index: 0, defaultVal: "x", expected: "a"},
67+
{name: "returns default when out of range", args: []string{"a"}, index: 1, defaultVal: "default", expected: "default"},
68+
{name: "returns default for empty args", args: []string{}, index: 0, defaultVal: "default", expected: "default"},
69+
{name: "returns default for nil args", args: nil, index: 0, defaultVal: "default", expected: "default"},
70+
{name: "returns empty default", args: []string{}, index: 0, defaultVal: "", expected: ""},
71+
{name: "returns default for negative index", args: []string{"a", "b"}, index: -1, defaultVal: "default", expected: "default"},
72+
{name: "returns default for large negative index", args: []string{"a"}, index: -100, defaultVal: "fallback", expected: "fallback"},
73+
}
74+
75+
for _, tt := range tests {
76+
t.Run(tt.name, func(t *testing.T) {
77+
t.Parallel()
78+
assert.Equal(t, tt.expected, ArgAt(tt.args, tt.index, tt.defaultVal))
79+
})
80+
}
81+
}
82+
83+
func TestStdinOrFileArgs(t *testing.T) {
84+
t.Parallel()
85+
86+
validator := StdinOrFileArgs(1, 2)
87+
88+
t.Run("accepts one arg", func(t *testing.T) {
89+
t.Parallel()
90+
err := validator(nil, []string{"spec.yaml"})
91+
assert.NoError(t, err)
92+
})
93+
94+
t.Run("accepts two args", func(t *testing.T) {
95+
t.Parallel()
96+
err := validator(nil, []string{"spec.yaml", "out.yaml"})
97+
assert.NoError(t, err)
98+
})
99+
100+
t.Run("rejects three args with max 2", func(t *testing.T) {
101+
t.Parallel()
102+
err := validator(nil, []string{"a", "b", "c"})
103+
assert.Error(t, err)
104+
assert.Contains(t, err.Error(), "accepts at most 2 arg(s)")
105+
})
106+
107+
t.Run("unbounded max accepts many args", func(t *testing.T) {
108+
t.Parallel()
109+
unbounded := StdinOrFileArgs(1, -1)
110+
err := unbounded(nil, []string{"a", "b", "c", "d", "e"})
111+
assert.NoError(t, err, "negative maxArgs should allow unlimited args")
112+
})
113+
114+
t.Run("zero minArgs accepts any arg count", func(t *testing.T) {
115+
t.Parallel()
116+
noMin := StdinOrFileArgs(0, 2)
117+
err := noMin(nil, []string{})
118+
assert.NoError(t, err, "zero minArgs should accept empty args")
119+
})
120+
121+
t.Run("error message includes min arg count", func(t *testing.T) {
122+
t.Parallel()
123+
min3 := StdinOrFileArgs(3, 5)
124+
err := min3(nil, []string{"a", "b"})
125+
if err != nil {
126+
assert.Contains(t, err.Error(), "requires at least 3 arg(s)")
127+
}
128+
})
129+
}

cmd/openapi/commands/openapi/bundle.go

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ var bundleCmd = &cobra.Command{
2020
Long: `Bundle transforms an OpenAPI document by bringing all external references into the components section,
2121
creating a self-contained document that maintains the reference structure but doesn't depend on external files.
2222
23+
Stdin is supported — either pipe data directly or use '-' explicitly:
24+
cat spec.yaml | openapi spec bundle
25+
cat spec.yaml | openapi spec bundle -
26+
2327
This operation is useful when you want to:
2428
• Create portable documents that combine multiple OpenAPI files
2529
• Maintain reference structure for tooling that supports references
@@ -42,7 +46,7 @@ Examples:
4246
4347
# Bundle with filepath naming (default)
4448
openapi spec bundle --naming filepath ./spec.yaml ./bundled.yaml`,
45-
Args: cobra.RangeArgs(1, 2),
49+
Args: stdinOrFileArgs(1, 2),
4650
RunE: runBundleCommand,
4751
}
4852

@@ -55,11 +59,8 @@ func runBundleCommand(cmd *cobra.Command, args []string) error {
5559
ctx := context.Background()
5660

5761
// Parse arguments
58-
inputFile := args[0]
59-
var outputFile string
60-
if len(args) > 1 {
61-
outputFile = args[1]
62-
}
62+
inputFile := inputFileFromArgs(args)
63+
outputFile := outputFileFromArgs(args)
6364

6465
// Validate naming strategy
6566
var namingStrategy openapi.BundleNamingStrategy
@@ -88,10 +89,14 @@ func runBundleCommand(cmd *cobra.Command, args []string) error {
8889
processor.ReportValidationErrors(validationErrors)
8990

9091
// Configure bundle options
92+
targetLocation := inputFile
93+
if processor.ReadFromStdin {
94+
targetLocation = ""
95+
}
9196
opts := openapi.BundleOptions{
9297
ResolveOptions: openapi.ResolveOptions{
9398
RootDocument: doc,
94-
TargetLocation: inputFile,
99+
TargetLocation: targetLocation,
95100
},
96101
NamingStrategy: namingStrategy,
97102
}

cmd/openapi/commands/openapi/clean.go

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@ var cleanCmd = &cobra.Command{
1515
Short: "Remove unused components and unused top-level tags from an OpenAPI specification",
1616
Long: `Remove unused components and unused top-level tags from an OpenAPI specification to create a cleaner, more focused document.
1717
18+
Stdin is supported — either pipe data directly or use '-' explicitly:
19+
cat spec.yaml | openapi spec clean
20+
cat spec.yaml | openapi spec clean -
21+
1822
This command uses reachability-based analysis to keep only what is actually used by the API surface:
1923
- Seeds reachability exclusively from API surface areas: entries under /paths and the top-level security section
2024
- Expands through $ref links across component sections until a fixed point is reached
@@ -52,7 +56,7 @@ Output options:
5256
- No output file specified: writes to stdout (pipe-friendly)
5357
- Output file specified: writes to the specified file
5458
- --write flag: writes in-place to the input file`,
55-
Args: cobra.RangeArgs(1, 2),
59+
Args: stdinOrFileArgs(1, 2),
5660
Run: runClean,
5761
}
5862

@@ -64,12 +68,9 @@ func init() {
6468

6569
func runClean(cmd *cobra.Command, args []string) {
6670
ctx := cmd.Context()
67-
inputFile := args[0]
71+
inputFile := inputFileFromArgs(args)
6872

69-
var outputFile string
70-
if len(args) > 1 {
71-
outputFile = args[1]
72-
}
73+
outputFile := outputFileFromArgs(args)
7374

7475
processor, err := NewOpenAPIProcessor(inputFile, outputFile, cleanWriteInPlace)
7576
if err != nil {

0 commit comments

Comments
 (0)