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
2 changes: 1 addition & 1 deletion .release-please-manifest.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
{
".": "3.1.6"
".": "3.2.0"
}
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,24 @@
# Changelog

## 3.2.0 (2026-03-06)

Full Changelog: [v3.1.6...v3.2.0](https://github.com/trycourier/courier-cli/compare/v3.1.6...v3.2.0)

### Features

* add `--max-items` flag for paginated/streaming endpoints ([1b04d3a](https://github.com/trycourier/courier-cli/commit/1b04d3ac7ba4296f61e0b0c857e2275032b0dbe0))
* support passing required body params through pipes ([a555982](https://github.com/trycourier/courier-cli/commit/a555982030cb1767e781eeeb88c72a4de084fde9))


### Bug Fixes

* fix for encoding arrays with `any` type items ([e5d46ce](https://github.com/trycourier/courier-cli/commit/e5d46ce9dfc8fce75ad022d7056b9844d0aed4c2))


### Chores

* **internal:** codegen related update ([05da3b8](https://github.com/trycourier/courier-cli/commit/05da3b8bce65dc6b4418aafa51e163bbc29e8275))

## 3.1.6 (2026-03-04)

Full Changelog: [v3.1.5...v3.1.6](https://github.com/trycourier/courier-cli/compare/v3.1.5...v3.1.6)
Expand Down
9 changes: 9 additions & 0 deletions internal/apiform/encoder.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,15 @@ func (e *encoder) encodeArray(key string, val reflect.Value, writer *multipart.W
var values []string
for i := 0; i < val.Len(); i++ {
item := val.Index(i)
if (item.Kind() == reflect.Pointer || item.Kind() == reflect.Interface) && item.IsNil() {
// Null values are sent as an empty string
values = append(values, "")
continue
}
// If item is an interface, reduce it to the concrete type
if item.Kind() == reflect.Interface {
item = item.Elem()
}
var strValue string
switch item.Kind() {
case reflect.String:
Expand Down
2 changes: 1 addition & 1 deletion internal/binaryparam/binary_param.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ func FileOrStdin(stdin io.ReadCloser, path string) (io.ReadCloser, bool, error)
// When the special glyph "-" is used, read from stdin. Although probably less necessary, also support
// special Unix files that refer to stdin.
switch path {
case stdinGlyph, "/dev/fd/0", "/dev/stdin":
case "", stdinGlyph, "/dev/fd/0", "/dev/stdin":
return stdin, true, nil
}

Expand Down
81 changes: 28 additions & 53 deletions internal/binaryparam/binary_param_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,57 +28,32 @@ func TestFileOrStdin(t *testing.T) {
require.False(t, stdinInUse)
})

t.Run("WithStdinGlyph", func(t *testing.T) {
tempFile := t.TempDir() + "/test_file.txt"
require.NoError(t, os.WriteFile(tempFile, []byte(expectedContents), 0600))

stubStdin, err := os.Open(tempFile)
require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, stubStdin.Close()) })

readCloser, stdinInUse, err := FileOrStdin(stubStdin, "-")
require.NoError(t, err)

actualContents, err := io.ReadAll(readCloser)
require.NoError(t, err)
require.Equal(t, expectedContents, string(actualContents))

require.True(t, stdinInUse)
})

t.Run("WithDevFD0File", func(t *testing.T) {
tempFile := t.TempDir() + "/dev_fd_0"
require.NoError(t, os.WriteFile(tempFile, []byte(expectedContents), 0600))

stubStdin, err := os.Open(tempFile)
require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, stubStdin.Close()) })

readCloser, stdinInUse, err := FileOrStdin(stubStdin, "/dev/fd/0")
require.NoError(t, err)

actualContents, err := io.ReadAll(readCloser)
require.NoError(t, err)
require.Equal(t, expectedContents, string(actualContents))

require.True(t, stdinInUse)
})

t.Run("WithDevStdinFile", func(t *testing.T) {
tempFile := t.TempDir() + "/dev_stdin"
require.NoError(t, os.WriteFile(tempFile, []byte(expectedContents), 0600))

stubStdin, err := os.Open(tempFile)
require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, stubStdin.Close()) })

readCloser, stdinInUse, err := FileOrStdin(stubStdin, "/dev/stdin")
require.NoError(t, err)

actualContents, err := io.ReadAll(readCloser)
require.NoError(t, err)
require.Equal(t, expectedContents, string(actualContents))

require.True(t, stdinInUse)
})
stdinTests := []struct {
testName string
path string
}{
{"TestEmptyString", ""},
{"TestDash", "-"},
{"TestDevStdin", "/dev/stdin"},
{"TestDevFD0", "/dev/fd/0"},
}
for _, test := range stdinTests {
t.Run(test.testName, func(t *testing.T) {
tempFile := t.TempDir() + "/test_file.txt"
require.NoError(t, os.WriteFile(tempFile, []byte(expectedContents), 0600))

stubStdin, err := os.Open(tempFile)
require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, stubStdin.Close()) })

readCloser, stdinInUse, err := FileOrStdin(stubStdin, test.path)
require.NoError(t, err)

actualContents, err := io.ReadAll(readCloser)
require.NoError(t, err)
require.Equal(t, expectedContents, string(actualContents))

require.True(t, stdinInUse)
})
}
}
70 changes: 22 additions & 48 deletions internal/mocktest/mocktest.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package mocktest

