[object Object]

← 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

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 →