From 1783179979ad97ab59d4419b351e159ad8889ec2 Mon Sep 17 00:00:00 2001 From: Jon Gallant <2163001+jongio@users.noreply.github.com> Date: Sun, 12 Jul 2026 10:54:17 -0700 Subject: [PATCH] feat: add --expect to assert JMESPath expressions on the response Add a repeatable --expect flag that evaluates a JMESPath expression against the JSON response and exits non-zero when it does not hold, so a script or CI step can assert on a response without piping --query into test or jq. A bare expression must be truthy under JMESPath rules. The expr=value form requires the result to equal value, comparing scalars by their plain string value so properties.provisioningState=Succeeded and count=3 both work. The split keeps JMESPath comparators (==, !=, <=, >=) intact by only splitting on a standalone =. The response body still prints, then the check runs against the original body so --expect stays independent of --query. An assertion that fails exits 1; a non-JSON response, an empty expression, or an invalid expression exits 2. Closes #269 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- README.md | 4 + cli/src/internal/cmd/root.go | 3 + cli/src/internal/config/config.go | 1 + cli/src/internal/service/expect.go | 148 ++++++++++++++++++++ cli/src/internal/service/expect_test.go | 178 ++++++++++++++++++++++++ cli/src/internal/service/service.go | 14 ++ web/src/pages/reference.astro | 3 +- 7 files changed, 350 insertions(+), 1 deletion(-) create mode 100644 cli/src/internal/service/expect.go create mode 100644 cli/src/internal/service/expect_test.go diff --git a/README.md b/README.md index e3157eb..00ec64f 100644 --- a/README.md +++ b/README.md @@ -142,6 +142,10 @@ azd rest doctor # Exit non-zero (code 22) on an HTTP error so scripts and CI stop on failure azd rest get https://management.azure.com/subscriptions/{sub}/resourceGroups/{rg}?api-version=2021-04-01 --fail + +# Assert on the response body in CI: fail the step unless the resource is provisioned +azd rest get https://management.azure.com/subscriptions/{sub}/resourceGroups/{rg}?api-version=2021-04-01 \ + --expect "properties.provisioningState=Succeeded" ``` For the complete command and flag reference, see the [CLI Reference](https://jongio.github.io/azd-rest/reference/cli/) on the website. diff --git a/cli/src/internal/cmd/root.go b/cli/src/internal/cmd/root.go index 35e9e2a..335e13b 100644 --- a/cli/src/internal/cmd/root.go +++ b/cli/src/internal/cmd/root.go @@ -62,6 +62,7 @@ var ( tableColumns []string dumpHeaders string fail bool + expect []string rawOutput bool compact bool ) @@ -227,6 +228,7 @@ Examples: rootCmd.PersistentFlags().StringSliceVar(&tableColumns, "table-columns", nil, "Comma-separated columns to show, in order, for --format table (ignored for other formats)") rootCmd.PersistentFlags().StringVar(&dumpHeaders, "dump-headers", "", "Write response status line and headers to a file (use - for stderr)") rootCmd.PersistentFlags().BoolVar(&fail, "fail", false, "Exit with code 22 when the response status is 400 or higher (the response body is still printed)") + rootCmd.PersistentFlags().StringArrayVar(&expect, "expect", []string{}, "Assert a JMESPath expression against the JSON response (repeatable). Bare expression must be truthy; expr=value requires equality. Exits non-zero when an assertion fails") rootCmd.PersistentFlags().BoolVarP(&rawOutput, "raw-output", "r", false, "With --query, print a string result unquoted and an array of strings one per line (like jq -r)") rootCmd.PersistentFlags().BoolVarP(&compact, "compact", "c", false, "Minify JSON output to a single line (applies to auto and json formats and --query results)") @@ -302,6 +304,7 @@ func snapshotConfig() config.Config { TableColumns: tableColumns, DumpHeaders: dumpHeaders, Fail: fail, + Expect: expect, RawOutput: rawOutput, Compact: compact, } diff --git a/cli/src/internal/config/config.go b/cli/src/internal/config/config.go index 0e8015c..b8a92ff 100644 --- a/cli/src/internal/config/config.go +++ b/cli/src/internal/config/config.go @@ -48,6 +48,7 @@ type Config struct { TableColumns []string DumpHeaders string Fail bool + Expect []string RawOutput bool Compact bool } diff --git a/cli/src/internal/service/expect.go b/cli/src/internal/service/expect.go new file mode 100644 index 0000000..89b7876 --- /dev/null +++ b/cli/src/internal/service/expect.go @@ -0,0 +1,148 @@ +package service + +import ( + "bytes" + "encoding/json" + "fmt" + "strings" + + "github.com/jmespath-community/go-jmespath" + "github.com/jongio/azd-rest/src/internal/client" +) + +// expectUsageError signals invalid --expect usage: a non-JSON response, an +// invalid JMESPath expression, or a malformed --expect argument. It reports +// exit code 2 (the invalid-configuration code) through the ExitCoder contract +// so main can tell it apart from an assertion that simply did not hold (exit 1). +type expectUsageError struct{ msg string } + +func (e *expectUsageError) Error() string { return e.msg } + +// ExitCode returns 2 for invalid --expect usage. +func (e *expectUsageError) ExitCode() int { return 2 } + +// evaluateExpectations checks every --expect assertion against the JSON body. +// Each assertion is a JMESPath expression, optionally followed by "=value" to +// require a specific result. A bare expression passes when its result is truthy +// under JMESPath rules. An assertion that does not hold returns a plain error +// (exit 1). A non-JSON body, an empty or invalid expression, or a malformed +// argument returns an expectUsageError (exit 2). +// +// body must be the original response JSON captured before --query rewrites it, +// so --expect asserts on the full response regardless of what --query prints. +func evaluateExpectations(body []byte, contentType string, expects []string) error { + if len(expects) == 0 { + return nil + } + if !strings.Contains(strings.ToLower(contentType), "json") && !client.IsJSON(body) { + return &expectUsageError{msg: "--expect requires a JSON response"} + } + + dec := json.NewDecoder(bytes.NewReader(body)) + dec.UseNumber() + var data any + if err := dec.Decode(&data); err != nil { + return &expectUsageError{msg: fmt.Sprintf("failed to parse JSON response for --expect: %v", err)} + } + + for _, raw := range expects { + if err := evaluateExpectation(data, raw); err != nil { + return err + } + } + return nil +} + +// evaluateExpectation runs a single --expect assertion against the decoded data. +func evaluateExpectation(data any, raw string) error { + expr, expected, hasEquality := splitExpectArg(raw) + if expr == "" { + return &expectUsageError{msg: fmt.Sprintf("invalid --expect %q: expression is empty", raw)} + } + + result, err := jmespath.Search(expr, data) + if err != nil { + return &expectUsageError{msg: fmt.Sprintf("invalid --expect expression %q: %v", expr, err)} + } + + if hasEquality { + got := stringifyExpectResult(result) + if got != strings.TrimSpace(expected) { + return fmt.Errorf("--expect %q failed: expected %q, got %q", raw, strings.TrimSpace(expected), got) + } + return nil + } + + if !isExpectTruthy(result) { + return fmt.Errorf("--expect %q failed: expression is not truthy (got %s)", expr, stringifyExpectResult(result)) + } + return nil +} + +// splitExpectArg separates a JMESPath expression from an optional "=value" +// equality suffix. It splits on the first standalone "=" so JMESPath +// comparators (==, !=, <=, >=) inside a bare boolean expression are preserved. +// When no standalone "=" is present the whole argument is a truthy expression. +func splitExpectArg(raw string) (expr, expected string, hasEquality bool) { + for i := 0; i < len(raw); i++ { + if raw[i] != '=' { + continue + } + // Skip "==" (equality comparator): consume the second '=' too. + if i+1 < len(raw) && raw[i+1] == '=' { + i++ + continue + } + // Skip "!=", "<=", ">=" (a comparator ending in '='). + if i > 0 && (raw[i-1] == '!' || raw[i-1] == '<' || raw[i-1] == '>') { + continue + } + return strings.TrimSpace(raw[:i]), raw[i+1:], true + } + return strings.TrimSpace(raw), "", false +} + +// stringifyExpectResult renders a JMESPath result as a string for equality +// comparison. Scalars render as their plain value so "expr=Succeeded" matches a +// JSON string and "expr=3" matches a number; composite values fall back to their +// compact JSON form. +func stringifyExpectResult(v any) string { + switch t := v.(type) { + case string: + return t + case bool: + if t { + return "true" + } + return "false" + case json.Number: + return t.String() + default: + // Covers nil (marshals to "null"), numbers, arrays, and objects. + b, err := json.Marshal(t) + if err != nil { + return fmt.Sprintf("%v", t) + } + return string(b) + } +} + +// isExpectTruthy reports whether a JMESPath result is truthy under JMESPath +// rules: false, null, an empty string, an empty array, and an empty object are +// falsy; every other value (including any number) is truthy. +func isExpectTruthy(v any) bool { + switch t := v.(type) { + case nil: + return false + case bool: + return t + case string: + return t != "" + case []any: + return len(t) > 0 + case map[string]any: + return len(t) > 0 + default: + return true + } +} diff --git a/cli/src/internal/service/expect_test.go b/cli/src/internal/service/expect_test.go new file mode 100644 index 0000000..fc1aa1d --- /dev/null +++ b/cli/src/internal/service/expect_test.go @@ -0,0 +1,178 @@ +package service + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func expectTestServer(t *testing.T, body string) *httptest.Server { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(body)) + })) + t.Cleanup(srv.Close) + return srv +} + +func TestSplitExpectArg(t *testing.T) { + cases := []struct { + name string + raw string + wantExpr string + wantExpected string + wantEquality bool + }{ + {"truthy only", "value", "value", "", false}, + {"equality", "properties.state=Succeeded", "properties.state", "Succeeded", true}, + {"equality trims expr", " a.b =x", "a.b", "x", true}, + {"double equals stays truthy", "a=='b'", "a=='b'", "", false}, + {"not equals stays truthy", "a!='b'", "a!='b'", "", false}, + {"greater equals stays truthy", "length(value)>=3", "length(value)>=3", "", false}, + {"less equals stays truthy", "n<=3", "n<=3", "", false}, + {"first standalone equals wins", "a=b=c", "a", "b=c", true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + expr, expected, hasEq := splitExpectArg(tc.raw) + assert.Equal(t, tc.wantExpr, expr) + assert.Equal(t, tc.wantExpected, expected) + assert.Equal(t, tc.wantEquality, hasEq) + }) + } +} + +func TestIsExpectTruthy(t *testing.T) { + assert.False(t, isExpectTruthy(nil)) + assert.False(t, isExpectTruthy(false)) + assert.False(t, isExpectTruthy("")) + assert.False(t, isExpectTruthy([]any{})) + assert.False(t, isExpectTruthy(map[string]any{})) + assert.True(t, isExpectTruthy(true)) + assert.True(t, isExpectTruthy("x")) + assert.True(t, isExpectTruthy([]any{1})) + assert.True(t, isExpectTruthy(map[string]any{"a": 1})) + assert.True(t, isExpectTruthy(0.0)) +} + +func TestEvaluateExpectations_Truthy(t *testing.T) { + body := []byte(`{"value":[{"id":1}],"enabled":true,"name":""}`) + + require.NoError(t, evaluateExpectations(body, "application/json", []string{"value"})) + require.NoError(t, evaluateExpectations(body, "application/json", []string{"enabled"})) + + // An empty string is falsy under JMESPath rules, so the assertion fails. + err := evaluateExpectations(body, "application/json", []string{"name"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "not truthy") +} + +func TestEvaluateExpectations_Equality(t *testing.T) { + body := []byte(`{"properties":{"provisioningState":"Succeeded"},"count":3,"active":true}`) + + require.NoError(t, evaluateExpectations(body, "application/json", + []string{"properties.provisioningState=Succeeded"})) + require.NoError(t, evaluateExpectations(body, "application/json", []string{"count=3"})) + require.NoError(t, evaluateExpectations(body, "application/json", []string{"active=true"})) + + // A missing field resolves to null and can be asserted with "=null". + require.NoError(t, evaluateExpectations(body, "application/json", []string{"missing=null"})) + + err := evaluateExpectations(body, "application/json", + []string{"properties.provisioningState=Failed"}) + require.Error(t, err) + assert.Contains(t, err.Error(), `expected "Failed"`) + assert.Contains(t, err.Error(), `got "Succeeded"`) +} + +func TestEvaluateExpectations_MultipleStopAtFirstFailure(t *testing.T) { + body := []byte(`{"a":1,"b":2}`) + err := evaluateExpectations(body, "application/json", []string{"a=1", "b=99", "missing"}) + require.Error(t, err) + assert.Contains(t, err.Error(), `--expect "b=99" failed`) +} + +func TestEvaluateExpectations_UsageErrorsExit2(t *testing.T) { + var coder exitCoder + + // Non-JSON body. + err := evaluateExpectations([]byte("plain text"), "text/plain", []string{"a"}) + require.Error(t, err) + require.True(t, errors.As(err, &coder)) + assert.Equal(t, 2, coder.ExitCode()) + assert.Contains(t, err.Error(), "requires a JSON response") + + // Invalid JMESPath expression. + err = evaluateExpectations([]byte(`{"a":1}`), "application/json", []string{"a[["}) + require.Error(t, err) + require.True(t, errors.As(err, &coder)) + assert.Equal(t, 2, coder.ExitCode()) + + // Empty expression. + err = evaluateExpectations([]byte(`{"a":1}`), "application/json", []string{"=x"}) + require.Error(t, err) + require.True(t, errors.As(err, &coder)) + assert.Equal(t, 2, coder.ExitCode()) +} + +func TestEvaluateExpectations_NoExpectsIsNoOp(t *testing.T) { + require.NoError(t, evaluateExpectations([]byte("not json"), "text/plain", nil)) +} + +func TestExecute_Expect_PassPrintsBody(t *testing.T) { + srv := expectTestServer(t, `{"status":"Succeeded"}`) + + cfg := baseTestConfig(t) + cfg.Expect = []string{"status=Succeeded"} + + err := newTestService().Execute(context.Background(), cfg, "GET", srv.URL) + require.NoError(t, err) + + out, readErr := os.ReadFile(cfg.OutputFile) + require.NoError(t, readErr) + assert.Contains(t, string(out), "Succeeded") +} + +func TestExecute_Expect_FailureReturnsExit1AndStillPrints(t *testing.T) { + srv := expectTestServer(t, `{"status":"Failed"}`) + + cfg := baseTestConfig(t) + cfg.Expect = []string{"status=Succeeded"} + + err := newTestService().Execute(context.Background(), cfg, "GET", srv.URL) + require.Error(t, err) + + // An assertion failure is a plain error, so it exits 1, not 2 (usage). + var coder exitCoder + assert.False(t, errors.As(err, &coder), "assertion failure should not be an ExitCoder") + + // The body is written before the assertion is checked. + out, readErr := os.ReadFile(cfg.OutputFile) + require.NoError(t, readErr) + assert.Contains(t, string(out), "Failed") +} + +func TestExecute_Expect_IndependentOfQuery(t *testing.T) { + // --query narrows the printed output, but --expect still asserts against the + // full response body. + srv := expectTestServer(t, `{"name":"vm1","properties":{"state":"Succeeded"}}`) + + cfg := baseTestConfig(t) + cfg.Query = "name" + cfg.Expect = []string{"properties.state=Succeeded"} + + err := newTestService().Execute(context.Background(), cfg, "GET", srv.URL) + require.NoError(t, err) + + out, readErr := os.ReadFile(cfg.OutputFile) + require.NoError(t, readErr) + assert.Contains(t, string(out), "vm1") +} diff --git a/cli/src/internal/service/service.go b/cli/src/internal/service/service.go index c12646c..9b2d06b 100644 --- a/cli/src/internal/service/service.go +++ b/cli/src/internal/service/service.go @@ -436,6 +436,10 @@ func (s *RequestService) Execute(ctx context.Context, cfg config.Config, method, return err } + // Capture the original response body before --query rewrites it so --expect + // asserts on the full response regardless of what --query prints (#269). + originalBody := resp.Body + if cfg.Query != "" { if err := applyQueryToResponse(resp, cfg.Query); err != nil { return err @@ -460,6 +464,16 @@ func (s *RequestService) Execute(ctx context.Context, cfg config.Config, method, fmt.Fprint(os.Stderr, ExpandWriteOut(cfg.WriteOut, opts.Method, opts.URL, resp)) } + // --expect (#269): after the body has been written, assert JMESPath + // expressions against the original response and return a non-zero exit when + // one does not hold. Evaluated before --fail so an explicit body assertion + // is reported ahead of the coarser status-code gate. + if len(cfg.Expect) > 0 { + if err := evaluateExpectations(originalBody, resp.Headers.Get("Content-Type"), cfg.Expect); err != nil { + return err + } + } + // --fail (#233): after the body and metadata have been written, return a // non-zero exit for an error status so scripts and CI can detect failures. if cfg.Fail && resp.StatusCode >= 400 { diff --git a/web/src/pages/reference.astro b/web/src/pages/reference.astro index 446eed5..fd570e7 100644 --- a/web/src/pages/reference.astro +++ b/web/src/pages/reference.astro @@ -126,6 +126,7 @@ const base = import.meta.env.BASE_URL; --repeatint1Send the request N times and print latency stats (min/mean/p50/p95/p99/max) to stderr; only the last response body goes to stdout --allow-hoststring[][]Restrict requests to hosts matching a pattern (repeatable; leading *. matches subdomains). Env: AZD_REST_ALLOWED_HOSTS (comma separated) --failboolfalseExit with code 22 when the response status is 400 or higher. The response body is still printed, so error details stay visible. + --expectstring[][]Assert a JMESPath expression against the JSON response (repeatable). A bare expression must be truthy; expr=value requires the result to equal value. The body is still printed, then the command exits non-zero when an assertion fails (exit 2 for a non-JSON response or an invalid expression). @@ -308,7 +309,7 @@ azd rest get https://api.example.com/resource --retry 0`} language="bash" /> 0Success - 1Request failed (network error, invalid response) + 1Request failed (network error, invalid response) or an --expect assertion did not hold 2Invalid arguments or configuration 22Response status was 400 or higher and --fail was set