import (
"bytes"
"context"
"fmt"
"net"
Expand All @@ -14,6 +15,7 @@ import (
"testing"
"time"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

Expand Down Expand Up @@ -54,8 +56,14 @@ func restoreNetwork(origClient, origDefault http.RoundTripper) {
}

// TestRunMockTestWithFlags runs a test against a mock server with the provided
// CLI flags and ensures it succeeds
func TestRunMockTestWithFlags(t *testing.T, flags ...string) {
// CLI args and ensures it succeeds
func TestRunMockTestWithFlags(t *testing.T, args ...string) {
TestRunMockTestWithPipeAndFlags(t, nil, args...)
}

// TestRunMockTestWithPipeAndFlags runs a test against a mock server with the provided
// data piped over stdin and CLI args and ensures it succeeds
func TestRunMockTestWithPipeAndFlags(t *testing.T, pipeData []byte, args ...string) {
origClient, origDefault := blockNetworkExceptMockServer()
defer restoreNetwork(origClient, origDefault)

Expand All @@ -71,52 +79,18 @@ func TestRunMockTestWithFlags(t *testing.T, flags ...string) {
_, filename, _, ok := runtime.Caller(0)
require.True(t, ok, "Could not get current file path")
dirPath := filepath.Dir(filename)
project := filepath.Join(dirPath, "..", "..", "cmd", "...")

args := []string{"run", project, "--base-url", mockServerURL.String()}
args = append(args, flags...)

t.Logf("Testing command: courier %s", strings.Join(args[4:], " "))

cliCmd := exec.Command("go", args...)

// Pipe the CLI tool's output into `head` so it doesn't hang when simulating
// paginated or streamed endpoints. 100 lines of output should be enough to
// test that the API endpoint worked, or report back a meaningful amount of
// data if something went wrong.
headCmd := exec.Command("head", "-n", "100")
pipe, err := cliCmd.StdoutPipe()
require.NoError(t, err, "Failed to create pipe for CLI command")
headCmd.Stdin = pipe

// Capture `head` output and CLI command stderr outputs:
var output strings.Builder
headCmd.Stdout = &output
headCmd.Stderr = &output
cliCmd.Stderr = &output

// First start `head`, so it's ready for data to come in:
err = headCmd.Start()
require.NoError(t, err, "Failed to start `head` command")

// Next start the CLI command so it can pipe data to `head` without
// buffering any data in advance:
err = cliCmd.Start()
require.NoError(t, err, "Failed to start CLI command")

// Ensure that the stdout pipe is closed as soon as `head` exits, to let the
// CLI tool know that no more output is needed and it can stop streaming
// test data for streaming/paginated endpoints. This needs to happen before
// calling `cliCmd.Wait()`, otherwise there will be a deadlock.
err = headCmd.Wait()
pipe.Close()
require.NoError(t, err, "`head` command finished with an error")

// Finally, wait for the CLI tool to finish up:
err = cliCmd.Wait()
require.NoError(t, err, "CLI command failed\n%s", output.String())

t.Logf("Test passed successfully\nOutput:\n%s", output.String())
project := filepath.Join(dirPath, "..", "..", "cmd", "courier")

args = append([]string{"run", project, "--base-url", mockServerURL.String()}, args...)

t.Logf("Testing command: go run ./cmd/courier %s", strings.Join(args[2:], " "))

cmd := exec.Command("go", args...)
cmd.Stdin = bytes.NewReader(pipeData)
output, err := cmd.CombinedOutput()
assert.NoError(t, err, "Test failed\nError: %v\nOutput: %s", err, output)

t.Logf("Test passed successfully\nOutput:\n%s", string(output))
}

func TestFile(t *testing.T, contents string) string {
Expand Down
47 changes: 47 additions & 0 deletions internal/requestflag/requestflag.go
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,40 @@ func ExtractRequestContents(cmd *cli.Command) RequestContents {
return res
}

func GetMissingRequiredFlags(cmd *cli.Command, body any) []cli.Flag {
missing := []cli.Flag{}
for _, flag := range cmd.Flags {
if flag.IsSet() {
continue
}

if required, ok := flag.(cli.RequiredFlag); ok && required.IsRequired() {
missing = append(missing, flag)
continue
}

if r, ok := flag.(RequiredFlagOrStdin); !ok || !r.IsRequiredAsFlagOrStdin() {
continue
}

if toSend, ok := flag.(InRequest); ok {
if toSend.IsBodyRoot() {
if body != nil {
continue
}
} else if bodyPath := toSend.GetBodyPath(); bodyPath != "" {
if bodyMap, ok := body.(map[string]any); ok {
if _, found := bodyMap[bodyPath]; found {
continue
}
}
}
}
missing = append(missing, flag)
}
return missing
}

// Implementation of the cli.Flag interface
var _ cli.Flag = (*Flag[any])(nil) // Type assertion to ensure interface compliance

Expand Down Expand Up @@ -221,6 +255,19 @@ func (f *Flag[T]) SetCategory(c string) {
var _ cli.RequiredFlag = (*Flag[any])(nil) // Type assertion to ensure interface compliance

func (f *Flag[T]) IsRequired() bool {
// Intentionally don't use `f.Required`, because request flags may be passed
// over stdin as well as by flag.
if f.BodyPath != "" || f.BodyRoot {
return false
}
return f.Required
}

type RequiredFlagOrStdin interface {
IsRequiredAsFlagOrStdin() bool
}

func (f *Flag[T]) IsRequiredAsFlagOrStdin() bool {
return f.Required
}

Expand Down
Loading
Loading