← 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
M internal/generator/generator.goM internal/generator/generator_test.goM internal/generator/templates/client.go.tmplM internal/generator/templates/command_endpoint.go.tmplM internal/generator/templates/command_promoted.go.tmplM internal/spec/spec.goM internal/spec/spec_test.go
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 →