Котики выходят на новый уровень! 🐾
Привет, котики и котолюбы! В первой части нашего кошачьего приключения мы выбрали инструменты (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.: Если заметили ошибку, напишите мне пожалуйста, я исправлю. Код на гитхабе обновлён, там всегда актуальная, рабочая версия, если тут вдруг стало не понятно или нужно посмотреть код целиком, можете посмотреть там или опять же, написать мне, я постараюсь поправить в статье, так чтобы стало лучше. Спасибо.
