objectstore_service/
id.rs

1//! Definitions for object identifiers, including usecases and scopes.
2//!
3//! This module contains types to define and manage object identifiers:
4//!
5//!  - [`ObjectId`] is the main identifier type for objects, consisting of a usecase, scopes, and a
6//!    key. Every object stored in the object store has a unique `ObjectId`.
7//!  - [`Scope`] and [`Scopes`] define hierarchical scopes for objects, which are part of the
8//!    `ObjectId`.
9//!
10//! ## Usecases
11//!
12//! A usecase (sometimes called a "product") is a top-level namespace like
13//! `"attachments"` or `"debug-files"`. It groups related objects and can have
14//! usecase-specific server configuration (rate limits, killswitches, etc.).
15//!
16//! ## Scopes
17//!
18//! [`Scopes`] are ordered key-value pairs that
19//! form a hierarchy within a usecase — for example,
20//! `organization=17, project=42`. They serve as both an organizational structure
21//! and an authorization boundary. See the
22//! [`objectstore-types` docs](objectstore_types) for details on scope validation
23//! and formatting.
24//!
25//! ## Storage paths
26//!
27//! An [`ObjectId`] is converted to a storage path for backends via
28//! [`ObjectId::as_storage_path`]:
29//!
30//! ```text
31//! {usecase}/{scope1_key}.{scope1_value}/{scope2_key}.{scope2_value}/objects/{key}
32//! ```
33//!
34//! For example: `attachments/org.17/project.42/objects/abc123`
35
36use std::fmt;
37
38use objectstore_types::scope::{Scope, Scopes};
39
40/// Defines where an object, or batch of objects, belongs within the object store.
41///
42/// This is part of the full object identifier for single objects, see [`ObjectId`].
43#[derive(Debug, Clone, PartialEq, Eq, Hash)]
44pub struct ObjectContext {
45    /// The usecase, or "product" this object belongs to.
46    ///
47    /// This can be defined on-the-fly by the client, but special server logic
48    /// (such as the concrete backend/bucket) can be tied to this as well.
49    pub usecase: String,
50
51    /// The scopes of the object, used for compartmentalization and authorization.
52    ///
53    /// Scopes are hierarchical key-value pairs that act as containers for objects. The first,
54    /// top-level scope can contain sub scopes, like a structured nested folder system. As such,
55    /// scopes are used for isolation and access authorization.
56    ///
57    /// # Ordering
58    ///
59    /// Note that the order of scopes matters! For example, `organization=17,project=42` indicates
60    /// that project _42_ is part of organization _17_. If an object were created with these scopes
61    /// reversed, it counts as a different object.
62    ///
63    /// Not every object within a usecase needs to have the same scopes. It is perfectly valid to
64    /// create objects with disjunct or a subset of scopes. However, by convention, we recommend to
65    /// use the same scopes for all objects within a usecase where possible.
66    ///
67    /// # Creation
68    ///
69    /// To create scopes, collect from an iterator of [`Scope`]s. Since scopes must be validated,
70    /// you must use [`Scope::create`] to create them:
71    ///
72    /// ```
73    /// use objectstore_service::id::ObjectContext;
74    /// use objectstore_types::scope::{Scope, Scopes};
75    ///
76    /// let object_id = ObjectContext {
77    ///     usecase: "my_usecase".to_string(),
78    ///     scopes: Scopes::from_iter([
79    ///         Scope::create("organization", "17").unwrap(),
80    ///         Scope::create("project", "42").unwrap(),
81    ///     ]),
82    /// };
83    /// ```
84    pub scopes: Scopes,
85}
86
87/// The fully qualified identifier of an object.
88///
89/// This consists of a usecase and the scopes, which make up the object's context and define where
90/// the object belongs within objectstore, as well as the unique key within the context.
91#[derive(Debug, Clone, PartialEq, Eq, Hash)]
92pub struct ObjectId {
93    /// The usecase and scopes this object belongs to.
94    pub context: ObjectContext,
95
96    /// This key uniquely identifies the object within its usecase and scopes.
97    ///
98    /// Note that keys can be reused across different contexts. Only in combination with the context
99    /// a key makes a unique identifier.
100    ///
101    /// Keys can be assigned by the service. For this, use [`ObjectId::random`].
102    pub key: ObjectKey,
103}
104
105/// A key that uniquely identifies an object within its usecase and scopes.
106pub type ObjectKey = String;
107
108impl ObjectId {
109    /// Creates a new `ObjectId` with the given `context` and `key`.
110    pub fn new(context: ObjectContext, key: String) -> Self {
111        Self::optional(context, Some(key))
112    }
113
114    /// Creates a new `ObjectId` from all of its parts.
115    pub fn from_parts(usecase: String, scopes: Scopes, key: String) -> Self {
116        Self::new(ObjectContext { usecase, scopes }, key)
117    }
118
119    /// Creates a unique `ObjectId` with a random key.
120    ///
121    /// This can be used when creating an object with a server-generated key.
122    pub fn random(context: ObjectContext) -> Self {
123        Self::optional(context, None)
124    }
125
126    /// Creates a new `ObjectId`, generating a key if none is provided.
127    ///
128    /// This creates a unique key like [`ObjectId::random`] if no `key` is provided, or otherwise
129    /// uses the provided `key`.
130    pub fn optional(context: ObjectContext, key: Option<String>) -> Self {
131        Self {
132            context,
133            key: key.unwrap_or_else(|| uuid::Uuid::new_v4().to_string()),
134        }
135    }
136
137    /// Returns the key of the object.
138    ///
139    /// See [`key`](field@ObjectId::key) for more information.
140    pub fn key(&self) -> &str {
141        &self.key
142    }
143
144    /// Returns the context of the object.
145    ///
146    /// See [`context`](field@ObjectId::context) for more information.
147    pub fn context(&self) -> &ObjectContext {
148        &self.context
149    }
150
151    /// Returns the usecase of the object.
152    ///
153    /// See [`ObjectContext::usecase`] for more information.
154    pub fn usecase(&self) -> &str {
155        &self.context.usecase
156    }
157
158    /// Returns the scopes of the object.
159    ///
160    /// See [`ObjectContext::scopes`] for more information.
161    pub fn scopes(&self) -> &Scopes {
162        &self.context.scopes
163    }
164
165    /// Returns an iterator over all scopes of the object.
166    ///
167    /// See [`ObjectContext::scopes`] for more information.
168    pub fn iter_scopes(&self) -> impl Iterator<Item = &Scope> {
169        self.context.scopes.iter()
170    }
171
172    /// Returns a view that formats this ID as a storage path.
173    ///
174    /// This will format a hierarchical path in the format
175    /// `{usecase}/{scope1.key}.{scope1.value}/.../{key}` that is intended to be used by backends to
176    /// reference the object in a storage system.
177    pub fn as_storage_path(&self) -> AsStoragePath<'_> {
178        AsStoragePath { inner: self }
179    }
180}
181
182/// A view returned by [`ObjectId::as_storage_path`].
183#[derive(Debug)]
184pub struct AsStoragePath<'a> {
185    inner: &'a ObjectId,
186}
187
188impl fmt::Display for AsStoragePath<'_> {
189    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
190        write!(f, "{}/", self.inner.context.usecase)?;
191        if !self.inner.context.scopes.is_empty() {
192            write!(f, "{}/", self.inner.context.scopes.as_storage_path())?;
193        }
194        write!(f, "objects/{}", self.inner.key)
195    }
196}
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201
202    #[test]
203    fn test_storage_path() {
204        let object_id = ObjectId {
205            context: ObjectContext {
206                usecase: "testing".to_string(),
207                scopes: Scopes::from_iter([
208                    Scope::create("org", "12345").unwrap(),
209                    Scope::create("project", "1337").unwrap(),
210                ]),
211            },
212            key: "foo/bar".to_string(),
213        };
214
215        let path = object_id.as_storage_path().to_string();
216        assert_eq!(path, "testing/org.12345/project.1337/objects/foo/bar");
217    }
218
219    #[test]
220    fn test_storage_path_empty_scopes() {
221        let object_id = ObjectId {
222            context: ObjectContext {
223                usecase: "testing".to_string(),
224                scopes: Scopes::empty(),
225            },
226            key: "foo/bar".to_string(),
227        };
228
229        let path = object_id.as_storage_path().to_string();
230        assert_eq!(path, "testing/objects/foo/bar");
231    }
232}