use crate::{Result, path::Path};
use async_trait::async_trait;
use std::{fmt, time::Duration};
pub use http::Method;
pub use http::{HeaderMap, HeaderName, HeaderValue};
pub use url::Url;
#[derive(Debug, Clone, Default)]
#[non_exhaustive]
pub struct SignedUrlOptions {
pub extra_query: Vec<(String, String)>,
pub signed_headers: HeaderMap,
}
impl SignedUrlOptions {
pub fn new() -> Self {
Self::default()
}
pub fn with_query<I, K, V>(mut self, query: I) -> Self
where
I: IntoIterator<Item = (K, V)>,
K: Into<String>,
V: Into<String>,
{
self.extra_query
.extend(query.into_iter().map(|(k, v)| (k.into(), v.into())));
self
}
pub fn with_signed_header(mut self, name: HeaderName, value: HeaderValue) -> Self {
self.signed_headers.append(name, value);
self
}
pub fn is_empty(&self) -> bool {
self.extra_query.is_empty() && self.signed_headers.is_empty()
}
}
#[async_trait]
pub trait Signer: Send + Sync + fmt::Debug + 'static {
async fn signed_url(&self, method: Method, path: &Path, expires_in: Duration) -> Result<Url>;
async fn signed_url_opts(
&self,
method: Method,
path: &Path,
expires_in: Duration,
options: &SignedUrlOptions,
) -> Result<Url> {
if options.is_empty() {
return self.signed_url(method, path, expires_in).await;
}
Err(crate::Error::NotSupported {
source: "this object store does not support signing URLs with additional \
query parameters or headers"
.into(),
})
}
async fn signed_urls(
&self,
method: Method,
paths: &[Path],
expires_in: Duration,
) -> Result<Vec<Url>> {
let mut urls = Vec::with_capacity(paths.len());
for path in paths {
urls.push(self.signed_url(method.clone(), path, expires_in).await?);
}
Ok(urls)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Error;
use http::header::CONTENT_TYPE;
#[derive(Debug)]
struct MinimalSigner;
#[async_trait]
impl Signer for MinimalSigner {
async fn signed_url(
&self,
_method: Method,
path: &Path,
_expires_in: Duration,
) -> Result<Url> {
Ok(Url::parse(&format!("https://example.com/{path}")).unwrap())
}
}
#[tokio::test]
async fn default_signed_url_opts_delegates_when_empty() {
let signer = MinimalSigner;
let url = signer
.signed_url_opts(
Method::GET,
&Path::from("file.txt"),
Duration::from_secs(60),
&SignedUrlOptions::default(),
)
.await
.unwrap();
assert_eq!(url.as_str(), "https://example.com/file.txt");
}
#[tokio::test]
async fn default_signed_url_opts_rejects_extras() {
let signer = MinimalSigner;
let query_err = signer
.signed_url_opts(
Method::PUT,
&Path::from("file.txt"),
Duration::from_secs(60),
&SignedUrlOptions::default().with_query([("partNumber", "1")]),
)
.await
.unwrap_err();
assert!(matches!(query_err, Error::NotSupported { .. }));
let header_err = signer
.signed_url_opts(
Method::PUT,
&Path::from("file.txt"),
Duration::from_secs(60),
&SignedUrlOptions::default()
.with_signed_header(CONTENT_TYPE, HeaderValue::from_static("text/plain")),
)
.await
.unwrap_err();
assert!(matches!(header_err, Error::NotSupported { .. }));
}
}