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""" + + + + + + {title} + + + + + +
+
+

RemoteTerm API

+

{title}

+

+ Explore radio control, messaging, packet inspection, push notifications, + and fanout integration endpoints from one interactive console. +

+
+ GET /api/health + OpenAPI JSON + WebSocket /api/ws +
+
+ Version {version} + REST base /api + Auth optional Basic +
+
+
+
+ + + + + +""" + + +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)) diff --git a/app/main.py b/app/main.py index 3083b2c..8cf66b3 100644 --- a/app/main.py +++ b/app/main.py @@ -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( diff --git a/app/tcp_proxy/__init__.py b/app/tcp_proxy/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/frontend/src/components/settings/SettingsFanoutSection.tsx b/frontend/src/components/settings/SettingsFanoutSection.tsx index 3efe9c1..adc4a05 100644 --- a/frontend/src/components/settings/SettingsFanoutSection.tsx +++ b/frontend/src/components/settings/SettingsFanoutSection.tsx @@ -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({ )} - +
+ + +
{ }); 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(); diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index 5e1feb4..d541922 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -15,7 +15,6 @@ "noUnusedLocals": true, "noUnusedParameters": true, "noFallthroughCasesInSwitch": true, - "baseUrl": ".", "paths": { "@/*": ["./src/*"] } diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index f99c25e..a11177e 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -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, diff --git a/tests/test_http_quality.py b/tests/test_http_quality.py index 3efb0b0..5209cb2 100644 --- a/tests/test_http_quality.py +++ b/tests/test_http_quality.py @@ -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"