fix: GET / без редиректа на //, 405 на неверный метод под /api/v1 (OQ-61, OQ-62, #9 п.3)
Причина редиректа: `FastAPI(root_path="/")` — путь маршрута для `GET /` после вычитания root_path пуст, монтирование SPA его не находит, и роутер Starlette отвечал 307 на `//`. Теперь `root_path` нормализуется (`/` → "", хвостовой `/` снимается) — `_normalize_root_path` существовал, но не применялся. `SPAStaticFiles` для путей `api/...` сверяется с маршрутами приложения: путь совпал, метод нет (`Match.PARTIAL`) — 405 с заголовком `Allow` и русским `detail`; иначе — прежний 404 (dev-маршруты при выключенных флагах — 404). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
This commit is contained in:
parent
0eedb822e2
commit
4b873468fb
@ -426,7 +426,10 @@ email без учёта регистра проверяет только сер
|
||||
| `LAST_ADMIN` | 409 | операция оставила бы систему без активного `USERS_ADMIN` |
|
||||
|
||||
Файл импорта больше `IMPORT_MAX_BYTES` — 413 со строкой в `detail`. Недостаточно прав — 403 строкой
|
||||
(`недостаточно прав: требуется <ПРАВО>`), нет токена или он неверен — 401.
|
||||
(`недостаточно прав: требуется <ПРАВО>`), нет токена или он неверен — 401. Под `/api/v1` SPA не отвечает: неверный
|
||||
метод на существующий маршрут — 405 `{"detail": "метод не поддерживается этим маршрутом"}` с заголовком `Allow`
|
||||
(например, `POST /api/v1/me`), несуществующий путь (и dev-маршрут при выключенных флагах) — 404 `{"detail": "Not Found"}`.
|
||||
Все остальные пути — SPA: `GET /` и клиентские маршруты (`/admin`) отдают `index.html` без редиректов.
|
||||
|
||||
## Конфигурация
|
||||
|
||||
|
||||
@ -32,6 +32,8 @@ from fastapi.staticfiles import StaticFiles
|
||||
from loguru import logger
|
||||
from miniopy_async import Minio
|
||||
from starlette.middleware.gzip import GZipMiddleware
|
||||
from starlette.routing import Match, Mount, Route
|
||||
from starlette.types import Scope
|
||||
from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware
|
||||
|
||||
from src.api.router import API_PREFIX, build_api_router
|
||||
@ -212,7 +214,9 @@ def create_app(app_settings: Settings, *, web_dir: str = "web") -> FastAPI:
|
||||
application = FastAPI(
|
||||
title="АРМ КА",
|
||||
version=VERSION,
|
||||
root_path=app_settings.ROOT_PATH,
|
||||
# `/` → "" (OQ-62): при root_path "/" путь маршрута для `GET /` вычисляется пустым, монтирование SPA его не
|
||||
# находит, и роутер отвечал редиректом на `//`
|
||||
root_path=_normalize_root_path(app_settings.ROOT_PATH),
|
||||
lifespan=lifespan,
|
||||
docs_url="/docs" if docs_enabled else None,
|
||||
redoc_url="/redoc" if docs_enabled else None,
|
||||
@ -254,11 +258,40 @@ API_STATIC_PATH = API_PREFIX.strip("/")
|
||||
_DOC_STATIC_PATHS = frozenset(path.strip("/") for path in DOC_PATHS)
|
||||
|
||||
|
||||
METHOD_NOT_ALLOWED_DETAIL = "метод не поддерживается этим маршрутом"
|
||||
|
||||
|
||||
def _allowed_methods(scope: Scope) -> set[str]:
|
||||
"""Методы маршрутов приложения, чей путь совпал с запросом, а метод — нет (`Match.PARTIAL`).
|
||||
|
||||
SPA смонтировано на `/` и совпадает с любым запросом полностью, поэтому роутер Starlette до своего 405 не доходит:
|
||||
неверный метод на существующий маршрут API попадает сюда. Монтирования (в том числе само SPA) не учитываются.
|
||||
"""
|
||||
allowed: set[str] = set()
|
||||
for route in scope["app"].router.routes:
|
||||
if isinstance(route, Mount) or not isinstance(route, Route):
|
||||
continue
|
||||
match, _ = route.matches(scope)
|
||||
if match is Match.PARTIAL and route.methods:
|
||||
allowed |= route.methods
|
||||
return allowed
|
||||
|
||||
|
||||
class SPAStaticFiles(StaticFiles):
|
||||
async def get_response(self, path: str, scope):
|
||||
# Несуществующий маршрут API — честный 404 (в т.ч. для POST), а не index.html SPA и не 405 от StaticFiles:
|
||||
# иначе dev-маршруты, которых при выключенных флагах нет, выглядели бы как «существующие»
|
||||
if path == API_STATIC_PATH or path.startswith(API_STATIC_PATH + "/") or path in _DOC_STATIC_PATHS:
|
||||
async def get_response(self, path: str, scope: Scope):
|
||||
# Под /api/v1 SPA не отвечает (OQ-61): путь совпал с маршрутом API, но метод другой — 405 с `Allow`;
|
||||
# маршрута нет — честный 404 (в т.ч. для POST), а не index.html SPA и не 405 от StaticFiles: иначе dev-маршруты,
|
||||
# которых при выключенных флагах нет, выглядели бы как «существующие»
|
||||
if path == API_STATIC_PATH or path.startswith(API_STATIC_PATH + "/"):
|
||||
allowed = _allowed_methods(scope)
|
||||
if allowed:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.METHOD_NOT_ALLOWED,
|
||||
detail=METHOD_NOT_ALLOWED_DETAIL,
|
||||
headers={"Allow": ", ".join(sorted(allowed))},
|
||||
)
|
||||
raise HTTPException(status_code=HTTPStatus.NOT_FOUND)
|
||||
if path in _DOC_STATIC_PATHS:
|
||||
raise HTTPException(status_code=HTTPStatus.NOT_FOUND)
|
||||
try:
|
||||
return await super().get_response(path, scope)
|
||||
|
||||
@ -533,3 +533,83 @@ def test_cors_options_rules() -> None:
|
||||
"allow_origins": ["*", ORIGIN],
|
||||
"allow_credentials": False,
|
||||
}
|
||||
|
||||
|
||||
# --- GET / без редиректа на //, 405 и 404 под /api/v1 (OQ-61, OQ-62, issue #9 п.3) ---------------------------------
|
||||
|
||||
SPA_HTML = "<html>АРМ КА SPA</html>"
|
||||
|
||||
|
||||
def _spa_app(tmp_path: Any, **update: Any) -> Any:
|
||||
(tmp_path / "index.html").write_text(SPA_HTML, encoding="utf-8")
|
||||
return _app_with(tmp_path, **update)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("root_path", ["/", ""])
|
||||
async def test_root_serves_spa_index_without_redirect(tmp_path: Any, root_path: str) -> None:
|
||||
response = await _request(_spa_app(tmp_path, ROOT_PATH=root_path), "GET", "/")
|
||||
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert response.text == SPA_HTML
|
||||
assert "location" not in response.headers
|
||||
|
||||
|
||||
async def test_root_path_prefix_serves_spa(tmp_path: Any) -> None:
|
||||
"""ROOT_PATH за прокси (`/armka`, с хвостовым `/` или без): корень приложения — index.html."""
|
||||
for root_path in ("/armka", "/armka/"):
|
||||
response = await _request(_spa_app(tmp_path, ROOT_PATH=root_path), "GET", "/armka/")
|
||||
assert response.status_code == HTTPStatus.OK, root_path
|
||||
assert response.text == SPA_HTML
|
||||
|
||||
|
||||
async def test_spa_client_route_serves_index(tmp_path: Any) -> None:
|
||||
response = await _request(_spa_app(tmp_path), "GET", "/admin")
|
||||
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert response.text == SPA_HTML
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("method", "path", "allowed"),
|
||||
[
|
||||
("POST", "/api/v1/me", {"GET"}),
|
||||
("DELETE", "/api/v1/me", {"GET"}),
|
||||
("PATCH", "/api/v1/directories/company-groups", {"GET", "POST"}),
|
||||
("GET", "/api/v1/directories/import", {"POST"}),
|
||||
("PUT", "/api/v1/directories/company-groups/00000000-0000-0000-0000-000000000001", {"GET", "PATCH"}),
|
||||
("GET", "/api/v1/dev/token", {"POST"}), # dev-эмитент включён в .env.test
|
||||
],
|
||||
)
|
||||
async def test_wrong_method_on_existing_api_route_is_405(
|
||||
tmp_path: Any, method: str, path: str, allowed: set[str]
|
||||
) -> None:
|
||||
response = await _request(_spa_app(tmp_path), method, path)
|
||||
|
||||
assert response.status_code == HTTPStatus.METHOD_NOT_ALLOWED
|
||||
assert {m.strip() for m in response.headers["allow"].split(",")} == allowed
|
||||
assert response.json() == {"detail": "метод не поддерживается этим маршрутом"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("method", "path"),
|
||||
[
|
||||
("GET", "/api/v1/no-such-route"),
|
||||
("POST", "/api/v1/no-such-route"),
|
||||
("GET", "/api/v1"),
|
||||
("GET", "/api/v1/"),
|
||||
("GET", "/api/v1/me/extra"),
|
||||
],
|
||||
)
|
||||
async def test_unknown_api_path_is_404(tmp_path: Any, method: str, path: str) -> None:
|
||||
response = await _request(_spa_app(tmp_path), method, path)
|
||||
|
||||
assert response.status_code == HTTPStatus.NOT_FOUND
|
||||
assert SPA_HTML not in response.text
|
||||
|
||||
|
||||
async def test_dev_route_absent_without_debug_is_404_not_405(tmp_path: Any) -> None:
|
||||
"""Поведение Среза 0: при выключенных флагах dev-маршрута нет в таблице — 404 на любой метод."""
|
||||
application = _spa_app(tmp_path, DEBUG=False)
|
||||
for method in ("GET", "POST"):
|
||||
response = await _request(application, method, "/api/v1/dev/token")
|
||||
assert response.status_code == HTTPStatus.NOT_FOUND, method
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user