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 @@ -34,6 +34,7 @@ var (
dataFile string
dataFormat string
query string
tmpl string
formFields []string
jsonFields []string
jsonFieldsRaw []string
Expand Down Expand Up @@ -199,6 +200,7 @@ Examples:
rootCmd.PersistentFlags().StringVar(&dataFile, "data-file", "", "Read request body from file (also accepts @{file} shorthand)")
rootCmd.PersistentFlags().StringVar(&dataFormat, "data-format", "json", "Interpret --data / --data-file as this format before sending: json or yaml. YAML is converted to a JSON body.")
rootCmd.PersistentFlags().StringVarP(&query, "query", "q", "", "JMESPath query to apply to JSON responses")
rootCmd.PersistentFlags().StringVar(&tmpl, "template", "", "Render the JSON response through a Go text/template (use @file to load from a file). Applies after --query and takes precedence over --format. Helpers: json, upper, lower, join.")
rootCmd.PersistentFlags().StringArrayVar(&formFields, "form-field", []string{}, "Add an application/x-www-form-urlencoded field (repeatable, format: key=value)")
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)")
Expand Down Expand Up @@ -274,6 +276,7 @@ func snapshotConfig() config.Config {
DataFile: dataFile,
DataFormat: dataFormat,
Query: query,
Template: tmpl,
FormFields: formFields,
JSONFields: jsonFields,
JSONFieldsRaw: jsonFieldsRaw,
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 @@ -20,6 +20,7 @@ type Config struct {
DataFile string
DataFormat string
Query string
Template string
FormFields []string
JSONFields []string
JSONFieldsRaw []string
Expand Down
20 changes: 20 additions & 0 deletions cli/src/internal/service/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -397,6 +397,15 @@ func (s *RequestService) Execute(ctx context.Context, cfg config.Config, method,
return &rawOutputUsageError{msg: "--raw-output requires --query"}
}

// --template (#279): compile the template up front so invalid syntax or a
// missing @file exits with code 2 before any network call is made. The
// compiled result is discarded here and rebuilt when the body is rendered.
if cfg.Template != "" {
if _, err := parseTemplate(cfg.Template); err != nil {
return err
}
}

// Echo the correlation ID so it can be quoted in an Azure support request.
if cfg.ClientRequestID != "" {
fmt.Fprintf(os.Stderr, "%s: %s\n", clientRequestIDHeader, cfg.ClientRequestID)
Expand Down Expand Up @@ -474,6 +483,17 @@ func (s *RequestService) Execute(ctx context.Context, cfg config.Config, method,
func (s *RequestService) writeResponseOutput(cfg config.Config, resp *client.Response) error {
formatter := client.NewFormatter(cfg.Verbose, cfg.OutputFormat)

// --template (#279): render the response through a Go text/template. It runs
// after --query (applied in Execute) and takes precedence over --format and
// the other output modes, so it returns before any of them run.
if cfg.Template != "" {
rendered, err := renderTemplate(cfg.Template, resp.Body)
if err != nil {
return err
}
return formatter.WriteOutput(rendered, cfg.OutputFile)
}

// --raw-output (#234): after --query, print a string result unquoted and an
// array of strings one per line. Other shapes fall through to JSON so
// nothing is silently mangled.
Expand Down
113 changes: 113 additions & 0 deletions cli/src/internal/service/template.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
package service

import (
"bytes"
"encoding/json"
"fmt"
"os"
"strings"
"text/template"
)

// templateConfigError signals that the --template value could not be loaded or
// parsed (a bad @file path or invalid template syntax). It reports exit code 2
// (invalid configuration) through the ExitCoder contract so main exits before,
// or independent of, any response handling.
type templateConfigError struct{ msg string }

func (e *templateConfigError) Error() string { return e.msg }

// ExitCode returns 2 for an invalid --template value.
func (e *templateConfigError) ExitCode() int { return 2 }

// templateFuncs are the helper functions exposed to --template templates. They
// cover the common shaping needs (embedding JSON, changing case, joining a
// list) without pulling in a large templating dependency.
var templateFuncs = template.FuncMap{
"json": templateJSON,
"upper": func(v any) string { return strings.ToUpper(fmt.Sprint(v)) },
"lower": func(v any) string { return strings.ToLower(fmt.Sprint(v)) },
"join": templateJoin,
}

// templateJSON marshals a value to compact JSON so a template can embed a
// nested object or array as a string.
func templateJSON(v any) (string, error) {
out, err := json.Marshal(v)
if err != nil {
return "", err
}
return string(out), nil
}

// templateJoin concatenates the elements of a list with a separator. It accepts
// the []any that JSON arrays decode into as well as a plain []string, and
// renders each element with fmt.Sprint so numbers and strings both work.
func templateJoin(sep string, list any) string {
switch v := list.(type) {
case []string:
return strings.Join(v, sep)
case []any:
parts := make([]string, len(v))
for i, item := range v {
parts[i] = fmt.Sprint(item)
}
return strings.Join(parts, sep)
default:
return fmt.Sprint(list)
}
}

// resolveTemplateText returns the template source for a --template value. A
// value that starts with "@" is treated as a path and the file is read; any
// other value is used as the template text directly. A missing file is returned
// as a templateConfigError so the process exits with code 2.
func resolveTemplateText(value string) (string, error) {
if !strings.HasPrefix(value, "@") {
return value, nil
}
path := value[1:]
data, err := os.ReadFile(path) // #nosec G304 -- User-specified template file path via --template @file is intentional.
if err != nil {
return "", &templateConfigError{msg: fmt.Sprintf("could not read --template file %q: %v", path, err)}
}
return string(data), nil
}

// parseTemplate resolves and compiles a --template value. Parse and file errors
// are returned as templateConfigError (exit 2) so an invalid template fails fast
// before any request is made.
func parseTemplate(value string) (*template.Template, error) {
text, err := resolveTemplateText(value)
if err != nil {
return nil, err
}
tmpl, err := template.New("response").Funcs(templateFuncs).Parse(text)
if err != nil {
return nil, &templateConfigError{msg: fmt.Sprintf("invalid --template syntax: %v", err)}
}
return tmpl, nil
}

// renderTemplate parses the --template value, decodes the JSON body, and
// executes the template against it. A non-JSON body is reported as a clear
// error. Template execution errors are wrapped with context.
func renderTemplate(value string, body []byte) (string, error) {
tmpl, err := parseTemplate(value)
if err != nil {
return "", err
}

dec := json.NewDecoder(bytes.NewReader(body))
dec.UseNumber()
var data any
if err := dec.Decode(&data); err != nil {
return "", fmt.Errorf("--template needs a JSON response, but the body did not parse as JSON: %w", err)
}

var out bytes.Buffer
if err := tmpl.Execute(&out, data); err != nil {
return "", fmt.Errorf("--template failed to render: %w", err)
}
return out.String(), nil
}
154 changes: 154 additions & 0 deletions cli/src/internal/service/template_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
package service

import (
"context"
"errors"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"

"github.com/jongio/azd-rest/src/internal/config"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// templateCoder mirrors the structural exit-code contract used by main so the
// tests can assert the exit code without importing the cmd package.
type templateCoder interface{ ExitCode() int }

func TestRenderTemplate_ARMValueNames(t *testing.T) {
body := []byte(`{"value":[{"name":"a"},{"name":"b"},{"name":"c"}]}`)
out, err := renderTemplate(`{{range .value}}{{.name}}{{"\n"}}{{end}}`, body)
require.NoError(t, err)
assert.Equal(t, "a\nb\nc\n", out)
}

func TestRenderTemplate_Helpers(t *testing.T) {
body := []byte(`{"name":"Kv","tags":["x","y"],"nested":{"k":1}}`)

tests := []struct {
name string
tmpl string
want string
}{
{name: "upper", tmpl: `{{upper .name}}`, want: "KV"},
{name: "lower", tmpl: `{{lower .name}}`, want: "kv"},
{name: "join", tmpl: `{{join "," .tags}}`, want: "x,y"},
{name: "json", tmpl: `{{json .nested}}`, want: `{"k":1}`},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
out, err := renderTemplate(tt.tmpl, body)
require.NoError(t, err)
assert.Equal(t, tt.want, out)
})
}
}

func TestRenderTemplate_InvalidSyntaxExits2(t *testing.T) {
_, err := renderTemplate(`{{range .value}}`, []byte(`{}`))
require.Error(t, err)

var coder templateCoder
require.True(t, errors.As(err, &coder), "invalid syntax should carry an exit code")
assert.Equal(t, 2, coder.ExitCode())
}

func TestRenderTemplate_NonJSONReportsError(t *testing.T) {
_, err := renderTemplate(`{{.name}}`, []byte("not json"))
require.Error(t, err)
assert.Contains(t, err.Error(), "JSON")
}

func TestParseTemplate_MissingFileExits2(t *testing.T) {
_, err := parseTemplate("@" + filepath.Join(t.TempDir(), "missing.tmpl"))
require.Error(t, err)

var coder templateCoder
require.True(t, errors.As(err, &coder), "missing file should carry an exit code")
assert.Equal(t, 2, coder.ExitCode())
}

func TestRenderTemplate_FromFile(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "report.tmpl")
require.NoError(t, os.WriteFile(path, []byte(`id={{.id}}`), 0o600))

out, err := renderTemplate("@"+path, []byte(`{"id":"abc"}`))
require.NoError(t, err)
assert.Equal(t, "id=abc", out)
}

func TestExecute_Template_TakesPrecedenceOverFormat(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":"one"},{"name":"two"}]}`))
}))
defer srv.Close()

tmp := filepath.Join(t.TempDir(), "out.txt")
cfg := config.Defaults()
cfg.NoAuth = true
cfg.OutputFile = tmp
cfg.OutputFormat = "yaml"
cfg.Template = `{{range .value}}{{.name}} {{end}}`

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.Equal(t, "one two ", got)
assert.NotContains(t, strings.ToLower(got), "name") // structured format keys not emitted
}

