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}