Страница с описанием методов для браузера
Штатные /docs и /openapi.json не требуют токена, поэтому раньше были выключены совсем. Теперь это свои маршруты, закрытые тем же списком адресов, что и остальной сервис: со своей машины открываются, с чужой отдают 403. У методов появились описания, в схеме объявлен Bearer - работает кнопка Authorize. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
13cccb18d1
commit
e3b4e3c73f
+57
-10
@@ -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
@@ -1 +1 @@
|
||||
__version__ = "0.1.6"
|
||||
__version__ = "0.1.7"
|
||||
|
||||
+61
-8
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user