Программирование с ИИ
ENRU
14.2

Кейс — веб-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.

  • Правило в схеме таблицы ловит повтор, но ответ за код ответа на обработчике.

  • Проверки данных пишутся один раз в схеме, а не в каждом обработчике.

  • Приёмка — прогон всех строк мини-ТЗ подряд, включая неудачные случаи.

  • Чего нет в проекте — должно быть решением, а не забывчивостью.