Skip to content

Litestar

hare.contrib.frameworks.litestar connects hare to a Litestar application. Install it with the litestar extra (Litestar 2.23 or newer, pydantic).

from litestar import Litestar, get
from litestar.di import NamedDependency

from hare.contrib.frameworks.litestar import HarePlugin, RequestQueryDIPlugin
from hare.contrib.request_query import Page

HARE_CONFIG = {
    "connections": {"default": "postgresql://library:secret@localhost:5432/library"},
    "apps": {"library": {"models": ["library.models"], "default_connection": "default"}},
}


@get("/books", dependencies={"books": RequestQueryDIPlugin.provide(BookQuery)})
async def list_books(books: NamedDependency[BookQuery]) -> Page[Book]:
    return await books.page()


app = Litestar([list_books], plugins=[HarePlugin(HARE_CONFIG, atomic_requests=True)])

HarePlugin

class HarePlugin:
    def __init__(
        self,
        config: Mapping[str, Any] | HareConfig | str,
        *,
        atomic_requests: bool | Sequence[str] = False,
        health_routes: HealthRoutes | None = None,
    ) -> None: ...

The plugin gives the application:

  • The HareContext. Opened with the application and closed with it, as the first lifespan manager — the application's own lifespan managers (a message broker, an outbox relay) already have the database, and stop before it closes. The context is the global fallback, so every request sees it. On shutdown Hare.close_connections() also waits for the background observers still running.
  • Request queries are checked at startup. RequestQuery.check_declarations() runs once the models are set up: a wrong request query stops the application from starting, with every wrong class listed.
  • Request queries as dependencies. HarePlugin is a RequestQueryDIPlugin itself: an application's plugins come before the ones Litestar adds, so it takes a request query before Litestar's own pydantic DI plugin would take it as a plain pydantic model.
  • hare models in responses and request data — HarePlugin gives a handler returning or taking them a HareDTO.
  • ORM errors as HTTP answers, in Litestar's error format, unless the application answers the error itself:
