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

Штатные /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="задача не найдена")
+1 -1
View File
@@ -1 +1 @@
__version__ = "0.1.6"
__version__ = "0.1.7"
+61 -8
View File
@@ -113,15 +113,9 @@ class TestHealth:
class TestSchemaExposure:
"""Схема API не требует токена, поэтому по умолчанию она закрыта."""
"""Штатный redoc остаётся выключенным: своя страница только одна."""
def test_docs_hidden_by_default(self, client):
assert client.get("/docs").status_code == 404
def test_openapi_hidden_by_default(self, client):
assert client.get("/openapi.json").status_code == 404
def test_redoc_hidden_by_default(self, client):
def test_redoc_is_off(self, client):
assert client.get("/redoc").status_code == 404
@@ -164,3 +158,62 @@ class TestConfigFileIsNotCode:
src = Path(__file__).resolve().parent.parent / "app" / "config.py"
text = src.read_text(encoding="utf-8")
assert "ЭТО КОД, А НЕ НАСТРОЙКИ" in text
class TestDocsPage:
"""Страница с методами полезна, но открывать её всем подряд незачем."""
def test_docs_available_when_ip_allowed(self, client):
# в тестовом конфиге список адресов пуст = ограничение выключено
assert client.get("/docs").status_code == 200
def test_openapi_available_when_ip_allowed(self, client):
assert client.get("/openapi.json").status_code == 200
def test_docs_need_no_token(self, client):
client.headers.pop("Authorization")
assert client.get("/docs").status_code == 200
def test_schema_declares_bearer_auth(self, client):
schema = client.get("/openapi.json").json()
assert "bearer" in schema["components"]["securitySchemes"]
def test_schema_lists_all_endpoints(self, client):
paths = client.get("/openapi.json").json()["paths"]
assert "/v1/jobs" in paths and "/health" in paths
@pytest.fixture
def restricted_client(tmp_path, monkeypatch):
"""Приложение со списком адресов, куда клиент тестов не входит."""
config = tmp_path / "config.toml"
config.write_text(
f'[server]\nhost="127.0.0.1"\nport=8756\n'
f'[security]\ntoken="{TOKEN}"\nallow_ips="10.1.2.3"\n'
f'[processing]\nthreads=1\n',
encoding="utf-8")
monkeypatch.setenv("TALKSCORE_ASR_HOME", str(tmp_path))
for name in ("sherpa_onnx", "onnx_asr", "onnxruntime"):
monkeypatch.setitem(sys.modules, name, types.ModuleType(name))
for mod in [m for m in list(sys.modules) if m.startswith("app.")]:
del sys.modules[mod]
import app.config as cfg
monkeypatch.setattr(cfg, "BASE_DIR", tmp_path)
import app.main as main
monkeypatch.setattr(main.pipeline, "_ffmpeg", "/bin/true", raising=False)
main._state["ready"] = True
main._worker_stop.set()
with TestClient(main.app) as c:
yield c
class TestDocsAccessControl:
def test_docs_closed_for_foreign_ip(self, restricted_client):
assert restricted_client.get("/docs").status_code == 403
def test_openapi_closed_for_foreign_ip(self, restricted_client):
assert restricted_client.get("/openapi.json").status_code == 403
def test_health_stays_open_for_foreign_ip(self, restricted_client):
"""Мониторинг должен работать всегда."""
assert restricted_client.get("/health").status_code == 200