← назад к разделу

PYTHON · REST API Фаза 14. API: REST и контракты

Обязательно

URL и ресурсы в REST API на FastAPI

Resources · Methods · Nesting

Адрес описывает ресурс, HTTP-метод - операцию, а вложенность показывает только настоящую зависимость.

1. Путь читается как предметная модель

1 / 5

Коллекции называются существительными во множественном числе, составные части - kebab-case. /orders/42/items понятно без документации.

/orders/42/itemsGET

2. Метод несёт семантику операции

2 / 5

GET читает, POST создаёт, PUT заменяет, PATCH меняет часть, DELETE удаляет. Глаголы внутри пути не дублируют то, что уже сказал метод.

GET /orders/42POST /ordersPATCH /orders/42DELETE /orders/42

3. Вложенность показывает владельца

3 / 5

Позиция не существует без заказа, поэтому /orders/42/items логична. Глубокие цепочки вроде /users/5/orders/42/items/7/reviews заменяют фильтром или отдельным ресурсом.

Order 42aggregate rootitemsGET /orders/42/itemsне глубже двух уровней

4. Идентификатор не раскрывает внутренности

4 / 5

UUID удобен для публичных ресурсов и не позволяет перебрать соседние записи. Последовательный id проще для внутреннего API, но не должен становиться секретом авторизации.

/orders/42простой и короткийвидна последовательность/orders/{uuid}публичный идентификаторправа всё равно проверяются

5. Изменение пути - тоже совместимость

5 / 5

Старый адрес не исчезает молча: редирект или поддержка двух вариантов даёт клиентам время. redirect_slashes отключают, чтобы /orders и /orders/ не разъезжались.

GET /order-itemsстарый клиент308 Redirectили два routes/orders/42/itemsновый путь

URL — первое, что видит пользователь вашего API. Хорошо спроектированный URL понятен без документации: GET /orders/{order_id}/items читается как «получи позиции заказа». Плохой URL — это вопросы от каждого нового разработчика и ошибки при интеграции.

В этой статье разберём, как строить URL в FastAPI: какой регистр выбирать, как именовать ресурсы, какой HTTP-метод когда использовать и как не запутаться с вложенностью.

Формат пути: kebab-case и никаких слешей в конце

Представьте, что вы заходите на страницу в браузере: my-site.ru/my-page читается хорошо, my-site.ru/myPage или my-site.ru/my_page — уже вопросы. Для URL REST API действуют те же принципы.

Правила формата пути:

  • всё строчными буквами;
  • слова в сегменте разделяются дефисом: order-items, delivery-addresses;
  • без слеша в конце;
  • без расширений вроде .json — формат передаётся через заголовок Accept.
from fastapi import APIRouter, FastAPI

app = FastAPI(redirect_slashes=False)
router = APIRouter(prefix="/api/v1")

@router.get("/order-items")
async def get_order_items(): ...

@router.get("/delivery-addresses")
async def get_delivery_addresses(): ...

app.include_router(router)

Частые ошибки:

Так писать не надоЧто не такКак надо
/api/v1/OrderItemsзаглавные буквы/api/v1/order-items
/api/v1/order_itemsподчёркивание вместо дефиса/api/v1/order-items
/api/v1/deliveryAddressescamelCase/api/v1/delivery-addresses
/api/v1/orders/слеш в конце/api/v1/orders
/api/v1/orders.jsonрасширение в пути: формат задаёт заголовок Accept/api/v1/orders

Почему redirect_slashes=False обязателен

По умолчанию FastAPI настроен так, что запрос на /orders/ перенаправляется на /orders кодом 307 Temporary Redirect. Это маскирует ошибку: клиент думал, что правильный URL — со слешем, и молча получал редирект. Лучше сразу возвращать ошибку, чтобы разработчик исправил URL.

Выключают редирект на самом приложении, а не на роутере: после include_router маршруты переезжают в корневой роутер, и redirect_slashes=False у APIRouter ни на что не влияет — редирект всё равно сделает приложение.

app = FastAPI(redirect_slashes=False)

Служебные эндпоинты вне версии

/health, /ready, /metrics не относятся к бизнес-API, поэтому живут вне /api/v1:

from fastapi import FastAPI

app = FastAPI()

@app.get("/health")
async def health(): return {"status": "ok"}

@app.get("/ready")
async def ready(): return {"status": "ok"}

Их не версионируют и не закрывают аутентификацией — они для инфраструктуры.

Как именовать ресурсы

REST строится вокруг ресурсов — сущностей, с которыми работает API. Ресурс — это заказ, пользователь, товар. URL должен называть ресурс, а не действие над ним.

Коллекции — во множественном числе:

@router.get("/orders")               # список заказов
@router.get("/orders/{order_id}")    # один заказ
@router.get("/products")             # список товаров

Singleton-ресурс (всегда один на контекст) — в единственном:

@router.get("/users/{user_id}/profile")   # профиль пользователя — он один

Имена берут из домена: если в коде и документации сущность называется Order, то и в URL — /orders. Не /purchases, не /transactions — только то слово, которое используется в проекте.

Сущность доменаРесурс
Order/orders
Product/products
Customer/customers

Когда имена расходятся в коде, документации и URL — каждый разработчик в команде понимает API по-своему. Единство терминологии снижает число вопросов.

Какой HTTP-метод выбрать

В REST метод запроса несёт смысл: он говорит, что вы хотите сделать с ресурсом. В FastAPI метод задаётся декоратором.

@router.get("/orders", status_code=200)
async def get_orders(): ...                        # читаем список

@router.post("/orders", status_code=201)
async def create_order(): ...                      # создаём заказ

@router.put("/orders/{order_id}", status_code=200)
async def replace_order(order_id: str): ...        # полная замена

