Skip to content

Commit 686e5fb

Browse files
Berik AshimovBerik Ashimov
authored andcommitted
docs(audit): DX comparison HawkAPI vs FastAPI with top-5 gaps
Feature-parity matrix across routing, DI, security, responses, OpenAPI, middleware, testing, WebSockets, forms, and docs. Identifies five prioritized gaps (yield-deps, route-level dependencies, response_model_exclude_*, OAuth2 scopes, status constants) with effort estimates and follow-up order.
1 parent 970b1af commit 686e5fb

1 file changed

Lines changed: 374 additions & 0 deletions

File tree

Lines changed: 374 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,374 @@
1+
# HawkAPI DX audit vs FastAPI
2+
3+
**Date:** 2026-04-18
4+
**Scope:** Feature-parity snapshot of HawkAPI vs FastAPI. Research-only; no code changes here.
5+
**Spec:** [docs/plans/2026-04-18-dx-audit-design.md](../plans/2026-04-18-dx-audit-design.md)
6+
7+
---
8+
9+
## Executive summary
10+
11+
HawkAPI currently **matches or exceeds FastAPI on ~85 % of the tutorial-level DX surface**, and meaningfully exceeds on differentiators FastAPI does not ship at all (API versioning, permission policies, built-in bulkhead/circuit-breaker/rate-limiter, observability, migration codemod, PEP 703 wheels).
12+
13+
The five gaps below are the ones that hurt migration from a real FastAPI codebase the most — not because they are large engineering tasks, but because they are everywhere in FastAPI tutorials and copy-pasted into real production code:
14+
15+
| # | Gap | Severity | Effort |
16+
|---|---|---|---|
17+
| 1 | **Yield-dependencies with per-request finalization** | Critical | M |
18+
| 2 | **Route-level `dependencies=[Depends(...)]`** on decorators | Important | S |
19+
| 3 | **`response_model_exclude_none/unset/defaults`** flags | Important | S |
20+
| 4 | **OAuth2 scopes enforcement + OpenAPI reflection** | Important | M |
21+
| 5 | **`status` module with HTTP_NNN constants** | Minor (cosmetic) | XS |
22+
23+
Everything else is either already present or a known out-of-scope differentiator. The non-gap surplus (built-in versioning, bulkhead, observability, codemod, etc.) is strong; the five items above close the last mile of "FastAPI users land on HawkAPI and nothing is missing."
24+
25+
---
26+
27+
## Feature-parity matrix
28+
29+
Legend: ✅ full, ⚠️ partial, ❌ missing.
30+
31+
### Routing & path operations
32+
33+
| FastAPI feature | HawkAPI | Status | Notes |
34+
|---|---|---|---|
35+
| `@app.get/post/put/patch/delete/head/options` | [src/hawkapi/routing/router.py](../../src/hawkapi/routing/router.py) || |
36+
| `APIRouter` with `prefix=`, `tags=`, `dependencies=` | `Router` class + `include_router` | ⚠️ | `prefix`/`tags` supported; `dependencies=` on routers not wired |
37+
| Typed path params `{id}` with `int`/`str`/... inference | [routing/_radix_tree.py](../../src/hawkapi/routing/_radix_tree.py) || `/items/{id:int}` |
38+
| Route-level `tags=`, `summary=`, `description=` | [routing/route.py](../../src/hawkapi/routing/route.py) || |
39+
| Route-level `response_model=` | Router.add_route || |
40+
| Route-level `status_code=` | Router.add_route || |
41+
| Route-level `include_in_schema=False` | Router.add_route || |
42+
| Route-level `dependencies=[...]` (side-effect deps) ||| Must use app-level hooks or middleware today (**Gap #2**) |
43+
| `include_router(responses=...)` default-response map ||| Not supported |
44+
| Sub-app mount `app.mount("/x", subapp)` | app.py || |
45+
46+
### Parameters (path / query / header / cookie / body)
47+
48+
| FastAPI feature | HawkAPI | Status | Notes |
49+
|---|---|---|---|
50+
| `Query()` marker (alias, validation) | [validation/constraints.py](../../src/hawkapi/validation/constraints.py) || Via `Annotated[T, Query(...)]` |
51+
| `Path()` marker | validation/constraints.py || |
52+
| `Header()` marker with `_``-` auto-conversion | validation/constraints.py + di/param_plan.py || |
53+
| `Cookie()` marker | validation/constraints.py || |
54+
| `Body()` marker | validation/constraints.py || |
55+
| `Annotated[T, Query(...)]` form | di/param_plan.py || First-class |
56+
| `Form()` marker || ⚠️ | Forms are parsed when `FormData` is declared, but no explicit `Form()` class for per-field validation |
57+
| `File()` / `UploadFile` | [requests/form_data.py](../../src/hawkapi/requests/form_data.py) || `.read()`, `.seek()`, `.close()` |
58+
| Multiple body params in one handler | di/param_plan.py || |
59+
60+
### Dependency injection
61+
62+
| FastAPI feature | HawkAPI | Status | Notes |
63+
|---|---|---|---|
64+
| `Depends(callable)` | [src/hawkapi/di/depends.py](../../src/hawkapi/di/depends.py) || |
65+
| Sub-dependencies (transitive) | di/param_plan.py || Resolved recursively |
66+
| `yield` dependencies with teardown after response || ⚠️ | App-level `lifespan` only; no per-request yield-dep (**Gap #1**) |
67+
| Class-callable as dependency | di/param_plan.py || |
68+
| Path-operation-level `dependencies=[...]` ||| Requires middleware today (**Gap #2**) |
69+
| Global (app-level) dependencies ||| Workaround: middleware |
70+
| Within-request caching of same `Depends(fn)` | di/scope.py || Scope-level caching |
71+
| `dependency_overrides` for tests | [testing/overrides.py](../../src/hawkapi/testing/overrides.py) || `override()` context manager |
72+
73+
### Security
74+
75+
| FastAPI feature | HawkAPI | Status | Notes |
76+
|---|---|---|---|
77+
| `OAuth2PasswordBearer` | [security/oauth2.py](../../src/hawkapi/security/oauth2.py) || |
78+
| `OAuth2PasswordRequestForm` ||| Form helper class not shipped |
79+
| `HTTPBasic` / `HTTPBasicCredentials` | [security/http_basic.py](../../src/hawkapi/security/http_basic.py) || |
80+
| `APIKeyHeader` / `APIKeyQuery` / `APIKeyCookie` | [security/api_key.py](../../src/hawkapi/security/api_key.py) || |
81+
| OAuth2 scopes enforcement + OpenAPI reflection | security/oauth2.py || `scopes` placeholder present but not enforced (**Gap #4**) |
82+
| `SecurityScheme` propagation into OpenAPI | security/base.py || |
83+
84+
### Responses
85+
86+
| FastAPI feature | HawkAPI | Status | Notes |
87+
|---|---|---|---|
88+
| `JSONResponse` | responses/json_response.py || |
89+
| `HTMLResponse`, `PlainTextResponse`, `RedirectResponse`, `FileResponse`, `StreamingResponse` | `src/hawkapi/responses/` || |
90+
| Return `Response` directly from handler (bypass serialization) | responses/response.py || |
91+
| `response_model_exclude_none/unset/defaults` ||| Not wired (**Gap #3**) |
92+
| `jsonable_encoder` equivalent | [serialization/encoder.py](../../src/hawkapi/serialization/encoder.py) || `encode_response()` |
93+
| Content negotiation (Accept → JSON vs MessagePack) | serialization/negotiation.py || Exceeds FastAPI |
94+
95+
### Exception handling
96+
97+
| FastAPI feature | HawkAPI | Status | Notes |
98+
|---|---|---|---|
99+
| `HTTPException(status_code, detail, headers)` | [exceptions.py](../../src/hawkapi/exceptions.py) || Returns RFC 7807 `application/problem+json` |
100+
| `@app.exception_handler(Cls)` registration | app.py || |
101+
| Default `RequestValidationError` handler | validation/errors.py || RFC 9457 `ProblemDetail` |
102+
103+
### OpenAPI customization
104+
105+
| FastAPI feature | HawkAPI | Status | Notes |
106+
|---|---|---|---|
107+
| `title=`, `description=`, `version=` on constructor | app.py || |
108+
| `contact=`, `license_info=` on constructor || ⚠️ | Not surfaced |
109+
| `openapi_tags=[{name, description, externalDocs}, ...]` ||| Route-level tags only |
110+
| `servers=[{url, description}, ...]` ||| |
111+
| Per-route `openapi_extra={}` ||| No hook to inject arbitrary OpenAPI extensions per route |
112+
| Customizable `docs_url`, `redoc_url`, `openapi_url` (or `None` to disable) | app.py || |
113+
| Swagger UI / ReDoc / **Scalar** shipped | _docs.py || Scalar = exceeds FastAPI |
114+
115+
### Middleware & hooks
116+
117+
| FastAPI feature | HawkAPI | Status | Notes |
118+
|---|---|---|---|
119+
| `app.add_middleware(Cls, **kwargs)` | app.py || |
120+
| Raw ASGI middleware supported | middleware/base.py || |
121+
| Lifespan context manager (`@asynccontextmanager` on app) | lifespan/manager.py || |
122+
| Legacy `@app.on_event("startup"/"shutdown")` || ⚠️ | Have `on_startup()` / `on_shutdown()` decorators; not the `on_event()` string-discriminant form |
123+
124+
### Testing
125+
126+
| FastAPI feature | HawkAPI | Status | Notes |
127+
|---|---|---|---|
128+
| `TestClient(app)` (sync httpx-style) | testing/client.py || |
129+
| Async test helpers | testing/client.py || `async_get()` et al. |
130+
| DI override mechanism | testing/overrides.py || |
131+
| Lifespan fires in TestClient | testing/client.py || |
132+
133+
### Background tasks & lifespan
134+
135+
| FastAPI feature | HawkAPI | Status | Notes |
136+
|---|---|---|---|
137+
| `BackgroundTasks` injection + `add_task()` | [background.py](../../src/hawkapi/background.py) || |
138+
| Tasks run after response sent | app.py || |
139+
| Longer-running job primitive beyond per-request tasks ||| No built-in scheduler / worker (also not in FastAPI) |
140+
141+
### WebSockets
142+
143+
| FastAPI feature | HawkAPI | Status | Notes |
144+
|---|---|---|---|
145+
| `WebSocket` injected into handler | websocket/connection.py || |
146+
| `Depends(...)` inside WebSocket handlers | websocket | ⚠️ | Basic resolution; security schemes not enforced the same way as HTTP |
147+
| WebSocket routes visible in OpenAPI/AsyncAPI || ⚠️ | No first-class AsyncAPI story |
148+
149+
### Static files & templates
150+
151+
| FastAPI feature | HawkAPI | Status | Notes |
152+
|---|---|---|---|
153+
| `StaticFiles` mount | staticfiles.py || |
154+
| `Jinja2Templates` + `TemplateResponse` ||| Server-rendered apps need external integration |
155+
156+
### Forms & file uploads
157+
158+
| FastAPI feature | HawkAPI | Status | Notes |
159+
|---|---|---|---|
160+
| `application/x-www-form-urlencoded` auto-parsing | requests/form_data.py || Via `FormData` declaration |
161+
| `multipart/form-data` streaming `UploadFile` | requests/form_data.py || |
162+
163+
### Other DX conveniences
164+
165+
| FastAPI feature | HawkAPI | Status | Notes |
166+
|---|---|---|---|
167+
| `from fastapi import status``status.HTTP_201_CREATED` ||| No constants module (**Gap #5**) |
168+
| CORS middleware | middleware/cors.py || |
169+
| GZip middleware | middleware/gzip.py || |
170+
| Session (signed cookies) middleware | middleware/session.py || |
171+
| TrustedHost middleware | middleware/trusted_host.py || |
172+
| Official starter scaffold CLI | cli.py (`hawkapi new`) || Exceeds FastAPI |
173+
| FastAPI → framework migration codemod | _migrate/codemod.py || Exceeds FastAPI |
174+
175+
---
176+
177+
## Top-5 gaps (detailed)
178+
179+
### Gap #1 — Yield-dependencies with per-request finalization
180+
181+
**Severity:** Critical **Effort:** M
182+
183+
**What FastAPI has:**
184+
185+
```python
186+
async def get_db() -> AsyncIterator[AsyncSession]:
187+
async with SessionLocal() as session:
188+
yield session # returned to handler
189+
190+
@app.get("/users")
191+
async def list_users(db: AsyncSession = Depends(get_db)) -> list[User]:
192+
...
193+
# After response is sent, session context manager exits; rolls back or commits.
194+
```
195+
196+
This is THE pattern in FastAPI tutorials and production code for database sessions, HTTP clients, Redis connections, transactions. Every SQLAlchemy/Databases example depends on it.
197+
198+
**What HawkAPI has:** App-level `lifespan` context manager for long-lived resources. No per-request teardown.
199+
200+
**Work required:**
201+
- Extend `src/hawkapi/di/param_plan.py` to detect generator/async-generator dependencies.
202+
- Register each yield-dep on a per-request stack (push on entry, pop + run remaining code after the handler's response body is sent).
203+
- Ensure cancellation and exception paths run the finalizers.
204+
- Wire teardown to run AFTER response headers+body flushed (like FastAPI) so the client isn't blocked.
205+
- Test: yield that raises during teardown — how should the error be reported? (FastAPI: logs, ignores.)
206+
207+
**Payoff:** Single biggest migration-friction item for any FastAPI user with a database.
208+
209+
---
210+
211+
### Gap #2 — Route-level `dependencies=[Depends(...)]`
212+
213+
**Severity:** Important **Effort:** S
214+
215+
**What FastAPI has:**
216+
217+
```python
218+
@app.get("/admin/reports", dependencies=[Depends(require_admin)])
219+
async def reports() -> ...:
220+
...
221+
```
222+
223+
Used pervasively for auth guards, audit-log writers, rate-limit increments that don't need a return value. Also accepted on `APIRouter(dependencies=[...])` for whole-router guards.
224+
225+
**What HawkAPI has:** Workaround via app-level hooks or middleware. No decorator-level kwarg.
226+
227+
**Work required:**
228+
- Add `dependencies: Sequence[Depends] | None = None` kwarg to the route decorators in `src/hawkapi/routing/router.py`.
229+
- Add the same kwarg on the `Router` class constructor and `include_router` call; merge router-level + route-level lists.
230+
- On request: resolve each `Depends` before invoking the handler; any raised exception short-circuits (consistent with FastAPI behavior).
231+
- Make the results *not* injected into the handler's signature — they're executed for side effects only.
232+
- Tests: chain ordering, exceptions, interaction with yield-dependencies.
233+
234+
**Payoff:** Biggest ergonomic win for anyone who reads FastAPI auth examples and copy-pastes.
235+
236+
---
237+
238+
### Gap #3`response_model_exclude_none / _unset / _defaults`
239+
240+
**Severity:** Important **Effort:** S
241+
242+
**What FastAPI has:**
243+
244+
```python
245+
@app.get(
246+
"/items/{id}",
247+
response_model=Item,
248+
response_model_exclude_none=True, # drop keys whose value is None
249+
response_model_exclude_unset=True, # drop keys the user didn't set
250+
response_model_exclude_defaults=True, # drop keys equal to their default
251+
)
252+
```
253+
254+
Used for:
255+
- APIs where optional fields shouldn't serialize as `"field": null`
256+
- Versioned responses where fields were added later and old clients shouldn't see them
257+
- Admin vs public response shapes
258+
259+
**What HawkAPI has:** `response_model` accepted on routes; the three exclusion knobs are not wired through to the serializer.
260+
261+
**Work required:**
262+
- Plumb three flags from route metadata → `src/hawkapi/serialization/encoder.py`.
263+
- msgspec already supports field exclusion at encode time; map the flags onto its API.
264+
- Tests per flag + combinations.
265+
266+
**Payoff:** Low-effort closure of a feature 70 % of FastAPI response_model users eventually reach for.
267+
268+
---
269+
270+
### Gap #4 — OAuth2 scopes enforcement + OpenAPI reflection
271+
272+
**Severity:** Important **Effort:** M
273+
274+
**What FastAPI has:**
275+
276+
```python
277+
from fastapi import Security
278+
from fastapi.security import SecurityScopes
279+
280+
oauth2_scheme = OAuth2PasswordBearer(
281+
tokenUrl="token",
282+
scopes={"read:items": "Read items", "admin": "Admin"}
283+
)
284+
285+
async def get_current_user(
286+
scopes: SecurityScopes,
287+
token: str = Depends(oauth2_scheme),
288+
) -> User:
289+
# validate token, verify required scopes against user scopes
290+
...
291+
292+
@app.get("/items", dependencies=[Security(get_current_user, scopes=["read:items"])])
293+
async def list_items() -> ...:
294+
...
295+
```
296+
297+
- Scopes are declared in OpenAPI under `components.securitySchemes` with full descriptions.
298+
- Each route's required scopes appear in its `security` entry.
299+
- Framework enforces by collecting required scopes down the dep chain and passing them to the scheme's `authenticate()` call.
300+
301+
**What HawkAPI has:** `OAuth2PasswordBearer` exists with a `scopes` dict placeholder, but nothing enforces or propagates scopes.
302+
303+
**Work required:**
304+
- Add a `Security()` marker parallel to `Depends()` carrying `scopes: Sequence[str]`.
305+
- Track route-level required scopes; inject a `SecurityScopes`-like context into the scheme's callable.
306+
- Propagate `securitySchemes.oauth2.flows.password.scopes` into the OpenAPI document.
307+
- Annotate each route's OpenAPI `security` field with required scopes.
308+
- Tests: scope subset check; 403 on insufficient scope; multi-route aggregation.
309+
310+
**Payoff:** Largest "production OAuth2 users" gap. A real-world API without scopes rolled-your-own is unusual.
311+
312+
---
313+
314+
### Gap #5`status` module with HTTP_NNN constants
315+
316+
**Severity:** Minor (but high frequency) **Effort:** XS
317+
318+
**What FastAPI has:**
319+
320+
```python
321+
from fastapi import status
322+
323+
@app.post("/items", status_code=status.HTTP_201_CREATED)
324+
async def create_item(...): ...
325+
```
326+
327+
Pure cosmetic convenience re-exported from Starlette. Everyone uses it.
328+
329+
**What HawkAPI has:** Users hardcode `201`.
330+
331+
**Work required:**
332+
- Create `src/hawkapi/status.py` re-exporting the standard HTTP constants (can copy from Starlette's `starlette.status`, or use `http.HTTPStatus`'s integer values).
333+
- Export `status` from `hawkapi/__init__.py` so `from hawkapi import status` works.
334+
- One-line unit test.
335+
336+
**Payoff:** Removes a paper cut. A one-hour job that eliminates a reason someone says "HawkAPI is missing things FastAPI has."
337+
338+
---
339+
340+
## Where HawkAPI already exceeds FastAPI
341+
342+
Not gaps — differentiators we should not accidentally dilute while closing the gaps above:
343+
344+
- **API versioning**`VersionRouter` with per-version OpenAPI specs. FastAPI has no first-class story.
345+
- **Permission policies**`PermissionPolicy` with pluggable resolvers. FastAPI users hand-roll.
346+
- **Observability** — one-flag tracing, structured logs, Prometheus metrics out of the box.
347+
- **Bulkhead** (just shipped) + Redis-distributed variant.
348+
- **Circuit breaker** — local + Redis variant.
349+
- **Adaptive concurrency limiter** — Netflix gradient2 auto-tune.
350+
- **Rate limiter** — local + Redis variant.
351+
- **CSRF middleware** with double-submit cookie.
352+
- **Session middleware** with signed cookies.
353+
- **Content negotiation** — Accept-based JSON vs MessagePack serialization.
354+
- **Scalar UI** for API docs (in addition to Swagger + ReDoc).
355+
- **mypyc-compiled hot paths** — routing / route record / param converters / middleware pipeline.
356+
- **Free-threaded Python 3.13 wheels** (PEP 703).
357+
- **`hawkapi migrate` codemod** — FastAPI → HawkAPI AST rewriter.
358+
- **`hawkapi new` scaffold CLI** — project starter.
359+
- **Perf regression gate in CI** — 5 % mean threshold.
360+
- **Memory budget tests** — pytest-memray.
361+
362+
---
363+
364+
## Follow-ups (not this project)
365+
366+
Each of the top-5 gaps becomes its own design spec + implementation plan. Recommended order (smallest effort / highest ratio first, to accumulate shipped wins):
367+
368+
1. **Gap #5** (`status` constants) — afternoon of work.
369+
2. **Gap #3** (`response_model_exclude_*`) — one spec, small impl.
370+
3. **Gap #2** (`dependencies=[...]` kwarg) — clean decorator change.
371+
4. **Gap #1** (yield-dependencies) — the big one, touches DI core.
372+
5. **Gap #4** (OAuth2 scopes) — biggest surface area, last.
373+
374+
This order gives four visible DX-parity wins before the fifth (scopes) takes more effort. Each gets an independent cycle — spec → plan → implement — following the workflow used for Tier 1 and Tier 2.

0 commit comments

Comments
 (0)