| // Licensed to the Apache Software Foundation (ASF) under one |
| // or more contributor license agreements. See the NOTICE file |
| // distributed with this work for additional information |
| // regarding copyright ownership. The ASF licenses this file |
| // to you under the Apache License, Version 2.0 (the |
| // "License"); you may not use this file except in compliance |
| // with the License. You may obtain a copy of the License at |
| // |
| // http://www.apache.org/licenses/LICENSE-2.0 |
| // |
| // Unless required by applicable law or agreed to in writing, |
| // software distributed under the License is distributed on an |
| // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY |
| // KIND, either express or implied. See the License for the |
| // specific language governing permissions and limitations |
| // under the License. |
| |
| use crate::client::header::{HeaderConfig, get_etag, header_meta}; |
| use crate::client::retry::RetryContext; |
| use crate::client::{HttpResponse, HttpResponseBody}; |
| use crate::path::Path; |
| use crate::{ |
| Attribute, Attributes, GetOptions, GetRange, GetResult, GetResultPayload, ObjectMeta, Result, |
| RetryConfig, |
| }; |
| use async_trait::async_trait; |
| use bytes::Bytes; |
| use futures_util::StreamExt; |
| use futures_util::stream::BoxStream; |
| use http::StatusCode; |
| use http::header::{ |
| CACHE_CONTROL, CONTENT_DISPOSITION, CONTENT_ENCODING, CONTENT_LANGUAGE, CONTENT_RANGE, |
| CONTENT_TYPE, |
| }; |
| use http_body_util::BodyExt; |
| use reqwest::header::ToStrError; |
| use std::ops::Range; |
| use std::sync::Arc; |
| use tracing::info; |
| |
| /// A client that can perform a get request |
| #[async_trait] |
| pub(crate) trait GetClient: Send + Sync + 'static { |
| const STORE: &'static str; |
| |
| /// Configure the [`HeaderConfig`] for this client |
| const HEADER_CONFIG: HeaderConfig; |
| |
| fn retry_config(&self) -> &RetryConfig; |
| |
| async fn get_request( |
| &self, |
| ctx: &mut RetryContext, |
| path: &Path, |
| options: GetOptions, |
| ) -> Result<HttpResponse>; |
| } |
| |
| /// Extension trait for [`GetClient`] that adds common retrieval functionality |
| #[async_trait] |
| pub(crate) trait GetClientExt { |
| async fn get_opts(&self, location: &Path, options: GetOptions) -> Result<GetResult>; |
| } |
| |
| #[async_trait] |
| impl<T: GetClient> GetClientExt for Arc<T> { |
| async fn get_opts(&self, location: &Path, options: GetOptions) -> Result<GetResult> { |
| let ctx = GetContext { |
| location: location.clone(), |
| options, |
| client: Self::clone(self), |
| retry_ctx: RetryContext::new(self.retry_config()), |
| }; |
| |
| ctx.get_result().await |
| } |
| } |
| |
| struct ContentRange { |
| /// The range of the object returned |
| range: Range<u64>, |
| /// The total size of the object being requested |
| size: u64, |
| } |
| |
| impl ContentRange { |
| /// Parse a content range of the form `bytes <range-start>-<range-end>/<size>` |
| /// |
| /// <https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Range> |
| fn from_str(s: &str) -> Option<Self> { |
| let rem = s.trim().strip_prefix("bytes ")?; |
| let (range, size) = rem.split_once('/')?; |
| let size = size.parse().ok()?; |
| |
| let (start_s, end_s) = range.split_once('-')?; |
| |
| let start = start_s.parse().ok()?; |
| let end: u64 = end_s.parse().ok()?; |
| |
| Some(Self { |
| size, |
| range: start..end + 1, |
| }) |
| } |
| } |
| |
| /// A specialized `Error` for get-related errors |
| #[derive(Debug, thiserror::Error)] |
| enum GetResultError { |
| #[error(transparent)] |
| Header { |
| #[from] |
| source: crate::client::header::Error, |
| }, |
| |
| #[error(transparent)] |
| InvalidRangeRequest { |
| #[from] |
| source: crate::util::InvalidGetRange, |
| }, |
| |
| #[error("Received non-partial response when range requested")] |
| NotPartial, |
| |
| #[error("Content-Range header not present in partial response")] |
| NoContentRange, |
| |
| #[error("Failed to parse value for CONTENT_RANGE header: \"{value}\"")] |
| ParseContentRange { value: String }, |
| |
| #[error("Content-Range header contained non UTF-8 characters")] |
| InvalidContentRange { source: ToStrError }, |
| |
| #[error("Cache-Control header contained non UTF-8 characters")] |
| InvalidCacheControl { source: ToStrError }, |
| |
| #[error("Content-Disposition header contained non UTF-8 characters")] |
| InvalidContentDisposition { source: ToStrError }, |
| |
| #[error("Content-Encoding header contained non UTF-8 characters")] |
| InvalidContentEncoding { source: ToStrError }, |
| |
| #[error("Content-Language header contained non UTF-8 characters")] |
| InvalidContentLanguage { source: ToStrError }, |
| |
| #[error("Content-Type header contained non UTF-8 characters")] |
| InvalidContentType { source: ToStrError }, |
| |
| #[error("Metadata value for \"{key:?}\" contained non UTF-8 characters")] |
| InvalidMetadata { key: String }, |
| |
| #[error("Requested {expected:?}, got {actual:?}")] |
| UnexpectedRange { |
| expected: Range<u64>, |
| actual: Range<u64>, |
| }, |
| } |
| |
| /// Retry context for a streaming get request |
| struct GetContext<T: GetClient> { |
| client: Arc<T>, |
| location: Path, |
| options: GetOptions, |
| retry_ctx: RetryContext, |
| } |
| |
| impl<T: GetClient> GetContext<T> { |
| async fn get_result(mut self) -> Result<GetResult> { |
| if let Some(r) = &self.options.range { |
| r.is_valid().map_err(Self::err)?; |
| } |
| |
| let request = self |
| .client |
| .get_request(&mut self.retry_ctx, &self.location, self.options.clone()) |
| .await?; |
| |
| let (parts, body) = request.into_parts(); |
| let (range, meta) = get_range_meta( |
| T::HEADER_CONFIG, |
| &self.location, |
| self.options.range.as_ref(), |
| &parts, |
| ) |
| .map_err(Self::err)?; |
| |
| let attributes = get_attributes(T::HEADER_CONFIG, &parts.headers).map_err(Self::err)?; |
| let stream = self.retry_stream(body, meta.e_tag.clone(), range.clone()); |
| |
| Ok(GetResult { |
| payload: GetResultPayload::Stream(stream), |
| meta, |
| range, |
| attributes, |
| }) |
| } |
| |
| fn retry_stream( |
| self, |
| body: HttpResponseBody, |
| etag: Option<String>, |
| range: Range<u64>, |
| ) -> BoxStream<'static, Result<Bytes>> { |
| futures_util::stream::try_unfold( |
| (self, body, etag, range), |
| |(mut ctx, mut body, etag, mut range)| async move { |
| while let Some(ret) = body.frame().await { |
| match (ret, &etag) { |
| (Ok(frame), _) => match frame.into_data() { |
| Ok(bytes) => { |
| range.start += bytes.len() as u64; |
| return Ok(Some((bytes, (ctx, body, etag, range)))); |
| } |
| Err(_) => continue, // Isn't data frame |
| }, |
| // Retry all response body errors |
| (Err(e), Some(etag)) if !ctx.retry_ctx.exhausted() => { |
| let sleep = ctx.retry_ctx.backoff(); |
| info!( |
| "Encountered error while reading response body: {}. Retrying in {}s", |
| e, |
| sleep.as_secs_f32() |
| ); |
| |
| tokio::time::sleep(sleep).await; |
| |
| let options = GetOptions { |
| range: Some(GetRange::Bounded(range.clone())), |
| ..ctx.options.clone() |
| }; |
| |
| // Note: this will potentially retry internally if applicable |
| let request = ctx |
| .client |
| .get_request(&mut ctx.retry_ctx, &ctx.location, options) |
| .await |
| .map_err(Self::err)?; |
| |
| let (parts, retry_body) = request.into_parts(); |
| let retry_etag = get_etag(&parts.headers).map_err(Self::err)?; |
| |
| if etag != &retry_etag { |
| // Return the original error |
| return Err(Self::err(e)); |
| } |
| |
| body = retry_body; |
| } |
| (Err(e), _) => return Err(Self::err(e)), |
| } |
| } |
| Ok(None) |
| }, |
| ) |
| .boxed() |
| } |
| |
| fn err<E: std::error::Error + Send + Sync + 'static>(e: E) -> crate::Error { |
| crate::Error::Generic { |
| store: T::STORE, |
| source: Box::new(e), |
| } |
| } |
| } |
| |
| fn get_range_meta( |
| cfg: HeaderConfig, |
| location: &Path, |
| range: Option<&GetRange>, |
| response: &http::response::Parts, |
| ) -> Result<(Range<u64>, ObjectMeta), GetResultError> { |
| let mut meta = header_meta(location, &response.headers, cfg)?; |
| let range = if let Some(expected) = range { |
| if response.status != StatusCode::PARTIAL_CONTENT { |
| return Err(GetResultError::NotPartial); |
| } |
| |
| let value = parse_range(&response.headers)?; |
| let actual = value.range; |
| |
| // Update size to reflect the full size of the object (#5272) |
| meta.size = value.size; |
| |
| let expected = expected.as_range(meta.size)?; |
| if actual != expected { |
| return Err(GetResultError::UnexpectedRange { expected, actual }); |
| } |
| |
| actual |
| } else { |
| 0..meta.size |
| }; |
| |
| Ok((range, meta)) |
| } |
| |
| /// Extracts the [CONTENT_RANGE] header |
| fn parse_range(headers: &http::HeaderMap) -> Result<ContentRange, GetResultError> { |
| let val = headers |
| .get(CONTENT_RANGE) |
| .ok_or(GetResultError::NoContentRange)?; |
| |
| let value = val |
| .to_str() |
| .map_err(|source| GetResultError::InvalidContentRange { source })?; |
| |
| ContentRange::from_str(value).ok_or_else(|| { |
| let value = value.into(); |
| GetResultError::ParseContentRange { value } |
| }) |
| } |
| |
| /// Extracts [`Attributes`] from the response headers |
| fn get_attributes( |
| cfg: HeaderConfig, |
| headers: &http::HeaderMap, |
| ) -> Result<Attributes, GetResultError> { |
| macro_rules! parse_attributes { |
| ($headers:expr, $(($header:expr, $attr:expr, $map_err:expr)),*) => {{ |
| let mut attributes = Attributes::new(); |
| $( |
| if let Some(x) = $headers.get($header) { |
| let x = x.to_str().map_err($map_err)?; |
| attributes.insert($attr, x.to_string().into()); |
| } |
| )* |
| attributes |
| }} |
| } |
| |
| let mut attributes = parse_attributes!( |
| headers, |
| (CACHE_CONTROL, Attribute::CacheControl, |source| { |
| GetResultError::InvalidCacheControl { source } |
| }), |
| ( |
| CONTENT_DISPOSITION, |
| Attribute::ContentDisposition, |
| |source| GetResultError::InvalidContentDisposition { source } |
| ), |
| (CONTENT_ENCODING, Attribute::ContentEncoding, |source| { |
| GetResultError::InvalidContentEncoding { source } |
| }), |
| (CONTENT_LANGUAGE, Attribute::ContentLanguage, |source| { |
| GetResultError::InvalidContentLanguage { source } |
| }), |
| (CONTENT_TYPE, Attribute::ContentType, |source| { |
| GetResultError::InvalidContentType { source } |
| }) |
| ); |
| |
| // Add attributes that match the user-defined metadata prefix (e.g. x-amz-meta-) |
| if let Some(prefix) = cfg.user_defined_metadata_prefix { |
| for (key, val) in headers { |
| if let Some(suffix) = key.as_str().strip_prefix(prefix) { |
| if let Ok(val_str) = val.to_str() { |
| attributes.insert( |
| Attribute::Metadata(suffix.to_string().into()), |
| val_str.to_string().into(), |
| ); |
| } else { |
| return Err(GetResultError::InvalidMetadata { |
| key: key.to_string(), |
| }); |
| } |
| } |
| } |
| } |
| Ok(attributes) |
| } |
| |
| #[cfg(test)] |
| mod tests { |
| use super::*; |
| use http::header::*; |
| |
| fn make_response( |
| object_size: usize, |
| status: StatusCode, |
| content_range: Option<&str>, |
| headers: Option<Vec<(&str, &str)>>, |
| ) -> http::response::Parts { |
| let mut builder = http::Response::builder(); |
| if let Some(range) = content_range { |
| builder = builder.header(CONTENT_RANGE, range); |
| } |
| |
| if let Some(headers) = headers { |
| for (key, value) in headers { |
| builder = builder.header(key, value); |
| } |
| } |
| |
| builder |
| .status(status) |
| .header(CONTENT_LENGTH, object_size) |
| .body(()) |
| .unwrap() |
| .into_parts() |
| .0 |
| } |
| |
| const CFG: HeaderConfig = HeaderConfig { |
| etag_required: false, |
| last_modified_required: false, |
| version_header: None, |
| user_defined_metadata_prefix: Some("x-test-meta-"), |
| }; |
| |
| #[tokio::test] |
| async fn test_get_range_meta() { |
| let path = Path::from("test"); |
| |
| let resp = make_response(12, StatusCode::OK, None, None); |
| let (range, meta) = get_range_meta(CFG, &path, None, &resp).unwrap(); |
| assert_eq!(meta.size, 12); |
| assert_eq!(range, 0..12); |
| |
| let get_range = GetRange::from(2..3); |
| |
| let resp = make_response(12, StatusCode::PARTIAL_CONTENT, Some("bytes 2-2/12"), None); |
| let (range, meta) = get_range_meta(CFG, &path, Some(&get_range), &resp).unwrap(); |
| assert_eq!(meta.size, 12); |
| assert_eq!(range, 2..3); |
| |
| let resp = make_response(12, StatusCode::OK, None, None); |
| let err = get_range_meta(CFG, &path, Some(&get_range), &resp).unwrap_err(); |
| assert_eq!( |
| err.to_string(), |
| "Received non-partial response when range requested" |
| ); |
| |
| let resp = make_response(12, StatusCode::PARTIAL_CONTENT, Some("bytes 2-3/12"), None); |
| let err = get_range_meta(CFG, &path, Some(&get_range), &resp).unwrap_err(); |
| assert_eq!(err.to_string(), "Requested 2..3, got 2..4"); |
| |
| let resp = make_response(12, StatusCode::PARTIAL_CONTENT, Some("bytes 2-2/*"), None); |
| let err = get_range_meta(CFG, &path, Some(&get_range), &resp).unwrap_err(); |
| assert_eq!( |
| err.to_string(), |
| "Failed to parse value for CONTENT_RANGE header: \"bytes 2-2/*\"" |
| ); |
| |
| let resp = make_response(12, StatusCode::PARTIAL_CONTENT, None, None); |
| let err = get_range_meta(CFG, &path, Some(&get_range), &resp).unwrap_err(); |
| assert_eq!( |
| err.to_string(), |
| "Content-Range header not present in partial response" |
| ); |
| |
| let resp = make_response(2, StatusCode::PARTIAL_CONTENT, Some("bytes 2-3/2"), None); |
| let err = get_range_meta(CFG, &path, Some(&get_range), &resp).unwrap_err(); |
| assert_eq!( |
| err.to_string(), |
| "Wanted range starting at 2, but object was only 2 bytes long" |
| ); |
| |
| let resp = make_response(6, StatusCode::PARTIAL_CONTENT, Some("bytes 2-5/6"), None); |
| let (range, meta) = get_range_meta(CFG, &path, Some(&GetRange::Suffix(4)), &resp).unwrap(); |
| assert_eq!(meta.size, 6); |
| assert_eq!(range, 2..6); |
| |
| let resp = make_response(6, StatusCode::PARTIAL_CONTENT, Some("bytes 2-3/6"), None); |
| let err = get_range_meta(CFG, &path, Some(&GetRange::Suffix(4)), &resp).unwrap_err(); |
| assert_eq!(err.to_string(), "Requested 2..6, got 2..4"); |
| } |
| |
| #[test] |
| fn test_get_attributes() { |
| let resp = make_response( |
| 12, |
| StatusCode::OK, |
| None, |
| Some(vec![("x-test-meta-foo", "bar")]), |
| ); |
| |
| let attributes = get_attributes(CFG, &resp.headers).unwrap(); |
| assert_eq!( |
| attributes.get(&Attribute::Metadata("foo".into())), |
| Some(&"bar".into()) |
| ); |
| } |
| } |
| #[cfg(all(test, feature = "http", not(target_arch = "wasm32")))] |
| mod http_tests { |
| use crate::client::mock_server::MockServer; |
| use crate::client::{HttpError, HttpErrorKind, HttpResponseBody}; |
| use crate::http::HttpBuilder; |
| use crate::path::Path; |
| use crate::{ClientOptions, ObjectStoreExt, RetryConfig}; |
| use bytes::Bytes; |
| use futures_util::FutureExt; |
| use http::header::{CONNECTION, CONTENT_LENGTH, CONTENT_RANGE, ETAG, RANGE}; |
| use http::{Response, StatusCode}; |
| use hyper::body::Frame; |
| use std::pin::Pin; |
| use std::task::{Context, Poll, ready}; |
| use std::time::Duration; |
| |
| #[derive(Debug, thiserror::Error)] |
| #[error("ChunkedErr")] |
| struct ChunkedErr {} |
| |
| /// A Body from a list of results |
| /// |
| /// Sleeps between each frame to avoid the HTTP Server coalescing the frames |
| struct Chunked { |
| chunks: std::vec::IntoIter<Result<Bytes, ()>>, |
| sleep: Option<Pin<Box<tokio::time::Sleep>>>, |
| } |
| |
| impl Chunked { |
| fn new(v: Vec<Result<Bytes, ()>>) -> Self { |
| Self { |
| chunks: v.into_iter(), |
| sleep: None, |
| } |
| } |
| } |
| |
| impl hyper::body::Body for Chunked { |
| type Data = Bytes; |
| type Error = HttpError; |
| |
| fn poll_frame( |
| mut self: Pin<&mut Self>, |
| cx: &mut Context<'_>, |
| ) -> Poll<Option<Result<Frame<Self::Data>, Self::Error>>> { |
| if let Some(sleep) = &mut self.sleep { |
| ready!(sleep.poll_unpin(cx)); |
| self.sleep = None; |
| } |
| |
| Poll::Ready(match self.chunks.next() { |
| None => None, |
| Some(Ok(b)) => { |
| self.sleep = Some(Box::pin(tokio::time::sleep(Duration::from_millis(1)))); |
| Some(Ok(Frame::data(b))) |
| } |
| Some(Err(_)) => Some(Err(HttpError::new(HttpErrorKind::Unknown, ChunkedErr {}))), |
| }) |
| } |
| } |
| |
| impl From<Chunked> for HttpResponseBody { |
| fn from(value: Chunked) -> Self { |
| Self::new(value) |
| } |
| } |
| |
| #[tokio::test] |
| async fn test_stream_retry() { |
| let mock = MockServer::new().await; |
| let retry = RetryConfig { |
| backoff: Default::default(), |
| max_retries: 3, |
| retry_timeout: Duration::from_secs(1000), |
| }; |
| |
| let options = ClientOptions::new().with_allow_http(true); |
| let store = HttpBuilder::new() |
| .with_client_options(options) |
| .with_retry(retry) |
| .with_url(mock.url()) |
| .build() |
| .unwrap(); |
| |
| let path = Path::from("test"); |
| |
| // Test basic |
| let resp = Response::builder() |
| .header(CONTENT_LENGTH, 11) |
| .header(ETAG, "123") |
| .body("Hello World".to_string()) |
| .unwrap(); |
| |
| mock.push(resp); |
| |
| let b = store.get(&path).await.unwrap().bytes().await.unwrap(); |
| assert_eq!(b.as_ref(), b"Hello World"); |
| |
| // Test basic with range |
| let resp = Response::builder() |
| .status(StatusCode::PARTIAL_CONTENT) |
| .header(CONTENT_LENGTH, 4) |
| .header(ETAG, "123") |
| .header(CONTENT_RANGE, "bytes 1-4/11") |
| .body("ello".to_string()) |
| .unwrap(); |
| |
| mock.push(resp); |
| |
| let b = store.get_range(&path, 1..5).await.unwrap(); |
| assert_eq!(b.as_ref(), b"ello"); |
| |
| // NOTE: if debug_assertions is true, hyper panics with response content length header |
| // value does not match the length of response body. |
| if !cfg!(debug_assertions) { |
| // Test retry if a response body is incomplete. |
| mock.push( |
| Response::builder() |
| .header(CONTENT_LENGTH, 12) |
| .header(ETAG, "123") |
| // connection: close disables keep alive |
| .header(CONNECTION, "close") |
| .body("Hello".to_string()) |
| .unwrap(), |
| ); |
| |
| mock.push_fn(|req| { |
| assert_eq!( |
| req.headers().get(RANGE).unwrap().to_str().unwrap(), |
| "bytes=5-11" |
| ); |
| |
| Response::builder() |
| .status(StatusCode::PARTIAL_CONTENT) |
| .header(CONTENT_LENGTH, 7) |
| .header(ETAG, "123") |
| .header(CONTENT_RANGE, "bytes 5-11/12") |
| .body(" World!".to_string()) |
| .unwrap() |
| }); |
| |
| let ret = store.get(&path).await.unwrap().bytes().await.unwrap(); |
| assert_eq!(ret.as_ref(), b"Hello World!"); |
| |
| // Test retry if a get range response body is incomplete. |
| mock.push( |
| Response::builder() |
| .status(StatusCode::PARTIAL_CONTENT) |
| .header(CONTENT_LENGTH, 5) |
| .header(CONNECTION, "close") |
| .header(ETAG, "123") |
| .header(CONTENT_RANGE, "bytes 6-10/12") |
| .body("Wo".to_string()) |
| .unwrap(), |
| ); |
| |
| mock.push_fn(|req| { |
| assert_eq!( |
| req.headers().get(RANGE).unwrap().to_str().unwrap(), |
| "bytes=8-10" |
| ); |
| |
| Response::builder() |
| .status(StatusCode::PARTIAL_CONTENT) |
| .header(CONTENT_LENGTH, 3) |
| .header(ETAG, "123") |
| .header(CONTENT_RANGE, "bytes 8-10/12") |
| .body("rld".to_string()) |
| .unwrap() |
| }); |
| |
| let ret = store.get_range(&path, 6..11).await.unwrap(); |
| assert_eq!(ret.as_ref(), b"World"); |
| } |
| |
| // Should retry with range |
| mock.push( |
| Response::builder() |
| .header(CONTENT_LENGTH, 10) |
| .header(ETAG, "123") |
| .body(Chunked::new(vec![ |
| Ok(Bytes::from_static(b"banana")), |
| Err(()), |
| ])) |
| .unwrap(), |
| ); |
| |
| mock.push_fn(|req| { |
| assert_eq!( |
| req.headers().get(RANGE).unwrap().to_str().unwrap(), |
| "bytes=6-9" |
| ); |
| |
| Response::builder() |
| .status(StatusCode::PARTIAL_CONTENT) |
| .header(CONTENT_LENGTH, 3) |
| .header(ETAG, "123") |
| .header(CONTENT_RANGE, "bytes 6-9/10") |
| .body("123".to_string()) |
| .unwrap() |
| }); |
| |
| let ret = store.get(&path).await.unwrap().bytes().await.unwrap(); |
| assert_eq!(ret.as_ref(), b"banana123"); |
| |
| // Should retry multiple times |
| mock.push( |
| Response::builder() |
| .header(CONTENT_LENGTH, 20) |
| .header(ETAG, "foo") |
| .body(Chunked::new(vec![ |
| Ok(Bytes::from_static(b"hello")), |
| Err(()), |
| ])) |
| .unwrap(), |
| ); |
| |
| mock.push_fn(|req| { |
| assert_eq!( |
| req.headers().get(RANGE).unwrap().to_str().unwrap(), |
| "bytes=5-19" |
| ); |
| |
| Response::builder() |
| .status(StatusCode::PARTIAL_CONTENT) |
| .header(CONTENT_LENGTH, 15) |
| .header(ETAG, "foo") |
| .header(CONTENT_RANGE, "bytes 5-19/20") |
| .body(Chunked::new(vec![Ok(Bytes::from_static(b"baz")), Err(())])) |
| .unwrap() |
| }); |
| |
| mock.push_fn::<_, String>(|req| { |
| assert_eq!( |
| req.headers().get(RANGE).unwrap().to_str().unwrap(), |
| "bytes=8-19" |
| ); |
| Response::builder() |
| .status(StatusCode::BAD_GATEWAY) |
| .body("ignored".to_string()) |
| .unwrap() |
| }); |
| |
| mock.push_fn(|req| { |
| assert_eq!( |
| req.headers().get(RANGE).unwrap().to_str().unwrap(), |
| "bytes=8-19" |
| ); |
| |
| Response::builder() |
| .status(StatusCode::PARTIAL_CONTENT) |
| .header(CONTENT_LENGTH, 12) |
| .header(ETAG, "foo") |
| .header(CONTENT_RANGE, "bytes 8-19/20") |
| .body("123456789012".to_string()) |
| .unwrap() |
| }); |
| |
| let ret = store.get(&path).await.unwrap().bytes().await.unwrap(); |
| assert_eq!(ret.as_ref(), b"hellobaz123456789012"); |
| |
| // Should abort if etag doesn't match |
| mock.push( |
| Response::builder() |
| .header(CONTENT_LENGTH, 12) |
| .header(ETAG, "foo") |
| .body(Chunked::new(vec![Ok(Bytes::from_static(b"test")), Err(())])) |
| .unwrap(), |
| ); |
| |
| mock.push_fn(|req| { |
| assert_eq!( |
| req.headers().get(RANGE).unwrap().to_str().unwrap(), |
| "bytes=4-11" |
| ); |
| |
| Response::builder() |
| .status(StatusCode::PARTIAL_CONTENT) |
| .header(CONTENT_LENGTH, 7) |
| .header(ETAG, "baz") |
| .header(CONTENT_RANGE, "bytes 4-11/12") |
| .body("1234567".to_string()) |
| .unwrap() |
| }); |
| |
| let err = store.get(&path).await.unwrap().bytes().await.unwrap_err(); |
| assert_eq!( |
| err.to_string(), |
| "Generic HTTP error: HTTP error: request or response body error" |
| ); |
| } |
| } |