Error Answer
DoesNotExist 404 Not Found
IntegrityError (unique, foreign key, protected relation) 409 Conflict, without the database's message, which names tables and constraints
TransactionRetryError (a serialization failure or deadlock — the request may be sent again) 409 Conflict
ValidationError (a value a field refuses: out of its range, too long, a null byte PostgreSQL refuses) 422 Unprocessable Content, the field's message in detail
InvalidRequestQuery 400 Bad Request as Litestar answers invalid parameters itself: {"status_code", "detail", "extra"}, each error in extra as {"message", "key", "source"} (query or path); an error of the whole query (a model validator's) has no key
RequestQueryForbidden (a parameter asks for what the request may not see: deleted rows without may_see_deleted()) 403 Forbidden, the parameter under extra
ApiGetterError (a value of another service a request query's Meta.api_getter requires couldn't be loaded) 502 Bad Gateway, without the error's message, which names the service and the values asked
  • A request's own state. Each request starts with an empty count of RepeatedQueryDetector, so it reports the queries of one request. Only its own writes send its reads to the connection written through (reading your own writes) — not the writes of the requests its task served before or of the application's startup.
  • A transaction per request with atomic_requests — see below.

config is what Hare.init(config=...) takes.

Request queries as dependencies

RequestQueryDIPlugin.provide(QueryClass) is the dependency building a request query from the request. Each parameter of the class is a parameter of the handler, validated by Litestar and listed in the OpenAPI schema with its type, default, limits (limit's maximum is the pagination's max_limit) and description. A parameter is a query parameter unless its annotation says otherwise:

class ChapterQuery(RequestQuery[Chapter]):
    pk: FromPath[KeyColumns[int, int]]

    class Meta:
        queryset = Chapter.objects.all()
        pagination = None


@get("/chapters/{pk:str}", dependencies={"chapter": RequestQueryDIPlugin.provide(ChapterQuery)})
async def get_chapter(chapter: NamedDependency[ChapterQuery]) -> ChapterSchema: ...

KeyColumns and CommaSeparated reach the query as the text of the parameter, which the query parses. The query gets the request: query.request, and the addresses of a page's next and previous.

Litestar builds the handlers' signatures when the application is created, before its lifespan opens the connections. HarePlugin binds the models of its configuration then (Hare.bind_models()), so the parameters of Meta.filters are in the signatures and the schema; with RequestQueryDIPlugin alone, call Hare.bind_models() before creating the application.

Without HarePlugin, list RequestQueryDIPlugin() in plugins.

Responses: HareDTO

A handler returns hare models — a row, a list of rows, a page of rows (Page[Book]) — and HarePlugin gives it a HareDTO of the model, Litestar's own way of shaping a response: the rows are encoded without a schema of their own, and the OpenAPI schema describes them.

@get("/books", dependencies={"books": RequestQueryDIPlugin.provide(BookQuery)})
async def list_books(books: NamedDependency[BookQuery]) -> Page[Book]:
    return await books.page()

The fields of a HareDTO are the model's fields:

  • each value, typed by the field (its enum for an enum field), null for a nullable one — and for a field the query left out (only(), a request query's fields), not the field's default;
  • a relation's key column (author_id);
  • each relation (author, tags, a reverse relation) as the related rows — null unless the query loaded them with select_related()/prefetch_related() or a request query's include, so encoding a response never runs a query. Loaded rows go one level deep (DTOConfig.max_nested_depth, 1 by default): without their own relations.

A page keeps its own fields (count, limit, next ...) and has its rows in result.

The plugin builds one DTO per model and forgets it with the model's caches: a live model unregistered and registered again (Hare.register_live_models()) gets a DTO of its own, and the unregistered class isn't kept.

A DTO of its own shapes the response as for any Litestar DTO:

from litestar.dto import DTOConfig

from hare.contrib.frameworks.litestar import HareDTO


class BookDTO(HareDTO[Book]):
    config = DTOConfig(exclude={"tags"}, rename_strategy="camel")


@get("/books", return_dto=BookDTO, dependencies={"books": RequestQueryDIPlugin.provide(BookQuery)})
async def list_books(books: NamedDependency[BookQuery]) -> Page[Book]:
    return await books.page()

The handler loads the relations it answers with; nothing else is needed:

@get("/books/{book_id:int}")
async def get_book(book_id: FromPath[int]) -> Book:
    return await Book.objects.all().select_related("author").prefetch_related("tags").get(id=book_id)
# {"id": 1, "title": "...", "author_id": 3, "author": {"id": 3, "name": "Anna"},
#  "tags": [{"id": 1, "name": "fantasy"}], "lines": null}

A relation to one row is the related row, a relation to many rows (a reverse foreign key, a many-to-many relation) the list of them, and a relation the query didn't load is null. The related rows go as deep as DTOConfig.max_nested_depth — 1 by default, the related rows without their own relations; the relations of related rows need a DTO of its own and the query loading them:

class BookDetailDTO(HareDTO[Book]):
    config = DTOConfig(max_nested_depth=2)


@get("/books/{book_id:int}/detail", return_dto=BookDetailDTO)
async def get_book_detail(book_id: FromPath[int]) -> Book:
    return await Book.objects.all().select_related("author__profile").prefetch_related("author__books").get(id=book_id)

A request query loads the relations a request names with its include option (?include=author,tags).

DTOConfig's exclude, include and rename_fields name a field of related rows by its path through the relations — author.name, tags.id, author.books.title:

class BookDTO(HareDTO[Book]):
    config = DTOConfig(
        max_nested_depth=2,
        exclude={"author.books", "tags.id"},
        rename_fields={"author.name": "authorName"},
    )

A sensitive=True field (an EncryptedTextField too) is in the DTO like any other field — leave it out with exclude, as exclude_sensitive=True does for a Pydantic model.

A handler taking a model (data: Book) gets a HareDTO of it too: the request's data makes the row. Relations, the values the database generates (an autoincrement key) and the auto_now/auto_now_add stamps the ORM fills on save are read-only, and so is Meta.soft_delete_field, which delete() and restore() alone write — the data sets the key column (author_id), not the relation, and a stamp or a deletion time sent in the body is ignored.

A change of some fields only (PATCH) takes a DTO with DTOConfig(partial=True) and DTOData: the model built from partial data has the defaults in the fields not sent, update_instance() sets only the sent ones on the row read from the database:

class PartialBookDTO(HareDTO[Book]):
    config = DTOConfig(partial=True)

@patch("/books/{book_id:int}", dto=PartialBookDTO)
async def change_book(book_id: int, data: DTOData[Book]) -> Book:
    book = data.update_instance(await Book.objects.get(id=book_id))
    await book.save()
    return book

Values that are no fields of the model

A response often carries values the model doesn't store: a count the query annotated, a flag the handler worked out, a property, a field of a related row put next to the row's own. A HareDTO subclass declares them, and each is read-only and null where a row has no value for it:

class BookDTO(HareDTO[Book]):
    config = DTOConfig(exclude={"author.books"})
    extra_fields = {"reviews_count": int, "is_new": bool}  # read from the row by name, with the type
    computed = ("title_length", "author.initials")          # properties and methods, typed by their return
    related_values = {"author_name": "author.name"}         # a related row's field, flattened


@get("/books", return_dto=BookDTO)
async def list_books() -> list[Book]:
    books = await Book.objects.select_related("author").annotate(reviews_count=Count("reviews"))
    for book in books:
        book.is_new = book.added_at > last_visit
    return books
# [{"id": 1, "title": "...", "author": {"id": 3, "name": "Anna", "initials": "A"}, ...,
#   "reviews_count": 4, "is_new": true, "title_length": 11, "author_name": "Anna"}]
  • extra_fields — a query's annotation, or an attribute the handler (or a request query's api_getter) set on the row after reading it, with its type: a query builds it, so the DTO can't learn it from the model. A row without it — a query that didn't annotate it — answers null.
  • computed — a property or method of the model, typed by its return annotation. A field the query didn't select (only(), a request query's fields=) read inside it answers null; any other AttributeError raised inside it fails the request (500) instead of answering null. That includes such a field read by another property it reads: Python reports a property failing with an AttributeError as a missing property, so select the fields the properties it reads need.
  • related_values — a field of a related row through relations to one row; null when the query didn't load the relation.

