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 README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
2 changes: 1 addition & 1 deletion cli/docs/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
2 changes: 1 addition & 1 deletion cli/src/internal/cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)")
Expand Down
144 changes: 144 additions & 0 deletions cli/src/internal/service/format_dotenv.go
Original file line number Diff line number Diff line change
@@ -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()
}
148 changes: 148 additions & 0 deletions cli/src/internal/service/format_dotenv_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
10 changes: 9 additions & 1 deletion cli/src/internal/service/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion cli/src/internal/skills/azd-rest/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ Use `azd rest scope <url>` 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 |
Expand Down
1 change: 1 addition & 0 deletions cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
"jmespath",
"jongio",
"jsonl",
"dotenv",
"keyvault",
"kusto",
"ldflags",
Expand Down
2 changes: 1 addition & 1 deletion web/src/pages/reference.astro
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ const base = import.meta.env.BASE_URL;
<tr><th>Flag</th><th>Short</th><th>Type</th><th>Default</th><th>Description</th></tr>
</thead>
<tbody>
<tr><td><code>--format</code></td><td><code>-f</code></td><td>string</td><td>auto</td><td>Output format: <code>auto</code> (pretty JSON), <code>json</code> (pretty JSON), <code>raw</code>, <code>table</code>, <code>jsonl</code> (one object per line), <code>yaml</code>, <code>csv</code> (RFC 4180)</td></tr>
<tr><td><code>--format</code></td><td><code>-f</code></td><td>string</td><td>auto</td><td>Output format: <code>auto</code> (pretty JSON), <code>json</code> (pretty JSON), <code>raw</code>, <code>table</code>, <code>jsonl</code> (one object per line), <code>yaml</code>, <code>csv</code> (RFC 4180), <code>dotenv</code> (KEY=value env lines)</td></tr>
<tr><td><code>--compact</code></td><td><code>-c</code></td><td>bool</td><td>false</td><td>Minify JSON output to a single line with no extra whitespace. Applies to <code>auto</code> and <code>json</code> formats and <code>--query</code> results. Non-JSON, binary, <code>raw</code>, <code>table</code>, <code>jsonl</code>, <code>yaml</code>, and <code>csv</code> are left unchanged.</td></tr>
<tr><td><code>--table-columns</code></td><td></td><td>string</td><td>""</td><td>Comma-separated columns to show, in order, for <code>--format table</code>. Missing fields render as empty cells. Ignored for other formats.</td></tr>
<tr><td><code>--output-file</code></td><td></td><td>string</td><td>""</td><td>Write response to file</td></tr>
Expand Down
Loading