mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-05-14 21:36:03 +02:00
516 lines
15 KiB
Python
516 lines
15 KiB
Python
"""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> /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))
|