From 4386afc7fb74a045c3be8af844fac5ed943d65e3 Mon Sep 17 00:00:00 2001 From: ChethanUK Date: Sun, 19 Jul 2026 20:24:09 +0200 Subject: [PATCH] Drop hardcoded OpenAPI servers so self-hosted docs use the request origin --- docs/v3/contributing/self-hosting.mdx | 14 +++- src/main.py | 7 +- tests/conftest.py | 2 + tests/test_openapi.py | 106 ++++++++++++++++++++++++++ 4 files changed, 124 insertions(+), 5 deletions(-) create mode 100644 tests/test_openapi.py diff --git a/docs/v3/contributing/self-hosting.mdx b/docs/v3/contributing/self-hosting.mdx index b062a766..d9cb4c68 100644 --- a/docs/v3/contributing/self-hosting.mdx +++ b/docs/v3/contributing/self-hosting.mdx @@ -270,7 +270,19 @@ If you get back a workspace object with an `id`, your database is connected and ### 3. API Documentation -Visit `http://localhost:8000/docs` to see the interactive API documentation. +Visit `http://localhost:8000/docs` to see the interactive API documentation. If you mapped the API to +a different host port or reach it over a hostname, use that origin instead — the docs page and its +"Try it out" button always send requests to the origin you loaded `/docs` from. + +The one case that needs configuration is a reverse proxy that **strips a path prefix** before +forwarding (e.g. the proxy serves `https://example.com/api/...` but Honcho receives `/...`). Tell the +app about the prefix with `--root-path`, and it will advertise it in the schema: + +```bash +fastapi run src/main.py --root-path /api +``` + +A proxy that forwards the path unchanged needs no `--root-path`. ### 4. Test with SDK diff --git a/src/main.py b/src/main.py index b0611f59..24c33366 100644 --- a/src/main.py +++ b/src/main.py @@ -164,10 +164,9 @@ async def lifespan(_: FastAPI): app = FastAPI( lifespan=lifespan, - servers=[ - {"url": "https://api.honcho.dev", "description": "Production SaaS Platform"}, - {"url": "http://localhost:8000", "description": "Local Development Server"}, - ], + # No `servers`: FastAPI then omits the OpenAPI `servers` key, so Swagger UI targets + # the origin /docs was served from -- correct for any self-hosted deployment. Behind a + # prefix-stripping proxy, run with --root-path; FastAPI advertises it automatically. title="Honcho API", summary="The Identity Layer for the Agentic World", description="""Honcho is a platform for giving agents user-centric memory and social cognition.""", diff --git a/tests/conftest.py b/tests/conftest.py index af35c999..0d101b72 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -88,6 +88,8 @@ _RUNTIME_MOCK_TEST_BLOCKLIST_PREFIXES = ( # Pure JWT scope tests — operate on src.security directly, no DB needed. "tests/test_security.py", "tests/test_generate_jwt_script.py", + # Pure OpenAPI schema tests — serving /openapi.json touches no DB. + "tests/test_openapi.py", ) _LIVE_LLM_MARKER = "live_llm" diff --git a/tests/test_openapi.py b/tests/test_openapi.py new file mode 100644 index 00000000..d5db8197 --- /dev/null +++ b/tests/test_openapi.py @@ -0,0 +1,106 @@ +"""OpenAPI schema policy tests. + +Regression coverage for #875: the app declared a hardcoded ``servers`` list +(``https://api.honcho.dev`` + ``http://localhost:8000``), which is baked into the +schema at code-definition time and drives the "Servers" dropdown in Swagger UI. +Any self-hosted deployment not reachable at exactly ``localhost:8000`` -- a +remapped host port, a LAN hostname, a reverse proxy -- got a dropdown where no +entry matched reality, so "Try it out" sent requests to the wrong origin. + +Omitting ``servers`` entirely lets Swagger UI fall back to the origin the ``/docs`` +page was served from, which is correct for every deployment topology. FastAPI still +advertises ``root_path`` automatically for prefix-stripping proxies. + +These tests drive ``GET /openapi.json`` -- the same endpoint the browser fetches to +populate the dropdown -- rather than calling ``app.openapi()`` directly, so they also +cover the ``root_path`` server entry, which is injected per-request by the route +handler and is invisible to ``app.openapi()``. +""" + +from typing import Any +from urllib.parse import urlparse + +import pytest +from fastapi.testclient import TestClient + +from src.main import app + +# Deployment topologies a self-hoster actually runs. `root_path` is what the app is +# told about a proxy prefix; `origin` is where the browser reaches the API. +DEPLOYMENT_TOPOLOGIES: list[tuple[str, str, str]] = [ + # (test id, root_path, browser origin) + ("port_remap_8070", "", "http://192.168.1.50:8070"), # the #875 repro + ("lan_hostname", "", "http://honcho.lan:9000"), + ( + "localhost_default", + "", + "http://localhost:8000", + ), # the only case that ever worked + ("proxy_prefix", "/api", "https://honcho.example.com"), + ("proxy_prefix_trailing_slash", "/api/", "https://honcho.example.com"), + ("proxy_root_slash", "/", "https://honcho.example.com"), # rstrip("/") -> no entry +] + + +def _get_schema(root_path: str, origin: str) -> dict[str, Any]: + """Fetch /openapi.json the way a browser at `origin` would. + + Deliberately not used as a context manager: serving the schema needs no + database or cache, and running the lifespan handler would drag both in. + """ + client = TestClient(app, base_url=origin, root_path=root_path) + response = client.get("/openapi.json") + assert response.status_code == 200 + return response.json() + + +@pytest.mark.parametrize( + ("root_path", "origin"), + [pytest.param(rp, origin, id=tid) for tid, rp, origin in DEPLOYMENT_TOPOLOGIES], +) +def test_openapi_advertises_no_foreign_origin(root_path: str, origin: str) -> None: + """No server entry may name an absolute origin. + + An absolute URL is a guess about where the API lives; it is wrong for every + deployment that isn't the one guessed. Relative entries (or none at all) resolve + against the origin the schema was fetched from, which is always right. + """ + servers: list[dict[str, str]] = _get_schema(root_path, origin).get("servers", []) + + for server in servers: + url = server.get("url", "") + parsed = urlparse(url) + assert not parsed.scheme and not parsed.netloc, ( + f"schema fetched from {origin} advertises absolute origin {url!r}; " + f"a client at {origin} would send requests there instead of to " + f"{origin} (#875)" + ) + + +def test_openapi_omits_servers_without_root_path() -> None: + """Plain deployment: no `servers` key, so Swagger UI uses the docs page origin.""" + schema = _get_schema(root_path="", origin="http://192.168.1.50:8070") + + assert "servers" not in schema + + +@pytest.mark.parametrize( + ("root_path", "expected"), + [ + pytest.param("/api", [{"url": "/api"}], id="prefix"), + pytest.param("/api/", [{"url": "/api"}], id="prefix_trailing_slash_stripped"), + pytest.param("/", None, id="root_slash_is_not_a_prefix"), + pytest.param("", None, id="no_prefix"), + ], +) +def test_openapi_advertises_root_path_for_proxies( + root_path: str, expected: list[dict[str, str]] | None +) -> None: + """Behind a prefix-stripping proxy, FastAPI advertises `root_path` on its own. + + This is what replaces the hardcoded list for proxied deployments, so it must keep + working once `servers` is gone. + """ + schema = _get_schema(root_path, origin="https://honcho.example.com") + + assert schema.get("servers") == expected