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;
26use crate::rate_limits::LimitExceeded;
27
28#[derive(Debug, Error)]
30pub enum BatchError {
31 #[error("bad request: {0}")]
33 BadRequest(String),
34
35 #[error("multipart error: {0}")]
37 Multipart(#[from] MultipartError),
38
39 #[error("metadata error: {0}")]
41 Metadata(#[from] objectstore_types::metadata::Error),
42
43 #[error("batch limit exceeded: {0}")]
45 LimitExceeded(String),
46
47 #[error("{0}")]
49 RateLimited(LimitExceeded),
50
51 #[error("response part serialization error: {context}")]
53 ResponseSerialization {
54 context: String,
56 #[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
141pub 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; const 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", 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 assert!(matches!(&operations[0], Err(BatchError::BadRequest(_))));
365 assert!(matches!(
367 &operations[1].as_ref().unwrap(),
368 Operation::Get(g) if g.key == "valid"
369 ));
370 assert!(matches!(&operations[2], Err(BatchError::BadRequest(_))));
372 assert!(matches!(&operations[3], Err(BatchError::LimitExceeded(_))));
374 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}