Внешний API вернул массив: проверяем пакет через TypeAdapter
Сервис получает от поставщика массив остатков: [{"sku": "A", "stock": 4}, ...]. В ответе нет поля items, и добавлять его на транспортной границе нельзя. При этом обычные аннотации list[Stock] не проверяют JSON во время выполнения. После первого корректного ответа всё выглядит нормально, пока поставщик не присылает отрицательный остаток или объект вместо массива.
Проверяем настоящий корень документа
TypeAdapter строит валидатор для произвольного описания типа. Передадим list[Stock] и будем получать список уже проверенных моделей. Это убирает искусственную BaseModel с единственным полем, которое никогда не существовало в API поставщика. Если нужна именованная модель корневого значения со своими методами, можно выбрать RootModel; для локальной границы функции адаптер обычно проще.
Адаптер имеет смысл создать один раз рядом с клиентом интеграции. Его построение включает подготовку схемы, поэтому делать это внутри цикла для каждой строки незачем. Внешний HTTP-клиент остаётся отдельным слоем: сначала ограничивает размер и проверяет статус, затем передаёт тело в адаптер. Ошибка сети и ошибка формата поставщика должны оставаться разными причинами отказа.
Входные байты и содержательный результат
Понадобится Pydantic v2. validate_json получает непосредственно байты JSON, а validate_python нужен для уже разобранных Python-значений. Пример вычисляет общий остаток после проверки, отклоняет неверную строку и проверяет повторный экспорт массива.
from pydantic import BaseModel, Field, TypeAdapter, ValidationError
class Stock(BaseModel):
sku: str
stock: int = Field(ge=0)
stocks = TypeAdapter(list[Stock])
raw = b'[{"sku":"A","stock":4},{"sku":"B","stock":0}]'
items = stocks.validate_json(raw)
assert sum(item.stock for item in items) == 4
assert [item.sku for item in items] == ["A", "B"]
for bad, expected in [
(b'[{"sku":"A","stock":-1}]', (0, "stock")),
(b'{"sku":"A","stock":4}', ()),
]:
try:
stocks.validate_json(bad)
except ValidationError as exc:
assert exc.errors()[0]["loc"] == expected
print({"rejected_at": expected, "code": exc.errors()[0]["type"]})
else:
raise AssertionError("invalid document accepted")
wire = stocks.dump_json(items)
assert wire.startswith(b"[")
assert stocks.validate_json(wire) == items
print({"rows": len(items), "stock": sum(i.stock for i in items)})
Что делать с одной плохой строкой
В показанном договоре неверный элемент отклоняет весь пакет. Это полезно для полного снимка остатков: частичный снимок может выглядеть достоверным и случайно обнулить отсутствующие позиции. Если требуется принимать хорошие строки отдельно, такой режим должен возвращать отчёт с исходным индексом и причинами отказов. Простое удаление невалидных строк скрывает потерю данных.
TypeAdapter не проверяет уникальность sku автоматически. Если поставщик обещает один остаток на товар, после структурной проверки добавьте правило уникальности пакета или используйте RootModel с валидатором. Не складывайте дубликаты молча, пока это не является смыслом входного формата. Тип и граница числа — только часть договора интеграции.
Для JSON очень большого размера полная проверка массива всё равно создаёт список в памяти. Адаптер не превращает загрузку тела в потоковый разбор. Если объём растёт, сначала посмотрите, поддерживает ли поставщик страницы или построчный формат, и определите границу одной обрабатываемой порции. Так размер проверки будет согласован с транзакцией и повторной загрузкой.
В HTTP-обработчике ошибка входа клиента обычно даёт 422, а неверный ответ поставщика относится к вашей внешней зависимости. Поэтому не возвращайте её как обвинение пользователя. Разделение этих сценариев помогает диагностике: клиент исправляет свой запрос только тогда, когда проблема действительно в нём.
Зафиксируйте правила преобразования чисел
Обычное поле int может преобразовывать допустимые строковые представления. Для поставщика, чей договор требует JSON-число, используйте строгое поле и добавьте тест со строкой вместо числа. Такое изменение стоит согласовать с реальными данными интеграции: внезапный строгий режим способен отклонить пакеты, которые раньше принимались. Начните с сохранённого обезличенного примера ответа и проверьте обе стороны границы.
После проверки передавайте дальше типизированные элементы, а не первоначальные словари. Иначе следующая функция снова начнёт обращаться к непроверенному raw и преимущества адаптера исчезнут. Для записи в базу полезна отдельная функция, принимающая list[Stock]. Её тесты тогда сосредоточатся на правилах обновления остатков, а тест клиента — на форме внешнего документа.
Продолжить на практике
Оформите клиент поставщика как явную зависимость и подмените его в тестах корректным и сломанным ответом. В программе продвинутого Python есть Protocol, stub и pytest: на них удобно построить такую проверяемую интеграцию. Откройте программу продвинутого Python.
Продолжить на практике
- Python для продвинутых: ООП, типизация и тестирование ↗
Практический курс-мост от базового Python к разработке поддерживаемых приложений. Вы разберётесь со структурой пакетов, объектной моделью, современной типизацией и автоматическими тестами, а затем соедините эти инструменты в небольшом приложении с хранением данных и логированием.
