Writing Handlers#

Handlers are your functions and coroutines that uapi calls to process incoming requests.

Handlers are registered to apps using App.route(), or helper decorators like App.get() and App.post().

@app.get("/")
async def index() -> None:
    return

# Alternatively,
app.route("/", index, methods=["GET"])

A route is a combination of a path, an HTTP method, a handler, and a route name.

We strongly recommend not using async handlers with Flask or Django unless you know what you’re doing, even though (technically) all supported frameworks support both sync and async handlers.

Route Names#

Each route is registered under a certain name. The name is a simple string identifying the route, and defaults to the name of the handler function or coroutine. Names are propagated to the underlying frameworks where they have framework-specific purposes.

Names are also used in the generated OpenAPI schema:

  • to generate the Operation summary

  • as the operationId Operation property

Names should be unique across routes, so if you want to register the same handler for two routes you will need to specify one of the names manually.

@app.get("/")
@app.post("/", name="post-multipurpose-handler")
async def multipurpose_handler() -> None:
    return

Receiving Data#

Query Parameters#

To receive query parameters, annotate a handler parameter with any type that hasn’t been overriden and is not a path parameter. The App’s dependency injection system is configured to fulfill handler parameters from query parameters by default; directly when annotated as strings or Any or through the App’s converter if any other type. Query parameters may have default values.

Query params will be present in the OpenAPI schema; parameters with defaults will be rendered as required=False.

@app.get("/query_handler")
async def query_handler(string_query: str, int_query: int = 0) -> None:
    # The int_query param will be the result of `app.converter.structure(int_query, int)`
    return

Path Parameters#

One of the simplest ways of getting data into a handler is by using path parameters. A path parameter is inserted into the handler route string and the value of the parameter is given to the handler. Since the routing is left to the underlying framework, the format of the route string is framework-specific.

The path parameter in the route string and the name of the handler argument must match. The type annotation is not examined; all frameworks default to string path parameters.

@app.get("/{article_id}")
async def get_article(article_id: str) -> str:
    return "Getting the article"

Starlette uses curly brackets for path parameters and supports several built-in converters.

@app.get("/<article_id>")
def get_article(article_id: str) -> str:
    return "Getting the article"

Flask uses angle brackets for path parameters and supports several built-in converters.

@app.get("/<article_id>")
async def get_article(article_id: str) -> str:
    return "Getting the article"

Quart uses angle brackets for path parameters and supports several built-in converters.

@app.get("/<article_id>")
async def get_article(article_id: str) -> str:
    return "Getting the article"

Django uses angle brackets for path parameters and come with several built-in converters, alongside the ability to add your own.

@app.get("/{article_id}")
async def get_article(article_id: str) -> str:
    return "Getting the article"

Aiohttp uses curly brackets for path parameters and only supports strings.

JSON Request Bodies#

If the HTTP request body data is a JSON object, it should be modeled as an attrs class and declared as a ReqBody parameter in the handler.

from attrs import define

@define
class Article:
    article_id: str

@app.post("/article")
async def create_article(article: ReqBody[Article]) -> None:
    # `article` is an instance of `Article`
    ...

Note

A parameter annotated as a ReqBody[T] will be equivalent to T in the function body.

ReqBody[T] is an easier way of saying typing.Annotated[T, JsonReqLoader()], and typing.Annotated is a way to add metadata to a type.

If the request body cannot be loaded into the given model, a 400 Bad Request response will be returned instead. This can be customized by providing your own own instance of uapi.requests.JsonBodyLoader with a custom error_handler.

from typing import Annotated, TypeVar
from uapi.requests import JsonBodyLoader
from uapi.status import BadRequest

T = TypeVar("T")

def make_error_response(exc: Exception, payload: bytes) -> BadRequest[None]:
    # Examine the exception.
    return BadRequest("Bad payload buddy")

MyErrorReqBody = Annotated[T, JsonBodyLoader(error_handler=make_error_response)]

@app.post("/endpoint")
async def create_article(article: MyErrorReqBody[Article]) -> None:
    # `article` is an instance of `Article`
    ...

The handler requires the caller to set the content-type header to application/json; a 415 Unsupported Media Type error will be returned otherwise. This is a security feature, helping with some forms of CSRF.

Custom values for content-type can be required by providing your own instance of uapi.requests.JsonBodyLoader.

from typing import Annotated, TypeVar
from uapi.requests import JsonBodyLoader

T = TypeVar("T")

MyReqBody = Annotated[T, JsonBodyLoader("application/vnd.myapp.v1+json")]

@app.post("/endpoint")
async def create_article(article: MyReqBody[Article]) -> None:
    # `article` is an instance of `Article`
    ...

Content type validation can be disabled by passing None to the JsonBodyLoader; the content-type header will be ignored. This in inadvisable unless you have no other choice.

