[object Object]

← back to Cli Printing Press

feat(cli): per-resource base_url override in spec schema (#405)

6586c0a28b135134cab5d5a8f3a4e3156e3c71bc · 2026-04-29 20:34:10 -0700 · Trevin Chow

* feat(cli): per-resource base_url override in spec schema

Adds a BaseURL field to spec.Resource so APIs that split
functionality across distinct hosts can be modeled cleanly. The
motivating case is Open-Meteo:

  forecast:    https://api.open-meteo.com/v1/...
  geocoding:   https://geocoding-api.open-meteo.com/v1/...
  air-quality: https://air-quality-api.open-meteo.com/v1/...

Three different services, three different hosts. Today's spec model
is a single top-level base_url; with this change each resource (and
sub-resource) can override.

Distinct from #403's EndpointTemplateVars: that mechanism resolves
runtime placeholders from env vars (Shopify-style multi-tenant).
This is generation-time fixed routing per resource.

How it works:

- spec.Resource gains a BaseURL field. Sub-resources inherit the
  parent's override; an explicit sub-resource BaseURL wins.
- The endpoint and promoted-command templates emit the full URL into
  the handler's `path :=` literal when the resource carries an
  override (`https://geocoding-api.example.com/v1/search`).
- The client's do() learns to detect absolute URLs (https://, http://)
  and skip the c.BaseURL concat for those calls. The detection branch
  and isAbsoluteURL helper are gated on a generator-computed flag
  (HasResourceBaseURLOverride) so specs that don't opt into the
  feature regenerate byte-identically — the existing
  TestGenerateNoEndpointTemplateVarsByteCompat caught this when the
  branch was emitted unconditionally.

The proxy-envelope client pattern is incompatible with per-resource
overrides (it POSTs everything to a single URL). Documented on the
field's doc comment; not enforced at parse time because no spec in
the library uses both today.

Tests:
- TestGenerateResourceBaseURLOverrideRoutesToOverrideHost: Open-Meteo-
  shaped fixture, asserts override host lands in the geocoding handler
  and the no-override resource keeps its relative path.
- TestGenerateSubResourceInheritsParentBaseURL: parent override flows
  to sub-resources, explicit sub-resource override wins.
- TestGenerateNoResourceBaseURLOverrideByteCompat: byte-compat
  guarantee at the resource level (loops fixture has no overrides;
  isAbsoluteURL helper must not be emitted).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(cli): consolidate per-resource base_url helpers + named struct

Apply /simplify findings on the per-resource base_url change:

- Move HasResourceBaseURLOverride to a method on *spec.APISpec,
  matching the established HasHTMLExtraction pattern in the spec
  package. Drops the parallel free function in generator.go and
  the matching struct field on clientTemplateData; the template
  consumes the method directly via APISpec embedding.
- Extract the duplicate epData anonymous struct (top-level resource
  + sub-resource loops) into a named endpointTemplateData type
  alongside the other named template-data structs.
- Hoist the sub-resource BaseURL inheritance computation out of the
  endpoints loop. The result is a sub-resource property, not a
  per-endpoint one — runs once per sub-resource now.
- Trim the BaseURL field doc to match AGENTS.md "no incident-shaped
  justification" — the original named specific APIs (Open-Meteo,
  AWS) which belong in PR description, not the field doc.
- Replace the path :=  inline comment with a Go-template comment so
  the explanation lives in the template author's view, not in every
  generated handler file. Trailing-comment placement preserves the
  surrounding blank lines that {{- /* */}} would consume.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(cli): trim trailing slash on resource base_url + reject proxy-envelope combo

Address review follow-ups on the per-resource base_url change:

- Trim trailing slash off resource.BaseURL at the data-passing site so
  spec.yaml entries like `base_url: "https://api.example.com/v1/"`
  don't produce `https://api.example.com/v1//search` after the
  template's `path := <base><endpoint.path>` concat. Trimming once at
  the generator means spec authors don't have to memorize the
  no-trailing-slash convention. Applied at all three sites: top-level
  resource, sub-resource (with parent-fallback), and the promoted
  command lookup.

- Reject `client_pattern: proxy-envelope` combined with any
  resource-level base_url override at parse time. Proxy-envelope POSTs
  every request to a single URL; per-resource overrides would be
  silently ignored at runtime. Failing in Validate() surfaces the
  conflict at parse time instead of producing a CLI that silently
  routes to the wrong host.

- Add a test for EndpointTemplateVars + Resource.BaseURL combined.
  Confirms the orthogonality claim: a resource override carrying a
  {placeholder} marker (e.g., `https://{shop}.storefront-api.example.com/v1`)
  flows through the generator into `path` verbatim, and the client's
  buildURL substitutes the placeholder via the absolute-URL branch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 6586c0a28b135134cab5d5a8f3a4e3156e3c71bc
