Skip to main content

objectstore_client/
expiry.rs

1use std::time::{Duration, SystemTime};
2
3use objectstore_types::metadata::{self, ExpiryAnchor, MetadataUpdate};
4use objectstore_types::time::Timestamp;
5use reqwest::StatusCode;
6
7use crate::response::ResponseExt as _;
8use crate::{ObjectKey, Session};
9
10/// A requested minimum expiration deadline.
11#[derive(Clone, Copy, Debug, PartialEq, Eq)]
12pub enum ExpiryExtension {
13    /// An absolute deadline, rounded upward to a whole second.
14    At(SystemTime),
15    /// Total lifetime since the object's creation or most recent replacement.
16    ///
17    /// Fractional seconds are truncated when sent to the server.
18    FromCreation(Duration),
19    /// Lifetime from the start of this request on the server.
20    ///
21    /// Fractional seconds are truncated. Retrying establishes a new server-time
22    /// anchor and can extend the deadline further.
23    FromNow(Duration),
24}
25
26impl ExpiryExtension {
27    fn into_update(self) -> crate::Result<MetadataUpdate> {
28        let extend_expiry = match self {
29            Self::At(at) => metadata::ExpiryExtension::At {
30                at: Timestamp::try_from(at)
31                    .map_err(metadata::Error::ExpirationTime)?
32                    .as_rfc3339(),
33            },
34            Self::FromCreation(after) => metadata::ExpiryExtension::After {
35                after,
36                from: ExpiryAnchor::Creation,
37            },
38            Self::FromNow(after) => metadata::ExpiryExtension::After {
39                after,
40                from: ExpiryAnchor::Now,
41            },
42        };
43        Ok(MetadataUpdate { extend_expiry })
44    }
45}
46
47/// The outcome of a [`Session::extend_expiry`] call.
48#[derive(Clone, Copy, Debug, PartialEq, Eq)]
49pub enum ExtendExpiryResponse {
50    /// The deadline was extended or already satisfied the request.
51    ///
52    /// Does not report whether the deadline changed or its stored value.
53    Satisfied,
54    /// The object was observed to be absent or expired.
55    NotFound,
56    /// The extension could not be satisfied.
57    ///
58    /// The object is non-expiring, lacks creation metadata required by a
59    /// creation-relative target, or conflicts with a conditional update.
60    /// A conflict can result from concurrent deletion, so this does not
61    /// guarantee that the object still exists.
62    Rejected,
63}
64
65impl Session {
66    /// Extends an object's expiration deadline while preserving its payload.
67    ///
68    /// An already-sufficient deadline succeeds without being shortened. Relative
69    /// targets are resolved by the server, not added to the existing deadline.
70    /// This requires object-write permission.
71    ///
72    /// Actual extensions adjust TTL duration to approximately match the total lifetime
73    /// since creation; TTI duration remains unchanged.
74    ///
75    /// Returns [`ExtendExpiryResponse`] to distinguish a satisfied request, an
76    /// absent or expired object, and a rejected extension. Other HTTP and transport
77    /// errors are returned through [`crate::Error::Reqwest`].
78    ///
79    /// ```no_run
80    /// # async fn example(session: objectstore_client::Session) -> objectstore_client::Result<()> {
81    /// use std::time::Duration;
82    /// use objectstore_client::{ExpiryExtension, ExtendExpiryResponse};
83    ///
84    /// match session.extend_expiry("key", ExpiryExtension::FromNow(Duration::from_secs(86400)))
85    ///     .send().await?
86    /// {
87    ///     ExtendExpiryResponse::Satisfied => println!("Deadline satisfied"),
88    ///     ExtendExpiryResponse::NotFound => println!("Object is missing or expired"),
89    ///     ExtendExpiryResponse::Rejected => println!("Extension was rejected"),
90    /// }
91    /// # Ok(())
92    /// # }
93    /// ```
94    pub fn extend_expiry(&self, key: &str, target: ExpiryExtension) -> ExtendExpiryBuilder {
95        ExtendExpiryBuilder {
96            session: self.clone(),
97            key: key.to_owned(),
98            target,
99        }
100    }
101}
102
103/// A [`Session::extend_expiry`] request builder.
104#[derive(Debug)]
105pub struct ExtendExpiryBuilder {
106    session: Session,
107    key: ObjectKey,
108    target: ExpiryExtension,
109}
110
111impl ExtendExpiryBuilder {
112    /// Sends the extension request, failing locally for an out-of-range timestamp.
113    pub async fn send(self) -> crate::Result<ExtendExpiryResponse> {
114        let update = self.target.into_update()?;
115        let response = self
116            .session
117            .request(reqwest::Method::PATCH, &self.key)?
118            .json(&update)
119            .send()
120            .await?;
121        let outcome = match response.status() {
122            StatusCode::NOT_FOUND => ExtendExpiryResponse::NotFound,
123            StatusCode::CONFLICT => ExtendExpiryResponse::Rejected,
124            _ => {
125                response
126                    .error_for_status_and_drain()
127                    .await?
128                    .drain_body()
129                    .await;
130                return Ok(ExtendExpiryResponse::Satisfied);
131            }
132        };
133        response.drain_body().await;
134        Ok(outcome)
135    }
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141
142    #[test]
143    fn wire_targets() {
144        for (target, expected) in [
145            (
146                ExpiryExtension::At(SystemTime::UNIX_EPOCH + Duration::from_millis(1500)),
147                metadata::ExpiryExtension::At {
148                    at: "1970-01-01T00:00:02Z".parse().unwrap(),
149                },
150            ),
151            (
152                ExpiryExtension::FromCreation(Duration::from_millis(1500)),
153                metadata::ExpiryExtension::After {
154                    after: Duration::from_millis(1500),
155                    from: ExpiryAnchor::Creation,
156                },
157            ),
158            (
159                ExpiryExtension::FromNow(Duration::ZERO),
160                metadata::ExpiryExtension::After {
161                    after: Duration::ZERO,
162                    from: ExpiryAnchor::Now,
163                },
164            ),
165        ] {
166            assert_eq!(target.into_update().unwrap().extend_expiry, expected);
167        }
168    }
169
170    #[test]
171    fn rejects_out_of_range_timestamps() {
172        for at in [
173            SystemTime::UNIX_EPOCH - Duration::from_secs(1),
174            SystemTime::UNIX_EPOCH + Duration::from_secs(253_402_300_800),
175        ] {
176            assert!(matches!(
177                ExpiryExtension::At(at).into_update(),
178                Err(crate::Error::Metadata(metadata::Error::ExpirationTime(_)))
179            ));
180        }
181    }
182}