feat(api-types): generate the API wire types instead of hand-writing them - #357
Merged
Conversation
added 4 commits
August 3, 2026 07:23
…them
The UI hand-wrote the shape of every API response. That is why the last round
of cross-repo bugs were all contract bugs: the event-type picker fetched
GET /api/v1/me/event-types, a path the OSS server never implemented, because
someone typed the interface from a stale spec rather than from the server.
The response shapes now come from the API's OpenAPI spec.
src/lib/api/openapi/swagger.yaml vendored from openctemio/api
-> swagger2openapi (swaggo v1 emits Swagger 2.0;
openapi-typescript v7 reads 3.x only)
-> openapi-typescript
-> src/lib/api/generated/api.types.ts
src/lib/api/generated/index.ts is a naming layer over that: the generated
schema keys are Go package paths (internal_infra_http_handler.FindingResponse)
and change whenever a handler type moves package. It declares no field shapes
of its own.
Converted — the six feature groups whose endpoints the server documents:
asset-types identical field sets; only optionality changed
asset-groups ApiAssetGroup declared tenant_id, which the server does not
return. ApiAssetGroupStats declared critical_groups and
high_risk_groups; neither exists. Anything reading them read
undefined.
credentials ApiIdentityExposure declared exposures?: ApiCredential[],
which that endpoint does not return — per-identity credentials
come from /credentials/identities/{identity}/exposures. It
also narrowed identity_type to a three-member union the server
does not promise.
components the UI was blind to depth, is_direct and parent_component_id —
the component's position in the dependency tree.
scope field sets already matched.
findings NOT converted, see below.
Left hand-written, with the reason recorded in each file:
• GET /credentials/stats, /credentials/enums, /components,
/components/{id}/assets and /components/{id}/vulnerabilities return
map[string]any, so the spec describes them only as "an object". There is
nothing to generate until those handlers get real response structs.
• The other 35 *.types.ts files are UI-only — label maps, colour configs,
filter state, view models behind a transformer — or their endpoints are
among the 459 registered routes the server does not document at all.
Every generated response field is optional, because swaggo emits no `required`
list for response structs. That is the contract as the server states it, so the
defaults live at the API-to-view-model boundary (the transform*/mapper
functions) rather than in a type that asserts the fields are always present.
The asserting is exactly what let the UI declare fields the server never
returned.
That honesty immediately exposed a live bug: components/api/mapper.ts read
`(apiComponent.ecosystem as ComponentEcosystem) || 'active'` — 'active' is a
Status value, copy-pasted from the line below. TypeScript could not see it,
because after the cast every operand is a truthy string literal, so the
right-hand side is unreachable to the checker while still live at runtime. An
omitted ecosystem would have matched neither the label map nor the colour map.
Now 'npm', matching mapEcosystem()'s existing fallback.
`npm run check:api-types` fails when the generated file and the spec disagree,
and runs in CI unconditionally — it compares a committed file against a pure
function of another committed file, so it has the same answer on a push as on
a pull request.
The pre-commit hook formats *.yaml, and it reformatted src/lib/api/openapi/ swagger.yaml on the way in — 6,665 lines of block-sequence re-indentation. That file is vendored from openctemio/api and has to stay a byte copy of what the server generates, otherwise re-vendoring it looks like a contract change every time and it cannot be diffed against the source of truth. The generated api.types.ts stays prettier-formatted: scripts/generate-api-types.sh runs prettier with --config so the committed output matches what format:check expects.
The in-repo drift check proves the generated types match the vendored spec. It cannot prove the vendored spec still matches the server — which is the same failure one hop out: the UI would be generating a faithful client for a server that has moved on. scripts/check-spec-vendored.sh diffs src/lib/api/openapi/swagger.yaml against openctemio/api (raw.githubusercontent, branch from API_REF, default develop), or against a local checkout via SPEC_SOURCE. A fetch failure exits 2 rather than passing, since a check that cannot run must not report as passed. NOT wired into ci.yml yet, deliberately. It compares against api's develop, and the spec this branch vendors only lands there when api#407 merges — wiring it now would ship a job that is red for a reason no UI change can fix, which is the same "gate people route around" problem in reverse. The PR body carries the exact job to add as a one-line follow-up once api#407 is in. Verified both directions: exit 0 against the api#407 tree, exit 1 with a 46-line diff against develop as it stands today.
The script landed unwired in the previous commit for a stated reason: it compares against openctemio/api's develop, and the spec this branch vendors had not merged there yet, so the job would have been red for something no UI change could fix. api#407 merged, develop's api/openapi/swagger.yaml is now byte-identical to the vendored copy, and the check passes. Wiring it closes the outer loop: the in-repo check proves the generated types match the vendored spec, and this proves the vendored spec is still the server's.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Pairs with openctemio/api#407, which makes the OpenAPI spec a generated, CI-enforced artifact. Merge that first.
The problem
The UI hand-wrote the shape of every API response. Every cross-repo bug found in the last two days was a symptom:
GET /api/v1/me/event-types— a path the OSS server never implemented. Someone typed the interface from a stale spec rather than from the server.sla_breachnotified nobody./auth/providersadvertised OAuth providers whose routes were never registered, so two pages rendered buttons that 404'd.A hand-written wire type cannot be wrong in a way anything notices. That is the whole failure mode.
What changed
src/lib/api/generated/index.tsis a naming layer over the output. The generated schema keys are Go package paths (internal_infra_http_handler.FindingResponse) and change whenever a handler type moves package; feature code should not depend on that. It declares no field shapes of its own — every export resolves to a generated type, plus three type operators (ApiResponse<path, method>,ApiRequestBody,ApiQuery) for the endpoints whose list envelopes the spec declares inline rather than as named schemas.openapi-typescriptwas already the recommendation in this repo's owndocs/guides/ORGANIZING_TYPES_AT_SCALE.mdand had never been acted on. That section now documents what actually exists.Converted, and what the server disagreed about
Six feature groups — the ones whose endpoints the server documents.
asset-types/api/asset-type-api.types.tsasset-groups/api/asset-group-api.types.tsApiAssetGroupdeclaredtenant_id, which the server does not return.ApiAssetGroupStatsdeclaredcritical_groupsandhigh_risk_groups— neither exists onAssetGroupStatsResponse. Anything reading them readundefined.credentials/api/credential-api.types.tsApiIdentityExposuredeclaredexposures?: ApiCredential[], which that endpoint does not return — per-identity credentials come from/credentials/identities/{identity}/exposures. It also narrowedidentity_typeto a three-member union the server does not promise.components/api/component-api.types.tsdepth,is_directandparent_component_id— the component's position in the dependency tree, returned all along.scope/api/scope-api.types.tsThe optionality question, and the bug it exposed
Every generated response field is optional, because swaggo emits no
requiredlist for response structs. That is weaker than reality (a Go field withoutomitemptyalways serialises) but it is the contract as the server states it, so I did not paper over it. Defaults live at the API-to-view-model boundary — thetransform*andmapperfunctions — rather than in a type that asserts the fields are always present. That asserting is exactly what let the UI declare fields the server never returned.Making the types honest immediately surfaced a live bug in
components/api/mapper.ts:'active'is aStatusvalue, copy-pasted from thestatus:line below it. TypeScript could never see it: after the cast every operand is a truthy string literal, so the right-hand side is unreachable to the checker while still live at runtime. An omittedecosystemwould have producedecosystem === 'active', matching neitherCOMPONENT_ECOSYSTEM_LABELSnorecosystemColorsand rendering an unstyled badge. Now'npm', matchingmapEcosystem()'s existing fallback. This is a behaviour change, and'npm'is a guess — if you would rather surface honest ignorance,ComponentEcosystemneeds an'unknown'member.Left hand-written, and why
Endpoints the server describes only as "an object".
GET /credentials/stats,/credentials/enums,GET /components,/components/{id}/assetsand/components/{id}/vulnerabilitiesreturnmap[string]anyin Go, so the spec has nothing to generate from. Each is markedNOT GENERATEDin place with the reason. Typing them means giving those handlers real response structs on the server.Findings.
ApiFindinghas 124 fields againstFindingResponse's 139, and is read by dozens of components. Converting it belongs in its own review. It is worth naming what the UI is currently blind to, because these are not cosmetic:and on
ApiVulnerability:cisa_kev,references,affected_versions. A KEV flag the server returns and the UI's type does not mention, on a CTEM product. The aliases are already declared ingenerated/index.tswith this noted, so the follow-up is a small change.The other 35
*.types.tsfiles. They are UI-only — label maps, colour configs, filter and form state, view models sitting behind a transformer — or their endpoints are among the 459 registered routes the server does not document at all (see api#407). Converting a type whose endpoint has no spec entry is not possible, and converting a label map is not desirable.The drift checks
npm run check:api-typesregenerates into a temp dir and diffs. Wired into thequalityjob, unconditional — noif:, no base-ref lookup. It compares a committed file against a pure function of another committed file, so it has the same answer on a push as on a PR. Proven to fail: hand-adding/me/drifted-endpointto the vendored spec exits 1 and names it; reverting returns exit 0.npm run check:spec-vendoreddiffs the vendored spec againstopenctemio/apiitself, closing the outer loop: the in-repo check proves the generated types match the vendored copy, and this proves the copy is still the server's. A fetch failure exits 2 rather than passing.It landed unwired at first — it compares against api's
develop, and the spec this branch vendors had not merged there yet, so the job would have been red for a reason no UI change could fix. api#407 is now merged,develop's spec is byte-identical to the vendored copy, and the check is wired into thequalityjob unconditionally.Also fixed
The pre-commit hook formats
*.yamland reformatted the vendored spec on its way in — 6,665 lines of block-sequence re-indentation. That file has to stay a byte copy of what the server generates, or re-vendoring looks like a contract change every time. It is now in.prettierignore; the generatedapi.types.tsstays prettier-formatted, because the generator runs prettier with--configso the committed output satisfiesformat:check.Verification
The generated file is 30k lines; it is a build artifact and reviewing it line by line is not the point. The reviewable surface is
generated/index.ts, the six converted*.types.tsfiles, and the?? defaultadditions at the transformer boundaries.