func TestExecute_Template_RunsAfterQuery(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"},{"name":"b"}]}`))
}))
defer srv.Close()

tmp := filepath.Join(t.TempDir(), "out.txt")
cfg := config.Defaults()
cfg.NoAuth = true
cfg.OutputFile = tmp
cfg.Query = "value"
cfg.Template = `{{range .}}{{.name}}-{{end}}`

err := newTestService().Execute(context.Background(), cfg, "GET", srv.URL+"/list")
require.NoError(t, err)

out, readErr := os.ReadFile(tmp)
require.NoError(t, readErr)
assert.Equal(t, "a-b-", string(out))
}

func TestExecute_Template_InvalidExitsBeforeRequest(t *testing.T) {
called := false
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
called = true
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()

cfg := config.Defaults()
cfg.NoAuth = true
cfg.OutputFile = filepath.Join(t.TempDir(), "out.txt")
cfg.Template = `{{range .value}}`

err := newTestService().Execute(context.Background(), cfg, "GET", srv.URL+"/never")
require.Error(t, err)

var coder templateCoder
require.True(t, errors.As(err, &coder))
assert.Equal(t, 2, coder.ExitCode())
assert.False(t, called, "no request should be made when the template is invalid")
}
1 change: 1 addition & 0 deletions cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@
"synapse",
"tcpdump",
"telemetry",
"tmpl",
"traceparent",
"tracestate",
"usgovcloudapi",
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>--template</code></td><td></td><td>string</td><td>""</td><td>Render the JSON response through a Go <code>text/template</code>. Use <code>@file</code> to load the template from a file. Runs after <code>--query</code> and takes precedence over <code>--format</code>. Helper functions: <code>json</code>, <code>upper</code>, <code>lower</code>, <code>join</code>. Invalid template syntax or a missing file exits with code 2.</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