1#![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
19pub const HEADER_EXPIRATION: &str = "x-sn-expiration";
21pub const HEADER_REDIRECT_TOMBSTONE: &str = "x-sn-redirect-tombstone";
23pub const HEADER_META_PREFIX: &str = "x-snme-";
25
26pub const DEFAULT_CONTENT_TYPE: &str = "application/octet-stream";
28
29#[derive(Debug, thiserror::Error)]
31pub enum Error {
32 #[error("error dealing with http headers")]
34 Header(#[from] Option<http::Error>),
35 #[error("invalid expiration policy value")]
37 InvalidExpiration(#[from] Option<humantime::DurationError>),
38 #[error("invalid compression value")]
40 InvalidCompression,
41 #[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 Self::Header(None)
59 }
60}
61
62#[derive(Debug, Default, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
68pub enum ExpirationPolicy {
69 #[default]
72 Manual,
73 TimeToLive(Duration),
75 TimeToIdle(Duration),
77}
78impl ExpirationPolicy {
79 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 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 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#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
134pub enum Compression {
135 Zstd,
137 }
142
143impl Compression {
144 pub fn as_str(&self) -> &str {
146 match self {
147 Compression::Zstd => "zstd",
148 }
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 _ => Err(Error::InvalidCompression),
169 }
170 }
171}
172
173#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
178#[serde(default)]
179pub struct Metadata {
180 #[serde(skip_serializing_if = "Option::is_none")]
187 pub is_redirect_tombstone: Option<bool>,
188
189 #[serde(skip_serializing_if = "ExpirationPolicy::is_manual")]
191 pub expiration_policy: ExpirationPolicy,
192
193 pub content_type: Cow<'static, str>,
195
196 #[serde(skip_serializing_if = "Option::is_none")]
198 pub compression: Option<Compression>,
199
200 #[serde(skip_serializing_if = "Option::is_none")]
202 pub size: Option<usize>,
203
204 #[serde(skip_serializing_if = "BTreeMap::is_empty")]
206 pub custom: BTreeMap<String, String>,
207}
208
209impl Metadata {
210 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 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 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 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 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 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 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 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
308fn 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}