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) -> ActionResult<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//!
27//! The outcome is often decided while the operation runs, so the envelope can be
28//! adjusted after it is created — fields are public and [`with_status`] /
29//! [`with_message`] chain:
30//!
31//! ```ignore
32//! let mut outcome = OperationResult::success("published");
33//! if already_published {
34//!     outcome = outcome.with_status(1001).with_message("already published");
35//! }
36//! Ok(outcome)
37//! ```
38//!
39//! [`with_status`]: OperationResult::with_status
40//! [`with_message`]: OperationResult::with_message
41
42use serde::Serialize;
43
44/// Outcome of an operation: success flag, business status, message, and — for
45/// [`OperationResult<T>`](OperationResult) — the business payload.
46///
47/// `OperationResult` (the default `T = ()`) has no `result` member at all, so
48/// update/delete-style endpoints can report the outcome without declaring a
49/// one-off type. The fields are public; the constructors keep `success` and
50/// `status` agreeing with each other.
51#[derive(Debug, Clone, Serialize)]
52pub struct OperationResult<T = ()> {
53    /// Whether the operation achieved its goal.
54    pub success: bool,
55    /// Business status code, defined by the caller (the constructors use `0`
56    /// when no explicit code is given). It is not an HTTP status: the transport
57    /// layer never reads it.
58    pub status: i32,
59    /// Human-readable outcome for the caller (UI toast, CLI output, logs).
60    pub message: String,
61    /// The business payload; omitted from JSON when absent (the unit form, or
62    /// a [`failed`](Self::failed) result).
63    #[serde(skip_serializing_if = "Option::is_none")]
64    pub result: Option<T>,
65}
66
67impl<T> OperationResult<T> {
68    /// A successful result carrying `result`, with the default business status
69    /// `0`.
70    pub fn ok(result: T, message: impl Into<String>) -> Self {
71        Self::ok_with_status(0, result, message)
72    }
73
74    /// A successful result carrying `result` and an explicit business status.
75    pub fn ok_with_status(status: i32, result: T, message: impl Into<String>) -> Self {
76        Self {
77            success: true,
78            status,
79            message: message.into(),
80            result: Some(result),
81        }
82    }
83
84    /// A failed result with the business status that explains why. No payload is
85    /// attached.
86    pub fn failed(status: i32, message: impl Into<String>) -> Self {
87        Self {
88            success: false,
89            status,
90            message: message.into(),
91            result: None,
92        }
93    }
94
95    /// Replace the business status, keeping `success` and the message.
96    ///
97    /// Use it when the outcome is only known part-way through the operation
98    /// (`result = result.with_status(1001)`); the fields are public as well, so
99    /// `result.status = 1001` works too.
100    pub fn with_status(mut self, status: i32) -> Self {
101        self.status = status;
102        self
103    }
104
105    /// Replace the message, keeping `success` and the business status.
106    pub fn with_message(mut self, message: impl Into<String>) -> Self {
107        self.message = message.into();
108        self
109    }
110}
111
112impl OperationResult<()> {
113    /// A successful result with no payload, and the default business status `0`.
114    ///
115    /// The JSON body is `{"success":true,"status":0,"message":"..."}`.
116    pub fn success(message: impl Into<String>) -> Self {
117        Self::success_with_status(0, message)
118    }
119
120    /// A successful result with no payload and an explicit business status.
121    pub fn success_with_status(status: i32, message: impl Into<String>) -> Self {
122        Self {
123            success: true,
124            status,
125            message: message.into(),
126            result: None,
127        }
128    }
129
130    /// Attach the business payload once it is known, turning the unit envelope
131    /// into [`OperationResult<T>`](OperationResult); success, status and message
132    /// are kept.
133    pub fn with_result<T>(self, result: T) -> OperationResult<T> {
134        OperationResult {
135            success: self.success,
136            status: self.status,
137            message: self.message,
138            result: Some(result),
139        }
140    }
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146
147    #[derive(Serialize)]
148    struct UserDto {
149        id: u32,
150    }
151
152    #[test]
153    fn unit_result_omits_the_result_member() {
154        let value = serde_json::to_value(OperationResult::success("deleted")).unwrap();
155        assert_eq!(
156            value,
157            serde_json::json!({ "success": true, "status": 0, "message": "deleted" })
158        );
159    }
160
161    #[test]
162    fn generic_result_carries_the_payload_and_its_business_status() {
163        let value = serde_json::to_value(OperationResult::ok_with_status(
164            7,
165            UserDto { id: 7 },
166            "created",
167        ))
168        .unwrap();
169        assert_eq!(
170            value,
171            serde_json::json!({
172                "success": true,
173                "status": 7,
174                "message": "created",
175                "result": { "id": 7 }
176            })
177        );
178    }
179
180    #[test]
181    fn failed_result_keeps_the_business_status_and_drops_the_payload() {
182        let value =
183            serde_json::to_value(OperationResult::<UserDto>::failed(1001, "state conflict"))
184                .unwrap();
185        assert_eq!(
186            value,
187            serde_json::json!({
188                "success": false,
189                "status": 1001,
190                "message": "state conflict"
191            })
192        );
193    }
194
195    #[test]
196    fn setters_adjust_an_outcome_decided_while_the_operation_runs() {
197        let mut outcome = OperationResult::success("published");
198        if true {
199            outcome = outcome.with_status(1001).with_message("already published");
200        }
201        // The fields stay public for in-place adjustment too.
202        outcome.status = 1001;
203
204        let value = serde_json::to_value(outcome).unwrap();
205        assert_eq!(
206            value,
207            serde_json::json!({
208                "success": true,
209                "status": 1001,
210                "message": "already published"
211            })
212        );
213    }
214
215    #[test]
216    fn with_result_attaches_the_payload_to_a_unit_envelope() {
217        let value = serde_json::to_value(
218            OperationResult::success("created").with_result(UserDto { id: 9 }),
219        )
220        .unwrap();
221        assert_eq!(
222            value,
223            serde_json::json!({
224                "success": true,
225                "status": 0,
226                "message": "created",
227                "result": { "id": 9 }
228            })
229        );
230    }
231}