Статусы заказа в FastAPI: Enum для входа и правила переходов

В базе появились одновременно pending, Pending и pendng. Все три строки задумывались как один статус заказа, но фильтры считают их разными. Перечисление допустимых значений решает форму входа. После этого остаётся второй вопрос: можно ли уже оплаченный заказ вернуть в ожидание оплаты.

Две проверки на разных этапах

Строковый Enum задаёт словарь допустимых статусов. FastAPI разбирает JSON в этот тип и отклоняет неизвестное значение до обработчика с 422. Значения остаются удобными строками в JSON и видны в OpenAPI. Клиент может построить форму выбора из того же договора.

Однако любое допустимое значение не обязательно допустимо сейчас. Переход pending → paid разрешён, paid → pending запрещён. Такое правило зависит от текущего заказа, поэтому выполняется после его получения. Для конфликта с текущим состоянием вернём 409. Клиент отличит опечатку в названии от команды, которую нельзя выполнить над этим заказом.

Реализуем маленькую машину состояний

Словарь allowed делает переходы обозримыми. Повтор того же статуса считаем успешной идемпотентной командой. Это осознанный выбор: некоторым процессам нужен запрет повторного действия, но тогда проверять надо саму операцию, например повторное списание, а не только значение поля.

Сохраните весь блок в файл и запустите Python 3.11+ в отдельном окружении с пакетами fastapi, httpx. Для моделей используется Pydantic v2; отдельный сервер для TestClient не нужен.

from enum import Enum
from fastapi import FastAPI, HTTPException
from fastapi.testclient import TestClient
from pydantic import BaseModel

class Status(str, Enum):
    pending = "pending"
    paid = "paid"
    cancelled = "cancelled"
class Change(BaseModel):
    status: Status

app = FastAPI()
orders = {1: Status.pending}
allowed = {Status.pending: {Status.paid, Status.cancelled},
           Status.paid: set(), Status.cancelled: set()}

@app.put("/orders/{order_id}/status")
def change(order_id: int, data: Change):
    if order_id not in orders:
        raise HTTPException(404, "order not found")
    current = orders[order_id]
    if data.status != current and data.status not in allowed[current]:
        raise HTTPException(409, "transition is not allowed")
    orders[order_id] = data.status
    return {"status": data.status}

with TestClient(app) as client:
    assert client.put("/orders/1/status", json={"status": "Paid"}).status_code == 422
    assert client.put("/orders/1/status", json={"status": "paid"}).json() == {"status": "paid"}
    assert client.put("/orders/1/status", json={"status": "paid"}).status_code == 200
    assert client.put("/orders/1/status", json={"status": "pending"}).status_code == 409
    assert client.put("/orders/99/status", json={"status": "paid"}).status_code == 404
print("value validation, transition and missing order checked")

Как читать матрицу ответов

Неизвестный статус не меняет заказ. Разрешённый переход меняет его один раз. Повтор paid сохраняет paid, а попытка вернуться в pending даёт конфликт. Запрос к отсутствующему заказу возвращает 404. Эта последовательность проверяет поведение во времени, которое нельзя увидеть отдельным созданием Enum.

В базе переход должен проверяться атомарно вместе с обновлением. Между чтением pending и записью paid другой запрос способен отменить заказ. Для реального сервиса используйте транзакцию и подходящий контроль конкуренции: условное обновление по ожидаемому состоянию, блокировку или номер версии. Выбирайте механизм под базу; один Python-словарь в примере объясняет HTTP-контракт.

Как расширять статусы без хаоса

Добавление shipped меняет не только список строк. Решите, откуда в него можно перейти, какие действия выполняются и что увидит старый клиент. Перечисление помогает найти точки изменения в коде, но не заменяет сценарии продукта. Полезно хранить тест на каждый разрешённый переход и один запрет из каждого конечного состояния.

Если хочется разрешить произвольную строку ради будущих статусов, разделите вход команды и чтение истории. Команда может оставаться строгой, а клиент чтения — корректно отображать неизвестный статус. Тогда появление нового состояния не превращает опечатки в разрешённые команды.

Следующая практика

В открытом уроке о полном и частичном обновлении вы закрепите, как API меняет ресурс. После выполнения базовой практики добавьте матрицу разрешённых переходов статуса и проверьте конфликтную команду. Пройдите бесплатный урок об обновлении ресурса.

Развивайте обновление ресурса вместе с хранением и проверкой пользователя: эти части входят в бесплатную программу FastAPI. Начните с базового CRUD, затем добавляйте правила собственного процесса. Откройте программу курса.

Продолжить на практике