A name written as a path through the relations ("author.initials") belongs to the related model: its rows get the value wherever the response holds them. The OpenAPI schema lists each value with its type. A name of a model field, one declared twice, a path through what is no relation, a computed name that is no property or method (or has no return annotation) and a related value through a relation to many rows or ending at a relation raise ConfigurationError when the handler's DTO is built.

A transaction per request: atomic_requests

HarePlugin(..., atomic_requests=True) runs each HTTP request in a transaction; a route handler with opt={SKIP_TRANSACTION_OPT_KEY: True} runs without it:

from litestar import post

from hare.contrib.frameworks.litestar import SKIP_TRANSACTION_OPT_KEY


@post("/imports", opt={SKIP_TRANSACTION_OPT_KEY: True})
async def import_books() -> None: ...

When the transaction commits or rolls back, and what else runs without it — see Web frameworks.

Health routes: health_routes

from hare.contrib.frameworks.health_routes import HealthRoutes
from hare.health import HealthCheck

app = Litestar(route_handlers, plugins=[HarePlugin(HARE_CONFIG, health_routes=HealthRoutes(HealthCheck()))])

The readiness and liveness routes it adds — see Web frameworks.

Tests

With hare's pytest plugin the application under test runs on the test databases and takes the test's context. AsyncTestClient runs it in the thread of an anyio portal, an event loop of its own: under hare_app_transactional_db its writes commit and the tables are emptied after the test, on every driver; under hare_app_db they are rolled back with the test's on the postgresql and SQLite drivers, and postgresql+asyncpg refuses them — see Application tests.

@pytest.mark.asyncio
async def test_create_reader(hare_app_transactional_db):
    async with AsyncTestClient(app=app) as client:
        assert (await client.post("/readers", json={"name": "ann"})).status_code == 201