diff --git a/routstr/proxy.py b/routstr/proxy.py index 9d2dbf09..ad301a45 100644 --- a/routstr/proxy.py +++ b/routstr/proxy.py @@ -1,8 +1,9 @@ import json +from pathlib import Path from typing import Any from fastapi import APIRouter, Depends, HTTPException, Request -from fastapi.responses import Response, StreamingResponse +from fastapi.responses import HTMLResponse, JSONResponse, Response, StreamingResponse from sqlmodel import select from .algorithm import create_model_mappings @@ -150,10 +151,51 @@ async def refresh_model_maps_periodically() -> None: ) +_API_PATH_PREFIXES = ("v1/", "responses") + +_NOT_FOUND_HTML_FILE = Path(__file__).parent.parent / "ui_out" / "404.html" + + +def _read_not_found_html() -> str | None: + try: + return _NOT_FOUND_HTML_FILE.read_text(encoding="utf-8") + except OSError: + return None + + +_NOT_FOUND_HTML: str | None = _read_not_found_html() + + +def _build_not_found_response(request: Request, path: str) -> Response: + """Return a 404 for unknown paths. + """ + accept = request.headers.get("accept", "").lower() + prefers_json = "application/json" in accept and "text/html" not in accept + request_id = getattr(request.state, "request_id", "unknown") + + if not prefers_json and _NOT_FOUND_HTML is not None: + return HTMLResponse(content=_NOT_FOUND_HTML, status_code=404) + + return JSONResponse( + status_code=404, + content={ + "error": { + "message": f"Path '/{path}' not found", + "type": "not_found", + "code": 404, + }, + "request_id": request_id, + }, + ) + + @proxy_router.api_route("/{path:path}", methods=["GET", "POST"], response_model=None) async def proxy( request: Request, path: str, session: AsyncSession = Depends(get_session) ) -> Response | StreamingResponse: + if not path.startswith(_API_PATH_PREFIXES): + return _build_not_found_response(request, path) + headers = dict(request.headers) is_responses_api = path.startswith("v1/responses") or path.startswith("responses") diff --git a/tests/unit/test_proxy_not_found.py b/tests/unit/test_proxy_not_found.py new file mode 100644 index 00000000..54edd29c --- /dev/null +++ b/tests/unit/test_proxy_not_found.py @@ -0,0 +1,64 @@ +"""Tests for the built-in 404 handler in routstr.proxy.""" + +from __future__ import annotations + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from routstr import proxy +from routstr.proxy import proxy_router + + +def _make_app() -> FastAPI: + app = FastAPI() + app.include_router(proxy_router) + return app + + +@pytest.mark.skipif( + proxy._NOT_FOUND_HTML is None, + reason="UI bundle (ui_out/404.html) not present in this environment", +) +def test_unknown_path_returns_html_404_for_browser() -> None: + client = TestClient(_make_app()) + response = client.get("/some/random/page", headers={"accept": "text/html"}) + assert response.status_code == 404 + assert response.headers["content-type"].startswith("text/html") + assert "404" in response.text + + +def test_unknown_path_returns_json_404_for_api_client() -> None: + client = TestClient(_make_app()) + response = client.get( + "/some/random/page", headers={"accept": "application/json"} + ) + assert response.status_code == 404 + assert response.headers["content-type"].startswith("application/json") + payload = response.json() + assert payload["error"]["type"] == "not_found" + assert payload["error"]["code"] == 404 + assert "/some/random/page" in payload["error"]["message"] + + +def test_root_path_returns_404_for_proxy_router() -> None: + client = TestClient(_make_app()) + response = client.get("/", headers={"accept": "application/json"}) + assert response.status_code == 404 + + +def test_v1_path_is_not_intercepted_by_404_handler() -> None: + """Paths starting with v1/ must reach the proxy logic, not the 404 handler.""" + client = TestClient(_make_app(), raise_server_exceptions=False) + response = client.get("/v1/anything") + if response.status_code == 404: + # Any 404 here must come from inner proxy logic, not our HTML page. + assert "" not in response.text + + +def test_json_returned_when_ui_html_missing(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(proxy, "_NOT_FOUND_HTML", None) + client = TestClient(_make_app()) + response = client.get("/some/random/page", headers={"accept": "text/html"}) + assert response.status_code == 404 + assert response.headers["content-type"].startswith("application/json")