Котики выходят на новый уровень! 🐾

Привет, котики и котолюбы! В первой части нашего кошачьего приключения мы выбрали инструменты (Litestar вместо FastAPI, Granian вместо Gunicorn, KeyDB вместо Redis), настроили uv и заложили фундамент проекта. Во второй части мы построили полноценное CRUD API для резюме котиков (или людей, если вам так ближе), подружили его с PostgreSQL через SQLAlchemy, настроили миграции с Alembic и написали тесты с Pytest. У нас уже есть стены и фундамент, но пора ставить крышу и готовиться к продакшену! 🏠

Сегодня мы сделаем наш API ещё круче: вынесем конфиги в отдельный модуль с помощью msgspec, добавим аутентификацию через встроенный JWT в Litestar, ускорим API с KeyDB, проверим покрытие тестами с coverage, упакуем всё в Docker и нарисуем резюме котиков с помощью Jinja. К концу статьи наш кошачий проект будет готов к реальной жизни — поехали! 🚀

Если раньше мы просто тренировались на кошках, то теперь выпускаем их в большой мир с пропусками, кешем и стильными резюме. Полный код, как всегда, на GitHub — ссылка в конце! 🐱

Вынос конфигов — приводим все настройки в порядок 🗂️

Зачем это нужно?

Наш проект растёт, и захардкоженные строки вроде DATABASE_URL начинают мяукать от неудобства. Давай вынесем конфиги в отдельный модуль src/configs/app_config.py и будем хранить значения в settings-example.yaml. Мы уже используем msgspec для моделей, так что применим его и для конфигов — это сделает код чище и быстрее, а котики любят порядок! 😺

Что будем делать?

  • Создадим классы конфигов с помощью msgspec.Struct.

  • Настроим парсинг settings-example.yaml в эти классы.

  • Обновим src/app.py, чтобы использовать новые конфиги.

Детали реализации

Установка зависимостей

Нам понадобится pyyaml для парсинга YAML. msgspec у нас уже есть, так как он используется в проекте:

uv add pyyaml

Создаём классы конфигов

Создаём файл src/configs/app_config.py. Используем msgspec.Struct для определения структуры конфигов:

from msgspec import Struct
import argparse
import yaml
from pathlib import Path


class DatabaseConfig(Struct):
    user: str = "postgres"
    password: str = "postgres"
    host: str = "127.0.0.1"
    port: str = "5432"
    database_name: str = "test_db"
    url: str = None
    echo: bool = False

    def get_connection_url(self) -> str:
        if self.url:
            return self.url
        return f"postgresql+asyncpg://{self.user}:{self.password}@{self.host}:{self.port}/{self.database_name}"

class KeyDBConfig(Struct):
    host: str = "127.0.0.1"
    port: str = "6379"
    db: str = "0"
    url: str = None

    def get_connection_url(self) -> str:
        if self.url:
            return self.url
        return f"keydb://{self.host}:{self.port}/{self.db}"

class JWTConfig(Struct):
    secret: str
    token_secret: str

class AppConfig(Struct):
    database: DatabaseConfig
    keydb: KeyDBConfig
    jwt: JWTConfig

def load_config(file_path: str) -> AppConfig:
    with open(file_path, "r") as f:
        config_data = yaml.safe_load(f)
    return AppConfig(
        database=DatabaseConfig(**config_data["database"]),
        keydb=KeyDBConfig(**config_data["keydb"]),
        jwt=JWTConfig(**config_data["jwt"]),
    )

def configure() -> AppConfig:
    title = "LitestarCats"
    parser = argparse.ArgumentParser(title)
    src_dir = Path(__file__).absolute().parent
    config_file = src_dir / "settings-example.yaml"
    parser.add_argument(
        "-c", "--config", type=str, default=config_file, help="Config file"
    )
    args = parser.parse_known_args()
    if args and args[0].config:
        config_file = args[0].config
    config = load_config(config_file)
    return config

