diff --git a/README.md b/README.md index e3157eb..6274ebd 100644 --- a/README.md +++ b/README.md @@ -123,6 +123,9 @@ azd rest get https://management.azure.com/subscriptions?api-version=2020-01-01 - # Newline-delimited JSON (one object per line) for piping to jq -c azd rest get https://management.azure.com/subscriptions?api-version=2020-01-01 --format jsonl +# dotenv KEY=value lines to eval into your shell or feed a CI job / docker --env-file +azd rest get https://management.azure.com/subscriptions/$SUB?api-version=2022-12-01 --format dotenv + # Read a single field into a shell variable without piping through jq -r name=$(azd rest get https://management.azure.com/subscriptions/$SUB?api-version=2022-12-01 --query displayName -r) diff --git a/cli/docs/cli-reference.md b/cli/docs/cli-reference.md index b7a5f27..292ffae 100644 --- a/cli/docs/cli-reference.md +++ b/cli/docs/cli-reference.md @@ -200,7 +200,7 @@ These flags are available for all HTTP method commands: | Flag | Short | Type | Default | Description | |------|-------|------|---------|-------------| -| `--format` | `-f` | string | auto | Output format: `auto` (pretty JSON), `json` (compact JSON), `raw` (raw response), `table`, `jsonl` (one object per line), `yaml`, `csv`. | +| `--format` | `-f` | string | auto | Output format: `auto` (pretty JSON), `json` (compact JSON), `raw` (raw response), `table`, `jsonl` (one object per line), `yaml`, `csv`, `dotenv` (KEY=value env lines). | | `--output-file` | | string | "" | Write response to file (raw for binary content). | | `--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. | diff --git a/cli/src/internal/cmd/root.go b/cli/src/internal/cmd/root.go index 35e9e2a..3b4f961 100644 --- a/cli/src/internal/cmd/root.go +++ b/cli/src/internal/cmd/root.go @@ -203,7 +203,7 @@ Examples: rootCmd.PersistentFlags().StringArrayVar(&jsonFields, "json-field", []string{}, "Add a string field to a JSON request body (repeatable, format: key=value; dotted keys nest)") rootCmd.PersistentFlags().StringArrayVar(&jsonFieldsRaw, "json-field-raw", []string{}, "Add a raw JSON field to a JSON request body (repeatable, format: key:=json; dotted keys nest)") rootCmd.PersistentFlags().StringVar(&outputFile, "output-file", "", "Write response to file (raw for binary content)") - rootCmd.PersistentFlags().StringVarP(&outputFormat, "format", "f", defaults.OutputFormat, "Output format: auto, json, raw, table, jsonl, yaml, csv") + rootCmd.PersistentFlags().StringVarP(&outputFormat, "format", "f", defaults.OutputFormat, "Output format: auto, json, raw, table, jsonl, yaml, csv, dotenv") rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "Verbose output (show headers, timing)") rootCmd.PersistentFlags().BoolVar(&paginate, "paginate", false, "Follow continuation tokens/next links when supported") rootCmd.PersistentFlags().BoolVar(&flatten, "flatten", false, "Flatten a JSON response into a single-level object keyed by dotted paths (e.g. properties.state, value[0].name)") diff --git a/cli/src/internal/service/format_dotenv.go b/cli/src/internal/service/format_dotenv.go new file mode 100644 index 0000000..5b34e3f --- /dev/null +++ b/cli/src/internal/service/format_dotenv.go @@ -0,0 +1,144 @@ +package service + +import ( + "bytes" + "encoding/json" + "fmt" + "sort" + "strings" +) + +// jsonNull is the JSON literal for a null value. +const jsonNull = "null" + +// renderDotenv renders a JSON response as dotenv-style KEY=value lines for a +// shell eval, a CI job's environment, or a docker --env-file. +// +// The body is flattened with flattenJSONBody so nested fields become dotted +// paths, then each path is mapped to an uppercase, underscore-separated key +// (properties.state becomes PROPERTIES_STATE, value[0].name becomes +// VALUE_0_NAME). String values are emitted bare when they +// are already shell-safe and double-quoted with escaping otherwise. Numbers and +// booleans render as their literal text, and null renders as an empty value. +// Keys are sorted for deterministic output. A response that is not a JSON object +// or array has no field names to emit, so it returns an error. +func renderDotenv(body []byte) (string, error) { + flat, err := flattenJSONBody(body) + if err != nil { + return "", fmt.Errorf("dotenv format requires a JSON response: %w", err) + } + + fields := map[string]json.RawMessage{} + dec := json.NewDecoder(bytes.NewReader(flat)) + dec.UseNumber() + if err := dec.Decode(&fields); err != nil { + return "", fmt.Errorf("dotenv format requires a JSON object or array response: %w", err) + } + + paths := make([]string, 0, len(fields)) + for p := range fields { + paths = append(paths, p) + } + sort.Strings(paths) + + var b strings.Builder + for _, p := range paths { + value, err := dotenvValue(fields[p]) + if err != nil { + return "", err + } + b.WriteString(dotenvKey(p)) + b.WriteByte('=') + b.WriteString(value) + b.WriteByte('\n') + } + return b.String(), nil +} + +// dotenvKey converts a flattened dotted path into a shell-safe environment +// variable name: every run of characters outside [A-Za-z0-9] collapses to a +// single underscore, surrounding underscores are trimmed, each letter is +// converted to upper case, and a leading digit is prefixed with an underscore +// so the name is a valid identifier. +func dotenvKey(path string) string { + var b strings.Builder + prevUnderscore := false + for _, r := range path { + switch { + case r >= 'A' && r <= 'Z', r >= 'a' && r <= 'z', r >= '0' && r <= '9': + b.WriteRune(toUpperASCII(r)) + prevUnderscore = false + case !prevUnderscore: + b.WriteByte('_') + prevUnderscore = true + } + } + key := strings.Trim(b.String(), "_") + if key == "" { + return "_" + } + if key[0] >= '0' && key[0] <= '9' { + return "_" + key + } + return key +} + +// toUpperASCII maps an ASCII letter to its upper case form and leaves every other rune unchanged. +func toUpperASCII(r rune) rune { + if r >= 'a' && r <= 'z' { + return r - ('a' - 'A') + } + return r +} + +// dotenvValue renders a single flattened leaf value as a dotenv right-hand side. +// null and an empty raw value become an empty string, JSON strings are unquoted +// then re-quoted only when needed, empty objects and arrays are emitted as +// compact JSON, and numbers and booleans pass through as safe literals. +func dotenvValue(raw json.RawMessage) (string, error) { + trimmed := bytes.TrimSpace(raw) + if len(trimmed) == 0 || string(trimmed) == jsonNull { + return "", nil + } + switch trimmed[0] { + case '"': + var s string + if err := json.Unmarshal(trimmed, &s); err != nil { + return "", fmt.Errorf("failed to decode dotenv value: %w", err) + } + return dotenvQuote(s), nil + case '{', '[': + return dotenvQuote(string(trimmed)), nil + default: + return string(trimmed), nil + } +} + +// dotenvQuote returns s unquoted when it is a non-empty run of shell-safe +// characters, and otherwise wraps it in double quotes with backslash, quote, and +// control characters escaped so the line round-trips through common dotenv +// parsers. +func dotenvQuote(s string) string { + if s != "" && !strings.ContainsAny(s, " \t\r\n\"'\\#=$`") { + return s + } + var b strings.Builder + b.WriteByte('"') + for _, r := range s { + switch r { + case '\\', '"': + b.WriteByte('\\') + b.WriteRune(r) + case '\n': + b.WriteString(`\n`) + case '\r': + b.WriteString(`\r`) + case '\t': + b.WriteString(`\t`) + default: + b.WriteRune(r) + } + } + b.WriteByte('"') + return b.String() +} diff --git a/cli/src/internal/service/format_dotenv_test.go b/cli/src/internal/service/format_dotenv_test.go new file mode 100644 index 0000000..727d43a --- /dev/null +++ b/cli/src/internal/service/format_dotenv_test.go @@ -0,0 +1,148 @@ +package service + +import ( + "strings" + "testing" +) + +// parseDotenv splits dotenv output into a key to value map for assertions. It +// keeps the raw right-hand side (quotes and escapes intact) so tests can assert +// on the exact rendered form. +func parseDotenv(t *testing.T, s string) map[string]string { + t.Helper() + out := map[string]string{} + for _, line := range strings.Split(strings.TrimRight(s, "\n"), "\n") { + if line == "" { + continue + } + eq := strings.IndexByte(line, '=') + if eq < 0 { + t.Fatalf("line has no '=': %q", line) + } + out[line[:eq]] = line[eq+1:] + } + return out +} + +func TestRenderDotenvFlatObject(t *testing.T) { + body := `{"name":"web","port":8080,"enabled":true,"note":null}` + out, err := renderDotenv([]byte(body)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + got := parseDotenv(t, out) + if got["NAME"] != "web" { + t.Errorf("NAME: got %q", got["NAME"]) + } + if got["PORT"] != "8080" { + t.Errorf("PORT: got %q", got["PORT"]) + } + if got["ENABLED"] != "true" { + t.Errorf("ENABLED: got %q", got["ENABLED"]) + } + if _, ok := got["NOTE"]; !ok || got["NOTE"] != "" { + t.Errorf("NOTE should be present and empty, got %q (present %v)", got["NOTE"], ok) + } +} + +func TestRenderDotenvNestedToDottedKeys(t *testing.T) { + body := `{"properties":{"state":"Succeeded"}}` + out, err := renderDotenv([]byte(body)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + got := parseDotenv(t, out) + if got["PROPERTIES_STATE"] != "Succeeded" { + t.Errorf("nested key not mapped: %v", got) + } +} + +func TestRenderDotenvArrayIndexKeys(t *testing.T) { + body := `{"value":[{"name":"a"},{"name":"b"}]}` + out, err := renderDotenv([]byte(body)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + got := parseDotenv(t, out) + if got["VALUE_0_NAME"] != "a" || got["VALUE_1_NAME"] != "b" { + t.Errorf("array index keys not mapped: %v", got) + } +} + +func TestRenderDotenvTopLevelArray(t *testing.T) { + body := `[{"name":"a"}]` + out, err := renderDotenv([]byte(body)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + got := parseDotenv(t, out) + // A leading digit is prefixed with an underscore to stay a valid identifier. + if got["_0_NAME"] != "a" { + t.Errorf("top-level array key not mapped: %v", got) + } +} + +func TestRenderDotenvQuotesUnsafeValues(t *testing.T) { + body := `{"msg":"hello world","path":"a=b#c","tab":"x\ty"}` + out, err := renderDotenv([]byte(body)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + got := parseDotenv(t, out) + if got["MSG"] != `"hello world"` { + t.Errorf("space value should be quoted, got %q", got["MSG"]) + } + if got["PATH"] != `"a=b#c"` { + t.Errorf("special chars should be quoted, got %q", got["PATH"]) + } + if got["TAB"] != `"x\ty"` { + t.Errorf("tab should be escaped, got %q", got["TAB"]) + } +} + +func TestRenderDotenvEscapesQuotesAndBackslash(t *testing.T) { + body := `{"note":"say \"hi\" \\ done"}` + out, err := renderDotenv([]byte(body)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + got := parseDotenv(t, out) + if got["NOTE"] != `"say \"hi\" \\ done"` { + t.Errorf("quotes/backslash not escaped, got %q", got["NOTE"]) + } +} + +func TestRenderDotenvSafeStringUnquoted(t *testing.T) { + body := `{"id":"resource-group-01"}` + out, err := renderDotenv([]byte(body)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + got := parseDotenv(t, out) + if got["ID"] != "resource-group-01" { + t.Errorf("safe string should be bare, got %q", got["ID"]) + } +} + +func TestRenderDotenvSortedDeterministic(t *testing.T) { + body := `{"b":"2","a":"1","c":"3"}` + out, err := renderDotenv([]byte(body)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if out != "A=1\nB=2\nC=3\n" { + t.Errorf("output not sorted deterministically: %q", out) + } +} + +func TestRenderDotenvBareScalarError(t *testing.T) { + if _, err := renderDotenv([]byte(`"just a string"`)); err == nil { + t.Fatalf("expected error for bare scalar body") + } +} + +func TestRenderDotenvInvalidJSON(t *testing.T) { + if _, err := renderDotenv([]byte(`not json`)); err == nil { + t.Fatalf("expected error for non-JSON body") + } +} diff --git a/cli/src/internal/service/service.go b/cli/src/internal/service/service.go index c12646c..3f887f0 100644 --- a/cli/src/internal/service/service.go +++ b/cli/src/internal/service/service.go @@ -537,7 +537,7 @@ func (s *RequestService) writeResponseOutput(cfg config.Config, resp *client.Res } // azd-rest renders formats that azd-core's formatter does not support - // (currently "table", "jsonl", "yaml", and "csv"), then delegates everything else to azd-core. + // (currently "table", "jsonl", "yaml", "csv", and "dotenv"), then delegates everything else to azd-core. if cfg.OutputFormat == "table" { out, err := renderTableWithColumns(resp.Body, cfg.TableColumns) if err != nil { @@ -570,6 +570,14 @@ func (s *RequestService) writeResponseOutput(cfg config.Config, resp *client.Res return formatter.WriteOutput(out, cfg.OutputFile) } + if cfg.OutputFormat == "dotenv" { + out, err := renderDotenv(resp.Body) + if err != nil { + return err + } + return formatter.WriteOutput(out, cfg.OutputFile) + } + // --compact (#235): minify JSON to a single line for the auto and json // formats and --query output. Raw, binary, table, jsonl, yaml, and csv are // left untouched. A non-JSON body is left unchanged with a note on stderr. diff --git a/cli/src/internal/skills/azd-rest/SKILL.md b/cli/src/internal/skills/azd-rest/SKILL.md index edbafe3..8794b2b 100644 --- a/cli/src/internal/skills/azd-rest/SKILL.md +++ b/cli/src/internal/skills/azd-rest/SKILL.md @@ -47,7 +47,7 @@ Use `azd rest scope ` to preview the detected OAuth scope and auth mode for | `--json-field-raw` | | [] | Add a raw JSON field to a JSON body (repeatable, key:=json; dotted keys nest) | | `--output-file` | | "" | Write response to file | | `--redact` | | [] | Mask a JSON response field before output (repeatable, dotted path, * matches array elements) | -| `--format` | `-f` | auto | Output format: auto, json, raw, table, jsonl, yaml, csv | +| `--format` | `-f` | auto | Output format: auto, json, raw, table, jsonl, yaml, csv, dotenv | | `--verbose` | `-v` | false | Show request/response details | | `--paginate` | | false | Follow continuation tokens/next links | | `--retry` | | 3 | Retry attempts with exponential backoff | diff --git a/cspell.json b/cspell.json index 3e461fa..429b5b2 100644 --- a/cspell.json +++ b/cspell.json @@ -41,6 +41,7 @@ "jmespath", "jongio", "jsonl", + "dotenv", "keyvault", "kusto", "ldflags", diff --git a/web/src/pages/reference.astro b/web/src/pages/reference.astro index 446eed5..2fab684 100644 --- a/web/src/pages/reference.astro +++ b/web/src/pages/reference.astro @@ -95,7 +95,7 @@ const base = import.meta.env.BASE_URL; FlagShortTypeDefaultDescription - --format-fstringautoOutput format: auto (pretty JSON), json (pretty JSON), raw, table, jsonl (one object per line), yaml, csv (RFC 4180) + --format-fstringautoOutput format: auto (pretty JSON), json (pretty JSON), raw, table, jsonl (one object per line), yaml, csv (RFC 4180), dotenv (KEY=value env lines) --compact-cboolfalseMinify JSON output to a single line with no extra whitespace. Applies to auto and json formats and --query results. Non-JSON, binary, raw, table, jsonl, yaml, and csv are left unchanged. --table-columnsstring""Comma-separated columns to show, in order, for --format table. Missing fields render as empty cells. Ignored for other formats. --output-filestring""Write response to file