Skip to main content

objectstore_server/extractors/
batch.rs

1//! Axum extractor for batch operation streams.
2//!
3//! Provides [`BatchOperationStream`], which parses a multipart request body into a
4//! lazy stream of [`Operation`]s.
5//!
6//! All insert metadata uses the shared request timestamp, even for fields parsed
7//! later while streaming the body.
8
9use std::fmt::Debug;
10
11use axum::RequestExt;
12use axum::extract::{
13    FromRequest, Multipart, Request,
14    multipart::{Field, MultipartError, MultipartRejection},
15};
16use bytes::BytesMut;
17use futures::{StreamExt, stream::BoxStream};
18use objectstore_service::streaming::{Delete, Get, Head, Insert, Operation};
19use objectstore_types::headers;
20use objectstore_types::metadata::Metadata;
21use objectstore_types::time::Timestamp;
22use thiserror::Error;
23
24use crate::batch::{HEADER_BATCH_OPERATION_KEY, HEADER_BATCH_OPERATION_KIND};
25use crate::extractors::request_time::RequestTime;
26
27/// Errors that can occur when processing or executing batch operations.
28#[derive(Debug, Error)]
29pub enum BatchError {
30    /// Malformed request.
31    #[error("bad request: {0}")]
32    BadRequest(String),
33
34    /// Errors in parsing or reading a multipart request body.
35    #[error("multipart error: {0}")]
36    Multipart(#[from] MultipartError),
37
38    /// Errors related to de/serialization and parsing of object metadata.
39    #[error("metadata error: {0}")]
40    Metadata(#[from] objectstore_types::metadata::Error),
41
42    /// Size or cardinality limit exceeded.
43    #[error("batch limit exceeded: {0}")]
44    LimitExceeded(String),
45
46    /// Operation rejected due to rate limiting.
47    #[error("rate limited")]
48    RateLimited,
49
50    /// Errors encountered when serializing batch response parts.
51    #[error("response part serialization error: {context}")]
52    ResponseSerialization {
53        /// Context describing what was being serialized.
54        context: String,
55        /// The underlying error.
56        #[source]
57        cause: Box<dyn std::error::Error + Send + Sync>,
58    },
59}
60
61async fn try_operation_from_field(
62    mut field: Field<'_>,
63    access_time: Timestamp,
64) -> Result<Operation, BatchError> {
65    let kind = field
66        .headers()
67        .get(HEADER_BATCH_OPERATION_KIND)
68        .ok_or_else(|| {
69            BatchError::BadRequest(format!("missing {HEADER_BATCH_OPERATION_KIND} header"))
70        })?;
71    let kind = kind
72        .to_str()
73        .map_err(|_| {
74            BatchError::BadRequest(format!(
75                "unable to convert {HEADER_BATCH_OPERATION_KIND} header value to string"
76            ))
77        })?
78        .to_lowercase();
79
80    let key = field
81        .headers()
82        .get(HEADER_BATCH_OPERATION_KEY)
83        .map(|v| {
84            headers::decode_header_value(v).map_err(|_| {
85                BatchError::BadRequest(format!(
86                    "unable to percent-decode {HEADER_BATCH_OPERATION_KEY} header value"
87                ))
88            })
89        })
90        .transpose()?;
91
92    let operation = match kind.as_str() {
93        "get" => Operation::Get(Get {
94            key: key.ok_or_else(|| {
95                BatchError::BadRequest(format!(
96                    "missing {HEADER_BATCH_OPERATION_KEY} header for {kind} operation"
97                ))
98            })?,
99        }),
100        "delete" => Operation::Delete(Delete {
101            key: key.ok_or_else(|| {
102                BatchError::BadRequest(format!(
103                    "missing {HEADER_BATCH_OPERATION_KEY} header for {kind} operation"
104                ))
105            })?,
106        }),
107        "head" => Operation::Head(Head {
108            key: key.ok_or_else(|| {
109                BatchError::BadRequest(format!(
110                    "missing {HEADER_BATCH_OPERATION_KEY} header for {kind} operation"
111                ))
112            })?,
113        }),
114        "insert" => {
115            let metadata = Metadata::from_insert_headers(field.headers(), "", access_time)?;
116            let mut payload = BytesMut::new();
117            while let Some(chunk) = field.chunk().await? {
118                if payload.len() + chunk.len() > MAX_FIELD_SIZE {
119                    return Err(BatchError::LimitExceeded(format!(
120                        "individual request in batch exceeds body size limit of {MAX_FIELD_SIZE} bytes"
121                    )));
122                }
123                payload.extend_from_slice(&chunk);
124            }
125            Operation::Insert(Box::new(Insert {
126                key,
127                metadata,
128                payload: payload.freeze(),
129            }))
130        }
131        _ => {
132            return Err(BatchError::BadRequest(format!(
133                "invalid operation kind: {kind}"
134            )));
135        }
136    };
137    Ok(operation)
138}
139
140/// A lazily-parsed stream of batch operations extracted from a multipart request body.
141pub struct BatchOperationStream(pub BoxStream<'static, Result<Operation, BatchError>>);
142
143impl Debug for BatchOperationStream {
144    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
145        f.debug_struct("BatchOperationStream").finish()
146    }
147}
148
149const MAX_FIELD_SIZE: usize = 1024 * 1024; // 1 MB
150const MAX_OPERATIONS: usize = 1000;
151
152impl<S> FromRequest<S> for BatchOperationStream
153where
154    S: Send + Sync,
155{
156    type Rejection = MultipartRejection;
157
158    async fn from_request(mut request: Request, state: &S) -> Result<Self, Self::Rejection> {
159        let Ok(RequestTime(access_time)) = request.extract_parts::<RequestTime>().await;
160        let mut multipart = Multipart::from_request(request, state).await?;
161
162        let requests = async_stream::stream! {
163            let mut count = 0;
164            loop {
165                let field = match multipart.next_field().await {
166                    Ok(Some(field)) => field,
167                    Ok(None) => break,
168                    Err(e) => {
169                        yield Err(BatchError::from(e));
170                        continue;
171                    }
172                };
173                if count >= MAX_OPERATIONS {
174                    yield Err(BatchError::LimitExceeded(format!(
175                        "exceeded {MAX_OPERATIONS} operations per batch request"
176                    )));
177                    continue;
178                }
179                count += 1;
180                yield try_operation_from_field(field, access_time).await;
181            }
182        }
183        .boxed();
184
185        Ok(Self(requests))
186    }
187}
188
189#[cfg(test)]
190mod tests {
191    use std::time::Duration;
192
193    use super::*;
194    use axum::body::Body;
195    use axum::http::{Request, header::CONTENT_TYPE};
196    use futures::StreamExt;
197    use objectstore_service::streaming::Operation;
198    use objectstore_types::headers;
199    use objectstore_types::metadata::{ExpirationPolicy, HEADER_EXPIRATION, HEADER_ORIGIN};
200
201    #[tokio::test]
202    async fn test_valid_request_works() {
203        let insert1_data = b"first blob data";
204        let insert2_data = b"second blob data";
205        let expiration = ExpirationPolicy::TimeToLive(Duration::from_hours(1));
206        let body = format!(
207            "--boundary\r\n\
208             {HEADER_BATCH_OPERATION_KEY}: {key0}\r\n\
209             {HEADER_BATCH_OPERATION_KIND}: get\r\n\
210             \r\n\
211             \r\n\
212             --boundary\r\n\
213             {HEADER_BATCH_OPERATION_KEY}: {key1}\r\n\
214             {HEADER_BATCH_OPERATION_KIND}: insert\r\n\
215             Content-Type: application/octet-stream\r\n\
216             \r\n\
217             {insert1}\r\n\
218             --boundary\r\n\
219             {HEADER_BATCH_OPERATION_KEY}: {key2}\r\n\
220             {HEADER_BATCH_OPERATION_KIND}: insert\r\n\
221             {HEADER_EXPIRATION}: {expiration}\r\n\
222             {HEADER_ORIGIN}: 203.0.113.42\r\n\
223             Content-Type: text/plain\r\n\
224             \r\n\
225             {insert2}\r\n\
226             --boundary\r\n\
227             {HEADER_BATCH_OPERATION_KEY}: {key3}\r\n\
228             {HEADER_BATCH_OPERATION_KIND}: delete\r\n\
229             \r\n\
230             \r\n\
231             --boundary--\r\n",
232            key0 = "test%2F0", // "test/0" percent-encoded
233            key1 = "test1",
234            key2 = "test2",
235            key3 = "test3",
236            insert1 = String::from_utf8_lossy(insert1_data),
237            insert2 = String::from_utf8_lossy(insert2_data),
238        );
239
240        let access_time = Timestamp::UNIX_EPOCH;
241        let request = Request::builder()
242            .extension(RequestTime(access_time))
243            .header(CONTENT_TYPE, "multipart/form-data; boundary=boundary")
244            .body(Body::from(body))
245            .unwrap();
246
247        let batch_request = BatchOperationStream::from_request(request, &())
248            .await
249            .unwrap();
250
251        let operations: Vec<_> = batch_request.0.collect().await;
252        assert_eq!(operations.len(), 4);
253
254        let Operation::Get(get_op) = &operations[0].as_ref().unwrap() else {
255            panic!("expected get operation");
256        };
257        assert_eq!(get_op.key, "test/0");
258
259        let Operation::Insert(insert_op1) = &operations[1].as_ref().unwrap() else {
260            panic!("expected insert operation");
261        };
262        assert_eq!(insert_op1.key.as_deref(), Some("test1"));
263        assert_eq!(insert_op1.metadata.content_type, "application/octet-stream");
264        assert_eq!(insert_op1.metadata.origin, None);
265        assert_eq!(insert_op1.metadata.time_created, Some(access_time));
266        assert_eq!(insert_op1.payload.as_ref(), insert1_data);
267
268        let Operation::Insert(insert_op2) = &operations[2].as_ref().unwrap() else {
269            panic!("expected insert operation");
270        };
271        assert_eq!(insert_op2.key.as_deref(), Some("test2"));
272        assert_eq!(insert_op2.metadata.content_type, "text/plain");
273        assert_eq!(insert_op2.metadata.expiration_policy, expiration);
274        assert_eq!(insert_op2.metadata.time_created, Some(access_time));
275        assert_eq!(
276            insert_op2.metadata.time_expires,
277            Some(access_time + Duration::from_hours(1))
278        );
279        assert_eq!(insert_op2.metadata.origin.as_deref(), Some("203.0.113.42"));
280        assert_eq!(insert_op2.payload.as_ref(), insert2_data);
281
282        let Operation::Delete(delete_op) = &operations[3].as_ref().unwrap() else {
283            panic!("expected delete operation");
284        };
285        assert_eq!(delete_op.key, "test3");
286    }
287
288    #[tokio::test]
289    async fn test_insert_without_key_header() {
290        let body = format!(
291            "--boundary\r\n\
292             {HEADER_BATCH_OPERATION_KIND}: insert\r\n\
293             Content-Type: application/octet-stream\r\n\
294             \r\n\
295             keyless payload\r\n\
296             --boundary--\r\n",
297        );
298
299        let request = Request::builder()
300            .header(CONTENT_TYPE, "multipart/form-data; boundary=boundary")
301            .body(Body::from(body))
302            .unwrap();
303
304        let batch_request = BatchOperationStream::from_request(request, &())
305            .await
306            .unwrap();
307
308        let operations: Vec<_> = batch_request.0.collect().await;
309        assert_eq!(operations.len(), 1);
310
311        let Operation::Insert(insert_op) = &operations[0].as_ref().unwrap() else {
312            panic!("expected insert operation");
313        };
314        assert!(insert_op.key.is_none());
315        assert_eq!(insert_op.payload.as_ref(), b"keyless payload");
316    }
317
318    #[tokio::test]
319    async fn test_individual_errors_with_isolation() {
320        let large_payload = "x".repeat(MAX_FIELD_SIZE + 1);
321        let valid_key = headers::encode_header_str("valid");
322        let body = format!(
323            "--boundary\r\n\
324             {HEADER_BATCH_OPERATION_KIND}: get\r\n\
325             \r\n\
326             \r\n\
327             --boundary\r\n\
328             {HEADER_BATCH_OPERATION_KEY}: {valid_key}\r\n\
329             {HEADER_BATCH_OPERATION_KIND}: get\r\n\
330             \r\n\
331             \r\n\
332             --boundary\r\n\
333             {HEADER_BATCH_OPERATION_KIND}: delete\r\n\
334             \r\n\
335             \r\n\
336             --boundary\r\n\
337             {HEADER_BATCH_OPERATION_KEY}: {valid_key}\r\n\
338             {HEADER_BATCH_OPERATION_KIND}: insert\r\n\
339             Content-Type: application/octet-stream\r\n\
340             \r\n\
341             {large_payload}\r\n\
342             --boundary\r\n\
343             {HEADER_BATCH_OPERATION_KEY}: {valid_key}\r\n\
344             {HEADER_BATCH_OPERATION_KIND}: delete\r\n\
345             \r\n\
346             \r\n\
347             --boundary--\r\n",
348        );
349
350        let request = Request::builder()
351            .header(CONTENT_TYPE, "multipart/form-data; boundary=boundary")
352            .body(Body::from(body))
353            .unwrap();
354
355        let batch_request = BatchOperationStream::from_request(request, &())
356            .await
357            .unwrap();
358
359        let operations: Vec<_> = batch_request.0.collect().await;
360        assert_eq!(operations.len(), 5);
361
362        // get without key → BadRequest
363        assert!(matches!(&operations[0], Err(BatchError::BadRequest(_))));
364        // valid get
365        assert!(matches!(
366            &operations[1].as_ref().unwrap(),
367            Operation::Get(g) if g.key == "valid"
368        ));
369        // delete without key → BadRequest
370        assert!(matches!(&operations[2], Err(BatchError::BadRequest(_))));
371        // oversized insert → LimitExceeded
372        assert!(matches!(&operations[3], Err(BatchError::LimitExceeded(_))));
373        // valid delete still succeeds after prior errors
374        assert!(matches!(
375            &operations[4].as_ref().unwrap(),
376            Operation::Delete(d) if d.key == "valid"
377        ));
378    }
379
380    #[tokio::test]
381    async fn test_max_operations_limit_enforced() {
382        let mut body = String::new();
383        for i in 0..(MAX_OPERATIONS + 1) {
384            let key = headers::encode_header_str(&format!("test{i}")).to_string();
385            body.push_str(&format!(
386                "--boundary\r\n\
387                 {HEADER_BATCH_OPERATION_KEY}: {key}\r\n\
388                 {HEADER_BATCH_OPERATION_KIND}: get\r\n\
389                 \r\n\
390                 \r\n"
391            ));
392        }
393        body.push_str("--boundary--\r\n");
394
395        let request = Request::builder()
396            .header(CONTENT_TYPE, "multipart/form-data; boundary=boundary")
397            .body(Body::from(body))
398            .unwrap();
399
400        let batch_request = BatchOperationStream::from_request(request, &())
401            .await
402            .unwrap();
403        let operations: Vec<_> = batch_request.0.collect().await;
404
405        assert_eq!(operations.len(), MAX_OPERATIONS + 1);
406        assert!(matches!(
407            &operations[MAX_OPERATIONS],
408            Err(BatchError::LimitExceeded(_))
409        ));
410    }
411
412    #[tokio::test]
413    async fn test_head_operation() {
414        let key = headers::encode_header_str("head-key");
415        let body = format!(
416            "--boundary\r\n\
417             {HEADER_BATCH_OPERATION_KEY}: {key}\r\n\
418             {HEADER_BATCH_OPERATION_KIND}: head\r\n\
419             \r\n\
420             \r\n\
421             --boundary--\r\n",
422        );
423
424        let request = Request::builder()
425            .header(CONTENT_TYPE, "multipart/form-data; boundary=boundary")
426            .body(Body::from(body))
427            .unwrap();
428
429        let batch_request = BatchOperationStream::from_request(request, &())
430            .await
431            .unwrap();
432        let operations: Vec<_> = batch_request.0.collect().await;
433        assert_eq!(operations.len(), 1);
434
435        let Operation::Head(head_op) = &operations[0].as_ref().unwrap() else {
436            panic!("expected head operation");
437        };
438        assert_eq!(head_op.key, "head-key");
439    }
440
441    #[tokio::test]
442    async fn test_head_without_key_is_error() {
443        let body = format!(
444            "--boundary\r\n\
445             {HEADER_BATCH_OPERATION_KIND}: head\r\n\
446             \r\n\
447             \r\n\
448             --boundary--\r\n",
449        );
450
451        let request = Request::builder()
452            .header(CONTENT_TYPE, "multipart/form-data; boundary=boundary")
453            .body(Body::from(body))
454            .unwrap();
455
456        let batch_request = BatchOperationStream::from_request(request, &())
457            .await
458            .unwrap();
459        let operations: Vec<_> = batch_request.0.collect().await;
460        assert_eq!(operations.len(), 1);
461        assert!(matches!(&operations[0], Err(BatchError::BadRequest(_))));
462    }
463}