"The generated client keeps calling DELETE /items/{id}."
"The server returns 405 Method Not Allowed."
An OpenAPI spec is not just documentation. SDKs, frontend types, API docs, mock servers — everything is generated from the spec. When the implementation drifts from it, everything generated starts lying quietly.
This is about oas-drift, a CLI I built that detects that drift — with zero dependencies (Python 3.11+ standard library only, no LLM).
https://github.com/sunnydachs/oas-drift
You give it an OpenAPI JSON spec and a Python codebase. It finds three classes of drift:
oas-drift --spec openapi.json
oas-drift --spec openapi.json ./src --json
Here is a real run against a deliberately-drifted demo app:
oas-drift — scanned src
spec: 5 endpoint(s) | code: 4 route(s)
/health ➕ CODE ONLY
route implemented (GET) but not defined in spec — src/app.py:21
/items/{id} ⚠️ METHOD MISMATCH
/items/{id}: in spec but not implemented: DELETE; implemented but not in spec: POST — src/app.py:17
/users/{id} ⚠️ METHOD MISMATCH
/users/{id}: in spec but not implemented: DELETE — src/app.py:13
/admin/stats ⬜ SPEC ONLY
defined in spec (GET) but no matching route in codebase
/items ⬜ SPEC ONLY
defined in spec (GET) but no matching route in codebase
summary: {"code_only": 1, "method_mismatch": 2, "spec_only": 2} | ok: 2
"The spec is the contract" only works if someone checks both sides. Code review sees the diff against the last commit — not against a spec written three months ago.
This was the core design decision. Most CI-facing drift tools fail the build when they find anything. oas-drift's exit code is 0 either way. Three reasons:
/health endpoint missing from the spec is usually fine. A METHOD MISMATCH on a payment route is not. Which drift fails the build is policy — the tool shouldn't decide that for you.ast module — never imports, never executes, never writes. Same input → same report, always.
If you do want to fail on specific statuses, wire it into CI with --json and jq — the report is machine-readable by design.
Same principles as the sibling tools I shipped this month — doc-drift (README↔code) and plan-drift (tracking plan↔code). Deterministic work deserves deterministic tools.
The detail I obsessed over: path parameters and router prefixes. The rule:
/users/{id} in the spec matches prefix="/items" serving /{id} does not match a spec's /items/{id}.
I validated this against a real, widely-used codebase — the backend of FastAPI's official full-stack-fastapi-template (25 files, 14 paths, 23 routes detected). Scanning it with a spec written in prefixed paths produces exactly the false-positive pair this rule predicts:
/items/{id} ⬜ SPEC ONLY
defined in spec (DELETE, GET, PUT) but no matching route in codebase
/{id} ➕ CODE ONLY
route implemented (DELETE, GET, PUT) but not defined in spec — backend/app/api/routes/items.py:48, ...
Two names for the same route. oas-drift doesn't guess — it reports what literally exists. If your project uses router prefixes, normalize the spec side first (the surest source is the /openapi.json your app actually serves).
And the important detection works: flipping the implemented POST /login/access-token to PUT in a test spec gets reported precisely:
/login/access-token ⚠️ METHOD MISMATCH
/login/access-token: in spec but not implemented: PUT; implemented but not in spec: POST — backend/app/api/routes/login.py:23
@app.get(f"/users/{id}") isn't extracted; string literals only.
These are documented in the README. The current version prioritizes minimal, honest detection over coverage.
Specs drift the moment the code moves and nobody updates them. A contract is only a contract if something checks it — oas-drift is that checking part, built read-only, fully deterministic, and dependency-free.
This is a personal OSS project with no warranty. If you hit bugs or have suggestions, GitHub issues are the best way to reach me.