Requests and responses¶
Use this when a handler needs the request's details, or must build a specific response.
What a handler gets¶
A handler's parameters say what it needs. Most handlers never touch the request itself:
import msgspec
from korvex import App
app = App()
class Task(msgspec.Struct):
id: int
title: str
@app.get("/tasks/{id:int}")
def get_task(id: int, verbose: bool = False) -> Task: # path, then query string
return Task(id=id, title="write docs")
When it does need it, it asks for it: a parameter named request, or
annotated Request.
from korvex import App, Request, Response
app = App()
@app.post("/echo")
async def echo(request: Request):
return Response(await request.body())
Validation lists everything a parameter can come from.
The request¶
| Attribute | |
|---|---|
method, path, scheme |
"GET", "/tasks/7", "https" |
headers |
headers.get("accept") the first value, headers.get_all("accept") every one; names in any case |
query |
the query string: query.get("page"), query.get_all("tag") |
cookies |
a dict |
path_params |
a dict |
root_path |
the prefix the app is mounted or served under, "" if none |
client |
(host, port) of the client (the real one behind a trusted proxy), or None |
app |
the App |
And three methods to read the body:
await request.body(): the bytes.await request.json(): parsed JSON (a 400 if it isn't).await request.form(): a form, urlencoded or multipart, read like the query (form.get("title")). Files areUploadedFiles, withfilename,content_typeanddata. A body that isn't a form gets a 415; text that can't be decoded, a 400.
A WebSocket has the same attributes but no body: both are a
Connection.
Responses¶
from korvex import Response
def chunks(): # your generator
yield "part one, "
yield b"part two"
Response("raw bytes or text", status=200, headers={"X-Total": "3"})
Response.text("hello")
Response.json({"id": 1})
Response.redirect("/login", status=303)
Response.stream(chunks) # a generator, sync or async, of bytes or str
Response.file("report.pdf", filename="report.pdf") # your file; read in chunks; `filename` makes it a download
A handler can also return a model (-> Task, sent as JSON) or None: see
Validation.
headers= takes a dict, or (name, value) pairs to repeat a name. A
Content-Type given there wins over the one text() or json() sets.
response.headers is a Headers, the same as request.headers: names
in any case, and a name can repeat (several Set-Cookie).
get(name), headers[name] |
the first value ([] raises KeyError if missing) |
get_all(name) |
every value |
set(name, value), headers[name] = value |
replaces every value |
add(name, value) |
adds one more |
remove(name) |
removes them all |
name in headers, for name, value in headers |
test, and go through the pairs |
Cookies¶
from korvex import Response
token = "abc123" # your value
response = Response.redirect("/")
response.set_cookie("session", token, max_age=3600, httponly=True, secure=True)
response.delete_cookie("theme")
samesite is "lax" unless you say otherwise. Without max_age, the
cookie lasts until the browser closes.
Streams and files¶
Response.stream() sends each chunk as it comes, to a slow client too:
once 1 MiB waits for it, the generator waits. Response.file() reads the
file in chunks, without blocking the server, and sets its type and
length.
from korvex import App, Request, Response
app = App()
@app.get("/video")
def video(request: Request):
return Response.file("movie.mp4", request=request)
With request=, the file gets an ETag, a Last-Modified and
Accept-Ranges, and the response follows the request: 304 if the browser
has the current file, 206 for a Range (resumable downloads, video
seeking), 416 for a range past the end. Without it, the whole file is
sent. A compress() hook leaves 206 and 304 alone and makes the ETag
weak when it compresses a file. app.static()
works this way.
Server-Sent Events¶
Server-Sent Events push a stream of events to a browser.
import asyncio
from korvex import App, Header, Response, ServerSentEvent
app = App()
@app.get("/feed")
async def feed(last_event_id: int = Header(0)):
async def events():
try:
for n in range(last_event_id + 1, 1000):
yield ServerSentEvent({"n": n}, event="tick", id=str(n))
await asyncio.sleep(1)
finally:
... # runs when the client leaves
return Response.sse(events(), ping=15.0)
A str is sent as is, anything else as JSON. In an async generator, a
: ping comment goes out after ping seconds of silence (15 by default,
ping=0 turns it off); a sync generator gets none. The response sets
Content-Type: text/event-stream, Cache-Control: no-cache and
X-Accel-Buffering: no (for nginx), and compress() leaves it alone.
When the client leaves, the generator is closed and its finally runs.
A browser that reconnects sends the last id in Last-Event-ID; read it
with Header(). A newline in event or id raises ValueError.
The event is encoded when you create it, so a bad value fails there:
bytes data raises TypeError; a lone surrogate or NaN raises
ValueError.
On the built-in server, a client that left is noticed at the next write.
Keep ping on (15 s by default): the generator is closed within ping
seconds. With ping=0 or a sync generator, only when the next event is
sent. Under ASGI servers it is noticed right away.
Background tasks¶
Ask for BackgroundTasks to run background tasks:
work that goes on after the response is sent.
import msgspec
from korvex import App, BackgroundTasks
app = App()
class NewUser(msgspec.Struct):
email: str
name: str
class User(msgspec.Struct):
email: str
name: str
def create_user(body: NewUser) -> User: ... # your code
def send_welcome_email(email: str, name: str): ... # your code, blocking smtplib
@app.post("/signup", status=201)
def signup(body: NewUser, tasks: BackgroundTasks) -> User:
user = create_user(body)
tasks.add(send_welcome_email, user.email, name=user.name)
return user
A handler and its dependencies share one object. Tasks run in the order added, after the response, the after-hooks, a stream's end and dependency teardown; also if the client left.
- A coroutine function is awaited; a plain function runs in a thread.
- An error is logged on the
"korvex"logger, the next task still runs, the client never sees it.TestClientraises it (raise_server_errors=Falsegives just the response) and waits for the tasks, so a test can check their effect right away. - Tasks run only if the handler returned, not after an
HTTPExceptionor a 500. - There is no limit on how many run at once: for heavy or many tasks, use a job queue.
- A task should not block forever: shutdown cuts it off.
- A WebSocket route cannot ask for it:
TypeErrorwhen declared. - On shutdown the built-in server waits for running tasks up to
graceful_timeout, then cuts them off with a warning.
Tasks run in the worker's process: for work that must survive a restart or needs retries, use a real queue.
Next: Dependencies.