use std::borrow::Cow;
use std::collections::BTreeMap;
use std::fmt;
use bytes::Bytes;
use objectstore_types::metadata::Metadata;
use objectstore_types::resumable::{
CompleteUploadResponse, CreateSessionResponse, HEADER_UPLOAD_LENGTH, HEADER_UPLOAD_OFFSET,
UploadOffset,
};
use reqwest::{Body, Method, Response, StatusCode};
use serde::Serialize;
use tokio::io::AsyncRead;
use tokio_util::io::ReaderStream;
pub use objectstore_types::resumable::{SessionToken, UploadProgress};
use crate::response::ResponseExt as _;
use crate::{ClientStream, Compression, Error, ExpirationPolicy, ObjectKey, Session};
#[derive(Serialize)]
#[serde(rename_all = "snake_case")]
enum UploadType {
Resumable,
}
#[derive(Serialize)]
struct UploadTypeQuery {
upload_type: UploadType,
}
#[derive(Serialize)]
struct SessionQuery<'a> {
session: &'a SessionToken,
}
#[derive(Clone, Debug)]
pub struct ResumableUpload {
session: Session,
key: ObjectKey,
token: SessionToken,
total_length: Option<u64>,
granularity: Option<u64>,
}
impl Session {
pub fn create_upload(&self, object_length: u64) -> CreateResumableUploadBuilder {
let metadata = Metadata {
expiration_policy: self.scope.usecase().expiration_policy(),
..Default::default()
};
CreateResumableUploadBuilder {
session: self.clone(),
total_length: object_length,
key: None,
metadata,
}
}
pub fn resume_upload(&self, key: impl Into<ObjectKey>, token: SessionToken) -> ResumableUpload {
ResumableUpload {
session: self.clone(),
key: key.into(),
token,
total_length: None,
granularity: None,
}
}
}
impl ResumableUpload {
pub fn key(&self) -> &str {
&self.key
}
pub fn token(&self) -> &SessionToken {
&self.token
}
pub fn granularity(&self) -> Option<u64> {
self.granularity
}
pub fn progress(&self) -> UploadProgressBuilder {
UploadProgressBuilder {
upload: self.clone(),
}
}
pub fn put(&self, offset: u64, chunk: impl Into<Bytes>) -> PutChunkBuilder {
let chunk = chunk.into();
self.put_body(offset, chunk.len() as u64, chunk.into())
}
pub fn put_stream(&self, offset: u64, length: u64, stream: ClientStream) -> PutChunkBuilder {
self.put_body(offset, length, Body::wrap_stream(stream))
}
pub fn put_read<R>(&self, offset: u64, length: u64, reader: R) -> PutChunkBuilder
where
R: AsyncRead + Send + Sync + 'static,
{
self.put_body(offset, length, Body::wrap_stream(ReaderStream::new(reader)))
}
fn put_body(&self, offset: u64, length: u64, body: Body) -> PutChunkBuilder {
PutChunkBuilder {
upload: self.clone(),
offset,
length,
body,
}
}
fn validate_chunk_length(&self, offset: u64, chunk_length: u64) -> crate::Result<()> {
let (Some(granularity), Some(total_length)) = (self.granularity, self.total_length) else {
return Ok(());
};
if granularity == 0 || chunk_length == 0 || chunk_length >= granularity {
return Ok(());
}
if !offset
.checked_add(chunk_length)
.is_some_and(|end| end < total_length)
{
return Ok(());
}
Err(Error::ChunkTooSmall {
chunk_length,
upload_granularity: granularity,
})
}
pub fn cancel(&self) -> CancelUploadBuilder {
CancelUploadBuilder {
upload: self.clone(),
}
}
fn request(&self, method: Method) -> crate::Result<reqwest::RequestBuilder> {
Ok(self
.session
.request(method, &self.key)?
.query(&SessionQuery {
session: &self.token,
}))
}
}
#[derive(Debug)]
pub struct CreateResumableUploadBuilder {
session: Session,
total_length: u64,
key: Option<ObjectKey>,
metadata: Metadata,
}
impl CreateResumableUploadBuilder {
pub fn key(mut self, key: impl Into<ObjectKey>) -> Self {
self.key = Some(key.into()).filter(|key| !key.is_empty());
self
}
pub fn content_type(mut self, content_type: impl Into<Cow<'static, str>>) -> Self {
self.metadata.content_type = content_type.into();
self
}
pub fn expiration_policy(mut self, expiration_policy: ExpirationPolicy) -> Self {
self.metadata.expiration_policy = expiration_policy;
self
}
pub fn compression(mut self, compression: impl Into<Option<Compression>>) -> Self {
self.metadata.compression = compression.into();
self
}
pub fn origin(mut self, origin: impl Into<String>) -> Self {
self.metadata.origin = Some(origin.into());
self
}
pub fn filename(mut self, filename: impl Into<String>) -> Self {
self.metadata.filename = Some(filename.into());
self
}
pub fn set_metadata(mut self, metadata: impl Into<BTreeMap<String, String>>) -> Self {
self.metadata.custom = metadata.into();
self
}
pub fn append_metadata(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.metadata.custom.insert(key.into(), value.into());
self
}
pub async fn send(self) -> crate::Result<Option<ResumableUpload>> {
let method = if self.key.is_some() {
Method::PUT
} else {
Method::POST
};
let request = self
.session
.request(method, self.key.as_deref().unwrap_or_default())?
.query(&UploadTypeQuery {
upload_type: UploadType::Resumable,
})
.headers(self.metadata.to_headers("")?)
.header(HEADER_UPLOAD_LENGTH, self.total_length.to_string());
let response = request.send().await?;
match response.status() {
StatusCode::OK => {}
StatusCode::NOT_IMPLEMENTED => {
response.drain_body().await;
return Ok(None);
}
status => {
let response = response.error_for_status_and_drain().await?;
response.drain_body().await;
return Err(Error::MalformedResponse(format!(
"unexpected HTTP status {status} while creating a resumable upload"
)));
}
}
let response: CreateSessionResponse = response.json().await?;
let upload = ResumableUpload {
session: self.session,
key: response.key,
token: response.session,
total_length: Some(self.total_length),
granularity: Some(response.granularity),
};
Ok(Some(upload))
}
}
#[derive(Debug)]
pub struct UploadProgressBuilder {
upload: ResumableUpload,
}
impl UploadProgressBuilder {
pub async fn send(self) -> crate::Result<UploadProgress> {
let response = self
.upload
.request(Method::PUT)?
.header(HEADER_UPLOAD_OFFSET, "*")
.send()
.await?;
parse_progress_response(response).await
}
}
pub struct PutChunkBuilder {
upload: ResumableUpload,
offset: u64,
length: u64,
body: Body,
}
impl fmt::Debug for PutChunkBuilder {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("PutChunkBuilder")
.field("upload", &self.upload)
.field("offset", &self.offset)
.field("length", &self.length)
.finish()
}
}
impl PutChunkBuilder {
pub async fn send(self) -> crate::Result<UploadProgress> {
self.upload
.validate_chunk_length(self.offset, self.length)?;
let response = self
.upload
.request(Method::PUT)?
.header(HEADER_UPLOAD_OFFSET, self.offset.to_string())
.header(reqwest::header::CONTENT_LENGTH, self.length)
.body(self.body)
.send()
.await?;
parse_progress_response(response).await
}
}
#[derive(Debug)]
pub struct CancelUploadBuilder {
upload: ResumableUpload,
}
impl CancelUploadBuilder {
pub async fn send(self) -> crate::Result<()> {
let response = self.upload.request(Method::DELETE)?.send().await?;
match response.status() {
StatusCode::NO_CONTENT => {
response.drain_body().await;
Ok(())
}
StatusCode::NOT_FOUND | StatusCode::GONE => {
response.drain_body().await;
Err(Error::ResumableUploadUnavailable)
}
status => {
let response = response.error_for_status_and_drain().await?;
response.drain_body().await;
Err(Error::MalformedResponse(format!(
"unexpected HTTP status {status} while canceling a resumable upload"
)))
}
}
}
}
async fn parse_progress_response(response: Response) -> crate::Result<UploadProgress> {
match response.status() {
status @ (StatusCode::NO_CONTENT | StatusCode::CONFLICT) => {
let offset = parse_offset(&response);
response.drain_body().await;
let offset = offset.ok_or_else(|| {
crate::Error::MalformedResponse(
"resumable upload response has no valid Upload-Offset header".into(),
)
})?;
if status == StatusCode::CONFLICT {
Err(Error::UploadOffsetMismatch { offset })
} else {
Ok(UploadProgress::Incomplete { offset })
}
}
StatusCode::CREATED => {
let _: CompleteUploadResponse = response.json().await?;
Ok(UploadProgress::Complete)
}
StatusCode::NOT_FOUND | StatusCode::GONE => {
response.drain_body().await;
Err(Error::ResumableUploadUnavailable)
}
status => {
let response = response.error_for_status_and_drain().await?;
response.drain_body().await;
Err(Error::MalformedResponse(format!(
"unexpected HTTP status {status} while continuing a resumable upload"
)))
}
}
}
fn parse_offset(response: &Response) -> Option<u64> {
let value = response
.headers()
.get(HEADER_UPLOAD_OFFSET)?
.to_str()
.ok()?;
match value.parse().ok()? {
UploadOffset::At(offset) => Some(offset),
UploadOffset::Unknown => None,
}
}