Skip to main content

mqdb_core/
transport.rs

1// Copyright 2025-2026 LabOverWire. All rights reserved.
2// SPDX-License-Identifier: AGPL-3.0-only
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}
64
65impl Request {
66    #[must_use]
67    pub fn operation_label(&self) -> &'static str {
68        match self {
69            Request::Create { .. } => "create",
70            Request::Read { .. } => "read",
71            Request::Update { .. } => "update",
72            Request::Delete { .. } => "delete",
73            Request::List { .. } => "list",
74            Request::Subscribe { .. } => "subscribe",
75            Request::Unsubscribe { .. } => "unsubscribe",
76        }
77    }
78}
79
80#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
81pub enum ErrorCode {
82    BadRequest = 400,
83    Forbidden = 403,
84    NotFound = 404,
85    Conflict = 409,
86    Internal = 500,
87}
88
89impl ErrorCode {
90    #[must_use]
91    pub fn as_u16(self) -> u16 {
92        self as u16
93    }
94
95    #[must_use]
96    pub fn as_grpc_code(self) -> i32 {
97        match self {
98            ErrorCode::BadRequest => 3,
99            ErrorCode::Forbidden => 7,
100            ErrorCode::NotFound => 5,
101            ErrorCode::Conflict => 6,
102            ErrorCode::Internal => 13,
103        }
104    }
105}
106
107#[derive(Debug, Clone, Serialize, Deserialize)]
108pub struct ErrorResponse {
109    pub code: ErrorCode,
110    pub message: String,
111}
112
113#[derive(Debug, Clone, Serialize, Deserialize)]
114#[serde(tag = "status", rename_all = "snake_case")]
115pub enum Response {
116    Ok { data: Value },
117    Error { code: u16, message: String },
118}
119
120impl Response {
121    #[must_use]
122    pub fn ok(data: Value) -> Self {
123        Response::Ok { data }
124    }
125
126    pub fn error(code: ErrorCode, message: impl Into<String>) -> Self {
127        Response::Error {
128            code: code.as_u16(),
129            message: message.into(),
130        }
131    }
132
133    #[must_use]
134    pub fn is_ok(&self) -> bool {
135        matches!(self, Response::Ok { .. })
136    }
137
138    #[must_use]
139    pub fn is_error(&self) -> bool {
140        matches!(self, Response::Error { .. })
141    }
142}
143
144impl From<Error> for Response {
145    fn from(e: Error) -> Self {
146        let (code, message) = match &e {
147            Error::NotFound { entity, .. } => (ErrorCode::NotFound, format!("not found: {entity}")),
148            Error::Validation(msg) => (ErrorCode::BadRequest, format!("validation error: {msg}")),
149            Error::SchemaViolation { field, reason, .. } => (
150                ErrorCode::BadRequest,
151                format!("schema validation failed: {field} - {reason}"),
152            ),
153            Error::Forbidden(_) => (ErrorCode::Forbidden, "forbidden".to_string()),
154            Error::ConstraintViolation(msg) => {
155                (ErrorCode::Conflict, format!("constraint violation: {msg}"))
156            }
157            Error::UniqueViolation { entity, field, .. } => (
158                ErrorCode::Conflict,
159                format!("unique constraint violation: {entity}.{field}"),
160            ),
161            Error::ForeignKeyViolation { entity, field, .. } => (
162                ErrorCode::Conflict,
163                format!("foreign key violation: {entity}.{field}"),
164            ),
165            Error::ForeignKeyRestrict { entity, .. } => (
166                ErrorCode::Conflict,
167                format!("cannot delete {entity}: referenced by other entities"),
168            ),
169            Error::NotNullViolation { entity, field } => (
170                ErrorCode::Conflict,
171                format!("not null violation: {entity}.{field}"),
172            ),
173            Error::CascadeBlocked(info) => (
174                ErrorCode::Conflict,
175                format!(
176                    "cascade blocked: {}/{} owned by '{}' has non-nullable FK field '{}'",
177                    info.blocked_entity, info.blocked_id, info.blocked_owner, info.blocked_field
178                ),
179            ),
180            Error::Conflict(msg) => (ErrorCode::Conflict, format!("conflict: {msg}")),
181            _ => {
182                tracing::error!(error = %e, "internal error in client request");
183                (ErrorCode::Internal, "internal error".to_string())
184            }
185        };
186        Response::error(code, message)
187    }
188}
189
190#[cfg(test)]
191mod tests {
192    use super::*;
193
194    #[test]
195    fn test_request_serialization() {
196        let request = Request::Create {
197            entity: "users".to_string(),
198            data: serde_json::json!({"name": "Alice"}),
199        };
200        let json = serde_json::to_string(&request).unwrap();
201        assert!(json.contains("\"op\":\"create\""));
202        assert!(json.contains("\"entity\":\"users\""));
203    }
204
205    #[test]
206    fn test_request_deserialization() {
207        let json = r#"{"op": "read", "entity": "users", "id": "123"}"#;
208        let request: Request = serde_json::from_str(json).unwrap();
209        match request {
210            Request::Read { entity, id, .. } => {
211                assert_eq!(entity, "users");
212                assert_eq!(id, "123");
213            }
214            _ => panic!("expected Read request"),
215        }
216    }
217
218    #[test]
219    fn test_response_ok() {
220        let response = Response::ok(serde_json::json!({"id": "1", "name": "Alice"}));
221        assert!(response.is_ok());
222        let json = serde_json::to_string(&response).unwrap();
223        assert!(json.contains("\"status\":\"ok\""));
224    }
225
226    #[test]
227    fn test_response_error() {
228        let response = Response::error(ErrorCode::NotFound, "User not found");
229        assert!(response.is_error());
230        let json = serde_json::to_string(&response).unwrap();
231        assert!(json.contains("\"status\":\"error\""));
232        assert!(json.contains("\"code\":404"));
233    }
234
235    #[test]
236    fn test_error_code_mapping() {
237        assert_eq!(ErrorCode::NotFound.as_u16(), 404);
238        assert_eq!(ErrorCode::BadRequest.as_u16(), 400);
239        assert_eq!(ErrorCode::Conflict.as_u16(), 409);
240        assert_eq!(ErrorCode::Internal.as_u16(), 500);
241
242        assert_eq!(ErrorCode::NotFound.as_grpc_code(), 5);
243        assert_eq!(ErrorCode::BadRequest.as_grpc_code(), 3);
244    }
245
246    #[test]
247    fn test_error_conversion() {
248        let error = Error::NotFound {
249            entity: "users".to_string(),
250            id: "123".to_string(),
251        };
252        let response: Response = error.into();
253        match response {
254            Response::Error { code, message } => {
255                assert_eq!(code, 404);
256                assert!(message.contains("not found"));
257            }
258            Response::Ok { .. } => panic!("expected error response"),
259        }
260    }
261}