Страница с описанием методов для браузера

Штатные /docs и /openapi.json не требуют токена, поэтому раньше были
выключены совсем. Теперь это свои маршруты, закрытые тем же списком
адресов, что и остальной сервис: со своей машины открываются, с чужой
отдают 403. У методов появились описания, в схеме объявлен Bearer -
работает кнопка Authorize.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vladimir Bryzgalov
2026-08-15 22:23:16 +05:00
co-authored by Claude Opus 5
parent 13cccb18d1
commit e3b4e3c73f
3 changed files with 119 additions and 19 deletions
+57 -10
View File
@@ -9,7 +9,10 @@ from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import Depends, FastAPI, File, HTTPException, Query, Request, UploadFile
from fastapi.openapi.docs import get_swagger_ui_html
from fastapi.openapi.utils import get_openapi
from fastapi.responses import JSONResponse
from fastapi.security import HTTPBearer
from app.config import Settings, load_settings
from app.pipeline import ModelsMissing, Pipeline, to_wav16k
@@ -91,16 +94,29 @@ async def lifespan(app: FastAPI):
worker.join(timeout=5)
# Схема API не требует токена, поэтому по умолчанию она не публикуется:
# знать устройство сервиса посторонним незачем.
# Штатные /docs и /openapi.json отключены: они не требуют токена. Вместо них
# ниже свои маршруты, закрытые тем же списком адресов, что и остальной сервис.
app = FastAPI(
title="talkscore-asr",
version="0.1.0",
version=__version__,
description=(
"Локальное распознавание русской речи с разделением по говорящим.\n\n"
"Все методы, кроме `/health`, требуют заголовок `Authorization: Bearer <токен>`. "
"Нажмите **Authorize** и вставьте токен из `config.toml`."
),
lifespan=lifespan,
docs_url="/docs" if settings.docs else None,
redoc_url="/redoc" if settings.docs else None,
openapi_url="/openapi.json" if settings.docs else None,
docs_url=None,
redoc_url=None,
openapi_url=None,
)
bearer_scheme = HTTPBearer(description="Токен из config.toml, раздел [security]")
def ip_guard(request: Request) -> None:
"""Только проверка адреса: нужна для страницы с описанием методов."""
client_ip = request.client.host if request.client else None
if not ip_allowed(client_ip, allowlist):
raise HTTPException(status_code=403, detail="адрес не в списке разрешённых")
def guard(request: Request) -> None:
@@ -113,7 +129,29 @@ def guard(request: Request) -> None:
raise HTTPException(status_code=401, detail="неверный или отсутствующий токен")
@app.get("/health")
@app.get("/docs", include_in_schema=False)
def docs_page(request: Request):
"""Описание методов для браузера. Открывается только с разрешённых адресов."""
ip_guard(request)
return get_swagger_ui_html(openapi_url="openapi.json", title="talkscore-asr")
@app.get("/openapi.json", include_in_schema=False)
def openapi_schema(request: Request) -> JSONResponse:
ip_guard(request)
schema = get_openapi(title=app.title, version=app.version,
description=app.description, routes=app.routes)
schema["components"] = schema.get("components", {})
schema["components"]["securitySchemes"] = {
"bearer": {"type": "http", "scheme": "bearer"}}
schema["security"] = [{"bearer": []}]
return JSONResponse(schema)
@app.get("/health", summary="Состояние сервиса",
description="Единственный метод без токена - годится для мониторинга. "
"Показывает версию, очередь, число потоков и то, каким сервис "
"видит ваш адрес.")
def health(request: Request) -> JSONResponse:
"""Проверка живости - без токена, чтобы годилась для мониторинга.
@@ -134,7 +172,11 @@ def health(request: Request) -> JSONResponse:
})
@app.post("/v1/jobs", dependencies=[Depends(guard)])
@app.post("/v1/jobs", dependencies=[Depends(guard)], summary="Отправить запись",
description="Принимает аудиофайл любого формата, который читает ffmpeg: "
"mp3, wav, m4a, ogg, opus, wma. Возвращает идентификатор задачи - "
"результат забирается отдельным запросом, потому что обработка "
"занимает минуты.")
async def create_job(
file: UploadFile = File(...),
speakers: int | None = Query(None, ge=0, le=10,
@@ -169,7 +211,10 @@ async def create_job(
"queue_position": store.queue_position(job_id)}
@app.get("/v1/jobs/{job_id}", dependencies=[Depends(guard)])
@app.get("/v1/jobs/{job_id}", dependencies=[Depends(guard)], summary="Забрать результат",
description="Пока задача не готова, возвращает статус и место в очереди. "
"Готовый результат содержит реплики с говорящими и тайм-кодами, "
"статистику разговора и время обработки.")
def get_job(job_id: str) -> dict:
job = store.get(job_id)
if job is None:
@@ -185,7 +230,9 @@ def get_job(job_id: str) -> dict:
return body
@app.delete("/v1/jobs/{job_id}", dependencies=[Depends(guard)])
@app.delete("/v1/jobs/{job_id}", dependencies=[Depends(guard)], summary="Удалить задачу",
description="Убирает задачу и её результат. Завершённые задачи и так "
"удаляются сами через срок из keep_results_hours.")
def delete_job(job_id: str) -> dict:
if store.get(job_id) is None:
raise HTTPException(status_code=404, detail="задача не найдена")