objectstore_types/
lib.rs

1//! This is a collection of types shared among various objectstore crates.
2//!
3//! It primarily includes metadata-related structures being used by both the client and server/service
4//! components.
5
6#![warn(missing_docs)]
7#![warn(missing_debug_implementations)]
8
9use std::borrow::Cow;
10use std::collections::BTreeMap;
11use std::fmt;
12use std::str::FromStr;
13use std::time::{Duration, SystemTime};
14
15use http::header::{self, HeaderMap, HeaderName};
16use humantime::{format_duration, format_rfc3339_seconds, parse_duration};
17use serde::{Deserialize, Serialize};
18
19/// The custom HTTP header that contains the serialized [`ExpirationPolicy`].
20pub const HEADER_EXPIRATION: &str = "x-sn-expiration";
21/// The custom HTTP header that contains the serialized redirect tombstone.
22pub const HEADER_REDIRECT_TOMBSTONE: &str = "x-sn-redirect-tombstone";
23/// The prefix for custom HTTP headers containing custom per-object metadata.
24pub const HEADER_META_PREFIX: &str = "x-snme-";
25
26/// The default content type for objects without a known content type.
27pub const DEFAULT_CONTENT_TYPE: &str = "application/octet-stream";
28
29/// Errors that can happen dealing with metadata
30#[derive(Debug, thiserror::Error)]
31pub enum Error {
32    /// Any problems dealing with http headers, essentially converting to/from [`str`].
33    #[error("error dealing with http headers")]
34    Header(#[from] Option<http::Error>),
35    /// The value for the expiration policy is invalid.
36    #[error("invalid expiration policy value")]
37    InvalidExpiration(#[from] Option<humantime::DurationError>),
38    /// The compression algorithm is invalid.
39    #[error("invalid compression value")]
40    InvalidCompression,
41    /// The content type is invalid.
42    #[error("invalid content type")]
43    InvalidContentType(#[from] mediatype::MediaTypeError),
44}
45impl From<http::header::InvalidHeaderValue> for Error {
46    fn from(err: http::header::InvalidHeaderValue) -> Self {
47        Self::Header(Some(err.into()))
48    }
49}
50impl From<http::header::InvalidHeaderName> for Error {
51    fn from(err: http::header::InvalidHeaderName) -> Self {
52        Self::Header(Some(err.into()))
53    }
54}
55impl From<http::header::ToStrError> for Error {
56    fn from(_err: http::header::ToStrError) -> Self {
57        // the error happens when converting a header value back to a `str`
58        Self::Header(None)
59    }
60}
61
62/// The per-object expiration policy
63///
64/// We support automatic time-to-live and time-to-idle policies.
65/// Setting this to `Manual` means that the object has no automatic policy, and will not be
66/// garbage-collected automatically. It essentially lives forever until manually deleted.
67#[derive(Debug, Default, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
68pub enum ExpirationPolicy {
69    /// Manual expiration, meaning no automatic cleanup.
70    // IMPORTANT: Do not change the default, we rely on this for persisted objects.
71    #[default]
72    Manual,
73    /// Time to live, with expiration after the specified duration.
74    TimeToLive(Duration),
75    /// Time to idle, with expiration once the object has not been accessed within the specified duration.
76    TimeToIdle(Duration),
77}
78impl ExpirationPolicy {
79    /// Returns the duration after which the object expires.
80    pub fn expires_in(&self) -> Option<Duration> {
81        match self {
82            ExpirationPolicy::Manual => None,
83            ExpirationPolicy::TimeToLive(duration) => Some(*duration),
84            ExpirationPolicy::TimeToIdle(duration) => Some(*duration),
85        }
86    }
87
88    /// Returns `true` if this policy indicates time-based expiry.
89    pub fn is_timeout(&self) -> bool {
90        match self {
91            ExpirationPolicy::TimeToLive(_) => true,
92            ExpirationPolicy::TimeToIdle(_) => true,
93            ExpirationPolicy::Manual => false,
94        }
95    }
96
97    /// Returns `true` if this policy is `Manual`.
98    pub fn is_manual(&self) -> bool {
99        *self == ExpirationPolicy::Manual
100    }
101}
102impl fmt::Display for ExpirationPolicy {
103    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
104        match self {
105            ExpirationPolicy::TimeToLive(duration) => {
106                write!(f, "ttl:{}", format_duration(*duration))
107            }
108            ExpirationPolicy::TimeToIdle(duration) => {
109                write!(f, "tti:{}", format_duration(*duration))
110            }
111            ExpirationPolicy::Manual => f.write_str("manual"),
112        }
113    }
114}
115impl FromStr for ExpirationPolicy {
116    type Err = Error;
117
118    fn from_str(s: &str) -> Result<Self, Self::Err> {
119        if s == "manual" {
120            return Ok(ExpirationPolicy::Manual);
121        }
122        if let Some(duration) = s.strip_prefix("ttl:") {
123            return Ok(ExpirationPolicy::TimeToLive(parse_duration(duration)?));
124        }
125        if let Some(duration) = s.strip_prefix("tti:") {
126            return Ok(ExpirationPolicy::TimeToIdle(parse_duration(duration)?));
127        }
128        Err(Error::InvalidExpiration(None))
129    }
130}
131
132/// The compression algorithm of an object to upload.
133#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
134pub enum Compression {
135    /// Compressed using `zstd`.
136    Zstd,
137    // /// Compressed using `gzip`.
138    // Gzip,
139    // /// Compressed using `lz4`.
140    // Lz4,
141}
142
143impl Compression {
144    /// Returns a string representation of the compression algorithm.
145    pub fn as_str(&self) -> &str {
146        match self {
147            Compression::Zstd => "zstd",
148            // Compression::Gzip => "gzip",
149            // Compression::Lz4 => "lz4",
150        }
151    }
152}
153
154impl fmt::Display for Compression {
155    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
156        f.write_str(self.as_str())
157    }
158}
159
160impl FromStr for Compression {
161    type Err = Error;
162
163    fn from_str(s: &str) -> Result<Self, Self::Err> {
164        match s {
165            "zstd" => Ok(Compression::Zstd),
166            // "gzip" => Compression::Gzip,
167            // "lz4" => Compression::Lz4,
168            _ => Err(Error::InvalidCompression),
169        }
170    }
171}
172
173/// Per-object Metadata.
174///
175/// This includes special metadata like the expiration policy and compression used,
176/// as well as arbitrary user-provided metadata.
177#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
178#[serde(default)]
179pub struct Metadata {
180    /// The object/metadata denotes a "redirect key".
181    ///
182    /// This means that this particular object is just a tombstone, and the real thing
183    /// is rather found on the other backend.
184    /// In practice this means that the tombstone is stored on the "HighVolume" backend,
185    /// to avoid unnecessarily slow "not found" requests on the "LongTerm" backend.
186    #[serde(skip_serializing_if = "Option::is_none")]
187    pub is_redirect_tombstone: Option<bool>,
188
189    /// The expiration policy of the object.
190    #[serde(skip_serializing_if = "ExpirationPolicy::is_manual")]
191    pub expiration_policy: ExpirationPolicy,
192
193    /// The content type of the object, if known.
194    pub content_type: Cow<'static, str>,
195
196    /// The compression algorithm used for this object, if any.
197    #[serde(skip_serializing_if = "Option::is_none")]
198    pub compression: Option<Compression>,
199
200    /// Size of the data in bytes, if known.
201    #[serde(skip_serializing_if = "Option::is_none")]
202    pub size: Option<usize>,
203
204    /// Some arbitrary user-provided metadata.
205    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
206    pub custom: BTreeMap<String, String>,
207}
208
209impl Metadata {
210    /// Extracts metadata from the given [`HeaderMap`].
211    ///
212    /// A prefix can be also be provided which is being stripped from custom non-standard headers.
213    pub fn from_headers(headers: &HeaderMap, prefix: &str) -> Result<Self, Error> {
214        let mut metadata = Metadata::default();
215
216        for (name, value) in headers {
217            match *name {
218                // standard HTTP headers
219                header::CONTENT_TYPE => {
220                    let content_type = value.to_str()?;
221                    validate_content_type(content_type)?;
222                    metadata.content_type = content_type.to_owned().into();
223                }
224                header::CONTENT_ENCODING => {
225                    let compression = value.to_str()?;
226                    metadata.compression = Some(Compression::from_str(compression)?);
227                }
228                _ => {
229                    let Some(name) = name.as_str().strip_prefix(prefix) else {
230                        continue;
231                    };
232
233                    match name {
234                        // Objectstore first-class metadata
235                        HEADER_EXPIRATION => {
236                            let expiration_policy = value.to_str()?;
237                            metadata.expiration_policy =
238                                ExpirationPolicy::from_str(expiration_policy)?;
239                        }
240                        HEADER_REDIRECT_TOMBSTONE => {
241                            if value.to_str()? == "true" {
242                                metadata.is_redirect_tombstone = Some(true);
243                            }
244                        }
245                        _ => {
246                            // customer-provided metadata
247                            if let Some(name) = name.strip_prefix(HEADER_META_PREFIX) {
248                                let value = value.to_str()?;
249                                metadata.custom.insert(name.into(), value.into());
250                            }
251                        }
252                    }
253                }
254            }
255        }
256
257        Ok(metadata)
258    }
259
260    /// Turns the metadata into a [`HeaderMap`].
261    ///
262    /// It will prefix any non-standard headers with the given `prefix`.
263    /// If the `with_expiration` parameter is set, it will additionally resolve the expiration policy
264    /// into a specific RFC3339 datetime, and set that as the `Custom-Time` header.
265    pub fn to_headers(&self, prefix: &str, with_expiration: bool) -> Result<HeaderMap, Error> {
266        let Self {
267            is_redirect_tombstone,
268            content_type,
269            compression,
270            expiration_policy,
271            size: _,
272            custom,
273        } = self;
274
275        let mut headers = HeaderMap::new();
276
277        // standard headers
278        headers.append(header::CONTENT_TYPE, content_type.parse()?);
279        if let Some(compression) = compression {
280            headers.append(header::CONTENT_ENCODING, compression.as_str().parse()?);
281        }
282
283        // Objectstore first-class metadata
284        if matches!(is_redirect_tombstone, Some(true)) {
285            let name = HeaderName::try_from(format!("{prefix}{HEADER_REDIRECT_TOMBSTONE}"))?;
286            headers.append(name, "true".parse()?);
287        }
288        if *expiration_policy != ExpirationPolicy::Manual {
289            let name = HeaderName::try_from(format!("{prefix}{HEADER_EXPIRATION}"))?;
290            headers.append(name, expiration_policy.to_string().parse()?);
291            if with_expiration {
292                let expires_in = expiration_policy.expires_in().unwrap_or_default();
293                let expires_at = format_rfc3339_seconds(SystemTime::now() + expires_in);
294                headers.append("x-goog-custom-time", expires_at.to_string().parse()?);
295            }
296        }
297
298        // customer-provided metadata
299        for (key, value) in custom {
300            let name = HeaderName::try_from(format!("{prefix}{HEADER_META_PREFIX}{key}"))?;
301            headers.append(name, value.parse()?);
302        }
303
304        Ok(headers)
305    }
306}
307
308/// Validates that `content_type` is a valid [IANA Media
309/// Type](https://www.iana.org/assignments/media-types/media-types.xhtml).
310fn validate_content_type(content_type: &str) -> Result<(), Error> {
311    mediatype::MediaType::parse(content_type)?;
312    Ok(())
313}
314
315impl Default for Metadata {
316    fn default() -> Self {
317        Self {
318            is_redirect_tombstone: None,
319            expiration_policy: ExpirationPolicy::Manual,
320            content_type: DEFAULT_CONTENT_TYPE.into(),
321            compression: None,
322            size: None,
323            custom: BTreeMap::new(),
324        }
325    }
326}