Skip to content

Commit ed33892

Browse files
rustyconoverclaude
andcommitted
Style 401 Unauthorized and 404 error pages to match landing page design and bump version to 0.1.26
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 14dce08 commit ed33892

3 files changed

Lines changed: 116 additions & 21 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "vgi-rpc"
3-
version = "0.1.25"
3+
version = "0.1.26"
44
description = "Vector Gateway Interface - RPC framework based on Apache Arrow"
55
readme = "README.md"
66
requires-python = ">=3.13"

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

vgi_rpc/http/_server.py

Lines changed: 114 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1081,27 +1081,54 @@ def _unpack_and_recover_state(
10811081
return state_obj, output_schema, input_schema
10821082

10831083

1084+
# ---------------------------------------------------------------------------
1085+
# Shared HTML styles for error and landing pages
1086+
# ---------------------------------------------------------------------------
1087+
1088+
_FONT_IMPORTS = (
1089+
'<link rel="preconnect" href="https://fonts.googleapis.com">'
1090+
'<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>'
1091+
'<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&family=JetBrains+Mono:wght@400;600&display=swap" rel="stylesheet">' # noqa: E501
1092+
)
1093+
10841094
# ---------------------------------------------------------------------------
10851095
# 404 sink for unmatched routes
10861096
# ---------------------------------------------------------------------------
10871097

1088-
_NOT_FOUND_HTML_TEMPLATE = """\
1098+
_ERROR_PAGE_STYLE = """\
1099+
<style>
1100+
body {{ font-family: 'Inter', system-ui, -apple-system, sans-serif; max-width: 600px;
1101+
margin: 0 auto; padding: 60px 20px 0; color: #2c2c1e; text-align: center;
1102+
background: #faf8f0; }}
1103+
.logo {{ margin-bottom: 24px; }}
1104+
.logo img {{ width: 120px; height: 120px; border-radius: 50%;
1105+
box-shadow: 0 4px 24px rgba(0,0,0,0.12); }}
1106+
h1 {{ color: #2d5016; margin-bottom: 8px; font-weight: 700; }}
1107+
code {{ font-family: 'JetBrains Mono', monospace; background: #f0ece0;
1108+
padding: 2px 6px; border-radius: 3px; font-size: 0.9em; color: #2c2c1e; }}
1109+
a {{ color: #2d5016; text-decoration: none; }}
1110+
a:hover {{ color: #4a7c23; }}
1111+
p {{ line-height: 1.7; color: #6b6b5a; }}
1112+
.detail {{ margin-top: 12px; padding: 12px 16px; background: #f0ece0;
1113+
border-radius: 6px; font-size: 0.9em; color: #6b6b5a; }}
1114+
footer {{ margin-top: 48px; padding: 20px 0; border-top: 1px solid #f0ece0;
1115+
color: #6b6b5a; font-size: 0.85em; }}
1116+
footer a {{ color: #2d5016; font-weight: 600; }}
1117+
footer a:hover {{ color: #4a7c23; }}
1118+
</style>"""
1119+
1120+
_NOT_FOUND_HTML_TEMPLATE = (
1121+
"""\
10891122
<!DOCTYPE html>
10901123
<html lang="en">
10911124
<head>
10921125
<meta charset="utf-8">
10931126
<meta name="viewport" content="width=device-width, initial-scale=1">
1094-
<title>404 &mdash; vgi-rpc endpoint</title>
1095-
<style>
1096-
body {{ font-family: system-ui, -apple-system, sans-serif; max-width: 600px;
1097-
margin: 60px auto; padding: 0 20px; color: #333; text-align: center; }}
1098-
.logo {{ margin-bottom: 24px; }}
1099-
.logo img {{ width: 120px; height: 120px; }}
1100-
h1 {{ color: #555; }}
1101-
code {{ background: #f4f4f4; padding: 2px 6px; border-radius: 3px; font-size: 0.95em; }}
1102-
a {{ color: #0066cc; }}
1103-
p {{ line-height: 1.6; }}
1104-
</style>
1127+
<title>404 &mdash; vgi-rpc</title>
1128+
"""
1129+
+ _FONT_IMPORTS
1130+
+ _ERROR_PAGE_STYLE
1131+
+ """
11051132
</head>
11061133
<body>
11071134
<div class="logo">
@@ -1110,9 +1137,82 @@ def _unpack_and_recover_state(
11101137
<h1>404 &mdash; Not Found</h1>
11111138
<p>This is a <code>vgi-rpc</code> service endpoint{protocol_fragment}.</p>
11121139
<p>RPC methods are available under <code>{prefix}/&lt;method&gt;</code>.</p>
1113-
<p>Learn more at <a href="https://vgi-rpc.query.farm">vgi-rpc.query.farm</a>.</p>
1140+
<footer>
1141+
Powered by <a href="https://vgi-rpc.query.farm"><code>vgi-rpc</code></a>
1142+
</footer>
1143+
</body>
1144+
</html>"""
1145+
)
1146+
1147+
_UNAUTHORIZED_HTML_TEMPLATE = (
1148+
"""\
1149+
<!DOCTYPE html>
1150+
<html lang="en">
1151+
<head>
1152+
<meta charset="utf-8">
1153+
<meta name="viewport" content="width=device-width, initial-scale=1">
1154+
<title>401 &mdash; Unauthorized</title>
1155+
"""
1156+
+ _FONT_IMPORTS
1157+
+ _ERROR_PAGE_STYLE
1158+
+ """
1159+
</head>
1160+
<body>
1161+
<div class="logo">
1162+
<img src="https://vgi-rpc-python.query.farm/assets/logo-hero.png" alt="vgi-rpc logo">
1163+
</div>
1164+
<h1>401 &mdash; Unauthorized</h1>
1165+
<p>Authentication is required to access this <code>vgi-rpc</code> service.</p>
1166+
{detail}
1167+
<footer>
1168+
Powered by <a href="https://vgi-rpc.query.farm"><code>vgi-rpc</code></a>
1169+
</footer>
11141170
</body>
11151171
</html>"""
1172+
)
1173+
1174+
_UNAUTHORIZED_BODY_CACHE: dict[str, bytes] = {}
1175+
1176+
1177+
def _get_unauthorized_body(detail: str) -> bytes:
1178+
"""Return cached UTF-8 bytes for a 401 HTML page with the given detail message.
1179+
1180+
Args:
1181+
detail: The error description to display on the page.
1182+
1183+
Returns:
1184+
UTF-8 encoded HTML bytes.
1185+
1186+
"""
1187+
body = _UNAUTHORIZED_BODY_CACHE.get(detail)
1188+
if body is not None:
1189+
return body
1190+
detail_html = f'<div class="detail">{_html.escape(detail)}</div>' if detail else ""
1191+
body = _UNAUTHORIZED_HTML_TEMPLATE.format(detail=detail_html).encode("utf-8")
1192+
# Bound cache size to prevent memory issues with unique error messages.
1193+
if len(_UNAUTHORIZED_BODY_CACHE) < 64:
1194+
_UNAUTHORIZED_BODY_CACHE[detail] = body
1195+
return body
1196+
1197+
1198+
def _error_serializer(req: falcon.Request, resp: falcon.Response, exc: falcon.HTTPError) -> None:
1199+
"""Serialize Falcon HTTP errors as styled HTML pages.
1200+
1201+
Only ``HTTPUnauthorized`` (401) gets a styled HTML page; all other errors
1202+
fall back to Falcon's default JSON serialization.
1203+
1204+
Args:
1205+
req: The Falcon request.
1206+
resp: The Falcon response.
1207+
exc: The Falcon HTTP error being serialized.
1208+
1209+
"""
1210+
if isinstance(exc, falcon.HTTPUnauthorized):
1211+
resp.content_type = "text/html; charset=utf-8"
1212+
resp.data = _get_unauthorized_body(exc.description or "")
1213+
else:
1214+
resp.content_type = falcon.MEDIA_JSON
1215+
resp.data = exc.to_json()
11161216

11171217

11181218
def _make_not_found_sink(
@@ -1151,12 +1251,6 @@ def _not_found_sink(req: falcon.Request, resp: falcon.Response, **kwargs: Any) -
11511251
# Landing page at GET {prefix}
11521252
# ---------------------------------------------------------------------------
11531253

1154-
_FONT_IMPORTS = (
1155-
'<link rel="preconnect" href="https://fonts.googleapis.com">'
1156-
'<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>'
1157-
'<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&family=JetBrains+Mono:wght@400;600&display=swap" rel="stylesheet">' # noqa: E501
1158-
)
1159-
11601254
_LANDING_HTML_TEMPLATE = (
11611255
"""\
11621256
<!DOCTYPE html>
@@ -2084,6 +2178,7 @@ def make_wsgi_app(
20842178
if capability_headers:
20852179
middleware.append(_CapabilitiesMiddleware(capability_headers))
20862180
app: falcon.App[falcon.Request, falcon.Response] = falcon.App(middleware=middleware or None)
2181+
app.set_error_serializer(_error_serializer)
20872182

20882183
# OAuth well-known endpoint (must be before RPC routes)
20892184
if _validated_oauth_metadata is not None:

0 commit comments

Comments
 (0)