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` |
|
| `LAST_ADMIN` | 409 | операция оставила бы систему без активного `USERS_ADMIN` |
|
||||||
|
|
||||||
Файл импорта больше `IMPORT_MAX_BYTES` — 413 со строкой в `detail`. Недостаточно прав — 403 строкой
|
Файл импорта больше `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 loguru import logger
|
||||||
from miniopy_async import Minio
|
from miniopy_async import Minio
|
||||||
from starlette.middleware.gzip import GZipMiddleware
|
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 uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware
|
||||||
|
|
||||||
from src.api.router import API_PREFIX, build_api_router
|
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(
|
application = FastAPI(
|
||||||
title="АРМ КА",
|
title="АРМ КА",
|
||||||
version=VERSION,
|
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,
|
lifespan=lifespan,
|
||||||
docs_url="/docs" if docs_enabled else None,
|
docs_url="/docs" if docs_enabled else None,
|
||||||
redoc_url="/redoc" 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)
|
_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):
|
class SPAStaticFiles(StaticFiles):
|
||||||
async def get_response(self, path: str, scope):
|
async def get_response(self, path: str, scope: Scope):
|
||||||
# Несуществующий маршрут API — честный 404 (в т.ч. для POST), а не index.html SPA и не 405 от StaticFiles:
|
# Под /api/v1 SPA не отвечает (OQ-61): путь совпал с маршрутом API, но метод другой — 405 с `Allow`;
|
||||||
# иначе dev-маршруты, которых при выключенных флагах нет, выглядели бы как «существующие»
|
# маршрута нет — честный 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:
|
# которых при выключенных флагах нет, выглядели бы как «существующие»
|
||||||
|
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)
|
raise HTTPException(status_code=HTTPStatus.NOT_FOUND)
|
||||||
try:
|
try:
|
||||||
return await super().get_response(path, scope)
|
return await super().get_response(path, scope)
|
||||||
|
|||||||
@ -533,3 +533,83 @@ def test_cors_options_rules() -> None:
|
|||||||
"allow_origins": ["*", ORIGIN],
|
"allow_origins": ["*", ORIGIN],
|
||||||
"allow_credentials": False,
|
"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