msgspec не поддерживает автоматическую десериализацию из словарей так, как это делает pydantic, поэтому мы вручную преобразуем данные из YAML в наши структуры. Это немного более явный подход, но он соответствует философии msgspec: быть лёгким и быстрым.

Создаём settings-example.yaml

Создаём файл settings-example.yaml с такой структурой, чтобы она соответствовала нашим классам:

database:
  host: 127.0.0.1
  port: 5432
  user: postgres
  password: postgres
  database_name: ltcats_test_db
  #url: "postgresql+asyncpg://postgres:postgres@localhost/ltcats_test_db" local
  url: "postgresql+asyncpg://postgres:postgres@localhost/ltcats_test_db" # docker
  echo: true
keydb:
  host: 127.0.0.1
  port: 6379
  db: 0
  url: "keydb://localhost:6379/0"
jwt:
  secret: "your-secret-key"
  token_secret: "super-secret"

📌 Примечание: в продакшене лучше хранить секреты в централизованном secrets manager (Vault, AWS/GCP/Azure Secret Manager), но для простоты мы используем YAML. Если захотите, можно добавить поддержку .env через os.getenv или другую библиотеку.

Обновляем app.py

Теперь обновим src/app.py, чтобы использовать наши конфиги и добавим логирование, чтобы видеть что происходит во время работы приложения:

from litestar.contrib.sqlalchemy.plugins import (
    AsyncSessionConfig,
    SQLAlchemyAsyncConfig,
    SQLAlchemyInitPlugin,
)
from litestar.logging import LoggingConfig
from src.configs.app_config import configure


# Загружаем конфиги
config = configure()

logging_config = LoggingConfig(
    root={"level": "INFO", "handlers": ["queue_listener"]},
    formatters={
        "standard": {"format": "%(asctime)s - %(name)s - %(levelname)s - %(message)s"}
    },
    log_exceptions="always",  # Включить логирование исключений с трассировкой
)

...

session_config = AsyncSessionConfig(expire_on_commit=False)

db_config = SQLAlchemyAsyncConfig(
    connection_string=config.database.get_connection_url(), # тут достаем переменную через config
    before_send_handler="autocommit",
    session_config=session_config,
)

app = Litestar(
    route_handlers=[...],
    logging_config=logging_config, # добавляем логирование
)

Проверка

Запускаем приложение:

make run

Если всё работает, значит, конфиги подгружаются корректно. Теперь у нас есть единое место для всех настроек, и мы используем msgspec для консистентности с остальным проектом — котики довольны, порядок наведён! 🐾

Аутентификация с JWT — «Усы, лапы и хвост — вот мои документы!» 🔑

Зачем это нужно?

Безопасность — первое правило кошачьего клуба. Мы не хотим, чтобы Барсик редактировал резюме Мурзика без спроса! Для этого добавим аутентификацию через JWT, причём используем встроенный модуль из Litestar — быстро, надёжно и без лишних зависимостей. Только котики с пропуском смогут войти! 😺

Что будем делать?

  • Настроим JWT через JWTAuth из Litestar.

  • Добавим эндпоинт /login и защитим CRUD-операции.

  • Обновим модель User для хранения пароля.

Детали реализации

Добавляем пароль в модель

Сначала добавим поле для хранения хешированного пароля в нашу модель User. Открываем src/postgres/models/user.py и обновляем:

from sqlalchemy import String
from sqlalchemy import CheckConstraint, Index
from sqlalchemy.orm import Mapped, mapped_column, relationship
from litestar.plugins.sqlalchemy import base
from typing import Optional


class User(base.UUIDAuditBase):
    tablename = "users"
    first_name: Mapped[str] = mapped_column(String(50), nullable=False)
    last_name: Mapped[str] = mapped_column(String(50), nullable=True)
    email: Mapped[str] = mapped_column(
        String(255), nullable=False, unique=True, index=True
    )
    hashed_password: Mapped[str] = mapped_column(String(255), nullable=False) #добавлен пароль
    profile_photo_url: Mapped[Optional[str]] = mapped_column(String(255))
    user_roles: Mapped[list["UserRole"]] = relationship(back_populates="user")
    cvs: Mapped[list["CV"]] = relationship(back_populates="user")

    __table_args__ = (
        CheckConstraint("length(first_name) > 0", name="check_first_name_not_empty"),
        CheckConstraint("length(last_name) > 0", name="check_last_name_not_empty"),
        CheckConstraint("email LIKE '%@%.%'", name="check_email_format"),
        Index("idx_users_created_at", "created_at"),
    )

