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 @@ -117,6 +117,10 @@ azd rest scope https://management.azure.com/subscriptions?api-version=2020-01-01
azd rest get https://management.azure.com/subscriptions?api-version=2020-01-01 \
--header "Accept: application/json" --output-file subscriptions.json

# Save structured response metadata for scripts or audit logs
azd rest get https://management.azure.com/subscriptions?api-version=2020-01-01 \
--metadata-file metadata.json

# Table output (works with arrays and ARM value[] responses)
azd rest get https://management.azure.com/subscriptions?api-version=2020-01-01 --format table

Expand Down
29 changes: 29 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. |
| `--metadata-file` | | string | "" | Write structured response metadata as JSON to a file. Sensitive header values are redacted. |
| `--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,34 @@ 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.

## Structured Response Metadata

Use `--metadata-file` to write a JSON sidecar file with response metadata while keeping the response body on stdout or in `--output-file`. The file includes method, final URL, status, status code, duration in milliseconds, downloaded body size, content type, and redacted response headers.

```bash
azd rest get https://management.azure.com/subscriptions?api-version=2020-01-01 \
--output-file body.json \
--metadata-file metadata.json
```

Example metadata:

```json
{
"method": "GET",
"url": "https://management.azure.com/subscriptions?api-version=2020-01-01",
"status": "200 OK",
"statusCode": 200,
"durationMs": 234,
"sizeDownload": 1234,
"contentType": "application/json",
"headers": {
"Content-Type": ["application/json"],
"x-ms-request-id": ["6f1c..."]
}
}
```

## 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
metadataFile 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().StringVar(&metadataFile, "metadata-file", "", "Write structured response metadata as JSON to a file")
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,
MetadataFile: metadataFile,
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 = ""
metadataFile = ""
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
MetadataFile string
Fail bool
RawOutput bool
Compact bool
Expand Down
69 changes: 69 additions & 0 deletions cli/src/internal/service/metadata_file.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package service

import (
"encoding/json"
"fmt"
"net/http"
"os"
"time"

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

type responseMetadata struct {
Method string `json:"method"`
URL string `json:"url"`
Status string `json:"status"`
StatusCode int `json:"statusCode"`
DurationMs int64 `json:"durationMs"`
SizeDownload int `json:"sizeDownload"`
ContentType string `json:"contentType,omitempty"`
Headers map[string][]string `json:"headers"`
}

func newResponseMetadata(method, requestURL string, resp *client.Response) responseMetadata {
return responseMetadata{
Method: method,
URL: requestURL,
Status: resp.Status,
StatusCode: resp.StatusCode,
DurationMs: durationMilliseconds(resp.Duration),
SizeDownload: len(resp.Body),
ContentType: resp.Headers.Get("Content-Type"),
Headers: redactedMetadataHeaders(resp.Headers),
}
}

func writeResponseMetadata(path, method, requestURL string, resp *client.Response) error {
if path == "" {
return nil
}

metadata := newResponseMetadata(method, requestURL, resp)
data, err := json.MarshalIndent(metadata, "", " ")
if err != nil {
return fmt.Errorf("failed to encode response metadata: %w", err)
}
data = append(data, '\n')

if err := os.WriteFile(path, data, 0o600); err != nil {
return fmt.Errorf("failed to write metadata file: %w", err)
}
return nil
}

func redactedMetadataHeaders(headers http.Header) map[string][]string {
result := make(map[string][]string, len(headers))
for name, values := range headers {
redacted := make([]string, 0, len(values))
for _, value := range values {
redacted = append(redacted, client.RedactSensitiveHeader(name, value))
}
result[name] = redacted
}
return result
}

func durationMilliseconds(d time.Duration) int64 {
return d.Milliseconds()
}
100 changes: 100 additions & 0 deletions cli/src/internal/service/metadata_file_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
package service

import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"

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

func TestNewResponseMetadata_RedactsHeaders(t *testing.T) {
resp := &client.Response{
Status: "201 Created",
StatusCode: http.StatusCreated,
Duration: 1500 * time.Millisecond,
Body: []byte(`{"ok":true}`),
Headers: http.Header{
"Content-Type": []string{"application/json"},
"Authorization": []string{"******"},
"Set-Cookie": []string{"session=super-secret-cookie-value"},
},
}

metadata := newResponseMetadata("POST", "https://example.com/resource", resp)

assert.Equal(t, "POST", metadata.Method)
assert.Equal(t, "https://example.com/resource", metadata.URL)
assert.Equal(t, "201 Created", metadata.Status)
assert.Equal(t, http.StatusCreated, metadata.StatusCode)
assert.Equal(t, int64(1500), metadata.DurationMs)
assert.Equal(t, len(resp.Body), metadata.SizeDownload)
assert.Equal(t, "application/json", metadata.ContentType)
assert.NotContains(t, metadata.Headers["Set-Cookie"][0], "super-secret-cookie-value")
}

func TestWriteResponseMetadata_WritesJSONFile(t *testing.T) {
path := filepath.Join(t.TempDir(), "metadata.json")
resp := &client.Response{
Status: "200 OK",
StatusCode: http.StatusOK,
Duration: 25 * time.Millisecond,
Body: []byte(`{"ok":true}`),
Headers: http.Header{
"Content-Type": []string{"application/json"},
},
}

require.NoError(t, writeResponseMetadata(path, "GET", "https://example.com", resp))

data, err := os.ReadFile(path)
require.NoError(t, err)

var metadata responseMetadata
require.NoError(t, json.Unmarshal(data, &metadata))
assert.Equal(t, "GET", metadata.Method)
assert.Equal(t, "200 OK", metadata.Status)
assert.Equal(t, int64(25), metadata.DurationMs)
}

func TestWriteResponseMetadata_WriteError(t *testing.T) {
path := filepath.Join(t.TempDir(), "missing", "metadata.json")
resp := &client.Response{Status: "200 OK", StatusCode: http.StatusOK}

err := writeResponseMetadata(path, "GET", "https://example.com", resp)
require.Error(t, err)
assert.Contains(t, err.Error(), "failed to write metadata file")
}

func TestExecute_MetadataFile(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("X-Request-Id", "req-123")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"ok":true}`))
}))
defer srv.Close()

