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/deliveryAddresses | camelCase | /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.
Глубже двух уровней вложенность стоит дороже, чем даёт: три идентификатора и проверки, что они связаны, против одного.
@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, а не молчаливое удаление.
Что почитать дальше
- Версионирование REST API на FastAPI — как использовать
prefix="/api/v1"и переходить на v2. - Query-параметры и пагинация — фильтры,
Query(alias=...), курсорная пагинация. - OpenAPI и антипаттерны —
operation_id,tags, типичные ошибки в схеме.