Skip to content

Commit d8c9984

Browse files
rustyconoverclaude
andcommitted
Extract parameter descriptions from docstrings for describe page
Parse Google-style Args: sections from Protocol method docstrings using docstring-parser and surface them through introspection and the HTML describe page. Adds param_docs field to RpcMethodInfo and MethodDescription, bumps DESCRIBE_VERSION to 3, and adds a Description column to the parameters table on the describe page. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 78481e3 commit d8c9984

10 files changed

Lines changed: 799 additions & 31 deletions

File tree

docs/api/http.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,45 @@ with http_connect(MyService, client=client) as proxy:
4040
assert proxy.echo(message="hello") == "hello"
4141
```
4242

43+
### Landing Page
44+
45+
By default, `GET {prefix}` (e.g. `GET /vgi`) returns an HTML landing page showing the `vgi-rpc` logo, the protocol name, server ID, and links. When the server has `enable_describe=True`, the landing page includes a link to the [describe page](#describe-page).
46+
47+
To disable the landing page:
48+
49+
```python
50+
app = make_wsgi_app(server, enable_landing_page=False)
51+
```
52+
53+
`POST {prefix}` returns 405 Method Not Allowed — it does not interfere with RPC routing.
54+
55+
### Describe Page
56+
57+
When the server has `enable_describe=True`, `GET {prefix}/describe` (e.g. `GET /vgi/describe`) returns an HTML page listing all methods, their parameters (name, type, default), return types, docstrings, and method type badges (UNARY / STREAM). The `__describe__` introspection method is filtered out.
58+
59+
Both `enable_describe=True` on the `RpcServer` **and** `enable_describe_page=True` (the default) on `make_wsgi_app()` are required.
60+
61+
To disable only the HTML page while keeping the `__describe__` RPC method available:
62+
63+
```python
64+
app = make_wsgi_app(server, enable_describe_page=False)
65+
```
66+
67+
!!! note "Reserved path"
68+
When the describe page is active, the path `{prefix}/describe` is reserved for the HTML page. If your service has an RPC method literally named `describe`, you must set `enable_describe_page=False`.
69+
70+
### Not-Found Page
71+
72+
By default, `make_wsgi_app()` installs a friendly HTML 404 page for any request that does not match an RPC route. If someone navigates to the server root or a random path in a browser, they see the `vgi-rpc` logo, the service protocol name, and a link to [vgi-rpc.query.farm](https://vgi-rpc.query.farm) instead of a generic error.
73+
74+
This does **not** affect RPC clients — a request to a valid RPC route for a non-existent method still returns a machine-readable Arrow IPC error with HTTP 404.
75+
76+
To disable the page:
77+
78+
```python
79+
app = make_wsgi_app(server, enable_not_found_page=False)
80+
```
81+
4382
## API Reference
4483

4584
### Server

docs/hosting.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,9 @@ app = make_wsgi_app(
4646
| `cors_origins` | Enable CORS for browser clients | None (disabled) |
4747
| `upload_url_provider` | Enable pre-signed upload URL vending | None (disabled) |
4848
| `otel_config` | [OpenTelemetry](api/otel.md) instrumentation | None (disabled) |
49+
| `enable_not_found_page` | Friendly HTML 404 for unmatched routes | `True` |
50+
| `enable_landing_page` | HTML landing page at `GET {prefix}` | `True` |
51+
| `enable_describe_page` | HTML describe page at `GET {prefix}/describe` | `True` (requires `enable_describe=True`) |
4952

5053
!!! warning "Signing key in multi-worker deployments"
5154
Stream state tokens are signed with HMAC-SHA256. If each worker generates its own random key, a token signed by worker A is rejected by worker B. **Always provide a shared `signing_key`** from environment variables or a secrets manager.
@@ -419,4 +422,7 @@ Before going live, verify these settings:
419422
- **Introspection**`enable_describe=True` for [service discovery](api/introspection.md) (disable in production if sensitive)
420423
- **Logging** — structured JSON logging with [`VgiJsonFormatter`](api/logging.md)
421424
- **OpenTelemetry**[`otel_config`](api/otel.md) for distributed tracing (`pip install vgi-rpc[otel]`)
425+
- **Not-found page**`enable_not_found_page=True` (default) shows a branded HTML 404; set to `False` if you prefer your reverse proxy's error pages
426+
- **Landing page**`enable_landing_page=True` (default) serves an HTML page at `GET {prefix}` with protocol name, server ID, and links
427+
- **Describe page**`enable_describe_page=True` (default) serves an HTML API reference at `GET {prefix}/describe` when `enable_describe=True`; disable in production if sensitive
422428
- **Health check** — platform health probe configured (e.g., Cloud Run startup probe)

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ classifiers = [
1616
"Topic :: Software Development :: Libraries :: Python Modules",
1717
"Typing :: Typed",
1818
]
19-
dependencies = ["pyarrow>=14.0", "tzdata>=2024.1; sys_platform == 'win32'"]
19+
dependencies = ["pyarrow>=14.0", "docstring-parser>=0.16", "tzdata>=2024.1; sys_platform == 'win32'"]
2020

2121
[project.urls]
2222
Homepage = "https://github.com/Query-farm/vgi-rpc-python"

tests/test_http.py

Lines changed: 265 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1423,3 +1423,268 @@ def test_stream_exchange_no_compression(self) -> None:
14231423
result = session.exchange(input_batch)
14241424
assert result.batch.column("value")[0].as_py() == 30.0
14251425
client.close()
1426+
1427+
1428+
# ---------------------------------------------------------------------------
1429+
# 404 HTML page
1430+
# ---------------------------------------------------------------------------
1431+
1432+
1433+
class TestNotFoundHtmlPage:
1434+
"""Custom HTML 404 page for unmatched routes."""
1435+
1436+
def test_root_returns_html_404(self, client: _SyncTestClient) -> None:
1437+
"""GET / returns a friendly HTML 404 page."""
1438+
resp = client._client.simulate_get("/")
1439+
assert resp.status_code == 404
1440+
assert "text/html" in resp.headers.get("content-type", "")
1441+
assert "<code>vgi-rpc</code>" in resp.text
1442+
assert "vgi-rpc.query.farm" in resp.text
1443+
1444+
def test_random_path_returns_html_404(self, client: _SyncTestClient) -> None:
1445+
"""GET /random returns a friendly HTML 404 page."""
1446+
resp = client._client.simulate_get("/random")
1447+
assert resp.status_code == 404
1448+
assert "text/html" in resp.headers.get("content-type", "")
1449+
1450+
def test_prefix_without_method_returns_landing_page(self, client: _SyncTestClient) -> None:
1451+
"""GET /vgi (no method segment) returns landing page (200)."""
1452+
resp = client._client.simulate_get("/vgi")
1453+
assert resp.status_code == 200
1454+
assert "text/html" in resp.headers.get("content-type", "")
1455+
assert "<code>vgi-rpc</code>" in resp.text
1456+
1457+
def test_existing_method_404_still_arrow_ipc(self, client: _SyncTestClient) -> None:
1458+
"""Unknown method on a matched route still returns Arrow IPC 404 (not HTML)."""
1459+
resp = client.post(
1460+
f"{_BASE_URL}/vgi/nonexistent",
1461+
content=b"",
1462+
headers={"Content-Type": _ARROW_CONTENT_TYPE},
1463+
)
1464+
assert resp.status_code == 404
1465+
assert "text/html" not in (resp.headers.get("content-type") or "")
1466+
1467+
def test_protocol_name_in_html(self, client: _SyncTestClient) -> None:
1468+
"""Protocol name appears in the 404 HTML body."""
1469+
resp = client._client.simulate_get("/")
1470+
assert resp.status_code == 404
1471+
assert "RpcFixtureService" in resp.text
1472+
1473+
def test_logo_in_html(self, client: _SyncTestClient) -> None:
1474+
"""HTML contains the vgi-rpc logo from the docs site."""
1475+
resp = client._client.simulate_get("/")
1476+
assert resp.status_code == 404
1477+
assert "vgi-rpc-python.query.farm/assets/logo-hero.png" in resp.text
1478+
1479+
def test_disabled_not_found_page(self) -> None:
1480+
"""When enable_not_found_page=False, Falcon's default 404 is used."""
1481+
c = make_sync_client(
1482+
RpcServer(RpcFixtureService, RpcFixtureServiceImpl()),
1483+
signing_key=b"test-key",
1484+
enable_not_found_page=False,
1485+
)
1486+
resp = c._client.simulate_get("/")
1487+
# Falcon's default 404 does not contain our custom HTML
1488+
assert resp.status_code == 404
1489+
assert "<code>vgi-rpc</code>" not in resp.text
1490+
c.close()
1491+
1492+
1493+
# ---------------------------------------------------------------------------
1494+
# Landing page
1495+
# ---------------------------------------------------------------------------
1496+
1497+
1498+
class TestLandingPage:
1499+
"""HTML landing page at GET {prefix}."""
1500+
1501+
@pytest.fixture
1502+
def landing_client(self) -> Iterator[_SyncTestClient]:
1503+
"""Client with landing page enabled (default)."""
1504+
c = make_sync_client(
1505+
RpcServer(RpcFixtureService, RpcFixtureServiceImpl()),
1506+
signing_key=b"test-key",
1507+
)
1508+
yield c
1509+
c.close()
1510+
1511+
def test_get_prefix_returns_landing_page(self, landing_client: _SyncTestClient) -> None:
1512+
"""GET /vgi returns 200 with HTML content."""
1513+
resp = landing_client._client.simulate_get("/vgi")
1514+
assert resp.status_code == 200
1515+
assert "text/html" in resp.headers.get("content-type", "")
1516+
1517+
def test_protocol_name_in_landing(self, landing_client: _SyncTestClient) -> None:
1518+
"""Protocol name appears in the landing page."""
1519+
resp = landing_client._client.simulate_get("/vgi")
1520+
assert "RpcFixtureService" in resp.text
1521+
1522+
def test_server_id_in_landing(self, landing_client: _SyncTestClient) -> None:
1523+
"""Server ID appears in the landing page."""
1524+
server = RpcServer(RpcFixtureService, RpcFixtureServiceImpl(), server_id="test-id-abc")
1525+
c = make_sync_client(server, signing_key=b"test-key")
1526+
resp = c._client.simulate_get("/vgi")
1527+
assert "test-id-abc" in resp.text
1528+
c.close()
1529+
1530+
def test_logo_in_landing(self, landing_client: _SyncTestClient) -> None:
1531+
"""Logo URL is present in the landing page."""
1532+
resp = landing_client._client.simulate_get("/vgi")
1533+
assert "vgi-rpc-python.query.farm/assets/logo-hero.png" in resp.text
1534+
1535+
def test_vgi_rpc_in_code_tag(self, landing_client: _SyncTestClient) -> None:
1536+
"""'vgi-rpc' appears in <code> tags."""
1537+
resp = landing_client._client.simulate_get("/vgi")
1538+
assert "<code>vgi-rpc</code>" in resp.text
1539+
1540+
def test_vgi_rpc_link(self, landing_client: _SyncTestClient) -> None:
1541+
"""'Learn more about vgi-rpc' link and Query.Farm copyright are present."""
1542+
resp = landing_client._client.simulate_get("/vgi")
1543+
assert "Learn more about" in resp.text
1544+
assert "<code>vgi-rpc</code>" in resp.text
1545+
assert "Query.Farm LLC" in resp.text
1546+
assert "2026" in resp.text
1547+
1548+
def test_repo_url_in_landing(self) -> None:
1549+
"""Repo URL appears as a link when provided."""
1550+
c = make_sync_client(
1551+
RpcServer(RpcFixtureService, RpcFixtureServiceImpl()),
1552+
signing_key=b"test-key",
1553+
repo_url="https://github.com/example/my-service",
1554+
)
1555+
resp = c._client.simulate_get("/vgi")
1556+
assert "https://github.com/example/my-service" in resp.text
1557+
assert "Source repository" in resp.text
1558+
c.close()
1559+
1560+
def test_describe_link_when_enabled(self) -> None:
1561+
"""Landing page contains describe link when describe is enabled on server."""
1562+
server = RpcServer(RpcFixtureService, RpcFixtureServiceImpl(), enable_describe=True)
1563+
c = make_sync_client(server, signing_key=b"test-key")
1564+
resp = c._client.simulate_get("/vgi")
1565+
assert "/vgi/describe" in resp.text
1566+
assert "View service API" in resp.text
1567+
c.close()
1568+
1569+
def test_no_describe_link_when_disabled(self, landing_client: _SyncTestClient) -> None:
1570+
"""Landing page omits describe link when enable_describe=False on server."""
1571+
resp = landing_client._client.simulate_get("/vgi")
1572+
assert "/vgi/describe" not in resp.text
1573+
1574+
def test_no_describe_link_when_page_disabled(self) -> None:
1575+
"""Landing page omits describe link when enable_describe_page=False."""
1576+
server = RpcServer(RpcFixtureService, RpcFixtureServiceImpl(), enable_describe=True)
1577+
c = make_sync_client(server, signing_key=b"test-key", enable_describe_page=False)
1578+
resp = c._client.simulate_get("/vgi")
1579+
assert "/vgi/describe" not in resp.text
1580+
c.close()
1581+
1582+
def test_disabled_landing_page(self) -> None:
1583+
"""When enable_landing_page=False, GET /vgi returns 404."""
1584+
c = make_sync_client(
1585+
RpcServer(RpcFixtureService, RpcFixtureServiceImpl()),
1586+
signing_key=b"test-key",
1587+
enable_landing_page=False,
1588+
)
1589+
resp = c._client.simulate_get("/vgi")
1590+
assert resp.status_code == 404
1591+
c.close()
1592+
1593+
def test_post_to_prefix_returns_405(self, landing_client: _SyncTestClient) -> None:
1594+
"""POST /vgi returns 405 Method Not Allowed."""
1595+
resp = landing_client._client.simulate_post("/vgi")
1596+
assert resp.status_code == 405
1597+
1598+
1599+
# ---------------------------------------------------------------------------
1600+
# Describe HTML page
1601+
# ---------------------------------------------------------------------------
1602+
1603+
1604+
class TestDescribeHtmlPage:
1605+
"""HTML describe page at GET {prefix}/describe."""
1606+
1607+
@pytest.fixture
1608+
def describe_client(self) -> Iterator[_SyncTestClient]:
1609+
"""Client with describe page enabled."""
1610+
server = RpcServer(RpcFixtureService, RpcFixtureServiceImpl(), enable_describe=True)
1611+
c = make_sync_client(server, signing_key=b"test-key")
1612+
yield c
1613+
c.close()
1614+
1615+
def test_get_describe_returns_page(self, describe_client: _SyncTestClient) -> None:
1616+
"""GET /vgi/describe returns 200 with HTML content."""
1617+
resp = describe_client._client.simulate_get("/vgi/describe")
1618+
assert resp.status_code == 200
1619+
assert "text/html" in resp.headers.get("content-type", "")
1620+
1621+
def test_protocol_name_in_page(self, describe_client: _SyncTestClient) -> None:
1622+
"""Protocol name appears in the describe page."""
1623+
resp = describe_client._client.simulate_get("/vgi/describe")
1624+
assert "RpcFixtureService" in resp.text
1625+
1626+
def test_method_names_in_page(self, describe_client: _SyncTestClient) -> None:
1627+
"""All method names appear in the describe page."""
1628+
resp = describe_client._client.simulate_get("/vgi/describe")
1629+
for method in ("add", "greet", "generate", "transform"):
1630+
assert method in resp.text
1631+
1632+
def test_method_types_shown(self, describe_client: _SyncTestClient) -> None:
1633+
"""UNARY, STREAM, EXCHANGE, PRODUCER, and HEADER badges are shown."""
1634+
resp = describe_client._client.simulate_get("/vgi/describe")
1635+
assert "badge-unary" in resp.text
1636+
assert "badge-stream" in resp.text
1637+
assert "badge-exchange" in resp.text
1638+
assert "badge-producer" in resp.text
1639+
assert "badge-header" in resp.text
1640+
1641+
def test_docstrings_shown(self, describe_client: _SyncTestClient) -> None:
1642+
"""Method docstrings appear in the describe page."""
1643+
resp = describe_client._client.simulate_get("/vgi/describe")
1644+
assert "Add two numbers" in resp.text
1645+
assert "Greet by name" in resp.text
1646+
1647+
def test_parameter_types_shown(self, describe_client: _SyncTestClient) -> None:
1648+
"""Parameter types appear in the describe page."""
1649+
resp = describe_client._client.simulate_get("/vgi/describe")
1650+
assert "float" in resp.text
1651+
1652+
def test_logo_in_page(self, describe_client: _SyncTestClient) -> None:
1653+
"""Logo URL is present in the describe page."""
1654+
resp = describe_client._client.simulate_get("/vgi/describe")
1655+
assert "vgi-rpc-python.query.farm/assets/logo-hero.png" in resp.text
1656+
1657+
def test_vgi_rpc_in_code_tag(self, describe_client: _SyncTestClient) -> None:
1658+
"""'vgi-rpc' appears in <code> tags."""
1659+
resp = describe_client._client.simulate_get("/vgi/describe")
1660+
assert "<code>vgi-rpc</code>" in resp.text
1661+
1662+
def test_describe_method_hidden(self, describe_client: _SyncTestClient) -> None:
1663+
"""The __describe__ method is filtered out of the page."""
1664+
resp = describe_client._client.simulate_get("/vgi/describe")
1665+
assert "__describe__" not in resp.text
1666+
1667+
def test_disabled_via_parameter(self) -> None:
1668+
"""When enable_describe_page=False, GET /vgi/describe is not served."""
1669+
server = RpcServer(RpcFixtureService, RpcFixtureServiceImpl(), enable_describe=True)
1670+
c = make_sync_client(server, signing_key=b"test-key", enable_describe_page=False)
1671+
resp = c._client.simulate_get("/vgi/describe")
1672+
# Falls through to {prefix}/{method} route which only has on_post → 405
1673+
assert resp.status_code == 405
1674+
c.close()
1675+
1676+
def test_parameter_descriptions_shown(self, describe_client: _SyncTestClient) -> None:
1677+
"""Parameter descriptions from docstring Args: sections appear in the page."""
1678+
resp = describe_client._client.simulate_get("/vgi/describe")
1679+
assert "The first number" in resp.text
1680+
assert "The second number" in resp.text
1681+
assert "The name to greet" in resp.text
1682+
1683+
def test_disabled_when_describe_not_enabled(self) -> None:
1684+
"""When enable_describe=False on server, GET /vgi/describe is not served."""
1685+
server = RpcServer(RpcFixtureService, RpcFixtureServiceImpl())
1686+
c = make_sync_client(server, signing_key=b"test-key")
1687+
resp = c._client.simulate_get("/vgi/describe")
1688+
# Falls through to {prefix}/{method} route which only has on_post → 405
1689+
assert resp.status_code == 405
1690+
c.close()

0 commit comments

Comments
 (0)