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}