doc(msg-bus): add asyncapi doc generator
you can test generated doc resources/gen/asyncapi.json into https://studio.asyncapi.com/
This commit is contained in:
@@ -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.<jwt>` 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:<uuid>` or `user:<uuid>: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" },
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user