diff --git a/README.md b/README.md index e3157eb..159e3b6 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/cli/docs/cli-reference.md b/cli/docs/cli-reference.md index b7a5f27..016f131 100644 --- a/cli/docs/cli-reference.md +++ b/cli/docs/cli-reference.md @@ -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. | @@ -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. diff --git a/cli/src/internal/cmd/root.go b/cli/src/internal/cmd/root.go index 35e9e2a..d7bf557 100644 --- a/cli/src/internal/cmd/root.go +++ b/cli/src/internal/cmd/root.go @@ -61,6 +61,7 @@ var ( redactPaths []string tableColumns []string dumpHeaders string + metadataFile string fail bool rawOutput bool compact bool @@ -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)") @@ -301,6 +303,7 @@ func snapshotConfig() config.Config { Redact: redactPaths, TableColumns: tableColumns, DumpHeaders: dumpHeaders, + MetadataFile: metadataFile, Fail: fail, RawOutput: rawOutput, Compact: compact, diff --git a/cli/src/internal/cmd/root_test.go b/cli/src/internal/cmd/root_test.go index a1268be..d3241a0 100644 --- a/cli/src/internal/cmd/root_test.go +++ b/cli/src/internal/cmd/root_test.go @@ -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 @@ -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) { diff --git a/cli/src/internal/config/config.go b/cli/src/internal/config/config.go index 0e8015c..05f9cf6 100644 --- a/cli/src/internal/config/config.go +++ b/cli/src/internal/config/config.go @@ -47,6 +47,7 @@ type Config struct { Redact []string TableColumns []string DumpHeaders string + MetadataFile string Fail bool RawOutput bool Compact bool diff --git a/cli/src/internal/service/metadata_file.go b/cli/src/internal/service/metadata_file.go new file mode 100644 index 0000000..bb76b61 --- /dev/null +++ b/cli/src/internal/service/metadata_file.go @@ -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() +} diff --git a/cli/src/internal/service/metadata_file_test.go b/cli/src/internal/service/metadata_file_test.go new file mode 100644 index 0000000..a79846f --- /dev/null +++ b/cli/src/internal/service/metadata_file_test.go @@ -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]) +} diff --git a/cli/src/internal/service/repeat.go b/cli/src/internal/service/repeat.go index 1d42196..549c22e 100644 --- a/cli/src/internal/service/repeat.go +++ b/cli/src/internal/service/repeat.go @@ -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) } diff --git a/cli/src/internal/service/service.go b/cli/src/internal/service/service.go index c12646c..e249858 100644 --- a/cli/src/internal/service/service.go +++ b/cli/src/internal/service/service.go @@ -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 diff --git a/web/src/pages/reference.astro b/web/src/pages/reference.astro index 446eed5..74381d1 100644 --- a/web/src/pages/reference.astro +++ b/web/src/pages/reference.astro @@ -103,6 +103,7 @@ const base = import.meta.env.BASE_URL;
--redact* matches array elements. Applies to json, auto, table, and jsonl.--flattenproperties.provisioningState, value[0].name). Leaf values keep their type. Applies to the json and auto output paths and composes with --query.--dump-headers- for stderr). Sensitive values are redacted.--metadata-file--binary--verbose-v--colorauto (TTY only, honors NO_COLOR), always, never
+ Use --metadata-file to write a JSON sidecar with status, timing,
+ body size, content type, and redacted response headers while keeping the response body separate.
+