Skip to content

fix(client): response types, path filtering and query hooks - #43

Merged
juicycleff merged 11 commits into
mainfrom
fix/remaining-criticals
Aug 3, 2026
Merged

fix(client): response types, path filtering and query hooks#43
juicycleff merged 11 commits into
mainfrom
fix/remaining-criticals

Conversation

@juicycleff

@juicycleff juicycleff commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Six fixes and two features in the client generator, found by generating a client from a real service and then trying to use it.

Also carries bed49bb0 fix(deps): clear the remaining npm advisories, which was already on this branch and predates the work below.

The bug that motivated the rest

spec_parser.go never read the response status code:

for statusCode, resp := range op.Responses {
    code := 0
    if statusCode != "default" {
    }          // empty body — the code is never parsed

code stayed 0, so every response — including 200 — was filed into DefaultError and Endpoint.Responses was always empty. The TypeScript generator behaved correctly on empty input: no 2xx found, so every method was typed Promise<void>.

Nothing failed. The generated client compiled, published, and discarded every response body. This affected every language the CLI generates, not just TypeScript.

Fixes

commit problem
640a5af3 response status codes never parsed — every method returned void
e46b3e5e package.json was invalid JSON when info.description spanned lines; npm install refused it
20de17b5 generated clients failed tsc against a current lib.dom (BodyInit narrowed in TS 5.7)
239ca523 string slice flags neither split on commas nor accumulated across repeats, despite comments claiming both
5a718db1 client config resolved from the project root, so a package's own .forge-client.yaml was ignored in a workspace; types imported as values broke verbatimModuleSyntax
2cca6a1e 30 errors under exactOptionalPropertyTypes — optional properties declared ?: T while methods assign possibly-undefined values into them

Three of these share a signature: a comment describing behaviour the code did not implement. None produced an error; all produced plausible output that was wrong.

Features

Path filtering (82cdebcc, 61ade033) — --include / --exclude, repeatable and comma-separated, also readable from .forge-client.yaml. Patterns accept a path prefix, a glob, or a trailing /**; prefixes match on segment boundaries, so /identity covers /identity/login but not /identity-provider.

Unreachable component schemas are pruned transitively, following $ref through properties, items, the polymorphic combinators, additionalProperties and discriminator mappings. That half matters as much: schemas generate a type each, so filtering endpoints alone leaves a types file that is mostly unreachable from the client's own surface.

A filter matching nothing is an error rather than an empty client.

TanStack Query hooks (41816538) — opt-in via --react-query. Each hook calls the method rest.go already produced rather than issuing its own request, so the API is described once. Cache keys carry every parameter; a key that omits one serves one request's cached answer to a different request, and where an API is versioned along two time axes that failure is silent and plausible. Reads become useQuery, writes useMutation; a mutation is not keyed. @tanstack/react-query is a peer dependency, declared only when hooks are generated.

78172870 extracts the method parameter ordering so the hooks derive their argument order from the same place the signature does — output is byte-identical.

Verification

  • All suites green, including the 40s TypeScript generator run; gofmt clean
  • 22 new test cases: status codes and wildcards, manifest escaping, path matching, schema pruning, recursion, discriminators, slice-flag parsing, query keys, the query/mutation split, determinism
  • End to end against a real 150-path spec: --include /api/v1 → 25 methods, 180 schemas → 41 types, Promise<void> 0, tsc 0 errors, npm run build ESM + CJS + DTS

docs: pnpm.overrides for postcss ^8.5.18, sharp ^0.35.0, esbuild ^0.28.1.
Overrides rather than 'pnpm update' because all three were transitive and
pinned below the fix by their parents' ranges - postcss in particular had a
vulnerable 8.4.31 alongside a patched 8.5.25.

shell: dompurify ^3.4.12 (17 alerts), vite 5.4.21 -> 6.4.3, esbuild -> 0.25.x,
react-router-dom 6.30.4 -> 7.18.2. vite and esbuild also needed overrides -
vitest's vite-node held vite 5 even after the direct bump.

Two majors here, so verified rather than assumed: 127 tests across 11 files
pass and 'pnpm build' succeeds on both. react-router 7 needed no source
changes - all 18 react-router-dom imports still resolve through its v7
re-export shim.

The dashboard's tracked dist/ is deliberately untouched.
The status code was never read. `code` was initialised to zero and the
branch meant to parse it was empty, so every response — including 200 —
fell through to DefaultError and Endpoint.Responses was always empty.

Nothing failed loudly. The TypeScript generator found no 2xx response,
correctly typed every method `Promise<void>`, and produced a client
that compiled cleanly while discarding every response body. A client
whose calls all resolve to void is worse than no client: it typechecks,
so nothing tells you the data is gone.

Exact codes are applied before class wildcards, because map iteration
order is random and a single pass would let a "2XX" overwrite an
explicit "200" on some runs and not others. A key that is neither a
code, a wildcard, nor "default" is now dropped rather than filed as
DefaultError — treating an unparseable status as the error shape is how
a typo silently becomes an endpoint's error type.
package.json is assembled from a format string so its keys keep a fixed
order, which means every interpolated value has to arrive escaped. None
of them did. A specification whose info.description spanned more than
one line — the normal case for an API that documents itself — wrote raw
newlines inside a JSON string, and npm refused to parse the manifest at
all. The generated client could not be installed, let alone built.

The description is also reduced to its first paragraph with line breaks
collapsed. npm renders this field as a single line, so the full API
description belongs in the README the generator already writes.
From TypeScript 5.7 the DOM lib parameterises ArrayBufferView, and
BodyInit accepts only ArrayBufferView<ArrayBuffer>. The generated cast
to a bare `ArrayBuffer | ArrayBufferView` therefore widened to include
SharedArrayBuffer and stopped being assignable, so every generated
client failed to typecheck against a current lib.dom.

BodyInit is what the field is declared as, and the branch has already
proven the value is a valid body through the bodyTag test and
ArrayBuffer.isView. Casting to the declared type is also version-
agnostic, where naming ArrayBufferView<ArrayBuffer> would break on
older TypeScript.
The comments claimed comma-separated values and repeatable occurrences.
The code delivered neither: the value was wrapped whole, so `--flag a,b`
was a single element named "a,b", and each occurrence replaced the last,
so `--flag a --flag b` kept only b.

Both forms now work and compose. Empty segments are dropped so that a
trailing comma does not become an argument that silently matches
nothing — which is the failure this whole class of bug produces, an
option that appears to have been applied and was not.
A specification is usually larger than the API any one consumer talks
to. A service that mounts an auth engine, an admin dashboard and its own
domain routes publishes all three from one document, and a client
generated over the whole thing buries the endpoints a caller wants under
the ones it must never touch.

Patterns accept a path prefix, a glob, or a trailing "/**". Prefixes
match on a segment boundary, so "/identity" covers "/identity/login" and
not "/identity-provider". Plain path.Match alone would not do: its "*"
never crosses a separator, so "/api/*" would miss "/api/v1/models",
which is the pattern everyone writes first.

Pruning unreachable component schemas is the half that makes this worth
having. Schemas generate a type each, so filtering endpoints alone
leaves a types file that is mostly unreachable from the client's own
surface — the endpoints look filtered while the types plainly are not.
Reachability follows $ref through properties, items, the polymorphic
combinators, additionalProperties and discriminator mappings, and stops
on revisit so a self-referential schema terminates.

A filter matching nothing is an error rather than an empty client. A
mistyped pattern otherwise yields a package that builds, publishes and
calls nothing.
Both are repeatable and accept comma-separated lists. Flags win over
.forge-client.yaml, which gains include and exclude keys so the split
lives with the package it generates rather than in a shell history.

The chosen patterns are echoed before generation. A client quietly
missing half its endpoints looks identical to one whose server never had
them, and the difference should not have to be discovered by calling
something that is not there.
…orts

Two things stopped a generated package from being usable inside a
workspace.

The client config was resolved from the Forge project root rather than
the working directory. LoadClientConfig already walks upward, so
starting at the working directory finds a config beside the package
being generated and one at the project root; starting at the root finds
only the root's, which in a workspace is the one place the file usually
is not. A package carrying its own .forge-client.yaml was silently
generated with defaults.

Types are now imported with `import type`. Under verbatimModuleSyntax —
on by default in a strict project, and not something a generated package
can ask its consumer to turn off — a type imported as a value is a
compile error, because the emitter is forbidden from guessing which
imports to elide. The generated client could not be typechecked inside
such a project at all.
Query and header parameters are emitted optional, so required ones are
grouped first to avoid an optional-before-required signature. That
ordering was expressed only inside the code that renders a signature,
which left anything generating a *call* to these methods to reimplement
it.

Two implementations would drift, and the drift is silent: passing a
limit where an id is expected still compiles when both are strings.
Output is byte-identical — the generator suite passes unchanged.
A layer, not a second client. Each hook calls the method rest.go already
produced, so the API is described once and a change to it lands in one
place. Deriving the surface twice is how a hook and the method it wraps
come to disagree about a parameter — which no compiler catches, because
both sides were generated from a spec that never changed.

Every parameter a method accepts is part of its cache key. A key that
omits one serves one request's cached answer to a different request, and
where an API is versioned along two time axes that failure is silent and
plausible: a key carrying only the valid time returns what is true now
to a caller that asked what was known then. There is a test for it.

Reads become useQuery and writes become useMutation. A mutation is not
keyed, because caching a write would serve a stale answer to a request
whose whole purpose was to change something.

The client is a parameter rather than a module singleton or a context
this file invents: a generated file should not decide how an application
provides its dependencies, and an explicit argument keeps the hooks
usable from a test with no provider tree. react-query is a peer
dependency so the hooks share the QueryClient the application already
made — a second copy in the tree is a second cache no invalidation
reaches — and it is only declared when hooks are actually generated.
Optional properties were declared `?: T`, and the generated methods
assign possibly-undefined values into them (`signal: options?.signal`).
Under exactOptionalPropertyTypes those are different types, so a client
generated for a strict project produced thirty errors the moment that
project compiled it — in a workspace where the package is consumed from
source, which is the normal monorepo arrangement, that is every build.

Optional properties are now `?: T | undefined`, and the fetch call
spreads its body rather than assigning `undefined` to a field
RequestInit declares optional. The alternative is every consumer turning
the check off, which is how a generated client quietly stops being
typechecked at all.
@vercel

vercel Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

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

Project Deployment Actions Updated (UTC)
forge Ready Ready Preview Aug 3, 2026 6:58pm

Request Review

@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Conventional Commits Validation

PR Title: valid
Commits: all 11 follow conventional format

@github-actions github-actions Bot added the fix label Aug 3, 2026
@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Conventional Commits Validation

PR Title: valid
Commits: all 11 follow conventional format

@github-actions github-actions Bot added fix and removed fix labels Aug 3, 2026
@juicycleff
juicycleff merged commit ed26d54 into main Aug 3, 2026
24 of 26 checks passed
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