Перейти к содержанию

Litestar

hare.contrib.frameworks.litestar подключает hare к приложению Litestar. Ставится вместе с дополнительным пакетом litestar (Litestar 2.23 или новее, 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: ...

Плагин даёт приложению:

  • HareContext. Открывается вместе с приложением и закрывается вместе с ним — первым среди обработчиков жизненного цикла, поэтому собственные обработчики приложения (брокер сообщений, доставщик исходящих событий) уже получают базу и останавливаются до её закрытия. Контекст — глобальный запасной, поэтому его видит каждый запрос. При остановке Hare.close_connections() ещё и дожидается фоновых наблюдателей, которые ещё работают.
  • Запросы из параметров проверяются при запуске. RequestQuery.check_declarations() выполняется, как только модели настроены: ошибочный запрос из параметров не даёт приложению запуститься, и все ошибочные классы перечисляются.
  • Запросы из параметров как зависимости. HarePlugin сам является RequestQueryDIPlugin: плагины приложения идут раньше тех, что добавляет Litestar, поэтому он забирает запрос из параметров раньше, чем собственный плагин зависимостей pydantic в Litestar принял бы его за обычную модель pydantic.
  • Модели hare в ответах и в данных запроса — HarePlugin даёт обработчику, который их возвращает или принимает, HareDTO.
  • Ошибки ORM как ответы HTTP в формате ошибок Litestar, если приложение не отвечает на ошибку само:
Ошибка Ответ
DoesNotExist 404 Not Found
IntegrityError (уникальность, внешний ключ, защищённая связь) 409 Conflict, без сообщения базы, в котором названы таблицы и ограничения
TransactionRetryError (сбой сериализации или взаимная блокировка — запрос можно отправить снова) 409 Conflict
ValidationError (значение, которое поле не принимает: вне диапазона, слишком длинное, нулевой байт, который отвергает PostgreSQL) 422 Unprocessable Content, сообщение поля в detail
InvalidRequestQuery 400 Bad Request, как Litestar сам отвечает на неверные параметры: {"status_code", "detail", "extra"}, каждая ошибка в extra в виде {"message", "key", "source"} (query или path); у ошибки всего запроса (от проверки модели) нет key
RequestQueryForbidden (параметр просит то, что запросу видеть нельзя: удалённые строки без разрешения may_see_deleted()) 403 Forbidden, параметр — в extra
ApiGetterError (не загрузилось значение другого сервиса, которое требует Meta.api_getter запроса) 502 Bad Gateway без текста ошибки — он называет сервис и запрошенные значения
  • Собственное состояние у каждого запроса. Каждый запрос начинает с пустого счётчика RepeatedQueryDetector, поэтому тот сообщает о запросах к базе одного запроса к API. Только его собственные записи направляют его чтения в подключение, через которое он писал (чтение своих записей), — не записи запросов, которые его задача обслужила раньше, и не записи при старте приложения.
  • Транзакция на каждый запрос при atomic_requests — см. ниже.

config — то же, что принимает Hare.init(config=...).

Запросы из параметров как зависимости

RequestQueryDIPlugin.provide(QueryClass) — зависимость, которая строит запрос из параметров HTTP-запроса. Каждый параметр класса становится параметром обработчика, проверяется Litestar и перечисляется в схеме OpenAPI с типом, значением по умолчанию, ограничениями (наибольшее значение limit — это max_limit деления на страницы) и описанием. Параметр — параметр строки запроса, если его аннотация не говорит иного:

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 и CommaSeparated приходят в запрос текстом параметра, который запрос разбирает сам. Запрос получает сам HTTP-запрос: query.request, а также адреса next и previous для страницы.

Litestar строит сигнатуры обработчиков при создании приложения, до того как при запуске откроются подключения. HarePlugin в этот момент привязывает модели своей конфигурации (Hare.bind_models()), поэтому параметры Meta.filters попадают в сигнатуры и в схему; если используется только RequestQueryDIPlugin, вызовите Hare.bind_models() до создания приложения.

Без HarePlugin добавьте RequestQueryDIPlugin() в plugins.

Ответы: HareDTO

Обработчик возвращает модели hare — строку, список строк, страницу строк (Page[Book]), — а HarePlugin даёт ему HareDTO модели — собственный способ Litestar оформлять ответ: строки кодируются без отдельной схемы, а схема OpenAPI их описывает.

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

Поля HareDTO — это поля модели:

  • каждое значение с типом поля (перечисление — для поля-перечисления), null для поля, допускающего NULL, — и для поля, которое запрос не выбрал (only(), опция fields запроса из параметров), а не значение поля по умолчанию;
  • колонка ключа связи (author_id);
  • каждая связь (author, tags, обратная связь) как связанные строки — null, если запрос не загрузил их через select_related()/prefetch_related() или опцию include запроса из параметров, поэтому кодирование ответа никогда не выполняет запросов к базе. Загруженные строки идут на один уровень вглубь (DTOConfig.max_nested_depth, по умолчанию 1): без их собственных связей.

Страница сохраняет свои поля (count, limit, next ...), а её строки лежат в result.

Плагин строит по одному DTO на модель и забывает его вместе с кэшами модели: модель, зарегистрированная во время работы, снятая с регистрации и зарегистрированная снова (Hare.register_live_models()), получает свой DTO, а снятый с регистрации класс не удерживается.

Собственный DTO оформляет ответ так же, как любой DTO Litestar:

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()

Обработчик загружает связи, которые возвращает в ответе; больше ничего не нужно:

@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}

