From 7b9f8f00f510c0ec3cdda3b381ddc6a9d2d2e52d Mon Sep 17 00:00:00 2001 From: Justas Balcas Date: Mon, 28 Sep 2026 08:57:05 -0500 Subject: [PATCH] Build problem type/instance URLs from forwarded host, proto and prefix --- app/request_context.py | 21 +++++++++++++++++---- app/routers/error_handlers.py | 22 +++++++++++++++------- 2 files changed, 32 insertions(+), 11 deletions(-) diff --git a/app/request_context.py b/app/request_context.py index cc8c8828..005c2b3b 100644 --- a/app/request_context.py +++ b/app/request_context.py @@ -13,15 +13,28 @@ def _first_header_value(value: str | None) -> str: return (value or "").split(",")[0].strip() -def set_api_url_base(request: Request) -> None: - """Set the per-request API URL base from forwarding headers.""" +def external_origin(request: Request) -> str | None: + """Return ``proto://host/prefix`` as the client addressed this API, from forwarding headers. + + Every URL the API hands back (self-links, problem ``type`` / ``instance``) is built on + this, so a gateway that mounts the API under a path (``X-Forwarded-Prefix``) gets links + that work through it. Returns None when no host is known. + """ host = _first_header_value(request.headers.get("x-forwarded-host") or request.headers.get("host", "")) + if not host: + return None proto = _first_header_value(request.headers.get("x-forwarded-proto") or request.url.scheme) prefix = _first_header_value(request.headers.get("x-forwarded-prefix") or request.headers.get("x-script-name")).rstrip("/") + return f"{proto}://{host}{prefix}" + + +def set_api_url_base(request: Request) -> None: + """Set the per-request API URL base from forwarding headers.""" + origin = external_origin(request) api_prefix = config.API_PREFIX.rstrip("/") api_url = config.API_URL.strip("/") - if host: - _api_url_base.set(f"{proto}://{host}{prefix}{api_prefix}/{api_url}") + if origin: + _api_url_base.set(f"{origin}{api_prefix}/{api_url}") def get_url_prefix() -> str: diff --git a/app/routers/error_handlers.py b/app/routers/error_handlers.py index be61ef41..d5990a9a 100644 --- a/app/routers/error_handlers.py +++ b/app/routers/error_handlers.py @@ -14,6 +14,7 @@ from starlette.exceptions import HTTPException as StarletteHTTPException from .. import config +from ..request_context import external_origin class Problem(BaseModel): @@ -36,16 +37,23 @@ def allowed_methods_for_path(app: FastAPI, path: str) -> list[str]: return sorted(methods) +def _request_origin(request: Request) -> str: + """Return ``proto://host/prefix`` as the client addressed this API (see request_context.external_origin). + + Read from the request itself, not the per-request context variable: the 500 + handler runs outside the middleware that sets it. + """ + parts = urlsplit(str(request.url)) + return external_origin(request) or f"{parts.scheme}://{parts.netloc}" + + def get_url_base(request: Request) -> str: - """Return the base URL for the API.""" - # If behind a proxy (and x-forwarded-* headers present), use the forwarded host and protocol - host = (request.headers.get("x-forwarded-host") or request.headers.get("host", "")).split(",")[0].strip() - proto = (request.headers.get("x-forwarded-proto") or request.url.scheme).split(",")[0].strip() - return f"{proto}://{host}/problems" + """Return the base URL for problem types, honoring the gateway's forwarded host, proto and prefix.""" + return f"{_request_origin(request)}/problems" def safe_instance_url(request: Request) -> str: - """Return a URL-safe version of the request URL for the 'instance' field.""" + """Return the URL-safe request URL, as the client addressed it, for the 'instance' field.""" parts = urlsplit(str(request.url)) # Encode unsafe characters in each component @@ -53,7 +61,7 @@ def safe_instance_url(request: Request) -> str: safe_query = quote(parts.query, safe="=&?/:@+$,;=-._~") safe_fragment = quote(parts.fragment, safe="=&?/:@+$,;=-._~") - return urlunsplit((parts.scheme, parts.netloc, safe_path, safe_query, safe_fragment)) + return _request_origin(request) + urlunsplit(("", "", safe_path, safe_query, safe_fragment)) def problem_response(*, request: Request, status: int, title, detail, problem_type: str, invalid_params=None, extra_headers=None):