← back to Cli Printing Press
feat(cli): split BaseURL from GraphQLEndpointPath in GraphQL parser + client
699e8ed750158b1644159e06ac0ab894f25c1dc6 · 2026-04-29 20:44:46 -0500 · Cathryn Lavery
Today the GraphQL parser stored the full URL ("https://api.linear.app/graphql")
in BaseURL and the template hardcoded "/graphql" in c.Post — the printed CLI
ended up posting to "<base>/graphql/graphql" if anyone wired BaseURL through
unchanged, and there was no way to model per-tenant or versioned endpoints
like Shopify's "/admin/api/{version}/graphql.json".
Splits the two:
- Linear/GitHub/Shopify defaults now return BaseURL + endpoint path
separately ("https://api.linear.app", "/graphql"). Existing GraphQL specs
default the path to "/graphql" so the parser is byte-compatible at the
generator output for the standard case.
- graphql_client.go.tmpl now renders {{.GraphQLEndpointPath}} into a const
and posts against that, instead of the literal "/graphql".
Tests cover (a) the parser exposes both fields independently, (b) REST
specs do not populate the GraphQL-only fields, and (c) the existing
GraphQL-compiles regression keeps passing (touched as part of the wider
test run, not in this commit).
PR-2 will wire EndpointTemplateVars into env-var substitution at request
time. PR-1 carries the field as plumbing only.
Files touched
M internal/generator/templates/graphql_client.go.tmplM internal/graphql/parser.goM internal/graphql/parser_test.goM internal/openapi/parser_test.goM internal/spec/spec.go
Diff
commit 699e8ed750158b1644159e06ac0ab894f25c1dc6
Author: Cathryn Lavery <50469282+cathrynlavery@users.noreply.github.com>
Date: Wed Apr 29 20:44:46 2026 -0500
feat(cli): split BaseURL from GraphQLEndpointPath in GraphQL parser + client
Today the GraphQL parser stored the full URL ("https://api.linear.app/graphql")
in BaseURL and the template hardcoded "/graphql" in c.Post — the printed CLI
ended up posting to "<base>/graphql/graphql" if anyone wired BaseURL through
unchanged, and there was no way to model per-tenant or versioned endpoints
like Shopify's "/admin/api/{version}/graphql.json".
Splits the two:
- Linear/GitHub/Shopify defaults now return BaseURL + endpoint path
separately ("https://api.linear.app", "/graphql"). Existing GraphQL specs
default the path to "/graphql" so the parser is byte-compatible at the
generator output for the standard case.
- graphql_client.go.tmpl now renders {{.GraphQLEndpointPath}} into a const
and posts against that, instead of the literal "/graphql".
Tests cover (a) the parser exposes both fields independently, (b) REST
specs do not populate the GraphQL-only fields, and (c) the existing
GraphQL-compiles regression keeps passing (touched as part of the wider
test run, not in this commit).
PR-2 will wire EndpointTemplateVars into env-var substitution at request
time. PR-1 carries the field as plumbing only.
---
.../generator/templates/graphql_client.go.tmpl | 16 ++++++--
internal/graphql/parser.go | 35 +++++++++++------
internal/graphql/parser_test.go | 4 +-
internal/openapi/parser_test.go | 5 +++
internal/spec/spec.go | 44 +++++++++++-----------
5 files changed, 66 insertions(+), 38 deletions(-)
diff --git a/internal/generator/templates/graphql_client.go.tmpl b/internal/generator/templates/graphql_client.go.tmpl
index d037b5dc..22154063 100644
--- a/internal/generator/templates/graphql_client.go.tmpl
+++ b/internal/generator/templates/graphql_client.go.tmpl
@@ -53,10 +53,17 @@ func isGraphQLAccessDenialCode(code string) bool {
return false
}
-// Query executes a GraphQL query via POST /graphql and returns the raw data payload.
+// graphqlEndpointPath is the request path appended to BaseURL for every
+// GraphQL POST. It is rendered from APISpec.GraphQLEndpointPath at generate
+// time so per-tenant or versioned endpoints (e.g.,
+// "/admin/api/{version}/graphql.json") work without editing the client.
+const graphqlEndpointPath = "{{.GraphQLEndpointPath}}"
+
+// Query executes a GraphQL query via POST against graphqlEndpointPath and
+// returns the raw data payload.
func (c *Client) Query(query string, variables map[string]any) (json.RawMessage, error) {
req := GraphQLRequest{Query: query, Variables: variables}
- raw, _, err := c.Post("/graphql", req)
+ raw, _, err := c.Post(graphqlEndpointPath, req)
if err != nil {
return nil, err
}
@@ -86,8 +93,9 @@ func (c *Client) Query(query string, variables map[string]any) (json.RawMessage,
return resp.Data, nil
}
-// Mutate executes a GraphQL mutation via POST /graphql and returns the raw data payload.
-// Identical transport to Query; separate method for clarity.
+// Mutate executes a GraphQL mutation via POST against graphqlEndpointPath and
+// returns the raw data payload. Identical transport to Query; separate method
+// for clarity.
func (c *Client) Mutate(query string, variables map[string]any) (json.RawMessage, error) {
return c.Query(query, variables)
}
diff --git a/internal/graphql/parser.go b/internal/graphql/parser.go
index d3413a29..d33390cb 100644
--- a/internal/graphql/parser.go
+++ b/internal/graphql/parser.go
@@ -70,9 +70,16 @@ func parseSDLContent(source, raw string) (*spec.APISpec, error) {
}
name := deriveAPIName(source)
- baseURL, auth := knownGraphQLDefaults(name, source)
+ baseURL, endpointPath, auth := knownGraphQLDefaults(name, source)
if baseURL == "" {
- baseURL = "https://api.example.com/graphql"
+ baseURL = "https://api.example.com"
+ }
+ if endpointPath == "" {
+ // Default to the canonical "/graphql" path so existing GraphQL specs
+ // regenerate to a working client without spec-author intervention.
+ // Specs that need a different path (Shopify's
+ // "/admin/api/{version}/graphql.json") populate the field directly.
+ endpointPath = "/graphql"
}
if auth.Type == "" {
auth = spec.AuthConfig{
@@ -83,10 +90,11 @@ func parseSDLContent(source, raw string) (*spec.APISpec, error) {
}
apiSpec := &spec.APISpec{
- Name: name,
- Description: "Generated from GraphQL schema",
- BaseURL: baseURL,
- Auth: auth,
+ Name: name,
+ Description: "Generated from GraphQL schema",
+ BaseURL: baseURL,
+ GraphQLEndpointPath: endpointPath,
+ Auth: auth,
Config: spec.ConfigSpec{
Format: "toml",
Path: fmt.Sprintf("~/.config/%s-pp-cli/config.toml", name),
@@ -772,28 +780,33 @@ func deriveAPIName(source string) string {
return base
}
-func knownGraphQLDefaults(name, source string) (string, spec.AuthConfig) {
+// knownGraphQLDefaults returns the (baseURL, graphqlEndpointPath, auth) tuple
+// for APIs the parser recognizes by name or source URL. The split between
+// baseURL and endpointPath lets the generated client build per-tenant or
+// versioned URLs at request time (PR-2 substitutes {var} placeholders) rather
+// than baking the path into the BaseURL string.
+func knownGraphQLDefaults(name, source string) (string, string, spec.AuthConfig) {
match := strings.ToLower(name + " " + source)
switch {
case strings.Contains(match, "linear"):
- return "https://api.linear.app/graphql", spec.AuthConfig{
+ return "https://api.linear.app", "/graphql", spec.AuthConfig{
Type: "api_key",
Header: "Authorization",
EnvVars: []string{"LINEAR_API_KEY"},
}
case strings.Contains(match, "github"):
- return "https://api.github.com/graphql", spec.AuthConfig{
+ return "https://api.github.com", "/graphql", spec.AuthConfig{
Type: "bearer_token",
Header: "Authorization",
EnvVars: []string{"GITHUB_TOKEN"},
}
case strings.Contains(match, "shopify"):
- return "https://shopify.dev/graphql", spec.AuthConfig{
+ return "https://shopify.dev", "/graphql", spec.AuthConfig{
Type: "api_key",
Header: "X-Shopify-Access-Token",
EnvVars: []string{"SHOPIFY_ACCESS_TOKEN"},
}
default:
- return "", spec.AuthConfig{}
+ return "", "", spec.AuthConfig{}
}
}
diff --git a/internal/graphql/parser_test.go b/internal/graphql/parser_test.go
index f70ad934..12329392 100644
--- a/internal/graphql/parser_test.go
+++ b/internal/graphql/parser_test.go
@@ -100,7 +100,9 @@ func TestParseSDLContent(t *testing.T) {
require.NoError(t, err)
assert.Equal(t, "linear", parsed.Name)
- assert.Equal(t, "https://api.linear.app/graphql", parsed.BaseURL)
+ assert.Equal(t, "https://api.linear.app", parsed.BaseURL)
+ assert.Equal(t, "/graphql", parsed.GraphQLEndpointPath)
+ assert.Empty(t, parsed.EndpointTemplateVars)
assert.Equal(t, "api_key", parsed.Auth.Type)
assert.Equal(t, []string{"LINEAR_API_KEY"}, parsed.Auth.EnvVars)
diff --git a/internal/openapi/parser_test.go b/internal/openapi/parser_test.go
index e9babbcd..ef5b2bbe 100644
--- a/internal/openapi/parser_test.go
+++ b/internal/openapi/parser_test.go
@@ -29,6 +29,11 @@ func TestParsePetstore(t *testing.T) {
assert.Equal(t, "petstore", parsed.Name)
assert.Equal(t, "", parsed.BaseURL)
assert.Equal(t, "/api/v3", parsed.BasePath)
+ // REST specs must leave the GraphQL-only fields unset; the generated
+ // graphql_client template is gated on isGraphQLSpec so a stray value here
+ // would silently leak into REST clients that never call POST /graphql.
+ assert.Empty(t, parsed.GraphQLEndpointPath)
+ assert.Empty(t, parsed.EndpointTemplateVars)
assert.NotEmpty(t, parsed.Resources)
hasEndpoint := false
diff --git a/internal/spec/spec.go b/internal/spec/spec.go
index 6b7f81e0..5737f049 100644
--- a/internal/spec/spec.go
+++ b/internal/spec/spec.go
@@ -70,10 +70,10 @@ type APISpec struct {
// then to a generic "Manage <api> resources via the <api> API". Adding
// this field eliminates a recurring manual rewrite step that the main
// skill used to instruct Claude to perform after every generation.
- CLIDescription string `yaml:"cli_description,omitempty" json:"cli_description,omitempty"`
- Version string `yaml:"version" json:"version"`
- BaseURL string `yaml:"base_url" json:"base_url"`
- BasePath string `yaml:"base_path,omitempty" json:"base_path,omitempty"`
+ CLIDescription string `yaml:"cli_description,omitempty" json:"cli_description,omitempty"`
+ Version string `yaml:"version" json:"version"`
+ BaseURL string `yaml:"base_url" json:"base_url"`
+ BasePath string `yaml:"base_path,omitempty" json:"base_path,omitempty"`
// GraphQLEndpointPath is the path appended to BaseURL for GraphQL POSTs.
// REST specs leave it empty; GraphQL specs default it to "/graphql" but
// can override (e.g., Shopify's "/admin/api/{version}/graphql.json").
@@ -87,24 +87,24 @@ type APISpec struct {
// generator emits per-variable env-var lookups in the printed CLI's
// config so users can resolve them at runtime. PR-1 carries this field
// as plumbing only; PR-2 wires the runtime substitution.
- EndpointTemplateVars []string `yaml:"endpoint_template_vars,omitempty" json:"endpoint_template_vars,omitempty"`
- Owner string `yaml:"owner,omitempty" json:"owner,omitempty"` // GitHub owner for import paths and Homebrew tap
- Kind string `yaml:"kind,omitempty" json:"kind,omitempty"` // "rest" (default) or "synthetic" — synthetic CLIs aggregate multiple sources beyond the spec; dogfood's path-validity check is relaxed accordingly
- SpecSource string `yaml:"spec_source,omitempty" json:"spec_source,omitempty"` // official, community, sniffed, docs — affects generated client defaults
- ClientPattern string `yaml:"client_pattern,omitempty" json:"client_pattern,omitempty"` // rest (default), proxy-envelope — affects generated HTTP client
- HTTPTransport string `yaml:"http_transport,omitempty" json:"http_transport,omitempty"` // standard (default for official APIs), browser-chrome, or browser-chrome-h3
- ProxyRoutes map[string]string `yaml:"proxy_routes,omitempty" json:"proxy_routes,omitempty"` // path prefix → service name for proxy-envelope routing
- WebsiteURL string `yaml:"website_url,omitempty" json:"website_url,omitempty"` // product/company website (not the API base URL)
- Category string `yaml:"category,omitempty" json:"category,omitempty"` // catalog category (e.g., productivity, developer-tools) — used for library install path
- Auth AuthConfig `yaml:"auth" json:"auth"`
- RequiredHeaders []RequiredHeader `yaml:"required_headers,omitempty" json:"required_headers,omitempty"`
- Config ConfigSpec `yaml:"config" json:"config"`
- Resources map[string]Resource `yaml:"resources" json:"resources"`
- Types map[string]TypeDef `yaml:"types" json:"types"`
- ExtraCommands []ExtraCommand `yaml:"extra_commands,omitempty" json:"extra_commands,omitempty"` // hand-written cobra commands declared so SKILL.md can document them; spec-only metadata, no code generated
- Cache CacheConfig `yaml:"cache,omitempty" json:"cache"` // cache freshness + auto-refresh config; when enabled, generated read commands auto-refresh stale local data before serving
- Share ShareConfig `yaml:"share,omitempty" json:"share"` // git-backed snapshot sharing config; when enabled, emits a `share` subcommand that publishes/subscribes to a git repo
- MCP MCPConfig `yaml:"mcp,omitempty" json:"mcp"` // MCP server generation config; when unset, the emitted MCP binary is stdio-only (today's default). Opting into http adds a --transport/--addr flag surface so the same binary can serve cloud-hosted agents.
+ EndpointTemplateVars []string `yaml:"endpoint_template_vars,omitempty" json:"endpoint_template_vars,omitempty"`
+ Owner string `yaml:"owner,omitempty" json:"owner,omitempty"` // GitHub owner for import paths and Homebrew tap
+ Kind string `yaml:"kind,omitempty" json:"kind,omitempty"` // "rest" (default) or "synthetic" — synthetic CLIs aggregate multiple sources beyond the spec; dogfood's path-validity check is relaxed accordingly
+ SpecSource string `yaml:"spec_source,omitempty" json:"spec_source,omitempty"` // official, community, sniffed, docs — affects generated client defaults
+ ClientPattern string `yaml:"client_pattern,omitempty" json:"client_pattern,omitempty"` // rest (default), proxy-envelope — affects generated HTTP client
+ HTTPTransport string `yaml:"http_transport,omitempty" json:"http_transport,omitempty"` // standard (default for official APIs), browser-chrome, or browser-chrome-h3
+ ProxyRoutes map[string]string `yaml:"proxy_routes,omitempty" json:"proxy_routes,omitempty"` // path prefix → service name for proxy-envelope routing
+ WebsiteURL string `yaml:"website_url,omitempty" json:"website_url,omitempty"` // product/company website (not the API base URL)
+ Category string `yaml:"category,omitempty" json:"category,omitempty"` // catalog category (e.g., productivity, developer-tools) — used for library install path
+ Auth AuthConfig `yaml:"auth" json:"auth"`
+ RequiredHeaders []RequiredHeader `yaml:"required_headers,omitempty" json:"required_headers,omitempty"`
+ Config ConfigSpec `yaml:"config" json:"config"`
+ Resources map[string]Resource `yaml:"resources" json:"resources"`
+ Types map[string]TypeDef `yaml:"types" json:"types"`
+ ExtraCommands []ExtraCommand `yaml:"extra_commands,omitempty" json:"extra_commands,omitempty"` // hand-written cobra commands declared so SKILL.md can document them; spec-only metadata, no code generated
+ Cache CacheConfig `yaml:"cache,omitempty" json:"cache"` // cache freshness + auto-refresh config; when enabled, generated read commands auto-refresh stale local data before serving
+ Share ShareConfig `yaml:"share,omitempty" json:"share"` // git-backed snapshot sharing config; when enabled, emits a `share` subcommand that publishes/subscribes to a git repo
+ MCP MCPConfig `yaml:"mcp,omitempty" json:"mcp"` // MCP server generation config; when unset, the emitted MCP binary is stdio-only (today's default). Opting into http adds a --transport/--addr flag surface so the same binary can serve cloud-hosted agents.
}
// ExtraCommand declares a hand-written cobra command so the SKILL.md
← 73d136f7 feat(cli): add GraphQLEndpointPath and EndpointTemplateVars
·
back to Cli Printing Press
·
test(cli): cover templated BaseURL + GraphQLEndpointPath ren 7e08324a →