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

Штатные /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 pathlib import Path
from fastapi import Depends, FastAPI, File, HTTPException, Query, Request, UploadFile 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.responses import JSONResponse
from fastapi.security import HTTPBearer
from app.config import Settings, load_settings from app.config import Settings, load_settings
from app.pipeline import ModelsMissing, Pipeline, to_wav16k from app.pipeline import ModelsMissing, Pipeline, to_wav16k
@@ -91,16 +94,29 @@ async def lifespan(app: FastAPI):
worker.join(timeout=5) worker.join(timeout=5)
# Схема API не требует токена, поэтому по умолчанию она не публикуется: # Штатные /docs и /openapi.json отключены: они не требуют токена. Вместо них
# знать устройство сервиса посторонним незачем. # ниже свои маршруты, закрытые тем же списком адресов, что и остальной сервис.
app = FastAPI( app = FastAPI(
title="talkscore-asr", title="talkscore-asr",
version="0.1.0", version=__version__,
description=(
"Локальное распознавание русской речи с разделением по говорящим.\n\n"
"Все методы, кроме `/health`, требуют заголовок `Authorization: Bearer <токен>`. "
"Нажмите **Authorize** и вставьте токен из `config.toml`."
),
lifespan=lifespan, lifespan=lifespan,
docs_url="/docs" if settings.docs else None, docs_url=None,
redoc_url="/redoc" if settings.docs else None, redoc_url=None,
openapi_url="/openapi.json" if settings.docs else 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: def guard(request: Request) -> None:
@@ -113,7 +129,29 @@ def guard(request: Request) -> None:
raise HTTPException(status_code=401, detail="неверный или отсутствующий токен") 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: 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( async def create_job(
file: UploadFile = File(...), file: UploadFile = File(...),
speakers: int | None = Query(None, ge=0, le=10, speakers: int | None = Query(None, ge=0, le=10,
@@ -169,7 +211,10 @@ async def create_job(
"queue_position": store.queue_position(job_id)} "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: def get_job(job_id: str) -> dict:
job = store.get(job_id) job = store.get(job_id)
if job is None: if job is None:
@@ -185,7 +230,9 @@ def get_job(job_id: str) -> dict:
return body 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: def delete_job(job_id: str) -> dict:
if store.get(job_id) is None: if store.get(job_id) is None:
raise HTTPException(status_code=404, detail="задача не найдена") 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: class TestSchemaExposure:
"""Схема API не требует токена, поэтому по умолчанию она закрыта.""" """Штатный redoc остаётся выключенным: своя страница только одна."""
def test_docs_hidden_by_default(self, client): def test_redoc_is_off(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):
assert client.get("/redoc").status_code == 404 assert client.get("/redoc").status_code == 404
@@ -164,3 +158,62 @@ class TestConfigFileIsNotCode:
src = Path(__file__).resolve().parent.parent / "app" / "config.py" src = Path(__file__).resolve().parent.parent / "app" / "config.py"
text = src.read_text(encoding="utf-8") text = src.read_text(encoding="utf-8")
assert "ЭТО КОД, А НЕ НАСТРОЙКИ" in text 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