DEV Community

Cover image for Your OpenAPI spec is a contract. Nobody is checking it. I built a CLI that detects spec↔code drift with zero dependencies.
sunnydachs
sunnydachs

Posted on

Your OpenAPI spec is a contract. Nobody is checking it. I built a CLI that detects spec↔code drift with zero dependencies.

"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

What it does

You give it an OpenAPI JSON spec and a Python codebase. It finds three classes of drift:

  • SPEC ONLY — defined in the spec, but no matching route in code
  • CODE ONLY — route exists in code, but is not in the spec
  • METHOD MISMATCH — path matches, but the HTTP method differs
# scan the current directory against a spec (read-only)
oas-drift --spec openapi.json

# scan a specific root, machine-readable output
oas-drift --spec openapi.json ./src --json
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

"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.

Why it's a detector, not a gate

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:

  1. Adoption survives. A tool that fails your build on day one gets removed on day two. A detector stays.
  2. Drift has priorities. A /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.
  3. Fully deterministic. It parses sources with Python's 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 rule: paths match literally

The detail I obsessed over: path parameters and router prefixes. The rule:

  • /users/{id} in the spec matches /users/{id} in code — and only that.
  • Router prefixes are NOT resolved. A router with 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, ...
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Testing against real-world data

  • 15 pure-function tests (no network, no fixtures on disk), all passing. Runtime: 0.04s.
  • e2e on a real install path: wheel build → install → actual drift detection, verified.
  • A real codebase: FastAPI's official full-stack-fastapi-template backend — the deliberately planted METHOD MISMATCH was caught, and the router-prefix false-positive pair was something I discovered and documented.

Honest limitations

  • JSON only (OpenAPI 3.x JSON; YAML is a future item).
  • Literal path matching — normalizing router prefixes is on you.
  • f-string paths are invisible@app.get(f"/users/{id}") isn't extracted; string literals only.
  • Routes and methods only — request/response schema comparison is a future item.

These are documented in the README. The current version prioritizes minimal, honest detection over coverage.

Wrap-up

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.

https://github.com/sunnydachs/oas-drift

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.

Top comments (0)