use std::io;
use std::sync::Arc;
use std::time::Duration;
use bytes::Bytes;
use futures_util::stream::BoxStream;
use objectstore_types::metadata::{Compression, ExpirationPolicy};
use objectstore_types::scope;
use reqwest::RequestBuilder;
use url::Url;
use crate::IntoTokenProvider;
use crate::auth::TokenProvider;
const USER_AGENT: &str = concat!("objectstore-client/", env!("CARGO_PKG_VERSION"));
#[derive(Debug)]
struct ClientBuilderInner {
service_url: Url,
propagate_traces: bool,
reqwest_builder: reqwest::ClientBuilder,
token: Option<TokenProvider>,
}
impl ClientBuilderInner {
fn apply_defaults(mut self) -> Self {
self.reqwest_builder = self
.reqwest_builder
.no_brotli()
.no_deflate()
.no_gzip()
.no_zstd();
self
}
}
#[must_use = "call .build() on this ClientBuilder to create a Client"]
#[derive(Debug)]
pub struct ClientBuilder(crate::Result<ClientBuilderInner>);
impl ClientBuilder {
pub fn new(service_url: impl reqwest::IntoUrl) -> Self {
let service_url = match service_url.into_url() {
Ok(url) => url,
Err(err) => return Self(Err(err.into())),
};
if service_url.cannot_be_a_base() {
return ClientBuilder(Err(crate::Error::InvalidUrl {
message: "service_url cannot be a base".to_owned(),
}));
}
let reqwest_builder = reqwest::Client::builder()
.connect_timeout(Duration::from_millis(100))
.user_agent(USER_AGENT);
Self(Ok(ClientBuilderInner {
service_url,
propagate_traces: false,
reqwest_builder,
token: None,
}))
}
pub fn propagate_traces(mut self, propagate_traces: bool) -> Self {
if let Ok(ref mut inner) = self.0 {
inner.propagate_traces = propagate_traces;
}
self
}
pub fn timeout(self, timeout: Duration) -> Self {
let Ok(mut inner) = self.0 else { return self };
inner.reqwest_builder = inner.reqwest_builder.read_timeout(timeout);
Self(Ok(inner))
}
pub fn configure_reqwest<F>(self, closure: F) -> Self
where
F: FnOnce(reqwest::ClientBuilder) -> reqwest::ClientBuilder,
{
let Ok(mut inner) = self.0 else { return self };
inner.reqwest_builder = closure(inner.reqwest_builder);
Self(Ok(inner))
}
pub fn token(self, token: impl IntoTokenProvider) -> Self {
let Ok(mut inner) = self.0 else { return self };
inner.token = token.into_token_provider();
Self(Ok(inner))
}
pub fn build(self) -> crate::Result<Client> {
let inner = self.0?.apply_defaults();
Ok(Client {
inner: Arc::new(ClientInner {
reqwest: inner.reqwest_builder.build()?,
service_url: inner.service_url,
propagate_traces: inner.propagate_traces,
token: inner.token,
}),
})
}
}
#[derive(Debug, Clone)]
pub struct Usecase {
name: Arc<str>,
compression: Option<Compression>,
expiration_policy: ExpirationPolicy,
}
impl Usecase {
pub fn new(name: &str) -> Self {
Self {
name: name.into(),
compression: Some(Compression::Zstd),
expiration_policy: ExpirationPolicy::default(),
}
}
#[inline]
pub fn name(&self) -> &str {
&self.name
}
#[inline]
pub fn compression(&self) -> Option<Compression> {
self.compression
}
pub fn with_compression(self, compression: impl Into<Option<Compression>>) -> Self {
Self {
compression: compression.into(),
..self
}
}
#[inline]
pub fn expiration_policy(&self) -> ExpirationPolicy {
self.expiration_policy
}
pub fn with_expiration_policy(self, expiration_policy: ExpirationPolicy) -> Self {
Self {
expiration_policy,
..self
}
}
pub fn scope(&self) -> Scope {
Scope::new(self.clone())
}
pub fn for_organization(&self, organization: u64) -> Scope {
Scope::for_organization(self.clone(), organization)
}
pub fn for_project(&self, organization: u64, project: u64) -> Scope {
Scope::for_project(self.clone(), organization, project)
}
}
#[derive(Debug)]
pub(crate) struct ScopeInner {
usecase: Usecase,
scopes: scope::Scopes,
}
impl ScopeInner {
#[inline]
pub(crate) fn usecase(&self) -> &Usecase {
&self.usecase
}
#[inline]
pub(crate) fn scopes(&self) -> &scope::Scopes {
&self.scopes
}
}
#[derive(Debug)]
pub struct Scope(pub(crate) crate::Result<ScopeInner>);
impl Scope {
pub fn new(usecase: Usecase) -> Self {
Self(Ok(ScopeInner {
usecase,
scopes: scope::Scopes::empty(),
}))
}
fn for_organization(usecase: Usecase, organization: u64) -> Self {
Self::new(usecase).push("org", organization)
}
fn for_project(usecase: Usecase, organization: u64, project: u64) -> Self {
Self::for_organization(usecase, organization).push("project", project)
}
pub fn push<V>(self, key: &str, value: V) -> Self
where
V: std::fmt::Display,
{
let result = self.0.and_then(|mut inner| {
inner.scopes.push(key, value)?;
Ok(inner)
});
Self(result)
}
pub fn session(self, client: &Client) -> crate::Result<Session> {
client.session(self)
}
}
#[derive(Debug)]
pub(crate) struct ClientInner {
reqwest: reqwest::Client,
service_url: Url,
propagate_traces: bool,
token: Option<TokenProvider>,
}
#[derive(Debug, Clone)]
pub struct Client {
inner: Arc<ClientInner>,
}
impl Client {
pub fn new(service_url: impl reqwest::IntoUrl) -> crate::Result<Client> {
ClientBuilder::new(service_url).build()
}
pub fn builder(service_url: impl reqwest::IntoUrl) -> ClientBuilder {
ClientBuilder::new(service_url)
}
pub fn session(&self, scope: Scope) -> crate::Result<Session> {
scope.0.map(|inner| Session {
scope: inner.into(),
client: self.inner.clone(),
})
}
}
#[derive(Debug, Clone)]
pub struct Session {
pub(crate) scope: Arc<ScopeInner>,
pub(crate) client: Arc<ClientInner>,
}
pub type ClientStream = BoxStream<'static, io::Result<Bytes>>;
impl Session {
pub fn object_url(&self, object_key: &str) -> Url {
let mut url = self.client.service_url.clone();
let mut segments = url.path_segments_mut().unwrap();
segments
.push("v1")
.push("objects")
.push(&self.scope.usecase.name)
.push(&self.scope.scopes.as_api_path().to_string())
.extend(object_key.split('/'));
drop(segments);
url
}
pub fn mint_token(&self) -> crate::Result<Option<String>> {
match &self.client.token {
Some(TokenProvider::Generator(generator)) => {
Ok(Some(generator.sign_for_scope(&self.scope)?))
}
Some(TokenProvider::Static(token)) => Ok(Some(token.clone())),
None => Ok(None),
}
}
fn batch_url(&self) -> Url {
let mut url = self.client.service_url.clone();
let mut segments = url.path_segments_mut().unwrap();
segments
.push("v1")
.push("objects:batch")
.push(self.scope.usecase().name())
.push(&self.scope.scopes.as_api_path().to_string())
.push(""); drop(segments);
url
}
#[cfg(feature = "multipart")]
fn multipart_url(
&self,
suffix: Option<&'static str>,
object_key: Option<&str>,
query_pairs: Option<Vec<(&str, String)>>,
) -> Url {
let mut url = self.client.service_url.clone();
let mut segments = url.path_segments_mut().unwrap();
segments
.push("v1")
.push(match suffix {
Some("parts") => "objects:multipart:parts",
Some("complete") => "objects:multipart:complete",
_ => "objects:multipart",
})
.push(&self.scope.usecase.name)
.push(&self.scope.scopes.as_api_path().to_string());
if let Some(object_key) = object_key.filter(|key| !key.is_empty()) {
segments.extend(object_key.split('/'));
}
drop(segments);
if let Some(query_pairs) = query_pairs {
let mut pairs = url.query_pairs_mut();
for (key, value) in query_pairs {
pairs.append_pair(key, &value);
}
}
url
}
fn prepare_builder(&self, mut builder: RequestBuilder) -> crate::Result<RequestBuilder> {
if let Some(token) = self.mint_token()? {
builder = builder.header("x-os-auth", format!("Bearer {token}"));
}
if self.client.propagate_traces {
let trace_headers =
sentry_core::configure_scope(|scope| Some(scope.iter_trace_propagation_headers()));
for (header_name, value) in trace_headers.into_iter().flatten() {
builder = builder.header(header_name, value);
}
}
Ok(builder)
}
pub(crate) fn request(
&self,
method: reqwest::Method,
object_key: &str,
) -> crate::Result<RequestBuilder> {
let url = self.object_url(object_key);
let builder = self.client.reqwest.request(method, url);
self.prepare_builder(builder)
}
pub(crate) fn batch_request(&self) -> crate::Result<RequestBuilder> {
let url = self.batch_url();
let builder = self.client.reqwest.post(url);
self.prepare_builder(builder)
}
#[cfg(feature = "multipart")]
pub(crate) fn multipart_request(
&self,
method: reqwest::Method,
action: Option<&'static str>,
object_key: Option<&str>,
query_pairs: Option<Vec<(&str, String)>>,
) -> crate::Result<RequestBuilder> {
let url = self.multipart_url(action, object_key, query_pairs);
let builder = self.client.reqwest.request(method, url);
self.prepare_builder(builder)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_object_url() {
let client = Client::new("http://127.0.0.1:8888/").unwrap();
let usecase = Usecase::new("testing");
let scope = usecase
.for_project(12345, 1337)
.push("app_slug", "email_app");
let session = client.session(scope).unwrap();
assert_eq!(
session.object_url("foo/bar").to_string(),
"http://127.0.0.1:8888/v1/objects/testing/org=12345;project=1337;app_slug=email_app/foo/bar"
)
}
#[test]
fn test_object_url_with_base_path() {
let client = Client::new("http://127.0.0.1:8888/api/prefix").unwrap();
let usecase = Usecase::new("testing");
let scope = usecase.for_project(12345, 1337);
let session = client.session(scope).unwrap();
assert_eq!(
session.object_url("foo/bar").to_string(),
"http://127.0.0.1:8888/api/prefix/v1/objects/testing/org=12345;project=1337/foo/bar"
)
}
}