Skip to content

fix: make the generated OpenAPI client work, inherit path-level parameters, repair the website - #437

Merged
jonaslagoni merged 5 commits into
mainfrom
fix/openapi-http-client-responses
Jul 31, 2026
Merged

fix: make the generated OpenAPI client work, inherit path-level parameters, repair the website#437
jonaslagoni merged 5 commits into
mainfrom
fix/openapi-http-client-responses

Conversation

@jonaslagoni

@jonaslagoni jonaslagoni commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Related Issue: none (sourced from .claude/thoughts/shared/improvements/2026-07-30-1558-improvements.md, ideas 1–3)

What

Three problems, each reproduced before and after, and in each case the guardrail that should have caught it was also missing:

  1. The generated OpenAPI HTTP client failed on ordinary documents — three separate ways: a schema named Error broke compilation, an array-typed success response threw on the success path, and responses with no body either vanished or crashed.
  2. Path-item level parameters were silently dropped, so requests went out with a literal {petId} in the URL and the generated API offered no way to supply the value.
  3. The website shipped placeholder metadata, a dead MCP endpoint and ~25 broken links, none of which any CI check could see.

Why

A single realistic document hit all three of (1) at once — that is how they were found. A list endpoint, a create, a delete, and a component schema called Error:

  • Error is the conventional name for an error schema. Its generated model is imported into http_client.ts, which shadows the global Error for the whole module — and the same file declares class HttpError extends Error plus several new Error(...) calls. Six TS2345/TS2339 errors, none of them in code the user wrote. Renaming the schema to ApiError and changing nothing else made the identical document compile: the schema name was the only variable.
  • extractStatusCodeValue read the x-modelina-status-codes decoration only from a ConstrainedReferenceModel wrapping an object, so an array member was filtered out and the emitted dispatch had no branch for its code. A plain 200 from a list endpoint raised No matching type found for status code: 200, while theCodeGenSchema two lines below it in the same file carried $id: listBooks_Response_200 — the generator knew about the response and only the dispatch dropped it. The trigger is narrower than it looks: an array-only 200 produces no union and works, so it needs a non-object success response plus at least one other documented status code — i.e. any list endpoint that documents a 400.
  • No-content responses had no representation at all. payloads.ts gates its response entry on responseSchemas.length > 0 and a response without content legitimately yields no schema, so channels/openapi.ts hit if (!replyMessageType) return undefined and dropped the operation. A DELETE204 API therefore generated no client file, and the diagnostic told the user to set protocols — which they already had. Mixed with an error code it was worse: the function was typed HttpClientResponse<DeleteBookResponse_404>, the error model as the success type, and a real 204 threw SyntaxError: Unexpected end of JSON input, because .json() on an empty body throws. There was no no-content handling anywhere to fall back on.

(2) is the same class of silence in the input layer. parameters.ts and headers.ts both carried the comment // Collect parameters from operation and path-level directly above const allParameters = operationObj.parameters ?? []pathItem.parameters was never read. Declaring a path parameter once on the path item is the idiomatic way to avoid repeating it per method, and it produced an empty GetPetContext extends HttpClientContext {} with url = \${config.baseUrl}/pets/{petId}`. Verified against a stub transport: the request went to https://api.pets.example/pets/{petId}`. It compiled, nothing warned, and there was no workaround — the parameter could not be passed at any price. All of this sits under docs/inputs/openapi.md, which promises unsupported constructs are "reported with a warning rather than dropped silently".

None of this was catchable. test/blackbox/test_files.ts built its corpus from ./schemas/asyncapi and ./schemas/jsonschema only. There was no schemas/openapi/ and every config declared asyncapi or jsonschema, so the harness's inputType === 'openapi' arm was dead code — the OpenAPI HTTP client, the largest generator surface, was compile-checked at breadth nowhere. scripts/generateExamples.js even justifies not compiling examples on the grounds that "compilation of generated output is covered by the blackbox tier". No fixture anywhere declared a schema named Error or a "204" response, and the one fixture with array-typed 200s (examples/openapi-filtering/openapi.json) documents no other status code, landing it on the safe side of the boundary.