In addition, the request body may be modeled as a dict of str to a primitive type or an attrs class.

@app.post("/articles")
async def create_articles(articles: ReqBody[dict[str, Article]]) -> None:
    ...

Headers#

HTTP headers are provided to your handlers when one or more of your handler parameters are annotated using uapi.Header[T].

Note

Technically, HTTP requests may contain several headers of the same name. All underlying frameworks return the first value encountered.

from uapi import Header


@app.post("/login")
async def login(session_token: Header[str]) -> None:
    # `session_token` is a `str`
    ...

By default, the name of the header is the name of the handler parameter with underscores replaced by dashes. (So, in the above example, the expected header name is session-token.)

If the header parameter has no default and the header is not present in the request, the resulting scenario is left to the underlying framework. The current options are:

  • Quart: a response with status 400 is returned

  • All others: a response with status 500 is returned

uapi.Header[T] is equivalent to Annotated[T, uapi.HeaderSpec], and header behavior can be customized by providing your own instance of uapi.requests.HeaderSpec.

For example, the header name can be customized on a case-by-case basis like this:

from typing import Annotated
from uapi import HeaderSpec


@app.post("/login")
async def login(session_token: Annotated[str, HeaderSpec("my_header")]) -> None:
    # `session_token` is a `str`
    ...

Headers may have defaults which will be used if the header is not present in the request. Headers with defaults will be rendered as required=False in the OpenAPI schema.

@app.post("/login")
async def login(session_token: Header[str | None] = None) -> None:
    # `session_token` is a `str | None`
    ...

Header types may be strings or anything else. Strings are provided directly by the underlying frameworks, any other type is produced by structuring the string value into that type using the App cattrs Converter.

Cookies#

Cookies are provided to your handlers when one or more of your handler parameters are annotated using uapi.Cookie, which is a subclass of str. By default, the name of the cookie is the exact name of the handler parameter.

from uapi import Cookie


@app.post("/login")
async def login(session_token: Cookie) -> None:
    # `session_token` is a `str` subclass
    ...

The name of the cookie can be customized on an individual basis by using typing.Annotated:

from typing import Annotated
from uapi import Cookie


@app.post("/login")
async def login(session_token: Annotated[str, Cookie("session-token")]) -> None:
    # `session_token` is a `str` subclass, fetched from the `session-token` cookie
    ...

Cookies may have defaults which will be used if the cookie is not present in the request. Cookies with defaults will be rendered as required=False in the OpenAPI schema.

Cookies may be set by using uapi.cookies.set_cookie().

from uapi.status import Ok
from uapi.cookies import set_cookie

async def sets_cookies() -> Ok[str]
    return Ok("response", headers=set_cookie("my_cookie_name", "my_cookie_value"))

Tip

Since uapi.cookies.set_cookie() returns a dictionary, multiple cookies can be set by using the | operator.

Forms#

Form data can be modeled as an attrs class and declared as a FormBody parameter in the handler.

from attrs import define

@define
class ArticleForm:
    article_id: str

@app.post("/article")
async def create_article(article: FormBody[Article]) -> None:
    # `article` is an instance of `Article`
    ...

Note

A parameter annotated as a FormBody[T] will be equivalent to T in the function body.

FormBody[T] is an easier way of saying typing.Annotated[T, FormSpec()], and typing.Annotated is a way to add metadata to a type.

All underlying frameworks expect the content-type to be set to application/x-www-form-urlencoded, which browsers set by default. If a different content-type header is set all frameworks silently supply an empty form payload; whether this succeeds or not depends on whether all form model fields have default values.

Only post forms are currently supported; for get forms see Query Parameters. Consequently, receiving form data is only supported in post routes.

When a form payload cannot be successfully structured, a 400 Bad Request response is returned.

Security

Using just forms by themselves opens the application to the possibility of a cross-site request forgery attack.

Consider employing one or more prevention strategies or using JSON endpoints.

Multipart forms are not yet supported. Forms containing nested objects aren’t supported due to the complexities of encoding; in these cases JSON endpoints should be preferred.

Note

Starlette requires an extra package, python-multipart, to be installed before forms can be handled.

Framework-specific Request Objects#

In case uapi doesn’t cover your exact needs, your handler can be given the request object provided by your underlying framework. Annotate a handler parameter with your framework’s request type.

These parameters cannot be inspected by uapi so they won’t show up in the OpenAPI schema. Additionally, they tie your handlers to a specific underlying framework making your handlers less portable. They can, however, help in incrementally porting to uapi.

from starlette.requests import Request

@app.get("/")
async def get_root(req: Request) -> None:
    # Do something with `req`
    return
from flask import request

@app.get("/")
def get_root() -> None:
    # Do something with `request`
    return

Flask uses the usual flask.request threadlocal object for the request, so no handler parameter is necessary.

from quart import request

@app.get("/")
async def get_root() -> None:
    # Do something with `request`
    return

Quart uses the usual quart.request contextvar object for the request, so no handler parameter is necessary.

from django.http import HttpRequest

@app.get("/")
def get_root(req: HttpRequest) -> None:
    # Do something with `req`
    return
from aiohttp.web import Request

@app.get("/")
async def get_root(req: Request) -> None:
    # Do something with `req`
    return

Returning Data#

Nothing (204 No Content)#

If your handler returns no data, annotate the return type as None.

@app.delete("/article")
async def delete_article() -> None:
    ... # Perform side-effects.

Tip

Whether the response contains the content-type header is up to the underlying framework.

Flask, Quart and Django add a text/html content type by default.

A longer equivalent, with the added benefit of being able to specify response headers, is returning the NoContent response explicitly.

from uapi.status import NoContent

@app.delete("/article")
async def delete_article() -> NoContent:
    # Perform side-effects.
    return NoContent(headers={"key": "value"})

This functionality is handled by NoneShorthand.

Strings and Bytes (200 OK)#

If your handler returns a string or bytes, the response will be returned directly alongside the 200 OK status code.

@app.get("/article/image")
async def get_article_image() -> bytes:
    ...

For strings, the content-type header is set to text/plain, and for bytes to application/octet-stream.

This functionality is handled by StrShorthand and BytesShorthand.

attrs Classes#

Handlers can return an instance of an attrs class. The return value with be deserialized into JSON using the App cattrs converter, which can be customized as per the usual cattrs ways.

The status code will be set to 200 OK and the content type to application/json. The class will be added to the OpenAPI schema.

from attrs import define

@define
class Article:
    title: str

@app.get("/article")
async def get_article() -> Article:
    ...

Custom Response Shorthands#

The str, bytes, None and attrs return types are examples of response shorthands. Custom response shorthands can be defined and added to apps; see the Response Shorthands section for the details.

uapi Status Code Classes#

uapi contains a variety of classes, mapping to status codes, for returning from handlers. All of these classes also take an optional header parameter for response headers.

from uapi.status import Ok

@app.get("/article")
async def get_article() -> Ok[Article]:
    # fetch article
    return Ok(article, headers={"my-header": "header value"})

Returning Multiple Status Codes#

Use a union of uapi response types and shorthands if your handler can return multiple status codes.

All responses defined this way will be rendered in the OpenAPI schema.

@app.get("/profile")
async def user_profile() -> Ok[Profile] | NoContent:
    ...

uapi ResponseExceptions#

Any raised instances of uapi.ResponseException will be caught and transformed into a proper response. Like any exception, ResponseExceptions short-circuit handlers so they can be useful for validation and middleware. In other cases, simply returning a response is faster and usually more type-safe.

ResponseExceptions contain instances of uapi status code classes and so can return rich response data, just like any normal response.

from uapi import ResponseException
from uapi.status import Ok, NotFound

@app.get("/article")
async def get_article() -> Ok[Article]:
    article = await fetch_article()
    if article is None:
        raise ResponseException(NotFound("article not found"))
    ...

Since exceptions don’t show up in the handler signature they won’t be present in the generated OpenAPI schema. If you need them to, you can add the actual response type into the handler response signature as part of a union:

from uapi import ResponseException
from uapi.status import Ok, NotFound

@app.get("/article")
async def get_article() -> Ok[Article] | NotFound[str]:
    article = await fetch_article()
    if article is None:
        raise ResponseException(NotFound("article not found"))
    ...

Custom Status Codes#

If you require a status code that is not included with uapi, you can define your own status code class like this:

from typing import Literal
from uapi.status import BaseResponse, R

class TooManyRequests(BaseResponse[Literal[429], R]):
    pass

@api.get("/throttled")
async def throttled() -> Ok[None] | TooManyRequests[None]:
    return TooManyRequests(None)

The custom status code will be included in the generated OpenAPI schema.

Framework-specific Response Objects#

If you need to return your framework’s native response class, you can.

These responses cannot be inspected by uapi so they won’t show up in the OpenAPI schema. Additionally, they tie your handlers to a specific underlying framework making your handlers less portable. They can, however, help in incrementally porting to uapi.

from starlette.responses import PlainTextResponse

@app.get("/")
async def get_root() -> PlainTextResponse:
    return PlainTextResponse("content")
from flask import Response

@app.get("/")
def get_root() -> Response:
    return Response("content")
from quart import Response

@app.get("/")
async def get_root() -> Response:
    return Response("content")
from django.http import HttpResponse

@app.get("/")
def get_root() -> HttpResponse:
    return HttpResponse("content")
from aiohttp.web import Response

@app.get("/")
async def get_root() -> Response:
    return Response(body="content")