Here’s a bug I’ve seen more than once.
You write a spec for a user lookup endpoint:
# openapi.yaml
paths:
/users/{id}:
get:
responses:
"200":
content:
application/json:
schema:
type: object # ← single user
properties:
id:
type: integer
name:
type: string
The implementation ships. Everything works. Six months later, someone adds pagination and wraps the response in a list. The spec isn’t updated. Tests still pass — they were testing the wrong shape. Spectral still passes — it only reads the spec, not your code.
Your API consumers start getting TypeErrors at runtime.
This is semantic drift: the spec and the implementation diverge in meaning, not in syntax. No existing tool catches it — because no existing tool looks at both.
I built one that does.
Why existing tools don’t catch this
| Tool | What it checks | Catches semantic drift? |
|---|---|---|
| Spectral | Spec syntax and style rules | ❌ |
| Prism | Generates mocks from spec | ❌ |
| oasdiff | Compares two specs | ❌ |
| openapi-sv | Compares spec against implementation code | ✅ |
Spectral, Prism, and oasdiff are excellent tools. They check spec-vs-spec consistency. But none of them open your source files.
openapi-sv does exactly one thing differently: it reads your Python implementation via static AST analysis, finds the route handlers, classifies their return shapes, and compares them against what the spec declares.
See it in action
Clone the repo and start the server:
git clone https://github.com/frandle331-yh/openapi-sv
cd openapi-sv
pip install -r requirements.txt
python main.py
# Uvicorn running on http://0.0.0.0:8080
The samples/ directory includes a deliberately broken implementation. Send it to the API:
# Create a zip of the divergent implementation
zip impl.zip samples/impl_divergent.py
curl -X POST http://localhost:8080/verify
-F "openapi_file=@samples/openapi_divergent.yaml"
-F "impl_archive=@impl.zip"
The divergent implementation looks like this:
# samples/impl_divergent.py
from flask import Flask, jsonify
app = Flask(__name__)
@app.route("/users/ ")
def get_user(id):
# BUG: spec says object, but this returns an array
return jsonify([{"id": id, "name": "Alice"}])
The response:
{
"verdict": "DIVERGENT",
"summary": {
"top_divergences": [
{
"type": "shape",
"description": "GET /users/{id}: response is array but spec declares object"
}
],
"next_action": "Fix shape divergences in: GET /users/{id}. See grounds for details."
},
"grounds": [
{
"claim_id": "C1",
"claim": "GET /users/{id} → HTTP 200: Top-level shape: spec=object, impl=array",
"status": "DIVERGENT",
"evidence": [
{
"side": "spec",
"pointer": "openapi.yaml#/paths/~1users~1{id}/get/responses/200/content/application~1json/schema"
},
{
"side": "impl",
"pointer": "impl_divergent.py:7"
}
]
}
],
"verification": {
"input_hashes": {
"openapi_sha256": "2c3b1f157d...",
"impl_archive_sha256": "1b60899f81..."
},
"api_version": "0.2.0"
}
}
Two things worth noting in this response:
1. impl_divergent.py:7 — not just “there’s a divergence somewhere.” The exact file and line number.
2. SHA-256 hashes of both inputs — the result is fully reproducible. Send the same files, get the same verdict. This matters when you’re debugging in a team or logging evidence.
Try it without any files
If you want to see the result before cloning anything, open the demo UI in your browser:
👉 frandle331-yh.github.io/openapi-sv
Start the API locally (python main.py), then click “Try with Sample”. The sample files are embedded — no upload needed. One click, one DIVERGENT verdict, five seconds.
How it works
The core is a Python AST analyzer (detectors/d1_shape.py).
Step 1: Extract shapes from the spec
Walk the OpenAPI paths object, find all 2xx application/json responses, read their type field. type: object means the spec expects a single object. type: array means a list.
Step 2: Find handlers in the implementation
Parse every .py file in the uploaded ZIP using Python’s ast module. Walk function definitions looking for route decorators:
# Matches both:
@app.route("/users/ ") # Flask
@router.get("/users/{id}") # FastAPI
Extract the route path and HTTP method from the decorator arguments.
Step 3: Classify the return shape
Inspect return statements in each handler:
return jsonify([...]) # → array
return {"id": 1, ...} # → object
return jsonify({...}) # → object
Step 4: Compare
If the spec says object and the handler returns array, that’s a D1 Shape Divergence. Report it with evidence pointers to both sides.
No code is executed. No network requests are made. The analysis is purely static.
What it can’t do yet
I’d rather tell you the limits upfront than have you discover them in production.
Language support: Python only. Flask and FastAPI route decorators are supported. Go, TypeScript, Java — not yet. Static AST analysis requires language-specific implementations, and I started where the pain was sharpest for me.
Complex returns: If a handler’s return value comes from a database query, a function call, or a conditional branch, the analyzer often can’t determine the shape statically. In that case, the verdict is INDETERMINATE — not a false ALIGNED. The API tells you when it doesn’t know.
Divergence types: Currently only D1 (shape: object vs array). Type mismatches, missing required fields, and status code divergences are on the roadmap.
Verdicts
| Verdict | Meaning |
|---|---|
ALIGNED |
All verifiable shapes match |
DIVERGENT |
At least one confirmed mismatch |
INDETERMINATE |
Handler shape could not be determined statically |
REJECTED |
Input was invalid (bad YAML, corrupt ZIP, etc.) |
REJECTED responses include a how_to_fix field so you’re never left staring at an error code:
{
"verdict": "REJECTED",
"rejection": {
"code": "PARSE_FAIL",
"reason": "The OpenAPI specification file could not be parsed as valid YAML.",
"how_to_fix": "Ensure the file is valid YAML syntax."
}
}
Input format
POST /verify
Content-Type: multipart/form-data
openapi_file — OpenAPI 3.x YAML or JSON
impl_archive — ZIP archive of your Python source files
The archive can contain multiple files in subdirectories. The analyzer walks all .py files recursively.
What’s next
-
D2: Type mismatch detection (spec says
integer, impl returnsstring) - D3: Missing required fields in response objects
- TypeScript support: Express and Fastify route handlers
-
CI integration: A
--exit-codeflag so you can use this in pre-merge checks
Try it
git clone https://github.com/frandle331-yh/openapi-sv
cd openapi-sv
pip install -r requirements.txt
python main.py
# Or with Docker (once Docker Desktop is connected):
docker build -t openapi-sv . && docker run -p 8080:8080 openapi-sv
API docs at http://localhost:8080/docs (FastAPI auto-generated Swagger UI).
🔗 github.com/frandle331-yh/openapi-sv
🎮 Live demo UI
If this saves you a debugging session, a ⭐ on GitHub helps others find it.
Built because I got bitten by this exact bug. The spec said object. The code returned array. Spectral said everything was fine.