diff --git a/cli/src/internal/cmd/root.go b/cli/src/internal/cmd/root.go index 35e9e2a..72e3e18 100644 --- a/cli/src/internal/cmd/root.go +++ b/cli/src/internal/cmd/root.go @@ -59,6 +59,7 @@ var ( include bool allowHosts []string redactPaths []string + fields []string tableColumns []string dumpHeaders string fail bool @@ -225,6 +226,7 @@ Examples: rootCmd.PersistentFlags().StringArrayVar(&allowHosts, "allow-host", []string{}, "Restrict requests to hosts matching a pattern (repeatable; leading *. matches subdomains). Env: AZD_REST_ALLOWED_HOSTS (comma separated)") 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().StringSliceVar(&fields, "fields", nil, "Comma-separated top-level fields to keep in a JSON response. Applies to an object, an array of objects, and an ARM value[] wrapper (keeping paging links). Runs after --query and before formatting, so every output format sees the trimmed data.") 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().BoolVarP(&rawOutput, "raw-output", "r", false, "With --query, print a string result unquoted and an array of strings one per line (like jq -r)") @@ -299,6 +301,7 @@ func snapshotConfig() config.Config { Include: include, AllowedHosts: allowHosts, Redact: redactPaths, + Fields: fields, TableColumns: tableColumns, DumpHeaders: dumpHeaders, Fail: fail, diff --git a/cli/src/internal/config/config.go b/cli/src/internal/config/config.go index 0e8015c..283f3d9 100644 --- a/cli/src/internal/config/config.go +++ b/cli/src/internal/config/config.go @@ -45,6 +45,7 @@ type Config struct { Include bool AllowedHosts []string Redact []string + Fields []string TableColumns []string DumpHeaders string Fail bool diff --git a/cli/src/internal/service/fields.go b/cli/src/internal/service/fields.go new file mode 100644 index 0000000..83f2f9e --- /dev/null +++ b/cli/src/internal/service/fields.go @@ -0,0 +1,81 @@ +package service + +import ( + "bytes" + "encoding/json" +) + +// projectFields parses body as JSON and keeps only the listed top-level fields. +// It handles three shapes: a single object keeps the listed keys, an array of +// objects trims each element, and an ARM value[] wrapper trims each element of +// value[] while keeping the wrapper so paging links (nextLink) survive. A value +// that is not an object is left as-is. +// +// The second return value is false when the body is not valid JSON, so the +// caller can leave the response unchanged and print a note. +func projectFields(body []byte, fields []string) ([]byte, bool) { + dec := json.NewDecoder(bytes.NewReader(body)) + dec.UseNumber() + + var parsed any + if err := dec.Decode(&parsed); err != nil { + return body, false + } + + keep := make(map[string]struct{}, len(fields)) + for _, f := range fields { + keep[f] = struct{}{} + } + + projected := projectValue(parsed, keep) + + var b bytes.Buffer + enc := json.NewEncoder(&b) + enc.SetEscapeHTML(false) + if err := enc.Encode(projected); err != nil { + return body, false + } + return bytes.TrimRight(b.Bytes(), "\n"), true +} + +// projectValue applies the field filter to a decoded JSON value, dispatching on +// the three supported shapes. +func projectValue(v any, keep map[string]struct{}) any { + switch node := v.(type) { + case map[string]any: + // ARM value[] wrapper: trim each element of value[] and keep the + // wrapper (and its paging keys) so nextLink survives. + if inner, ok := node["value"].([]any); ok { + node["value"] = projectArray(inner, keep) + return node + } + return projectObject(node, keep) + case []any: + return projectArray(node, keep) + default: + return v + } +} + +// projectArray trims each object element of an array, leaving non-object +// elements unchanged. +func projectArray(arr []any, keep map[string]struct{}) []any { + for i, item := range arr { + if obj, ok := item.(map[string]any); ok { + arr[i] = projectObject(obj, keep) + } + } + return arr +} + +// projectObject returns a new object containing only the keys in keep that are +// present in the source object. +func projectObject(obj map[string]any, keep map[string]struct{}) map[string]any { + out := make(map[string]any, len(keep)) + for k := range keep { + if val, ok := obj[k]; ok { + out[k] = val + } + } + return out +} diff --git a/cli/src/internal/service/fields_test.go b/cli/src/internal/service/fields_test.go new file mode 100644 index 0000000..e0eb18d --- /dev/null +++ b/cli/src/internal/service/fields_test.go @@ -0,0 +1,141 @@ +package service + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/jongio/azd-rest/src/internal/config" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestProjectFields_Object(t *testing.T) { + body := []byte(`{"name":"kv","location":"eastus","id":"/subs/x","type":"vault"}`) + out, ok := projectFields(body, []string{"name", "location"}) + require.True(t, ok) + + var got map[string]any + require.NoError(t, json.Unmarshal(out, &got)) + assert.Equal(t, map[string]any{"name": "kv", "location": "eastus"}, got) +} + +func TestProjectFields_ArrayOfObjects(t *testing.T) { + body := []byte(`[{"name":"a","id":1},{"name":"b","id":2}]`) + out, ok := projectFields(body, []string{"name"}) + require.True(t, ok) + + var got []map[string]any + require.NoError(t, json.Unmarshal(out, &got)) + assert.Equal(t, []map[string]any{{"name": "a"}, {"name": "b"}}, got) +} + +func TestProjectFields_ARMValueWrapperKeepsPagingLink(t *testing.T) { + body := []byte(`{"value":[{"name":"a","id":"/x/a"},{"name":"b","id":"/x/b"}],"nextLink":"https://next"}`) + out, ok := projectFields(body, []string{"name"}) + require.True(t, ok) + + var got map[string]any + require.NoError(t, json.Unmarshal(out, &got)) + assert.Equal(t, "https://next", got["nextLink"]) + + values, isArray := got["value"].([]any) + require.True(t, isArray) + require.Len(t, values, 2) + first := values[0].(map[string]any) + assert.Equal(t, map[string]any{"name": "a"}, first) +} + +func TestProjectFields_MissingFieldOmitted(t *testing.T) { + body := []byte(`{"name":"kv"}`) + out, ok := projectFields(body, []string{"name", "missing"}) + require.True(t, ok) + + var got map[string]any + require.NoError(t, json.Unmarshal(out, &got)) + assert.Equal(t, map[string]any{"name": "kv"}, got) +} + +func TestProjectFields_NonJSONReturnsFalse(t *testing.T) { + body := []byte("not json") + out, ok := projectFields(body, []string{"name"}) + assert.False(t, ok) + assert.Equal(t, body, out) +} + +func TestExecute_Fields_TrimsJSON(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(`{"name":"kv","location":"eastus","id":"/subs/x"}`)) + })) + defer srv.Close() + + tmp := filepath.Join(t.TempDir(), "out.json") + cfg := config.Defaults() + cfg.NoAuth = true + cfg.OutputFile = tmp + cfg.Fields = []string{"name", "location"} + + err := newTestService().Execute(context.Background(), cfg, "GET", srv.URL+"/x") + require.NoError(t, err) + + out, readErr := os.ReadFile(tmp) + require.NoError(t, readErr) + + var got map[string]any + require.NoError(t, json.Unmarshal(out, &got)) + assert.Equal(t, map[string]any{"name": "kv", "location": "eastus"}, got) +} + +func TestExecute_Fields_AppliesToCSVFormat(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(`{"value":[{"name":"a","location":"eastus","id":"/x/a"}]}`)) + })) + defer srv.Close() + + tmp := filepath.Join(t.TempDir(), "out.csv") + cfg := config.Defaults() + cfg.NoAuth = true + cfg.OutputFile = tmp + cfg.OutputFormat = "csv" + cfg.Fields = []string{"name", "location"} + + err := newTestService().Execute(context.Background(), cfg, "GET", srv.URL+"/list") + require.NoError(t, err) + + out, readErr := os.ReadFile(tmp) + require.NoError(t, readErr) + got := string(out) + assert.Contains(t, got, "name") + assert.Contains(t, got, "location") + assert.NotContains(t, got, "/x/a") // id column dropped +} + +func TestExecute_Fields_NonJSONLeftUnchanged(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/plain") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("plain text body")) + })) + defer srv.Close() + + tmp := filepath.Join(t.TempDir(), "out.txt") + cfg := config.Defaults() + cfg.NoAuth = true + cfg.OutputFile = tmp + cfg.Fields = []string{"name"} + + err := newTestService().Execute(context.Background(), cfg, "GET", srv.URL+"/plain") + require.NoError(t, err) + + out, readErr := os.ReadFile(tmp) + require.NoError(t, readErr) + assert.Contains(t, string(out), "plain text body") +} diff --git a/cli/src/internal/service/service.go b/cli/src/internal/service/service.go index c12646c..d35effa 100644 --- a/cli/src/internal/service/service.go +++ b/cli/src/internal/service/service.go @@ -483,6 +483,21 @@ func (s *RequestService) writeResponseOutput(cfg config.Config, resp *client.Res } } + // --fields (#281): keep only the listed top-level fields. Unlike redaction + // and flatten, this applies across every output format (json, table, csv, + // yaml) and downstream pipes, so it runs before the format dispatch. Raw and + // binary output cannot be parsed as JSON and are left unchanged with a note. + if len(cfg.Fields) > 0 { + isBinary := cfg.Binary || client.DetectContentType(resp.Body, resp.Headers.Get("Content-Type")) + if isBinary || cfg.OutputFormat == formatRaw { + writeDiagnostic(os.Stderr, cfg.Silent, "> --fields needs parsed JSON; leaving raw or binary output unchanged\n") + } else if projected, ok := projectFields(resp.Body, cfg.Fields); ok { + resp.Body = projected + } else { + writeDiagnostic(os.Stderr, cfg.Silent, "> --fields could not parse the response as JSON; leaving it unchanged\n") + } + } + // Redaction (#216): mask matched JSON response fields before formatting. // Raw and binary output cannot be parsed as JSON, so it is left unchanged // with a note on stderr. diff --git a/web/src/pages/reference.astro b/web/src/pages/reference.astro index 446eed5..0c6e9e9 100644 --- a/web/src/pages/reference.astro +++ b/web/src/pages/reference.astro @@ -102,6 +102,7 @@ const base = import.meta.env.BASE_URL;
--raw-output-r--query, print a string result unquoted and an array of strings one per line, like jq -r. Requires --query.--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.--fieldsvalue[] wrapper (paging links survive). Runs after --query and before formatting, so json, table, csv, and yaml all see the trimmed data. A non-JSON response is left unchanged.--dump-headers- for stderr). Sensitive values are redacted.--binary--verbose-v