objectstore-client 0.2.10

Client SDK for Objectstore, the Sentry object storage platform
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
//! Low-level client API for resumable uploads.
//!
//! Enable the `resumable-upload-api` feature to use this API.
//! It maps directly to the server operations and is intended for advanced use cases that need
//! low-level protocol calls.
//! A future release of `objectstore-client` will use this API under the hood for eligible
//! `Session::put` calls, so users can benefit from this feature without the need to interact with
//! this API directly.

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,
}

/// A handle bound to one resumable upload session.
///
/// See the [crate-level documentation](crate#resumable-upload-api) for more information and
/// examples on using this API.
#[derive(Clone, Debug)]
pub struct ResumableUpload {
    session: Session,
    key: ObjectKey,
    token: SessionToken,
    total_length: Option<u64>,
    granularity: Option<u64>,
}

impl Session {
    /// Starts building a resumable upload for an object with `object_length` bytes.
    ///
    /// `object_length` is the total length of the object to be uploaded.
    /// Unlike regular uploads, the client does not automatically compress chunk contents.
    /// The caller must pre-compress the object according to `compression`, then create the upload
    /// with the post-compression `object_length`. Offsets similarly refer to the bytes after compression.
    ///
    /// The object key is generated by the server unless one is supplied with
    /// [`CreateResumableUploadBuilder::key`].
    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,
        }
    }

    /// Reconstructs a resumable upload handle without contacting the server.
    ///
    /// The `key` and `token` must come from the same previously created upload and scope. The
    /// server rejects a mismatched or unknown token when an operation is sent.
    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 {
    /// Returns the object key bound to this upload.
    pub fn key(&self) -> &str {
        &self.key
    }

    /// Returns the session token bound to this upload.
    pub fn token(&self) -> &SessionToken {
        &self.token
    }

    /// Returns this upload's granularity in bytes, if known.
    ///
    /// The granularity is the persistence unit for non-final chunks: chunks shorter than one
    /// unit are rejected, and larger chunks may persist only an aligned prefix.
    pub fn granularity(&self) -> Option<u64> {
        self.granularity
    }

    /// Builds a request for the server's authoritative upload progress.
    pub fn progress(&self) -> UploadProgressBuilder {
        UploadProgressBuilder {
            upload: self.clone(),
        }
    }

    /// Builds a request to write `chunk` starting at `offset`.
    ///
    /// `put` doesn't perform any automatic compression of the payload, so the caller is
    /// responsible for applying compression to the entire payload beforehand and passing chunks
    /// of the already compressed payload to this method.
    ///
    /// The returned progress contains the authoritative server offset, which should be used for
    /// subsequent requests.
    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())
    }

    /// Builds a request to write a streaming chunk of `length` bytes at `offset`.
    ///
    /// The stream must yield exactly `length` bytes. If this upload records compression,
    /// those bytes must come from the object after it has been compressed in full.
    pub fn put_stream(&self, offset: u64, length: u64, stream: ClientStream) -> PutChunkBuilder {
        self.put_body(offset, length, Body::wrap_stream(stream))
    }

    /// Builds a request to write an [`AsyncRead`] chunk of `length` bytes at `offset`.
    ///
    /// The reader must produce exactly `length` bytes. If this upload records compression,
    /// those bytes must come from the object after it has been compressed in full.
    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 {
            // A reconstructed handle cannot tell whether this is the final chunk. The backend
            // retains the authoritative total length and validates the request.
            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,
        })
    }

    /// Builds a request to cancel this upload session, discarding any uploaded bytes.
    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,
            }))
    }
}

/// A builder for [`Session::create_upload`].
#[derive(Debug)]
pub struct CreateResumableUploadBuilder {
    session: Session,
    total_length: u64,
    key: Option<ObjectKey>,
    metadata: Metadata,
}

impl CreateResumableUploadBuilder {
    /// Sets an explicit object key instead of asking the server to generate one.
    pub fn key(mut self, key: impl Into<ObjectKey>) -> Self {
        self.key = Some(key.into()).filter(|key| !key.is_empty());
        self
    }

