Skip to main content

mqdb_core/
transport.rs

1// Copyright 2025-2026 LabOverWire. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4use crate::subscription::SubscriptionMode;
5use crate::{Error, Filter, Pagination, SortOrder};
6use serde::{Deserialize, Serialize};
7use serde_json::Value;
8
9pub enum VaultConstraintData {
10    Create(Value),
11    Update(Value, Value),
12}
13
14#[derive(Debug, Clone, Serialize, Deserialize)]
15#[serde(tag = "op", rename_all = "snake_case")]
16pub enum Request {
17    Create {
18        entity: String,
19        data: Value,
20    },
21    Read {
22        entity: String,
23        id: String,
24        #[serde(default)]
25        includes: Vec<String>,
26        #[serde(default)]
27        projection: Option<Vec<String>>,
28    },
29    Update {
30        entity: String,
31        id: String,
32        fields: Value,
33    },
34    Delete {
35        entity: String,
36        id: String,
37    },
38    List {
39        entity: String,
40        #[serde(default)]
41        filters: Vec<Filter>,
42        #[serde(default)]
43        sort: Vec<SortOrder>,
44        #[serde(default)]
45        pagination: Option<Pagination>,
46        #[serde(default)]
47        includes: Vec<String>,
48        #[serde(default)]
49        projection: Option<Vec<String>>,
50    },
51    Subscribe {
52        pattern: String,
53        #[serde(default)]
54        entity: Option<String>,
55        #[serde(default)]
56        share_group: Option<String>,
57        #[serde(default)]
58        mode: Option<SubscriptionMode>,
59    },
60    Unsubscribe {
61        id: String,
62    },
63    Share {
64        entity: String,
65        id: String,
66        grantee: String,
67        permission: String,
68        #[serde(default = "default_cascade")]
69        cascade: bool,
70    },
71    Unshare {
72        entity: String,
73        id: String,
74        grantee: String,
75        #[serde(default = "default_cascade")]
76        cascade: bool,
77    },
78    Shares {
79        entity: String,
80        id: String,
81    },
82    Shared {
83        entity: String,
84    },
85}
86
87fn default_cascade() -> bool {
88    true
89}
90
91impl Request {
92    #[must_use]
93    pub fn operation_label(&self) -> &'static str {
94        match self {
95            Request::Create { .. } => "create",
96            Request::Read { .. } => "read",
97            Request::Update { .. } => "update",
98            Request::Delete { .. } => "delete",
99            Request::List { .. } => "list",
100            Request::Subscribe { .. } => "subscribe",
101            Request::Unsubscribe { .. } => "unsubscribe",
102            Request::Share { .. } => "share",
103            Request::Unshare { .. } => "unshare",
104            Request::Shares { .. } => "shares",
105            Request::Shared { .. } => "shared",
106        }
107    }
108}
109
110#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
111pub enum ErrorCode {
112    BadRequest = 400,
113    Unauthorized = 401,
114    Forbidden = 403,
115    NotFound = 404,
116    Conflict = 409,
117    RateLimited = 429,
118    Internal = 500,
119}
120
121impl ErrorCode {
122    #[must_use]
123    pub fn as_u16(self) -> u16 {
124        self as u16
125    }
126
127    #[must_use]
128    pub fn as_grpc_code(self) -> i32 {
129        match self {
130            ErrorCode::BadRequest => 3,
131            ErrorCode::Unauthorized => 16,
132            ErrorCode::Forbidden => 7,
133            ErrorCode::NotFound => 5,
134            ErrorCode::Conflict => 6,
135            ErrorCode::RateLimited => 8,
136            ErrorCode::Internal => 13,
137        }
138    }
139}
140
141#[derive(Debug, Clone, Serialize, Deserialize)]
142pub struct ErrorResponse {
143    pub code: ErrorCode,
144    pub message: String,
145}
146
147#[derive(Debug, Clone, Serialize, Deserialize)]
148#[serde(tag = "status", rename_all = "snake_case")]
149pub enum Response {
150    Ok { data: Value },
151    Error { code: u16, message: String },
152}
153
154impl Response {
155    #[must_use]
156    pub fn ok(data: Value) -> Self {
157        Response::Ok { data }
158    }
159
160    pub fn error(code: ErrorCode, message: impl Into<String>) -> Self {
161        Response::Error {
162            code: code.as_u16(),
163            message: message.into(),
164        }
165    }
166
167    #[must_use]
168    pub fn is_ok(&self) -> bool {
169        matches!(self, Response::Ok { .. })
170    }
171
172    #[must_use]
173    pub fn is_error(&self) -> bool {
174        matches!(self, Response::Error { .. })
175    }
176}
177
178impl From<Error> for Response {
179    fn from(e: Error) -> Self {
180        let (code, message) = match &e {
181            Error::NotFound { entity, .. } => (ErrorCode::NotFound, format!("not found: {entity}")),
182            Error::Validation(msg) => (ErrorCode::BadRequest, format!("validation error: {msg}")),
183            Error::SchemaViolation { field, reason, .. } => (
184                ErrorCode::BadRequest,
185                format!("schema validation failed: {field} - {reason}"),
186            ),
187            Error::Forbidden(_) => (ErrorCode::Forbidden, "forbidden".to_string()),
188            Error::ConstraintViolation(msg) => {
189                (ErrorCode::Conflict, format!("constraint violation: {msg}"))
190            }
191            Error::UniqueViolation { entity, field, .. } => (
192                ErrorCode::Conflict,
193                format!("unique constraint violation: {entity}.{field}"),
194            ),
195            Error::ForeignKeyViolation { entity, field, .. } => (
196                ErrorCode::Conflict,
197                format!("foreign key violation: {entity}.{field}"),
198            ),
199            Error::ForeignKeyRestrict { entity, .. } => (
200                ErrorCode::Conflict,
201                format!("cannot delete {entity}: referenced by other entities"),
202            ),
203            Error::NotNullViolation { entity, field } => (
204                ErrorCode::Conflict,
205                format!("not null violation: {entity}.{field}"),
206            ),
207            Error::CascadeBlocked(info) => (
208                ErrorCode::Conflict,
209                format!(
210                    "cascade blocked: {}/{} owned by '{}' has non-nullable FK field '{}'",
211                    info.blocked_entity, info.blocked_id, info.blocked_owner, info.blocked_field
212                ),
213            ),
214            Error::Conflict(msg) => (ErrorCode::Conflict, format!("conflict: {msg}")),
215            _ => {
216                tracing::error!(error = %e, "internal error in client request");
217                (ErrorCode::Internal, "internal error".to_string())
218            }
219        };
220        Response::error(code, message)
221    }
222}
223
224#[cfg(test)]
225mod tests {
226    use super::*;
227
228    #[test]
229    fn test_request_serialization() {
230        let request = Request::Create {
231            entity: "users".to_string(),
232            data: serde_json::json!({"name": "Alice"}),
233        };
234        let json = serde_json::to_string(&request).unwrap();
235        assert!(json.contains("\"op\":\"create\""));
236        assert!(json.contains("\"entity\":\"users\""));
237    }
238
239    #[test]
240    fn test_request_deserialization() {
241        let json = r#"{"op": "read", "entity": "users", "id": "123"}"#;
242        let request: Request = serde_json::from_str(json).unwrap();
243        match request {
244            Request::Read { entity, id, .. } => {
245                assert_eq!(entity, "users");
246                assert_eq!(id, "123");
247            }
248            _ => panic!("expected Read request"),
249        }
250    }
251
252    #[test]
253    fn test_response_ok() {
254        let response = Response::ok(serde_json::json!({"id": "1", "name": "Alice"}));
255        assert!(response.is_ok());
256        let json = serde_json::to_string(&response).unwrap();
257        assert!(json.contains("\"status\":\"ok\""));
258    }
259
260    #[test]
261    fn test_response_error() {
262        let response = Response::error(ErrorCode::NotFound, "User not found");
263        assert!(response.is_error());
264        let json = serde_json::to_string(&response).unwrap();
265        assert!(json.contains("\"status\":\"error\""));
266        assert!(json.contains("\"code\":404"));
267    }
268
269    #[test]
270    fn test_error_code_mapping() {
271        assert_eq!(ErrorCode::NotFound.as_u16(), 404);
272        assert_eq!(ErrorCode::BadRequest.as_u16(), 400);
273        assert_eq!(ErrorCode::Conflict.as_u16(), 409);
274        assert_eq!(ErrorCode::Internal.as_u16(), 500);
275
276        assert_eq!(ErrorCode::NotFound.as_grpc_code(), 5);
277        assert_eq!(ErrorCode::BadRequest.as_grpc_code(), 3);
278    }
279
280    #[test]
281    fn test_error_conversion() {
282        let error = Error::NotFound {
283            entity: "users".to_string(),
284            id: "123".to_string(),
285        };
286        let response: Response = error.into();
287        match response {
288            Response::Error { code, message } => {
289                assert_eq!(code, 404);
290                assert!(message.contains("not found"));
291            }
292            Response::Ok { .. } => panic!("expected error response"),
293        }
294    }
295}