Build1 publisher3 min readPublished
Detecting OpenAPI spec drift with ast buys determinism at the cost of prefixed routers
A new zero-dependency CLI reads a Python codebase with the standard library and reports spec-only, code-only and method-mismatch paths, exiting 0 every time so the question of which drift fails a build stays with the team.
The Engineer · Build desk
What happened
- A CLI called oas-drift compares an OpenAPI JSON spec against a Python codebase and sorts the differences into three classes: spec only, code only, and method mismatch.
- It reads sources with Python's standard-library ast module and never imports, executes or writes anything, so the scan is read-only and returns the same report for the same input.
- Run against the backend of FastAPI's official full-stack template, 25 files with 14 paths and 23 routes, it caught a POST flipped to PUT on /login/access-token and cited the file and line.
Compiled by The EngineerSomething wrong?How this is made
Why it matters
- decision Since nothing fails the build by default, the team has to write the gating rule itself, and that means deciding in advance that a missing /health is tolerable while a method mismatch on a payment route is not.
- constraint Any project that mounts routers under a prefix cannot read the report until it normalises the spec side, most reliably by taking the /openapi.json the running app serves rather than the checked-in file.
- exposure A codebase that composes route paths with f-strings can pass a clean scan while serving endpoints that appear in no spec, and the code-only class is exactly the one no downstream generator will flag.
Code review sees the diff against the last commit, not against a spec written three months ago, according to the tool's author [19]. That spec is what generated the SDKs, the frontend types, the API docs and the mock servers [18]. The failure it produces is quiet: a generated client keeps calling DELETE /items/{id} and the server answers 405 Method Not Allowed [17].
The spec side is a JSON parse. The code side is an ast walk over your sources that picks string literals out of route decorators, and the tool never imports the module, never executes it, and never writes [3]. The comparison key is the literal path string: /users/{id} in the spec matches /users/{id} in code and nothing else [8].
Method mismatch is the third bucket. On the FastAPI template backend the tool detected 23 routes across 14 paths [10], roughly 1.6 methods per path [20], so a path-level match tells you nothing about whether DELETE still exists on it. Flipping the implemented POST /login/access-token to PUT in a test spec came back as a method mismatch pointing at backend/app/api/routes/login.py:23 [13].
Literal matching produces a false positive, and the author documented it. A router mounted with prefix="/items" serving /{id} does not match a spec's /items/{id} [8], so scanning that template against a prefixed spec reported the same route twice: /items/{id} as spec only, /{id} as code only [11]. The recommended fix is to normalise the spec side first, and the surest source is the /openapi.json the app actually serves [12].
Exit code is 0 whether or not drift is found [4]. "A tool that fails your build on day one gets removed on day two. A detector stays," the author wrote [5]. The rest of the reasoning is that drift has priorities: a /health endpoint missing from the spec is usually fine, a method mismatch on a payment route is not, and which one fails the build is policy the tool should not decide [6]. If you want a gate, the report is machine-readable and you wire it with --json and jq [7].
For the numbers to mean anything in your repo, your routes have to be declared as string literals in decorators and your spec paths have to be full paths. A path built with an f-string, @app.get(f"/users/{id}"), is not extracted at all [16]. The code-only side under-reports, silently. YAML specs are a future item, so the file you hand it has to be OpenAPI 3.x JSON [15].
The demo run scores five findings against two clean paths [9][21] on an app drifted on purpose [9]. The published test evidence is 15 pure-function tests passing in 0.04s [14] plus a wheel build, install and detection run end to end [23]; the post does not report how long the template scan took.
What to watch
- Whether YAML spec support lands, since today the tool reads OpenAPI 3.x JSON only.
- Whether router-prefix resolution is added, which would remove the documented false-positive pair without spec normalisation.
- Whether a built-in policy flag replaces the jq-plus---json pattern for failing a build on selected statuses.