В прошлом уроке мы строили To-Do API вместе — шаг за шагом, с объяснением каждого решения. Теперь ваша очередь.
Этот урок — самостоятельная работа. Задание разбито на шаги в том же порядке что и прошлый проект. Если вы застряли — вернитесь к Уроку 20 и найдите аналогичный фрагмент. Все паттерны которые нужны для этого проекта уже были показаны.
Тема: REST API для каталога товаров с категориями.
Интернет-магазин хранит товары разбитые по категориям. Администратор управляет категориями и товарами через API.
Две таблицы, одна связь:
categories products
┌───────────────────────┐ ┌──────────────────────────────────┐
│ id INT (PK) │◄──────│ category_id INT (FK, NOT NULL)│
│ name TEXT NOT NULL│ │ id INT (PK) │
│ UNIQUE │ │ name TEXT NOT NULL │
└───────────────────────┘ │ description TEXT (nullable) │
│ price REAL NOT NULL > 0 │
│ stock INT NOT NULL >= 0 │
│ created_at TEXT NOT NULL │
└──────────────────────────────────┘
Категории:
| Метод | URL | Действие | Успешный код |
|---|---|---|---|
GET |
/categories |
Все категории | 200 |
GET |
/categories/{id} |
Одна категория | 200 |
POST |
/categories |
Создать категорию | 201 |
DELETE |
/categories/{id} |
Удалить категорию | 204 |
Товары:
| Метод | URL | Действие | Успешный код |
|---|---|---|---|
GET |
/products |
Все товары (с фильтрами) | 200 |
GET |
/products/{id} |
Один товар | 200 |
POST |
/products |
Создать товар | 201 |
PUT |
/products/{id}/stock |
Обновить остаток | 200 |
DELETE |
/products/{id} |
Удалить товар | 204 |
Query-параметры для GET /products:
category_id: int(необязательный) — фильтр по категорииmin_price: float(необязательный) — товары дороже указанной ценыin_stock: bool(необязательный) — только товары с остатком > 0
Системный:
| Метод | URL | Действие |
|---|---|---|
GET |
/health |
Статус сервиса |
catalog_api/
├── main.py
├── database.py
├── exceptions.py
├── repositories/
│ ├── __init__.py
│ ├── categories.py
│ └── products.py
├── schemas/
│ ├── __init__.py
│ ├── categories.py
│ └── products.py
└── routers/
├── __init__.py
├── categories.py
└── products.py
Перед написанием кода ответьте письменно на вопросы:
- Какие поля обязательны при создании товара, а какие необязательны?
- Что вернёт
GET /products/999если товара нет? - Что вернёт
POST /categoriesесли категория с таким именем уже есть? - В каком порядке нужно удалять данные если захотим удалить категорию у которой есть товары?
- Какие поля войдут в
ProductResponseкоторых нет вproductsнапрямую?
Реализуйте файл со следующим содержимым:
- Константа
DB_PATH = 'catalog.db' - Функция
init_db()— создаёт таблицы черезCREATE TABLE IF NOT EXISTS. Порядок: сначалаcategories, потомproducts(внешний ключ) - Функция
get_connection()— генератор сyieldиfinally - Функции
get_category_repo()иget_product_repo()— фабрики зависимостей черезDepends
Подсказка: структура полностью аналогична database.py из Урока 20. Измените имена таблиц и путь к файлу.
Реализуйте иерархию исключений:
AppException(Exception)— базовый класс сstatus_codeиmessageNotFoundError(AppException)—404, сообщение:'{resource} с id={id} не найден'AlreadyExistsError(AppException)—400, сообщение:'Запись с {field}={value!r} уже существует'
Реализуйте CategoryRepository с методами:
get_all() -> list— все категории, сортировка поnameget_by_id(category_id: int)— одна илиNonecreate(name: str) -> int | None— возвращаетidилиNoneприIntegrityErrordelete(category_id: int) -> bool—Trueесли удалена
Реализуйте ProductRepository с методами:
-
get_all(category_id, min_price, in_stock) -> listВсе три параметра необязательные. Динамически строите WHERE:
category_id—p.category_id = ?min_price—p.price >= ?in_stock=True—p.stock > 0
Запрос должен использовать JOIN с
categoriesчтобы вернутьcategory_name. -
get_by_id(product_id: int)— одна запись сcategory_nameилиNone -
create(name, description, price, stock, category_id, created_at) -> int -
update_stock(product_id: int, new_stock: int) -> bool -
delete(product_id: int) -> bool
Подсказка: паттерн динамического WHERE через список условий и параметров — точно такой же как в TaskRepository.get_all() из Урока 20.
Реализуйте:
CategoryCreate— полеname: str, минимум 1 символ, максимум 100CategoryResponse— поляid: int,name: str
Реализуйте:
-
ProductCreate— поля:name: str— минимум 1 символ, максимум 200description: Optional[str] = None— необязательноеprice: float— больше 0 (gt=0)stock: int— не меньше 0 (ge=0), по умолчанию 0category_id: int— больше 0 (gt=0)created_at: str— дата создания
-
ProductStockUpdate— одно полеstock: int, не меньше 0 -
ProductResponse— все поля изProductCreateплюсid: intиcategory_name: str
Не забудьте добавить __init__.py в каждую папку с реэкспортом классов.
Реализуйте роутер с эндпоинтами:
GET /—response_model=List[CategoryResponse]GET /{category_id}—response_model=CategoryResponse,404если не найденаPOST /—response_model=CategoryResponse,status_code=201,400если имя занятоDELETE /{category_id}—status_code=204,404если не найдена
Реализуйте роутер с эндпоинтами:
GET /— принимает query-параметрыcategory_id,min_price,in_stock. Передаёт их вrepo.get_all()GET /{product_id}—404если не найденPOST /— создаёт товар, возвращает полный объект черезget_by_idPUT /{product_id}/stock— принимаетProductStockUpdate, обновляет остаток, возвращает обновлённый товарDELETE /{product_id}—204,404если не найден
Соберите приложение:
FastAPI(title='Catalog API', version='1.0')@app.on_event('startup')— вызовinit_db()- Три обработчика исключений:
AppException,HTTPException,Exception - Подключение роутеров с префиксами
/categoriesи/products - Эндпоинт
GET /health
Запустите приложение и проверьте следующие сценарии:
POST /categories {"name": "Электроника"}→201, получитьidPOST /categories {"name": "Электроника"}повторно →400POST /productsс корректными данными →201POST /productsсprice: -100→422POST /productsсcategory_id: 9999→ приложение не должно упастьGET /products?category_id=1→ только товары из категории 1GET /products?min_price=1000&in_stock=true→ фильтрацияPUT /products/1/stock {"stock": 50}→ обновлённый товарGET /products/9999→{"error": true, "code": 404, ...}DELETE /products/1→204
Обязательно:
- Все эндпоинты из технического задания реализованы
- Структура проекта соответствует заданию
- Все запросы параметризованы — никаких f-строк в SQL
response_modelуказан для всех GET и POST эндпоинтов404при обращении к несуществующему ресурсу400при нарушении уникальности имени категории422при невалидных данных (проверяется Pydantic автоматически)- Единый формат ошибок через обработчики в
main.py GET /productsподдерживает все три фильтра
Хорошо:
category_nameприсутствует вProductResponse- Динамический WHERE без дублирования кода
__init__.pyс реэкспортом в каждой папке- Эндпоинт
/healthприсутствует - Код читается и структурирован понятно