@router.patch("/orders/{order_id}", status_code=200)
async def update_order(order_id: str): ...         # частичное обновление

@router.delete("/orders/{order_id}", status_code=204)
async def delete_order(order_id: str): ...         # удаляем
ДекораторЧто делаетКод успеха
@router.getчитает ресурс200
@router.postсоздаёт ресурс или выполняет команду201
@router.putполностью заменяет ресурс200
@router.patchчастично обновляет ресурс200
@router.deleteудаляет ресурс204

Частая ошибка: использовать GET для операций с побочным эффектом. GET — только для чтения, он не должен ничего менять.

# Правильно: команда с побочным эффектом — POST
@router.post("/orders/{order_id}/cancel")
async def cancel_order(order_id: str): ...

# Неправильно: GET изменяет состояние
@router.get("/orders/{order_id}/cancel")
async def cancel_order(order_id: str): ...

Вложенность ресурсов

Иногда один ресурс логически принадлежит другому: позиции заказа существуют в контексте заказа. В таких случаях используют вложенные URL.

вложенный путь /customers/{c}/orders/{o}/items/{i} три идентификатора, две сверки связей плоский путь /order-items/{i} один идентификатор, список через ?orderId=

Глубже двух уровней вложенность стоит дороже, чем даёт: три идентификатора и проверки, что они связаны, против одного.

@router.get("/orders/{order_id}/items")
async def get_order_items(order_id: str): ...

@router.get("/orders/{order_id}/items/{item_id}")
async def get_order_item(order_id: str, item_id: str): ...

Два уровня вложенности — нормально. Три и больше — уже сложно читать и поддерживать.

Если нужно выйти за два уровня, лучше перейти к плоскому ресурсу с фильтром:

# Три уровня — сложно
# /users/{user_id}/orders/{order_id}/items/{item_id}

# Лучше: плоский ресурс с параметром
@router.get("/items")
async def get_items(order_id: str = Query(alias="orderId")): ...

Имена параметров пути

При нескольких вложенных ресурсах параметры пути должны иметь уникальные имена, иначе Swagger UI и Redoc не смогут правильно отобразить документацию.

# Правильно: уникальные имена
@router.get("/orders/{order_id}/items/{item_id}")
async def get_order_item(order_id: str, item_id: str): ...

# Неправильно: одинаковые имена
@router.get("/orders/{id}/items/{id}")   # ошибка в инструментах документации
Дополнительно: при первом чтении можно пропустить

Глубже: идентификатор в пути: число или UUIDрасширенное

Выбор влияет не на красоту, а на безопасность и на базу. Последовательное число легко читать и легко перебрать: зная свой заказ 1043, пробуют 1042 и 1044, и если проверка владения в обработчике не стоит, чужие данные уходят наружу. Это уязвимость IDOR, её разбирает статья про владение ресурсом. Число ещё и раскрывает объёмы: конкурент создаёт заказ в понедельник и в пятницу и узнаёт вашу недельную выручку в штуках.

UUID не перебирается и ничего не раскрывает, а FastAPI проверяет его до входа в функцию:

@router.get("/orders/{order_id}")
async def get_order(order_id: UUID) -> OrderResponse: ...

На /orders/42 клиент получит 422, обработчик даже не вызовется. Цена UUID в базе: случайный uuid4 ложится в индекс в произвольные места и портит локальность вставок. С Python 3.14 в стандартной библиотеке есть uuid.uuid7(): первые байты это время, новые ключи идут в конец индекса, и для новых таблиц берут именно его. Хранят в PostgreSQL типом uuid, 16 байт, а не строкой из 36 символов.

Компромисс для старых таблиц с bigint: внутренний числовой ключ остаётся, а наружу отдают отдельный public_id типа UUID с уникальным индексом.

Глубже: если URL всё-таки пришлось менятьрасширенное

Правило «адреса не меняют» иногда приходится нарушить: ресурс переименовали, вложенность исправили. Тогда действуют как с любым ломающим изменением, через период совместимости.

Старый путь отвечает перенаправлением. Для API это 308 Permanent Redirect, а не 301: 308 обязывает клиента повторить тот же метод с тем же телом, тогда как после 301 часть клиентов превращает POST в GET. В FastAPI это один маршрут: return RedirectResponse(url=f"/api/v1/order-items/{item_id}", status_code=308).

Старый путь живёт с заголовками Deprecation и Sunset с датой, как в статье про вывод эндпоинтов из работы, а его использование считают метрикой по шаблону маршрута: пока на старый адрес ходят, убирать его нельзя. Через дату Sunset старый путь отвечает 410 Gone.

Чего не делают: не меняют смысл старого адреса (вчера /orders/{id} отдавал заказ, сегодня список) и не перенаправляют POST с телом на другой хост, где нет ваших cookie и заголовков авторизации.

Коротко

  • Путь — строчными буквами, слова через дефис (order-items), без слеша в конце.
  • FastAPI(redirect_slashes=False) обязателен, иначе приложение молча редиректит ошибочные URL; у APIRouter этот параметр после включения в приложение не действует.
  • Коллекции — во множественном числе (/orders), singleton — в единственном (/profile).
  • Имена ресурсов берут из домена: одно слово — везде.
  • Метод задаётся декоратором: @router.get — чтение, @router.post — создание или команда.
  • GET не должен изменять состояние — для команд с побочным эффектом используют POST.
  • Вложенность — не глубже двух уровней; глубже — плоский ресурс с фильтром.
  • Параметры пути при нескольких уровнях должны иметь уникальные имена.
  • Идентификатор в пути — UUID в сигнатуре (422 до входа в функцию), для новых таблиц uuid.uuid7(); переезд адреса — 308 со старого пути и Sunset, а не молчаливое удаление.

Что почитать дальше