Add Swagger UI api page on Automation page

This commit is contained in:
Björkan
2026-05-01 08:15:50 +02:00
parent f95745cb05
commit 431d4d419a
8 changed files with 590 additions and 6 deletions
+515
View File
@@ -0,0 +1,515 @@
"""Custom OpenAPI documentation page for the RemoteTerm API."""
from __future__ import annotations
import json
from html import escape
from typing import Any
from fastapi import FastAPI
from fastapi.openapi.utils import get_openapi
from fastapi.responses import HTMLResponse
API_DESCRIPTION = (
"RemoteTerm exposes the MeshCore companion radio as a local REST and WebSocket API.\n\n"
"REST endpoints are mounted below `/api`. The live WebSocket stream is available at "
"`/api/ws` for health, message, raw-packet, contact, and telemetry events.\n\n"
"**Trusted network note:** RemoteTerm is designed for trusted local networks. Optional "
"HTTP Basic auth can be enabled for the whole app, but operators should pair it with "
"HTTPS when credentials cross the network."
)
API_TAGS_METADATA: list[dict[str, Any]] = [
{
"name": "health",
"description": "Connection state, build info, database size, radio stats, and fanout health.",
},
{
"name": "debug",
"description": "Support snapshots for logs, live radio probes, drift audits, and version data.",
},
{
"name": "radio",
"description": "Radio configuration, connection lifecycle, discovery, trace, and advert commands.",
},
{
"name": "contacts",
"description": "Mesh contacts, analytics, read state, route overrides, and path discovery.",
},
{
"name": "repeaters",
"description": "Repeater login, telemetry, ACL, owner info, radio settings, and CLI commands.",
},
{
"name": "rooms",
"description": "Room-server login, status, telemetry, and ACL operations.",
},
{
"name": "channels",
"description": "Channel creation, metadata, read state, flood scope, and path-hash overrides.",
},
{
"name": "messages",
"description": "Message history, direct sends, channel sends, and channel resend workflows.",
},
{
"name": "packets",
"description": "Raw packet inspection, historical decryption, undecrypted counts, and maintenance.",
},
{
"name": "read-state",
"description": "Server-side unread counters, mention flags, and mark-all-read operations.",
},
{
"name": "settings",
"description": "App settings, favorites, muted channels, block lists, and telemetry tracking.",
},
{
"name": "push",
"description": "Browser Web Push subscriptions, per-device preferences, tests, and conversations.",
},
{
"name": "fanout",
"description": "MQTT, bots, webhooks, Apprise, SQS, Home Assistant, and map upload integrations.",
},
{
"name": "statistics",
"description": "Aggregated mesh, message, packet, channel, and contact statistics.",
},
]
SWAGGER_UI_CSS_URL = "https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui.css"
SWAGGER_UI_BUNDLE_URL = "https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui-bundle.js"
SWAGGER_UI_PRESET_URL = (
"https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui-standalone-preset.js"
)
COMMON_ERROR_RESPONSES: dict[str, str] = {
"400": "Bad request",
"401": "Authentication required",
"403": "Forbidden",
"404": "Not found",
"408": "Request timed out",
"409": "Conflict",
"422": "Validation or command error",
"423": "Radio unavailable or locked",
"500": "Server error",
}
ERROR_RESPONSE_SCHEMA: dict[str, Any] = {
"type": "object",
"properties": {
"detail": {
"description": "Human-readable error detail or structured validation detail.",
"oneOf": [
{"type": "string"},
{"type": "array", "items": {}},
{"type": "object", "additionalProperties": True},
],
}
},
}
def _relative_openapi_url(app: FastAPI) -> str:
"""Keep docs usable behind reverse-proxy path prefixes."""
openapi_url = app.openapi_url or "/openapi.json"
return openapi_url.lstrip("/")
def _error_response(description: str) -> dict[str, Any]:
return {
"description": description,
"content": {
"application/json": {
"schema": ERROR_RESPONSE_SCHEMA,
}
},
}
def _add_common_error_responses(openapi_schema: dict[str, Any]) -> None:
paths = openapi_schema.get("paths")
if not isinstance(paths, dict):
return
for path_item in paths.values():
if not isinstance(path_item, dict):
continue
for operation in path_item.values():
if not isinstance(operation, dict):
continue
responses = operation.setdefault("responses", {})
if not isinstance(responses, dict):
continue
for status_code, description in COMMON_ERROR_RESPONSES.items():
responses.setdefault(status_code, _error_response(description))
def install_custom_openapi(app: FastAPI) -> None:
"""Install OpenAPI metadata polishing used by the custom docs page."""
original_openapi = app.openapi
def custom_openapi() -> dict[str, Any]:
if app.openapi_schema:
return app.openapi_schema
openapi_schema = get_openapi(
title=app.title,
version=app.version,
openapi_version=app.openapi_version,
description=app.description,
routes=app.routes,
tags=app.openapi_tags,
servers=app.servers,
)
_add_common_error_responses(openapi_schema)
app.openapi_schema = openapi_schema
return app.openapi_schema
# Keep a reference for debuggability and for tests that may introspect it.
app.state.default_openapi = original_openapi
app.openapi = custom_openapi # type: ignore[method-assign]
def _build_swagger_docs_html(app: FastAPI) -> str:
title = escape(app.title)
version = escape(app.version)
openapi_url = json.dumps(_relative_openapi_url(app))
return f"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light">
<title>{title}</title>
<link rel="stylesheet" href="{SWAGGER_UI_CSS_URL}">
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='14' fill='%23111419'/%3E%3Cpath d='M16 42h32M18 42l8-20 6 14 6-14 8 20' fill='none' stroke='%2374d99f' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'/%3E%3Ccircle cx='26' cy='22' r='4' fill='%23f5c15c'/%3E%3Ccircle cx='38' cy='22' r='4' fill='%2386b7ff'/%3E%3C/svg%3E">
<style>
:root {{
--docs-ink: #111419;
--docs-muted: #5b6674;
--docs-panel: #ffffff;
--docs-border: #d8dee7;
--docs-page: #f4f7f6;
--docs-green: #2f9d69;
--docs-amber: #b7791f;
--docs-blue: #286fc2;
--docs-red: #c2413b;
}}
* {{
box-sizing: border-box;
}}
body {{
margin: 0;
background: var(--docs-page);
color: var(--docs-ink);
font-family:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
sans-serif;
}}
.docs-hero {{
color: #ffffff;
background: #141a1d;
border-bottom: 1px solid rgba(255, 255, 255, 0.14);
}}
.docs-hero-inner {{
width: min(1280px, calc(100% - 40px));
margin: 0 auto;
padding: 32px 0 28px;
}}
.docs-kicker {{
margin: 0 0 10px;
color: #74d99f;
font-size: 0.78rem;
font-weight: 800;
letter-spacing: 0;
text-transform: uppercase;
}}
.docs-hero h1 {{
margin: 0;
font-size: 3.2rem;
line-height: 1.04;
letter-spacing: 0;
}}
.docs-lede {{
max-width: 780px;
margin: 14px 0 0;
color: rgba(255, 255, 255, 0.82);
font-size: 1.04rem;
line-height: 1.55;
}}
.docs-actions {{
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 22px;
}}
.docs-action {{
display: inline-flex;
align-items: center;
min-height: 38px;
padding: 0 13px;
border: 1px solid rgba(255, 255, 255, 0.22);
border-radius: 8px;
color: #ffffff;
background: rgba(255, 255, 255, 0.08);
font-size: 0.9rem;
font-weight: 700;
text-decoration: none;
}}
.docs-action:hover {{
background: rgba(255, 255, 255, 0.15);
}}
.docs-action strong {{
color: #f5c15c;
font-weight: 800;
}}
.docs-meta {{
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 22px;
}}
.docs-pill {{
display: inline-flex;
align-items: center;
min-height: 28px;
padding: 0 10px;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 999px;
color: rgba(255, 255, 255, 0.84);
background: rgba(255, 255, 255, 0.08);
font-size: 0.78rem;
font-weight: 700;
white-space: nowrap;
}}
.docs-pill span {{
margin-left: 6px;
color: #ffffff;
font-weight: 800;
}}
.swagger-ui {{
width: min(1280px, calc(100% - 40px));
margin: 24px auto 56px;
}}
.swagger-ui .topbar {{
display: none;
}}
.swagger-ui .information-container.wrapper,
.swagger-ui .scheme-container,
.swagger-ui .opblock-tag-section,
.swagger-ui .models {{
max-width: none;
}}
.swagger-ui .info {{
margin: 0 0 20px;
padding: 22px 24px;
border: 1px solid var(--docs-border);
border-radius: 8px;
background: var(--docs-panel);
box-shadow: 0 10px 32px rgba(17, 20, 25, 0.08);
}}
.swagger-ui .info .title {{
color: var(--docs-ink);
font-size: 1.55rem;
}}
.swagger-ui .info p,
.swagger-ui .info li,
.swagger-ui .markdown p {{
color: var(--docs-muted);
line-height: 1.55;
}}
.swagger-ui .scheme-container {{
margin: 0 0 20px;
padding: 16px 18px;
border: 1px solid var(--docs-border);
border-radius: 8px;
background: var(--docs-panel);
box-shadow: 0 10px 32px rgba(17, 20, 25, 0.06);
}}
.swagger-ui .opblock-tag {{
margin: 18px 0 10px;
padding: 14px 18px;
border: 1px solid var(--docs-border);
border-radius: 8px;
background: rgba(255, 255, 255, 0.92);
color: var(--docs-ink);
box-shadow: 0 8px 22px rgba(17, 20, 25, 0.05);
}}
.swagger-ui .opblock {{
overflow: hidden;
border-radius: 8px;
box-shadow: 0 8px 22px rgba(17, 20, 25, 0.05);
}}
.swagger-ui .opblock.opblock-get {{
border-color: rgba(40, 111, 194, 0.42);
background: rgba(40, 111, 194, 0.06);
}}
.swagger-ui .opblock.opblock-post {{
border-color: rgba(47, 157, 105, 0.42);
background: rgba(47, 157, 105, 0.06);
}}
.swagger-ui .opblock.opblock-put,
.swagger-ui .opblock.opblock-patch {{
border-color: rgba(183, 121, 31, 0.44);
background: rgba(183, 121, 31, 0.07);
}}
.swagger-ui .opblock.opblock-delete {{
border-color: rgba(194, 65, 59, 0.4);
background: rgba(194, 65, 59, 0.06);
}}
.swagger-ui .opblock .opblock-summary-method {{
border-radius: 6px;
min-width: 78px;
text-shadow: none;
}}
.swagger-ui .opblock .opblock-summary-path,
.swagger-ui .opblock .opblock-summary-path__deprecated {{
color: var(--docs-ink);
font-weight: 800;
}}
.swagger-ui .btn,
.swagger-ui select {{
border-radius: 7px;
}}
.swagger-ui input[type="text"],
.swagger-ui textarea {{
border-radius: 7px;
}}
.swagger-ui .models {{
border: 1px solid var(--docs-border);
border-radius: 8px;
background: var(--docs-panel);
}}
@media (max-width: 720px) {{
.docs-hero-inner,
.swagger-ui {{
width: min(100% - 24px, 1280px);
}}
.docs-hero-inner {{
padding: 24px 0 22px;
}}
.docs-hero h1 {{
font-size: 2.05rem;
}}
.docs-actions {{
display: grid;
grid-template-columns: 1fr;
}}
.docs-action {{
justify-content: center;
width: 100%;
}}
.swagger-ui {{
margin-top: 16px;
}}
.swagger-ui .info,
.swagger-ui .scheme-container {{
padding: 16px;
}}
}}
</style>
</head>
<body>
<header class="docs-hero">
<div class="docs-hero-inner">
<p class="docs-kicker">RemoteTerm API</p>
<h1>{title}</h1>
<p class="docs-lede">
Explore radio control, messaging, packet inspection, push notifications,
and fanout integration endpoints from one interactive console.
</p>
<div class="docs-actions" aria-label="API shortcuts">
<a class="docs-action" href="api/health"><strong>GET</strong>&nbsp;/api/health</a>
<a class="docs-action" href="openapi.json">OpenAPI JSON</a>
<span class="docs-action">WebSocket /api/ws</span>
</div>
<div class="docs-meta" aria-label="API metadata">
<span class="docs-pill">Version <span>{version}</span></span>
<span class="docs-pill">REST base <span>/api</span></span>
<span class="docs-pill">Auth <span>optional Basic</span></span>
</div>
</div>
</header>
<main id="swagger-ui" aria-label="Swagger UI"></main>
<script src="{SWAGGER_UI_BUNDLE_URL}" crossorigin></script>
<script src="{SWAGGER_UI_PRESET_URL}" crossorigin></script>
<script>
window.ui = SwaggerUIBundle({{
url: {openapi_url},
dom_id: "#swagger-ui",
deepLinking: true,
displayRequestDuration: true,
docExpansion: "none",
filter: true,
persistAuthorization: true,
showExtensions: true,
showCommonExtensions: true,
tryItOutEnabled: false,
withCredentials: true,
defaultModelsExpandDepth: 1,
defaultModelExpandDepth: 1,
syntaxHighlight: {{
activate: true,
theme: "nord"
}},
presets: [
SwaggerUIBundle.presets.apis,
SwaggerUIStandalonePreset
],
layout: "BaseLayout"
}});
</script>
</body>
</html>
"""
def register_api_docs_routes(app: FastAPI) -> None:
"""Register the custom Swagger UI route."""
install_custom_openapi(app)
@app.get("/docs", include_in_schema=False)
async def swagger_ui_html() -> HTMLResponse:
return HTMLResponse(_build_swagger_docs_html(app))
+5 -1
View File
@@ -44,6 +44,7 @@ from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.gzip import GZipMiddleware
from fastapi.responses import JSONResponse
from app.api_docs import API_DESCRIPTION, API_TAGS_METADATA, register_api_docs_routes
from app.config import settings as server_settings
from app.config import setup_logging
from app.database import db
@@ -158,11 +159,14 @@ async def lifespan(app: FastAPI):
app = FastAPI(
title="RemoteTerm for MeshCore API",
description="API for interacting with MeshCore mesh radio networks",
description=API_DESCRIPTION,
version=get_app_build_info().version,
openapi_tags=API_TAGS_METADATA,
docs_url=None,
lifespan=lifespan,
)
register_api_docs_routes(app)
add_optional_basic_auth_middleware(app, server_settings)
app.add_middleware(GZipMiddleware, minimum_size=500)
app.add_middleware(
View File
@@ -8,7 +8,7 @@ import {
Suspense,
type ReactNode,
} from 'react';
import { ChevronDown, Info } from 'lucide-react';
import { BookOpen, ChevronDown, Info } from 'lucide-react';
import { Input } from '../ui/input';
import { Label } from '../ui/label';
import { Button } from '../ui/button';
@@ -3312,9 +3312,17 @@ export function SettingsFanoutSection({
</div>
)}
<Button type="button" size="sm" onClick={() => setCreateDialogOpen(true)}>
Add Integration
</Button>
<div className="flex flex-wrap gap-2">
<Button type="button" size="sm" onClick={() => setCreateDialogOpen(true)}>
Add Integration
</Button>
<Button asChild size="sm" variant="secondary">
<a href="./docs" target="_blank" rel="noopener noreferrer">
<BookOpen className="mr-1.5 h-4 w-4" aria-hidden="true" />
API Docs
</a>
</Button>
</div>
<CreateIntegrationDialog
open={createDialogOpen}
+12
View File
@@ -127,6 +127,18 @@ beforeEach(() => {
});
describe('SettingsFanoutSection', () => {
it('shows an API docs link beside the add integration button', async () => {
renderSection();
await waitFor(() => {
expect(screen.getByRole('button', { name: 'Add Integration' })).toBeInTheDocument();
});
const apiDocsLink = screen.getByRole('link', { name: 'API Docs' });
expect(apiDocsLink).toHaveAttribute('href', './docs');
expect(apiDocsLink).toHaveAttribute('target', '_blank');
});
it('shows add integration dialog with all integration types', async () => {
renderSection();
const dialog = await openCreateIntegrationDialog();
-1
View File
@@ -15,7 +15,6 @@
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
+8
View File
@@ -22,6 +22,14 @@ export default defineConfig({
changeOrigin: true,
ws: true,
},
'/docs': {
target: 'http://localhost:8000',
changeOrigin: true,
},
'/openapi.json': {
target: 'http://localhost:8000',
changeOrigin: true,
},
},
watch: {
usePolling: true,
+38
View File
@@ -11,3 +11,41 @@ def test_openapi_json_is_gzipped_when_client_accepts_gzip():
assert response.status_code == 200
assert response.headers["content-encoding"] == "gzip"
def test_custom_swagger_docs_page_is_served():
with TestClient(app) as client:
response = client.get("/docs")
assert response.status_code == 200
assert "RemoteTerm API" in response.text
assert "SwaggerUIBundle" in response.text
assert 'url: "openapi.json"' in response.text
assert 'href="api/health"' in response.text
assert "repeating-linear-gradient" not in response.text
assert "background-size: 32px 32px" not in response.text
def test_openapi_includes_docs_metadata():
with TestClient(app) as client:
response = client.get("/openapi.json")
assert response.status_code == 200
data = response.json()
assert data["info"]["description"].startswith("RemoteTerm exposes")
assert "/docs" not in data["paths"]
tags = {tag["name"]: tag["description"] for tag in data["tags"]}
assert tags["messages"].startswith("Message history")
assert tags["radio"].startswith("Radio configuration")
def test_openapi_documents_common_error_responses():
with TestClient(app) as client:
response = client.get("/openapi.json")
assert response.status_code == 200
data = response.json()
responses = data["paths"]["/api/messages/channel"]["post"]["responses"]
assert responses["400"]["description"] == "Bad request"
assert responses["423"]["description"] == "Radio unavailable or locked"
assert responses["500"]["description"] == "Server error"