Drop hardcoded OpenAPI servers so self-hosted docs use the request origin

This commit is contained in:
ChethanUK 2026-07-19 20:24:09 +02:00
parent 68c46cde81
commit 4386afc7fb
No known key found for this signature in database
4 changed files with 124 additions and 5 deletions

View File

@ -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

View File

@ -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.""",

View File

@ -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"

106
tests/test_openapi.py Normal file
View File

@ -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