DealDocumentScreening/src/contract_check/api/routes/README.md
2026-08-24 01:00:11 +03:00

22 KiB
Raw Blame History

API Routers

FastAPI-роутеры сервиса contract_check.api. Все пути — префикс /api/v1 (кроме инфра-эндпоинтов /healthz, /readyz, /metrics).

Роутеры регистрируются в приложении через include_router; теги соответствуют атрибуту tags= каждого APIRouter.

Содержание


Аутентификация

Три независимые схемы, реализованные в api/deps.py:

Зависимость Заголовок Кому выдаётся Где используется
AuthDep Authorization: Bearer <svc-token> Сервисный токен (адаптеры bot/web/cli) /auth/telegram/* — вход от имени бота
CurrentUserDep Authorization: Bearer <user-jwt> JWT пользователя (Telegram-логин) /me, /documents, /reports, /b2b/keys*
ApiKeyAuthDep X-API-Key: <raw-key> B2B-ключ внешнего клиента /analyze, /b2b/reports/*, /b2b/usage
  • Сервисный токен и B2B-ключ хешируются (hash_token, hash_api_key), в БД хранится только хеш; raw-значение возвращается один раз при создании.
  • ApiKeyAuthDep дополнительно применяет token-bucket rate limit и месячный квоты (429 при превышении).
  • CurrentUserDep проверяет, что пользователь ещё есть в таблице users (stateless JWT + defence-in-depth).

health

Файл: health.py. Тег: health. Без аутентификации.

GET /healthz

Liveness-проба. Возвращает 200 всегда.

{ "status": "ok" }

GET /readyz

Readiness-проба. Выполняет SELECT 1 в БД.

{ "status": "ok" }

Если БД недоступна:

{ "status": "not_ready", "reason": "db: <error>" }

metrics

Файл: metrics.py. Тег: metrics. Без аутентификации.

GET /metrics

Exposition-эндпоинт Prometheus. Тело — текст в формате text/plain; version=0.0.4 (prometheus_client.generate_latest).


auth

Файлы: auth/telegram.py, password.py, passkeys.py, magic_links.py, support.py. Тег: auth.

Три источника идентичности сходятся к одному JWT:

POST /api/v1/auth/telegram/bot

Бот (aiogram) обменивает проверенный telegram_id на JWT пользователя. Auth: AuthDep (сервисный токен бота).

Тело — TelegramBotAuthRequest:

Поле Тип Условие Описание
telegram_id int > 0 Verified Telegram user id из aiogram

Ответ 200AuthResponse (см. ниже).

POST /api/v1/auth/telegram/web

Callback Telegram Login Widget. Auth: нет (проверяется подпись Telegram).

Тело — TelegramWebAuthRequest:

Поле Тип Условие
id int > 0
first_name str?
last_name str?
username str?
photo_url str?
auth_date int
hash str

401 при ошибке проверки подписи. Ответ 200AuthResponse.

POST /api/v1/auth/telegram/miniapp

Mini App initData. Auth: нет (проверяется подпись initData).

Тело — TelegramMiniAppAuthRequest:

Поле Тип Описание
init_data str Raw initData query string из Telegram.WebApp

401 при ошибке. Ответ 200AuthResponse.

AuthResponse

{
  "access_token": "<jwt>",
  "token_type": "bearer",
  "expires_in": 3600,
  "user_id": "uuid",
  "telegram_id": 123456789
}

expires_in = jwt_access_ttl_minutes * 60 из настроек.

GET /api/v1/auth/me

Интроспекция Bearer-JWT. Auth: Authorization: Bearer <jwt> в заголовке.

Ответ 200TokenIntrospectResponse:

Поле Тип
sub str (UUID)
telegram_id int
type str
exp int (unix)

401 — отсутствует/невалиден.

GET /api/v1/auth/me/permissions

Заглушка под будущее RBAC.

{ "permissions": ["upload", "read_reports", "read_me"] }

WebUI auth (email + password)

Шесть эндпоинтов для SPA-фронта. Возвращают JWT-пару: короткий access (default 24h) + длинный refresh (default 30d, ttl в Redis). Refresh-token поддерживает отзыв через logout; access невалидируется только истечением TTL.

Метод Путь Auth Описание
POST /api/v1/auth/register Регистрация → 201 + JWT pair
POST /api/v1/auth/login Вход → 200 + JWT pair
POST /api/v1/auth/logout Отзыв refresh-токена (в теле)
GET /api/v1/auth/me Bearer Текущий пользователь (расширенный ответ)
POST /api/v1/auth/forgot-password Запрос сброса (202, всегда одинаковый ответ)
POST /api/v1/auth/reset-password Сброс пароля по токену из письма

POST /api/v1/auth/register

Тело — RegisterRequest:

Поле Тип Условие
email EmailStr валидный email
password str 8..128 символов

409 если email занят. 201TokenPairResponse.

POST /api/v1/auth/login

Тело — LoginRequest (те же поля). 401 при неверных кредах.

TokenPairResponse

{
  "access_token": "<jwt>",
  "refresh_token": "<jwt>",
  "token_type": "bearer",
  "expires_in": 86400,
  "user": {
    "id": "uuid",
    "email": "user@example.com",
    "telegram_id": null,
    "credits_left": 0,
    "is_active": true,
    "created_at": "2026-08-12T..."
  }
}

POST /api/v1/auth/logout

Тело — LogoutRequest:

Поле Тип
refresh_token str

Идемпотентен. Access-токен остаётся валидным до истечения своего TTL (см. JWT_ACCESS_TTL_MINUTES); refresh уничтожается в Redis сразу.

POST /api/v1/auth/forgot-password

Тело — ForgotPasswordRequest (email). Всегда отвечает 202 OkResponse с телом {"ok": true, "detail": "if the email exists, a reset link was sent"} — чтобы не раскрывать, какие адреса зарегистрированы. API:

  1. Генерирует secrets.token_urlsafe(32), хранит только SHA-256 от него в users.password_reset_token_hash с TTL = PASSWORD_RESET_TTL_MINUTES.
  2. Публикует NotificationMessage(kind=password_reset) в очередь notify.q (RabbitMQ).
  3. worker-notify достаёт сообщение и шлёт письмо через SMTP (SMTP_HOST/SMTP_PORT/SMTP_USE_TLS/SMTP_FROM). При пустом SMTP_HOST (dev) тело письма пишется в лог.

POST /api/v1/auth/reset-password

Тело — ResetPasswordRequest:

Поле Тип Условие
token str из письма
password str 8..128 символов

400 при невалидном/просроченном токене. Успех: пароль перезаписывается (argon2id), password_reset_token_hash сбрасывается, все активные refresh-токены этого пользователя отзываются в Redis (принудительный re-login на всех устройствах).

GET /api/v1/auth/me

Расширение прежнего introspect-эндпоинта: теперь читает Bearer access JWT, ищет пользователя в БД и возвращает полный профиль. Старые поля (sub, telegram_id, type, exp) сохранены для совместимости; добавлены email, credits_left, is_active, created_at.

Passwordless-методы веб-аутентификации. Оба выдают один access JWT (без refresh): passkeyAuthenticationResponseSchema / magicLinkVerifyResponseSchema на фронте.

Метод Путь Auth Описание
POST /api/v1/auth/passkeys/register/start Bearer PublicKeyCredentialCreationOptions + challenge в Redis
POST /api/v1/auth/passkeys/register/finish Bearer Проверка attestation → 201, ключ сохранён
POST /api/v1/auth/passkeys/authenticate/start PublicKeyCredentialRequestOptions (discoverable)
POST /api/v1/auth/passkeys/authenticate/finish Проверка assertion → access JWT
GET /api/v1/auth/passkeys Bearer Список пасскеев пользователя
DEL /api/v1/auth/passkeys/{id} Bearer Удалить пасскей (только свой)
POST /api/v1/auth/magic-link/request Одноразовая ссылка входа на email
POST /api/v1/auth/magic-link/verify Обмен токена из письма на access JWT

Детали:

  • Ceremonies — по два шага (start/finish). Challenge живёт в Redis PASSKEY_CHALLENGE_TTL_SECONDS (default 120 c), одноразовый. Регистрация: challenge ключуется user_id; аутентификация — самим challenge (клиент возвращает его в finish-запросе).
  • authenticatorSelection: residentKey=required, userVerification=required → discoverable-креды, allowCredentials пустой; пользователь определяется на finish по credential.id из БД.
  • RP-параметры: PASSKEY_RP_ID / PASSKEY_RP_NAME / PASSKEY_RP_ORIGINS (comma-separated). Флаги PASSKEY_ENABLED, MAGIC_LINK_ENABLED включают/выключают группы эндпоинтов (404 при выключении).
  • Хранение: таблица passkey_credentials (credential_id/public_key — base64url, sign_count — защита от клонирования, ротация на каждом входе).
  • Magic-link повторяет паттерн forgot-password: SHA-256 хеш токена + TTL (MAGIC_LINK_TTL_MINUTES, default 15) в users, письмо через notify.q (kind=magic_link), ссылка {WEB_APP_BASE_URL}/magic-link?token=.... Ответ request всегда одинаковый (не раскрывает зарегистрированные email), verify одноразовый — хеш сбрасывается сразу.

POST /api/v1/auth/passkeys/register/start

Тело (опционально): { "device_name": "YubiKey 5" }. Ответ — PasskeyRegistrationOptions (camelCase, base64url-строки; соответствует zod-схеме фронта): challenge, rp {id, name, origin}, user {id, name, displayName}, pubKeyCredParams[], timeout, attestation="none", excludeCredentials[], authenticatorSelection {residentKey, userVerification}.

POST /api/v1/auth/passkeys/register/finish

Тело: { "credential": {...result of navigator.credentials.create()...}, "device_name"? } (base64url). Ответ 201{ "verified": true, "credentialId": "<base64url>" }. 400 — нет/просрочен challenge, битый attestation; 409 — credential уже зарегистрирован.

POST /api/v1/auth/passkeys/authenticate/start|finish

start — без тела, ответ PasskeyAuthenticationOptions: challenge, timeout, rpId, allowCredentials=[], userVerification. finish — тело { "challenge": "<из start>", "credential": {...result of navigator.credentials.get()...} }; ответ — access JWT (см. ниже). 400 — неизвестный/просроченный challenge или битая подпись; 401 — неизвестный credential; 403 — аккаунт отключён.

GET /api/v1/auth/passkeys / DELETE /api/v1/auth/passkeys/{id}

Список — массив { id, credentialID, credentialPublicKey, counter, userId, deviceName, createdAt }, новые первыми. Удаление чужого/несуществующего — 404, успех — { "ok": true }.

POST /api/v1/auth/magic-link/request / verify

request — тело { "email" }, всегда 200 { success, message, expires_in } (секунды). verify — тело { "token" }, ответ:

{
  "access_token": "<jwt>",
  "token_type": "bearer",
  "expires_in": 86400,
  "user_id": "uuid",
  "email": "user@example.com"
}

400 — невалидный/просроченный токен (протухшие сбрасываются сразу).


me

Файл: me.py. Тег: me. Auth: CurrentUserDep (user JWT).

GET /api/v1/me

Профиль пользователя и баланс кредитов.

{ "telegram_id": 123456789, "credits_left": 7 }

documents

Файл: documents.py. Тег: documents. Auth: CurrentUserDep.

POST /api/v1/documents

Загрузить документ на анализ. Резервирует кредит, кладёт файл в MinIO, создаёт строки documents + jobs, публикует DocumentUploaded в RabbitMQ.

Запрос — multipart/form-data:

Поле Тип Описание
file UploadFile Расширения: .pdf, .docx, .rtf, .txt, .csv, .png, .jpg, .jpeg, .tif, .tiff (см. core/extraction/formats.py)

Ответ 202 (результат upload_and_enqueue):

{
  "document_id": "uuid",
  "correlation_id": "uuid",
  "credits_left": 6
}

Ошибки:

  • 400 — нет имени / неподдерживаемый формат / пустой файл;
  • 402 — нет кредитов (no credits available);
  • 500 — сбой MinIO/БД/RabbitMQ (кредит возвращается).

reports

Файл: reports.py. Тег: reports. Auth: CurrentUserDep.

GET /api/v1/reports/{document_id}

Опрос статуса/результата анализа. Документ обязан принадлежать аутентифицированному пользователю.

Параметр пути: document_id — UUID.

Пока анализ не готов (status != "done"):

{
  "document_id": "uuid",
  "status": "queued | extracting | analyzing | failed",
  "stage": "<current stage>"
}

Готовый отчёт (status == "done"):

{
  "document_id": "uuid",
  "status": "done",
  "filename": "contract.pdf",
  "markdown": "<markdown-отчёт>",
  "findings": { ... },
  "model_used": "<llm model>",
  "prompt_tokens": 1234,
  "eval_tokens": 5678,
  "latency_ms": 91011
}

404 — отчёт не найден (или чужой).


b2b

Файл: b2b.py. Тег: b2b.

Анализ-эндпоинты используют X-API-Key (ApiKeyAuthDep). Эндпоинты управления ключами используют user JWT (CurrentUserDep), т.к. вызываются внутренними адаптерами от имени пользователя (telegram_id).

POST /api/v1/analyze

Загрузить документ под B2B-ключом. Auth: X-API-Key.

Запрос — multipart/form-data (file), как в POST /api/v1/documents.

Резервирует кредит у владельца ключа, пишет api_key_requests, инкрементит api_keys.monthly_used. Логирование usage — best-effort (не валит загрузку при сбое записи).

Ответ 202 — как у POST /api/v1/documents.

GET /api/v1/b2b/reports/{document_id}

Опрос отчёта, scoped к владельцу ключа. Auth: X-API-Key.

Параметр пути: document_id — UUID.

Формат ответа — идентичен GET /api/v1/reports/{document_id}. 404 — чужой/несуществующий.

GET /api/v1/b2b/usage

Текущее использование ключа. Auth: X-API-Key.

{
  "api_key_id": "uuid",
  "rate_limit_rps": 3,
  "monthly_quota": 1000,
  "monthly_used": 17,
  "requests_this_month": 17,
  "resets_at": "2026-09-01T00:00:00+00:00"
}

POST /api/v1/b2b/keys

Создать новый B2B-ключ. Auth: CurrentUserDep.

Тело — CreateApiKeyRequest:

Поле Тип Default Описание
name str Метка ключа
rate_limit_rps int? 3 Должно быть > 0
monthly_quota int? null Без лимита, если не задан

Ответ 201raw-ключ возвращается только один раз:

{
  "api_key": "sk_b2b_...",
  "id": "uuid",
  "name": "prod-webhooks",
  "rate_limit_rps": 3,
  "monthly_quota": null,
  "monthly_used": 0,
  "revoked": false,
  "created_at": "2026-08-12T..."
}

400rate_limit_rps не положителен; 500 — сбой БД.

GET /api/v1/b2b/keys

Список ключей пользователя. Auth: CurrentUserDep.

Ответ — массив объектов формы ApiKeyResponse:

Поле Тип
id UUID
name str
rate_limit_rps int
monthly_quota int?
monthly_used int
revoked bool
created_at ISO-str

Сортировка — created_at DESC. Raw-ключи не возвращаются.

POST /api/v1/b2b/keys/{key_id}/revoke

Отозвать ключ. Auth: CurrentUserDep. Параметр пути: key_id — UUID.

{ "id": "uuid", "revoked": true }

404 — ключ не найден, чужой или уже отозван.

GET /api/v1/b2b/keys/{key_id}/usage

Помесячное использование конкретного ключа. Auth: CurrentUserDep.

{
  "key_id": "uuid",
  "name": "prod-webhooks",
  "rate_limit_rps": 3,
  "monthly_quota": 1000,
  "monthly_used": 17,
  "resets_at": "2026-09-01T...",
  "monthly_requests": [
    { "month": "2026-08-01T...", "requests": 17 }
  ]
}

404 — ключ не найден или чужой.


Коды ошибок

Общий формат ошибки FastAPI:

{ "detail": "<сообщение>" }
Код Когда
400 Неверный запрос (bad format / пустой файл / bad arg)
401 Нет/невалиден токен или API-ключ
402 no credits available (при резерве кредита)
404 Ресурс не найден или чужой
429 Rate limit / месячный квот B2B-ключа исчерпан
500 Сбой инфраструктуры (MinIO/PG/RabbitMQ)

При 429 от rate limiter добавляется заголовок Retry-After.