#51 — Tech debt: generate ApiRequestMap from the backend's OpenAPI spec instead of hand-maintaining it #51
Labels
No labels
priority/could
priority/must
priority/should
priority/wont
status/blocked
status/claimed
status/done-migrated
type/bug
type/feature
type/infra
type/tech-debt
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
robert/todo#51
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Tech debt: generate
ApiRequestMapfrom the backend's OpenAPI spec instead of hand-maintaining itReported by: Software Architect / Frontend Engineer (found during
#49code review)Priority: Could — no user-facing symptom; narrows a guarantee
#49already shipped rather thanfixing a bug
Date: 2026-07-12
Problem
#49addedReactUi/src/api/requests.ts'sApiRequestMap, a hand-transcribed TypeScript map ofevery
requestNamestring to its request/response shape, sourced by reading the matching C#IRequest<TResult>record inCqsTodo/Features/**. This closes the gap#49set out to close (awrong field name at a
callApi(...)call site now failstsc -b), but only protects againstfrontend-authored typos — nothing ties
ApiRequestMapto the real backend contract on anongoing basis. If a backend engineer renames a field or changes a command's shape, the C# build
stays green and
tsc -bstays green too, becauserequests.tswasn't touched and stilltype-checks consistently against itself. The mismatch only surfaces when a real request
400s/404s at runtime — the same class of "manual-discovery, not compile-time" problem
#49'sdesign note says it exists to eliminate, just narrowed to one direction (frontend mistakes) that
happens to be a large fraction, but not all, of the risk this ticket's title names.
The backend already exposes a full OpenAPI spec via Swashbuckle
(
CqsTodo.WebApi/Program.cs:app.UseSwagger(),.AddSwaggerGen(...)) that fully describesevery
IRequest<TResult>record's shape.Proposal
Replace (or generate)
ApiRequestMapfrom the backend's live OpenAPI spec using a codegen tool(
openapi-typescriptor NSwag), run either as a build step or a checked-in generated filerefreshed via a script. This would give the same "wrong field name fails
tsc -b" guarantee withzero hand-maintenance and no drift risk in either direction (frontend typo or backend change).
Acceptance criteria (PO-refined; moved to
ready/)ApiRequestMapis a derived TypeScript type computed from the backend's OpenAPI spec(via
openapi-typescript), not a hand-transcribed interface — seeReactUi/src/api/requests.tspaths(thegenerated schema) changes on the next
npm run generate:api, and anycallApi(...)call siteor
dtos.ts/valueTypes.tsassignment that no longer matches failstsc -bnpm run generate:api(pure Node, no.NET needed) reads the backend's
openapi/swagger.json; CI regenerates it automaticallybetween the backend and frontend jobs (see
_design.md)(enum wire format, required-vs-optional, response nullability) to safely reflect the real
contract; these are spec/tooling corrections, not endpoint behavior changes. See
_design.mdfor the full list and the one intentional, documented strictness change (
assignedToMeOnlybecomes an explicitly-optional key instead of implicitly always-required by omission-with-
default, which was already its real behavior).
Blockers
None. Builds on
#49'sApiRequestMapshape/call-site pattern, which stays valid regardless ofwhether the map is hand-written or generated.
design (
51_generate_api_request_map_from_openapi_design.md)Design note —
#51GenerateApiRequestMapfrom OpenAPIAuthor: Software Architect
Status: Implemented this cycle
Summary
ApiRequestMap(ReactUi/src/api/requests.ts) is now a TypeScript mapped type derived fromReactUi/src/api/generated/openapi-schema.d.ts, whichopenapi-typescriptgenerates fromReactUi/openapi/swagger.json, which the backend emits via the Swashbuckle CLI(
dotnet swagger tofile). Neither generated file is committed — both are build artifacts,regenerated fresh every time (
.gitignore:/openapi/,/src/api/generated/).Key decisions
1. Spec extraction doesn't need a live database
dotnet tool run swagger tofileruns the WebApi'sMainvia Swashbuckle'sHostFactoryResolver(the same mechanismdotnet efuses), which intercepts atapp.Run()—not at
builder.Build(). Everything betweenBuild()andRun()inProgram.Mainstillexecutes for real, which includes
await Setup.MigrateAndSeedAsync(app.Services)— confirmedexperimentally (it ran real migrations against a live Postgres). Fix:
Program.csnow skipsthat call when
SWAGGER_GEN=1is set, so the whole extraction is DB-free and portable — it worksin the CI
backendjob (no Postgres/Redis service containers needed) and in any local devenvironment, DB or no DB.
2. Swashbuckle's default schema is wrong in three ways relative to actual runtime behavior
Naively wiring
openapi-typescriptstraight to Swashbuckle's out-of-the-box output would havebeen a regression, not a neutral tooling swap — three custom
ISchemaFilter/IOperationFilterimplementations in
CqsTodo.WebApi/fix this:StringEnumSchemaFilter—Program.csregisters a globalJsonStringEnumConverter, soenums serialize as strings on the wire, but Swashbuckle's default schema described them as
integers (
0 | 1 | 2). Uncorrected, generated types likeTodoPrioritywould have been0 | 1 | 2instead of'High' | 'Normal' | 'Low'— wrong, not just imprecise.RequiredPropertiesSchemaFilter— Swashbuckle's minimal-API schema generation doesn'tcompute
requiredfor record-bound bodies at all (every field looked optional). The fixdistinguishes two schema roles, because "required" means something different for each:
IRequest<TResult>types (top-levelCommand/Querybodies, client-constructed): aprimary-constructor parameter with a default value (e.g.
GetTodosOfCurrentUserQuery(bool AssignedToMeOnly = false)) is genuinely optional to send.every public property regardless of a constructor default or nullability, so these are
always required — nullability is captured separately, not via omitting the key.
Getting this rule wrong in the DTO direction was caught immediately by
requests.typecheck.ts'sassertMissingRequiredFieldIsRejectedtest and bytsc -bpickingup dozens of call sites that assumed always-present fields like
TodoDto.prioritywereoptional.
NullableReferenceSchemaFilter+NullableResponseOperationFilter— OpenAPI 3.0forbids sibling keywords next to a bare
$ref, so Swashbuckle silently drops nullability forany property or response whose schema is a
$ref— which is every Vogen value object(
TodoDueDate?,UserId?, ...) and every nested DTO reference (TodoAssigneeDto? Assignee),plus top-level nullable results (
GetActiveListInvitationQuery : IRequest<ListInvitationDto?>,GetCurrentUserIdQuery : IRequest<UserId?>). Both filters wrap the$refas{ allOf: [ref], type: "null" }, which Microsoft.OpenApi's OpenAPI 3.0 writer downgrades to thelegacy
{ allOf: [ref], nullable: true }form. The operation-level filter reads nullability offthe handler's concrete
Handlemethod viaNullabilityInfoContext(not the genericEndpointRouteBuilderExtensions.Handle<TRequest, TResult>dispatcher, whose substitutedTResultcarries no annotation of its own), plus a directNullable.GetUnderlyingTypecheckfor value-type results (
UserId?), sinceNullabilityInfoContextonly tracks reference-typeNRT annotations.
3.
ApiRequestMapis a mapped type overpaths, not per-endpoint hand transcriptionAdding a new CQS endpoint on the backend needs no
requests.tsedit at all — the nextnpm run generate:apipicks it up automatically.dtos.tsandvalueTypes.tsstay hand-written(the same names/shapes the rest of the app already imports), which is what actually delivers the
drift guarantee: every
callApi(...)result flows from the generated schema into ahand-maintained
TodoDto/UserDto/etc. at the call site, so any real divergence between them isa
tsc -bstructural-assignability error, not a silent pass.dtos.tsneeded two accuracy fixesin the same direction as filter
#2above (avatarImage,assigneewere marked optional with?where the wire format is actually "always-present key, nullable value" — dropped the?,kept the
| null).4. Bare-string results aren't
application/jsonGetPushVapidPublicKeyQueryreturnsVapidPublicKey(a Vogen struct wrappingstring) — ASP.NETCore's minimal APIs write bare-string results as
Content-Type: text/plain, confirmed against alive request in this cycle's dev environment.
api.tsx'scallApialready special-cases this(reads
.text()when the content type contains "text").ResultOf<P>inrequests.tsnow fallsback to the
text/plaincontent entry whenapplication/jsonisn't present, matching thatexisting runtime behavior instead of silently resolving to
neverfor this one endpoint.5. CI wiring
The
backendjob now also restores theswashbuckle.aspnetcore.clilocal tool(
dotnet-tools.json, already at repo root) and runsSWAGGER_GEN=1 dotnet tool run swagger tofileright afterdotnet build, uploadingopenapi/swagger.jsonas a build artifact. Thefrontendjob now depends onbackend,downloads that artifact, and runs
npm run generate:apibeforenpm run build/npm run coverage— so a backend contract change that isn't reflected on the frontend fails CI on thevery next push, which is the ticket's core acceptance criterion.
Trade-offs accepted
assignedToMeOnly(and similarlyunreadOnly,limit,skip,take) become genuinelyoptional keys (matching their C# default values) rather than the previous hand-map's ad hoc
per-field choices — this is the only behavior-adjacent change, and it's a loosening (callers
may now omit a key that used to be optional too) not a tightening, so no existing call site
broke.
IRequest<TResult>vs. DTO distinction holdingfor future types. If a future request type is reused as a nested field inside another request
(not currently the case anywhere in the codebase), the "client-constructed, defaults are
optional" rule would incorrectly apply to it in a response context. Worth a one-line comment
if that pattern ever appears.
npm run buildoutside CIrequires a prior
dotnet build+dotnet tool run swagger tofilestep (documented inpackage.json'sgenerate:apiscript and this design note) — there is no fallback for afrontend-only checkout with no .NET SDK available.
frontendjob now depends onbackend(to consume itsopenapi-specartifact),where it previously ran in parallel. This adds roughly
backend's duration to the criticalpath before
frontendeven starts, sincefrontendcan no longer overlap with it. Acceptedas the direct cost of the drift guarantee this ticket asks for — the alternative (skip the spec
in CI, only regenerate it as a manual/local step) would silently reintroduce the exact gap
#51exists to close.
qa (
51_generate_api_request_map_from_openapi_qa.md)QA notes —
#51GenerateApiRequestMapfrom OpenAPIVerified this cycle (real backend + Postgres/Redis were reachable in this dev environment,
unlike prior cycles — see team memory update):
dotnet build Cqs.sln -c Release— clean, 0 errorsdotnet test—Common.Tests119/119,CqsTodo.WebApi.Tests12/12 (incl.VogenSchemaFilterTestsandCanBuildApp, confirming the new schema/operation filters don'tbreak app construction).
CqsTodo.Tests's Testcontainers-backed suite still can't run in thissandbox (no
dockerbinary/socket) — pre-existing limitation, unrelated to this change; willrun for real on the next "go" cycle's CI build-check per the standing pattern noted in the
roadmap's
#29decision-log entry.dotnet tool run swagger tofile— confirmed twice: with the sandbox's real Postgres/Redisreachable, and with
ConnectionStrings__*env vars explicitly unset — spec generation succeedseither way once
SWAGGER_GEN=1skips migration/seeding.CreateUserCommand) toconfirm real wire behavior for two specific things the generated spec needed to get right:
avatarImageis present-but-nullin a realUserDtoresponse (not omitted), andGetPushVapidPublicKeyQueryreally does respondContent-Type: text/plain.npx tsc -b— 0 errors, includingrequests.typecheck.ts's 4@ts-expect-errorregressionassertions from
#49(still correctly reporting errors, not "unused directive" — therequired-field guarantee survived the switch to a generated map)
npx vitest run/npm run coverage— 250/250 tests, exit 0npm run build(tsc -b && vite build) — clean production buildFixed as a mechanical consequence of tightening
avatarImage/assigneefrom optional torequired-key-nullable-value in
dtos.ts(matching real wire behavior): ~15 test fixtures acrossAssigneeBadge.test.tsx,AssignmentPicker.test.tsx,CurrentUserAvatar.test.tsx,Register.test.tsx,SettingsModal.test.tsx,store.test.ts,TodoItem.test.tsx,TodoList.test.tsxneeded the now-required field added to their mock literals. No productioncode changes were needed beyond
dtos.tsitself.Not covered: an actual CI run of the new
backend→frontendartifact hand-off (sandbox hasno Gitea Actions runner). This is the same category of gap
#46/#50already documented — verifiedlocally to the fullest extent possible, real confirmation deferred to the next "go" cycle's
CI build-check step.