    /// Sets the object's content type.
    pub fn content_type(mut self, content_type: impl Into<Cow<'static, str>>) -> Self {
        self.metadata.content_type = content_type.into();
        self
    }

    /// Sets the object's expiration policy.
    pub fn expiration_policy(mut self, expiration_policy: ExpirationPolicy) -> Self {
        self.metadata.expiration_policy = expiration_policy;
        self
    }

    /// Sets the compression algorithm recorded in this object's metadata.
    ///
    /// Unlike regular uploads, the client does not automatically compress chunk contents.
    /// The caller must pre-compress the object according to `compression`, then create the upload
    /// with the post-compression `object_length`. Offsets similarly refer to the bytes after compression.
    ///
    /// By default, no compression is set.
    pub fn compression(mut self, compression: impl Into<Option<Compression>>) -> Self {
        self.metadata.compression = compression.into();
        self
    }

    /// Sets the origin of the object, typically the IP address of the original source.
    pub fn origin(mut self, origin: impl Into<String>) -> Self {
        self.metadata.origin = Some(origin.into());
        self
    }

    /// Sets the filename recorded for the object.
    pub fn filename(mut self, filename: impl Into<String>) -> Self {
        self.metadata.filename = Some(filename.into());
        self
    }

    /// Replaces all custom metadata for the object.
    pub fn set_metadata(mut self, metadata: impl Into<BTreeMap<String, String>>) -> Self {
        self.metadata.custom = metadata.into();
        self
    }

    /// Adds or replaces one custom metadata entry.
    pub fn append_metadata(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
        self.metadata.custom.insert(key.into(), value.into());
        self
    }

    /// Creates the resumable upload and returns a handle bound to it.
    ///
    /// Returns `None` when the server declines resumable uploads for this object.
    /// Callers should fall back to a regular [`Session::put`] in that case.
    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))
    }
}

/// A builder for [`ResumableUpload::progress`].
#[derive(Debug)]
pub struct UploadProgressBuilder {
    upload: ResumableUpload,
}

impl UploadProgressBuilder {
    /// Queries the server's authoritative upload progress.
    ///
    /// # Errors
    ///
    /// Returns [`Error::ResumableUploadUnavailable`] when the session expired, was canceled, or
    /// could not be found. The upload must be restarted with a new session in that case.
    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
    }
}

/// A builder for [`ResumableUpload::put`], [`ResumableUpload::put_stream`], or
/// [`ResumableUpload::put_read`].
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 {
    /// Writes this chunk and returns the server's authoritative progress.
    ///
    /// # Errors
    ///
    /// Returns [`Error::UploadOffsetMismatch`] with the server's authoritative offset when
    /// `offset` differs from the server's current offset.
    /// Returns [`Error::ResumableUploadUnavailable`] when the session expired, was canceled, or
    /// could not be found. The upload must be restarted with a new session in that case.
    /// Returns [`Error::ChunkTooSmall`] before sending the request when this is known to be a
    /// non-final chunk shorter than the upload granularity.
    ///
    /// ```rust,ignore
    /// let offset = match upload.put(offset, chunk).send().await {
    ///     Ok(UploadProgress::Complete) => return Ok(()),
    ///     Ok(UploadProgress::Incomplete { offset }) => offset,
    ///     Err(Error::UploadOffsetMismatch { offset }) => offset,
    ///     Err(error @ Error::ResumableUploadUnavailable) => todo!("retry the whole upload"),
    ///     Err(error) => todo!("handle error"),
    /// };
    /// ```
    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
    }
}

/// A builder for [`ResumableUpload::cancel`].
#[derive(Debug)]
pub struct CancelUploadBuilder {
    upload: ResumableUpload,
}

impl CancelUploadBuilder {
    /// Cancels this upload and discards any bytes already uploaded.
    ///
    /// # Errors
    ///
    /// Returns [`Error::ResumableUploadUnavailable`] when the session expired, was already
    /// canceled, or could not be found.
    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,
    }
}