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
3 changes: 3 additions & 0 deletions cli/src/internal/cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ var (
include bool
allowHosts []string
redactPaths []string
fields []string
tableColumns []string
dumpHeaders string
fail bool
Expand Down Expand Up @@ -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)")
Expand Down Expand Up @@ -299,6 +301,7 @@ func snapshotConfig() config.Config {
Include: include,
AllowedHosts: allowHosts,
Redact: redactPaths,
Fields: fields,
TableColumns: tableColumns,
DumpHeaders: dumpHeaders,
Fail: fail,
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 @@ -45,6 +45,7 @@ type Config struct {
Include bool
AllowedHosts []string
Redact []string
Fields []string
TableColumns []string
DumpHeaders string
Fail bool
Expand Down
81 changes: 81 additions & 0 deletions cli/src/internal/service/fields.go
Original file line number Diff line number Diff line change
@@ -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
}
141 changes: 141 additions & 0 deletions cli/src/internal/service/fields_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
15 changes: 15 additions & 0 deletions cli/src/internal/service/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions web/src/pages/reference.astro
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ const base = import.meta.env.BASE_URL;
<tr><td><code>--raw-output</code></td><td><code>-r</code></td><td>bool</td><td>false</td><td>With <code>--query</code>, print a string result unquoted and an array of strings one per line, like <code>jq -r</code>. Requires <code>--query</code>.</td></tr>
<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>--fields</code></td><td></td><td>string[]</td><td>[]</td><td>Comma-separated top-level fields to keep in a JSON response. Trims a single object, each element of an array, and each element of an ARM <code>value[]</code> wrapper (paging links survive). Runs after <code>--query</code> and before formatting, so <code>json</code>, <code>table</code>, <code>csv</code>, and <code>yaml</code> all see the trimmed data. A non-JSON response is left unchanged.</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>--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>
Expand Down
Loading