Every handler receives aRequestand returns a value that Chirp turns into a
response.Requestis a frozen, read-only snapshot of the incoming HTTP request
-- method, path, headers, cookies, query string, and an async-readable body.
When you need to control the raw response (status, headers, cookies, redirects),
you build aResponse through chainable .with_*()transformations; each call
returns a new immutable copy.
Most handlers never construct aResponsedirectly. They return a
return type likeTemplateor
Fragment and let Chirp negotiate the response. Reach for Requestand
Responsewhen you need the raw HTTP details.
Request
Requestis a frozen dataclass -- a snapshot of what arrived. The data does not
change under you mid-handler, and the object is honest about that.
@app.route("/search")
async def search(request: Request):
q = request.query.get("q", "")
lang = request.headers.get("Accept-Language", "en")
session = request.cookies.get("session_id")
return Template("search.html", q=q)
Properties
| Property | Type | Description |
|---|---|---|
method |
str |
HTTP method (GET, POST, etc.) |
path |
str |
Request path (/search) |
query |
QueryParams |
Query string parameters |
headers |
Headers |
Immutable request headers |
cookies |
dict[str, str] |
Parsed cookies |
content_type |
str | None |
Content-Type header value |
Async body access
The body is read asynchronously -- it may not have arrived yet. Pick the access method that matches your content type; they all read the same underlying body.
async def upload(request: Request):
body_bytes = await request.body()async def note(request: Request):
text = await request.text()async def api(request: Request):
data = await request.json()async def submit(request: Request):
form = await request.form()async def ingest(request: Request):
async for chunk in request.stream():
process(chunk)htmx detection
When a request comes from htmx, you can branch on it. The richest surface is the
typedrequest.htmx namespace (HtmxDetails),
which is truthy when theHX-Requestheader is present:
@app.route("/results")
def results(request: Request):
if request.htmx: # any htmx request
target = request.htmx.target # HX-Target value (e.g. "#results")
trigger = request.htmx.trigger
...
For most handlers you do not need to branch by hand at all -- return a Page and Chirp negotiates a fragment for htmx and a full page for browsers. Branch manually only when the two paths truly differ.
Two convenience properties cover the common questions:
| Property | True when |
|---|---|
request.is_htmx |
Any htmx request (HX-Requestpresent) |
request.is_narrow_fragment |
A narrow swap -- excludes boosted navigations and history restores, which still need full page content |
QueryParams and Headers
BothQueryParams and Headers implement the MultiValueMappingprotocol --
the same key can carry multiple values:
# First value
q = request.query.get("q", "")
# All values for a key
tags = request.query.get_list("tag") # ["python", "web"]
# Typed coercion (None if missing or unparseable)
page = request.query.get_int("page", 1)
debug = request.query.get_bool("debug", False)
# Existence
if "q" in request.query:
...
Response
Responses are built through chainable transformations. Each.with_*()returns
a newResponse:
return (
Response("Created")
.with_status(201)
.with_header("Location", "/users/42")
.with_cookie("session", token)
)
Chainable methods
| Method | Description |
|---|---|
.with_status(code) |
Set status code |
.with_header(name, value) |
Add a header |
.with_headers(dict) |
Add multiple headers |
.with_content_type(type) |
Set Content-Type |
.with_cookie(name, value, **opts) |
Set a cookie |
.without_cookie(name) |
Delete a cookie |
.with_header() and .with_headers()append -- they do not replace an
existing header of the same name.
Why responses are immutable
Each.with_*() call creates a new Response; the original is never mutated:
base = Response("OK")
with_header = base.with_header("X-Custom", "value")
# base is unchanged
# with_header is a new Response with the added header
Immutability makes responses safe to pass through [response-transforming middleware] without one layer accidentally clobbering another's headers.
htmx response headers
Chirp provides htmx-specific response methods:
return (
Response("OK")
.with_hx_location("/new-page") # HX-Location
.with_hx_trigger("item-added") # HX-Trigger
.with_hx_trigger_after_settle("refresh-count") # HX-Trigger-After-Settle
)
Redirects
Redirect is the plain HTTP redirect, defaulting to status 302:
from chirp import Redirect
@app.route("/old")
def old():
return Redirect("/new")
For a redirect that must work for both htmx and plain requests, use
hx_redirect() (status 303by default):
from chirp import hx_redirect
@app.route("/items/{id:int}/archive", methods=["POST"])
def archive_item(id: int):
archive(id)
return hx_redirect("/items")