diff --git a/cli/cli.go b/cli/cli.go index 763217a..fd45576 100644 --- a/cli/cli.go +++ b/cli/cli.go @@ -552,6 +552,7 @@ Not after (expires): %s (%s) AddGlobalFlag("rsh-client-cert", "", "Path to a PEM encoded client certificate", "", false) AddGlobalFlag("rsh-client-key", "", "Path to a PEM encoded private key", "", false) AddGlobalFlag("rsh-ca-cert", "", "Path to a PEM encoded CA cert", "", false) + AddGlobalFlag("rsh-ignore-status-code", "", "Do not set exit code from HTTP status code", false, false) Root.RegisterFlagCompletionFunc("rsh-output-format", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { return []string{"auto", "json", "yaml"}, cobra.ShellCompDirectiveNoFileComp @@ -818,3 +819,12 @@ func Run() (returnErr error) { return returnErr } + +// GetExitCode returns the exit code to use based on the last HTTP status code. +func GetExitCode() int { + if s := GetLastStatus() / 100; s > 2 && !viper.GetBool("rsh-ignore-status-code") { + return s + } + + return 0 +} diff --git a/cli/cli_test.go b/cli/cli_test.go index 4bf554f..58275c2 100644 --- a/cli/cli_test.go +++ b/cli/cli_test.go @@ -53,6 +53,10 @@ func expectJSON(t *testing.T, cmd string, expected string) { assert.JSONEq(t, expected, captured) } +func expectExitCode(t *testing.T, expected int) { + assert.Equal(t, expected, GetExitCode()) +} + func TestGetURI(t *testing.T) { defer gock.Off() @@ -63,6 +67,7 @@ func TestGetURI(t *testing.T) { expectJSON(t, "http://example.com/foo", `{ "Hello": "World" }`) + expectExitCode(t, 0) } func TestPostURI(t *testing.T) { @@ -82,13 +87,27 @@ func TestPostURI(t *testing.T) { func TestPutURI400(t *testing.T) { defer gock.Off() - gock.New("http://example.com").Put("/foo/1").Reply(400).JSON(map[string]interface{}{ + gock.New("http://example.com").Put("/foo/1").Reply(422).JSON(map[string]interface{}{ "detail": "Invalid input", }) expectJSON(t, "put http://example.com/foo/1 value: 123", `{ "detail": "Invalid input" }`) + expectExitCode(t, 4) +} + +func TestIgnoreStatusCodeExit(t *testing.T) { + defer gock.Off() + + gock.New("http://example.com").Put("/foo/1").Reply(400).JSON(map[string]interface{}{ + "detail": "Invalid input", + }) + + expectJSON(t, "put http://example.com/foo/1 value: 123 --rsh-ignore-status-code", `{ + "detail": "Invalid input" + }`) + expectExitCode(t, 0) } func TestHeaderWithComma(t *testing.T) { diff --git a/cli/request.go b/cli/request.go index 3ad304a..288fd40 100644 --- a/cli/request.go +++ b/cli/request.go @@ -16,6 +16,15 @@ import ( "github.com/spf13/viper" ) +// lastStatus is the last HTTP status code returned by a request. +var lastStatus int + +// GetLastStatus returns the last HTTP status code returned by a request. A +// request can opt out of this via the IgnoreStatus option. +func GetLastStatus() int { + return lastStatus +} + // FixAddress can convert `:8000` or `example.com` to a full URL. func FixAddress(addr string) string { return fixAddress(addr) @@ -60,8 +69,9 @@ func fixAddress(addr string) string { } type requestOption struct { - client *http.Client - disableLog bool + client *http.Client + disableLog bool + ignoreStatus bool } // WithClient sets the client to use for the request. @@ -78,6 +88,13 @@ func WithoutLog() requestOption { } } +// IgnoreStatus ignores the response status code. +func IgnoreStatus() requestOption { + return requestOption{ + ignoreStatus: true, + } +} + // MakeRequest makes an HTTP request using the default client. It adds the // user-agent, auth, and any passed headers or query params to the request // before sending it out on the wire. If verbose mode is enabled, it will @@ -226,6 +243,7 @@ func MakeRequest(req *http.Request, options ...requestOption) (*http.Response, e } log := true + setStatus := true for _, option := range options { if option.client != nil { client = option.client @@ -234,6 +252,10 @@ func MakeRequest(req *http.Request, options ...requestOption) (*http.Response, e if option.disableLog { log = false } + + if option.ignoreStatus { + setStatus = false + } } if log { @@ -245,6 +267,10 @@ func MakeRequest(req *http.Request, options ...requestOption) (*http.Response, e return nil, err } + if setStatus { + lastStatus = resp.StatusCode + } + if log { LogDebugResponse(start, resp) } @@ -350,8 +376,8 @@ func ParseResponse(resp *http.Response) (Response, error) { // GetParsedResponse makes a request and gets the parsed response back. It // handles any auto-pagination or linking that needs to be done and may // return a psuedo-responsse that is a combination of all responses. -func GetParsedResponse(req *http.Request) (Response, error) { - resp, err := MakeRequest(req) +func GetParsedResponse(req *http.Request, options ...requestOption) (Response, error) { + resp, err := MakeRequest(req, options...) if err != nil { return Response{}, err } @@ -388,7 +414,7 @@ func GetParsedResponse(req *http.Request) (Response, error) { next = base.ResolveReference(next) req, _ = http.NewRequest(http.MethodGet, next.String(), nil) - resp, err = MakeRequest(req) + resp, err = MakeRequest(req, options...) if err != nil { return Response{}, err } diff --git a/cli/request_test.go b/cli/request_test.go index 39a9dc0..e92945f 100644 --- a/cli/request_test.go +++ b/cli/request_test.go @@ -85,3 +85,41 @@ func TestAuthHookFailure(t *testing.T) { MakeRequest(r) }) } + +func TestGetStatus(t *testing.T) { + defer gock.Off() + + reset(false) + lastStatus = 0 + + gock.New("http://example.com"). + Get("/"). + Reply(http.StatusOK) + + req, _ := http.NewRequest(http.MethodGet, "http://example.com/", nil) + resp, err := MakeRequest(req) + + assert.NoError(t, err) + assert.Equal(t, resp.StatusCode, http.StatusOK) + + assert.Equal(t, http.StatusOK, GetLastStatus()) +} + +func TestIgnoreStatus(t *testing.T) { + defer gock.Off() + + reset(false) + lastStatus = 0 + + gock.New("http://example.com"). + Get("/"). + Reply(http.StatusOK) + + req, _ := http.NewRequest(http.MethodGet, "http://example.com/", nil) + resp, err := MakeRequest(req, IgnoreStatus()) + + assert.NoError(t, err) + assert.Equal(t, resp.StatusCode, http.StatusOK) + + assert.Equal(t, 0, GetLastStatus()) +} diff --git a/docs/output.md b/docs/output.md index 1653ae5..ba7c87a 100644 --- a/docs/output.md +++ b/docs/output.md @@ -238,3 +238,18 @@ $ restish api.rest.sh/types -H Accept:application/json -r >types.json ``` ?> Raw mode without filtering will not parse the response, but _will_ decode it if compressed (e.g. with gzip or brotli). + +## Exit Status Codes + +Restish will exit with the following status codes by default in order to facilitate scripting. The most recent HTTP status code is used when a command makes more than one request. + +| Code | Description | +| ---- | -------------------- | +| 0 | Success | +| 1 | Unrecoverable errors | +| 2 | - | +| 3 | 3xx HTTP response | +| 4 | 4xx HTTP response | +| 5 | 5xx HTTP response | + +Use the `--rsh-ignore-status-code` option or `RSH_IGNORE_STATUS_CODE=1` environment variable to ignore the exit status code and always return 0 for 3xx/4xx/5xx responses. diff --git a/main.go b/main.go index f9bec00..a5e6cd4 100644 --- a/main.go +++ b/main.go @@ -40,4 +40,7 @@ func main() { if err := cli.Run(); err != nil { os.Exit(1) } + + // Exit based on the status code of the last request. + os.Exit(cli.GetExitCode()) }