cfg := baseTestConfig(t)
cfg.MetadataFile = filepath.Join(t.TempDir(), "metadata.json")

err := newTestService().Execute(context.Background(), cfg, "GET", srv.URL+"/items?api-version=2024-01-01")
require.NoError(t, err)

data, readErr := os.ReadFile(cfg.MetadataFile)
require.NoError(t, readErr)

var metadata responseMetadata
require.NoError(t, json.Unmarshal(data, &metadata))
assert.Equal(t, "GET", metadata.Method)
assert.Equal(t, srv.URL+"/items?api-version=2024-01-01", metadata.URL)
assert.Equal(t, http.StatusOK, metadata.StatusCode)
assert.Equal(t, "req-123", metadata.Headers["X-Request-Id"][0])
}
4 changes: 4 additions & 0 deletions cli/src/internal/service/repeat.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,10 @@ func (s *RequestService) executeRepeat(ctx context.Context, cfg config.Config, h
return fmt.Errorf("all %d requests failed", cfg.Repeat)
}

if err := writeResponseMetadata(cfg.MetadataFile, opts.Method, opts.URL, lastResp); err != nil {
return err
}

return s.writeResponseOutput(cfg, lastResp)
}

Expand Down
4 changes: 4 additions & 0 deletions cli/src/internal/service/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -446,6 +446,10 @@ func (s *RequestService) Execute(ctx context.Context, cfg config.Config, method,
writeThrottleInfo(os.Stderr, resp.Headers)
}

if err := writeResponseMetadata(cfg.MetadataFile, opts.Method, opts.URL, resp); err != nil {
return err
}

if cfg.DumpHeaders != "" {
if err := dumpResponseHeaders(cfg.DumpHeaders, resp); err != nil {
return err
Expand Down
10 changes: 10 additions & 0 deletions web/src/pages/reference.astro
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ const base = import.meta.env.BASE_URL;
<tr><td><code>--redact</code></td><td></td><td>string[]</td><td>[]</td><td>Mask a JSON response field before output (repeatable). Dotted path, <code>*</code> matches array elements. Applies to <code>json</code>, <code>auto</code>, <code>table</code>, and <code>jsonl</code>.</td></tr>
<tr><td><code>--flatten</code></td><td></td><td>bool</td><td>false</td><td>Flatten a JSON response into a single-level object keyed by dotted paths (<code>properties.provisioningState</code>, <code>value[0].name</code>). Leaf values keep their type. Applies to the <code>json</code> and <code>auto</code> output paths and composes with <code>--query</code>.</td></tr>
<tr><td><code>--dump-headers</code></td><td></td><td>string</td><td>""</td><td>Write response status line and headers to a file (use <code>-</code> for stderr). Sensitive values are redacted.</td></tr>
<tr><td><code>--metadata-file</code></td><td></td><td>string</td><td>""</td><td>Write structured response metadata as JSON to a file. Sensitive header values are redacted.</td></tr>
<tr><td><code>--binary</code></td><td></td><td>bool</td><td>false</td><td>Stream as binary without transformation</td></tr>
<tr><td><code>--verbose</code></td><td><code>-v</code></td><td>bool</td><td>false</td><td>Show headers, timing, request details (tokens redacted)</td></tr>
<tr><td><code>--color</code></td><td></td><td>string</td><td>auto</td><td>Colorize JSON output: <code>auto</code> (TTY only, honors <code>NO_COLOR</code>), <code>always</code>, <code>never</code></td></tr>
Expand Down Expand Up @@ -163,6 +164,15 @@ azd rest get https://management.azure.com/subscriptions?api-version=2020-01-01 \
# Status and timing on one line
azd rest get https://graph.microsoft.com/v1.0/me \\
--write-out "%{http_code} %{time_total}s %{size_download}B\\n"`} language="bash" />

<h3>Structured Metadata File</h3>
<p>
Use <code>--metadata-file</code> to write a JSON sidecar with status, timing,
body size, content type, and redacted response headers while keeping the response body separate.
</p>
<CodeBlock code={`azd rest get https://management.azure.com/subscriptions?api-version=2020-01-01 \\
--output-file body.json \\
--metadata-file metadata.json`} language="bash" />
</section>

<!-- Scope Command -->
Expand Down
Loading