← back to Cli Printing Press
fix(cli): OpenAPI parser walks per-operation servers as fallback (#511)
702f54a1455919be3516a104266f30ea3216fd60 · 2026-05-02 15:36:56 -0700 · Trevin Chow
Specs that declare `servers:` per-operation instead of at top level
were producing CLIs with `base_url: https://api.example.com` —
the placeholder set when no top-level servers are found. Live calls
DNS-failed on every operation. Surfaced on
`other/open-meteo` ([cli-printing-press#510](https://github.com/mvanhorn/cli-printing-press/issues/510)) where the spec declares
`servers:` under each path operation.
The parser now walks every operation's (and each path-item's)
`servers:` block when the top-level is empty, ranks resolved URLs
by occurrence count, and uses the most common one as the CLI's
base URL. Ties break lexicographically so the choice is
deterministic across runs.
Refactors the per-server URL normalization (template substitution,
protocol restoration, trailing-slash trim) into a shared
`resolveServerURL` helper so the top-level path and the
per-operation fallback share one normalization pass.
Tests:
- TestParsePerOperationServersFallback — 3 ops, 2x same URL,
asserts most-common wins
- TestParsePerOperationServersFallbackTieBreak — 1x each of two
URLs, asserts lexicographic tie-break
- TestParseTopLevelServersStillPreferred — top-level wins even
when operations have their own (regression guard)
Verified end-to-end against open-meteo's spec: regen now produces a
CLI with `base_url: https://api.open-meteo.com` and `forecast` calls
return real data.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Files touched
M internal/openapi/parser.goM internal/openapi/parser_test.go
Diff
commit 702f54a1455919be3516a104266f30ea3216fd60
Author: Trevin Chow <trevin@trevinchow.com>
Date: Sat May 2 15:36:56 2026 -0700
fix(cli): OpenAPI parser walks per-operation servers as fallback (#511)
Specs that declare `servers:` per-operation instead of at top level
were producing CLIs with `base_url: https://api.example.com` —
the placeholder set when no top-level servers are found. Live calls
DNS-failed on every operation. Surfaced on
`other/open-meteo` ([cli-printing-press#510](https://github.com/mvanhorn/cli-printing-press/issues/510)) where the spec declares
`servers:` under each path operation.
The parser now walks every operation's (and each path-item's)
`servers:` block when the top-level is empty, ranks resolved URLs
by occurrence count, and uses the most common one as the CLI's
base URL. Ties break lexicographically so the choice is
deterministic across runs.
Refactors the per-server URL normalization (template substitution,
protocol restoration, trailing-slash trim) into a shared
`resolveServerURL` helper so the top-level path and the
per-operation fallback share one normalization pass.
Tests:
- TestParsePerOperationServersFallback — 3 ops, 2x same URL,
asserts most-common wins
- TestParsePerOperationServersFallbackTieBreak — 1x each of two
URLs, asserts lexicographic tie-break
- TestParseTopLevelServersStillPreferred — top-level wins even
when operations have their own (regression guard)
Verified end-to-end against open-meteo's spec: regen now produces a
CLI with `base_url: https://api.open-meteo.com` and `forecast` calls
return real data.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
internal/openapi/parser.go | 140 +++++++++++++++++++++++++++++++---------
internal/openapi/parser_test.go | 102 +++++++++++++++++++++++++++++
2 files changed, 210 insertions(+), 32 deletions(-)
diff --git a/internal/openapi/parser.go b/internal/openapi/parser.go
index e104c1fb..4e21a06d 100644
--- a/internal/openapi/parser.go
+++ b/internal/openapi/parser.go
@@ -237,38 +237,17 @@ func parse(data []byte, lenient bool) (*spec.APISpec, error) {
baseURL := ""
basePath := ""
if len(doc.Servers) > 0 && doc.Servers[0] != nil {
- serverURL := strings.TrimRight(strings.TrimSpace(doc.Servers[0].URL), "/")
- // Resolve server URL template variables using defaults.
- if strings.Contains(serverURL, "{") && doc.Servers[0].Variables != nil {
- for varName, variable := range doc.Servers[0].Variables {
- if variable != nil && variable.Default != "" {
- serverURL = strings.ReplaceAll(serverURL, "{"+varName+"}", variable.Default)
- }
- }
- }
- // Strip any remaining unresolved template variables.
- for strings.Contains(serverURL, "{") {
- start := strings.Index(serverURL, "{")
- end := strings.Index(serverURL, "}")
- if start == -1 || end == -1 || end < start {
- break
- }
- serverURL = serverURL[:start] + serverURL[end+1:]
- }
- serverURL = strings.ReplaceAll(serverURL, "//", "/")
- // Restore protocol double-slash if normalization collapsed it.
- serverURL = strings.Replace(serverURL, "http:/", "http://", 1)
- serverURL = strings.Replace(serverURL, "https:/", "https://", 1)
- serverURL = strings.TrimRight(serverURL, "/")
- if serverURL != "" {
- lowerURL := strings.ToLower(serverURL)
- if strings.HasPrefix(lowerURL, "http://") || strings.HasPrefix(lowerURL, "https://") {
- baseURL = serverURL
- } else {
- // Relative URL - store the path portion to append when user configures a host
- basePath = serverURL
- warnf("server URL %q is relative; generated CLI will require base_url in config (e.g. https://example.com%s)", serverURL, serverURL)
- }
+ baseURL, basePath = resolveServerURL(doc.Servers[0])
+ }
+ if baseURL == "" && basePath == "" {
+ // No top-level servers — walk per-operation `servers:` blocks. Specs
+ // generated by some tools (older Open-Meteo, certain Stripe-derived
+ // flows) declare a server per-endpoint instead of globally; without
+ // this fallback the parser was emitting `https://api.example.com`
+ // and producing a CLI that DNS-fails on every call.
+ if perOpURL, perOpPath := mostCommonOperationServer(doc); perOpURL != "" || perOpPath != "" {
+ baseURL = perOpURL
+ basePath = perOpPath
}
}
if baseURL == "" && basePath == "" {
@@ -1227,6 +1206,103 @@ func operationAllowsAnonymous(op *openapi3.Operation, doc *openapi3.T) bool {
return false
}
+// resolveServerURL applies template-variable substitution and protocol
+// normalization to an OpenAPI Server, returning either an absolute http(s)
+// base URL or a relative base path. Empty strings indicate the server entry
+// produced no usable URL (e.g., empty after trimming).
+func resolveServerURL(server *openapi3.Server) (baseURL, basePath string) {
+ if server == nil {
+ return "", ""
+ }
+ serverURL := strings.TrimRight(strings.TrimSpace(server.URL), "/")
+ // Resolve server URL template variables using defaults.
+ if strings.Contains(serverURL, "{") && server.Variables != nil {
+ for varName, variable := range server.Variables {
+ if variable != nil && variable.Default != "" {
+ serverURL = strings.ReplaceAll(serverURL, "{"+varName+"}", variable.Default)
+ }
+ }
+ }
+ // Strip any remaining unresolved template variables.
+ for strings.Contains(serverURL, "{") {
+ start := strings.Index(serverURL, "{")
+ end := strings.Index(serverURL, "}")
+ if start == -1 || end == -1 || end < start {
+ break
+ }
+ serverURL = serverURL[:start] + serverURL[end+1:]
+ }
+ serverURL = strings.ReplaceAll(serverURL, "//", "/")
+ // Restore protocol double-slash if normalization collapsed it.
+ serverURL = strings.Replace(serverURL, "http:/", "http://", 1)
+ serverURL = strings.Replace(serverURL, "https:/", "https://", 1)
+ serverURL = strings.TrimRight(serverURL, "/")
+ if serverURL == "" {
+ return "", ""
+ }
+ lowerURL := strings.ToLower(serverURL)
+ if strings.HasPrefix(lowerURL, "http://") || strings.HasPrefix(lowerURL, "https://") {
+ return serverURL, ""
+ }
+ // Relative URL — caller will need to surface as basePath.
+ return "", serverURL
+}
+
+// mostCommonOperationServer scans every operation (and each operation's
+// parent path-item) for `servers:` blocks, ranks resolved URLs by occurrence
+// count, and returns the most common one. Used as a fallback when the spec
+// has no top-level `servers:` block. Ties are broken deterministically by
+// lexicographic URL order so the output doesn't churn across runs.
+func mostCommonOperationServer(doc *openapi3.T) (baseURL, basePath string) {
+ if doc == nil || doc.Paths == nil {
+ return "", ""
+ }
+ urlCounts := map[string]int{}
+ pathCounts := map[string]int{}
+ tally := func(servers openapi3.Servers) {
+ for _, srv := range servers {
+ u, p := resolveServerURL(srv)
+ if u != "" {
+ urlCounts[u]++
+ } else if p != "" {
+ pathCounts[p]++
+ }
+ }
+ }
+ for _, pathItem := range doc.Paths.Map() {
+ if pathItem == nil {
+ continue
+ }
+ tally(pathItem.Servers)
+ for _, op := range pathItem.Operations() {
+ if op != nil && op.Servers != nil {
+ tally(*op.Servers)
+ }
+ }
+ }
+ // Prefer absolute URLs over relative paths when both exist — gives the
+ // generated CLI a working default rather than a config-required relative.
+ pickTopKey := func(m map[string]int) string {
+ var best string
+ var bestCount int
+ for k, v := range m {
+ if v > bestCount || (v == bestCount && k < best) {
+ best = k
+ bestCount = v
+ }
+ }
+ return best
+ }
+ if u := pickTopKey(urlCounts); u != "" {
+ return u, ""
+ }
+ if p := pickTopKey(pathCounts); p != "" {
+ warnf("no top-level servers; using most common per-operation relative path %q (generated CLI will need base_url in config)", p)
+ return "", p
+ }
+ return "", ""
+}
+
func allOperationsAllowAnonymous(doc *openapi3.T) bool {
if doc == nil || doc.Paths == nil {
return false
diff --git a/internal/openapi/parser_test.go b/internal/openapi/parser_test.go
index 7df204d9..a8263386 100644
--- a/internal/openapi/parser_test.go
+++ b/internal/openapi/parser_test.go
@@ -1912,3 +1912,105 @@ paths:
assert.True(t, names[want], "param %q must be preserved", want)
}
}
+
+// TestParsePerOperationServersFallback covers the case where a spec has no
+// top-level `servers:` block but each operation declares its own. The parser
+// must walk per-operation servers and pick the most common one as base URL.
+// Pre-fix this hit https://api.example.com and produced a CLI that DNS-failed
+// every call — see cli-printing-press#510 for the open-meteo report.
+func TestParsePerOperationServersFallback(t *testing.T) {
+ t.Parallel()
+
+ specYAML := `openapi: "3.0.3"
+info:
+ title: Per-Op Servers Test
+ version: "1.0"
+paths:
+ /forecast:
+ get:
+ operationId: forecast
+ servers:
+ - url: https://api.example.com
+ responses:
+ '200':
+ description: OK
+ /historical:
+ get:
+ operationId: historical
+ servers:
+ - url: https://archive.example.com
+ responses:
+ '200':
+ description: OK
+ /weather:
+ get:
+ operationId: weather
+ servers:
+ - url: https://api.example.com
+ responses:
+ '200':
+ description: OK
+`
+ parsed, err := Parse([]byte(specYAML))
+ require.NoError(t, err)
+ // api.example.com appears 2x, archive.example.com appears 1x — most-common wins.
+ assert.Equal(t, "https://api.example.com", parsed.BaseURL)
+}
+
+// TestParsePerOperationServersFallbackTieBreak verifies deterministic
+// tie-breaking: when two server URLs appear with equal frequency, the
+// lexicographically smaller one wins so the output doesn't churn run-to-run.
+func TestParsePerOperationServersFallbackTieBreak(t *testing.T) {
+ t.Parallel()
+
+ specYAML := `openapi: "3.0.3"
+info:
+ title: Tie Break Test
+ version: "1.0"
+paths:
+ /alpha:
+ get:
+ operationId: alpha
+ servers:
+ - url: https://b.example.com
+ responses:
+ '200': {description: OK}
+ /beta:
+ get:
+ operationId: beta
+ servers:
+ - url: https://a.example.com
+ responses:
+ '200': {description: OK}
+`
+ parsed, err := Parse([]byte(specYAML))
+ require.NoError(t, err)
+ // Both URLs appear once; lexicographically smallest wins.
+ assert.Equal(t, "https://a.example.com", parsed.BaseURL)
+}
+
+// TestParseTopLevelServersStillPreferred verifies the per-operation walk is
+// only used as a fallback. When top-level `servers:` is set, the parser must
+// continue to use it even if operations also declare their own.
+func TestParseTopLevelServersStillPreferred(t *testing.T) {
+ t.Parallel()
+
+ specYAML := `openapi: "3.0.3"
+info:
+ title: Top-Level Wins Test
+ version: "1.0"
+servers:
+ - url: https://global.example.com
+paths:
+ /thing:
+ get:
+ operationId: thing
+ servers:
+ - url: https://override.example.com
+ responses:
+ '200': {description: OK}
+`
+ parsed, err := Parse([]byte(specYAML))
+ require.NoError(t, err)
+ assert.Equal(t, "https://global.example.com", parsed.BaseURL)
+}
← 53203a0c chore(main): release 3.6.0 (#509)
·
back to Cli Printing Press
·
chore(main): release 3.6.1 (#512) 83df8ae7 →