Drop hardcoded OpenAPI servers so self-hosted docs use the request origin
This commit is contained in:
parent
68c46cde81
commit
4386afc7fb
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.""",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
Loading…
Reference in New Issue