[object Object]

← back to Cli Printing Press

fix(openapi): preserve params on single-endpoint specs (#465)

45a7722b182d8b046317b2ba5a0bf1d2bd5d3ab4 · 2026-05-01 13:40:52 -0700 · Trevin Chow

* fix(generator): wrapWithProvenance handles non-JSON response bodies

For endpoints that return XML, RSS, plain text, or any other non-JSON
content type (e.g., pypi's /rss/updates.xml feed), the previous template
generated a wrapWithProvenance that called json.Marshal on a
json.RawMessage holding the raw bytes. json.Marshal validates embedded
RawMessages, so it errored with "invalid character '<'" the moment the
body started with an XML declaration — causing every --agent invocation
of an XML-returning endpoint to crash before reaching the consumer.

Surfaced by dogfooding pypi (#166): rss recent-updates --agent failed
with that exact error against pypi.org's RSS feed despite the spec
correctly declaring application/xml as the response content type.

Fix: when data isn't valid JSON, embed it as a JSON string so the
envelope marshals cleanly and the raw payload still reaches the
consumer. Valid JSON keeps its previous shape (embedded as the parsed
structure, not stringified).

Also fixes a pre-existing test (TestPRTitleWorkflowAllowsReleasePleaseScope)
left stale by #463 — the test asserted a scopes allow-list that #463
intentionally removed. Updated to accept either shape.

Verified end-to-end:
- regenerated pypi from source spec
- built binary
- rss recent-updates --agent now returns the full RSS XML wrapped as a
  JSON string under the {meta, results} envelope

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

* fix(openapi): require >=3 endpoints before applying global-param filter

filterGlobalParams strips query parameters that appear on >80% of
endpoints, on the assumption they're cross-cutting concerns better
handled at the client level (e.g., a global ?api_key). For
multi-endpoint specs that's correct.

For single-endpoint specs (and 2-endpoint specs) every parameter is
trivially on 100% of endpoints by definition. The filter then strips
the entire parameter set. open-meteo's spec has exactly one path
(/v1/forecast) with ~70 query parameters; all 70 got dropped, leaving
the generated forecast command as a no-arg shell that hardcoded an
empty params map.

Surfaced by dogfood verification on the open-meteo sweep
(printing-press-library #171). Pre-existing — not introduced by the
sweep — but a real generator bug.

Fix: require minEndpointsForGlobalFilter (3) before applying the
filter. Below that there's no meaningful pattern to compare and
"appears on 100% of 1 endpoint" doesn't generalize to "global
across the API."

Verified end-to-end:
- regenerated open-meteo from spec
- forecast command now exposes --latitude, --longitude, --hourly,
  --daily, --temperature-unit, --timezone, etc. as flags
- forecast --latitude 47.6 --longitude -122.3 --hourly temperature_2m
  returns real Seattle weather data from api.open-meteo.com

Test added: TestFilterGlobalParamsRequiresMinEndpoints pins a 1-endpoint
spec with 3 query params and asserts all three survive.

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 45a7722b182d8b046317b2ba5a0bf1d2bd5d3ab4
Author: Trevin Chow <trevin@trevinchow.com>
Date:   Fri May 1 13:40:52 2026 -0700

    fix(openapi): preserve params on single-endpoint specs (#465)
    
    * fix(generator): wrapWithProvenance handles non-JSON response bodies
    
    For endpoints that return XML, RSS, plain text, or any other non-JSON
    content type (e.g., pypi's /rss/updates.xml feed), the previous template
    generated a wrapWithProvenance that called json.Marshal on a
    json.RawMessage holding the raw bytes. json.Marshal validates embedded
    RawMessages, so it errored with "invalid character '<'" the moment the
    body started with an XML declaration — causing every --agent invocation
    of an XML-returning endpoint to crash before reaching the consumer.
    
    Surfaced by dogfooding pypi (#166): rss recent-updates --agent failed
    with that exact error against pypi.org's RSS feed despite the spec
    correctly declaring application/xml as the response content type.
    
    Fix: when data isn't valid JSON, embed it as a JSON string so the
    envelope marshals cleanly and the raw payload still reaches the
    consumer. Valid JSON keeps its previous shape (embedded as the parsed
    structure, not stringified).
    
    Also fixes a pre-existing test (TestPRTitleWorkflowAllowsReleasePleaseScope)
    left stale by #463 — the test asserted a scopes allow-list that #463
    intentionally removed. Updated to accept either shape.
    
    Verified end-to-end:
    - regenerated pypi from source spec
    - built binary
    - rss recent-updates --agent now returns the full RSS XML wrapped as a
      JSON string under the {meta, results} envelope
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
    
    * fix(openapi): require >=3 endpoints before applying global-param filter
    
    filterGlobalParams strips query parameters that appear on >80% of
    endpoints, on the assumption they're cross-cutting concerns better
    handled at the client level (e.g., a global ?api_key). For
    multi-endpoint specs that's correct.
    
    For single-endpoint specs (and 2-endpoint specs) every parameter is
    trivially on 100% of endpoints by definition. The filter then strips
    the entire parameter set. open-meteo's spec has exactly one path
    (/v1/forecast) with ~70 query parameters; all 70 got dropped, leaving
    the generated forecast command as a no-arg shell that hardcoded an
    empty params map.
    
    Surfaced by dogfood verification on the open-meteo sweep
    (printing-press-library #171). Pre-existing — not introduced by the
    sweep — but a real generator bug.
    
    Fix: require minEndpointsForGlobalFilter (3) before applying the
    filter. Below that there's no meaningful pattern to compare and
    "appears on 100% of 1 endpoint" doesn't generalize to "global
    across the API."
    
    Verified end-to-end:
    - regenerated open-meteo from spec
    - forecast command now exposes --latitude, --longitude, --hourly,
      --daily, --temperature-unit, --timezone, etc. as flags
    - forecast --latitude 47.6 --longitude -122.3 --hourly temperature_2m
      returns real Seattle weather data from api.open-meteo.com
    
    Test added: TestFilterGlobalParamsRequiresMinEndpoints pins a 1-endpoint
    spec with 3 query params and asserts all three survive.
    
    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/openapi/parser.go      |  9 ++++++-
 internal/openapi/parser_test.go | 55 +++++++++++++++++++++++++++++++++++++++++
 2 files changed, 63 insertions(+), 1 deletion(-)

diff --git a/internal/openapi/parser.go b/internal/openapi/parser.go
index 95dedc11..853d0426 100644
--- a/internal/openapi/parser.go
+++ b/internal/openapi/parser.go
@@ -1360,7 +1360,14 @@ func filterGlobalParams(resources map[string]spec.Resource) {
 		}
 	})
 
-	if totalEndpoints == 0 {
+	// "Global" only makes sense when there are several endpoints to be
+	// global across. With 1 or 2 endpoints, every param is trivially on
+	// 100% of them, which would strip the entire flag set from
+	// single-endpoint specs (e.g., open-meteo: 70+ query params all
+	// dropped, leaving `forecast` as a no-arg command). Require at least
+	// 3 endpoints before applying the filter.
+	const minEndpointsForGlobalFilter = 3
+	if totalEndpoints < minEndpointsForGlobalFilter {
 		return
 	}
 
diff --git a/internal/openapi/parser_test.go b/internal/openapi/parser_test.go
index 2af3f88e..f37e0f06 100644
--- a/internal/openapi/parser_test.go
+++ b/internal/openapi/parser_test.go
@@ -1734,3 +1734,58 @@ func TestParseFrameworkCollisionSelfCollisionBumpsSuffix(t *testing.T) {
 	})
 	assert.Contains(t, output, `"testapi-version-2"`)
 }
+
+// TestFilterGlobalParamsRequiresMinEndpoints pins the open-meteo regression:
+// a single-endpoint spec with many query parameters used to have all its
+// params stripped because every param trivially appeared on 100% of
+// endpoints (1/1) and the >80% global-filter threshold matched. The filter
+// now requires at least 3 endpoints before it considers any pattern
+// "global" — fewer endpoints means there's nothing meaningful to compare.
+func TestFilterGlobalParamsRequiresMinEndpoints(t *testing.T) {
+	t.Parallel()
+
+	specYAML := `openapi: 3.0.0
+info:
+  title: TestAPI
+  version: "1.0"
+paths:
+  /v1/forecast:
+    get:
+      operationId: list
+      tags: [forecast]
+      parameters:
+        - name: latitude
+          in: query
+          required: true
+          schema: {type: number}
+        - name: longitude
+          in: query
+          required: true
+          schema: {type: number}
+        - name: hourly
+          in: query
+          schema: {type: string}
+      responses:
+        "200":
+          description: ok
+          content:
+            application/json:
+              schema: {type: object}
+`
+	spec, err := Parse([]byte(specYAML))
+	require.NoError(t, err)
+
+	resource, ok := spec.Resources["forecast"]
+	require.True(t, ok, "forecast resource should exist")
+	endpoint, ok := resource.Endpoints["list"]
+	require.True(t, ok, "list endpoint should exist")
+	assert.Len(t, endpoint.Params, 3, "single-endpoint spec must keep its params; the global-filter must not strip them")
+
+	names := map[string]bool{}
+	for _, p := range endpoint.Params {
+		names[p.Name] = true
+	}
+	for _, want := range []string{"latitude", "longitude", "hourly"} {
+		assert.True(t, names[want], "param %q must be preserved", want)
+	}
+}

← eeb12ea0 fix(generator): wrapWithProvenance handles non-JSON response  ·  back to Cli Printing Press  ·  fix(generator): gate table creation on hasDomainUpsert (#465 05092ed1 →