Теперь нужно сгенерировать миграцию, чтобы база данных узнала о новом поле:

make revision msg="add hashed_password to users"
make upgrade

Эндпоинт логина и защита CRUD

Для хеширования паролей нам понадобится passlib, так что установим его:

uv add "passlib[bcrypt]"

Обновим src/controllers/user.py:

from litestar.di import Provide
from src.models.users import UserLogin
from passlib.context import CryptContext


pwd_context = CryptContext(schemes=["sha256_crypt"])


class UserController(Controller):
    path = "/users"
    dependencies = {
            "users_repo": Provide(provide_users_repo),
            }

    @post("/login", signature_types=[User])  # Отключаем защиту для логина
    async def login(
            self,
            data: UserLogin,
            users_repo: UsersRepository,
            ) -> dict:
        user = await users_repo.get_one_or_none(email=data.email)
        if not user or not pwd_context.verify(data.password, user.hashed_password):
            raise HTTPException(status_code=401, detail="Неверный email или пароль")
        token = app.jwt_auth.create_token(identifier=str(user.id))
        return {"access_token": token, "token_type": "bearer"}

   

Проверка

Давай проверим, как это работает. Сначала создаём пользователя (для теста можно временно убрать защиту с /users):

curl -X POST http://localhost:8000/users -H "Content-Type: application/json" -d '{"first_name": "Мурзик", "last_name": "Котов", "email": "murzik@example.com"}'

Теперь логинимся через /login:

curl -X POST http://localhost:8000/users/login -H "Content-Type: application/json" -d '{"first_name": "Мурзик", "last_name": "Котов", "email": "murzik@example.com"}'

Получаем токен в ответе, например:

{
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"token_type": "bearer"
}

Используем токен для доступа к защищённым эндпоинтам:

curl -X GET http://localhost:8000/users/<user_id> -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."

Если всё работает, то только котики с кошачьим пропуском могут войти! JWT от Litestar — это как миска с кормом: просто, вкусно и безопасно. 🐾

Кеширование с KeyDB — кошачья скорость ⚡

Зачем это нужно?

Если котики начнут массово запрашивать резюме, наша база скажет: «Мяу, я устала!» KeyDB с его многопоточными лапками ускорит всё, чтобы котики летали, а база отдыхала. Мы обещали использовать KeyDB ещё в первой части, так что пора выполнять обещания! 😺

Что будем делать?

  • Установим KeyDB через Docker.

  • Добавим кэширование для эндпоинта /users/{user_id}.

Детали реализации

Запуск KeyDB

Поднимаем KeyDB в Docker:

docker run -d -p 6379:6379 eqalpha/keydb

Добавляем зависимость

Устанавливаем библиотеку для работы с KeyDB:

uv add "redis[hiredis]"

Настройка клиента

Создаём файл src/clients/cache.py для работы с KeyDB, используя наш конфиг:

import redis.asyncio as redis
from src.configs.app_config import configure


config = configure()

keydb = redis.Redis(host=config.keydb.host, port=config.keydb.port, db=config.keydb.db)

Кэширование в контроллере

Обновим метод get_user в src/controllers/user.py, чтобы он сначала проверял кеш, а только потом лез в базу. Добавим также эндпоинт для резюме (о нём позже):

from src.clients.cache import keydb
from litestar.response import Template


logger = logging.getLogger(__name__)
pwd_context = CryptContext(schemes=["sha256_crypt"])


