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 @@ -137,6 +137,10 @@ azd rest put https://management.azure.com/subscriptions/{sub}/resourceGroups/{rg
azd rest get https://management.azure.com/subscriptions/{sub}/resourceGroups/{rg}?api-version=2021-04-01 \
--flatten

# Fail if a required response header is missing or has a different value
azd rest get https://management.azure.com/subscriptions?api-version=2020-01-01 \
--expect-header "Content-Type=application/json"

# Diagnose authentication issues
azd rest doctor

Expand Down
15 changes: 15 additions & 0 deletions cli/docs/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,7 @@ These flags are available for all HTTP method commands:
| `--redact` | | string[] | [] | Mask a JSON response field before output (repeatable, dotted path, `*` matches array elements). |
| `--binary` | | bool | false | Stream request/response as binary without transformation. |
| `--include` | `-i` | bool | false | Include the HTTP status line and response headers in the output (curl `-i` style). Sensitive header values are redacted. |
| `--expect-header` | | string[] | [] | Require a response header, optionally with an exact value. Repeatable. Formats: `Name`, `Name=value`, or `Name: value`. |
| `--verbose` | `-v` | bool | false | Verbose output (show headers, timing, request details). |
| `--silent` | | bool | false | Suppress non-error diagnostic messages on stderr (warnings and notices). Errors and response output are unaffected. |

Expand Down Expand Up @@ -677,6 +678,20 @@ x-ms-request-id: 6f1c...

Sensitive header values (for example `Authorization` and cookies) are redacted. Unlike `--verbose`, which writes request diagnostics and timing to stderr, `--include` writes only the status line and response headers alongside the body on stdout, which is convenient for scripts that need a header such as `Location`, `ETag`, or `x-ms-request-id`. `--include` works with the `auto`, `json`, and `raw` formats and with binary responses.

## Expect Response Headers

Use `--expect-header` to fail the command when a response does not include a required header or exact header value. Header names are matched case-insensitively, and sensitive header values are redacted in error messages.

```bash
# Require a response header
azd rest get https://management.azure.com/subscriptions?api-version=2020-01-01 \
--expect-header Content-Type

# Require an exact response header value
azd rest get https://management.azure.com/subscriptions?api-version=2020-01-01 \
--expect-header "Content-Type=application/json"
```

## Silent Mode

