Doc Public API - PlutoGr/TravelAgency GitHub Wiki
Публичный API
Всё, что видно снаружи, проходит через Gateway по префиксу /api/v1. Таблицы ниже собраны из конфигурации маршрутов Gateway (src/Gateway/TravelAgency.Gateway/appsettings.json, секция ReverseProxy), YarpExtensions.cs и контроллеров сервисов, права сверены с обработчиками команд и запросов.
На dev весь сайт дополнительно закрыт basic auth на nginx, поэтому снаружи API доступен только после ввода пароля стенда. Ниже описаны правила самого приложения.
Простой пример
Гость открывает каталог:
GET /api/v1/catalog/tours?country=Турция&sortBy=Price&sortDirection=Asc&page=1&pageSize=20
{
"items": [
{
"id": "3f2a…",
"title": "Неделя в Стамбуле",
"country": "Турция",
"durationDays": 7,
"coverMediaFileId": "9b1c…",
"priceFrom": 52000,
"currency": "RUB",
"nearestDate": "2026-11-10",
"previews": [{ "mediaFileId": "9b1c…", "alt": "Вид на Босфор", "isCover": true, "sortOrder": 0 }]
}
],
"totalCount": 1,
"page": 1,
"pageSize": 20,
"totalPages": 1,
"hasNextPage": false,
"hasPreviousPage": false
}
Фото браузер берёт по GET /api/v1/media/files/9b1c…/w800. Поля ответа сокращены; точный состав в DTO TourSummaryDto и PagedResult<T>.
Общие правила
| Тема | Как устроено |
|---|---|
| Аутентификация | httpOnly cookie access_token и refresh_token. Их ставит Gateway при входе; на каждом запросе Gateway превращает cookie в заголовок Authorization: Bearer. Подробно: Вход и сессия |
| Роли | Client (выдаётся при регистрации), Manager, Admin |
| Формат | JSON, имена полей в camelCase |
| Ошибки | ProblemDetails: type, title, status, detail; у некоторых ошибок есть расширения (например, missing) |
| Перечисления | статус заявки (status, newStatus) передаётся числом: 0 New, 1 InProgress, 2 ProposalSent, 3 Confirmed, 4 Closed, 5 Cancelled |
| Лимиты | на IP: вход, регистрация, обновление токена и выход 5 запросов в секунду, остальное 200 в минуту; сверх лимита 429. Identity добавляет свой лимит 5 попыток за 60 секунд (скользящее окно) на register, login, refresh |
| Версии | ломающие изменения только в /api/v2; новые необязательные поля добавляются в /api/v1 |
Коды ответов
| Код | Когда |
|---|---|
| 400 | ошибка валидации запроса |
| 401 | нет входа или токен истёк (фронтенд сам пробует refresh и повторяет запрос) |
| 403 | вход есть, прав нет (не та роль, чужой тур, чужая заявка) |
| 404 | ресурса нет или он скрыт (черновик тура, непубличное фото, закрытый маршрут presign) |
| 409 | конфликт: устаревшая версия тура, удаление не черновика, дубль email или избранного |
| 413 | файл больше лимита nginx (12 МБ) |
| 422 | тур не проходит проверку публикации, фото чужое, тур недоступен для заявки, недопустимый переход статуса заявки |
| 428 | для изменения тура не передан If-Match |
| 429 | превышен лимит запросов |
| 503 | сервис не смог обратиться к другому сервису по gRPC (например, Catalog к Media при публикации). Исключение: Chat при недоступном Booking отвечает 403 |
Маршруты Gateway
Gateway выбирает маршрут по пути и методу; при пересечении побеждает меньший Order. Затем он убирает префикс и отправляет запрос в сервис.
| Маршрут | Внешний путь | Методы | Доступ на Gateway | Сервис и путь внутри |
|---|---|---|---|---|
auth-route |
/api/v1/auth/** |
GET, POST, PATCH | все | Identity /identity/** |
catalog-wizard-route (Order -1) |
/api/v1/catalog/manage/** |
все | Manager, Admin | Catalog /catalog/manage/** |
catalog-public-route |
/api/v1/catalog/** |
GET | все | Catalog /catalog/** |
catalog-manage-route |
/api/v1/catalog/** |
POST, PUT, PATCH, DELETE | Manager, Admin | Catalog (эндпоинтов записи вне manage нет, запрос до действия не доходит; ответ 404 или 405) |
bookings-route |
/api/v1/bookings/** |
GET, POST, PUT, PATCH, DELETE | вошедший | Booking /bookings/** |
favorites-route |
/api/v1/favorites/** |
GET, POST, DELETE | вошедший | Booking /favorites/** |
chat-hub-route (Order 0) |
/api/v1/chat/hubs/** |
все | вошедший | Chat /hubs/** |
chat-route (Order 1) |
/api/v1/chat/** |
GET, POST | вошедший | Chat /chat/** |
media-files-public-route (Order 0) |
/api/v1/media/files/** |
GET | все | Media /media/files/** |
media-manage-files-route (Order 0) |
/api/v1/media/manage/** |
GET | Manager, Admin | Media /media/manage/** |
media-route (Order 100) |
/api/v1/media/** |
GET, POST, DELETE | вошедший | Media /media/** |
заглушка в YarpExtensions |
/api/v1/media/presign |
GET, POST, PUT, DELETE | закрыт | ответ 404, в сервис не идёт |
«Вошедший» означает любую из ролей Client, Manager, Admin (политика RequireAuthenticated).
Вход и профиль (Identity)
| Метод | Путь | Доступ | Тело | Ответ |
|---|---|---|---|---|
| POST | /api/v1/auth/register |
все | { email, password, firstName, lastName, phone? } |
201, cookie + { "success": true }; 409, если email занят |
| POST | /api/v1/auth/login |
все | { email, password } |
200, cookie + { "success": true } |
| POST | /api/v1/auth/refresh |
все | пусто (refresh-токен берётся из cookie) или { refreshToken } |
200, новые cookie |
| POST | /api/v1/auth/logout |
вошедший | пусто или { refreshToken } |
204, cookie удаляются |
| GET | /api/v1/auth/me |
вошедший | { id, email, firstName, lastName, phone?, role, createdAt } |
|
| PATCH | /api/v1/auth/me |
вошедший | { firstName?, lastName?, phone? } |
обновлённый профиль |
Сами токены в теле ответа не возвращаются: Gateway забирает их из ответа Identity и кладёт в cookie.
Каталог: публичная часть (Catalog)
Отдаются только опубликованные туры (Published).
| Метод | Путь | Доступ | Параметры | Ответ |
|---|---|---|---|---|
| GET | /api/v1/catalog/tours |
все | country, directionId, dateFrom, dateTo, minPrice, maxPrice, tourType, sortBy (CreatedAt, Title, Price, DurationDays), sortDirection (Asc, Desc), page, pageSize (по умолчанию 20) |
PagedResult<TourSummaryDto>: цена «от» и nearestDate (yyyy-MM-dd) по предложениям с днём вылета не раньше сегодня, до 5 превью |
| GET | /api/v1/catalog/tours/{id} |
все | PublicTourDto: описание, программа по дням, включено и не включено, питание, проживание, предложения с ценами, фото, components: []. Черновик и снятый тур: 404 |
|
| GET | /api/v1/catalog/tours/cards?ids=… |
все | до 50 id | карточки для избранного. Снятый тур: только id, title, обложка и available: false. Черновики и несуществующие id в ответ не попадают |
| GET | /api/v1/catalog/directions |
все | список направлений |
dateFrom и dateTo: строка yyyy-MM-dd, обе границы включительно. День это DepartureDate, местный день города вылета. Хвост со временем отбрасывается (DepartureCalendarDate.TryParse), пояс клиента день не сдвигает. «Сегодня» считает TimeProvider в поясе Catalog:DefaultTimeZone (по умолчанию Europe/Moscow): DepartureDate равный сегодня ещё входит в цену «от», фильтр цены и nearestDate.
nearestDate в списке, карточке и на странице тура, и departureDate у предложения (TourPriceDto): строка yyyy-MM-dd. Рядом у предложения остаются validFrom и validTo.
Параметр isActive в фильтре остался от старой модели и на выдачу не влияет: черновики и снятые туры он не открывает.
Каталог: управление турами (Catalog)
Только Manager и Admin. Менеджер работает со своими турами (владелец OwnerId), на чужой получает 403. Admin видит и меняет все туры.
| Метод | Путь | Тело | Ответ |
|---|---|---|---|
| GET | /api/v1/catalog/manage/tours |
список туров: у менеджера свои, у админа все. Фильтров в запросе нет | |
| GET | /api/v1/catalog/manage/tours/{id} |
тур с чек-листом готовности к публикации; версия в заголовке ETag |
|
| POST | /api/v1/catalog/manage/tours |
необязательно: { title?, shortDescription?, description?, departureCity?, country?, tourType?, durationDays?, directionId? } |
201, черновик. If-Match не нужен |
| PUT | …/{id}/basics |
{ title, shortDescription, departureCity, country, tourType, durationDays, directionId } |
тур с новой версией |
| PUT | …/{id}/description |
{ description } |
|
| PUT | …/{id}/program |
{ days: [{ dayNumber, title, description }] } |
|
| PUT | …/{id}/conditions |
{ inclusions: [{ text, kind, sortOrder }], mealPlan, accommodationText } |
|
| PUT | …/{id}/prices |
{ offers: [{ validFrom, validTo, pricePerPerson, currency, availableSeats }] }. Конструктор отправляет validFrom и validTo как yyyy-MM-dd |
тур; у предложения в ответе ещё departureDate (yyyy-MM-dd) |
| PUT | …/{id}/images |
{ images: [{ mediaFileId, sortOrder, isCover, alt }] } |
422, если фото не принадлежит владельцу тура |
| POST | …/{id}/publish |
тур в статусе Published; 422 со списком missing, если не готов |
|
| POST | …/{id}/unpublish |
тур в статусе Unpublished; 409 для черновика |
|
| DELETE | …/{id} |
204; удалить можно только черновик, иначе 409 |
Все запросы, кроме GET и POST черновика, требуют заголовок If-Match с версией из ETag. Без него 428, с устаревшей версией 409. Как это работает: Конструктор и публикация тура.
День вылета в PUT …/prices отдельно не передаётся. Если у validFrom время 00:00:00, departureDate равен этому календарному дню, пояс города не применяется. Если время ненулевое, день считается в поясе города вылета. Города нет в списке DepartureCityTimeZones: пояс Catalog:DefaultTimeZone.
Заявки и избранное (Booking)
| Метод | Путь | Доступ (Gateway и сервис) | Тело | Ответ |
|---|---|---|---|---|
| POST | /api/v1/bookings |
вошедший | { tourId, comment? } |
201, заявка в статусе New, с закреплённым менеджером клиента или в общей очереди; 422 tour-unavailable, если тур не опубликован; 503, если вызов Identity для проверки роли закреплённого менеджера завершился ошибкой, кроме NotFound (заявка не сохраняется) |
| GET | /api/v1/bookings?assigned=me|queue|all |
Manager, Admin | all (по умолчанию): все заявки; me: где текущий пользователь ответственный; queue: без ответственного; другое значение: 400 |
|
| GET | /api/v1/bookings/my |
вошедший | заявки текущего пользователя | |
| GET | /api/v1/bookings/{id} |
клиент только свою (иначе 403), Manager и Admin любую | заявка с предложениями; менеджер и админ дополнительно получают имя и email клиента и assignmentHistory |
|
| PATCH | /api/v1/bookings/{id}/status |
клиент только свою и только в Cancelled; Manager и Admin любые допустимые переходы |
{ newStatus } |
заявка; 400, если newStatus = Confirmed (для этого есть confirm); 422 на недопустимый переход |
| POST | /api/v1/bookings/{id}/proposal |
Manager, Admin | { notes? } |
201, предложение со снимком тура |
| POST | /api/v1/bookings/{id}/confirm |
клиент свою заявку; Manager и Admin тоже могут | { proposalId } |
заявка в статусе Confirmed |
| POST | /api/v1/bookings/{id}/take |
Manager, Admin (клиент: 403) | { expectedManagerId? } |
200 BookingDto, ответственный: текущий пользователь; 404; 409 «Заявку уже взял другой менеджер»; 422, если заявка Closed или Cancelled |
| PUT | /api/v1/bookings/{id}/assigned-manager |
Manager, Admin (клиент: 403) | { managerId, expectedManagerId?, comment? } (comment до 500 символов) |
200 BookingDto; 400 «Указанный пользователь не менеджер.», если Identity пользователя не нашёл или роль не Manager; 404; 409; 422; 503 при другой ошибке вызова Identity |
| GET | /api/v1/favorites |
вошедший | избранное текущего пользователя | |
| POST | /api/v1/favorites/{tourId} |
вошедший | 201; 409, если тур уже в избранном | |
| DELETE | /api/v1/favorites/{tourId} |
вошедший | 204 |
В BookingDto есть assignedManagerId, assignedAt и assignedManagerName. assignmentHistory (id, previousManagerId, newManagerId, changedByUserId, changedAt, comment) заполнен только для Manager и Admin. Подробно: Booking.
Переходы статусов и снимок тура: Заявки и чат.
Чат (Chat)
| Метод | Путь | Доступ | Ответ |
|---|---|---|---|
| GET | /api/v1/chat/booking/{bookingId}/messages |
клиент заявки, Manager, Admin; остальным 403 | список { id, bookingId, senderId, senderName, senderRole, text, attachments?, createdAt } |
| SignalR | /api/v1/chat/hubs/chat |
вошедший | методы JoinBookingGroup(bookingId), SendMessage(bookingId, text, attachments?); событие MessageReceived |
Доступ к заявке проверяется на каждом вызове через Booking.
Файлы (Media)
| Метод | Путь | Доступ | Ответ |
|---|---|---|---|
| POST | /api/v1/media/upload |
вошедший; с ?purpose=tour-image только Manager и Admin |
multipart, поле file. 201: { id, fileName, contentType, sizeBytes, thumbnails: [{ id, width, height }], uploadedAt, width?, height?, isPublic? }. Адресов в ответе нет |
| GET | /api/v1/media/files/{id}/{w200|w800|w1600} |
все | превью фото тура; только для публичных файлов, иначе 404. Cache-Control: public, max-age=31536000, immutable |
| GET | /api/v1/media/manage/files/{id}/{size} |
Manager и Admin на Gateway; в сервисе владелец файла или Admin, иначе 403 | превью черновика. Cache-Control: private, no-store |
| GET | /api/v1/media/{id} |
вошедший. Публичный tour-image, владелец, Manager или Admin. Публичный файл другого purpose и чужой непубличный файл: 404, как неизвестный id |
оригинал потоком. Ответ, кроме публичного фото тура: Cache-Control: private, no-store. Аноним: 401 |
| DELETE | /api/v1/media/{id} |
только владелец файла, иначе 403 | 204, файл и превью удаляются из хранилища |
| любой | /api/v1/media/presign |
закрыт на Gateway | 404 |
Ограничения загрузки: до 10 МБ; общие типы image/jpeg, image/png, image/webp, image/gif, application/pdf; для фото туров только jpeg, png и webp. Содержимое файла сверяется с заявленным типом. Подробно: Картинки.
Служебное
| Путь | Где | Что |
|---|---|---|
/healthz |
nginx | без пароля; Healthy, если Gateway и все сервисы готовы |
/health/live, /health/ready |
Gateway и каждый сервис | внутри сети; снаружи не опубликованы |