Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 17 additions & 4 deletions app/request_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
22 changes: 15 additions & 7 deletions app/routers/error_handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from starlette.exceptions import HTTPException as StarletteHTTPException

from .. import config
from ..request_context import external_origin


class Problem(BaseModel):
Expand All @@ -36,24 +37,31 @@ 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
safe_path = quote(parts.path, safe="/:@&+$,;=-._~")
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):
Expand Down
Loading