From 0adad147f5ae8e06450635ea19314ce49b2d4fcb Mon Sep 17 00:00:00 2001 From: Junwei Zhao Date: Mon, 23 Feb 2026 17:12:35 +1100 Subject: [PATCH] fix: dynamic API docs URLs match browser address OpenAPI schema now derives the server URL from the incoming request (Host header + X-Forwarded-Proto/X-Forwarded-Host for reverse proxies). Curl examples in API docs use $BASE_URL placeholder which is replaced at serve-time with the actual origin, so docs work correctly behind nginx or any reverse proxy with a custom domain. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- backend/app/api/routes.py | 16 +++++++------- backend/app/main.py | 46 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 53 insertions(+), 9 deletions(-) diff --git a/backend/app/api/routes.py b/backend/app/api/routes.py index bdcea12..59458ae 100644 --- a/backend/app/api/routes.py +++ b/backend/app/api/routes.py @@ -25,7 +25,7 @@ router = APIRouter() **Example (curl):** ```bash -curl 'http://localhost:8000/api/search?q=hello+world&category=web&page=1' +curl '$BASE_URL/api/search?q=hello+world&category=web&page=1' ``` **Example response (truncated):** @@ -53,7 +53,7 @@ curl 'http://localhost:8000/api/search?q=hello+world&category=web&page=1' **Image search with size filter:** ```bash -curl 'http://localhost:8000/api/search?q=cats&category=images&image_size=large' +curl '$BASE_URL/api/search?q=cats&category=images&image_size=large' ``` """, tags=["Search"], @@ -88,7 +88,7 @@ class AutocompleteResponse(BaseModel): **Example:** ```bash -curl 'http://localhost:8000/api/autocomplete?q=pyth' +curl '$BASE_URL/api/autocomplete?q=pyth' ``` **Response:** @@ -118,7 +118,7 @@ async def api_autocomplete( **Example:** ```bash -curl 'http://localhost:8000/api/engines' +curl '$BASE_URL/api/engines' ``` """, tags=["Engines"], @@ -139,7 +139,7 @@ class EngineToggleRequest(BaseModel): **Example:** ```bash -curl -X PUT 'http://localhost:8000/api/engines/google' \\ +curl -X PUT '$BASE_URL/api/engines/google' \\ -H 'Content-Type: application/json' \\ -d '{"enabled": false}' ``` @@ -177,7 +177,7 @@ class AddDomainRequest(BaseModel): **Example:** ```bash -curl 'http://localhost:8000/api/excluded-domains' +curl '$BASE_URL/api/excluded-domains' ``` """, tags=["Exclusions"], @@ -194,7 +194,7 @@ async def api_list_excluded_domains(): **Example:** ```bash -curl -X POST 'http://localhost:8000/api/excluded-domains' \\ +curl -X POST '$BASE_URL/api/excluded-domains' \\ -H 'Content-Type: application/json' \\ -d '{"domain": "example.com"}' ``` @@ -214,7 +214,7 @@ async def api_add_excluded_domain(body: AddDomainRequest): **Example:** ```bash -curl -X DELETE 'http://localhost:8000/api/excluded-domains/example.com' +curl -X DELETE '$BASE_URL/api/excluded-domains/example.com' ``` """, tags=["Exclusions"], diff --git a/backend/app/main.py b/backend/app/main.py index 0da015c..fbd05a0 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,11 +1,13 @@ """Hey Search - A metasearch engine.""" +import json import os import time from pathlib import Path from contextlib import asynccontextmanager from fastapi import FastAPI, Request +from fastapi.openapi.utils import get_openapi from fastapi.responses import JSONResponse from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles @@ -28,6 +30,8 @@ app = FastAPI( description="A privacy-respecting metasearch engine. See endpoints below for usage with curl examples.", version="1.4.0", lifespan=lifespan, + # Disable the default /openapi.json — we serve a dynamic one below + openapi_url=None, ) app.add_middleware( @@ -39,6 +43,47 @@ app.add_middleware( ) +# Cache the base schema (without servers) so we only compute it once +_openapi_schema_cache: dict | None = None + + +def _get_base_openapi_schema() -> dict: + """Generate the OpenAPI schema once and cache it.""" + global _openapi_schema_cache + if _openapi_schema_cache is None: + _openapi_schema_cache = get_openapi( + title=app.title, + version=app.version, + description=app.description, + routes=app.routes, + ) + return _openapi_schema_cache + + +@app.get("/openapi.json", include_in_schema=False) +async def dynamic_openapi(request: Request): + """Serve OpenAPI schema with servers[] matching the caller's origin.""" + base = _get_base_openapi_schema() + + # Derive the base URL from the request + proto = request.headers.get("x-forwarded-proto", request.url.scheme) + host = request.headers.get("x-forwarded-host") or request.headers.get("host", "localhost") + base_url = f"{proto}://{host}" + + # Deep-replace $BASE_URL in all description strings and set servers + raw = json.dumps(base) + raw = raw.replace("$BASE_URL", base_url) + schema = json.loads(raw) + + schema["servers"] = [{"url": base_url, "description": "Current server"}] + return schema + + +# Wire Swagger UI and Redoc to our dynamic endpoint +app.openapi_url = "/openapi.json" +app.setup() + + @app.middleware("http") async def add_rate_limit_headers(request: Request, call_next): """Add rate-limit placeholder and timing headers.""" @@ -46,7 +91,6 @@ async def add_rate_limit_headers(request: Request, call_next): response = await call_next(request) elapsed_ms = round((time.monotonic() - start) * 1000) response.headers["X-Response-Time-Ms"] = str(elapsed_ms) - # Rate-limit headers (placeholder values — no enforced limiting yet) response.headers["X-RateLimit-Limit"] = "60" response.headers["X-RateLimit-Remaining"] = "59" return response