From 69057a8f85469935e60fbdedf4df39ea96304f0c Mon Sep 17 00:00:00 2001 From: Jon Gallant <2163001+jongio@users.noreply.github.com> Date: Mon, 13 Jul 2026 10:46:41 -0700 Subject: [PATCH] feat: add --template to render responses with a Go template Add a --template flag that renders a JSON response through a Go text/template, so you can shape output into any text form (custom lines, env files, config snippets) without piping through jq or a separate templating step. The template runs after --query and takes precedence over --format, so you can narrow the response first and then render exactly what you want. Use @file to load the template from a file. Helper functions json, upper, lower, and join cover the common shaping needs. Invalid template syntax or a missing @file exits with code 2 before any request is made, so a typo fails fast instead of after a network call. A non-JSON body reports a clear error. Closes #279 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- cli/src/internal/cmd/root.go | 3 + cli/src/internal/config/config.go | 1 + cli/src/internal/service/service.go | 20 +++ cli/src/internal/service/template.go | 113 ++++++++++++++++ cli/src/internal/service/template_test.go | 154 ++++++++++++++++++++++ cspell.json | 1 + web/src/pages/reference.astro | 1 + 7 files changed, 293 insertions(+) create mode 100644 cli/src/internal/service/template.go create mode 100644 cli/src/internal/service/template_test.go diff --git a/cli/src/internal/cmd/root.go b/cli/src/internal/cmd/root.go index 35e9e2a..4ac8ebf 100644 --- a/cli/src/internal/cmd/root.go +++ b/cli/src/internal/cmd/root.go @@ -34,6 +34,7 @@ var ( dataFile string dataFormat string query string + tmpl string formFields []string jsonFields []string jsonFieldsRaw []string @@ -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)") @@ -274,6 +276,7 @@ func snapshotConfig() config.Config { DataFile: dataFile, DataFormat: dataFormat, Query: query, + Template: tmpl, FormFields: formFields, JSONFields: jsonFields, JSONFieldsRaw: jsonFieldsRaw, diff --git a/cli/src/internal/config/config.go b/cli/src/internal/config/config.go index 0e8015c..8b548a1 100644 --- a/cli/src/internal/config/config.go +++ b/cli/src/internal/config/config.go @@ -20,6 +20,7 @@ type Config struct { DataFile string DataFormat string Query string + Template string FormFields []string JSONFields []string JSONFieldsRaw []string diff --git a/cli/src/internal/service/service.go b/cli/src/internal/service/service.go index c12646c..09c9e28 100644 --- a/cli/src/internal/service/service.go +++ b/cli/src/internal/service/service.go @@ -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) @@ -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. diff --git a/cli/src/internal/service/template.go b/cli/src/internal/service/template.go new file mode 100644 index 0000000..893c49e --- /dev/null +++ b/cli/src/internal/service/template.go @@ -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 +} diff --git a/cli/src/internal/service/template_test.go b/cli/src/internal/service/template_test.go new file mode 100644 index 0000000..7a8041b --- /dev/null +++ b/cli/src/internal/service/template_test.go @@ -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") +} diff --git a/cspell.json b/cspell.json index 3e461fa..51c5b2a 100644 --- a/cspell.json +++ b/cspell.json @@ -60,6 +60,7 @@ "synapse", "tcpdump", "telemetry", + "tmpl", "traceparent", "tracestate", "usgovcloudapi", diff --git a/web/src/pages/reference.astro b/web/src/pages/reference.astro index 446eed5..4782ccd 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-rboolfalseWith --query, print a string result unquoted and an array of strings one per line, like jq -r. Requires --query. --redactstring[][]Mask a JSON response field before output (repeatable). Dotted path, * matches array elements. Applies to json, auto, table, and jsonl. --flattenboolfalseFlatten a JSON response into a single-level object keyed by dotted paths (properties.provisioningState, value[0].name). Leaf values keep their type. Applies to the json and auto output paths and composes with --query. + --templatestring""Render the JSON response through a Go text/template. Use @file to load the template from a file. Runs after --query and takes precedence over --format. Helper functions: json, upper, lower, join. Invalid template syntax or a missing file exits with code 2. --dump-headersstring""Write response status line and headers to a file (use - for stderr). Sensitive values are redacted. --binaryboolfalseStream as binary without transformation --verbose-vboolfalseShow headers, timing, request details (tokens redacted)