[object Object]

← 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

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 →