Skip to content

feat(apidocs): Generate TypeScript API contracts from the internal spec - #129576

Draft
billyvg wants to merge 2 commits into
billyvg/feat/apidocs-internal-specfrom
billyvg/feat/api-contracts-to-typescript
Draft

billyvg wants to merge 2 commits into
billyvg/feat/apidocs-internal-specfrom
billyvg/feat/api-contracts-to-typescript

Conversation

@billyvg

@billyvg billyvg commented Nov 2, 2026 •

Copy link
Copy Markdown
Member

Why?

#129315 types apiOptions responses from contracts generated from the backend. This pulls out the generator that writes static/app/utils/api/apiContracts.generated.ts, so it can be reviewed apart from the frontend changes.

Stacked on the PR below, which builds the internal spec this reads:

How?

make build-api-contracts runs make build-internal-api-docs, then tools/api_contracts_to_typescript.py, then oxfmt. The script writes:

  • one TypeScript type per component schema in the internal spec
  • <OperationId>Response for each success body that isn't a bare component reference, such as the ones the legacy api-docs JSON declares inline
  • <Property>Enum for each enum of two or more members that types properties in more than one place, such as the 598 timezones three types share, so it's declared once
  • ApiMapping, from each KnownSentryApiUrls route to {response: T} for each method that declares a success body, with a comment giving the operation and its publish status. T is always a type name.

Routes the frontend can't name are skipped. A route spelled with a literal alternation, such as (?:issues|groups), is mapped under every path in x-sentry-path-aliases. Where a path is spelled directly and also reached as another route's alias, the direct spelling wins.

Decisions

  • An undeclared response is left out, not typed unknown. Responses marked x-sentry-undocumented, and ones with no body, get no ApiMapping entry, so they don't look like contracts. A type hint the build couldn't resolve (x-sentry-unresolved) does become unknown, since the rest of its schema is still useful.
  • The script doesn't import Django. It spells out the x-sentry-* extension names itself. A test in tests/sentry/apidocs/internal/test_generator.py checks they match the names the internal build writes.
  • Enums are hoisted here, not by drf-spectacular's postprocess_schema_enums. Turned on for the internal build, that hook walks only a component's top-level properties, so most repeated enums stay inline. It also compares choice labels as well as values, so the timezones still came out as two 598-member enums. And it names collisions with a hash of the choices (Status42cEnum), which changes whenever the choices do. Here, as in that hook, a blank or null member stays at the use site (TimezoneEnum | ''). Enums that would share a name are each qualified by the first type that has them (GroupDetailsResponseStatusEnum).
  • The output already passes lint. Object keys are ordered required-first and then by name, to satisfy @sentry/sort-interface-keys. Arrays are T[] or Array<T> as typescript/array-type requires, and enum literals are sorted so the output doesn't change between builds.

Not in this PR: the generated file, the CI job that regenerates and commits it, the knip entry, apiExamples.generated.ts, and apiOptions.contract(). Until #129568 lands, make build-api-contracts fails at the internal spec's --validate step on the csrf endpoint.

🤖 Generated with Claude Code

@github-actions github-actions Bot added the Scope: Backend Automatically applied to PRs that change backend components label Nov 2, 2026
@linear-code

linear-code Bot commented Nov 3, 2026

Copy link
Copy Markdown

ENG-8961

Add tools/api_contracts_to_typescript.py and `make build-api-contracts`, which turn the
internal OpenAPI spec into static/app/utils/api/apiContracts.generated.ts: a type per
component schema, plus ApiMapping from each KnownSentryApiUrls route to the response
type of each method that declares one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@billyvg
billyvg force-pushed the billyvg/feat/apidocs-internal-spec branch from c781b98 to ba526f9 Compare November 3, 2026 20:44
@billyvg
billyvg force-pushed the billyvg/feat/api-contracts-to-typescript branch from 4cd9af6 to 1bfb1e5 Compare November 3, 2026 20:44
…contracts

ApiMapping spelled out a response whose schema isn't a component, such as
the ones the legacy api-docs JSON declares inline, and every component
repeated its enums inline, such as the 598 timezones three types share.

ApiMapping now holds only type names. A response that isn't a bare
component reference is declared as `<OperationId>Response` (the method and
route name it when there is no usable operation id), once per operation
however many routes reach it, with a number after it when the name is
taken.

An enum with more than one member that types properties in more than one
place is declared once, as `<Property>Enum`, after the property it types
most often. Like drf-spectacular's enum hook, a blank or null member stays
at the use site (`TimezoneEnum | ''`), so a choice set is one enum whether
or not a field also accepts a blank or null, and enums that would share a
name are each qualified by the first type that has them. One-member enums
are discriminators, such as `type: 'http'`, and stay inline.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Scope: Backend Automatically applied to PRs that change backend components

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant