Skip to main content

aimdb_core/remote/
protocol.rs

1//! AimX Protocol Message Types
2//!
3//! Defines the method-level payloads for the remote access protocol: the
4//! hello/welcome handshake bodies and the request/response/event shapes.
5//! These ride the **AimX** NDJSON envelope; the wire framing itself lives
6//! in [`crate::session::aimx`] (feature `connector-session`). See
7//! `docs/design/remote-access-via-connectors.md` for the architecture.
8
9use alloc::string::String;
10use alloc::vec::Vec;
11use serde::{Deserialize, Serialize};
12use serde_json::Value as JsonValue;
13
14/// Version of the AimX wire protocol spoken by this crate.
15pub const PROTOCOL_VERSION: &str = "3.0";
16
17/// Query-parameter name a WebSocket client uses to declare its
18/// [`PROTOCOL_VERSION`] at the HTTP upgrade. The socket transports gate the
19/// version inside the `hello` handshake, but a browser cannot set custom
20/// headers on the WebSocket handshake **and** the WS server runs
21/// `reads_hello:false`, so the WS upgrade carries the version in the URL
22/// instead (`…/ws?v=3.0`) and the server refuses an incompatible/absent one
23/// before the socket opens. See [`ws_url_with_version`].
24pub const VERSION_PARAM: &str = "v";
25
26/// Append this crate's [`PROTOCOL_VERSION`] as the [`VERSION_PARAM`] query
27/// parameter to a WebSocket upgrade `url`, joining with `&` if `url` already
28/// carries a query string and `?` otherwise. Every first-party WS client
29/// dialer routes its URL through this so the server's upgrade-time gate sees a
30/// version; keeping the `NAME=VALUE` contract here means the reader
31/// (the server) and the writers (the dialers) never drift.
32pub fn ws_url_with_version(url: &str) -> String {
33    let sep = if url.contains('?') { '&' } else { '?' };
34    alloc::format!("{url}{sep}{VERSION_PARAM}={PROTOCOL_VERSION}")
35}
36
37/// Major-version component of a `"MAJOR.MINOR"` protocol string, or `None` if it
38/// is empty or not in that shape.
39fn protocol_major(version: &str) -> Option<&str> {
40    match version.split('.').next() {
41        Some(major) if !major.is_empty() => Some(major),
42        _ => None,
43    }
44}
45
46/// Whether a peer advertising `their_version` is wire-compatible with this
47/// crate's [`PROTOCOL_VERSION`].
48///
49/// Compatibility is by **major** version — a breaking wire change bumps the
50/// major (2.x → 3.0), a compatible additive change bumps only the minor. A
51/// missing or malformed version string is treated as **incompatible** (fail
52/// closed): a peer that cannot state a version it speaks is exactly the legacy
53/// client this gate exists to turn away.
54pub fn version_compatible(their_version: &str) -> bool {
55    match (
56        protocol_major(their_version),
57        protocol_major(PROTOCOL_VERSION),
58    ) {
59        (Some(theirs), Some(ours)) => theirs == ours,
60        _ => false,
61    }
62}
63
64/// Client hello message
65#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct HelloMessage {
67    /// Protocol version
68    pub version: String,
69
70    /// Client identification string
71    pub client: String,
72
73    /// Desired capabilities (optional)
74    #[serde(skip_serializing_if = "Option::is_none")]
75    pub capabilities: Option<Vec<String>>,
76
77    /// Authentication token (optional)
78    #[serde(skip_serializing_if = "Option::is_none")]
79    pub auth_token: Option<String>,
80}
81
82/// Server welcome message
83#[derive(Debug, Clone, Serialize, Deserialize)]
84pub struct WelcomeMessage {
85    /// Protocol version
86    pub version: String,
87
88    /// Server identification string
89    pub server: String,
90
91    /// Granted permissions
92    pub permissions: Vec<String>,
93
94    /// Records that allow writes (empty for read-only)
95    pub writable_records: Vec<String>,
96
97    /// Maximum subscriptions per connection (optional)
98    #[serde(skip_serializing_if = "Option::is_none")]
99    pub max_subscriptions: Option<usize>,
100
101    /// Whether client is authenticated
102    #[serde(skip_serializing_if = "Option::is_none")]
103    pub authenticated: Option<bool>,
104}
105
106/// Request message from client
107#[derive(Debug, Clone, Serialize, Deserialize)]
108pub struct Request {
109    /// Unique request identifier
110    pub id: u64,
111
112    /// Method name
113    pub method: String,
114
115    /// Method parameters (optional)
116    #[serde(skip_serializing_if = "Option::is_none")]
117    pub params: Option<JsonValue>,
118}
119
120/// Response message from server
121#[derive(Debug, Clone, Serialize, Deserialize)]
122#[serde(untagged)]
123pub enum Response {
124    /// Success response
125    Success {
126        /// Request ID
127        id: u64,
128        /// Result value
129        result: JsonValue,
130    },
131    /// Error response
132    Error {
133        /// Request ID
134        id: u64,
135        /// Error details
136        error: ErrorObject,
137    },
138}
139
140impl Response {
141    /// Creates a success response
142    pub fn success(id: u64, result: JsonValue) -> Self {
143        Self::Success { id, result }
144    }
145
146    /// Creates an error response
147    pub fn error(id: u64, code: impl Into<String>, message: impl Into<String>) -> Self {
148        Self::Error {
149            id,
150            error: ErrorObject {
151                code: code.into(),
152                message: message.into(),
153                details: None,
154            },
155        }
156    }
157
158    /// Creates an error response with details
159    pub fn error_with_details(
160        id: u64,
161        code: impl Into<String>,
162        message: impl Into<String>,
163        details: JsonValue,
164    ) -> Self {
165        Self::Error {
166            id,
167            error: ErrorObject {
168                code: code.into(),
169                message: message.into(),
170                details: Some(details),
171            },
172        }
173    }
174}
175
176/// Error object in response
177#[derive(Debug, Clone, Serialize, Deserialize)]
178pub struct ErrorObject {
179    /// Error code
180    pub code: String,
181
182    /// Human-readable error message
183    pub message: String,
184
185    /// Additional error details (optional)
186    #[serde(skip_serializing_if = "Option::is_none")]
187    pub details: Option<JsonValue>,
188}
189
190/// Event message from server (subscription push)
191#[derive(Debug, Clone, Serialize, Deserialize)]
192pub struct Event {
193    /// Subscription identifier
194    pub subscription_id: String,
195
196    /// Monotonic sequence number
197    pub sequence: u64,
198
199    /// Event data (record value)
200    pub data: JsonValue,
201
202    /// Unix timestamp in "secs.nanosecs" format (e.g., "1730379296.123456789")
203    pub timestamp: String,
204
205    /// Number of dropped events since last delivery (optional)
206    #[serde(skip_serializing_if = "Option::is_none")]
207    pub dropped: Option<u64>,
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213    use alloc::string::ToString;
214    use alloc::vec;
215
216    #[test]
217    fn test_hello_serialization() {
218        let hello = HelloMessage {
219            version: PROTOCOL_VERSION.to_string(),
220            client: "test-client".to_string(),
221            capabilities: Some(vec!["read".to_string()]),
222            auth_token: None,
223        };
224
225        let json = serde_json::to_string(&hello).unwrap();
226        assert!(json.contains("\"version\":\"3.0\""));
227        assert!(json.contains("\"client\":\"test-client\""));
228    }
229
230    #[test]
231    fn version_compatible_matches_on_major() {
232        // Same major (current + a hypothetical future minor) → compatible.
233        assert!(version_compatible(PROTOCOL_VERSION));
234        assert!(version_compatible("3.0"));
235        assert!(version_compatible("3.7"));
236        assert!(version_compatible("3")); // bare major
237
238        // Older major (the legacy WS-era wire) → refused.
239        assert!(!version_compatible("2.0"));
240        assert!(!version_compatible("1.0"));
241
242        // Missing / malformed → fail closed.
243        assert!(!version_compatible(""));
244        assert!(!version_compatible(".0"));
245        assert!(!version_compatible("garbage"));
246    }
247
248    #[test]
249    fn test_request_serialization() {
250        let request = Request {
251            id: 1,
252            method: "record.list".to_string(),
253            params: Some(serde_json::json!({})),
254        };
255
256        let json = serde_json::to_string(&request).unwrap();
257        assert!(json.contains("\"id\":1"));
258        assert!(json.contains("\"method\":\"record.list\""));
259    }
260
261    #[test]
262    fn test_response_success() {
263        let response = Response::success(1, serde_json::json!({"status": "ok"}));
264
265        let json = serde_json::to_string(&response).unwrap();
266        assert!(json.contains("\"id\":1"));
267        assert!(json.contains("\"result\""));
268        assert!(json.contains("\"status\":\"ok\""));
269    }
270
271    #[test]
272    fn test_response_error() {
273        let response = Response::error(2, "NOT_FOUND", "Record not found");
274
275        let json = serde_json::to_string(&response).unwrap();
276        assert!(json.contains("\"id\":2"));
277        assert!(json.contains("\"error\""));
278        assert!(json.contains("\"code\":\"NOT_FOUND\""));
279        assert!(json.contains("\"message\":\"Record not found\""));
280    }
281
282    #[test]
283    fn test_event_serialization() {
284        let event = Event {
285            subscription_id: "sub-123".to_string(),
286            sequence: 42,
287            data: serde_json::json!({"temp": 23.5}),
288            timestamp: "1730379296.123456789".to_string(),
289            dropped: None,
290        };
291
292        let json = serde_json::to_string(&event).unwrap();
293        assert!(json.contains("\"subscription_id\":\"sub-123\""));
294        assert!(json.contains("\"sequence\":42"));
295        assert!(json.contains("\"temp\":23.5"));
296    }
297
298    #[test]
299    fn test_event_with_dropped() {
300        let event = Event {
301            subscription_id: "sub-456".to_string(),
302            sequence: 100,
303            data: serde_json::json!({"value": 1}),
304            timestamp: "1730379300.987654321".to_string(),
305            dropped: Some(5),
306        };
307
308        let json = serde_json::to_string(&event).unwrap();
309        assert!(json.contains("\"dropped\":5"));
310    }
311}