Same story for (3): website-pr-testing.yml was path-filtered to website/**, but the published docs live at the repo root and are copied in by move_docs.js at build time — so a docs-only PR never built the site. And onBrokenLinks/onBrokenMarkdownLinks were 'warn', so even a triggered build shipped 404s with console noise. Meanwhile the landing page read Hello from The Codegen Project with the meta description Description will go into a meta tag in <head /> — in production, on every social preview and search snippet — and /api/mcp, which docs/ai-assistants.md tells every visitor to add to Claude Code, Cursor or Windsurf four separate times, returned DEPLOYMENT_NOT_FOUND because vercel.json rewrote it to a preview deployment that no longer existed.

Changes

The generated HTTP client compiles and succeeds (fix(openapi), commit 2)

  • The emitted file captures the global once — const HttpGlobalError = globalThis.Error; type HttpGlobalError = InstanceType<typeof globalThis.Error>; — and all 19 references across common-types.ts, client.ts and security.ts go through it, so an imported payload named Error is harmless.
  • extractStatusCodeValue reads the decoration from the member itself when it is not a reference, so arrays, primitives and enums get a branch. Those have no unmarshal of their own, so they parse structurally (JSON.parse(json) as Book[]), mirroring the union's generic unmarshal.
  • New getNoContentStatusCodes collects bodyless success codes per operation; channels/openapi.ts now skips an operation only when it declares no responses at all. RenderHttpParameters.replyMessageType became optional and gained noContentStatusCodes, so data is typed undefined for a wholly bodyless operation and widened to T | undefined for a mixed one. New emitted helper readOptionalJsonBody returns undefined for 204/205/304 and for an unparseable body; rawData falls back to {} so the public HttpClientResponse shape is unchanged.
  • Swagger 2.0 host + basePath + schemes now produce the base URL (https preferred), instead of falling through to http://localhost:3000 for every 2.0 document.
  • generateResponseParsing and resolveReplyType were extracted rather than nesting ternaries, which is what lint asked for and reads better anyway.

Path-item parameters are inherited (fix(openapi), commit 1)

  • New collectOperationParameters merges path-item and operation parameters with the specification's override rule — an operation wins over an inherited parameter of the same name + in — used by parameters, headers, and the Swagger 2.0 body parameter.
  • All three processors now skip non-method path item keys. They iterated Object.entries(pathItem), so parameters, summary, servers and $ref were each processed as if they were an HTTP method — harmless until path-item parameters started being read, and wrong regardless. HTTP_METHODS moved into the shared utils so filter.ts and the processors agree on one list.
  • A non-JSON content type dropped because a JSON one was picked alongside it now warns. The existing warnings only fire when JSON is absent entirely, so application/json + multipart/form-data on one operation silently generated a JSON-only client.

The website is repaired, and the gate that would have caught it exists (fix(website), commit 3)

  • onBrokenLinks and onBrokenMarkdownLinks are 'throw'; docs/** and assets/** now trigger the website workflow. Anchors stay 'warn' deliberately — the ToCs in docs/usage.md and docs/migrations/v0.md are machine-generated, so a heading rename should surface rather than block.
  • Dead links fixed at the source where possible: protocols and inputs got real routes via _category_.json slug (relative to the docs routeBasePath — my first attempt at /docs/protocols landed the page at /docs/docs/protocols), which fixes the docs links and /docs/protocols from the blog in one change. examples/ is not published, so those links point at GitHub, which works from the site and the repo alike. Blog URLs are flat, so ../slug and ./slug both escaped /blog; absolute /blog/<slug> also fixes the tag and author listing pages that re-render those excerpts. /docs/protocols/httphttp_client, which is the page's real name.
  • The three broken anchors are fixed durably: # Dependencies was the only h1 among h2 siblings on its page, and the other two were generated ToCs linking their own page title, which gets no anchor — moving those titles into front matter leaves markdown-toc nothing to mislink, so regeneration keeps them correct.
  • Landing page title and description are real. /api/mcp points at a live deployment. Feature-card SVGs are labelled from their card titles — the stock illustrations carry unrelated internal titles, so a screen reader announced "Powered by React" for "Powered by Open Source".

The missing safety net (commit 2)

  • New test/blackbox/schemas/openapi/: awkward-responses.json carries all of the above shapes at once (array 200 + 400, a 204+404 delete, a 202/204-only operation, a primitive 200, a schema named Error, path-item parameters, bearer auth) and swagger-2-legacy.json covers 2.0 with host/basePath/schemes and path-item parameters.
  • Two configs (openapi-http, openapi-sdk) and an openapi CI bucket. Named to fall in exactly one bucket — openapi-http-client would also have matched the client bucket's substring and run twice. Verified every config still lands in exactly one bucket.

Testing

  • Build — npm run build
  • Lint + test typecheck — npm run lint (exit 0)
  • Typecheck — npm run typecheck (exit 0)
  • Unit tests — npm test: 772 passing, 1 skipped, 80 snapshots. Two snapshots updated for the intentional generated-code changes (rawData: rawData ?? {} and instanceof HttpGlobalError); one existing assertion updated from extends Error to extends HttpGlobalError.
  • New unit coverage — openapi-http-client-responses.spec.ts (5 tests, driven through the in-memory pipeline so it asserts against the file contents a user actually receives: no bare Error reference, a statusCode === 200 branch that parses structurally, a 204-only function typed HttpClientResponse<undefined> with readOptionalJsonBody, path-item parameter substitution, Swagger 2.0 base URL) and 3 added to test/codegen/inputs/openapi/parameters.spec.ts (inheritance, override by name+in, non-method keys ignored).
  • Blackbox — full matrix, 218 pairs (up from 214; the 4 new OpenAPI pairs pass across both new configs and both new documents)
  • Runtime tier against live Docker brokers — 566 tests green: payload-types (271), http (114), regular (96), nats channels (22), nats client (11), organization (14), mqtt (8), filter (8), amqp (7), eventsource (6), kafka (5)
  • Runtime project typechecks after regeneration — tsc --noEmit in test/runtime/typescript (exit 0), covering the 5 regenerated http_client.ts files alongside the hand-maintained expected-output surfaces
  • Website builds with zero broken links and zero broken anchors under onBrokenLinks: 'throw'
  • /api/mcp target verified live — MCP initialize over POST returns 200; GET returns 405, correct for a POST-only endpoint
  • CI green on the merged branch — 25/25 checks pass, including the new BlackBox testing - openapi bucket, all 10 runtime jobs, PR testing on ubuntu/macos/windows, the examples smoke test, and both Vercel deployments
  • Preview deployment spot-checked — /docs/protocols and /docs/inputs return 200 (both were 404 on production), as do /docs/getting-started and /docs/protocols/http_client
  • Original repros re-run end to end: the Error-schema document goes from 6 tsc errors to exit 0; listBooks 200 THREW: No matching type found for status code: 200listBooks 200 OK; DELETE 204 THREW: SyntaxErrorDELETE 204 OK, data = undefined; https://api.pets.example/pets/{petId}.../pets/p1
  • npm run prepare:pr as a single command — not run; its npm ci steps fail on pre-existing runtime lockfile drift. Every stage was run directly instead (build, generate:assets, lint:fix, test:update, runtime:typescript:generate). See Notes.
  • website/npm run typecheckfails, pre-existing and unrelated. See Notes.

Notes

No breaking changes to the public config surface. The generated HTTP client's shape changes in three visible ways, all of which fix outright failures: HttpError extends an aliased global rather than the bare Error (identical at runtime, instanceof Error still holds); the HttpHooks/RetryConfig callback signatures name that alias instead of Error (structurally identical); and operations with bodyless responses now have data: undefined in their type where previously they either did not exist or were mistyped as the error model. A consumer who had worked around a missing 204 function will find it present.

deleteBook still types its error model in the success union (DeleteBookResponse_404 | undefined). That is pre-existing and consistent: error responses are part of the response union for every operation, so listBooks likewise returns Book[] | ListBooksResponse_400 even though a 400 throws before reaching data. Excluding error codes from the success union is a larger design change to the shared payload union and was deliberately left out of scope.

website/npm run typecheck crashes with RangeError: Maximum call stack size exceeded inside tsc. Confirmed pre-existing by stashing the two website edits in this PR and reproducing. I had added it as a CI step and removed it again rather than turn CI red for an unrelated defect — worth a separate fix, since the script currently runs nowhere.

generate:assets remains non-idempotent on this checkout, independently of these changes: markdown-toc appends a stray blank line to several docs files, and docs/usage.md embeds the generating machine's platform string (generate:commands wrote darwin-arm64 node-v24.15.0 over the committed linux-x64 node-v22.23.1). Both were reverted rather than folded in, so docs/README.md carries no diff and docs/usage.md shows only the intended front-matter and ToC change. Still worth a separate fix.

vercel.json cannot carry comments — I broke the deployment once proving it. My first attempt documented the rewrite with a _comment key, and Vercel validates that file against a schema with additionalProperties: false, so the website deployment failed outright while the MCP one built fine. The key is gone and the note moved to mcp-server/README.md, together with the reason it cannot live in vercel.json. Both Vercel deployments pass now.

The MCP rewrite is still a deployment-specific URL, so it will stop tracking the MCP server after that project's next deploy and need updating by hand; pointing it at the project's production alias would make it self-maintaining. Verified as far as is possible pre-merge: the destination answers the MCP initialize handshake with 200 when called directly. Through the preview it answers 401 "Protected deployment" — that is Vercel's preview SSO, not the rewrite.

The canonical-host mismatch was left alone by decisiondocusaurus.config.ts declares the apex domain while production serves www, so every rel=canonical, og:url and sitemap entry 307-redirects (confirmed: https://the-codegen-project.org/api/mcp returns 307 today). The config is right; the redirect direction is the thing to fix.

Merged main in at v0.81.0 (#436). One conflict, in docs/migrations/v0.md, and only over trailing blank lines — generate:readme:toc appends one per run, so both sides had accumulated a different number; resolved to main's count so this branch adds none. The release also refreshed website/src/schemas/configuration-schema.json for the filter feature, which is the same refresh this branch had made, so that file now matches main exactly and carries no diff.

Conflicts with #422. That PR (feat(openapi): interface output + fetch-client profiles, open since Jul 16) touches eight of the same files, including channels/openapi.ts, protocols/http/client.ts, channels/types.ts and inputs/openapi/generators/parameters.ts. Whichever lands second will need a real merge, not a mechanical one — both change the RenderHttpParameters shape.

Follow-ups not taken here, all verified and recorded with evidence in .claude/thoughts/shared/improvements/2026-07-30-1558-improvements.md:

  • .claude/ is gitignored (.gitignore:17) — git ls-files .claude returns 0 against 51 local files, so every skill, agent, rule and thoughts document is absent from a fresh clone and unfixable through a normal PR. That is the structural root cause of the instruction drift the 12:22 run documented.
  • The NATS client silently swallows "not connected"corePublish.ts:52-58 emits a bare Promise.reject(...) that is never returned from an async method, so await client.publishToX(...) resolves as success while nothing was sent; coreSubscribe.ts:90-105 does the same inside an executor that then never settles, so the await hangs forever.
  • Query parameter values are double-encodedinputs/openapi/generators/parameters.ts:666-673 wraps each value in encodeURIComponent then hands it to URLSearchParams.append, which encodes again: tags: ['sci fi', 'a&b'] goes out as tags=sci%2520fi%2Ca%2526b.
  • Response headers, per-operation security, and multi-content-type request bodies are still not modelled (only warned about, for the last one).
  • The runtime filter suite is omitted from the CI matrix despite existing as test:filter — it passed locally here (8 tests) as part of the aggregate.
  • Docs drift verified by running the documented snippets: the models output samples in docs/inputs/jsonschema.md and docs/generators/models.md advertise marshal()/unmarshal() the preset never emits, models.md:287 crashes with TS_COMMON_PRESET is not defined, isolatedModules is documented but in no Zod schema, and headers.md documents a validation API that OpenAPI input cannot produce.

jonaslagoni and others added 3 commits July 31, 2026 09:18
A path item's shared `parameters` were never merged into the operations
under it, so declaring a path parameter once - the idiomatic way to avoid
repeating it per method - produced an operation with no parameters at
all. The generated context interface came out empty, giving the caller no
way to supply the value, and the URL kept the raw template: requests went
out to `/pets/{petId}` with the literal placeholder in the path. It
compiled cleanly and nothing warned, despite docs/inputs/openapi.md
promising that unsupported constructs are "reported with a warning rather
than dropped silently".

Add `collectOperationParameters`, which merges path-item and operation
parameters with the specification's override rule (an operation wins over
an inherited parameter of the same `name` + `in`), and use it for
parameters, headers and the Swagger 2.0 body parameter.

While here, stop treating non-method path item keys as operations. All
three processors iterated `Object.entries(pathItem)`, so `parameters`,
`summary`, `servers` and `$ref` were each processed as if they were an
HTTP method - harmless until path-item parameters started being read, and
wrong regardless. `HTTP_METHODS` moves to the shared utils so the filter
module and the processors agree on one list.

Also warn when a non-JSON content type is dropped because a JSON one was
picked alongside it. The existing warnings only fire when JSON is absent
entirely, so `application/json` + `multipart/form-data` on one operation
silently generated a JSON-only client.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…rdinary documents

Three independent failures, each triggered by a shape that is entirely
ordinary in an OpenAPI document.

A component schema named `Error` - the conventional name for one - broke
compilation. Its generated model is imported into `http_client.ts`, which
shadows the global `Error` for the whole module, and the same file
declares `class HttpError extends Error` plus several `new Error(...)`
calls. Six TypeScript errors, none of them in code the user wrote.
Renaming the schema and changing nothing else made the identical document
compile. The emitted file now captures the global once, as
`HttpGlobalError`, and routes every reference through it.

An array-typed success response threw on the success path.
`extractStatusCodeValue` read the status decoration only from a reference
to an object model, so an array member was filtered out and the emitted
dispatch had no branch for its code - a plain `200` from a list endpoint
raised "No matching type found for status code: 200", while the schema
constant in the same file proved the generator knew about it. The
decoration is now read from the member itself for arrays, primitives and
enums, which have no `unmarshal` of their own and so are parsed
structurally.

Responses with no body were mishandled twice. An operation whose declared
responses are all bodyless - the common `DELETE` -> `204` - was dropped
entirely, leaving no function and a diagnostic telling the user to set
`protocols`, which they already had. One that mixed `204` with an error
code was typed with the *error* model as its success type and threw
`SyntaxError: Unexpected end of JSON input`, because `.json()` on an empty
body throws. Bodyless success codes are now collected per operation, the
function is always generated, `data` is typed `undefined` (or widened to
include it), and the body is read through `readOptionalJsonBody`.

Also read the base URL from a Swagger 2.0 `host`/`basePath`/`schemes`.
Only `servers` was consulted, an OpenAPI 3.x field, so every 2.0 document
silently fell through to `http://localhost:3000`.

No tier could see any of this: test/blackbox/test_files.ts built its
corpus from `schemas/asyncapi` and `schemas/jsonschema` only, so the
harness's `inputType === 'openapi'` arm was dead code and the OpenAPI
client was compile-checked at breadth nowhere. Add an OpenAPI corpus
carrying all of these shapes, two configs, and an `openapi` CI bucket.

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

The landing page shipped unedited Docusaurus scaffolding: the tab read
"Hello from The Codegen Project" and every social preview and search
snippet read "Description will go into a meta tag in <head />".

Twenty-odd links were dead, including three of the four "Explore Further"
links closing the quickstart that the homepage's only call to action
points at. Rather than rewrite each one, give the `protocols` and `inputs`
categories real routes via `slug` (relative to the docs `routeBasePath`),
which fixes them at the source and makes `/docs/protocols` resolve for the
blog too. `examples/` is not published to the site - move_docs.js copies
only `docs/` and `assets/` - so those links now point at GitHub, which
works from the site and the repo alike. Blog URLs are flat, so `../slug`
and `./slug` both escaped `/blog`; they are absolute now, which also fixes
them on the tag and author listing pages that re-render the excerpts.

Three broken anchors: `#dependencies` targeted the page's only h1 among h2
siblings, and the ToCs of docs/usage.md and docs/migrations/v0.md linked
their own page title, which gets no anchor. Moving those titles into front
matter leaves markdown-toc nothing to mislink, so regeneration keeps them
correct.

`/api/mcp` - which docs/ai-assistants.md tells every visitor to add to
Claude Code, Cursor or Windsurf - returned `DEPLOYMENT_NOT_FOUND`, because
vercel.json rewrote it to a preview deployment that no longer exists. It
now points at a live deployment (verified: the MCP initialize handshake
returns 200).

None of this could be caught. The website workflow was path-filtered to
`website/**`, but the published docs live at the repo root and are copied
in at build time, so a docs-only change never built the site - and broken
links were configured as warnings, letting them accumulate. Broken links
and markdown links now fail the build, and `docs/**`/`assets/**` trigger
the workflow. Anchors stay a warning, since the two generated ToCs surface
here before anyone has had a chance to regenerate them.

Also refresh the playground's copied configuration schema, which predated
the root-config `filter` feature, and label the feature-card SVGs from
their card titles - the stock illustrations carry unrelated internal
titles, so a screen reader announced "Powered by React" for "Powered by
Open Source".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@jonaslagoni
jonaslagoni requested a review from ALagoni97 as a code owner July 31, 2026 07:19
@vercel

vercel Bot commented Jul 31, 2026

Copy link
Copy Markdown

Deployment failed with the following error:

The `vercel.json` schema validation failed with the following message: should NOT have additional property `_comment`

Learn More: https://vercel.com/docs/concepts/projects/project-configuration

@vercel

vercel Bot commented Jul 31, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
the-codegen-project Ready Ready Preview Jul 31, 2026 7:53am
the-codegen-project-mcp Ready Ready Preview Jul 31, 2026 7:53am

jonaslagoni and others added 2 commits July 31, 2026 09:36
Vercel validates vercel.json against a schema with
`additionalProperties: false`, so the `_comment` key I added alongside the
/api/mcp rewrite failed the deployment outright ("Deployment failed" on
the-codegen-project, while the-codegen-project-mcp built fine).

The note it carried - that the rewrite targets a deployment-specific URL
and so needs updating by hand after each MCP deploy - moves to
mcp-server/README.md, which can hold prose, along with the reason it
cannot live in vercel.json.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Brings in the v0.81.0 release (#436). One conflict, in
docs/migrations/v0.md, and only over trailing blank lines - repeated
`generate:readme:toc` runs append one each time, so both sides had
accumulated a different number. Resolved to main's count so this branch
adds none.

The release also refreshed website/src/schemas/configuration-schema.json
for the root-config `filter` feature, which is the same refresh this
branch had made, so that change is now redundant and the file matches main
exactly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@jonaslagoni
jonaslagoni merged commit 9f877e9 into main Jul 31, 2026
26 checks passed
@jonaslagoni
jonaslagoni deleted the fix/openapi-http-client-responses branch July 31, 2026 09:49
@jonaslagoni

Copy link
Copy Markdown
Contributor Author

🎉 This PR is included in version 0.81.1 🎉

The release is available on:

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant