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
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions cli/src/internal/cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ var (
tableColumns []string
dumpHeaders string
fail bool
expect []string
rawOutput bool
compact bool
)
Expand Down Expand Up @@ -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)")

Expand Down Expand Up @@ -302,6 +304,7 @@ func snapshotConfig() config.Config {
TableColumns: tableColumns,
DumpHeaders: dumpHeaders,
Fail: fail,
Expect: expect,
RawOutput: rawOutput,
Compact: compact,
}
Expand Down
1 change: 1 addition & 0 deletions cli/src/internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ type Config struct {
TableColumns []string
DumpHeaders string
Fail bool
Expect []string
RawOutput bool
Compact bool
}
Expand Down
148 changes: 148 additions & 0 deletions cli/src/internal/service/expect.go
Original file line number Diff line number Diff line change
@@ -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
}
}
178 changes: 178 additions & 0 deletions cli/src/internal/service/expect_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
14 changes: 14 additions & 0 deletions cli/src/internal/service/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand Down
Loading
Loading