Skip to content

feat(api-types): generate the API wire types instead of hand-writing them - #357

Merged
0xmanhnv merged 4 commits into
developfrom
feat/openapi-generated-types
Aug 3, 2026
Merged

feat(api-types): generate the API wire types instead of hand-writing them#357
0xmanhnv merged 4 commits into
developfrom
feat/openapi-generated-types

Conversation

@0xmanhnv

@0xmanhnv 0xmanhnv commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

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:

  • The event-type picker fetched 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.
  • Six notification event types were emitted with no checkbox, because the UI hand-maintained its own copy of a catalogue the spec never exposed. sla_breach notified nobody.
  • /auth/providers advertised 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/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 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-typescript was already the recommendation in this repo's own docs/guides/ORGANIZING_TYPES_AT_SCALE.md and 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.

file what generation corrected
asset-types/api/asset-type-api.types.ts identical field sets; only optionality changed
asset-groups/api/asset-group-api.types.ts ApiAssetGroup declared tenant_id, which the server does not return. ApiAssetGroupStats declared critical_groups and high_risk_groups — neither exists on AssetGroupStatsResponse. Anything reading them read undefined.
credentials/api/credential-api.types.ts 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/api/component-api.types.ts the UI was blind to depth, is_direct and parent_component_id — the component's position in the dependency tree, returned all along.
scope/api/scope-api.types.ts field sets already matched.
findings not converted — see below.

The optionality question, and the bug it exposed

Every generated response field is optional, because swaggo emits no required list for response structs. That is weaker than reality (a Go field without omitempty always 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 — the transform* and mapper functions — 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:

ecosystem: (apiComponent.ecosystem as ComponentEcosystem) || 'active'

'active' is a Status value, copy-pasted from the status: 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 omitted ecosystem would have produced ecosystem === 'active', matching neither COMPONENT_ECOSYSTEM_LABELS nor ecosystemColors and rendering an unstyled badge. Now 'npm', matching mapEcosystem()'s existing fallback. This is a behaviour change, and 'npm' is a guess — if you would rather surface honest ignorance, ComponentEcosystem needs 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}/assets and /components/{id}/vulnerabilities return map[string]any in Go, so the spec has nothing to generate from. Each is marked NOT GENERATED in place with the reason. Typing them means giving those handlers real response structs on the server.

Findings. ApiFinding has 124 fields against FindingResponse'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:

sla_deadline, is_internet_accessible, remediation, assigned_to_user, asset, component, attachments, stacks, related_locations, duplicate_of, fix_regex, hosted_viewer_uri, tool_id, verified_at, verified_by

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 in generated/index.ts with this noted, so the follow-up is a small change.

The other 35 *.types.ts files. 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-types regenerates into a temp dir and diffs. Wired into the quality job, unconditional — no if:, 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-endpoint to the vendored spec exits 1 and names it; reverting returns exit 0.

npm run check:spec-vendored diffs the vendored spec against openctemio/api itself, 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 the quality job unconditionally.

Also fixed

The pre-commit hook formats *.yaml and 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 generated api.types.ts stays prettier-formatted, because the generator runs prettier with --config so the committed output satisfies format:check.

Verification

tsc --noEmit                              0 errors
vitest run                                55 files, 898 tests, all pass
npm run build                             success (after a real npm ci)
npm run lint                              0 errors (20 pre-existing warnings)
prettier --check "src/**/*.{ts,tsx}"      clean
check-palette-drift.sh origin/develop     no new hardcoded palette classes
npm run check:api-types                   up to date

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.ts files, and the ?? default additions at the transformer boundaries.

Nguyen Manh 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.
@0xmanhnv
0xmanhnv merged commit 9b1a321 into develop Aug 3, 2026
13 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant