2026-09-10 01:04:01 +02:00
//! 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" },
},
2026-09-10 01:33:37 +02:00
// Applied to every message that doesn't set its own — the JSON-RPC
// control frames are all `application/json`. Binary Yjs frames
// stay out of AsyncAPI (see the Server description for pointers).
"defaultContentType" : "application/json" ,
2026-09-10 01:04:01 +02:00
"servers" : {
"default" : {
"host" : "{host}" ,
"pathname" : "/api/rt/ws" ,
"protocol" : "wss" ,
2026-09-10 01:33:37 +02:00
"description" : "OxiCloud realtime bus WebSocket endpoint. Text frames are JSON-RPC 2.0. Binary frames (out of AsyncAPI scope) are Yjs sync protocol for the collab editor — see `docs/plan/markdown-collab.md`." ,
2026-09-10 01:04:01 +02:00
"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" }
},
2026-09-10 01:33:37 +02:00
// Every request MUST be authenticated. Programmatic
// clients set `Authorization: Bearer <jwt>` on the WS
// upgrade (same header the REST API uses); browser
// clients — which can't set headers on `new WebSocket()`
// — will use the deferred ticket flow (a plain HTTP
// POST issues a short-lived one-shot ticket bound to
// the WS URL, see the plan's DPoP-gap section).
"security" : [
{ "$ref" : "#/components/securitySchemes/bearerAuth" }
],
2026-09-10 01:04:01 +02:00
}
},
"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" },
2026-09-10 01:33:37 +02:00
"PongResponse" : { "$ref" : "#/components/messages/RtPongResponse" },
2026-09-10 01:04:01 +02:00
"SubscribedResponse" : { "$ref" : "#/components/messages/RtSubscribedResponse" },
"ErrorResponse" : { "$ref" : "#/components/messages/RtErrorResponse" },
"FolderEvent" : { "$ref" : "#/components/messages/RtFolderEventNotification" },
2026-09-10 08:36:49 +02:00
"RevokedNotification" : { "$ref" : "#/components/messages/RtRevokedNotification" },
2026-09-10 01:04:01 +02:00
}
},
"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" }
]
2026-09-10 01:33:37 +02:00
},
2026-09-10 08:36:49 +02:00
"receiveRevoked" : {
"action" : "receive" ,
"channel" : { "$ref" : "#/channels/Folder" },
"summary" : "Server-initiated eviction of a subscription (grant revoked, resource deleted, etc.). Client stops rendering the topic." ,
"messages" : [
{ "$ref" : "#/channels/Folder/messages/RevokedNotification" }
]
},
2026-09-10 01:33:37 +02:00
// Application-layer keepalive. Separate from the RFC 6455 Ping
// control frame the server sends on `OXICLOUD_RT_WS_KEEPALIVE_SECONDS`
// (which is transport-level and not modelled in AsyncAPI). This
// operation lets a client actively confirm the socket is
// end-to-end alive when transport-level Pings alone can't rule
// out a proxy black-hole.
"ping" : {
"action" : "send" ,
"channel" : { "$ref" : "#/channels/Folder" },
"summary" : "Application-level keepalive; `rt.pong` reply confirms end-to-end liveness" ,
"messages" : [
{ "$ref" : "#/channels/Folder/messages/PingRequest" }
],
"reply" : {
"channel" : { "$ref" : "#/channels/Folder" },
"messages" : [
{ "$ref" : "#/channels/Folder/messages/PongResponse" }
]
}
2026-09-10 01:04:01 +02:00
}
})
}
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" },
},
2026-09-10 01:33:37 +02:00
"RtPongResponse" : {
"name" : "rt.pong" ,
"title" : "Reply to rt.ping — `result.pong == true`" ,
"contentType" : "application/json" ,
"payload" : { "$ref" : "#/components/schemas/RtPongResponseBody" },
},
2026-09-10 01:04:01 +02:00
// ── Notifications (server → client) ─────────────────────
"RtFolderEventNotification" : {
"name" : "rt.event" ,
"title" : "Folder mutation event" ,
"contentType" : "application/json" ,
"payload" : { "$ref" : "#/components/schemas/RtFolderEventBody" },
2026-09-10 08:36:49 +02:00
},
"RtRevokedNotification" : {
"name" : "rt.revoked" ,
"title" : "Subscription evicted" ,
"contentType" : "application/json" ,
"payload" : { "$ref" : "#/components/schemas/RtRevokedBody" },
2026-09-10 01:04:01 +02:00
}
},
"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 (),
2026-09-10 01:33:37 +02:00
"RtPongResponseBody" : rpc_pong_response_schema (),
2026-09-10 01:04:01 +02:00
"RtErrorResponseBody" : rpc_error_response_schema (),
"RtFolderEventBody" : folder_event_notification_schema (),
2026-09-10 08:36:49 +02:00
"RtRevokedBody" : revoked_notification_schema (),
2026-09-10 01:04:01 +02:00
"FileCreatedData" : file_created_schema (),
2026-09-10 07:15:09 +02:00
"FileRenamedData" : file_renamed_schema (),
"FileMovedData" : file_moved_schema (),
"FileDeletedData" : file_deleted_schema (),
2026-09-10 01:04:01 +02:00
"FolderCreatedData" : folder_created_schema (),
2026-09-10 07:15:09 +02:00
"FolderRenamedData" : folder_renamed_schema (),
"FolderMovedData" : folder_moved_schema (),
"FolderDeletedData" : folder_deleted_schema (),
2026-09-10 01:33:37 +02:00
},
// How the client authenticates. Handler side is `auth_middleware`
// — the same middleware every `/api/*` request goes through, so
// any JWT valid for REST is valid for WS.
"securitySchemes" : {
"bearerAuth" : {
"type" : "http" ,
"scheme" : "bearer" ,
"bearerFormat" : "JWT" ,
"description" : "OxiCloud JWT — same access_token minted by `POST /api/auth/login` (or the OPAQUE handshake). Programmatic clients set `Authorization: Bearer <jwt>` on the WS upgrade request. Browsers, which cannot set headers on `new WebSocket()`, will use the deferred ticket flow (`POST /api/rt/ticket` → short-lived one-shot ticket in the WS URL); see the plan's DPoP-gap section." ,
}
2026-09-10 01:04:01 +02:00
}
})
}
// ─── 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" },
}
})
}
2026-09-10 01:33:37 +02:00
/// Reply to `rt.ping` — the shape pins `result.pong == true` so
/// contract tests can assert on it directly.
fn rpc_pong_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" ,
"required" : [ "pong" ],
"properties" : {
"pong" : { "type" : "boolean" , "const" : true }
}
},
}
})
}
2026-09-10 01:04:01 +02:00
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" ,
2026-09-10 07:15:09 +02:00
"enum" : [
"file_created" , "file_renamed" , "file_moved" , "file_deleted" ,
"folder_created" , "folder_renamed" , "folder_moved" , "folder_deleted" ,
],
2026-09-10 01:04:01 +02:00
},
"data" : {
"oneOf" : [
{ "$ref" : "#/components/schemas/FileCreatedData" },
2026-09-10 07:15:09 +02:00
{ "$ref" : "#/components/schemas/FileRenamedData" },
{ "$ref" : "#/components/schemas/FileMovedData" },
{ "$ref" : "#/components/schemas/FileDeletedData" },
2026-09-10 01:04:01 +02:00
{ "$ref" : "#/components/schemas/FolderCreatedData" },
2026-09-10 07:15:09 +02:00
{ "$ref" : "#/components/schemas/FolderRenamedData" },
{ "$ref" : "#/components/schemas/FolderMovedData" },
{ "$ref" : "#/components/schemas/FolderDeletedData" },
2026-09-10 01:04:01 +02:00
]
}
}
}
}
})
}
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" },
}
})
}
2026-09-10 07:15:09 +02:00
fn file_renamed_schema () -> Value {
json! ({
"type" : "object" ,
"required" : [ "file_id" , "old_name" , "new_name" , "parent_id" , "actor" ],
"properties" : {
"file_id" : { "type" : "string" , "format" : "uuid" },
"old_name" : { "type" : "string" },
"new_name" : { "type" : "string" },
"parent_id" : { "type" : "string" , "format" : "uuid" },
"actor" : { "type" : "string" , "format" : "uuid" },
}
})
}
fn file_moved_schema () -> Value {
json! ({
"type" : "object" ,
"description" : "Emitted on BOTH the source (`from`) and destination (`to`) folder topics. Subscribers to either see the event exactly once because they're subscribed to only one of the two." ,
"required" : [ "file_id" , "name" , "from" , "to" , "actor" ],
"properties" : {
"file_id" : { "type" : "string" , "format" : "uuid" },
"name" : { "type" : "string" },
"from" : { "type" : "string" , "format" : "uuid" },
"to" : { "type" : "string" , "format" : "uuid" },
"actor" : { "type" : "string" , "format" : "uuid" },
}
})
}
fn file_deleted_schema () -> Value {
json! ({
"type" : "object" ,
"description" : "The wire doesn't distinguish soft (trash) vs. permanent delete — clients treat both as \" disappears from the folder view \" . `parent_id` is the folder the file used to live in." ,
"required" : [ "file_id" , "parent_id" , "actor" ],
"properties" : {
"file_id" : { "type" : "string" , "format" : "uuid" },
"parent_id" : { "type" : "string" , "format" : "uuid" },
"actor" : { "type" : "string" , "format" : "uuid" },
}
})
}
2026-09-10 01:04:01 +02:00
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" },
}
})
}
2026-09-10 07:15:09 +02:00
fn folder_renamed_schema () -> Value {
json! ({
"type" : "object" ,
"required" : [ "folder_id" , "old_name" , "new_name" , "parent_id" , "actor" ],
"properties" : {
"folder_id" : { "type" : "string" , "format" : "uuid" },
"old_name" : { "type" : "string" },
"new_name" : { "type" : "string" },
"parent_id" : { "type" : "string" , "format" : "uuid" },
"actor" : { "type" : "string" , "format" : "uuid" },
}
})
}
fn folder_moved_schema () -> Value {
json! ({
"type" : "object" ,
"description" : "Emitted on BOTH the source (`from`) and destination (`to`) folder topics — same shape as `FileMoved`." ,
"required" : [ "folder_id" , "name" , "from" , "to" , "actor" ],
"properties" : {
"folder_id" : { "type" : "string" , "format" : "uuid" },
"name" : { "type" : "string" },
"from" : { "type" : "string" , "format" : "uuid" },
"to" : { "type" : "string" , "format" : "uuid" },
"actor" : { "type" : "string" , "format" : "uuid" },
}
})
}
fn folder_deleted_schema () -> Value {
json! ({
"type" : "object" ,
"description" : "Soft vs. permanent delete are indistinguishable on the wire." ,
"required" : [ "folder_id" , "parent_id" , "actor" ],
"properties" : {
"folder_id" : { "type" : "string" , "format" : "uuid" },
"parent_id" : { "type" : "string" , "format" : "uuid" },
"actor" : { "type" : "string" , "format" : "uuid" },
}
})
}
2026-09-10 08:36:49 +02:00
/// `rt.revoked` notification body — server tells the client that a
/// specific subscription has been evicted. `topic` is the wire-form
/// string the client originally subscribed to. `reason` is the stable
/// eviction vocabulary — never repurpose an existing value (matches
/// the AuthZ audit-line convention).
fn revoked_notification_schema () -> Value {
json! ({
"type" : "object" ,
"description" : "JSON-RPC notification (no `id`). `method = \" rt.revoked \" `." ,
"required" : [ "jsonrpc" , "method" , "params" ],
"properties" : {
"jsonrpc" : { "type" : "string" , "const" : "2.0" },
"method" : { "type" : "string" , "const" : "rt.revoked" },
"params" : {
"type" : "object" ,
"required" : [ "topic" , "reason" ],
"properties" : {
"topic" : { "type" : "string" },
"reason" : {
"type" : "string" ,
"enum" : [
"grant_revoked" ,
"resource_deleted" ,
"group_membership_lost" ,
"admin_kick" ,
]
}
}
}
}
})
}