Generate OpenAPI from FastAPI and Django REST without decorating every view
Python backends split into two schema philosophies. FastAPI puts the contract next to the handler, in type annotations and model classes, so a lot of the OpenAPI document is already written in a form a parser can read. Django REST Framework (DRF) puts the contract in Serializer classes and ViewSet actions, which is expressive but considerably more dynamic. A code-to-OpenAPI scanner has to handle both honestly: recover everything that is statically provable, and mark the genuinely runtime parts as unresolved instead of guessing.
No Python toolchain required
The Python layer is parsed through tree-sitter shipped as WASM, so the scanner runs on a machine that has Node but no Python interpreter, no virtualenv, and no installed dependencies. It does not import your project, run Django, or execute migrations. That is a safety property as much as a convenience: the analysis cannot trigger import side effects, and it works on a checkout the way CI sees it.
FastAPI: annotations, dependencies, and aliases
For FastAPI the route graph comes from path operations and the schemas from annotated models and parameters. The constructs that matter in real apps are all static once the parser follows imports:
from typing import Annotated
from fastapi import APIRouter, Depends, Path, Query
from pydantic import BaseModel, EmailStr
router = APIRouter(prefix="/orgs/{org_id}", tags=["members"])
class MemberCreate(BaseModel):
email: EmailStr
role: str = "member"
metadata: dict[str, str] | None = None
class MemberOut(BaseModel):
id: int
email: str
role: str
async def get_org(org_id: int) -> dict:
...
@router.post("/members", response_model=MemberOut, status_code=201)
async def create_member(
org: Annotated[dict, Depends(get_org)],
org_id: Annotated[int, Path(alias="org_id")],
body: MemberCreate,
invite: Annotated[bool | None, Query()] = None,
):
...The scanner resolves the path parameter from the Annotated[..., Path(...)] declaration including an explicit alias, the dependency call from Depends(get_org), the request body from the MemberCreate model, the optional invite query parameter, and the 201 response from MemberOut. SQLModel tables are read the same way, since their fields are declared as typed model attributes. Named models become reusable components rather than inlined anonymous shapes.
DRF: viewsets, routers, and serializers
DRF routes are rarely a flat list of decorators. The common shape is a ViewSet registered with a router, plus custom actions:
from rest_framework import serializers, viewsets
from rest_framework.decorators import action
from rest_framework.response import Response
class TicketSerializer(serializers.ModelSerializer):
assignee_name = serializers.CharField(source="assignee.profile.display_name", read_only=True)
priority_label = serializers.SerializerMethodField()
class Meta:
model = Ticket
fields = ["id", "title", "status", "assignee", "assignee_name", "priority_label"]
def get_priority_label(self, obj):
return dict(obj.PRIORITY_CHOICES).get(obj.priority, obj.priority)
class TicketViewSet(viewsets.ModelViewSet):
serializer_class = TicketSerializer
queryset = Ticket.objects.all()
@action(detail=True, methods=["post"])
def escalate(self, request, pk=None):
ticket = self.get_object()
ticket.escalate()
return Response(self.get_serializer(ticket).data)The scanner follows the router registration to recover the standard list, create, retrieve, update, partial_update, and destroy operations, and reads @action methods such as escalate as additional routes. Function-based views and plain class-based views are traced as well. Serializer field declarations drive request and response properties, including read-only and write-only fields, which is essential because the same serializer describes different shapes on the way in and the way out.
The dynamic parts must stay honest
This is where a tool that wants to look smart becomes a liar. Several DRF constructs are not knowable from source alone:
- External models. Fields inherited from a model defined outside the scanned tree, such as Django's built-in
auth.User, have properties that are not visible without resolving that dependency. sourcemappings. A field likeassignee_namewithsource="assignee.profile.display_name"crosses relationships and may be nullable in ways the declaration does not state.SerializerMethodFieldand computed fields.get_priority_labelruns arbitrary Python; its return shape is not declared.- Dynamic choices. An enum built at runtime, from a function, a database table, or settings, has no static value list.
For each of these the correct output is an explicit gap, body-schema-unknown or response-schema-unknown, with the construct that caused it, not a fabricated enum or an expanded database entity. Dumping the entire underlying model into the response is the complementary failure: it leaks columns the endpoint never returns. The safe rule is to emit exactly what the serializer proves and mark the rest unresolved.
One serializer, four different responses
DRF actions do not share one response shape, and a scanner has to keep them separate:
| Operation | Request | Success response |
|---|---|---|
create | Writable serializer fields | 201 with the created object, including read-only fields |
partial_update (PATCH) | A subset of writable fields | 200 with the full object |
list | Filter and pagination query params | A paginated array envelope |
retrieve | Path id only | A single object |
Read-only fields such as id and assignee_name appear in responses but not in the request body; write-only fields do the reverse. Pagination wrappers differ between PageNumber and LimitOffset configurations. Collapsing all four actions into one generic schema is how generated docs send consumers to the wrong field set.
When static proof runs out, run the contract
Static analysis is the fastest and most repeatable source of truth, but it is not the final authority on runtime behavior. For a serializer field that depends on a method or choices built from the database, a real request against a known fixture decides: send a missing value, an empty string, an out-of-range value, and a valid one, and record what the endpoint actually accepts and returns. The optional AI gap resolver can propose a schema for the specific handler slice that could not be proven, clamped to a safe JSON Schema subset and shown for a human to accept, edit, or reject. It never invents a route, and a proposal that cannot be verified stays flagged rather than being promoted to a confident contract.
Running it on a Python project
import { scanProject } from "@powerduck/code-to-openapi";
const result = await scanProject({
root: "./backend",
frameworks: ["fastapi"], // or omit to auto-detect Django REST, Flask, and Starlette
});
const { document, documentValid, diagnostics } = await result.convert();
console.log("valid:", documentValid);
console.log("unresolved:", diagnostics.length);The output is a validated OpenAPI 3.2 document with proven routes and schemas, plus a gap report that names the exact dynamic constructs a human or a test should confirm. You can see the same reviewable workflow in the online demo, and the cross-language framework coverage and the three extraction methods are compared in the code-to-OpenAPI overview.