Use `--silent` to suppress non-error diagnostic messages that `azd rest` writes to stderr. This covers the insecure TLS warning, the "no scope found" warning, and the pagination notice. Errors, exit codes, and the response body on stdout are unaffected, so you never lose a genuine failure by silencing diagnostics.
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 @@ -61,6 +61,7 @@ var (
redactPaths []string
tableColumns []string
dumpHeaders string
expectedHeaders []string
fail bool
rawOutput bool
compact bool
Expand Down Expand Up @@ -226,6 +227,7 @@ Examples:
rootCmd.PersistentFlags().StringArrayVar(&redactPaths, "redact", []string{}, "Mask a JSON response field before output (repeatable, dotted path, * matches array elements)")
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().StringArrayVar(&expectedHeaders, "expect-header", []string{}, "Require a response header, optionally with an exact value (repeatable; formats: Name, Name=value, or Name: value)")
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().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 @@ -301,6 +303,7 @@ func snapshotConfig() config.Config {
Redact: redactPaths,
TableColumns: tableColumns,
DumpHeaders: dumpHeaders,
ExpectedHeaders: expectedHeaders,
Fail: fail,
RawOutput: rawOutput,
Compact: compact,
Expand Down
12 changes: 12 additions & 0 deletions cli/src/internal/cmd/root_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,11 +36,15 @@ func resetGlobalFlags() {
headerFile = ""
data = ""
dataFile = ""
dataFormat = "json"
query = ""
formFields = []string{}
jsonFields = []string{}
jsonFieldsRaw = []string{}
outputFile = ""
outputFormat = defaults.OutputFormat
verbose = false
flatten = false
paginate = false
retry = defaults.Retry
binary = false
Expand All @@ -54,9 +58,17 @@ func resetGlobalFlags() {
maxResponseSize = defaults.MaxResponseSize
showThrottle = false
repeat = defaults.Repeat
colorMode = defaults.Color
writeOut = ""
include = false
allowHosts = []string{}
redactPaths = []string{}
tableColumns = nil
dumpHeaders = ""
expectedHeaders = []string{}
fail = false
rawOutput = false
compact = false
}

func TestNewRootCmd(t *testing.T) {
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 @@ -47,6 +47,7 @@ type Config struct {
Redact []string
TableColumns []string
DumpHeaders string
ExpectedHeaders []string
Fail bool
RawOutput bool
Compact bool
Expand Down
105 changes: 105 additions & 0 deletions cli/src/internal/service/expect_header.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
package service

import (
"fmt"
"strings"

"github.com/jongio/azd-rest/src/internal/client"
)

type headerExpectation struct {
name string
value string
hasValue bool
original string
separator string
}

func parseHeaderExpectation(raw string) (headerExpectation, error) {
text := strings.TrimSpace(raw)
if text == "" {
return headerExpectation{}, fmt.Errorf("--expect-header cannot be empty")
}

separatorIndex := -1
separator := ""
for _, candidate := range []string{"=", ":"} {
if idx := strings.Index(text, candidate); idx >= 0 && (separatorIndex == -1 || idx < separatorIndex) {
separatorIndex = idx
separator = candidate
}
}

if separatorIndex == -1 {
return headerExpectation{name: text, original: raw}, nil
}

name := strings.TrimSpace(text[:separatorIndex])
if name == "" {
return headerExpectation{}, fmt.Errorf("invalid --expect-header %q: header name is required", raw)
}

return headerExpectation{
name: name,
value: strings.TrimSpace(text[separatorIndex+1:]),
hasValue: true,
original: raw,
separator: separator,
}, nil
}

func checkExpectedHeaders(resp *client.Response, expectations []string) error {
for _, raw := range expectations {
exp, err := parseHeaderExpectation(raw)
if err != nil {
return err
}

values := resp.Headers.Values(exp.name)
if len(values) == 0 {
return fmt.Errorf("expected response header %q to be present", exp.name)
}
if !exp.hasValue {
continue
}

matched := false
for _, actual := range values {
if actual == exp.value {
matched = true
break
}
}
if matched {
continue
}

return fmt.Errorf(
"expected response header %q to equal %q, got %s",
exp.name,
client.RedactSensitiveHeader(exp.name, exp.value),
redactedHeaderValues(exp.name, values),
)
}
return nil
}

func redactedHeaderValues(name string, values []string) string {
if len(values) == 0 {
return "<none>"
}
redacted := make([]string, 0, len(values))
for _, value := range values {
redacted = append(redacted, fmt.Sprintf("%q", client.RedactSensitiveHeader(name, value)))
}
return strings.Join(redacted, ", ")
}

func validateHeaderExpectations(expectations []string) error {
for _, raw := range expectations {
if _, err := parseHeaderExpectation(raw); err != nil {
return err
}
}
return nil
}
147 changes: 147 additions & 0 deletions cli/src/internal/service/expect_header_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
package service

import (
"context"
"errors"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"

"github.com/jongio/azd-rest/src/internal/client"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestParseHeaderExpectation(t *testing.T) {
tests := []struct {
name string
raw string
want headerExpectation
wantErr string
}{
{
name: "presence only",
raw: "ETag",
want: headerExpectation{name: "ETag", original: "ETag"},
},
{
name: "equals value",
raw: "Content-Type=application/json",
want: headerExpectation{name: "Content-Type", value: "application/json", hasValue: true, original: "Content-Type=application/json", separator: "="},
},
{
name: "colon value",
raw: "Content-Type: application/json",
want: headerExpectation{name: "Content-Type", value: "application/json", hasValue: true, original: "Content-Type: application/json", separator: ":"},
},
{
name: "empty",
raw: " ",
wantErr: "cannot be empty",
},
{
name: "missing name",
raw: "=application/json",
wantErr: "header name is required",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := parseHeaderExpectation(tt.raw)
if tt.wantErr != "" {
require.Error(t, err)
assert.Contains(t, err.Error(), tt.wantErr)
return
}
require.NoError(t, err)
assert.Equal(t, tt.want, got)
})
}
}

func TestCheckExpectedHeaders(t *testing.T) {
resp := &client.Response{
Headers: http.Header{
"Content-Type": []string{"application/json"},
"Etag": []string{`"v1"`},
},
}

require.NoError(t, checkExpectedHeaders(resp, []string{"content-type"}))
require.NoError(t, checkExpectedHeaders(resp, []string{"Content-Type=application/json"}))
require.NoError(t, checkExpectedHeaders(resp, []string{"Content-Type: application/json"}))

err := checkExpectedHeaders(resp, []string{"X-Request-Id"})
require.Error(t, err)
assert.Contains(t, err.Error(), "X-Request-Id")

err = checkExpectedHeaders(resp, []string{"Content-Type=text/plain"})
require.Error(t, err)
assert.Contains(t, err.Error(), "application/json")
}

func TestCheckExpectedHeaders_RedactsSensitiveValues(t *testing.T) {
resp := &client.Response{
Headers: http.Header{
"Set-Cookie": []string{"session=super-secret-cookie-value"},
},
}

err := checkExpectedHeaders(resp, []string{"Set-Cookie=expected-secret-cookie"})
require.Error(t, err)
assert.NotContains(t, err.Error(), "super-secret-cookie-value")
assert.NotContains(t, err.Error(), "expected-secret-cookie")
}

func TestExecute_ExpectHeaderMismatchWritesBodyThenReturnsError(t *testing.T) {
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(`{"ok":true}`))
}))
defer srv.Close()

cfg := baseTestConfig(t)
cfg.ExpectedHeaders = []string{"Content-Type=text/plain"}

err := newTestService().Execute(context.Background(), cfg, "GET", srv.URL)
require.Error(t, err)
assert.Contains(t, err.Error(), "Content-Type")

out, readErr := os.ReadFile(cfg.OutputFile)
require.NoError(t, readErr)
assert.Contains(t, string(out), `"ok": true`)
}

func TestExecute_ExpectHeaderPasses(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("X-Request-Id", "req-123")
w.WriteHeader(http.StatusNoContent)
}))
defer srv.Close()

