10. The Frontend API Client Is Generated by Hey API

Status

Accepted

Date

2026-09-21

Context

One OpenAPI document, backend/src/Api/wwwroot/openapi-v1.json - itself produced by ASP.NET’s GenerateOpenApiDocuments, not by NSwag - fed two generated clients, both produced by NSwag from the same MSBuild target on every backend build: a C# one for the integration tests, and a TypeScript one for the SPA.

The TypeScript half had three properties worth naming:

  • It was one class of 7,505 lines with a method per operation, each building its own request. Every call site reached it through a single instance, and a bundler could not drop the operations a route never called.

  • Its methods took positional parameters. getVolunteerOpportunities took thirteen, so a call for page one of ten read getVolunteerOpportunities(1, 10, undefined, undefined, undefined, undefined, …​) - which is why lib/volunteerOpportunities.ts exists as a named-options wrapper over exactly that call.

  • Its transport fell back to window when no http object was injected. This codebase always injects one, so it never mattered in practice, but it is the kind of thing that decides whether a client can run somewhere without a DOM.

The C# half has none of these problems and no alternative worth the switch.

Decision

Generate the frontend’s client with @hey-api/openapi-ts (frontend/openapi-ts.config.ts) into frontend/src/client/generated/. NSwag keeps generating the OpenAPI document itself and the integration tests' C# client, unchanged.

Three choices inside that are load-bearing:

  • @hey-api/transformers with dates: true. The previous generator parsed every date-time into a Date, and the entire app is written against that - lib/format.ts, the calendar, every time-slot comparison. Keeping the transformer makes this a client swap rather than a date-handling rewrite.

  • throwOnError: true, and the body unwrapped at one seam. The generated SDK resolves to { data, request, response }; src/client/index.ts returns the body. Nothing in the app reads the Request or Response back - everything that inspects one already happens inside api-instance.ts’s `fetch. A failed call still rejects with the parsed ProblemDetails, which is the shape lib/apiError.ts and every catch in the app already read.

  • The @tanstack/react-query plugin is not enabled. Its generated queryOptions() call the SDK directly, bypassing hooks/useApiClient.ts - the seam every component test mocks, and the only place the current bearer token is installed. The hand-written keys in lib/queryKeys.ts cost a few lines and keep both (see ADR-9).

Rationale

  • Options objects instead of positional parameters: getVolunteerOpportunities({ query: { pageNumber: 1, pageSize: 10 } }) says what it means without counting `undefined`s.

  • A per-operation function instead of a class means the generated surface is 427 lines rather than 7,505, with the per-operation request building that took the space replaced by one shared client.

  • No window in the transport. The client needs a fetch and nothing else, which is what keeps a non-browser runtime a porting job rather than a rewrite (see ADR-12).

  • The ProblemDetails contract is preserved exactly, so the error handling this app has accumulated over many issues keeps working without being re-derived.

Considered Alternatives

Stay on NSwag for both clients

  • Pros

    • No migration: 151 call sites and 41 argument assertions stay as they are

    • One generator, one build step, already wired

  • Cons

    • Keeps positional parameters, and with them the wrapper functions written to hide them

    • Keeps a window reference in the transport for no reason

Orval

  • Pros

    • Generates ready-made React Query hooks, and optional MSW mocks

  • Cons

    • Generated hooks decide the call shape for every component. This codebase’s test harness mocks one seam (useApiClient), and per-endpoint generated hooks would replace that with one mock per endpoint

openapi-typescript plus a hand-written fetch wrapper

  • Pros

    • Smallest possible generated output: types only

  • Cons

    • The request building - path interpolation, query serialisation, multipart bodies - becomes hand-written code that has to stay correct against 101 operations

Consequences

  • Generation is two steps rather than one: scripts/normalize-openapi.js narrows the ["integer", "string"] unions ASP.NET emits for all 112 numeric properties before the generator reads the document. Those unions describe JsonNumberHandling.AllowReadingFromString - what the server will accept, not what it sends - and NSwag had quietly narrowed them for years. Generating them faithfully would have meant a cast at every numeric read, which is a claim about the wire format that nothing checks. The script fails if it finds nothing to narrow, so the day the backend stops emitting them is a deliberate decision rather than a discovery.

  • The frontend client is no longer regenerated by the backend build. It is generated by pnpm client:generate and committed, and pnpm check:client-current regenerates into a scratch directory and fails CI when the committed output differs. That check exists because this is the artefact that can now go stale.

  • .claude/hooks/protect-generated-clients.sh blocks edits to frontend/src/client/generated/* as it did for the previous client.

  • The generated folder is in eslint.config.js’s `ignores. The previous client was protected only by an /* eslint-disable */ header its generator happened to emit; an explicit ignore does not depend on a template staying the same. pnpm check still type-checks it.

  • src/client/index.ts references the whole SDK namespace to unwrap it, so a bundler cannot drop unused operations. That trade was taken deliberately: it keeps 347 test mocks and every call site free of .data, and the operations are one-line wrappers over a shared client, so the bytes at stake are small. .size-limit.cjs budgets the client chunk, so if that ever stops being true it shows up as a failing check rather than a slow drift.