Skip to content

Validation

Use this when you want Korvex to read, check and convert the input of a handler from its annotations.

Annotations say where a parameter comes from, and what it must be. Models can be msgspec structs, pydantic models, or plain dataclasses and TypedDicts; Korvex needs none of these libraries to import, only to validate.

import msgspec

from korvex import App

app = App()


class NewUser(msgspec.Struct):
    name: str
    age: int


class User(msgspec.Struct):
    id: int
    name: str


@app.post("/users", status=201)
def create_user(body: NewUser, notify: bool = False) -> User:
    return User(id=1, name=body.name)

Where a parameter comes from

Parameter Comes from
a model (struct, pydantic model, dataclass, TypedDict), or a list of them (body: NewUser) the JSON body
named like a path parameter (id) the path
str, int, float, bool, UUID, date, datetime, or an Optional or list of them the query string
Header(), Cookie(), Form(), Query(alias=...) there
Depends(func) what func returns: see Dependencies
named request, or annotated Request the request itself
  • A default makes a parameter optional. Dates are ISO 8601.
  • Anything else is refused with a message when the route is declared, so a mistake shows at import.
  • When a field is repeated (?tag=a&tag=b), a single value takes the first, and a list[str] takes them all.

Dataclasses and TypedDict

A @dataclass or a TypedDict (or a list or Optional of them) works like a struct: body, return annotation and Form(), with the same 422. Defaults are filled in. It is checked by msgspec if installed, else pydantic; with neither, the route raises a TypeError telling you to pip install msgspec. A NamedTuple or attrs class is not supported. Before Python 3.12, pydantic wants TypedDict from typing_extensions.

from dataclasses import dataclass
from typing import TypedDict

from korvex import App
from korvex.testing import TestClient

app = App()


@dataclass
class NewTask:
    title: str
    done: bool = False


class Summary(TypedDict):
    title: str
    done: bool


@app.post("/tasks", status=201)
def add(task: NewTask) -> Summary:
    return {"title": task.title, "done": task.done}


print(TestClient(app).post("/tasks", json={"title": "milk"}).json())

Headers, cookies and forms

from korvex import App, Cookie, Form, Header, Query, UploadedFile

app = App()


@app.post("/photos")
async def upload(
    title: str = Form(),  # a form field, urlencoded or multipart
    photo: UploadedFile = Form(),  # a file from a multipart form
    x_client: str = Header("web"),  # the X-Client header, "web" if missing
    theme: str = Cookie("light"),
    page_size: int = Query(20, alias="page-size"),
): ...
  • x_client reads X-Client: underscores become hyphens, and case doesn't matter. alias= gives the exact name. list[str] gets every value of a repeated header.
  • An empty form field counts as missing, except for a str.
  • A whole model can come from a form, field by field: signup: Signup = Form(). A checkbox's on is true, and text converts to numbers and dates as the model says.
  • All four also work in Annotated[...] (from typing): theme: Annotated[str, Cookie()] = "light".

What the handler returns

  • A model, with a return annotation (-> User): sent as JSON, with the route's status (200 by default).
  • None, annotated -> None: an empty response, for status=204.
  • A Response: sent as it is, with its own status.

Errors

Invalid input fails validation and gets a 422 listing the problems:

{"detail": [{"loc": ["body", "age"], "msg": "Expected `int`, got `str`", "type": "validation"}]}

loc says where: ["query", "page"], ["header", "x-client"], ["form", "title"]. pydantic lists every problem; msgspec stops at the first. To answer differently, handle the 422; the exception is a RequestValidationError, with the list in errors:

from korvex import App, RequestValidationError, Response

app = App()


@app.exception_handler(422)
def invalid(request, exc: RequestValidationError):
    return Response.json({"problems": [e["loc"] for e in exc.errors]}, status=422)

Annotations are read once, when the route is declared.

Next: Requests and responses.