Кейс — веб-API с базой данных
и почему 500 вместо 409 — это баг
Что это
Продолжение первого кейса. У бота из статьи 14.1 появляются соседи: сайт и мобильное приложение. Держать список городов в трёх местах нельзя, значит, нужен один API, из которого берут все.
Идея в одну фразу: сервис, который хранит города пользователя и отдаёт их по HTTP.
Шаг 1. Мини-ТЗ
Вопросы те же (статья 13.6), поэтому только то, что отличается от бота:
| Вопрос | Ответ |
|---|---|
| кто пользуется | бот, сайт, приложение — не люди, а программы |
| что хранится | города и последняя известная температура |
| где хранится | SQLite: один процесс, записей мало (статья 13.3) |
| что нельзя потерять | список городов |
| как узнаю о поломке | коды ответов в логах |
Из строчки «пользуются программы» следует главное требование: ответы должны быть предсказуемыми. Программа не умеет догадываться, что значит «что-то пошло не так», — ей нужен код ответа, по которому видно, что делать.
Шаг 2. Запрос
API на FastAPI, Python 3.12, хранение в SQLite.
Эндпоинты:
GET /cities список городов
POST /cities добавить {name, temp}
DELETE /cities/{name} удалить
Требования:
- схемы запроса и ответа на pydantic, имя города 1–60 символов
- весь доступ к базе в одном месте
- коды ответов: 201 на создание, 204 на удаление,
404 если удалять нечего, 409 если город уже есть
- никаких ORM, запросы пишем руками
Сначала список файлов и что в каждом. Код после «да».Строка про коды ответов — самая важная. Без неё придёт код, который на всё отвечает 200, а об ошибках сообщает текстом внутри ответа.
Шаг 3. Что пришло
Структура получилась правильная, схемы на месте, запросы параметризованные (это важно — подстановка строкой открывает SQL-инъекцию, статья 7.8). Обработчик добавления:
@app.post("/cities", response_model=CityOut, status_code=201)
def add_city(city: CityIn):
with closing(db()) as conn:
conn.execute("INSERT INTO cities VALUES (?, ?)", (city.name, city.temp))
conn.commit()
return cityЗапускаем и проверяем руками — именно так, как советует статья 4.7: не только удачный путь.
$ curl -X POST localhost:8102/cities -d '{"name":"Oslo","temp":4}'
201
$ curl -X POST localhost:8102/cities -d '{"name":"Oslo","temp":4}'
Internal Server Error
HTTP 500Второй раз — 500. В логах причина:
sqlite3.IntegrityError: UNIQUE constraint failed: cities.nameЧто произошло: в схеме таблицы name объявлен первичным ключом — и это
правильно, повторов быть не должно (урок из статьи 14.1). Но обработчик об
этом не знает, исключение вылетает наружу, и FastAPI превращает его в 500.
Починка — четыре строки:
try:
conn.execute("INSERT INTO cities VALUES (?, ?)", (city.name, city.temp))
except sqlite3.IntegrityError:
raise HTTPException(409, f"город {city.name} уже добавлен")Шаг 4. Приёмка
Проверяем все случаи из мини-ТЗ подряд — и удачные, и нет:
первый POST : 201
второй POST : {"detail":"город Oslo уже добавлен"} | HTTP 409
GET : [{"name":"Oslo","temp":4.0}]
DELETE Oslo : 204
DELETE снова : 404И проверка данных, которую никто не писал, — она досталась от схемы:
$ curl -X POST localhost:8102/cities -d '{"name":"","temp":4}'
422{"detail":[{"type":"string_too_short","loc":["body","name"],
"msg":"String should have at least 1 character","ctx":{"min_length":1}}]}В ответе названо поле, названо правило, названо нарушение. Это и есть смысл схем из статьи 7.3: проверки пишутся один раз рядом с описанием данных, а не в каждом обработчике.
Пять строчек вывода выше — это пять проверок, каждая из которых соответствует строке мини-ТЗ. Такой прогон занимает минуту и заменяет вопрос «а точно работает?».
Шаг 5. Чего здесь намеренно нет
Нет авторизации. Любой, кто знает адрес, может добавить город. Для сервиса на своей машине это осознанное решение, для сервиса в интернете — дыра. Решение записано в мини-ТЗ, а не забыто, и в этом вся разница.
Нет пагинации. Городов десятки. Когда станут тысячи — появится, и признак назван заранее (статья 13.3).
Нет ORM. Запросов пять, все простые. ORM — это ещё одна библиотека, ещё один слой и ещё один источник непонятных ошибок; на пяти запросах она не окупается. Если бы запросов было пятьдесят, ответ был бы другим.
Шпаргалка
Если API пользуются программы, главное требование — предсказуемые коды.
Коды перечисляй в мини-ТЗ: без этого получишь 200 на всё.
500 — это всегда баг: случай не предусмотрен.
Повтор — 409, нечего удалять — 404, кривые данные — 422.
Правило в схеме таблицы ловит повтор, но ответ за код ответа на обработчике.
Проверки данных пишутся один раз в схеме, а не в каждом обработчике.
Приёмка — прогон всех строк мини-ТЗ подряд, включая неудачные случаи.
Чего нет в проекте — должно быть решением, а не забывчивостью.