feat(openapi): publish a per-tenant spec and generate clients from it - #37
Conversation
MIT Learn consumes this service through hand-written types and a hand-written axios client that mirror models.py column for column. That was the right call for the first cut - nothing here published a client, and blocking the dashboard on a cross-repo publish pipeline was not worth it - but it means the frontend drifts silently every time a materialized view gains or renames a column. It already has: ol-analytics-api#33 made three engagement totals nullable and added seven columns, and mit-learn's types still say otherwise. Each tenant is a mounted sub-app, so it owns its own /openapi.json and the root app's schema contains none of it. `openapi.py` builds the apps through the same create_app() the server runs and takes each tenant's document from there, with two fixups that exist because the output is for a client generator rather than for the tenant's own /docs: paths are re-prefixed with the mount path, since Starlette strips it before the sub-app sees a request and a client pointed at the service host would otherwise call URLs that do not exist; and the document version is pinned rather than read from the package, so a release that changes no route produces no diff. Every route now names its own operation_id. That string becomes the generated client's method name, and FastAPI's default derives one from the function name and the whole path - `contractUtilizationOrganizationsOrganizationIdContract UtilizationGet`, renamed whenever the path moves. The tag prefix is also what keeps the org and contract routers' identically-named panels apart. Verified rather than assumed, since the org and contract endpoints are registered in a loop over a table of specs with a runtime-parametrized generic: openapi-generator v7.2.0 emits a distinct TypeScript interface per row model and per envelope (no collapse to one untyped OrgAnalyticsResponse), resolves the 3.1 `anyOf: [integer, null]` columns to `number | null`, and the result typechecks clean under `tsc --strict`. A test asserts the non-collapse so a future registration change cannot quietly undo it. The spec is committed because it is a cross-repo interface: ol-infrastructure's api_clients_pipeline watches openapi/specs/*.yaml on release and publishes the TypeScript package from it, the same arrangement behind @mitodl/mitxonline-api-axios. Drift fails CI twice over - as a test, and as a --check run of the generator, which is the only thing that exercises the generator at all. openapi-diff.yml comments the changelog on any PR touching a spec and fails on a breaking change, because breaking one here means breaking a client someone already shipped. Still to land before mit-learn can drop its hand-written client: the ol-analytics-api-clients repo and the PIPELINE_CONFIGS entry pointing at it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AVSXwars1LvgtqV1YWhrB1
OpenAPI ChangesShow/hide changesUnexpected changes? Ensure your branch is up-to-date with |
There was a problem hiding this comment.
Pull request overview
Adds committed per-tenant OpenAPI contracts to support future generated clients and detect API drift.
Changes:
- Generates and validates the B2B dashboard OpenAPI specification.
- Adds stable operation IDs and tenant names.
- Adds OpenAPI drift and breaking-change CI checks.
Reviewed changes
Copilot reviewed 12 out of 14 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
.github/workflows/ci.yml |
Checks generated-spec drift. |
.github/workflows/openapi-diff.yml |
Reports and checks OpenAPI changes. |
README.md |
Documents spec generation. |
bin/generate-openapi-spec |
Generates or verifies tenant specs. |
openapi/specs/b2b_dashboard.yaml |
Commits the B2B API contract. |
pyproject.toml |
Adds generator dependencies and lint coverage. |
src/ol_analytics_api/main.py |
Adds stable tenant names. |
src/ol_analytics_api/openapi.py |
Builds and serializes tenant specifications. |
src/ol_analytics_api/tenants/b2b_dashboard/routers/admin.py |
Adds an explicit operation ID. |
src/ol_analytics_api/tenants/b2b_dashboard/routers/contracts.py |
Adds contract operation IDs. |
src/ol_analytics_api/tenants/b2b_dashboard/routers/organizations.py |
Adds organization operation IDs. |
tests/test_lifespan.py |
Updates tenant construction. |
tests/test_openapi_spec.py |
Tests published contract invariants. |
uv.lock |
Locks new development dependencies. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
…e the pipeline as future The base-only spec loop skipped added specs in the changelog and let the -f guard silently skip deleted specs in the breaking-change check -- deleting a whole published API would have passed CI. Iterate the union of base and head filenames instead, and fail explicitly on a removed spec. Pin oasdiff by digest in both invocations so an upstream image change can't silently alter breaking-change classification. The README, generator script, and workflow comment described the ol-infrastructure client-publishing pipeline as already wired up; none of it exists yet (no PIPELINE_CONFIGS entry, no release branch, MIT Learn still on its hand-written client). Reworded to the intended future state. Added a test asserting every route's operation_id is explicit: the existing uniqueness check still passes for a route that fell back to FastAPI's path-derived default, which is unique but not stable.
|
Addressed all 4 review threads from copilot-pull-request-reviewer in 4698a12: fixed the base-only spec loop (union diff now catches added/removed specs, deleted-spec-is-breaking), pinned oasdiff by digest, and reworded README/generator/workflow docs to describe the ol-infrastructure client-publishing pipeline as intended-future rather than active. Also added a test asserting every route's operation_id is explicit. All threads resolved; checks green. |
…#37) * feat(openapi): publish a per-tenant spec and generate clients from it MIT Learn consumes this service through hand-written types and a hand-written axios client that mirror models.py column for column. That was the right call for the first cut - nothing here published a client, and blocking the dashboard on a cross-repo publish pipeline was not worth it - but it means the frontend drifts silently every time a materialized view gains or renames a column. It already has: ol-analytics-api#33 made three engagement totals nullable and added seven columns, and mit-learn's types still say otherwise. Each tenant is a mounted sub-app, so it owns its own /openapi.json and the root app's schema contains none of it. `openapi.py` builds the apps through the same create_app() the server runs and takes each tenant's document from there, with two fixups that exist because the output is for a client generator rather than for the tenant's own /docs: paths are re-prefixed with the mount path, since Starlette strips it before the sub-app sees a request and a client pointed at the service host would otherwise call URLs that do not exist; and the document version is pinned rather than read from the package, so a release that changes no route produces no diff. Every route now names its own operation_id. That string becomes the generated client's method name, and FastAPI's default derives one from the function name and the whole path - `contractUtilizationOrganizationsOrganizationIdContract UtilizationGet`, renamed whenever the path moves. The tag prefix is also what keeps the org and contract routers' identically-named panels apart. Verified rather than assumed, since the org and contract endpoints are registered in a loop over a table of specs with a runtime-parametrized generic: openapi-generator v7.2.0 emits a distinct TypeScript interface per row model and per envelope (no collapse to one untyped OrgAnalyticsResponse), resolves the 3.1 `anyOf: [integer, null]` columns to `number | null`, and the result typechecks clean under `tsc --strict`. A test asserts the non-collapse so a future registration change cannot quietly undo it. The spec is committed because it is a cross-repo interface: ol-infrastructure's api_clients_pipeline watches openapi/specs/*.yaml on release and publishes the TypeScript package from it, the same arrangement behind @mitodl/mitxonline-api-axios. Drift fails CI twice over - as a test, and as a --check run of the generator, which is the only thing that exercises the generator at all. openapi-diff.yml comments the changelog on any PR touching a spec and fails on a breaking change, because breaking one here means breaking a client someone already shipped. Still to land before mit-learn can drop its hand-written client: the ol-analytics-api-clients repo and the PIPELINE_CONFIGS entry pointing at it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AVSXwars1LvgtqV1YWhrB1 * fix(openapi): diff the union of base/head specs, pin oasdiff, describe the pipeline as future The base-only spec loop skipped added specs in the changelog and let the -f guard silently skip deleted specs in the breaking-change check -- deleting a whole published API would have passed CI. Iterate the union of base and head filenames instead, and fail explicitly on a removed spec. Pin oasdiff by digest in both invocations so an upstream image change can't silently alter breaking-change classification. The README, generator script, and workflow comment described the ol-infrastructure client-publishing pipeline as already wired up; none of it exists yet (no PIPELINE_CONFIGS entry, no release branch, MIT Learn still on its hand-written client). Reworded to the intended future state. Added a test asserting every route's operation_id is explicit: the existing uniqueness check still passes for a route that fell back to FastAPI's path-derived default, which is unique but not stable. --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Stacked on #36 (
feat/complement-suppression), which is stacked on #34 — review those first; this diff is only the commit on top.MIT Learn consumes this service through hand-written types (
frontends/api/src/analytics/types.ts) and a hand-written axios client that mirrormodels.pycolumn for column. Deliberate for the first cut — nothing here published a client, and blocking the dashboard on a cross-repo publish pipeline was not worth it — but it means the frontend drifts silently whenever a materialized view gains or renames a column. It already has: #33 made threeMonthlyEngagementTrendtotals nullable and added seven columns across two models, and mit-learn'stypes.tsstill typestotal_videos_watched/total_problems_attempted/total_chatbot_interactionsasnumberand carries none of the seven.What this adds
openapi/specs/b2b_dashboard.yaml, regenerated byuv run bin/generate-openapi-spec, plus the CI that keeps it honest.A tenant is a mounted sub-app, so it owns its own
/openapi.jsonand the root app's schema contains none of it — dumpingapp.openapi()gets you the health endpoints and nothing else.openapi.pybuilds the apps through the samecreate_app()the server runs, takes each tenant's document from there, and fixes up two things that exist only because the output is for a client generator rather than the tenant's own/docs:/api/v1/analyticsbefore the sub-app ever sees a request, so the sub-app describes its routes relative to its own root. A generated client configured with the service host as its base URL would call URLs that do not exist. Prefixing here makes the generated paths identical to what mit-learn's hand-written client hardcodes today.operationIds are now named explicitly
That string becomes the generated client's method name. FastAPI's default derives one from the function name and the whole path, which gives you
contractUtilizationOrganizationsOrganizationIdContractUtilizationGet— and renames it whenever the path moves. The tag prefix is also what keeps the org and contract routers' identically-named panels apart (organizations_contract_utilization_retrievevscontracts_contract_utilization_retrieve).The generic-collapse risk, checked rather than assumed
The org and contract endpoints are registered in a loop over a table of specs with a runtime-parametrized
OrgAnalyticsResponse[spec.model]. If that collapsed to one component, every panel would generate the same untyped row and the whole exercise would be pointless.Ran openapi-generator v7.2.0 (
typescript-axios) against the committed spec:ContractUtilization,ContractMonthlyEngagementTrend, …) and each envelope its own (OrgAnalyticsResponseContractUtilization, …). No collapse.anyOf: [integer, "null"]columns resolve tonumber | null— including the exact ones mit-learn's hand-written types have wrong (total_videos_watched,video_watchers,chatbot_users, …)./api/v1/analytics/organizations/{organization_id}/…).tsc --strict --noEmit.tests/test_openapi_spec.pyasserts the non-collapse, so a future registration change cannot quietly undo it.Why the spec is committed
It is a cross-repo interface.
ol-infrastructure'sol_concourse/pipelines/libraries/api_clients_pipeline.pywatchesopenapi/specs/*.yamlon the release branch, runs openapi-generator, and publishes the npm package — the same arrangement behind@mitodl/mitxonline-api-axiosand@mitodl/mit-learn-api-axios. A column that lands without appearing in a diff is a column a consumer discovers at runtime.Drift fails CI twice: as a test, and as a
--checkrun of the generator. The second is not redundant — it is the only thing that runs the generator at all, and a build-time script that only ever runs by hand is one that breaks unnoticed and is discovered when someone needs it.openapi-diff.ymlmirrors mitxonline's: comments the oasdiff changelog on any PR touching a spec and fails on a breaking change, because breaking one here means breaking a client someone already shipped. Zizmor-clean at the repo'shigh/mediumgate; both checkouts pinpull_request.head.sha/base.sharather than branch names, so a push mid-run cannot swap the diffed commit.Not in this PR — needed before mit-learn can drop its hand-written client
ol-analytics-api-clientsGitHub repo. The pipeline generates into a separate repo. Readmitodl/mitxonline-api-clientsfor the shape it has to have:scripts/generate-inner.shlooping overconfig/typescript-axios-*.yaml(one openapi-generator config per spec file — so one named forb2b_dashboard.yamlhere),src/typescript/<package>/, and aVERSIONplus[tool.bumpver]the pipeline's bump step drives. Creating a repo is not mine to do unilaterally.PIPELINE_CONFIGSentry in ol-infrastructure. Eight lines, but two of its values are decisions: the client repo name, and which branch it watches.PIPELINE_CONFIGSusesreleasefor mitxonline and mit-learn; this repo hasreleases/<calver>tags-as-branches and noreleasebranch, so that needs settling before the entry means anything.src/analytics/types.ts, swapsrc/analytics/clients.tsfor the generated API classes, drop the./analytics-typesexport. That subsumes the open type-drift chore.Note on the base branch
Coverage shows
core/db/query.py:123-124uncovered —build_existence_check's empty-filter_columnsguard, which arrives with #34, not with this commit. Flagging it there rather than fixing it here.Testing
Plus the openapi-generator +
tsc --strictrun described above, done locally against the committed spec.🤖 Generated with Claude Code
https://claude.ai/code/session_01AVSXwars1LvgtqV1YWhrB1