Author: Trevin Chow <trevin@trevinchow.com>
Date:   Wed Apr 29 20:34:10 2026 -0700

    feat(cli): per-resource base_url override in spec schema (#405)
    
    * feat(cli): per-resource base_url override in spec schema
    
    Adds a BaseURL field to spec.Resource so APIs that split
    functionality across distinct hosts can be modeled cleanly. The
    motivating case is Open-Meteo:
    
      forecast:    https://api.open-meteo.com/v1/...
      geocoding:   https://geocoding-api.open-meteo.com/v1/...
      air-quality: https://air-quality-api.open-meteo.com/v1/...
    
    Three different services, three different hosts. Today's spec model
    is a single top-level base_url; with this change each resource (and
    sub-resource) can override.
    
    Distinct from #403's EndpointTemplateVars: that mechanism resolves
    runtime placeholders from env vars (Shopify-style multi-tenant).
    This is generation-time fixed routing per resource.
    
    How it works:
    
    - spec.Resource gains a BaseURL field. Sub-resources inherit the
      parent's override; an explicit sub-resource BaseURL wins.
    - The endpoint and promoted-command templates emit the full URL into
      the handler's `path :=` literal when the resource carries an
      override (`https://geocoding-api.example.com/v1/search`).
    - The client's do() learns to detect absolute URLs (https://, http://)
      and skip the c.BaseURL concat for those calls. The detection branch
      and isAbsoluteURL helper are gated on a generator-computed flag
      (HasResourceBaseURLOverride) so specs that don't opt into the
      feature regenerate byte-identically — the existing
      TestGenerateNoEndpointTemplateVarsByteCompat caught this when the
      branch was emitted unconditionally.
    
    The proxy-envelope client pattern is incompatible with per-resource
    overrides (it POSTs everything to a single URL). Documented on the
    field's doc comment; not enforced at parse time because no spec in
    the library uses both today.
    
    Tests:
    - TestGenerateResourceBaseURLOverrideRoutesToOverrideHost: Open-Meteo-
      shaped fixture, asserts override host lands in the geocoding handler
      and the no-override resource keeps its relative path.
    - TestGenerateSubResourceInheritsParentBaseURL: parent override flows
      to sub-resources, explicit sub-resource override wins.
    - TestGenerateNoResourceBaseURLOverrideByteCompat: byte-compat
      guarantee at the resource level (loops fixture has no overrides;
      isAbsoluteURL helper must not be emitted).
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
    
    * refactor(cli): consolidate per-resource base_url helpers + named struct
    
    Apply /simplify findings on the per-resource base_url change:
    
    - Move HasResourceBaseURLOverride to a method on *spec.APISpec,
      matching the established HasHTMLExtraction pattern in the spec
      package. Drops the parallel free function in generator.go and
      the matching struct field on clientTemplateData; the template
      consumes the method directly via APISpec embedding.
    - Extract the duplicate epData anonymous struct (top-level resource
      + sub-resource loops) into a named endpointTemplateData type
      alongside the other named template-data structs.
    - Hoist the sub-resource BaseURL inheritance computation out of the
      endpoints loop. The result is a sub-resource property, not a
      per-endpoint one — runs once per sub-resource now.
    - Trim the BaseURL field doc to match AGENTS.md "no incident-shaped
      justification" — the original named specific APIs (Open-Meteo,
      AWS) which belong in PR description, not the field doc.
    - Replace the path :=  inline comment with a Go-template comment so
      the explanation lives in the template author's view, not in every
      generated handler file. Trailing-comment placement preserves the
      surrounding blank lines that {{- /* */}} would consume.
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
    
    * fix(cli): trim trailing slash on resource base_url + reject proxy-envelope combo
    
    Address review follow-ups on the per-resource base_url change:
    
    - Trim trailing slash off resource.BaseURL at the data-passing site so
      spec.yaml entries like `base_url: "https://api.example.com/v1/"`
      don't produce `https://api.example.com/v1//search` after the
      template's `path := <base><endpoint.path>` concat. Trimming once at
      the generator means spec authors don't have to memorize the
      no-trailing-slash convention. Applied at all three sites: top-level
      resource, sub-resource (with parent-fallback), and the promoted
      command lookup.
    
    - Reject `client_pattern: proxy-envelope` combined with any
      resource-level base_url override at parse time. Proxy-envelope POSTs
      every request to a single URL; per-resource overrides would be
      silently ignored at runtime. Failing in Validate() surfaces the
      conflict at parse time instead of producing a CLI that silently
      routes to the wrong host.
    
    - Add a test for EndpointTemplateVars + Resource.BaseURL combined.
      Confirms the orthogonality claim: a resource override carrying a
      {placeholder} marker (e.g., `https://{shop}.storefront-api.example.com/v1`)
      flows through the generator into `path` verbatim, and the client's
      buildURL substitutes the placeholder via the absolute-URL branch.
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
    
    ---------
    
    Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
 internal/generator/generator.go                    |  97 ++++++----
 internal/generator/generator_test.go               | 210 +++++++++++++++++++++
 internal/generator/templates/client.go.tmpl        |  38 ++++
 .../generator/templates/command_endpoint.go.tmpl   |   2 +-
 .../generator/templates/command_promoted.go.tmpl   |   2 +-
 internal/spec/spec.go                              |  42 ++++-
 internal/spec/spec_test.go                         |  79 ++++++++
 7 files changed, 424 insertions(+), 46 deletions(-)

diff --git a/internal/generator/generator.go b/internal/generator/generator.go
index 5a426c4d..6fd86588 100644
--- a/internal/generator/generator.go
+++ b/internal/generator/generator.go
@@ -588,6 +588,24 @@ type clientTemplateData struct {
 	HasGraphQLPersistedQueries bool
 }
 
+// endpointTemplateData is the data passed to command_endpoint.go.tmpl
+// for both top-level resource endpoints and sub-resource endpoints.
+// ResourceBaseURL carries the resource's BaseURL override (or its
+// inherited parent override for sub-resources); the template prepends
+// it to Endpoint.Path so per-resource hosts produce absolute URLs.
+type endpointTemplateData struct {
+	ResourceName    string
+	ResourceBaseURL string
+	FuncPrefix      string
+	CommandPath     string
+	EndpointName    string
+	Endpoint        spec.Endpoint
+	HasStore        bool
+	IsAsync         bool
+	Async           AsyncJobInfo
+	*spec.APISpec
+}
+
 // readmeTemplateData wraps APISpec with additional fields for README rendering.
 type readmeTemplateData struct {
 	*spec.APISpec
@@ -1307,26 +1325,17 @@ func (g *Generator) renderResourceCommands(promotedResourceNames map[string]bool
 				continue
 			}
 			asyncInfo, isAsync := g.AsyncJobs[name+"/"+eName]
-			epData := struct {
-				ResourceName string
-				FuncPrefix   string
-				CommandPath  string
-				EndpointName string
-				Endpoint     spec.Endpoint
-				HasStore     bool
-				IsAsync      bool
-				Async        AsyncJobInfo
-				*spec.APISpec
-			}{
-				ResourceName: name,
-				FuncPrefix:   name,
-				CommandPath:  name,
-				EndpointName: eName,
-				Endpoint:     endpoint,
-				HasStore:     g.VisionSet.Store,
-				IsAsync:      isAsync,
-				Async:        asyncInfo,
-				APISpec:      g.Spec,
+			epData := endpointTemplateData{
+				ResourceName:    name,
+				ResourceBaseURL: strings.TrimRight(resource.BaseURL, "/"),
+				FuncPrefix:      name,
+				CommandPath:     name,
+				EndpointName:    eName,
+				Endpoint:        endpoint,
+				HasStore:        g.VisionSet.Store,
+				IsAsync:         isAsync,
+				Async:           asyncInfo,
+				APISpec:         g.Spec,
 			}
 			epPath := filepath.Join("internal", "cli", safeResourceFileStem(name+"_"+eName)+".go")
 			if err := g.renderTemplate("command_endpoint.go.tmpl", epPath, epData); err != nil {
@@ -1362,29 +1371,31 @@ func (g *Generator) renderResourceCommands(promotedResourceNames map[string]bool
 				}
 			}
 
+			// Sub-resources inherit the parent's BaseURL override; an
+			// explicit sub_resource.base_url wins. Falls through to the
+			// spec-level BaseURL when both are empty. Trailing slash is
+			// trimmed so the template's `path := <base><endpoint.path>`
+			// concat doesn't produce `https://x.com/v1//search` when the
+			// override and endpoint path both carry slashes.
+			subResourceBaseURL := subResource.BaseURL
+			if subResourceBaseURL == "" {
+				subResourceBaseURL = resource.BaseURL
+			}
+			subResourceBaseURL = strings.TrimRight(subResourceBaseURL, "/")
 			for eName, endpoint := range subResource.Endpoints {
 				subKey := subName + "/" + eName
 				asyncInfo, isAsync := g.AsyncJobs[subKey]
-				epData := struct {
-					ResourceName string
-					FuncPrefix   string
-					CommandPath  string
-					EndpointName string
-					Endpoint     spec.Endpoint
-					HasStore     bool
-					IsAsync      bool
-					Async        AsyncJobInfo
-					*spec.APISpec
-				}{
-					ResourceName: subName,
-					FuncPrefix:   name + "-" + subName,
-					CommandPath:  name + " " + subName,
-					EndpointName: eName,
-					Endpoint:     endpoint,
-					HasStore:     g.VisionSet.Store,
-					IsAsync:      isAsync,
-					Async:        asyncInfo,
-					APISpec:      g.Spec,
+				epData := endpointTemplateData{
+					ResourceName:    subName,
+					ResourceBaseURL: subResourceBaseURL,
+					FuncPrefix:      name + "-" + subName,
+					CommandPath:     name + " " + subName,
+					EndpointName:    eName,
+					Endpoint:        endpoint,
+					HasStore:        g.VisionSet.Store,
+					IsAsync:         isAsync,
+					Async:           asyncInfo,
+					APISpec:         g.Spec,
 				}
 				epPath := filepath.Join("internal", "cli", safeResourceFileStem(name+"_"+subName+"_"+eName)+".go")
 				if err := g.renderTemplate("command_endpoint.go.tmpl", epPath, epData); err != nil {
@@ -1758,8 +1769,12 @@ func (g *Generator) renderPromotedCommandFiles(promotedCommands []PromotedComman
 	// Generate promoted top-level commands (user-friendly aliases for nested API commands)
 	// promotedCommands was computed earlier so promoted resources can replace their raw parents.
 	for _, pc := range promotedCommands {
-		// Look up the full resource to pass sibling endpoints/sub-resources
+		// Look up the full resource to pass sibling endpoints/sub-resources.
+		// Trim trailing slash on BaseURL so the promoted handler's
+		// `path := <Resource.BaseURL><Endpoint.Path>` concat doesn't
+		// produce `https://x.com/v1//search`.
 		resource := g.Spec.Resources[pc.ResourceName]
+		resource.BaseURL = strings.TrimRight(resource.BaseURL, "/")
 		promotedData := struct {
 			PromotedName string
 			ResourceName string
diff --git a/internal/generator/generator_test.go b/internal/generator/generator_test.go
index bb143fec..e83647d5 100644
--- a/internal/generator/generator_test.go
+++ b/internal/generator/generator_test.go
@@ -4143,6 +4143,216 @@ func TestGenerateNoEndpointTemplateVarsByteCompat(t *testing.T) {
 		"config struct must not carry TemplateVars when EndpointTemplateVars is empty")
 }
 
+// TestGenerateResourceBaseURLOverrideRoutesToOverrideHost — Open-Meteo
+// shape: top-level BaseURL points at api.open-meteo.com, but the
+// geocoding resource lives at geocoding-api.open-meteo.com. The
+// generated handler must emit the override host into `path` so the
+// client's absolute-URL branch routes the request to the right host.
+//
+// Each resource carries two endpoints so the generator emits per-
+// endpoint handler files instead of promoting them to top-level
+// commands (single-endpoint resources are inlined into a promoted
+// command, which would skip the per-endpoint file emit path).
+func TestGenerateResourceBaseURLOverrideRoutesToOverrideHost(t *testing.T) {
+	t.Parallel()
+	apiSpec := minimalSpec("multihost")
+	apiSpec.BaseURL = "https://api.example.com/v1"
+	apiSpec.Resources["forecast"] = spec.Resource{
+		Description: "Weather forecast",
+		Endpoints: map[string]spec.Endpoint{
+			"now":    {Method: "GET", Path: "/forecast", Description: "Current"},
+			"hourly": {Method: "GET", Path: "/forecast/hourly", Description: "Hourly"},
+		},
+	}
+	apiSpec.Resources["geocoding"] = spec.Resource{
+		Description: "Geocoding lookup",
+		BaseURL:     "https://geocoding-api.example.com/v1",
+		Endpoints: map[string]spec.Endpoint{
+			"search":  {Method: "GET", Path: "/search", Description: "Search"},
+			"reverse": {Method: "GET", Path: "/reverse", Description: "Reverse"},
+		},
+	}
+
+	outputDir := filepath.Join(t.TempDir(), naming.CLI(apiSpec.Name))
+	gen := New(apiSpec, outputDir)
+	require.NoError(t, gen.Generate())
+
+	// The geocoding handler emits the absolute URL into `path`.
+	geoHandler, err := os.ReadFile(filepath.Join(outputDir, "internal", "cli", "geocoding_search.go"))
+	require.NoError(t, err)
+	assert.Contains(t, string(geoHandler), `path := "https://geocoding-api.example.com/v1/search"`,
+		"geocoding handler must emit the absolute URL into path")
+
+	// The forecast handler keeps the relative path (no override on this resource).
+	fcHandler, err := os.ReadFile(filepath.Join(outputDir, "internal", "cli", "forecast_now.go"))
+	require.NoError(t, err)
+	assert.Contains(t, string(fcHandler), `path := "/forecast"`,
+		"forecast handler must keep the relative path when its resource has no override")
+
+	// The client emits the absolute-URL branch + isAbsoluteURL helper.
+	clientGo, err := os.ReadFile(filepath.Join(outputDir, "internal", "client", "client.go"))
+	require.NoError(t, err)
+	assert.Contains(t, string(clientGo), "func isAbsoluteURL(path string) bool",
+		"client must emit isAbsoluteURL helper when at least one resource has a BaseURL override")
+	assert.Contains(t, string(clientGo), "if isAbsoluteURL(path) {",
+		"client.do() must branch on isAbsoluteURL")
+
+	// Must compile.
+	runGoCommand(t, outputDir, "mod", "tidy")
+	runGoCommand(t, outputDir, "build", "./...")
+}
+
+// TestGenerateSubResourceInheritsParentBaseURL — a sub-resource without
+// its own BaseURL inherits the parent resource's override. An explicit
+// sub-resource override takes precedence.
+func TestGenerateSubResourceInheritsParentBaseURL(t *testing.T) {
+	t.Parallel()
+	apiSpec := minimalSpec("multihost-sub")
+	apiSpec.BaseURL = "https://api.example.com/v1"
+	apiSpec.Resources["geocoding"] = spec.Resource{
+		Description: "Geocoding",
+		BaseURL:     "https://geocoding-api.example.com/v1",
+		SubResources: map[string]spec.Resource{
+			// Inherits parent override.
+			"city": {
+				Description: "City lookup",
+				Endpoints: map[string]spec.Endpoint{
+					"get": {Method: "GET", Path: "/city", Description: "Get"},
+				},
+			},
+			// Explicit override beats parent.
+			"reverse": {
+				Description: "Reverse geocoding",
+				BaseURL:     "https://reverse-api.example.com/v1",
+				Endpoints: map[string]spec.Endpoint{
+					"get": {Method: "GET", Path: "/reverse", Description: "Get"},
+				},
+			},
+		},
+	}
+
+	outputDir := filepath.Join(t.TempDir(), naming.CLI(apiSpec.Name))
+	gen := New(apiSpec, outputDir)
+	require.NoError(t, gen.Generate())
+
+	cityHandler, err := os.ReadFile(filepath.Join(outputDir, "internal", "cli", "geocoding_city_get.go"))
+	require.NoError(t, err)
+	assert.Contains(t, string(cityHandler), `path := "https://geocoding-api.example.com/v1/city"`,
+		"sub-resource without its own BaseURL must inherit the parent's override")
+
+	reverseHandler, err := os.ReadFile(filepath.Join(outputDir, "internal", "cli", "geocoding_reverse_get.go"))
+	require.NoError(t, err)
+	assert.Contains(t, string(reverseHandler), `path := "https://reverse-api.example.com/v1/reverse"`,
+		"sub-resource with its own BaseURL must use its own override")
+
+	runGoCommand(t, outputDir, "mod", "tidy")
+	runGoCommand(t, outputDir, "build", "./...")
+}
+
+// TestGenerateNoResourceBaseURLOverrideByteCompat — specs without any
+// per-resource BaseURL override must regenerate without the
+// isAbsoluteURL helper or the absolute-URL branch in client.do().
+// Mirrors the EndpointTemplateVars byte-compat guarantee at the
+// resource level.
+func TestGenerateNoResourceBaseURLOverrideByteCompat(t *testing.T) {
+	t.Parallel()
+	apiSpec, err := spec.Parse(filepath.Join("..", "..", "testdata", "loops.yaml"))
+	require.NoError(t, err)
+	for _, r := range apiSpec.Resources {
+		require.Empty(t, r.BaseURL,
+			"loops fixture must keep resource BaseURL empty for the byte-compat case")
+	}
+
+	outputDir := filepath.Join(t.TempDir(), naming.CLI(apiSpec.Name))
+	gen := New(apiSpec, outputDir)
+	require.NoError(t, gen.Generate())
+
+	clientGo, err := os.ReadFile(filepath.Join(outputDir, "internal", "client", "client.go"))
+	require.NoError(t, err)
+	assert.NotContains(t, string(clientGo), "isAbsoluteURL",
+		"client.go must not carry the isAbsoluteURL helper when no resource has a BaseURL override")
+	assert.Contains(t, string(clientGo), "targetURL := c.BaseURL + path",
+		"client.do() must keep the raw concat when no resource has a BaseURL override")
+}
+
+// TestGenerateResourceBaseURLTrailingSlashTrimmed — the most likely
+// spec-author mistake is `base_url: "https://api.example.com/v1/"`
+// (trailing slash) paired with `endpoints[].path: "/search"` (leading
+// slash). Without normalization the emitted handler concatenates to
+// `https://api.example.com/v1//search`. Trim trailing slash off
+// the override at the data-passing site so spec authors don't have
+// to memorize the convention.
+func TestGenerateResourceBaseURLTrailingSlashTrimmed(t *testing.T) {
+	t.Parallel()
+	apiSpec := minimalSpec("trailingslash")
+	apiSpec.Resources["geocoding"] = spec.Resource{
+		Description: "Geocoding lookup",
+		BaseURL:     "https://geocoding-api.example.com/v1/",
+		Endpoints: map[string]spec.Endpoint{
+			"search":  {Method: "GET", Path: "/search", Description: "Search"},
+			"reverse": {Method: "GET", Path: "/reverse", Description: "Reverse"},
+		},
+	}
+
+	outputDir := filepath.Join(t.TempDir(), naming.CLI(apiSpec.Name))
+	gen := New(apiSpec, outputDir)
+	require.NoError(t, gen.Generate())
+
+	geoHandler, err := os.ReadFile(filepath.Join(outputDir, "internal", "cli", "geocoding_search.go"))
+	require.NoError(t, err)
+	assert.Contains(t, string(geoHandler), `path := "https://geocoding-api.example.com/v1/search"`,
+		"trailing slash on the override must be trimmed before concatenation")
+	assert.NotContains(t, string(geoHandler), `path := "https://geocoding-api.example.com/v1//search"`,
+		"double slash from base+path concat must not leak into the generated handler")
+}
+
+// TestGenerateResourceBaseURLWithEndpointTemplateVars — confirms
+// per-resource BaseURL composes correctly with EndpointTemplateVars.
+// A resource declares both a templated host (`{shop}` resolved from
+// env at runtime) and a fixed override host. The generator emits the
+// templated absolute URL into `path`; the client's buildURL substitutes
+// the placeholder before the request fires.
+func TestGenerateResourceBaseURLWithEndpointTemplateVars(t *testing.T) {
+	t.Parallel()
+	apiSpec := minimalSpec("multitenant-multihost")
+	apiSpec.BaseURL = "https://{shop}.api.example.com/v1"
+	apiSpec.EndpointTemplateVars = []string{"shop"}
+	apiSpec.Resources["storefront"] = spec.Resource{
+		Description: "Storefront API on a per-tenant host",
+		BaseURL:     "https://{shop}.storefront-api.example.com/v1",
+		Endpoints: map[string]spec.Endpoint{
+			"products":    {Method: "GET", Path: "/products", Description: "List products"},
+			"collections": {Method: "GET", Path: "/collections", Description: "List collections"},
+		},
+	}
+
+	outputDir := filepath.Join(t.TempDir(), naming.CLI(apiSpec.Name))
+	gen := New(apiSpec, outputDir)
+	require.NoError(t, gen.Generate())
+
+	// Handler emits the override host with the placeholder intact;
+	// resolution happens in the client at request time.
+	handler, err := os.ReadFile(filepath.Join(outputDir, "internal", "cli", "storefront_products.go"))
+	require.NoError(t, err)
+	assert.Contains(t, string(handler),
+		`path := "https://{shop}.storefront-api.example.com/v1/products"`,
+		"override host with {placeholder} markers must flow into path verbatim")
+
+	// Client emits both the EndpointTemplateVars resolution AND the
+	// absolute-URL detection — the combined branch goes through
+	// `buildURL("", path, endpointVars)` for absolute paths.
+	clientGo, err := os.ReadFile(filepath.Join(outputDir, "internal", "client", "client.go"))
+	require.NoError(t, err)
+	clientStr := string(clientGo)
+	assert.Contains(t, clientStr, "func isAbsoluteURL(path string) bool",
+		"isAbsoluteURL helper must be emitted")
+	assert.Contains(t, clientStr, `buildURL("", path, endpointVars)`,
+		"absolute-URL branch must call buildURL with empty BaseURL so {placeholder} markers in path still substitute")
+
+	runGoCommand(t, outputDir, "mod", "tidy")
+	runGoCommand(t, outputDir, "build", "./...")
+}
+
 // TestGenerateMCPMainStdioDefault confirms that a spec with no mcp: block
 // produces the same stdio-only MCP entry point we've always emitted. Remote
 // transport is opt-in; the default stays on the current behavior so existing
diff --git a/internal/generator/templates/client.go.tmpl b/internal/generator/templates/client.go.tmpl
index f2042fe0..fba1d80b 100644
--- a/internal/generator/templates/client.go.tmpl
+++ b/internal/generator/templates/client.go.tmpl
@@ -281,13 +281,42 @@ func (c *Client) do(method, path string, params map[string]string, body any, hea
 {{- end}}
 {{- else}}
 {{- if .EndpointTemplateVars}}
+{{- if .HasResourceBaseURLOverride}}
+	// Per-resource BaseURL overrides flow through as absolute paths
+	// (https:// or http://); buildURL still substitutes any
+	// {placeholder} markers, but the relative-path concat with
+	// c.BaseURL must be skipped for absolute paths.
+	var targetURL string
+	var urlErr error
+	if isAbsoluteURL(path) {
+		targetURL, urlErr = buildURL("", path, endpointVars)
+	} else {
+		targetURL, urlErr = buildURL(c.BaseURL, path, endpointVars)
+	}
+	if urlErr != nil {
+		return nil, 0, urlErr
+	}
+{{- else}}
 	targetURL, urlErr := buildURL(c.BaseURL, path, endpointVars)
 	if urlErr != nil {
 		return nil, 0, urlErr
 	}
+{{- end}}
+{{- else}}
+{{- if .HasResourceBaseURLOverride}}
+	// Per-resource BaseURL overrides flow through as absolute paths
+	// (https:// or http://); the resource template emits the full URL
+	// in `path` and we skip the c.BaseURL concat for those.
+	var targetURL string
+	if isAbsoluteURL(path) {
+		targetURL = path
+	} else {
+		targetURL = c.BaseURL + path
+	}
 {{- else}}
 	targetURL := c.BaseURL + path
 {{- end}}
+{{- end}}
 {{- end}}
 
 	var bodyBytes []byte
@@ -753,6 +782,15 @@ func (c *Client) refreshAccessToken() error {
 	return nil
 }
 
+{{if .HasResourceBaseURLOverride -}}
+// isAbsoluteURL reports whether path is already a full URL (per-resource
+// BaseURL override emits the full URL into path; the relative-path
+// concat with c.BaseURL must be skipped for those calls).
+func isAbsoluteURL(path string) bool {
+	return strings.HasPrefix(path, "https://") || strings.HasPrefix(path, "http://")
+}
+
+{{end -}}
 // sanitizeJSONResponse strips known JSONP/XSSI prefixes and UTF-8 BOM from
 // response bodies so that downstream JSON parsing succeeds. For clean JSON
 // responses these checks are no-ops.
diff --git a/internal/generator/templates/command_endpoint.go.tmpl b/internal/generator/templates/command_endpoint.go.tmpl
index bb7cf40d..f29fa1c9 100644
--- a/internal/generator/templates/command_endpoint.go.tmpl
+++ b/internal/generator/templates/command_endpoint.go.tmpl
@@ -113,7 +113,7 @@ func new{{camel .FuncPrefix}}{{camel .EndpointName}}Cmd(flags *rootFlags) *cobra
 				return err
 			}
 
-			path := "{{.Endpoint.Path}}"
+			path := "{{.ResourceBaseURL}}{{.Endpoint.Path}}"{{/* ResourceBaseURL is empty unless the resource declares an override; when set it makes path absolute and the client skips the c.BaseURL concat. */}}
 {{- range $i, $p := .Endpoint.Params}}
 {{- if .Positional}}
 {{- if gt $i 0}}
diff --git a/internal/generator/templates/command_promoted.go.tmpl b/internal/generator/templates/command_promoted.go.tmpl
index 045a3c32..d139e444 100644
--- a/internal/generator/templates/command_promoted.go.tmpl
+++ b/internal/generator/templates/command_promoted.go.tmpl
@@ -75,7 +75,7 @@ func new{{camel .PromotedName}}PromotedCmd(flags *rootFlags) *cobra.Command {
 				return err
 			}
 
-			path := "{{.Endpoint.Path}}"
+			path := "{{.Resource.BaseURL}}{{.Endpoint.Path}}"{{/* Resource.BaseURL is empty unless the resource declares an override; when set it makes path absolute and the client skips the c.BaseURL concat. */}}
 {{- range $i, $p := .Endpoint.Params}}
 {{- if .Positional}}
 			if len(args) < {{add $i 1}} {
diff --git a/internal/spec/spec.go b/internal/spec/spec.go
index 5737f049..a8cce6b2 100644
--- a/internal/spec/spec.go
+++ b/internal/spec/spec.go
@@ -439,13 +439,46 @@ func (m MCPConfig) HasTransport(t string) bool {
 }
 
 type Resource struct {
-	Description  string              `yaml:"description" json:"description"`
-	Path         string              `yaml:"path,omitempty" json:"path,omitempty"`             // base path for operations shorthand (e.g., /api/items)
-	Operations   []string            `yaml:"operations,omitempty" json:"operations,omitempty"` // shorthand: list, get, create, update, delete, search
+	Description string   `yaml:"description" json:"description"`
+	Path        string   `yaml:"path,omitempty" json:"path,omitempty"`             // base path for operations shorthand (e.g., /api/items)
+	Operations  []string `yaml:"operations,omitempty" json:"operations,omitempty"` // shorthand: list, get, create, update, delete, search
+	// BaseURL overrides the spec-level BaseURL for this resource's
+	// endpoints. Fixed at generation time. Incompatible with the
+	// proxy-envelope client pattern, which POSTs every request to a
+	// single URL.
+	BaseURL      string              `yaml:"base_url,omitempty" json:"base_url,omitempty"`
 	Endpoints    map[string]Endpoint `yaml:"endpoints" json:"endpoints"`
 	SubResources map[string]Resource `yaml:"sub_resources,omitempty" json:"sub_resources,omitempty"`
 }
 
+// HasResourceBaseURLOverride reports whether any resource (top-level or
+// nested sub-resource) declares a BaseURL override. Used by the client
+// template to gate the absolute-URL detection branch — specs that don't
+// opt in regenerate byte-identically.
+func (s *APISpec) HasResourceBaseURLOverride() bool {
+	if s == nil {
+		return false
+	}
+	for _, resource := range s.Resources {
+		if resourceHasBaseURLOverride(resource) {
+			return true
+		}
+	}
+	return false
+}
+
+func resourceHasBaseURLOverride(resource Resource) bool {
+	if resource.BaseURL != "" {
+		return true
+	}
+	for _, sub := range resource.SubResources {
+		if resourceHasBaseURLOverride(sub) {
+			return true
+		}
+	}
+	return false
+}
+
 type Endpoint struct {
 	Method          string            `yaml:"method" json:"method"`
 	Path            string            `yaml:"path" json:"path"`
@@ -911,6 +944,9 @@ func (s *APISpec) Validate() error {
 	if err := validateMCP(s.MCP, s.Resources); err != nil {
 		return err
 	}
+	if s.ClientPattern == "proxy-envelope" && s.HasResourceBaseURLOverride() {
+		return fmt.Errorf("resource base_url overrides are incompatible with client_pattern=proxy-envelope; the proxy POSTs every request to the spec-level BaseURL, so per-resource overrides would be silently ignored")
+	}
 	for name, r := range s.Resources {
 		if len(r.Endpoints) == 0 && len(r.SubResources) == 0 {
 			return fmt.Errorf("resource %q has no endpoints", name)
diff --git a/internal/spec/spec_test.go b/internal/spec/spec_test.go
index 05bd8227..703bf057 100644
--- a/internal/spec/spec_test.go
+++ b/internal/spec/spec_test.go
@@ -2005,3 +2005,82 @@ func TestSnakeToPascal(t *testing.T) {
 		})
 	}
 }
+
+// TestValidateRejectsResourceBaseURLWithProxyEnvelope — proxy-envelope
+// POSTs every request to a single URL; per-resource overrides would
+// be silently ignored. Validate must fail-fast so spec authors who
+// declare both see the conflict at parse time, not at runtime when
+// requests mysteriously route to the wrong host.
+func TestValidateRejectsResourceBaseURLWithProxyEnvelope(t *testing.T) {
+	t.Parallel()
+	s := &APISpec{
+		Name:          "proxytest",
+		Version:       "0.1.0",
+		BaseURL:       "https://proxy.example.com",
+		ClientPattern: "proxy-envelope",
+		Resources: map[string]Resource{
+			"items": {
+				BaseURL: "https://other.example.com/v1",
+				Endpoints: map[string]Endpoint{
+					"list": {Method: "GET", Path: "/items", Description: "List"},
+				},
+			},
+		},
+	}
+	err := s.Validate()
+	require.Error(t, err)
+	assert.Contains(t, err.Error(), "proxy-envelope")
+	assert.Contains(t, err.Error(), "base_url")
+}
+
+// TestValidateAcceptsResourceBaseURLWithoutProxyEnvelope — the same
+// resource override is accepted when client_pattern is not the proxy
+// flavor. Negative cases (no resource override, proxy-envelope alone)
+// stay valid.
+func TestValidateAcceptsResourceBaseURLWithoutProxyEnvelope(t *testing.T) {
+	t.Parallel()
+	cases := []struct {
+		name string
+		spec *APISpec
+	}{
+		{
+			name: "rest client with resource override",
+			spec: &APISpec{
+				Name:          "ok-multihost",
+				Version:       "0.1.0",
+				BaseURL:       "https://api.example.com",
+				ClientPattern: "rest",
+				Resources: map[string]Resource{
+					"items": {
+						BaseURL: "https://other.example.com/v1",
+						Endpoints: map[string]Endpoint{
+							"list": {Method: "GET", Path: "/items", Description: "List"},
+						},
+					},
+				},
+			},
+		},
+		{
+			name: "proxy-envelope without any resource override",
+			spec: &APISpec{
+				Name:          "proxy-only",
+				Version:       "0.1.0",
+				BaseURL:       "https://proxy.example.com",
+				ClientPattern: "proxy-envelope",
+				Resources: map[string]Resource{
+					"items": {
+						Endpoints: map[string]Endpoint{
+							"list": {Method: "GET", Path: "/items", Description: "List"},
+						},
+					},
+				},
+			},
+		},
+	}
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			t.Parallel()
+			assert.NoError(t, tc.spec.Validate())
+		})
+	}
+}

← 7670fe98 fix(cli): mcp-sync bumps mcp-go pin when migrating to cobrat  ·  back to Cli Printing Press  ·  fix(cli): scorecard human output shows N/A for opt-out dimen 0954bcaf →