← back to Cli Printing Press
fix(generator): preserve multi-spec server prefixes (#861)
3e56bedf4b580a67e6bd54bf1705861687740e2f · 2026-05-10 23:51:57 -0700 · hnshah
* fix(generator): preserve multi-spec server prefixes
* test(cli): cover multi-spec nested base URLs
Files touched
M internal/cli/generate_test.goM internal/cli/root.go
Diff
commit 3e56bedf4b580a67e6bd54bf1705861687740e2f
Author: hnshah <hnshah@gmail.com>
Date: Sun May 10 23:51:57 2026 -0700
fix(generator): preserve multi-spec server prefixes (#861)
* fix(generator): preserve multi-spec server prefixes
* test(cli): cover multi-spec nested base URLs
---
internal/cli/generate_test.go | 119 ++++++++++++++++++++++++++++++++++++++++++
internal/cli/root.go | 40 ++++++++++++--
2 files changed, 155 insertions(+), 4 deletions(-)
diff --git a/internal/cli/generate_test.go b/internal/cli/generate_test.go
index 44136568..0a5492cd 100644
--- a/internal/cli/generate_test.go
+++ b/internal/cli/generate_test.go
@@ -898,6 +898,125 @@ resources:
assert.Contains(t, err.Error(), "not a shippable printed CLI runtime")
}
+func TestMergeSpecsPreservesPerSpecBaseURLPrefixes(t *testing.T) {
+ t.Parallel()
+
+ rootSpec := &spec.APISpec{
+ Name: "core",
+ Version: "0.1.0",
+ BaseURL: "https://tenant.example.com",
+ Resources: map[string]spec.Resource{
+ "projects": {
+ Endpoints: map[string]spec.Endpoint{
+ "list": {Method: "GET", Path: "/projects"},
+ },
+ },
+ },
+ Types: map[string]spec.TypeDef{},
+ }
+ wikiSpec := &spec.APISpec{
+ Name: "wiki",
+ Version: "0.1.0",
+ BaseURL: "https://tenant.example.com/wiki/api/v2",
+ Resources: map[string]spec.Resource{
+ "pages": {
+ Endpoints: map[string]spec.Endpoint{
+ "list": {Method: "GET", Path: "/pages"},
+ },
+ SubResources: map[string]spec.Resource{
+ "comments": {
+ Endpoints: map[string]spec.Endpoint{
+ "list": {Method: "GET", Path: "/pages/{page_id}/comments"},
+ },
+ },
+ },
+ },
+ },
+ Types: map[string]spec.TypeDef{},
+ }
+
+ merged := mergeSpecs([]*spec.APISpec{rootSpec, wikiSpec}, "combo")
+
+ assert.Equal(t, "https://tenant.example.com", merged.BaseURL)
+ assert.Empty(t, merged.Resources["projects"].BaseURL)
+ assert.Empty(t, merged.Resources["pages"].BaseURL)
+ assert.Empty(t, merged.Resources["pages"].SubResources["comments"].BaseURL)
+ assert.Equal(t, "/wiki/api/v2/pages", merged.Resources["pages"].Endpoints["list"].Path)
+ assert.Equal(
+ t,
+ "/wiki/api/v2/pages/{page_id}/comments",
+ merged.Resources["pages"].SubResources["comments"].Endpoints["list"].Path,
+ )
+}
+
+func TestGenerateMultiSpecEmitsNestedResourceBaseURLPrefix(t *testing.T) {
+ t.Parallel()
+
+ dir := t.TempDir()
+ rootSpecPath := filepath.Join(dir, "core.yaml")
+ wikiSpecPath := filepath.Join(dir, "wiki.yaml")
+ outputDir := filepath.Join(dir, "combo")
+ require.NoError(t, os.WriteFile(rootSpecPath, []byte(`name: core
+description: Core API
+version: 0.1.0
+base_url: https://tenant.example.com
+auth:
+ type: none
+resources:
+ projects:
+ description: Projects
+ endpoints:
+ list:
+ method: GET
+ path: /projects
+ description: List projects
+`), 0o644))
+ require.NoError(t, os.WriteFile(wikiSpecPath, []byte(`name: wiki
+description: Wiki API
+version: 0.1.0
+base_url: https://tenant.example.com/wiki/api/v2
+auth:
+ type: none
+resources:
+ pages:
+ description: Pages
+ endpoints:
+ list:
+ method: GET
+ path: /pages
+ description: List pages
+ sub_resources:
+ comments:
+ description: Page comments
+ endpoints:
+ list:
+ method: GET
+ path: /pages/{page_id}/comments
+ description: List page comments
+`), 0o644))
+
+ cmd := newGenerateCmd()
+ cmd.SetArgs([]string{
+ "--spec", rootSpecPath,
+ "--spec", wikiSpecPath,
+ "--name", "combo",
+ "--output", outputDir,
+ "--validate=false",
+ "--force",
+ })
+
+ require.NoError(t, cmd.Execute())
+
+ commentsHandler, err := os.ReadFile(filepath.Join(outputDir, "internal", "cli", "pages_comments_list.go"))
+ require.NoError(t, err)
+ assert.Contains(
+ t,
+ string(commentsHandler),
+ `path := "/wiki/api/v2/pages/{page_id}/comments"`,
+ )
+
+}
+
func TestMergeSpecsPrefersReplayableBrowserTransportOverUnshippablePageContext(t *testing.T) {
t.Parallel()
diff --git a/internal/cli/root.go b/internal/cli/root.go
index 033465f7..40fab783 100644
--- a/internal/cli/root.go
+++ b/internal/cli/root.go
@@ -754,7 +754,13 @@ func mergeSpecs(specs []*spec.APISpec, name string) *spec.APISpec {
prefix := perSpecPathPrefix[i]
for resourceName, resource := range s.Resources {
if prefix != "" {
- resource = prefixResourceEndpointPaths(resource, prefix)
+ // Same-host/different-path specs are normalized by folding each
+ // spec's path prefix into endpoint paths. Do not also preserve
+ // the source BaseURL path as a resource override, or generated
+ // commands double-prefix nested endpoints.
+ resource = prefixResourceEndpointPaths(resource, prefix, s.BaseURL)
+ } else {
+ resource = resourceWithMergedSpecBaseURL(resource, s.BaseURL, merged.BaseURL)
}
key := resourceName
if _, exists := merged.Resources[key]; exists {
@@ -779,6 +785,22 @@ func mergeSpecs(specs []*spec.APISpec, name string) *spec.APISpec {
return merged
}
+func resourceWithMergedSpecBaseURL(resource spec.Resource, sourceBaseURL, mergedBaseURL string) spec.Resource {
+ sourceBaseURL = strings.TrimRight(strings.TrimSpace(sourceBaseURL), "/")
+ mergedBaseURL = strings.TrimRight(strings.TrimSpace(mergedBaseURL), "/")
+ if sourceBaseURL != "" && sourceBaseURL != mergedBaseURL && strings.TrimSpace(resource.BaseURL) == "" {
+ resource.BaseURL = sourceBaseURL
+ }
+ if len(resource.SubResources) > 0 {
+ subResources := make(map[string]spec.Resource, len(resource.SubResources))
+ for name, sub := range resource.SubResources {
+ subResources[name] = resourceWithMergedSpecBaseURL(sub, sourceBaseURL, mergedBaseURL)
+ }
+ resource.SubResources = subResources
+ }
+ return resource
+}
+
// planMultiSpecBaseURL decides how to reconcile the BaseURL field across
// multiple input specs. The returned perSpecPathPrefix slice has one entry per
// spec; a non-empty entry tells the caller to prepend that prefix to every
@@ -851,12 +873,22 @@ func splitBaseURL(raw string) (host, path string) {
// declare an absolute BaseURL override are left alone — their path is
// resolved against that override at runtime, not the spec-level BaseURL, so
// folding the prefix in would double-resolve.
-func prefixResourceEndpointPaths(resource spec.Resource, prefix string) spec.Resource {
+func prefixResourceEndpointPaths(resource spec.Resource, prefix, sourceBaseURL string) spec.Resource {
out := resource
+ sourceBaseURL = strings.TrimRight(strings.TrimSpace(sourceBaseURL), "/")
+ // The path prefix is being folded into every endpoint path, so any inherited
+ // BaseURL for the same spec must be cleared. Keeping both causes generated
+ // absolute paths to include the prefix twice. Independent endpoint-level
+ // server overrides are preserved.
+ if strings.TrimRight(strings.TrimSpace(out.BaseURL), "/") == sourceBaseURL {
+ out.BaseURL = ""
+ }
if len(resource.Endpoints) > 0 {
out.Endpoints = make(map[string]spec.Endpoint, len(resource.Endpoints))
for name, ep := range resource.Endpoints {
- if ep.BaseURL == "" {
+ epBaseURL := strings.TrimRight(strings.TrimSpace(ep.BaseURL), "/")
+ if epBaseURL == "" || epBaseURL == sourceBaseURL {
+ ep.BaseURL = ""
ep.Path = prefix + ep.Path
}
out.Endpoints[name] = ep
@@ -865,7 +897,7 @@ func prefixResourceEndpointPaths(resource spec.Resource, prefix string) spec.Res
if len(resource.SubResources) > 0 {
out.SubResources = make(map[string]spec.Resource, len(resource.SubResources))
for name, sub := range resource.SubResources {
- out.SubResources[name] = prefixResourceEndpointPaths(sub, prefix)
+ out.SubResources[name] = prefixResourceEndpointPaths(sub, prefix, sourceBaseURL)
}
}
return out
← 5fabad63 fix(cli): sync skips resources with unresolved {key} placeho
·
back to Cli Printing Press
·
fix(cli): classify doctor HTTP 403 as scope-limited WARN, no b1dded7d →