Skip to main content

objectstore_client/
client.rs

1use std::io;
2use std::sync::Arc;
3use std::time::Duration;
4
5use bytes::Bytes;
6use futures_util::stream::BoxStream;
7use objectstore_types::metadata::{Compression, ExpirationPolicy};
8use objectstore_types::scope;
9use objectstore_types::usecase::{InvalidUsecaseName, Usecase as UsecaseName};
10use reqwest::RequestBuilder;
11use url::Url;
12
13use crate::IntoTokenProvider;
14use crate::auth::{TokenProvider, TokenRequest};
15
16const USER_AGENT: &str = concat!("objectstore-client/", env!("CARGO_PKG_VERSION"));
17
18#[derive(Debug)]
19struct ClientBuilderInner {
20    service_url: Url,
21    propagate_traces: bool,
22    reqwest_builder: reqwest::ClientBuilder,
23    token: Option<TokenProvider>,
24}
25
26impl ClientBuilderInner {
27    /// Applies defaults that cannot be overridden by the caller.
28    fn apply_defaults(mut self) -> Self {
29        self.reqwest_builder = self
30            .reqwest_builder
31            // hickory-dns: Controlled by the `reqwest/hickory-dns` feature flag
32            // we are dealing with de/compression ourselves:
33            .no_brotli()
34            .no_deflate()
35            .no_gzip()
36            .no_zstd();
37        self
38    }
39}
40
41/// Builder to create a [`Client`].
42#[must_use = "call .build() on this ClientBuilder to create a Client"]
43#[derive(Debug)]
44pub struct ClientBuilder(crate::Result<ClientBuilderInner>);
45
46impl ClientBuilder {
47    /// Creates a new [`ClientBuilder`], configured with the given `service_url`.
48    ///
49    /// To perform CRUD operations, one has to create a [`Client`], and then scope it to a [`Usecase`]
50    /// and Scope in order to create a [`Session`].
51    pub fn new(service_url: impl reqwest::IntoUrl) -> Self {
52        let service_url = match service_url.into_url() {
53            Ok(url) => url,
54            Err(err) => return Self(Err(err.into())),
55        };
56        if service_url.cannot_be_a_base() {
57            return ClientBuilder(Err(crate::Error::InvalidUrl {
58                message: "service_url cannot be a base".to_owned(),
59            }));
60        }
61
62        let reqwest_builder = reqwest::Client::builder()
63            // We define just a connection timeout by default but do not limit reads. A connect
64            // timeout of 100ms is still very conservative, but should provide a sensible upper
65            // bound to expected request latencies.
66            .connect_timeout(Duration::from_millis(100))
67            .user_agent(USER_AGENT);
68
69        Self(Ok(ClientBuilderInner {
70            service_url,
71            propagate_traces: false,
72            reqwest_builder,
73            token: None,
74        }))
75    }
76
77    /// Changes whether the `sentry-trace` header will be sent to Objectstore
78    /// to take advantage of Sentry's distributed tracing.
79    ///
80    /// By default, tracing headers will not be propagated.
81    pub fn propagate_traces(mut self, propagate_traces: bool) -> Self {
82        if let Ok(ref mut inner) = self.0 {
83            inner.propagate_traces = propagate_traces;
84        }
85        self
86    }
87
88    /// Defines a read timeout for the [`reqwest::Client`].
89    ///
90    /// The read timeout is defined to be "between consecutive read operations", for example between
91    /// chunks of a streaming response. For more fine-grained configuration of this and other
92    /// timeouts, use [`Self::configure_reqwest`].
93    ///
94    /// By default, no read timeout and a connect timeout of 100ms is set.
95    pub fn timeout(self, timeout: Duration) -> Self {
96        let Ok(mut inner) = self.0 else { return self };
97        inner.reqwest_builder = inner.reqwest_builder.read_timeout(timeout);
98        Self(Ok(inner))
99    }
100
101    /// Calls the closure with the underlying [`reqwest::ClientBuilder`].
102    ///
103    /// By default, the ClientBuilder is configured to create a reqwest Client with a connect and read timeout of 500ms and a user agent identifying this library.
104    pub fn configure_reqwest<F>(self, closure: F) -> Self
105    where
106        F: FnOnce(reqwest::ClientBuilder) -> reqwest::ClientBuilder,
107    {
108        let Ok(mut inner) = self.0 else { return self };
109        inner.reqwest_builder = closure(inner.reqwest_builder);
110        Self(Ok(inner))
111    }
112
113    /// Sets the authentication token to use for requests to Objectstore.
114    ///
115    /// Accepts anything that implements [`IntoTokenProvider`]:
116    /// - A [`TokenGenerator`](crate::TokenGenerator) — for internal services that have access to
117    ///   an EdDSA keypair. The generator signs a fresh JWT for each request.
118    /// - A `String` or `&str` — a pre-signed JWT, used as-is for every request.
119    /// - An `Option` of any of the above — a `None` leaves the client unauthenticated, which is
120    ///   convenient when authentication is configured conditionally.
121    pub fn token(self, token: impl IntoTokenProvider) -> Self {
122        let Ok(mut inner) = self.0 else { return self };
123        inner.token = token.into_token_provider();
124        Self(Ok(inner))
125    }
126
127    /// Returns a [`Client`] that uses this [`ClientBuilder`] configuration.
128    ///
129    /// # Errors
130    ///
131    /// This method fails if:
132    /// - the given `service_url` is invalid or cannot be used as a base URL
133    /// - the [`reqwest::Client`] fails to build. Refer to [`reqwest::ClientBuilder::build`] for
134    ///   more information on when this can happen.
135    pub fn build(self) -> crate::Result<Client> {
136        let inner = self.0?.apply_defaults();
137
138        Ok(Client {
139            inner: Arc::new(ClientInner {
140                reqwest: inner.reqwest_builder.build()?,
141                service_url: inner.service_url,
142                propagate_traces: inner.propagate_traces,
143                token: inner.token,
144            }),
145        })
146    }
147}
148
149/// An identifier for a workload in Objectstore, along with defaults to use for all
150/// operations within that Usecase.
151///
152/// Usecases need to be statically defined in Objectstore's configuration server-side.
153/// Objectstore can make decisions based on the Usecase. For example, choosing the most
154/// suitable storage backend.
155///
156/// Names must be 3–64 ASCII characters from `[A-Za-z0-9_-]`. Any permitted
157/// character may appear in any position. Names are case-sensitive and are never
158/// normalized.
159#[derive(Debug, Clone)]
160pub struct Usecase {
161    name: Arc<UsecaseName>,
162    compression: Option<Compression>,
163    expiration_policy: ExpirationPolicy,
164}
165
166impl Usecase {
167    /// Creates a Usecase for a statically defined name.
168    ///
169    /// Use [`Self::try_new`] for dynamic names that may fail validation.
170    ///
171    /// # Panics
172    ///
173    /// Panics if the name violates the naming rules on [`Usecase`].
174    pub fn new(name: &str) -> Self {
175        Self::try_new(name).expect("invalid usecase name")
176    }
177
178    /// Creates a Usecase for a dynamic name, returning any validation error.
179    ///
180    /// Use [`Self::new`] for statically defined names.
181    ///
182    /// # Errors
183    ///
184    /// Returns [`InvalidUsecaseName`] if the name violates the naming rules on
185    /// [`Usecase`].
186    pub fn try_new(name: &str) -> Result<Self, InvalidUsecaseName> {
187        Ok(Self {
188            name: Arc::new(UsecaseName::new(name)?),
189            compression: Some(Compression::Zstd),
190            expiration_policy: ExpirationPolicy::default(),
191        })
192    }
193
194    /// Returns the name of this usecase.
195    #[inline]
196    pub fn name(&self) -> &str {
197        self.name.as_str()
198    }
199
200    /// Returns the compression algorithm to use for operations within this usecase.
201    #[inline]
202    pub fn compression(&self) -> Option<Compression> {
203        self.compression
204    }
205
206    /// Sets the compression algorithm to use for operations within this usecase.
207    ///
208    /// It's still possible to override this default on each operation's builder.
209    ///
210    /// By default, [`Compression::Zstd`] is used. Pass [`None`] to disable compression.
211    pub fn with_compression(self, compression: impl Into<Option<Compression>>) -> Self {
212        Self {
213            compression: compression.into(),
214            ..self
215        }
216    }
217
218    /// Returns the expiration policy to use by default for operations within this usecase.
219    #[inline]
220    pub fn expiration_policy(&self) -> ExpirationPolicy {
221        self.expiration_policy
222    }
223
224    /// Sets the expiration policy to use for operations within this usecase.
225    ///
226    /// It's still possible to override this default on each operation's builder.
227    ///
228    /// By default, [`ExpirationPolicy::Manual`] is used, meaning that objects won't automatically
229    /// expire.
230    pub fn with_expiration_policy(self, expiration_policy: ExpirationPolicy) -> Self {
231        Self {
232            expiration_policy,
233            ..self
234        }
235    }
236
237    /// Creates a new custom [`Scope`].
238    ///
239    /// Add parts to it using [`Scope::push`].
240    ///
241    /// Generally, [`Usecase::for_organization`] and [`Usecase::for_project`] should fit most usecases,
242    /// so prefer using those methods rather than creating your own custom [`Scope`].
243    pub fn scope(&self) -> Scope {
244        Scope::new(self.clone())
245    }
246
247    /// Creates a new [`Scope`] tied to the given organization.
248    pub fn for_organization(&self, organization: u64) -> Scope {
249        Scope::for_organization(self.clone(), organization)
250    }
251
252    /// Creates a new [`Scope`] tied to the given organization and project.
253    pub fn for_project(&self, organization: u64, project: u64) -> Scope {
254        Scope::for_project(self.clone(), organization, project)
255    }
256}
257
258#[derive(Debug)]
259pub(crate) struct ScopeInner {
260    usecase: Usecase,
261    scopes: scope::Scopes,
262}
263
264impl ScopeInner {
265    #[inline]
266    pub(crate) fn usecase(&self) -> &Usecase {
267        &self.usecase
268    }
269
270    #[inline]
271    pub(crate) fn scopes(&self) -> &scope::Scopes {
272        &self.scopes
273    }
274}
275
276/// A [`Scope`] is a sequence of key-value pairs that defines a (possibly nested) namespace within a
277/// [`Usecase`].
278///
279/// To construct a [`Scope`], use [`Usecase::for_organization`], [`Usecase::for_project`], or
280/// [`Usecase::scope`] for custom scopes.
281#[derive(Debug)]
282pub struct Scope(pub(crate) crate::Result<ScopeInner>);
283
284impl Scope {
285    /// Creates a new root-level Scope for the given usecase.
286    ///
287    /// Using a custom Scope is discouraged, prefer using [`Usecase::for_organization`] or [`Usecase::for_project`] instead.
288    pub fn new(usecase: Usecase) -> Self {
289        Self(Ok(ScopeInner {
290            usecase,
291            scopes: scope::Scopes::empty(),
292        }))
293    }
294
295    fn for_organization(usecase: Usecase, organization: u64) -> Self {
296        Self::new(usecase).push("org", organization)
297    }
298
299    fn for_project(usecase: Usecase, organization: u64, project: u64) -> Self {
300        Self::for_organization(usecase, organization).push("project", project)
301    }
302
303    /// Extends this Scope by creating a new sub-scope nested within it.
304    pub fn push<V>(self, key: &str, value: V) -> Self
305    where
306        V: std::fmt::Display,
307    {
308        let result = self.0.and_then(|mut inner| {
309            inner.scopes.push(key, value)?;
310            Ok(inner)
311        });
312
313        Self(result)
314    }
315
316    /// Creates a session for this scope using the given client.
317    ///
318    /// # Errors
319    ///
320    /// Returns an error if the scope is invalid (e.g. it contains invalid characters).
321    pub fn session(self, client: &Client) -> crate::Result<Session> {
322        client.session(self)
323    }
324}
325
326#[derive(Debug)]
327pub(crate) struct ClientInner {
328    reqwest: reqwest::Client,
329    service_url: Url,
330    propagate_traces: bool,
331    token: Option<TokenProvider>,
332}
333
334/// A client for Objectstore. Use [`Client::builder`] to configure and construct a Client.
335///
336/// To perform CRUD operations, one has to create a Client, and then scope it to a [`Usecase`]
337/// and Scope in order to create a [`Session`].
338///
339/// If your Objectstore instance enforces authorization checks, you must provide
340/// authentication via [`ClientBuilder::token`]. It accepts anything that converts
341/// into a [`TokenProvider`]:
342///
343/// - **[`TokenGenerator`](crate::TokenGenerator)** — for internal services that have access to
344///   an EdDSA keypair. The generator signs a fresh JWT for each request, scoped to the
345///   specific usecase and scope being accessed.
346/// - **`String` / `&str`** — a pre-signed JWT, used as-is for every request.
347///   Use this for external services that receive a token from another source.
348///
349/// # Examples
350///
351/// Internal service with a keypair:
352///
353/// ```no_run
354/// use std::time::Duration;
355/// use objectstore_client::{Client, SecretKey, TokenGenerator, Usecase};
356/// use objectstore_types::auth::Permission;
357///
358/// # async fn example() -> objectstore_client::Result<()> {
359/// let token_generator = TokenGenerator::new(SecretKey {
360///         secret_key: "<safely inject secret key>".into(),
361///         kid: "my-service".into(),
362///     })?
363///     .expiry_seconds(30)
364///     .permissions(&[Permission::ObjectRead]);
365///
366/// let client = Client::builder("http://localhost:8888/")
367///     .timeout(Duration::from_secs(1))
368///     .propagate_traces(true)
369///     .token(token_generator)
370///     .build()?;
371/// # Ok(())
372/// # }
373/// ```
374///
375/// External service with a pre-signed JWT (obtained via
376/// [`TokenGenerator::create_token`](crate::TokenGenerator::create_token)):
377///
378/// ```no_run
379/// use objectstore_client::{Client, SecretKey, TokenGenerator, Usecase};
380///
381/// # fn example() -> objectstore_client::Result<()> {
382/// let scope = Usecase::new("my_app").for_project(42, 1337);
383/// let token = TokenGenerator::new(SecretKey {
384///     secret_key: "<private key>".into(),
385///     kid: "my-service".into(),
386/// })?.create_token(&scope).sign()?;
387///
388/// let client = Client::builder("http://localhost:8888/")
389///     .token(token)
390///     .build()?;
391/// # Ok(())
392/// # }
393/// ```
394///
395/// Optional authentication — pass an `Option` straight through, leaving the client
396/// unauthenticated when it is `None`:
397///
398/// ```no_run
399/// use objectstore_client::Client;
400///
401/// # fn example() -> objectstore_client::Result<()> {
402/// // Authenticate only if a token is present in the environment.
403/// let token_opt = std::env::var("OBJECTSTORE_TOKEN").ok();
404///
405/// let client = Client::builder("http://localhost:8888/")
406///     .token(token_opt)
407///     .build()?;
408/// # Ok(())
409/// # }
410/// ```
411#[derive(Debug, Clone)]
412pub struct Client {
413    inner: Arc<ClientInner>,
414}
415
416impl Client {
417    /// Creates a new [`Client`], configured with the given `service_url` and default
418    /// configuration.
419    ///
420    /// Use [`Client::builder`] for more fine-grained configuration.
421    ///
422    /// # Errors
423    ///
424    /// This method fails if [`ClientBuilder::build`] fails.
425    pub fn new(service_url: impl reqwest::IntoUrl) -> crate::Result<Client> {
426        ClientBuilder::new(service_url).build()
427    }
428
429    /// Convenience function to create a [`ClientBuilder`].
430    pub fn builder(service_url: impl reqwest::IntoUrl) -> ClientBuilder {
431        ClientBuilder::new(service_url)
432    }
433
434    /// Creates a session for the given scope using this client.
435    ///
436    /// # Errors
437    ///
438    /// Returns an error if the scope is invalid (e.g. it contains invalid characters).
439    pub fn session(&self, scope: Scope) -> crate::Result<Session> {
440        scope.0.map(|inner| Session {
441            scope: inner.into(),
442            client: self.inner.clone(),
443        })
444    }
445}
446
447/// Represents a session with Objectstore, tied to a specific Usecase and Scope within it.
448///
449/// Create a Session using [`Client::session`] or [`Scope::session`].
450#[derive(Debug, Clone)]
451pub struct Session {
452    pub(crate) scope: Arc<ScopeInner>,
453    pub(crate) client: Arc<ClientInner>,
454}
455
456/// The type of [`Stream`](futures_util::Stream) to be used for a PUT request.
457pub type ClientStream = BoxStream<'static, io::Result<Bytes>>;
458
459impl Session {
460    /// Generates a GET url to the object with the given `key`.
461    ///
462    /// This can then be used by downstream services to fetch the given object.
463    /// NOTE however that the service does not strictly follow HTTP semantics,
464    /// in particular in relation to `Accept-Encoding`.
465    pub fn object_url(&self, object_key: &str) -> Url {
466        let mut url = self.client.service_url.clone();
467
468        // `path_segments_mut` can only error if the url is cannot-be-a-base,
469        // and we check that in `ClientBuilder::new`, therefore this will never panic.
470        let mut segments = url.path_segments_mut().unwrap();
471        segments
472            .push("v1")
473            .push("objects")
474            .push(self.scope.usecase.name())
475            .push(&self.scope.scopes.as_api_path().to_string())
476            .extend(object_key.split('/'));
477        drop(segments);
478
479        url
480    }
481
482    /// Returns a token authorizing access to this session's scope.
483    ///
484    /// With a [`TokenGenerator`](crate::TokenGenerator), each call signs a fresh token whose expiry
485    /// starts now, using the configured permissions; use [`create_token`](Self::create_token) to
486    /// narrow either.
487    ///
488    /// If a pre-signed token was used to construct the client, it is returned verbatim, including
489    /// its original expiry.
490    ///
491    /// Returns `None` if the client was constructed without authentication.
492    ///
493    /// # Errors
494    ///
495    /// Returns an error if a new token cannot be signed with the configured key. Retrieving a
496    /// pre-signed token never fails.
497    pub fn get_token(&self) -> crate::Result<Option<String>> {
498        match self.client.token {
499            Some(TokenProvider::Generator(ref generator)) => {
500                generator.request_inner(&self.scope).sign().map(Some)
501            }
502            Some(TokenProvider::Static(ref token)) => Ok(Some(token.clone())),
503            None => Ok(None),
504        }
505    }
506
507    /// Deprecated. Use [`get_token`](Self::get_token) instead.
508    #[deprecated(note = "Use `get_token` instead.")]
509    pub fn mint_token(&self) -> crate::Result<Option<String>> {
510        self.get_token()
511    }
512
513    /// Creates a new token with configurable permissions and expiry.
514    ///
515    /// Use this to produce a static token that can be handed to an external service
516    /// with lower permissions than the current session.
517    ///
518    /// # Errors
519    ///
520    /// This requires a [`TokenGenerator`](crate::TokenGenerator) to sign a new token.
521    /// If not available, this will result in an error. To retrieve a token with the current
522    /// session's default permissions and expiry, use [`get_token`](Self::get_token) instead.
523    pub fn create_token(&self) -> crate::Result<Option<TokenRequest<'_>>> {
524        match self.client.token {
525            Some(TokenProvider::Generator(ref generator)) => {
526                Ok(Some(generator.request_inner(&self.scope)))
527            }
528            Some(TokenProvider::Static(_)) => Err(crate::Error::StaticTokenOverride),
529            None => Ok(None),
530        }
531    }
532
533    fn batch_url(&self) -> Url {
534        let mut url = self.client.service_url.clone();
535
536        // `path_segments_mut` can only error if the url is cannot-be-a-base,
537        // and we check that in `ClientBuilder::new`, therefore this will never panic.
538        let mut segments = url.path_segments_mut().unwrap();
539        segments
540            .push("v1")
541            .push("objects:batch")
542            .push(self.scope.usecase().name())
543            .push(&self.scope.scopes.as_api_path().to_string())
544            .push(""); // trailing slash
545        drop(segments);
546
547        url
548    }
549
550    #[cfg(feature = "multipart")]
551    fn multipart_url(
552        &self,
553        suffix: Option<&'static str>,
554        object_key: Option<&str>,
555        query_pairs: Option<Vec<(&str, String)>>,
556    ) -> Url {
557        let mut url = self.client.service_url.clone();
558
559        // `path_segments_mut` can only error if the url is cannot-be-a-base,
560        // and we check that in `ClientBuilder::new`, therefore this will never panic.
561        let mut segments = url.path_segments_mut().unwrap();
562        segments
563            .push("v1")
564            .push(match suffix {
565                Some("parts") => "objects:multipart:parts",
566                Some("complete") => "objects:multipart:complete",
567                _ => "objects:multipart",
568            })
569            .push(self.scope.usecase.name())
570            .push(&self.scope.scopes.as_api_path().to_string());
571        if let Some(object_key) = object_key.filter(|key| !key.is_empty()) {
572            segments.extend(object_key.split('/'));
573        }
574        drop(segments);
575        if let Some(query_pairs) = query_pairs {
576            let mut pairs = url.query_pairs_mut();
577            for (key, value) in query_pairs {
578                pairs.append_pair(key, &value);
579            }
580        }
581
582        url
583    }
584
585    fn prepare_builder(&self, mut builder: RequestBuilder) -> crate::Result<RequestBuilder> {
586        if let Some(token) = self.get_token()? {
587            builder = builder.header("x-os-auth", format!("Bearer {token}"));
588        }
589        if self.client.propagate_traces {
590            let trace_headers =
591                sentry_core::configure_scope(|scope| Some(scope.iter_trace_propagation_headers()));
592            for (header_name, value) in trace_headers.into_iter().flatten() {
593                builder = builder.header(header_name, value);
594            }
595        }
596        Ok(builder)
597    }
598
599    pub(crate) fn request(
600        &self,
601        method: reqwest::Method,
602        object_key: &str,
603    ) -> crate::Result<RequestBuilder> {
604        let url = self.object_url(object_key);
605        let builder = self.client.reqwest.request(method, url);
606        self.prepare_builder(builder)
607    }
608
609    pub(crate) fn batch_request(&self) -> crate::Result<RequestBuilder> {
610        let url = self.batch_url();
611        let builder = self.client.reqwest.post(url);
612        self.prepare_builder(builder)
613    }
614
615    #[cfg(feature = "multipart")]
616    pub(crate) fn multipart_request(
617        &self,
618        method: reqwest::Method,
619        action: Option<&'static str>,
620        object_key: Option<&str>,
621        query_pairs: Option<Vec<(&str, String)>>,
622    ) -> crate::Result<RequestBuilder> {
623        let url = self.multipart_url(action, object_key, query_pairs);
624        let builder = self.client.reqwest.request(method, url);
625        self.prepare_builder(builder)
626    }
627}
628
629#[cfg(test)]
630mod tests {
631    use super::*;
632
633    #[test]
634    fn test_usecase_construction() {
635        let name = "Debug_Files-123";
636        assert_eq!(Usecase::new(name).name(), name);
637        assert_eq!(Usecase::try_new(name).unwrap().name(), name);
638        assert!(matches!(
639            Usecase::try_new("_"),
640            Err(InvalidUsecaseName::InvalidLength)
641        ));
642        assert!(matches!(
643            Usecase::try_new("testing/org=999/injected"),
644            Err(InvalidUsecaseName::InvalidChar('/'))
645        ));
646    }
647
648    #[test]
649    #[should_panic(expected = "invalid usecase name")]
650    fn test_invalid_usecase_panics() {
651        Usecase::new("testing/org=999/injected");
652    }
653
654    #[test]
655    fn test_object_url() {
656        let client = Client::new("http://127.0.0.1:8888/").unwrap();
657        let usecase = Usecase::new("testing");
658        let scope = usecase
659            .for_project(12345, 1337)
660            .push("app_slug", "email_app");
661        let session = client.session(scope).unwrap();
662
663        assert_eq!(
664            session.object_url("foo/bar").to_string(),
665            "http://127.0.0.1:8888/v1/objects/testing/org=12345;project=1337;app_slug=email_app/foo/bar"
666        )
667    }
668
669    #[test]
670    fn test_object_url_with_base_path() {
671        let client = Client::new("http://127.0.0.1:8888/api/prefix").unwrap();
672        let usecase = Usecase::new("testing");
673        let scope = usecase.for_project(12345, 1337);
674        let session = client.session(scope).unwrap();
675
676        assert_eq!(
677            session.object_url("foo/bar").to_string(),
678            "http://127.0.0.1:8888/api/prefix/v1/objects/testing/org=12345;project=1337/foo/bar"
679        )
680    }
681}