cfg := baseTestConfig(t)
cfg.ExpectedHeaders = []string{"x-request-id=req-123"}

err := newTestService().Execute(context.Background(), cfg, "GET", srv.URL)
require.NoError(t, err)
}

func TestExecute_ExpectHeaderParseError(t *testing.T) {
cfg := baseTestConfig(t)
cfg.ExpectedHeaders = []string{"=value"}

err := newTestService().Execute(context.Background(), cfg, "GET", "http://127.0.0.1")
require.Error(t, err)
assert.Contains(t, err.Error(), "header name is required")
assert.False(t, errors.Is(err, http.ErrServerClosed))
}

func TestRedactedHeaderValues(t *testing.T) {
got := redactedHeaderValues("X-Test", []string{"one", "two"})
assert.True(t, strings.Contains(got, `"one"`))
assert.True(t, strings.Contains(got, `"two"`))
}
8 changes: 8 additions & 0 deletions cli/src/internal/service/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -397,6 +397,10 @@ func (s *RequestService) Execute(ctx context.Context, cfg config.Config, method,
return &rawOutputUsageError{msg: "--raw-output requires --query"}
}

if err := validateHeaderExpectations(cfg.ExpectedHeaders); err != nil {
return err
}

// Echo the correlation ID so it can be quoted in an Azure support request.
if cfg.ClientRequestID != "" {
fmt.Fprintf(os.Stderr, "%s: %s\n", clientRequestIDHeader, cfg.ClientRequestID)
Expand Down Expand Up @@ -460,6 +464,10 @@ func (s *RequestService) Execute(ctx context.Context, cfg config.Config, method,
fmt.Fprint(os.Stderr, ExpandWriteOut(cfg.WriteOut, opts.Method, opts.URL, resp))
}

if err := checkExpectedHeaders(resp, cfg.ExpectedHeaders); 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