Связь с одной строкой — это связанная строка, связь со многими строками (обратный внешний ключ, «многие-ко-многим») — их список, а связь, которую запрос не загрузил, — null. Связанные строки идут вглубь на DTOConfig.max_nested_depth уровней — по умолчанию 1, то есть связанные строки без их собственных связей; для связей связанных строк нужен свой DTO и запрос, который их загружает:

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)

Запрос из параметров загружает связи, которые называет клиент, через свою опцию include (?include=author,tags).

exclude, include и rename_fields у DTOConfig называют поле связанных строк путём через связи — 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"},
    )

Поле с sensitive=True (и EncryptedTextField) входит в DTO, как любое другое, — его убирают через exclude, как exclude_sensitive=True делает это для модели Pydantic.

Обработчик, принимающий модель (data: Book), тоже получает её HareDTO: из данных запроса создаётся строка. Связи, значения, которые генерирует база (автоинкрементный ключ), и отметки auto_now/ auto_now_add, которые ORM заполняет при сохранении, только для чтения, как и Meta.soft_delete_field, который пишут только delete() и restore(), — данные задают колонку ключа (author_id), а не саму связь, а отметка или время удаления, присланные в теле, не учитываются.

Изменение только части полей (PATCH) делается DTO с DTOConfig(partial=True) и DTOData: модель, собранная из частичных данных, держит в неприсланных полях значения по умолчанию, а update_instance() задаёт строке, прочитанной из базы, только присланные:

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

Значения, которых нет среди полей модели

Ответ часто несёт значения, которых модель не хранит: число, которое посчитал запрос, флаг, который вычислил обработчик, свойство, поле связанной строки рядом с полями самой строки. Подкласс HareDTO объявляет их; каждое только для чтения и null, где у строки его нет:

class BookDTO(HareDTO[Book]):
    config = DTOConfig(exclude={"author.books"})
    extra_fields = {"reviews_count": int, "is_new": bool}  # читаются со строки по имени, с типом
    computed = ("title_length", "author.initials")          # свойства и методы, тип — из аннотации возврата
    related_values = {"author_name": "author.name"}         # поле связанной строки, в плоском виде


@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 — аннотация запроса или атрибут, который обработчик (или api_getter запроса из параметров) положил на строку после чтения, с типом: его строит запрос, поэтому из модели DTO его не узнать. Строка без него — запрос его не посчитал — отвечает null.
  • computed — свойство или метод модели, тип — из аннотации возврата. Поле, которое запрос не выбрал (only(), fields= запроса из параметров), прочитанное внутри него, даёт null; любой другой AttributeError внутри него завершает запрос ошибкой (500), а не отвечает null. Это касается и такого поля, прочитанного другим свойством, которое он читает: свойство, упавшее с AttributeError, Python считает отсутствующим, поэтому выбирайте поля, нужные свойствам, которые он читает.
  • related_values — поле связанной строки через связи с одной строкой; null, если запрос не загрузил связь.

Имя, записанное путём через связи ("author.initials"), относится к связанной модели: её строки получают значение везде, где они есть в ответе. Схема OpenAPI описывает каждое значение с его типом. Имя поля модели, имя, объявленное дважды, путь через то, что не связь, вычисляемое имя, которое не свойство и не метод (или без аннотации возврата), и значение связи через связь со многими строками или с путём, оканчивающимся связью, дают ConfigurationError при построении DTO обработчика.

Транзакция на каждый запрос: atomic_requests

HarePlugin(..., atomic_requests=True) выполняет каждый HTTP-запрос в транзакции; обработчик маршрута с opt={SKIP_TRANSACTION_OPT_KEY: True} выполняется без неё:

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: ...

Когда транзакция фиксируется или откатывается и что ещё выполняется без неё — см. Веб-фреймворки.

Маршруты здоровья: 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()))])

Какие маршруты готовности и работоспособности он добавляет — см. Веб-фреймворки.

Тесты

С pytest-плагином hare тестируемое приложение работает на тестовых базах и берёт контекст теста. AsyncTestClient запускает его в потоке портала anyio — в своём цикле событий: под hare_app_transactional_db его записи фиксируются, а таблицы очищаются после теста, на любом драйвере; под hare_app_db они откатываются вместе с записями теста на драйверах postgresql и SQLite, а postgresql+asyncpg от них отказывается, — см. Тесты приложения.

@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