← back to Cli Printing Press
feat(cli): emit buildURL helper + Config.TemplateVars for templated endpoints
289e5988d6615058f9f7a892ec3a4a88ea8f11c2 · 2026-04-29 21:48:37 -0500 · Cathryn Lavery
Add internal/client/url.go (rendered from url.go.tmpl when the spec declares
EndpointTemplateVars) plus the matching Config.TemplateVars map. buildURL
substitutes every {placeholder} in baseURL+path against vars; missing values
produce an actionable error naming the env var and the export hint
(SHOPIFY_SHOP not set; export SHOPIFY_SHOP=<value>) instead of silently
sending a request to a literal "{shop}" URL.
The new file is gated behind len(EndpointTemplateVars) > 0 so existing
GraphQL/REST specs (Linear, OpenAPI fixtures) regenerate byte-compatibly.
Config.TemplateVars is populated at Load() time from
{upper Name}_{upper var} env vars (SHOPIFY_SHOP, SHOPIFY_API_VERSION).
Files touched
M internal/generator/generator.goM internal/generator/templates/config.go.tmplA internal/generator/templates/url.go.tmpl
Diff
commit 289e5988d6615058f9f7a892ec3a4a88ea8f11c2
Author: Cathryn Lavery <50469282+cathrynlavery@users.noreply.github.com>
Date: Wed Apr 29 21:48:37 2026 -0500
feat(cli): emit buildURL helper + Config.TemplateVars for templated endpoints
Add internal/client/url.go (rendered from url.go.tmpl when the spec declares
EndpointTemplateVars) plus the matching Config.TemplateVars map. buildURL
substitutes every {placeholder} in baseURL+path against vars; missing values
produce an actionable error naming the env var and the export hint
(SHOPIFY_SHOP not set; export SHOPIFY_SHOP=<value>) instead of silently
sending a request to a literal "{shop}" URL.
The new file is gated behind len(EndpointTemplateVars) > 0 so existing
GraphQL/REST specs (Linear, OpenAPI fixtures) regenerate byte-compatibly.
Config.TemplateVars is populated at Load() time from
{upper Name}_{upper var} env vars (SHOPIFY_SHOP, SHOPIFY_API_VERSION).
---
internal/generator/generator.go | 11 ++++
internal/generator/templates/config.go.tmpl | 23 ++++++++
internal/generator/templates/url.go.tmpl | 86 +++++++++++++++++++++++++++++
3 files changed, 120 insertions(+)
diff --git a/internal/generator/generator.go b/internal/generator/generator.go
index 27394ca2..5a426c4d 100644
--- a/internal/generator/generator.go
+++ b/internal/generator/generator.go
@@ -1154,6 +1154,17 @@ func (g *Generator) renderOptionalSupportFiles() error {
}
}
+ // Specs that declare per-tenant URL placeholders (e.g. Shopify's
+ // "{shop}" / "{version}") get a buildURL helper that resolves the
+ // {var} markers against env-backed Config.TemplateVars at request
+ // time. Specs without EndpointTemplateVars skip the file so existing
+ // CLIs regenerate byte-for-byte.
+ if len(g.Spec.EndpointTemplateVars) > 0 {
+ if err := g.renderTemplate("url.go.tmpl", filepath.Join("internal", "client", "url.go"), g.Spec); err != nil {
+ return fmt.Errorf("rendering url helper: %w", err)
+ }
+ }
+
return nil
}
diff --git a/internal/generator/templates/config.go.tmpl b/internal/generator/templates/config.go.tmpl
index f59fc2b4..776914aa 100644
--- a/internal/generator/templates/config.go.tmpl
+++ b/internal/generator/templates/config.go.tmpl
@@ -33,6 +33,13 @@ type Config struct {
{{envVarField .}} string `{{configTag $.Config.Format}}:"{{envVarPlaceholder .}}"`
{{- end}}
{{- end}}
+{{- if .EndpointTemplateVars}}
+ // TemplateVars holds the runtime values for {placeholder} markers in
+ // BaseURL and the request path (e.g. Shopify's {shop}/{version}). Populated
+ // at Load() time from env vars; consumed by the client's buildURL helper.
+ // Stored as a serializable map so non-default values survive a config save.
+ TemplateVars map[string]string `{{configTag .Config.Format}}:"template_vars,omitempty"`
+{{- end}}
}
func Load(configPath string) (*Config, error) {
@@ -85,6 +92,22 @@ func Load(configPath string) (*Config, error) {
if v := os.Getenv("{{envName .Name}}_BASE_URL"); v != "" {
cfg.BaseURL = v
}
+{{- if .EndpointTemplateVars}}
+
+ // Endpoint template vars: resolve each {placeholder} in BaseURL or the
+ // GraphQL path against the matching env var. Populated even when values
+ // are empty so the client's buildURL helper can issue an actionable
+ // "export FOO_BAR=..." error instead of silently sending a request to a
+ // URL with literal "{shop}" in it.
+ if cfg.TemplateVars == nil {
+ cfg.TemplateVars = map[string]string{}
+ }
+{{- range .EndpointTemplateVars}}
+ if v := os.Getenv("{{envName $.Name}}_{{upper .}}"); v != "" {
+ cfg.TemplateVars["{{.}}"] = v
+ }
+{{- end}}
+{{- end}}
return cfg, nil
}
diff --git a/internal/generator/templates/url.go.tmpl b/internal/generator/templates/url.go.tmpl
new file mode 100644
index 00000000..991967a6
--- /dev/null
+++ b/internal/generator/templates/url.go.tmpl
@@ -0,0 +1,86 @@
+// Copyright {{currentYear}} {{.Owner}}. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package client
+
+import (
+ "fmt"
+ "regexp"
+ "sort"
+ "strings"
+)
+
+// templateVarPattern matches {placeholder} occurrences in a URL. The character
+// class mirrors the placeholder names emitted by the spec parser
+// (lowercase ASCII identifiers) and intentionally rejects nested braces or
+// punctuation so an unrelated literal { in a URL — query payloads pasted
+// verbatim, for example — is left alone.
+var templateVarPattern = regexp.MustCompile(`\{([a-zA-Z_][a-zA-Z0-9_]*)\}`)
+
+// templateVarEnvNames maps each placeholder declared in
+// APISpec.EndpointTemplateVars to the environment variable that resolves it.
+// The mapping is fixed at generate time so request-time substitution can
+// produce an actionable error ("export SHOPIFY_SHOP=...") without consulting
+// the spec. Placeholders that escape into a URL but aren't in this map fall
+// back to a generic "template variable {x} unresolved" message.
+var templateVarEnvNames = map[string]string{
+{{- range .EndpointTemplateVars}}
+ "{{.}}": "{{envName $.Name}}_{{upper .}}",
+{{- end}}
+}
+
+// buildURL concatenates baseURL and path, then substitutes every {placeholder}
+// against vars. vars is normally Config.TemplateVars, populated from env at
+// Load() time.
+//
+// Both BaseURL and path may carry placeholders — Shopify's BaseURL
+// "https://{shop}" combines with GraphQLEndpointPath
+// "/admin/api/{version}/graphql.json", and a single helper avoids two passes
+// of replacement scattered across the request methods. When any placeholder
+// remains unresolved the function returns a TemplateVarError naming the env
+// var the user should export.
+func buildURL(baseURL, path string, vars map[string]string) (string, error) {
+ full := baseURL + path
+ if !templateVarPattern.MatchString(full) {
+ return full, nil
+ }
+ seen := map[string]bool{}
+ var unresolved []string
+ resolved := templateVarPattern.ReplaceAllStringFunc(full, func(match string) string {
+ key := match[1 : len(match)-1]
+ if v, ok := vars[key]; ok && v != "" {
+ return v
+ }
+ if !seen[key] {
+ seen[key] = true
+ unresolved = append(unresolved, key)
+ }
+ return match
+ })
+ if len(unresolved) == 0 {
+ return resolved, nil
+ }
+ sort.Strings(unresolved)
+ return "", &TemplateVarError{Names: unresolved}
+}
+
+// TemplateVarError reports unresolved {var} placeholders detected at request
+// time. The message names the env var(s) the user needs to export — not just
+// the placeholder — because the placeholder ("shop") is implementation
+// vocabulary while the env var ("SHOPIFY_SHOP") is what they will actually
+// type into a shell.
+type TemplateVarError struct {
+ Names []string
+}
+
+func (e *TemplateVarError) Error() string {
+ parts := make([]string, 0, len(e.Names))
+ for _, name := range e.Names {
+ if env := templateVarEnvNames[name]; env != "" {
+ parts = append(parts, fmt.Sprintf("%s not set; export %s=<value>", env, env))
+ continue
+ }
+ parts = append(parts, fmt.Sprintf("template variable {%s} unresolved", name))
+ }
+ return strings.Join(parts, "; ")
+}
← 7e08324a test(cli): cover templated BaseURL + GraphQLEndpointPath ren
·
back to Cli Printing Press
·
feat(cli): substitute EndpointTemplateVars in client.do() + 8cb57db3 →