Skip to content

feat(openapi): publish a per-tenant spec and generate clients from it - #37

Merged
blarghmatey merged 2 commits into
feat/complement-suppressionfrom
feat/openapi-spec-export
Aug 24, 2026
Merged

blarghmatey merged 2 commits into
feat/complement-suppressionfrom
feat/openapi-spec-export

Conversation

@blarghmatey

Copy link
Copy Markdown
Member

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 mirror models.py column 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 three MonthlyEngagementTrend totals nullable and added seven columns across two models, and mit-learn's types.ts still types total_videos_watched/total_problems_attempted/total_chatbot_interactions as number and carries none of the seven.

What this adds

openapi/specs/b2b_dashboard.yaml, regenerated by uv 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.json and the root app's schema contains none of it — dumping app.openapi() gets you the health endpoints and nothing else. openapi.py builds the apps through the same create_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:

  • Paths are re-prefixed with the mount path. Starlette's Mount strips /api/v1/analytics before 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.
  • The document version is pinned, not read from the package. Sourcing it from CalVer would rewrite the spec on every release; the committed file exists to make interface changes visible, so a release that changes no route should produce no diff.

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_retrieve vs contracts_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:

  • Each row model gets its own interface (ContractUtilization, ContractMonthlyEngagementTrend, …) and each envelope its own (OrgAnalyticsResponseContractUtilization, …). No collapse.
  • The OpenAPI 3.1 anyOf: [integer, "null"] columns resolve to number | null — including the exact ones mit-learn's hand-written types have wrong (total_videos_watched, video_watchers, chatbot_users, …).
  • Generated paths carry the mount prefix (/api/v1/analytics/organizations/{organization_id}/…).
  • The output typechecks clean under tsc --strict --noEmit.

tests/test_openapi_spec.py asserts 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's ol_concourse/pipelines/libraries/api_clients_pipeline.py watches openapi/specs/*.yaml on the release branch, runs openapi-generator, and publishes the npm package — the same arrangement behind @mitodl/mitxonline-api-axios and @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 --check run 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.yml mirrors 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's high/medium gate; both checkouts pin pull_request.head.sha/base.sha rather 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

  1. The ol-analytics-api-clients GitHub repo. The pipeline generates into a separate repo. Read mitodl/mitxonline-api-clients for the shape it has to have: scripts/generate-inner.sh looping over config/typescript-axios-*.yaml (one openapi-generator config per spec file — so one named for b2b_dashboard.yaml here), src/typescript/<package>/, and a VERSION plus [tool.bumpver] the pipeline's bump step drives. Creating a repo is not mine to do unilaterally.
  2. A PIPELINE_CONFIGS entry in ol-infrastructure. Eight lines, but two of its values are decisions: the client repo name, and which branch it watches. PIPELINE_CONFIGS uses release for mitxonline and mit-learn; this repo has releases/<calver> tags-as-branches and no release branch, so that needs settling before the entry means anything.
  3. mit-learn: add the package, delete src/analytics/types.ts, swap src/analytics/clients.ts for the generated API classes, drop the ./analytics-types export. That subsumes the open type-drift chore.

Note on the base branch

Coverage shows core/db/query.py:123-124 uncovered — build_existence_check's empty-filter_columns guard, which arrives with #34, not with this commit. Flagging it there rather than fixing it here.

Testing

uv run ruff check . && uv run ruff format --check .
uv run mypy src
uv run bin/generate-openapi-spec --check
uv run pytest    # 239 passed, openapi.py at 100%

Plus the openapi-generator + tsc --strict run described above, done locally against the committed spec.

🤖 Generated with Claude Code

https://claude.ai/code/session_01AVSXwars1LvgtqV1YWhrB1

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
@github-actions

github-actions Bot commented Aug 21, 2026 •

Copy link
Copy Markdown

OpenAPI Changes

Show/hide changes
## b2b_dashboard.yaml: added

Unexpected changes? Ensure your branch is up-to-date with main (consider rebasing).

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread .github/workflows/openapi-diff.yml Outdated
Comment thread README.md Outdated
Comment thread tests/test_openapi_spec.py
Comment thread .github/workflows/openapi-diff.yml Outdated
…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.
@blarghmatey

Copy link
Copy Markdown
Member Author

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.

@blarghmatey
blarghmatey merged commit d23bd05 into feat/complement-suppression Aug 24, 2026
5 checks passed
@blarghmatey
blarghmatey deleted the feat/openapi-spec-export branch August 24, 2026 20:31
blarghmatey added a commit that referenced this pull request Aug 28, 2026
…#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>
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.

3 participants