From 4a4c83b53a9494486f9bb68aa81c861c31399941 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Thu, 10 Sep 2026 01:04:01 +0200 Subject: [PATCH] doc(msg-bus): add asyncapi doc generator you can test generated doc resources/gen/asyncapi.json into https://studio.asyncapi.com/ --- src/bin/generate-asyncapi.rs | 356 +++++++++++++++++++++++++++++++++++ 1 file changed, 356 insertions(+) create mode 100644 src/bin/generate-asyncapi.rs diff --git a/src/bin/generate-asyncapi.rs b/src/bin/generate-asyncapi.rs new file mode 100644 index 00000000..9cc25056 --- /dev/null +++ b/src/bin/generate-asyncapi.rs @@ -0,0 +1,356 @@ +//! AsyncAPI 3.0 spec generator for the realtime message bus. +//! +//! Mirrors `generate-openapi.rs`: constructs the spec from the same +//! Rust enums the server uses (`Topic`, `RealtimeEvent`, JSON-RPC +//! error codes) and writes `resources/gen/asyncapi.json`. +//! +//! This is the first-PR MVP surface — the two topics and two events +//! that Phase A ships (see `docs/plan/message-bus.md § First PR`). +//! Adding a topic/event later is a match arm + a new schema block in +//! this file; the CI dirty-tree check (same as OpenAPI's) prevents +//! spec/code drift. +//! +//! Format: JSON, not YAML — matches `openapi.json`. AsyncAPI's own +//! tooling reads either; JSON also keeps us dep-free. +//! +//! Invocation: +//! +//! ```bash +//! cargo run --bin generate-asyncapi +//! # or +//! just asyncapi +//! ``` + +use std::fs; +use std::path::PathBuf; + +use oxicloud::application::ports::realtime_ports::error_code; +use serde_json::{Value, json}; + +fn main() { + let doc = build_asyncapi(); + let json = + serde_json::to_string_pretty(&doc).expect("Failed to serialize AsyncAPI spec to JSON"); + + let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let resources_gen_dir = manifest_dir.join("resources").join("gen"); + fs::create_dir_all(&resources_gen_dir).expect("Failed to create resources/gen directory"); + let output_path = resources_gen_dir.join("asyncapi.json"); + fs::write(&output_path, json).expect("Failed to write AsyncAPI spec to file"); + + println!( + "AsyncAPI spec generated successfully at: {}", + output_path.display() + ); +} + +fn build_asyncapi() -> Value { + json!({ + "asyncapi": "3.0.0", + "info": { + "title": "OxiCloud realtime message bus", + "version": env!("CARGO_PKG_VERSION"), + "description": r#" +JSON-RPC 2.0 over WebSocket for control + events, Yjs sync protocol for +CRDT binary frames. The wire is described here for the first-PR MVP +surface (folder-live updates); Phase B (comments, presence) and +Phase C (sync-client push, album live) extend the same channels — see +`docs/plan/message-bus.md`. +"#.trim(), + "license": { "name": "AGPL-3.0-or-later" }, + }, + "servers": { + "default": { + "host": "{host}", + "pathname": "/api/rt/ws", + "protocol": "wss", + "description": "OxiCloud realtime bus WebSocket endpoint", + "variables": { + "host": { + "description": "Server host — replace with the deployment domain", + "default": "cloud.example.com", + }, + }, + "protocolVersion": "13", + // Subprotocol advertised in the WS handshake. Handler + // accepts `oxi.rt.v1` and the optional bearer element + // `authorization.bearer.` alongside it. + "bindings": { + "ws": { "subProtocol": "oxi.rt.v1" } + }, + } + }, + "channels": channels(), + "operations": operations(), + "components": components(), + }) +} + +fn channels() -> Value { + json!({ + "Folder": { + "address": "folder:{folderId}", + "description": "A folder's mutation stream — file/subfolder created events fire here. AuthZ: caller must hold `Read` on the folder.", + "parameters": { + "folderId": { "description": "Folder UUID" } + }, + "messages": { + "SubscribeRequest": { "$ref": "#/components/messages/RtSubscribeRequest" }, + "UnsubscribeRequest": { "$ref": "#/components/messages/RtUnsubscribeRequest" }, + "PingRequest": { "$ref": "#/components/messages/RtPingRequest" }, + "SubscribedResponse": { "$ref": "#/components/messages/RtSubscribedResponse" }, + "ErrorResponse": { "$ref": "#/components/messages/RtErrorResponse" }, + "FolderEvent": { "$ref": "#/components/messages/RtFolderEventNotification" }, + } + }, + "UserAuthz": { + "address": "user:{userId}:authz", + "description": "A user's private AuthZ-change channel. Identity-scoped: caller_id must equal userId (no admin bypass).", + "parameters": { + "userId": { "description": "User UUID — must match the authenticated caller" } + }, + "messages": { + "SubscribeRequest": { "$ref": "#/components/messages/RtSubscribeRequest" }, + "UnsubscribeRequest": { "$ref": "#/components/messages/RtUnsubscribeRequest" }, + } + } + }) +} + +fn operations() -> Value { + json!({ + "subscribeFolder": { + "action": "send", + "channel": { "$ref": "#/channels/Folder" }, + "summary": "Subscribe to a folder's mutation stream", + "messages": [ + { "$ref": "#/channels/Folder/messages/SubscribeRequest" } + ], + "reply": { + "channel": { "$ref": "#/channels/Folder" }, + "messages": [ + { "$ref": "#/channels/Folder/messages/SubscribedResponse" }, + { "$ref": "#/channels/Folder/messages/ErrorResponse" }, + ] + } + }, + "unsubscribeFolder": { + "action": "send", + "channel": { "$ref": "#/channels/Folder" }, + "summary": "Unsubscribe from a folder's mutation stream", + "messages": [ + { "$ref": "#/channels/Folder/messages/UnsubscribeRequest" } + ] + }, + "receiveFolderEvent": { + "action": "receive", + "channel": { "$ref": "#/channels/Folder" }, + "summary": "Server-pushed `rt.event` notification for a folder mutation", + "messages": [ + { "$ref": "#/channels/Folder/messages/FolderEvent" } + ] + } + }) +} + +fn components() -> Value { + json!({ + "messages": { + // ── Requests ──────────────────────────────────────────── + "RtSubscribeRequest": { + "name": "rt.subscribe", + "title": "Subscribe to a topic", + "contentType": "application/json", + "payload": { "$ref": "#/components/schemas/RtSubscribeRequestBody" }, + }, + "RtUnsubscribeRequest": { + "name": "rt.unsubscribe", + "title": "Unsubscribe from a topic", + "contentType": "application/json", + "payload": { "$ref": "#/components/schemas/RtUnsubscribeRequestBody" }, + }, + "RtPingRequest": { + "name": "rt.ping", + "title": "Keepalive ping", + "contentType": "application/json", + "payload": { "$ref": "#/components/schemas/RtPingRequestBody" }, + }, + // ── Responses ─────────────────────────────────────────── + "RtSubscribedResponse": { + "name": "rt.subscribed", + "title": "Subscribe / unsubscribe ack", + "contentType": "application/json", + "payload": { "$ref": "#/components/schemas/RtSuccessResponseBody" }, + }, + "RtErrorResponse": { + "name": "rt.error", + "title": "JSON-RPC error object", + "contentType": "application/json", + "payload": { "$ref": "#/components/schemas/RtErrorResponseBody" }, + }, + // ── Notifications (server → client) ───────────────────── + "RtFolderEventNotification": { + "name": "rt.event", + "title": "Folder mutation event", + "contentType": "application/json", + "payload": { "$ref": "#/components/schemas/RtFolderEventBody" }, + } + }, + "schemas": { + "RtSubscribeRequestBody": rpc_request_schema("rt.subscribe", topic_params_schema()), + "RtUnsubscribeRequestBody": rpc_request_schema("rt.unsubscribe", topic_params_schema()), + "RtPingRequestBody": rpc_request_schema("rt.ping", json!({ "type": "null" })), + "RtSuccessResponseBody": rpc_success_response_schema(), + "RtErrorResponseBody": rpc_error_response_schema(), + "RtFolderEventBody": folder_event_notification_schema(), + "FileCreatedData": file_created_schema(), + "FolderCreatedData": folder_created_schema(), + } + }) +} + +// ─── Schema builders ──────────────────────────────────────────────────────── + +fn rpc_request_schema(method: &str, params_schema: Value) -> Value { + json!({ + "type": "object", + "required": ["jsonrpc", "id", "method"], + "properties": { + "jsonrpc": { "type": "string", "const": "2.0" }, + "id": { "type": ["integer", "string", "null"] }, + "method": { "type": "string", "const": method }, + "params": params_schema, + } + }) +} + +fn topic_params_schema() -> Value { + json!({ + "type": "object", + "required": ["topic"], + "properties": { + "topic": { + "type": "string", + "description": "Wire form: `folder:` or `user::authz`", + "examples": ["folder:00000000-0000-0000-0000-000000000000"], + } + } + }) +} + +fn rpc_success_response_schema() -> Value { + json!({ + "type": "object", + "required": ["jsonrpc", "id", "result"], + "properties": { + "jsonrpc": { "type": "string", "const": "2.0" }, + "id": { "type": ["integer", "string", "null"] }, + "result": { "type": "object" }, + } + }) +} + +fn rpc_error_response_schema() -> Value { + // The `code`/`message` catalog is the stable public vocabulary — + // any change here IS a wire break. Every entry mirrors + // `application/ports/realtime_ports.rs::error_code`. + json!({ + "type": "object", + "required": ["jsonrpc", "id", "error"], + "properties": { + "jsonrpc": { "type": "string", "const": "2.0" }, + "id": { "type": ["integer", "string", "null"] }, + "error": { + "type": "object", + "required": ["code", "message"], + "properties": { + "code": { + "type": "integer", + "enum": [ + error_code::NO_READ, + error_code::NO_SHARE, + error_code::NO_COMMENT, + error_code::TOPIC_FORBIDDEN, + error_code::SUB_LIMIT, + error_code::RATE_LIMITED, + error_code::NO_EDIT, + error_code::INTERNAL_ERROR, + error_code::INVALID_REQUEST, + error_code::METHOD_NOT_FOUND, + error_code::INVALID_PARAMS, + ], + }, + "message": { + "type": "string", + "description": "Stable wire vocabulary; matches the `code`.", + "enum": [ + "no_read", "no_share", "no_comment", "topic_forbidden", + "sub_limit", "rate_limited", "no_edit", + "internal_error", "invalid_request", + "method_not_found", "invalid_params", + ], + }, + "data": { + "type": "object", + "description": "Optional caller-facing context (e.g. offending topic).", + } + } + } + } + }) +} + +fn folder_event_notification_schema() -> Value { + json!({ + "type": "object", + "description": "JSON-RPC notification (no `id`). `method = \"rt.event\"`.", + "required": ["jsonrpc", "method", "params"], + "properties": { + "jsonrpc": { "type": "string", "const": "2.0" }, + "method": { "type": "string", "const": "rt.event" }, + "params": { + "type": "object", + "required": ["topic", "event", "data"], + "properties": { + "topic": { "type": "string" }, + "event": { + "type": "string", + "enum": ["file_created", "folder_created"], + }, + "data": { + "oneOf": [ + { "$ref": "#/components/schemas/FileCreatedData" }, + { "$ref": "#/components/schemas/FolderCreatedData" }, + ] + } + } + } + } + }) +} + +fn file_created_schema() -> Value { + json!({ + "type": "object", + "required": ["file_id", "name", "parent_id", "actor"], + "properties": { + "file_id": { "type": "string", "format": "uuid" }, + "name": { "type": "string" }, + "parent_id": { "type": "string", "format": "uuid" }, + "actor": { "type": "string", "format": "uuid" }, + } + }) +} + +fn folder_created_schema() -> Value { + json!({ + "type": "object", + "required": ["folder_id", "name", "parent_id", "actor"], + "properties": { + "folder_id": { "type": "string", "format": "uuid" }, + "name": { "type": "string" }, + "parent_id": { "type": "string", "format": "uuid" }, + "actor": { "type": "string", "format": "uuid" }, + } + }) +}