class UserController(Controller):
    path = "/users"
    dependencies = {
            "users_repo": Provide(provide_users_repo),
            }

    @get("/{user_id:uuid}", return_dto=MsgspecDTO[UserRead])
    async def get_user(
        self,
        user_id: UUID,
        users_repo: UsersRepository,
    ) -> UserRead:
        """Get an existing author."""
        cache_key = f"user:{user_id}"
        cached = await keydb.get(cache_key)
        if cached:
            return json.decode(cached, type=UserRead)
        user = await users_repo.get(user_id)
        await keydb.set(cache_key, json.encode(user.to_dict()), ex=3600)  # Кэш на час
        return user

    # -------------------------------------

    @get("/{user_id:uuid}/cv", media_type="text/html")
    async def get_user_resume(self, user_id: UUID, users_repo: UsersRepository) -> Template:
        user = await users_repo.get(user_id)
        template = app.env.get_template("cv.html")
        return Template(template=template, context={"user": user})

Проверка

Запрашиваем пользователя через curl:

curl -X GET http://localhost:8000/users/<user_id> -H "Authorization: Bearer <your-token>"

Первый запрос пойдёт в базу, а второй — уже из кеша, и будет быстрее. KeyDB — это как кошачья мята для API: котики летают, а база отдыхает. Мяу-скорость включена! ⚡

Покрытие тестами с Coverage — проверяем кошачью надёжность 🧪

Зачем это нужно?

У нас есть тесты, но как понять, всё ли мы проверили? Coverage покажет, где котики ещё не прошлись лапками, и поможет убедиться, что наш код надёжен как кошачья интуиция. 😸

Что будем делать?

  • Установим coverage.

  • Настроим запуск тестов с покрытием и выведем отчёт.

Детали реализации

Установка

Устанавливаем coverage:

uv add coverage

Настройка

Обновим Makefile, чтобы запускать тесты с покрытием:

test-coverage:
	uv run coverage run -m pytest

test-coverage-report:
	uv run coverage report --show-missing

Запуск

Запускаем:

make test-coverage-report

Пример вывода:

Name                    Stmts   Miss  Cover   Missing
-----------------------------------------------------
src/app.py                 20      2    90%   15-16
src/controllers/users.py   50     10    80%   25-30, 45-50
src/models/users.py        10      0   100%
-----------------------------------------------------
TOTAL                      80     12    85%

Проверка

Отчёт показывает, что у нас 85% покрытия — неплохо, но есть куда расти! Например, строки 25-30 в users.py — это обработка ошибок в /login, которую мы не протестировали. Давай добавим тест в src/tests/test_users.py:

@pytest.mark.asyncio
async def test_login_invalid_credentials(db_session: AsyncSession, test_client):
    # 1. Создаем пользователя с некорректным паролем
    user_data = UserCreate(
        first_name="Васька",
        last_name="Мурзиков",
        email="vasyamurzikov@whiskers.com",
        password="12345",
    )
    user_dict = structs.asdict(user_data)
    password = user_dict.pop("password")
    user = User(**user_dict, hashed_password=pwd_context.hash("wrong-pass"))
    db_session.add(user)
    await db_session.commit()

    # Данные для логина
    login_data = UserLogin(email=user.email, password=password)

    # Отправка POST-запроса через тестовый клиент
    response = await test_client.post("/users/login", json=structs.asdict(login_data))

    # Проверка результата
    assert response.status_code == 401
    assert response.json().get("detail") == "Неверный email или пароль"

И фикстуру тестового клиента в conftest.py:

from src.app import app
from litestar.testing import AsyncTestClient


@pytest_asyncio.fixture
async def test_client(db_session: AsyncSession) -> AsyncTestClient:
    async with AsyncTestClient(app) as client:
        yield client

Теперь повторяем make test-coverage — покрытие должно подрасти! Coverage — это как ветеринар для кода: сразу видно, где котик прихрамывает. Пора подтянуть хвосты! 🐾

Шаблон резюме с Jinja — кошачьи карточки

Зачем это нужно?

API — это круто, но хочется увидеть резюме котиков вживую! Сделаем шаблон в стиле баскетбольных карточек 90-х: звёзды сверху, имя-фамилия, описание, данные и табличка с опытом работы. Минимум JS и CSS, только чистый кошачий шик! 😺

