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