BuildHTTP Frameworks
Python HTTP Frameworks
Use framework middleware to run the same hello/echo contract through a local Chio sidecar in FastAPI or Django.
What it shows
chio-asgias a reusable ASGI middleware. That is the only Chio packageexamples/hello-fastapi/app.pyimports;chio-fastapi, which adds framework-native receipt access, is a declared dependency (pyproject.toml:11) that the example does not call.chio-django(ChioDjangoMiddleware) for standard Django request/response middleware.- Both call a local Chio sidecar over
/chio/evaluate; the smoke flow lists persisted receipts from the sidecar SQLite store. - Request bodies remain readable by the application after the middleware hashes them.
SDK shape
chio api protect sidecar. That keeps the Python footprint small and avoids any FFI build step. See HTTP Framework Middleware for the deeper pattern.Use this when
request.state.chio_receipt for FastAPI; request.chio_receipt for Django). For a reverse proxy in front of an unmodified service, read Protect an API and run chio api protect in front of an unmodified Python service. For Node see Node HTTP Frameworks; for Spring or ASP.NET see JVM and .NET.Prerequisites
- Python 3.11+ and
uv. - A checkout of the upstream repo, plus either a Rust toolchain or
CHIO_BINpointed at a chio binary you already built. The smokes never look onPATH:ensure_chio_bin()takes$CHIO_BINwhen it is set and executable, otherwisetarget/debug/chio, and runscargo build --bin chiowhen that is missing (examples/_shared/hello-http-common.sh:81-96). A globally installedchiowith no checkout satisfies neither smoke. - The smoke flows stand up
chio trust serveandchio api protectfor you.
The packages, if you are wiring your own service rather than running the examples:
# FastAPI, Starlette, or any ASGI app
pip install chio-asgi chio-fastapi chio-sdk-python
# Django
pip install chio-django chio-sdk-pythonThose are the dependency lists from examples/hello-fastapi/pyproject.toml and examples/hello-django/pyproject.toml, minus the web framework itself. The distribution chio-sdk-python installs the module chio_sdk, which holds the client and the capability models the middlewares share.
Run them
cd examples/hello-fastapi # or hello-django
./run.sh
# In another shell, run the full sidecar + trust + deny + allow smoke
./smoke.sh$ ./smoke.sh # last five lineshello-fastapi smoke passed artifacts: <chio-source>/examples/hello-fastapi/.artifacts/20260905T114903Z hello receipt: 1dba0f38326d8eaba47d2b8e50a54fdeb0da056639cbaa20fca0100e0dc67f64 deny receipt: 5d31d1463fe3819b3a5ffae0fb89e01c92339c3e4da455e6701511509690aaa3 allow receipt: 4c1415e347a6235dd9a409b2d391711111097876c141990cb5080c41b74008b3
One receipt id per call, including the refused one, and the run directory those artifacts landed in. Every command on this page below this point runs against that directory.
Default ports:
| Example | Env var | Default |
|---|---|---|
hello-fastapi | HELLO_FASTAPI_PORT | 8011 |
hello-django | HELLO_DJANGO_PORT | 8016 |
FastAPI (ASGI)
FastAPI runs under an ASGI server (uvicorn in this example). The middleware is registered as part of app construction; FastAPI applies it before route handlers run.
Before
from fastapi import FastAPI
from pydantic import BaseModel
class EchoRequest(BaseModel):
message: str
count: int = 1
app = FastAPI(title="hello-fastapi", version="0.1.0")
@app.get("/hello")
async def hello() -> dict[str, str]:
return {"message": "hello from fastapi"}
@app.post("/echo")
async def echo(payload: EchoRequest) -> dict[str, object]:
return {"message": payload.message, "count": payload.count}After
from __future__ import annotations
import os
from fastapi import FastAPI
from pydantic import BaseModel, ConfigDict, Field, StrictInt, StrictStr
from chio_asgi import ChioASGIMiddleware
from chio_asgi.config import ChioASGIConfig
class EchoRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
message: StrictStr = Field(min_length=1)
count: StrictInt = Field(default=1, ge=1)
def build_chio_config(sidecar_url: str | None = None) -> ChioASGIConfig:
return ChioASGIConfig(
sidecar_url=sidecar_url
or os.environ.get("CHIO_SIDECAR_URL", "http://127.0.0.1:9090"),
exclude_paths=frozenset({"/healthz"}),
)
def create_app(
*,
enable_chio: bool = True,
chio_config: ChioASGIConfig | None = None,
) -> FastAPI:
app = FastAPI(
title="hello-fastapi",
version="0.1.0",
docs_url=None,
redoc_url=None,
openapi_url=None,
)
if enable_chio:
app.add_middleware(
ChioASGIMiddleware,
config=chio_config or build_chio_config(),
)
@app.get("/healthz")
async def healthz() -> dict[str, str]:
return {"status": "ok"}
@app.get("/hello")
async def hello() -> dict[str, str]:
return {"message": "hello from fastapi"}
@app.post("/echo")
async def echo(payload: EchoRequest) -> dict[str, object]:
return {
"message": payload.message,
"count": payload.count,
}
return app
app = create_app()The ASGI middleware reads the request stream once, hashes the body, then replays it for FastAPI's body parser. Pydantic models receive the same bytes the sidecar evaluated, and the receipt id arrives as a response header.
Two things in the real file are the example's own choices rather than anything Chio requires, and both are worth copying. The strict EchoRequest (extra="forbid", StrictStr with min_length=1, StrictInt with ge=1) makes the app answer 422 for an unknown field, an empty message, a string "2", or count: 0, which the example's own tests pin (examples/hello-fastapi/test_app.py:41-62), so a malformed request is refused by the app rather than mediated as a valid one. And create_app takes enable_chio so that suite can build the same app with the middleware off (test_app.py:14).
./run.sh
# starts uvicorn on 127.0.0.1:8011 against app:appDjango (WSGI)
Django uses a synchronous middleware protocol. Registration is one string in MIDDLEWARE plus a few CHIO_* settings. The middleware decorates the request with request.chio_receipt.
Settings
MIDDLEWARE = [
"chio_django.ChioDjangoMiddleware",
]
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": BASE_DIR / "db.sqlite3",
}
}
USE_TZ = True
TIME_ZONE = "UTC"
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
CHIO_SIDECAR_URL = os.environ.get("CHIO_SIDECAR_URL", "http://127.0.0.1:9090")
CHIO_FAIL_OPEN = False
CHIO_EXCLUDE_PATHS = ["/healthz"]
CHIO_EXCLUDE_METHODS = ["OPTIONS"]
CHIO_RECEIPT_HEADER = "X-Chio-Receipt"Four of those five settings do what they say. CHIO_FAIL_OPEN does not: the middleware never reads it. Its only appearance in the package source is the docstring line CHIO_FAIL_OPEN: legacy compatibility setting; outages fail closed (sdks/python/chio-django/src/chio_django/middleware.py:91), and arc's own test for it is named test_fail_open_setting_still_fails_closed_when_sidecar_unavailable (tests/test_middleware.py:185-202). Setting it to True changes nothing. An unreachable sidecar is not a deny either: it is a 503 carrying CHIO_SIDECAR_UNAVAILABLE (middleware.py:199-208). The SDK's own README.md still documents the old fail-open behavior, so read the middleware rather than the README on this one.
URLs
from __future__ import annotations
from django.urls import path
from hello_app import views
urlpatterns = [
path("healthz", views.healthz),
path("hello", views.hello),
path("echo", views.echo),
]Views
from __future__ import annotations
import json
from dataclasses import dataclass
from django.http import HttpRequest, JsonResponse
@dataclass(frozen=True)
class EchoPayload:
message: str
count: int
class EchoPayloadError(ValueError):
pass
def _parse_echo_payload(body: bytes) -> EchoPayload:
try:
payload = json.loads(body.decode("utf-8") or "{}")
except json.JSONDecodeError as exc:
raise EchoPayloadError(str(exc)) from exc
if not isinstance(payload, dict):
raise EchoPayloadError("body must be a JSON object")
allowed_keys = {"message", "count"}
extra_keys = sorted(set(payload) - allowed_keys)
if extra_keys:
raise EchoPayloadError(f"unexpected fields: {', '.join(extra_keys)}")
message = payload.get("message")
if not isinstance(message, str) or not message:
raise EchoPayloadError("message must be a non-empty string")
count = payload.get("count", 1)
if isinstance(count, bool) or not isinstance(count, int) or count < 1:
raise EchoPayloadError("count must be an integer greater than or equal to 1")
return EchoPayload(message=message, count=count)
def _receipt_id(request: HttpRequest) -> str | None:
receipt = getattr(request, "chio_receipt", None)
if isinstance(receipt, dict):
return receipt.get("id")
return None
def healthz(_request: HttpRequest) -> JsonResponse:
return JsonResponse({"status": "ok"})
def hello(request: HttpRequest) -> JsonResponse:
return JsonResponse(
{
"message": "hello from django",
"receipt_id": _receipt_id(request),
}
)
def echo(request: HttpRequest) -> JsonResponse:
try:
payload = _parse_echo_payload(request.body)
except EchoPayloadError as exc:
return JsonResponse({"error": str(exc)}, status=400)
return JsonResponse(
{
"message": payload.message,
"count": payload.count,
"receipt_id": _receipt_id(request),
"body_cached": bool(request.body),
}
)request.body is still readable inside the view even though the middleware already hashed it, so body_cached is True on every allowed POST /echo. The caching is Django's, not Chio's: the middleware only reads request.body (chio_django/middleware.py:148-151), and Django's own HttpRequest.body property memoises the bytes the first time anything touches it. The outcome the example asserts is right; the mechanism belongs to the framework.
The view is stricter than a bare json.loads. _parse_echo_payload rejects a non-object body, any key outside message and count, a non-string or empty message, and a bool, non-integer or < 1 count, and returns 400 with {"error": <message>} for each. That is the WSGI counterpart of the FastAPI example's 422, written by hand because Django has no request model.
Where the receipt id lands
Both middlewares attach the kernel verdict to the request and add a response header for the client.
async def get_chio_receipt(request: Request) -> HttpReceipt | None:
"""FastAPI dependency that retrieves the Chio receipt from the request state.
The receipt is attached by the Chio middleware or decorators. Returns None
if no receipt is available.
"""
return getattr(request.state, "chio_receipt", None)FastAPI handlers reach for request.state.chio_receipt directly, or use Depends(get_chio_receipt). Django handlers read getattr(request, "chio_receipt", None).
Both responses carry the receipt id in X-Chio-Receipt. That is the Python spelling, and it is the default on both sides: ChioASGIConfig.receipt_header (sdks/python/chio-asgi/src/chio_asgi/config.py:35) and the Django CHIO_RECEIPT_HEADER fallback (sdks/python/chio-django/src/chio_django/middleware.py:110-111). The ASGI middleware lowercases it on the wire (chio_asgi/middleware.py:259), which is why a curl -D dump reads x-chio-receipt.
The other stacks spell it differently
X-Chio-Receipt-Id instead (sdks/typescript/packages/node-http/src/interceptor.ts:374, sdks/go/chio-go-http/chio.go:134, sdks/cpp/chio-drogon/src/drogon.cpp:25, sdks/dotnet/ChioMiddleware/src/ChioMiddlewareExtensions.cs:210, sdks/jvm/chio-spring-boot/src/main/kotlin/world/chio/ChioFilter.kt:188). Both spellings are real. Grep for the one your stack emits rather than the one a sibling page shows.Smoke assertions
Each smoke.sh spins up a trust service, the app, and the sidecar, then runs the same three curls and checks each response in a heredoc. Here is that whole sequence from the FastAPI example, verbatim, including how the capability the third curl carries gets minted:
curl -sS -D "${ARTIFACT_ROOT}/hello.headers" "${APP_URL}/hello" > "${ARTIFACT_ROOT}/hello.json"
python3 - "${ARTIFACT_ROOT}/hello.json" <<'PY'
import json
import sys
from pathlib import Path
body = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
assert body["message"] == "hello from fastapi", body
PY
curl -sS -D "${ARTIFACT_ROOT}/deny.headers" \
-H "content-type: application/json" \
--data '{"message":"denied","count":1}' \
"${APP_URL}/echo" \
> "${ARTIFACT_ROOT}/deny.json"
python3 - "${ARTIFACT_ROOT}/deny.json" <<'PY'
import json
import sys
from pathlib import Path
body = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
assert body["status"] == 403, body
assert body["message"], body
PY
issue_demo_capability \
"${CONTROL_URL}" \
"${SERVICE_TOKEN}" \
"${ARTIFACT_ROOT}/capability.json" \
"authorize_http_request" \
"chio_http_authority"
materialize_capability_token "${ARTIFACT_ROOT}/capability.json" "${ARTIFACT_ROOT}/capability.token"
curl -sS -D "${ARTIFACT_ROOT}/allow.headers" \
-H "content-type: application/json" \
-H "X-Chio-Capability: $(tr -d '\n' < "${ARTIFACT_ROOT}/capability.token")" \
--data '{"message":"hello","count":2}' \
"${APP_URL}/echo" \
> "${ARTIFACT_ROOT}/allow.json"
python3 - "${ARTIFACT_ROOT}/allow.json" <<'PY'
import json
import sys
from pathlib import Path
body = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
assert body["message"] == "hello", body
assert body["count"] == 2, bodyThree assertions decide the run: the read returns hello from fastapi, the capability-less write comes back with status 403 and a non-empty message, and the write carrying X-Chio-Capability echoes hello and count 2. The capability header is raw JSON, not an encoded token: materialize_capability_token writes the minted token straight into capability.token and tr -d '\n' flattens it onto the header line.
Django runs the same shape and asserts more, because its views return the receipt id in the body:
curl -sS -D "${ARTIFACT_ROOT}/hello.headers" "${APP_URL}/hello" > "${ARTIFACT_ROOT}/hello.json"
python3 - "${ARTIFACT_ROOT}/hello.json" <<'PY'
import json
import sys
from pathlib import Path
body = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
assert body["message"] == "hello from django", body
assert body["receipt_id"], body
PY
curl -sS -D "${ARTIFACT_ROOT}/deny.headers" \
-H "content-type: application/json" \
--data '{"message":"denied","count":1}' \
"${APP_URL}/echo" \
> "${ARTIFACT_ROOT}/deny.json"
python3 - "${ARTIFACT_ROOT}/deny.json" <<'PY'
import json
import sys
from pathlib import Path
body = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
assert body["error"]["code"] == "CHIO_GUARD_DENIED", body
assert body["error"]["message"], body
PY
grep -q " 403 " "${ARTIFACT_ROOT}/deny.headers"
issue_demo_capability \
"${CONTROL_URL}" \
"${SERVICE_TOKEN}" \
"${ARTIFACT_ROOT}/capability.json" \
"authorize_http_request" \
"chio_http_authority"
materialize_capability_token "${ARTIFACT_ROOT}/capability.json" "${ARTIFACT_ROOT}/capability.token"
curl -sS -D "${ARTIFACT_ROOT}/allow.headers" \
-H "content-type: application/json" \
-H "X-Chio-Capability: $(tr -d '\n' < "${ARTIFACT_ROOT}/capability.token")" \
--data '{"message":"hello","count":2}' \
"${APP_URL}/echo" \
> "${ARTIFACT_ROOT}/allow.json"
python3 - "${ARTIFACT_ROOT}/allow.json" <<'PY'
import json
import sys
from pathlib import Path
body = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
assert body["message"] == "hello", body
assert body["count"] == 2, body
assert body["receipt_id"], body
assert body["body_cached"] is True, bodyThe extra four are receipt_id non-empty on the read and on the allowed write, error.code == CHIO_GUARD_DENIED on the deny, and body_cached is True. The deny is checked twice, once on the body and once on the status line with grep -q " 403 " against the header dump.
Inspect after
Run output is written under .artifacts/<UTC-timestamp>/. Everything below is one real run of examples/hello-fastapi/smoke.sh, captured from its newest artifact directory. The ids move every run; the counts do not.
The read carries its receipt id in the response header:
$ cat hello.headers hello.jsonHTTP/1.1 200 OK
date: Sat, 05 Sep 2026 11:49:07 GMT
server: uvicorn
content-length: 32
content-type: application/json
x-chio-receipt: 1dba0f38326d8eaba47d2b8e50a54fdeb0da056639cbaa20fca0100e0dc67f64
{"message":"hello from fastapi"}The sidecar persists one receipt per request, refusals included:
$ wc -l receipts.ndjson && jq -r '.id, .verdict.verdict' receipts.ndjson3 receipts.ndjson 1dba0f38326d8eaba47d2b8e50a54fdeb0da056639cbaa20fca0100e0dc67f64 allow 5d31d1463fe3819b3a5ffae0fb89e01c92339c3e4da455e6701511509690aaa3 deny 4c1415e347a6235dd9a409b2d391711111097876c141990cb5080c41b74008b3 allow
$ jq -r '.verdict.verdict' receipts.ndjson | sort | uniq -c 2 allow
1 denyThree receipts, two allows and one deny, and the ids line up with the headers: the first is the GET /hello above, the second is the refused POST /echo, the third is the one that carried a capability. The same rows sit in the sidecar's SQLite store, in the http_receipts table it creates with columns id and receipt_json (crates/products/chio-api-protect/src/proxy/state.rs:144), so if you have the sqlite3 CLI:
sqlite3 state/sidecar-receipts.sqlite3 \
"select id, json_extract(receipt_json, '$.verdict.verdict') from http_receipts order by rowid desc limit 5;"That one is not captured here: the machine these transcripts were run on has no sqlite3 binary. The NDJSON dump above is the same data and needs nothing extra.
The denied call
The refusal never reaches the FastAPI handler. The sidecar answers with 403 and a body naming the guard that refused, and it still sets the receipt header:
$ cat deny.headers deny.jsonHTTP/1.1 403 Forbidden
date: Sat, 05 Sep 2026 11:49:08 GMT
server: uvicorn
content-type: application/json
content-length: 103
x-chio-receipt: 5d31d1463fe3819b3a5ffae0fb89e01c92339c3e4da455e6701511509690aaa3
{"error": "CapabilityGuard", "message": "side-effect route requires a capability token", "status": 403}The receipt for that call is the deny row above, so the refusal is queryable after the fact on the same footing as the two allows.
ASGI vs WSGI
| Aspect | FastAPI / ASGI | Django / WSGI |
|---|---|---|
| Middleware protocol | ASGI scope/receive/send | Sync request/response callable |
| SDK package | chio-asgi (+ chio-fastapi) | chio-django |
| Receipt access | Response header (and FastAPI helpers) | request.chio_receipt + header |
| Body replay | Wraps receive to replay bytes | Caches request.body |
| Async | Native | Sync; use ASGI Django for async views |
Next
- Python SDK reference
- HTTP Framework Middleware
- Protect an API: the zero-code sidecar alternative.
- Node HTTP Frameworks, JVM and .NET, Go and C++
- One contract, every stack: the registration line for all six languages, side by side, plus the policy and the sidecar command they share.
- Examples Overview