Что будем делать?

  • Установим jinja2.

  • Добавим модель и эндпоинт для отображения резюме (уже добавили в users.py).

  • Создадим HTML-шаблон в стиле баскетбольных карточек.

Детали реализации

Установка Jinja

Устанавливаем jinja2:

uv add jinja2

Настройка Jinja

Добавим в файл src/app.py код для работы с шаблонами:

from jinja2 import Environment, PackageLoader, select_autoescape

env = Environment(
        loader=PackageLoader("src"),
        autoescape=select_autoescape()
        )

Шаблон cv.html

Шрифт на карточке похож на Impact для заголовков и Arial для текста. Мы убрали фото, добавили звёзды, описание и табличку с опытом работы. Создаём templates/cv.html:

<!DOCTYPE html>
<html>
<head>
    <title>{{ user.first_name }} {{ user.last_name }} - Resume</title>
    <style>
        .card {
            border: 2px solid #000;
            width: 400px;
            margin: 20px auto;
            background: #f0f0f0;
            font-family: Arial, sans-serif;
            box-shadow: 5px 5px 10px rgba(0, 0, 0, 0.3);
        }
        .stars {
            text-align: center;
            font-size: 24px;
            color: #ff4500;
            padding: 5px;
        }
        .header {
            background: #ff4500;
            color: white;
            text-align: center;
            padding: 10px;
            font-family: Impact, sans-serif;
            font-size: 24px;
            text-transform: uppercase;
        }
        .subheader {
            font-family: Impact, sans-serif;
            font-size: 14px;
            color: #ff4500;
            text-align: center;
            margin: 5px 0;
        }
        .info {
            padding: 10px;
            font-size: 14px;
        }
        .description {
            font-style: italic;
            margin: 10px 0;
        }
        .stats {
            margin: 10px 0;
        }
        .stats div {
            margin: 5px 0;
        }
        .experience {
            border-top: 2px solid #ff4500;
            padding: 10px;
        }
        .experience h3 {
            font-family: Impact, sans-serif;
            font-size: 16px;
            text-align: center;
            margin-bottom: 10px;
            color: #000;
        }
        .experience table {
            width: 100%;
            border-collapse: collapse;
            font-size: 12px;
        }
        .experience th, .experience td {
            border: 1px solid #000;
            padding: 5px;
            text-align: left;
        }
        .experience th {
            background: #ff4500;
            color: white;
            font-family: Impact, sans-serif;
        }
    </style>
</head>
<body>
    <div class="card">
        <div class="stars">⭐⭐⭐⭐⭐</div>
        <div class="header">{{ user.first_name }} {{ user.last_name }}</div>
        <div class="subheader">PROFESSIONAL CAT CODER</div>
        <div class="info">
            <div class="stats">
                <div><b>Email:</b> {{ user.email }}</div>
                <div><b>Joined:</b> {{ user.created_at.strftime('%Y-%m-%d') }}</div>
            </div>
            <div class="description">
                {{ user.first_name }} is a purr-fect coder with a knack for catching bugs faster than a laser pointer. Known for napping on keyboards and delivering meow-nificent code, this cat is a true asset to any team!
            </div>
        </div>
        <div class="experience">
            <h3>WORK EXPERIENCE</h3>
            <table>
                <tr>
                    <th>Компания</th>
                    <th>Должность</th>
                    <th>Тип работы</th>
                    <th>Начало работы</th>
                    <th>Конец работы</th>
                    <th>Описание</th>
                </tr>
                {% for cv in user.cvs %}
                {%- for exp in cv.work_experiences %}
                <tr>
                    <td>{{ exp.company }}</td>
                    <td>{{ exp.job_title }}</td>
                    <td>{{ exp.employment_type }}</td>
                    <td>{{ exp.start_date }}</td>
                    <td>{{ exp.end_date }}</td>
                    <td>{{ exp.description }}</td>
                </tr>
                {% endfor %}
                {% endfor %}
            </table>
        </div>
    </div>
