Files
bruno 4ce677902a
CI / lint (push) Successful in 2m53s
CI / security (push) Successful in 2m5s
CI / test (push) Successful in 4m52s
CI / build (push) Successful in 2m5s
CI / e2e (push) Successful in 16m14s
feat: agent IA phase 4 — doublons #166, notifications externes #168 (Discord/Telegram/SMTP/webhook), taches planifiees #170
2026-10-04 13:11:25 -04:00

573 lines
25 KiB
Python

"""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": "Guide", "description": "Download the in-app user guide as Markdown or PDF (mirrors the help modal, FR/EN)."},
{"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": "MCP", "description": "Model Context Protocol server (Streamable HTTP) exposing the shared AI tool layer to external clients (Claude Desktop, Cursor…)."},
{"name": "Collaboration", "description": "Real-time collaborative editing over WebSocket (`/ws/collab/{vault}/{path}`): Yjs/CRDT updates, awareness (cursors) and debounced server-side persistence."},
{"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": "Duplicates", "description": "Duplicate-note detection and confirmed merge (#166)."},
{"name": "Notify", "description": "External notifications: Discord, Telegram, SMTP and generic webhooks (#168)."},
{"name": "Scheduler", "description": "Scheduled automatic tasks reusing the vault mutation services (#170)."},
{"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"`.
### Real-time collaboration
Besides the REST API, a WebSocket endpoint `GET /ws/collab/{vault}/{path}` (upgrade) powers
simultaneous editing of the same file: clients exchange Yjs/CRDT updates and awareness (remote
cursors), and the server persists the document 2 s after the last change. Authentication uses the
`access_token` cookie (or a `token` query parameter) and vault access is enforced per connection.
See the collaboration feature documentation (`docs/features/collaboration.md`) for the protocol.
### 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"^/mcp"), "MCP"),
(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/duplicates"), "Duplicates"),
(re.compile(r"^/api/notify"), "Notify"),
(re.compile(r"^/api/scheduler"), "Scheduler"),
(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/guide"), "Guide"),
(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",
"duplicates": "Duplicates",
"notify": "Notify",
"scheduler": "Scheduler",
"conflicts": "Conflicts",
"system": "System",
"frontend": "Frontend",
"ai": "AI",
"bookslm": "BooksLM",
"mcp": "MCP",
"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},
},
("put", "/api/file/{vault_name}/xlsx/save"): {
"request": {"sheet": "Budget", "cells": {"B1": "250"}, "allow_formula": False, "force": False},
"response": {"status": "ok", "vault": "TestVault", "path": "data/budget.xlsx", "size": 1},
},
("put", "/api/file/{vault_name}/xlsx/style"): {
"request": {
"ops": [
{"op": "cell", "sheet": "Budget", "range": "A1:B1", "style": {"bold": True, "fill_color": "#ffe08a"}},
{"op": "col_width", "sheet": "Budget", "col": "A", "width": 24},
],
"force": False,
"if_match": "18f2c0ab-1f4",
},
"response": {"status": "ok", "vault": "TestVault", "path": "data/budget.xlsx", "size": 2, "revision": "18f2c0ab-1f6"},
},
# GET : pas d'exemple de requête (un requestBody sur un GET serait un OpenAPI
# invalide) — les paramètres sont documentés par leurs Query().
("get", "/api/file/{vault_name}/xlsx/sheet"): {
"response": {
"vault": "TestVault",
"path": "data/budget.xlsx",
"sheet": "Budget",
"offset": 0,
"limit": 200,
"rows": 2,
"cols": 2,
"total_rows": 640,
"total_cols": 12,
"max_rows": 500,
"max_cols": 40,
"truncated": True,
"has_more": True,
"html": "<table>…</table>",
},
},
("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)
# ---------------------------------------------------------------------------
# MCP endpoint (not a FastAPI route: custom ASGI mount) — documented manually
# ---------------------------------------------------------------------------
_MCP_DESCRIPTION = (
"**Model Context Protocol** server over Streamable HTTP (JSON-RPC 2.0). "
"Exposes the shared AI tool layer to external MCP clients (Claude Desktop, "
"Cursor…). Authentication uses `Authorization: Bearer <JWT>` (the same "
"token as the REST API).\n\n"
"Primitives: read/search **tools** directly; write/destructive tools as a "
"two-step `propose_<tool>` / `apply_<tool>` pair (signed, single-use "
"confirmation token); **resources** `vault://<name>` and "
"`vault://<name>/<path>` (read-only, secrets redacted); **prompts** "
"`summarize-directory`, `generate-note`, `find-related`.\n\n"
"See `docs/MCP_GUIDE.md` for client setup."
)
def _inject_mcp_path(schema: dict[str, Any]) -> None:
"""Add the MCP Streamable HTTP endpoint to the schema (idempotent)."""
paths = schema.setdefault("paths", {})
if "/mcp" in paths:
return
paths["/mcp"] = {
"post": {
"tags": ["MCP"],
"summary": "MCP Streamable HTTP endpoint (JSON-RPC 2.0)",
"operationId": "mcp_streamable_http",
"description": _MCP_DESCRIPTION,
"requestBody": {
"required": True,
"content": {
"application/json": {
"example": {
"jsonrpc": "2.0",
"id": 1,
"method": "tools/list",
"params": {},
}
}
},
},
"responses": {
"200": {
"description": "JSON-RPC response (or 202 for notifications)",
"content": {
"application/json": {
"example": {
"jsonrpc": "2.0",
"id": 1,
"result": {"tools": []},
}
}
},
}
},
"security": [{"bearerAuth": []}],
}
}
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
_inject_mcp_path(schema)
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 &lt;token&gt;</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)