feat(api,ai): #72 OpenAPI 3.1 enrichie + fiabilisation de l'outil AI UI
CI / lint (push) Successful in 55s
CI / security (push) Successful in 37s
CI / test (push) Successful in 1m19s
CI / build (push) Successful in 34s
CI / e2e (push) Successful in 10m27s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s
CI / lint (push) Successful in 55s
CI / security (push) Successful in 37s
CI / test (push) Successful in 1m19s
CI / build (push) Successful in 34s
CI / e2e (push) Successful in 10m27s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s
API (#72): - backend/openapi_docs.py: 18 tags documentes, assignation auto par prefixe, securite bearerAuth/cookieAuth, erreurs 401/403/404/422/500, exemples, page /api autonome (liens /docs, /redoc, /openapi.json) - backend/schemas.py: response_model Pydantic pour ~35 endpoints sans modele - main.py: FastAPI enrichi + override app.openapi; routes /api et /api/ - ai_routes/bookslm_routes: response_model + doc SSE - frontend: entree 'API' du menu (i18n FR/EN) - tests/test_openapi.py (58 tests) AI UI: - BooksLM: requetes authentifiees (AuthManager), parsing SSE {token}/error, message utilisateur correct (plus le placeholder vide), barre de contexte - AI Editor: Ctrl/Cmd+J lie une seule fois, libelles i18n, modale de reecriture accessible a la place de window.prompt() - tests/frontend/ai.test.mjs (7 tests) + CI
This commit is contained in:
@@ -0,0 +1,452 @@
|
||||
"""OpenAPI 3.1 documentation helpers for ObsiGate (#72).
|
||||
|
||||
This module keeps the API-documentation concerns out of ``backend/main.py``:
|
||||
|
||||
* :data:`TAGS_METADATA` — human descriptions and ordering for every tag.
|
||||
* :func:`enrich_openapi_schema` — post-processes the schema generated by
|
||||
FastAPI to assign tags, add servers/security/examples and document common
|
||||
error responses.
|
||||
* :func:`render_api_landing` — a self-contained ``/api`` landing page that
|
||||
reads ``/openapi.json`` at runtime and groups endpoints by tag, with direct
|
||||
links to the Swagger UI (``/docs``) and ReDoc (``/redoc``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tag metadata
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
TAGS_METADATA: list[dict[str, str]] = [
|
||||
{"name": "System", "description": "Health checks, runtime configuration, diagnostics, dashboard stats and the SSE event stream."},
|
||||
{"name": "Auth", "description": "Authentication, sessions, user profile, MFA (TOTP/WebAuthn) and API keys."},
|
||||
{"name": "Files", "description": "Browse, read, create, rename, move, delete and download vault files."},
|
||||
{"name": "PDF", "description": "PDF metadata and byte-range streaming for inline viewing."},
|
||||
{"name": "Vaults", "description": "List, add and remove vaults; per-vault display settings; attachment indexing."},
|
||||
{"name": "Search", "description": "Full-text and advanced search, tags, title suggestions and the link graph."},
|
||||
{"name": "Bookmarks", "description": "Recently opened files, bookmarks and saved searches."},
|
||||
{"name": "Backups", "description": "Automatic file backups, diffs, restore, compression and purge."},
|
||||
{"name": "Export", "description": "Export notes or whole vaults to HTML, Markdown bundle or ePub."},
|
||||
{"name": "AI", "description": "AI-powered editor actions, provider status and model discovery."},
|
||||
{"name": "BooksLM", "description": "Directory-scoped AI chat (NotebookLM-style) over a vault folder."},
|
||||
{"name": "Sharing", "description": "Create and manage public read-only share links for documents."},
|
||||
{"name": "Webhooks", "description": "HTTP callbacks signed with HMAC-SHA256 for file events."},
|
||||
{"name": "Conflicts", "description": "Detect and resolve Syncthing sync-conflict files."},
|
||||
{"name": "Admin", "description": "Admin-only system monitoring: stats, audit log, backup stats and live stream."},
|
||||
{"name": "Plugins", "description": "Install, enable and manage user plugins."},
|
||||
{"name": "Push", "description": "Web Push (VAPID) subscription management and test notifications."},
|
||||
{"name": "Frontend", "description": "Static assets and SPA fallback routes."},
|
||||
]
|
||||
|
||||
API_DESCRIPTION = """
|
||||
**ObsiGate** exposes a REST API covering the entire application: vaults, files,
|
||||
full-text search, backups, exports, AI actions, sharing and administration.
|
||||
|
||||
### Authentication
|
||||
When authentication is enabled, send a bearer token obtained from
|
||||
`POST /api/auth/login`:
|
||||
|
||||
```
|
||||
Authorization: Bearer <access_token>
|
||||
```
|
||||
|
||||
The same token is also accepted as an HTTP-only cookie, so browser clients can
|
||||
simply use `credentials: "include"`.
|
||||
|
||||
### Interactive documentation
|
||||
* **Swagger UI** — [/docs](/docs): try requests directly from the browser.
|
||||
* **ReDoc** — [/redoc](/redoc): clean, reading-oriented reference.
|
||||
* **OpenAPI JSON** — [/openapi.json](/openapi.json): machine-readable schema.
|
||||
|
||||
### Errors
|
||||
Errors use the standard FastAPI envelope `{"detail": "..."}` with the
|
||||
appropriate HTTP status (`400`, `401`, `403`, `404`, `409`, `422`, `500`).
|
||||
"""
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Automatic tag assignment
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Rules are evaluated in order; the first match wins. Keep the most specific
|
||||
# prefixes first (e.g. BooksLM before AI, config/ai-* before config).
|
||||
_TAG_RULES: list[tuple[re.Pattern[str], str]] = [
|
||||
(re.compile(r"^/api/auth"), "Auth"),
|
||||
(re.compile(r"^/api/admin"), "Admin"),
|
||||
(re.compile(r"^/api/plugins"), "Plugins"),
|
||||
(re.compile(r"^/api/push"), "Push"),
|
||||
(re.compile(r"^/api/ai/bookslm"), "BooksLM"),
|
||||
(re.compile(r"^/api/ai"), "AI"),
|
||||
(re.compile(r"^/api/config/ai-"), "AI"),
|
||||
(re.compile(r"^/api/share"), "Sharing"),
|
||||
(re.compile(r"^/api/shares"), "Sharing"),
|
||||
(re.compile(r"^/s/"), "Sharing"),
|
||||
(re.compile(r"^/api/webhooks"), "Webhooks"),
|
||||
(re.compile(r"^/api/conflicts"), "Conflicts"),
|
||||
(re.compile(r"^/api/backups"), "Backups"),
|
||||
(re.compile(r"^/api/file/[^/]+/(backups|diff|restore)"), "Backups"),
|
||||
(re.compile(r"^/api/export"), "Export"),
|
||||
(re.compile(r"^/api/file/[^/]+/pdf"), "PDF"),
|
||||
(re.compile(r"^/api/search"), "Search"),
|
||||
(re.compile(r"^/api/tags"), "Search"),
|
||||
(re.compile(r"^/api/tree-search"), "Search"),
|
||||
(re.compile(r"^/api/suggest"), "Search"),
|
||||
(re.compile(r"^/api/graph"), "Search"),
|
||||
(re.compile(r"^/api/recent"), "Bookmarks"),
|
||||
(re.compile(r"^/api/bookmarks"), "Bookmarks"),
|
||||
(re.compile(r"^/api/saved-searches"), "Bookmarks"),
|
||||
(re.compile(r"^/api/vaults"), "Vaults"),
|
||||
(re.compile(r"^/api/vault/"), "Vaults"),
|
||||
(re.compile(r"^/api/index/reload"), "Vaults"),
|
||||
(re.compile(r"^/api/attachments"), "Vaults"),
|
||||
(re.compile(r"^/api/browse"), "Files"),
|
||||
(re.compile(r"^/api/file"), "Files"),
|
||||
(re.compile(r"^/api/directory"), "Files"),
|
||||
(re.compile(r"^/api/move"), "Files"),
|
||||
(re.compile(r"^/api/image"), "Files"),
|
||||
(re.compile(r"^/api/health"), "System"),
|
||||
(re.compile(r"^/api/config"), "System"),
|
||||
(re.compile(r"^/api/diagnostics"), "System"),
|
||||
(re.compile(r"^/api/dashboard"), "System"),
|
||||
(re.compile(r"^/api/events"), "System"),
|
||||
]
|
||||
|
||||
|
||||
def tag_for_path(path: str) -> str:
|
||||
"""Return the documentation tag for an API path (``Frontend`` as fallback)."""
|
||||
for pattern, tag in _TAG_RULES:
|
||||
if pattern.match(path):
|
||||
return tag
|
||||
return "Frontend"
|
||||
|
||||
|
||||
# Normalise tags declared by individual routers (``tags=["auth"]``) to the
|
||||
# canonical, documented tag names.
|
||||
_TAG_ALIASES: dict[str, str] = {
|
||||
"auth": "Auth",
|
||||
"admin": "Admin",
|
||||
"plugins": "Plugins",
|
||||
"push": "Push",
|
||||
"files": "Files",
|
||||
"vaults": "Vaults",
|
||||
"search": "Search",
|
||||
"backups": "Backups",
|
||||
"export": "Export",
|
||||
"sharing": "Sharing",
|
||||
"webhooks": "Webhooks",
|
||||
"conflicts": "Conflicts",
|
||||
"system": "System",
|
||||
"frontend": "Frontend",
|
||||
"ai": "AI",
|
||||
"bookslm": "BooksLM",
|
||||
"pdf": "PDF",
|
||||
"bookmarks": "Bookmarks",
|
||||
}
|
||||
|
||||
_CANONICAL_TAGS = {tag["name"] for tag in TAGS_METADATA}
|
||||
|
||||
|
||||
def canonical_tag(name: str) -> str:
|
||||
"""Map a router-declared tag to its documented name (unchanged if unknown)."""
|
||||
mapped = _TAG_ALIASES.get(name.lower(), name)
|
||||
return mapped if mapped in _CANONICAL_TAGS else name
|
||||
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Request/response examples injected into the schema
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_ENDPOINT_EXAMPLES: dict[tuple[str, str], dict[str, Any]] = {
|
||||
("post", "/api/file/{vault_name}"): {
|
||||
"request": {"path": "notes/Nouvelle note.md", "content": "# Titre\n\nContenu"},
|
||||
"response": {"success": True, "path": "notes/Nouvelle note.md"},
|
||||
},
|
||||
("put", "/api/file/{vault_name}/save"): {
|
||||
"request": {"path": "notes/Accueil.md", "content": "# Accueil\n\nMis à jour."},
|
||||
"response": {"status": "ok", "vault": "TestVault", "path": "notes/Accueil.md", "size": 26},
|
||||
},
|
||||
("post", "/api/search/replace"): {
|
||||
"request": {"query": "Python", "replacement": "Python 3", "vault": "all", "dry_run": True},
|
||||
"response": {"matches": [{"vault": "TestVault", "path": "note1.md", "title": "Python", "match_count": 3}], "total_matches": 3, "dry_run": True},
|
||||
},
|
||||
("post", "/api/ai/improve"): {
|
||||
"request": {"text": "ce texte est bof", "provider": "deepseek"},
|
||||
"response": {"result": "Ce texte mérite d'être amélioré.", "provider": "deepseek"},
|
||||
},
|
||||
("post", "/api/ai/bookslm/chat"): {
|
||||
"request": {"vault": "TestVault", "directory": "projets", "message": "Résume ce dossier", "conversation_history": []},
|
||||
"response": {"token": "Voici un résumé…", "provider": "deepseek", "model": "deepseek-chat"},
|
||||
},
|
||||
("post", "/api/share/{vault_name}"): {
|
||||
"request": {"path": "notes/Accueil.md", "expires_in_hours": 168},
|
||||
"response": {"id": "abc…", "token": "abc…", "vault": "TestVault", "path": "notes/Accueil.md", "url": "/s/abc…"},
|
||||
},
|
||||
("post", "/api/webhooks"): {
|
||||
"request": {"name": "CI", "url": "https://example.com/hook", "events": ["file_modified"], "secret": "s3cr3t"},
|
||||
"response": {"id": "3f2c…", "name": "CI", "url": "https://example.com/hook", "events": ["file_modified"], "enabled": True},
|
||||
},
|
||||
}
|
||||
|
||||
# Common error responses documented on every operation.
|
||||
_COMMON_ERRORS: dict[str, dict[str, Any]] = {
|
||||
"401": {
|
||||
"description": "Authentication required or token expired",
|
||||
"content": {"application/json": {"example": {"detail": "Not authenticated"}}},
|
||||
},
|
||||
"403": {
|
||||
"description": "Access to the vault is denied",
|
||||
"content": {"application/json": {"example": {"detail": "Accès refusé à la vault 'Notes'"}}},
|
||||
},
|
||||
"404": {
|
||||
"description": "Resource not found",
|
||||
"content": {"application/json": {"example": {"detail": "File not found: notes/x.md"}}},
|
||||
},
|
||||
"422": {
|
||||
"description": "Validation error",
|
||||
"content": {"application/json": {"example": {"detail": [{"loc": ["query", "path"], "msg": "field required", "type": "value_error.missing"}]}}},
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal server error",
|
||||
"content": {"application/json": {"example": {"detail": "Internal server error"}}},
|
||||
},
|
||||
}
|
||||
|
||||
# Paths that return a binary/streaming body — never add a JSON error example
|
||||
# to their 200 response, and skip the 500 JSON example for streams.
|
||||
_BINARY_MEDIA = ("application/pdf", "application/octet-stream", "image/", "text/event-stream")
|
||||
|
||||
|
||||
def _is_binary_operation(operation: dict[str, Any]) -> bool:
|
||||
content = operation.get("responses", {}).get("200", {}).get("content", {})
|
||||
return any(media.startswith(_BINARY_MEDIA) for media in content)
|
||||
|
||||
|
||||
def enrich_openapi_schema(schema: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Enrich a FastAPI-generated OpenAPI schema in place and return it.
|
||||
|
||||
The transformation is idempotent: calling it twice yields the same result.
|
||||
"""
|
||||
schema.setdefault("openapi", "3.1.0")
|
||||
info = schema.setdefault("info", {})
|
||||
info.setdefault("description", API_DESCRIPTION.strip())
|
||||
info.setdefault("contact", {"name": "ObsiGate", "url": "https://git.dracodev.net/Projets/ObsiGate"})
|
||||
info.setdefault("license", {"name": "MIT"})
|
||||
|
||||
schema.setdefault("servers", [{"url": "/", "description": "Current ObsiGate instance"}])
|
||||
schema["externalDocs"] = {
|
||||
"description": "ObsiGate source repository",
|
||||
"url": "https://git.dracodev.net/Projets/ObsiGate",
|
||||
}
|
||||
schema["tags"] = TAGS_METADATA
|
||||
|
||||
components = schema.setdefault("components", {})
|
||||
security_schemes = components.setdefault("securitySchemes", {})
|
||||
security_schemes.setdefault("bearerAuth", {
|
||||
"type": "http",
|
||||
"scheme": "bearer",
|
||||
"bearerFormat": "JWT",
|
||||
"description": "JWT access token issued by POST /api/auth/login.",
|
||||
})
|
||||
security_schemes.setdefault("cookieAuth", {
|
||||
"type": "apiKey",
|
||||
"in": "cookie",
|
||||
"name": "obsigate_token",
|
||||
"description": "HTTP-only session cookie (browser clients).",
|
||||
})
|
||||
components.setdefault("schemas", {}).setdefault("ErrorResponse", {
|
||||
"type": "object",
|
||||
"properties": {"detail": {"title": "Detail", "description": "Error message"}},
|
||||
})
|
||||
|
||||
for path, operations in schema.get("paths", {}).items():
|
||||
default_tag = tag_for_path(path)
|
||||
for method, operation in operations.items():
|
||||
if not isinstance(operation, dict):
|
||||
continue
|
||||
if operation.get("tags"):
|
||||
operation["tags"] = [canonical_tag(t) for t in operation["tags"]]
|
||||
else:
|
||||
operation["tags"] = [default_tag]
|
||||
|
||||
# Document common error responses without overriding explicit ones.
|
||||
responses = operation.setdefault("responses", {})
|
||||
binary = _is_binary_operation(operation)
|
||||
for code, spec in _COMMON_ERRORS.items():
|
||||
if code == "500" and binary:
|
||||
continue
|
||||
responses.setdefault(code, spec)
|
||||
|
||||
# Inject request/response examples for key endpoints.
|
||||
example = _ENDPOINT_EXAMPLES.get((method, path))
|
||||
if example:
|
||||
if "request" in example:
|
||||
body = operation.setdefault("requestBody", {})
|
||||
content = body.setdefault("content", {}).setdefault("application/json", {})
|
||||
content.setdefault("example", example["request"])
|
||||
if "response" in example:
|
||||
ok = responses.setdefault("200", {})
|
||||
content = ok.setdefault("content", {}).setdefault("application/json", {})
|
||||
content.setdefault("example", example["response"])
|
||||
|
||||
return schema
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /api landing page
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_METHOD_COLORS = {
|
||||
"get": "#2563eb",
|
||||
"post": "#16a34a",
|
||||
"put": "#d97706",
|
||||
"patch": "#7c3aed",
|
||||
"delete": "#dc2626",
|
||||
"head": "#64748b",
|
||||
"options": "#64748b",
|
||||
}
|
||||
|
||||
|
||||
def render_api_landing(version: str = "") -> str:
|
||||
"""Return the self-contained HTML for the ``/api`` documentation page."""
|
||||
return f"""<!DOCTYPE html>
|
||||
<html lang="en" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>ObsiGate API{(' — v' + version) if version else ''}</title>
|
||||
<style>
|
||||
:root {{
|
||||
--bg: #0f172a; --surface: #1e293b; --border: #334155;
|
||||
--text: #e2e8f0; --muted: #94a3b8; --accent: #60a5fa; --accent-2: #34d399;
|
||||
}}
|
||||
html[data-theme="light"] {{
|
||||
--bg: #f8fafc; --surface: #ffffff; --border: #e2e8f0;
|
||||
--text: #0f172a; --muted: #64748b; --accent: #2563eb; --accent-2: #059669;
|
||||
}}
|
||||
* {{ box-sizing: border-box; }}
|
||||
body {{
|
||||
margin: 0; background: var(--bg); color: var(--text);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
}}
|
||||
.wrap {{ max-width: 1080px; margin: 0 auto; padding: 40px 24px 80px; }}
|
||||
header h1 {{ margin: 0 0 4px; font-size: 1.9rem; }}
|
||||
header p {{ margin: 0; color: var(--muted); }}
|
||||
.actions {{ display: flex; flex-wrap: wrap; gap: 10px; margin: 24px 0 32px; }}
|
||||
.actions a {{
|
||||
text-decoration: none; color: var(--text); background: var(--surface);
|
||||
border: 1px solid var(--border); border-radius: 8px; padding: 10px 16px;
|
||||
font-weight: 600; font-size: .9rem; transition: border-color .15s, transform .15s;
|
||||
}}
|
||||
.actions a:hover {{ border-color: var(--accent); transform: translateY(-1px); }}
|
||||
.actions a.primary {{ background: var(--accent); border-color: var(--accent); color: #fff; }}
|
||||
.meta {{ color: var(--muted); font-size: .85rem; margin-bottom: 24px; }}
|
||||
.tag {{ margin: 28px 0; }}
|
||||
.tag > h2 {{
|
||||
font-size: 1.1rem; margin: 0 0 2px; display: flex; align-items: center; gap: 8px;
|
||||
}}
|
||||
.tag > p {{ margin: 0 0 12px; color: var(--muted); font-size: .85rem; }}
|
||||
.ops {{ display: flex; flex-direction: column; gap: 6px; }}
|
||||
.op {{
|
||||
display: flex; align-items: center; gap: 12px; padding: 8px 12px;
|
||||
background: var(--surface); border: 1px solid var(--border); border-radius: 8px;
|
||||
text-decoration: none; color: var(--text); font-size: .88rem;
|
||||
}}
|
||||
.op:hover {{ border-color: var(--accent); }}
|
||||
.method {{
|
||||
flex: 0 0 62px; text-align: center; font-weight: 700; font-size: .68rem;
|
||||
text-transform: uppercase; letter-spacing: .04em; color: #fff; border-radius: 5px; padding: 3px 0;
|
||||
}}
|
||||
.op code {{ color: var(--accent-2); font-size: .85rem; }}
|
||||
.op .summary {{ color: var(--muted); margin-left: auto; text-align: right; }}
|
||||
.loading {{ color: var(--muted); }}
|
||||
footer {{ margin-top: 48px; color: var(--muted); font-size: .8rem; }}
|
||||
@media (max-width: 640px) {{ .op .summary {{ display: none; }} }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<header>
|
||||
<h1>ObsiGate API</h1>
|
||||
<p>Interactive reference for the ObsiGate REST API (OpenAPI 3.1).</p>
|
||||
</header>
|
||||
<div class="actions">
|
||||
<a class="primary" href="/docs">Swagger UI — try it</a>
|
||||
<a href="/redoc">ReDoc — reference</a>
|
||||
<a href="/openapi.json">OpenAPI JSON</a>
|
||||
<a href="/">← Back to ObsiGate</a>
|
||||
</div>
|
||||
<div class="meta" id="meta">Loading specification…</div>
|
||||
<div id="tags"><p class="loading">Loading endpoints…</p></div>
|
||||
<footer>Generated from the live OpenAPI schema. Authentication: <code>Authorization: Bearer <token></code>.</footer>
|
||||
</div>
|
||||
<script>
|
||||
(function () {{
|
||||
var COLORS = {_js_colors()};
|
||||
function el(tag, cls, text) {{
|
||||
var e = document.createElement(tag);
|
||||
if (cls) e.className = cls;
|
||||
if (text != null) e.textContent = text;
|
||||
return e;
|
||||
}}
|
||||
fetch('/openapi.json', {{ credentials: 'include' }})
|
||||
.then(function (r) {{ if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); }})
|
||||
.then(function (spec) {{
|
||||
var info = spec.info || {{}};
|
||||
var tagMeta = {{}};
|
||||
(spec.tags || []).forEach(function (t) {{ tagMeta[t.name] = t.description || ''; }});
|
||||
var groups = {{}};
|
||||
var paths = spec.paths || {{}};
|
||||
var opCount = 0;
|
||||
Object.keys(paths).forEach(function (path) {{
|
||||
var ops = paths[path];
|
||||
Object.keys(ops).forEach(function (method) {{
|
||||
if (['get','post','put','patch','delete','head','options'].indexOf(method) === -1) return;
|
||||
var op = ops[method];
|
||||
if (op.deprecated && false) return;
|
||||
opCount++;
|
||||
var tag = (op.tags && op.tags[0]) || 'Other';
|
||||
(groups[tag] = groups[tag] || []).push({{ path: path, method: method, summary: op.summary || '' }});
|
||||
}});
|
||||
}});
|
||||
document.getElementById('meta').textContent =
|
||||
(info.title || 'ObsiGate') + (info.version ? ' v' + info.version : '') +
|
||||
' — OpenAPI ' + (spec.openapi || '3.1') + ' — ' + opCount + ' operations';
|
||||
var host = document.getElementById('tags');
|
||||
host.innerHTML = '';
|
||||
Object.keys(groups).forEach(function (tag) {{
|
||||
var section = el('section', 'tag');
|
||||
section.appendChild(el('h2', null, tag));
|
||||
if (tagMeta[tag]) section.appendChild(el('p', null, tagMeta[tag]));
|
||||
var ops = el('div', 'ops');
|
||||
groups[tag].forEach(function (o) {{
|
||||
var a = el('a', 'op');
|
||||
a.href = '/docs#/' + encodeURIComponent(tag) + '/' + encodeURIComponent(o.path);
|
||||
var m = el('span', 'method', o.method);
|
||||
m.style.background = COLORS[o.method] || '#64748b';
|
||||
a.appendChild(m);
|
||||
a.appendChild(el('code', null, o.path));
|
||||
if (o.summary) a.appendChild(el('span', 'summary', o.summary));
|
||||
ops.appendChild(a);
|
||||
}});
|
||||
section.appendChild(ops);
|
||||
host.appendChild(section);
|
||||
}});
|
||||
}})
|
||||
.catch(function (e) {{
|
||||
document.getElementById('meta').textContent = 'Failed to load OpenAPI schema: ' + e.message;
|
||||
}});
|
||||
}})();
|
||||
</script>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
|
||||
def _js_colors() -> str:
|
||||
"""Serialise the method→colour map for the landing page script."""
|
||||
import json
|
||||
|
||||
return json.dumps(_METHOD_COLORS)
|
||||
Reference in New Issue
Block a user