[object Object]

← 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

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 →