From 431d4d419a552c0122de18fee12be215f0c037c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rkan?= <93771679+Bjorkan@users.noreply.github.com> Date: Fri, 1 May 2026 08:15:50 +0200 Subject: [PATCH] Add Swagger UI api page on Automation page --- app/api_docs.py | 515 ++++++++++++++++++ app/main.py | 6 +- app/tcp_proxy/__init__.py | 0 .../settings/SettingsFanoutSection.tsx | 16 +- frontend/src/test/fanoutSection.test.tsx | 12 + frontend/tsconfig.json | 1 - frontend/vite.config.ts | 8 + tests/test_http_quality.py | 38 ++ 8 files changed, 590 insertions(+), 6 deletions(-) create mode 100644 app/api_docs.py create mode 100644 app/tcp_proxy/__init__.py diff --git a/app/api_docs.py b/app/api_docs.py new file mode 100644 index 0000000..c3e97d8 --- /dev/null +++ b/app/api_docs.py @@ -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""" + +
+ + + +RemoteTerm API
++ Explore radio control, messaging, packet inspection, push notifications, + and fanout integration endpoints from one interactive console. +
+ + +