objectstore_server/extractors/
batch.rs1use 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#[derive(Debug, Error)]
29pub enum BatchError {
30 #[error("bad request: {0}")]
32 BadRequest(String),
33
34 #[error("multipart error: {0}")]
36 Multipart(#[from] MultipartError),
37
38 #[error("metadata error: {0}")]
40 Metadata(#[from] objectstore_types::metadata::Error),
41
42 #[error("batch limit exceeded: {0}")]
44 LimitExceeded(String),
45
46 #[error("rate limited")]
48 RateLimited,
49
50 #[error("response part serialization error: {context}")]
52 ResponseSerialization {
53 context: String,
55 #[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
140pub 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; const 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", 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 assert!(matches!(&operations[0], Err(BatchError::BadRequest(_))));
364 assert!(matches!(
366 &operations[1].as_ref().unwrap(),
367 Operation::Get(g) if g.key == "valid"
368 ));
369 assert!(matches!(&operations[2], Err(BatchError::BadRequest(_))));
371 assert!(matches!(&operations[3], Err(BatchError::LimitExceeded(_))));
373 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}