</body>
</html>

Проверка

Добавим тестовые данные через SQL или API, чтобы у пользователя был опыт работы. Например:

INSERT INTO work_experience (user_id, company, position, description, created_at, updated_at)
VALUES ('<user_id>', 'CatTech', 'Senior Whisker Engineer', 'Python, MeowSQL', NOW(), NOW());

Теперь открываем в браузере http://localhost:8000/users/<user_id>/cv. Вы увидите стильную карточку: звёзды сверху, имя-фамилия, описание и табличка с опытом работы. Без лишнего JS — только чистый кошачий шик!

Упаковка в Docker — котики в коробке 📦

Зачем это нужно?

В продакшене код без Docker — как котик без коробки. Упакуем всё аккуратно, чтобы наш API был готов к деплою! 📦

Что будем делать?

  • Создадим Dockerfile.

  • Обновим docker-compose.yaml.

Детали реализации

Dockerfile

Создаём Dockerfile:

FROM python:3.13.3-slim
WORKDIR /app
# Копируем файлы с зависимостями и конфиг
COPY pyproject.toml uv.lock ./
COPY src/configs/settings-example.yaml ./configs/
# Устанавливаем uv (если он не установлен в базовом образе)
RUN pip install --no-cache-dir uv
# Устанавливаем зависимости через uv
RUN uv pip install --system -r pyproject.toml
# Копируем исходный код и остальные файлы
COPY src/ ./src/
COPY src/templates/ ./templates/
COPY src/configs/alembic.ini ./configs/
# Запускаем приложение через granian
CMD ["granian", "--interface", "asgi", "src.app:app"]

Обновлённый docker-compose.yaml

Обновляем docker-compose.yaml, чтобы включить KeyDB и приложение:

services:
  app:
    build: .
    ports:
      - "8000:8000"
    depends_on:
      postgres:
        condition: service_healthy
      keydb:
        condition: service_healthy
    networks:
      - litestarcats
  postgres:
    image: postgres:latest
    container_name: postgres
    hostname: postgres
    ports:
      - 5432:5432
    volumes:
      - "postgres-data:/var/lib/postgresql/data"
    networks:
      - litestarcats
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 10s
      timeout: 5s
      retries: 3
  keydb:
    image: eqalpha/keydb:latest
    container_name: keydb
    ports:
      - "6379:6379"
    volumes:
      - keydb_data:/data
    restart: unless-stopped
    networks:
      - litestarcats
    healthcheck:
      test: ["CMD-SHELL", "redis-cli ping"]
      interval: 10s
      timeout: 5s
      retries: 3

volumes:
  postgres-data:
  keydb_data:
    driver: local
networks:
  litestarcats:
    driver: bridge

Проверка

Запускаем:

docker compose up --build

Проверяем, что API доступно на http://localhost:8000 и резюме отображается. Котики в коробке, а проект в продакшене! Docker — это как переноска для нашего API. 🐾

Котики готовы к продакшену! 🎉

Итоги

Мы вынесли конфиги в отдельный модуль с помощью msgspec, добавили JWT для безопасности, KeyDB для скорости, coverage для надёжности, Jinja для стиля и Docker для деплоя. Наш кошачий API теперь готов к бою! 😺

Впечатления

Litestar — это находка, KeyDB летает, а msgspec сделал код ещё быстрее и консистентнее. Карточки получились на ура, Мурзик теперь выглядит как звезда! ⭐

Что дальше?

В следующей части можно Litestar с FastAPI. А может, кошачьи аватарки через API? Пишите в комментариях, что хотите в четвёртой части! 🐾

Ссылка

Код на GitHub. Лапки вверх, если понравилось! 😻

P.S.: Если заметили ошибку, напишите мне пожалуйста, я исправлю. Код на гитхабе обновлён, там всегда актуальная, рабочая версия, если тут вдруг стало не понятно или нужно посмотреть код целиком, можете посмотреть там или опять же, написать мне, я постараюсь поправить в статье, так чтобы стало лучше. Спасибо.