Skip to main content

webx_core/
operation.rs

1//! Business result envelope for command-style endpoints.
2//!
3//! [`PagedResult`](crate::pagination::PagedResult) answers "here is one page of
4//! data"; `OperationResult` answers "the operation ran — did it succeed, what
5//! does the business call this outcome, and what should we tell the caller".
6//! Both are ordinary DTOs the framework ships so that a module does not define
7//! its own `{ success, status, message }` shape:
8//!
9//! ```ignore
10//! #[put("/users/{id}")]
11//! impl IRequest<OperationResult<UserDto>> for UpdateUserRequest {}
12//!
13//! async fn handle(&mut self, req: UpdateUserRequest) -> Result<OperationResult<UserDto>> {
14//!     let user = self.update(req).await?;
15//!     Ok(OperationResult::ok(user, "user updated"))
16//! }
17//! ```
18//!
19//! The envelope is business data, not a transport concern: the HTTP status is
20//! still the framework's (200 with the serialized envelope, or `problem+json`
21//! when the handler returns `Err(webx::Error)`). [`status`](OperationResult::status)
22//! is whatever the business assigns — an error code, a workflow state — and the
23//! framework never interprets it. `OperationResult` serializes as
24//! `{"success":true,"status":0,"message":"ok"}`; `OperationResult<T>` adds the
25//! `result` member.
26
27use serde::Serialize;
28
29/// Outcome of an operation: success flag, business status, message, and — for
30/// [`OperationResult<T>`](OperationResult) — the business payload.
31///
32/// `OperationResult` (the default `T = ()`) has no `result` member at all, so
33/// update/delete-style endpoints can report the outcome without declaring a
34/// one-off type. The fields are public; the constructors keep `success` and
35/// `status` agreeing with each other.
36#[derive(Debug, Clone, Serialize)]
37pub struct OperationResult<T = ()> {
38    /// Whether the operation achieved its goal.
39    pub success: bool,
40    /// Business status code, defined by the caller (the constructors use `0`
41    /// when no explicit code is given). It is not an HTTP status: the transport
42    /// layer never reads it.
43    pub status: i32,
44    /// Human-readable outcome for the caller (UI toast, CLI output, logs).
45    pub message: String,
46    /// The business payload; omitted from JSON when absent (the unit form, or
47    /// a [`failed`](Self::failed) result).
48    #[serde(skip_serializing_if = "Option::is_none")]
49    pub result: Option<T>,
50}
51
52impl<T> OperationResult<T> {
53    /// A successful result carrying `result`, with the default business status
54    /// `0`.
55    pub fn ok(result: T, message: impl Into<String>) -> Self {
56        Self::ok_with_status(0, result, message)
57    }
58
59    /// A successful result carrying `result` and an explicit business status.
60    pub fn ok_with_status(status: i32, result: T, message: impl Into<String>) -> Self {
61        Self {
62            success: true,
63            status,
64            message: message.into(),
65            result: Some(result),
66        }
67    }
68
69    /// A failed result with the business status that explains why. No payload is
70    /// attached.
71    pub fn failed(status: i32, message: impl Into<String>) -> Self {
72        Self {
73            success: false,
74            status,
75            message: message.into(),
76            result: None,
77        }
78    }
79}
80
81impl OperationResult<()> {
82    /// A successful result with no payload, and the default business status `0`.
83    ///
84    /// The JSON body is `{"success":true,"status":0,"message":"..."}`.
85    pub fn success(message: impl Into<String>) -> Self {
86        Self::success_with_status(0, message)
87    }
88
89    /// A successful result with no payload and an explicit business status.
90    pub fn success_with_status(status: i32, message: impl Into<String>) -> Self {
91        Self {
92            success: true,
93            status,
94            message: message.into(),
95            result: None,
96        }
97    }
98}
99
100#[cfg(test)]
101mod tests {
102    use super::*;
103
104    #[derive(Serialize)]
105    struct UserDto {
106        id: u32,
107    }
108
109    #[test]
110    fn unit_result_omits_the_result_member() {
111        let value = serde_json::to_value(OperationResult::success("deleted")).unwrap();
112        assert_eq!(
113            value,
114            serde_json::json!({ "success": true, "status": 0, "message": "deleted" })
115        );
116    }
117
118    #[test]
119    fn generic_result_carries_the_payload_and_its_business_status() {
120        let value = serde_json::to_value(OperationResult::ok_with_status(
121            7,
122            UserDto { id: 7 },
123            "created",
124        ))
125        .unwrap();
126        assert_eq!(
127            value,
128            serde_json::json!({
129                "success": true,
130                "status": 7,
131                "message": "created",
132                "result": { "id": 7 }
133            })
134        );
135    }
136
137    #[test]
138    fn failed_result_keeps_the_business_status_and_drops_the_payload() {
139        let value =
140            serde_json::to_value(OperationResult::<UserDto>::failed(1001, "state conflict"))
141                .unwrap();
142        assert_eq!(
143            value,
144            serde_json::json!({
145                "success": false,
146                "status": 1001,
147                "message": "state conflict"
148            })
149        );
150    }
151}