Skip to main content

api_bones/
bulk.rs

1//! Bulk operation envelope types for batch API endpoints.
2//!
3//! [`BulkRequest<T>`] wraps a collection of items for a batch operation.
4//! [`BulkResponse<T>`] holds per-item [`BulkItemResult<T>`] variants so callers
5//! can inspect which items succeeded and which failed without unwrapping a
6//! single top-level error.
7//!
8//! ```rust
9//! use api_bones::bulk::{BulkRequest, BulkResponse, BulkItemResult};
10//! use api_bones::ApiError;
11//!
12//! let request: BulkRequest<i32> = BulkRequest { items: vec![1, 2, 3] };
13//! assert_eq!(request.items.len(), 3);
14//!
15//! let results: Vec<BulkItemResult<String>> = vec![
16//!     BulkItemResult::Success { data: "ok".to_string() },
17//!     BulkItemResult::Failure { index: 1, error: Box::new(ApiError::not_found("item 2 not found")) },
18//! ];
19//! let response: BulkResponse<String> = BulkResponse { results };
20//! assert_eq!(response.succeeded_count(), 1);
21//! assert_eq!(response.failed_count(), 1);
22//! assert!(response.has_failures());
23//! ```
24
25#[cfg(all(not(feature = "std"), feature = "alloc"))]
26use alloc::boxed::Box;
27#[cfg(all(not(feature = "std"), feature = "alloc"))]
28use alloc::vec::Vec;
29#[cfg(feature = "serde")]
30use serde::{Deserialize, Serialize};
31
32use crate::error::ApiError;
33
34// ---------------------------------------------------------------------------
35// BulkRequest
36// ---------------------------------------------------------------------------
37
38/// A batch of items to be processed in a single API call.
39///
40/// # Examples
41///
42/// ```rust
43/// use api_bones::bulk::BulkRequest;
44///
45/// let request: BulkRequest<i32> = BulkRequest { items: vec![1, 2, 3] };
46/// assert_eq!(request.items.len(), 3);
47/// ```
48#[derive(Debug, Clone, PartialEq)]
49#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
50#[cfg_attr(feature = "proptest", derive(proptest_derive::Arbitrary))]
51#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
52#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
53#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
54pub struct BulkRequest<T> {
55    /// The items to be processed.
56    pub items: Vec<T>,
57}
58
59// ---------------------------------------------------------------------------
60// BulkItemResult
61// ---------------------------------------------------------------------------
62
63/// The outcome of processing a single item in a [`BulkRequest`].
64#[derive(Debug, Clone, PartialEq)]
65#[cfg_attr(
66    all(feature = "std", feature = "serde"),
67    derive(Serialize, Deserialize)
68)]
69#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
70#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
71#[cfg_attr(
72    all(feature = "std", feature = "serde"),
73    serde(tag = "status", rename_all = "snake_case")
74)]
75pub enum BulkItemResult<T> {
76    /// The item was processed successfully.
77    Success {
78        /// The resulting data.
79        data: T,
80    },
81    /// The item failed to process.
82    Failure {
83        /// Zero-based index of the item in the original [`BulkRequest::items`] slice.
84        index: usize,
85        /// The error describing why processing failed.
86        error: Box<ApiError>,
87    },
88}
89
90impl<T> BulkItemResult<T> {
91    /// Returns `true` if this result is a [`BulkItemResult::Success`].
92    ///
93    /// # Examples
94    ///
95    /// ```rust
96    /// use api_bones::bulk::BulkItemResult;
97    ///
98    /// let result: BulkItemResult<i32> = BulkItemResult::Success { data: 42 };
99    /// assert!(result.is_success());
100    /// ```
101    #[must_use]
102    pub fn is_success(&self) -> bool {
103        matches!(self, Self::Success { .. })
104    }
105
106    /// Returns `true` if this result is a [`BulkItemResult::Failure`].
107    ///
108    /// # Examples
109    ///
110    /// ```rust
111    /// use api_bones::bulk::BulkItemResult;
112    /// use api_bones::ApiError;
113    ///
114    /// let result: BulkItemResult<i32> = BulkItemResult::Failure {
115    ///     index: 0,
116    ///     error: Box::new(ApiError::not_found("missing")),
117    /// };
118    /// assert!(result.is_failure());
119    /// ```
120    #[must_use]
121    pub fn is_failure(&self) -> bool {
122        matches!(self, Self::Failure { .. })
123    }
124}
125
126// ---------------------------------------------------------------------------
127// BulkResponse
128// ---------------------------------------------------------------------------
129
130/// The response to a [`BulkRequest`], containing per-item results.
131#[derive(Debug, Clone, PartialEq)]
132#[cfg_attr(
133    all(feature = "std", feature = "serde"),
134    derive(Serialize, Deserialize)
135)]
136#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
137#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
138pub struct BulkResponse<T> {
139    /// Per-item outcomes, in the same order as [`BulkRequest::items`].
140    pub results: Vec<BulkItemResult<T>>,
141}
142
143impl<T> BulkResponse<T> {
144    /// Returns the number of successfully processed items.
145    ///
146    /// # Examples
147    ///
148    /// ```rust
149    /// use api_bones::bulk::{BulkResponse, BulkItemResult};
150    ///
151    /// let response: BulkResponse<i32> = BulkResponse {
152    ///     results: vec![
153    ///         BulkItemResult::Success { data: 1 },
154    ///         BulkItemResult::Success { data: 2 },
155    ///     ],
156    /// };
157    /// assert_eq!(response.succeeded_count(), 2);
158    /// ```
159    #[must_use]
160    pub fn succeeded_count(&self) -> usize {
161        self.results.iter().filter(|r| r.is_success()).count()
162    }
163
164    /// Returns the number of items that failed to process.
165    ///
166    /// # Examples
167    ///
168    /// ```rust
169    /// use api_bones::bulk::{BulkResponse, BulkItemResult};
170    /// use api_bones::ApiError;
171    ///
172    /// let response: BulkResponse<i32> = BulkResponse {
173    ///     results: vec![
174    ///         BulkItemResult::Failure { index: 0, error: Box::new(ApiError::not_found("gone")) },
175    ///     ],
176    /// };
177    /// assert_eq!(response.failed_count(), 1);
178    /// ```
179    #[must_use]
180    pub fn failed_count(&self) -> usize {
181        self.results.iter().filter(|r| r.is_failure()).count()
182    }
183
184    /// Returns `true` if at least one item failed.
185    ///
186    /// # Examples
187    ///
188    /// ```rust
189    /// use api_bones::bulk::{BulkResponse, BulkItemResult};
190    /// use api_bones::ApiError;
191    ///
192    /// let response: BulkResponse<i32> = BulkResponse {
193    ///     results: vec![
194    ///         BulkItemResult::Success { data: 1 },
195    ///         BulkItemResult::Failure { index: 1, error: Box::new(ApiError::not_found("nope")) },
196    ///     ],
197    /// };
198    /// assert!(response.has_failures());
199    /// ```
200    #[must_use]
201    pub fn has_failures(&self) -> bool {
202        self.results.iter().any(BulkItemResult::is_failure)
203    }
204}
205
206// ---------------------------------------------------------------------------
207// Tests
208// ---------------------------------------------------------------------------
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213    use crate::error::{ApiError, ErrorCode};
214
215    fn make_error() -> ApiError {
216        ApiError::not_found("item not found")
217    }
218
219    // -----------------------------------------------------------------------
220    // BulkRequest
221    // -----------------------------------------------------------------------
222
223    #[test]
224    fn bulk_request_construction() {
225        let req: BulkRequest<i32> = BulkRequest {
226            items: vec![1, 2, 3],
227        };
228        assert_eq!(req.items, vec![1, 2, 3]);
229    }
230
231    #[test]
232    fn bulk_request_empty() {
233        let req: BulkRequest<String> = BulkRequest { items: vec![] };
234        assert!(req.items.is_empty());
235    }
236
237    // -----------------------------------------------------------------------
238    // BulkItemResult
239    // -----------------------------------------------------------------------
240
241    #[test]
242    fn bulk_item_result_success_is_success() {
243        let r: BulkItemResult<i32> = BulkItemResult::Success { data: 42 };
244        assert!(r.is_success());
245        assert!(!r.is_failure());
246    }
247
248    #[test]
249    fn bulk_item_result_failure_is_failure() {
250        let r: BulkItemResult<i32> = BulkItemResult::Failure {
251            index: 0,
252            error: Box::new(make_error()),
253        };
254        assert!(r.is_failure());
255        assert!(!r.is_success());
256    }
257
258    // -----------------------------------------------------------------------
259    // BulkResponse summary methods
260    // -----------------------------------------------------------------------
261
262    #[test]
263    fn bulk_response_all_success() {
264        let response: BulkResponse<i32> = BulkResponse {
265            results: vec![
266                BulkItemResult::Success { data: 1 },
267                BulkItemResult::Success { data: 2 },
268            ],
269        };
270        assert_eq!(response.succeeded_count(), 2);
271        assert_eq!(response.failed_count(), 0);
272        assert!(!response.has_failures());
273    }
274
275    #[test]
276    fn bulk_response_all_failure() {
277        let response: BulkResponse<i32> = BulkResponse {
278            results: vec![
279                BulkItemResult::Failure {
280                    index: 0,
281                    error: Box::new(make_error()),
282                },
283                BulkItemResult::Failure {
284                    index: 1,
285                    error: Box::new(make_error()),
286                },
287            ],
288        };
289        assert_eq!(response.succeeded_count(), 0);
290        assert_eq!(response.failed_count(), 2);
291        assert!(response.has_failures());
292    }
293
294    #[test]
295    fn bulk_response_mixed() {
296        let response: BulkResponse<String> = BulkResponse {
297            results: vec![
298                BulkItemResult::Success {
299                    data: "ok".to_string(),
300                },
301                BulkItemResult::Failure {
302                    index: 1,
303                    error: Box::new(make_error()),
304                },
305                BulkItemResult::Success {
306                    data: "also ok".to_string(),
307                },
308            ],
309        };
310        assert_eq!(response.succeeded_count(), 2);
311        assert_eq!(response.failed_count(), 1);
312        assert!(response.has_failures());
313    }
314
315    #[test]
316    fn bulk_response_empty() {
317        let response: BulkResponse<i32> = BulkResponse { results: vec![] };
318        assert_eq!(response.succeeded_count(), 0);
319        assert_eq!(response.failed_count(), 0);
320        assert!(!response.has_failures());
321    }
322
323    // -----------------------------------------------------------------------
324    // Serde round-trips
325    // -----------------------------------------------------------------------
326
327    #[cfg(feature = "serde")]
328    #[test]
329    fn bulk_request_serde_round_trip() {
330        let req: BulkRequest<i32> = BulkRequest {
331            items: vec![10, 20, 30],
332        };
333        let json = serde_json::to_value(&req).unwrap();
334        assert_eq!(json["items"], serde_json::json!([10, 20, 30]));
335        let back: BulkRequest<i32> = serde_json::from_value(json).unwrap();
336        assert_eq!(back, req);
337    }
338
339    #[cfg(feature = "serde")]
340    #[test]
341    fn bulk_item_result_success_serde_round_trip() {
342        let r: BulkItemResult<String> = BulkItemResult::Success {
343            data: "hello".to_string(),
344        };
345        let json = serde_json::to_value(&r).unwrap();
346        assert_eq!(json["status"], "success");
347        assert_eq!(json["data"], "hello");
348        let back: BulkItemResult<String> = serde_json::from_value(json).unwrap();
349        assert_eq!(back, r);
350    }
351
352    #[cfg(feature = "serde")]
353    #[test]
354    fn bulk_item_result_failure_serde_round_trip() {
355        let r: BulkItemResult<i32> = BulkItemResult::Failure {
356            index: 3,
357            error: Box::new(make_error()),
358        };
359        let json = serde_json::to_value(&r).unwrap();
360        assert_eq!(json["status"], "failure");
361        assert_eq!(json["index"], 3);
362        let back: BulkItemResult<i32> = serde_json::from_value(json).unwrap();
363        assert_eq!(back, r);
364    }
365
366    #[cfg(feature = "serde")]
367    #[test]
368    fn bulk_response_serde_round_trip_mixed() {
369        let response: BulkResponse<String> = BulkResponse {
370            results: vec![
371                BulkItemResult::Success {
372                    data: "ok".to_string(),
373                },
374                BulkItemResult::Failure {
375                    index: 1,
376                    error: Box::new(make_error()),
377                },
378            ],
379        };
380        let json = serde_json::to_value(&response).unwrap();
381        let back: BulkResponse<String> = serde_json::from_value(json).unwrap();
382        assert_eq!(back, response);
383    }
384
385    // -----------------------------------------------------------------------
386    // ErrorCode composition check
387    // -----------------------------------------------------------------------
388
389    #[test]
390    fn bulk_item_result_failure_uses_api_error() {
391        let error = ApiError::new(ErrorCode::ValidationFailed, "bad input");
392        let r: BulkItemResult<()> = BulkItemResult::Failure {
393            index: 0,
394            error: Box::new(error),
395        };
396        if let BulkItemResult::Failure { error, .. } = &r {
397            assert_eq!(error.code, ErrorCode::ValidationFailed);
398        } else {
399            panic!("expected Failure");
400        }
401    }
402}