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
use std::time::{Duration, SystemTime};
use objectstore_types::metadata::{self, ExpiryAnchor, MetadataUpdate};
use objectstore_types::time::Timestamp;
use reqwest::StatusCode;
use crate::response::ResponseExt as _;
use crate::{ObjectKey, Session};
/// A requested minimum expiration deadline.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ExpiryExtension {
/// An absolute deadline, rounded upward to a whole second.
At(SystemTime),
/// Total lifetime since the object's creation or most recent replacement.
///
/// Fractional seconds are truncated when sent to the server.
FromCreation(Duration),
/// Lifetime from the start of this request on the server.
///
/// Fractional seconds are truncated. Retrying establishes a new server-time
/// anchor and can extend the deadline further.
FromNow(Duration),
}
impl ExpiryExtension {
fn into_update(self) -> crate::Result<MetadataUpdate> {
let extend_expiry = match self {
Self::At(at) => metadata::ExpiryExtension::At {
at: Timestamp::try_from(at)
.map_err(metadata::Error::ExpirationTime)?
.as_rfc3339(),
},
Self::FromCreation(after) => metadata::ExpiryExtension::After {
after,
from: ExpiryAnchor::Creation,
},
Self::FromNow(after) => metadata::ExpiryExtension::After {
after,
from: ExpiryAnchor::Now,
},
};
Ok(MetadataUpdate { extend_expiry })
}
}
/// The outcome of a [`Session::extend_expiry`] call.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ExtendExpiryResponse {
/// The deadline was extended or already satisfied the request.
///
/// Does not report whether the deadline changed or its stored value.
Satisfied,
/// The object was observed to be absent or expired.
NotFound,
/// The extension could not be satisfied.
///
/// The object is non-expiring, lacks creation metadata required by a
/// creation-relative target, or conflicts with a conditional update.
/// A conflict can result from concurrent deletion, so this does not
/// guarantee that the object still exists.
Rejected,
}
impl Session {
/// Extends an object's expiration deadline while preserving its payload.
///
/// An already-sufficient deadline succeeds without being shortened. Relative
/// targets are resolved by the server, not added to the existing deadline.
/// This requires object-write permission.
///
/// Actual extensions adjust TTL duration to approximately match the total lifetime
/// since creation; TTI duration remains unchanged.
///
/// Returns [`ExtendExpiryResponse`] to distinguish a satisfied request, an
/// absent or expired object, and a rejected extension. Other HTTP and transport
/// errors are returned through [`crate::Error::Reqwest`].
///
/// ```no_run
/// # async fn example(session: objectstore_client::Session) -> objectstore_client::Result<()> {
/// use std::time::Duration;
/// use objectstore_client::{ExpiryExtension, ExtendExpiryResponse};
///
/// match session.extend_expiry("key", ExpiryExtension::FromNow(Duration::from_secs(86400)))
/// .send().await?
/// {
/// ExtendExpiryResponse::Satisfied => println!("Deadline satisfied"),
/// ExtendExpiryResponse::NotFound => println!("Object is missing or expired"),
/// ExtendExpiryResponse::Rejected => println!("Extension was rejected"),
/// }
/// # Ok(())
/// # }
/// ```
pub fn extend_expiry(&self, key: &str, target: ExpiryExtension) -> ExtendExpiryBuilder {
ExtendExpiryBuilder {
session: self.clone(),
key: key.to_owned(),
target,
}
}
}
/// A [`Session::extend_expiry`] request builder.
#[derive(Debug)]
pub struct ExtendExpiryBuilder {
session: Session,
key: ObjectKey,
target: ExpiryExtension,
}
impl ExtendExpiryBuilder {
/// Sends the extension request, failing locally for an out-of-range timestamp.
pub async fn send(self) -> crate::Result<ExtendExpiryResponse> {
let update = self.target.into_update()?;
let response = self
.session
.request(reqwest::Method::PATCH, &self.key)?
.json(&update)
.send()
.await?;
let outcome = match response.status() {
StatusCode::NOT_FOUND => ExtendExpiryResponse::NotFound,
StatusCode::CONFLICT => ExtendExpiryResponse::Rejected,
_ => {
response
.error_for_status_and_drain()
.await?
.drain_body()
.await;
return Ok(ExtendExpiryResponse::Satisfied);
}
};
response.drain_body().await;
Ok(outcome)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn wire_targets() {
for (target, expected) in [
(
ExpiryExtension::At(SystemTime::UNIX_EPOCH + Duration::from_millis(1500)),
metadata::ExpiryExtension::At {
at: "1970-01-01T00:00:02Z".parse().unwrap(),
},
),
(
ExpiryExtension::FromCreation(Duration::from_millis(1500)),
metadata::ExpiryExtension::After {
after: Duration::from_millis(1500),
from: ExpiryAnchor::Creation,
},
),
(
ExpiryExtension::FromNow(Duration::ZERO),
metadata::ExpiryExtension::After {
after: Duration::ZERO,
from: ExpiryAnchor::Now,
},
),
] {
assert_eq!(target.into_update().unwrap().extend_expiry, expected);
}
}
#[test]
fn rejects_out_of_range_timestamps() {
for at in [
SystemTime::UNIX_EPOCH - Duration::from_secs(1),
SystemTime::UNIX_EPOCH + Duration::from_secs(253_402_300_800),
] {
assert!(matches!(
ExpiryExtension::At(at).into_update(),
Err(crate::Error::Metadata(metadata::Error::ExpirationTime(_)))
));
}
}
}