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/// The fixed path segment that separates scopes from the object key in storage paths.
41const KEY_DELIMITER: &str = "objects";
42
43/// Defines where an object, or batch of objects, belongs within the object store.
44///
45/// This is part of the full object identifier for single objects, see [`ObjectId`].
46#[derive(Debug, Clone, PartialEq, Eq, Hash)]
47pub struct ObjectContext {
48    /// The usecase, or "product" this object belongs to.
49    ///
50    /// This can be defined on-the-fly by the client, but special server logic
51    /// (such as the concrete backend/bucket) can be tied to this as well.
52    pub usecase: String,
53
54    /// The scopes of the object, used for compartmentalization and authorization.
55    ///
56    /// Scopes are hierarchical key-value pairs that act as containers for objects. The first,
57    /// top-level scope can contain sub scopes, like a structured nested folder system. As such,
58    /// scopes are used for isolation and access authorization.
59    ///
60    /// # Ordering
61    ///
62    /// Note that the order of scopes matters! For example, `organization=17,project=42` indicates
63    /// that project _42_ is part of organization _17_. If an object were created with these scopes
64    /// reversed, it counts as a different object.
65    ///
66    /// Not every object within a usecase needs to have the same scopes. It is perfectly valid to
67    /// create objects with disjunct or a subset of scopes. However, by convention, we recommend to
68    /// use the same scopes for all objects within a usecase where possible.
69    ///
70    /// # Creation
71    ///
72    /// To create scopes, collect from an iterator of [`Scope`]s. Since scopes must be validated,
73    /// you must use [`Scope::create`] to create them:
74    ///
75    /// ```
76    /// use objectstore_service::id::ObjectContext;
77    /// use objectstore_types::scope::{Scope, Scopes};
78    ///
79    /// let object_id = ObjectContext {
80    ///     usecase: "my_usecase".to_string(),
81    ///     scopes: Scopes::from_iter([
82    ///         Scope::create("organization", "17").unwrap(),
83    ///         Scope::create("project", "42").unwrap(),
84    ///     ]),
85    /// };
86    /// ```
87    pub scopes: Scopes,
88}
89
90/// The fully qualified identifier of an object.
91///
92/// This consists of a usecase and the scopes, which make up the object's context and define where
93/// the object belongs within objectstore, as well as the unique key within the context.
94#[derive(Debug, Clone, PartialEq, Eq, Hash)]
95pub struct ObjectId {
96    /// The usecase and scopes this object belongs to.
97    pub context: ObjectContext,
98
99    /// This key uniquely identifies the object within its usecase and scopes.
100    ///
101    /// Note that keys can be reused across different contexts. Only in combination with the context
102    /// a key makes a unique identifier.
103    ///
104    /// Keys can be assigned by the service. For this, use [`ObjectId::random`].
105    pub key: ObjectKey,
106}
107
108/// A key that uniquely identifies an object within its usecase and scopes.
109pub type ObjectKey = String;
110
111impl ObjectId {
112    /// Creates a new `ObjectId` with the given `context` and `key`.
113    pub fn new(context: ObjectContext, key: String) -> Self {
114        Self::optional(context, Some(key))
115    }
116
117    /// Creates a new `ObjectId` from all of its parts.
118    pub fn from_parts(usecase: String, scopes: Scopes, key: String) -> Self {
119        Self::new(ObjectContext { usecase, scopes }, key)
120    }
121
122    /// Creates a unique `ObjectId` with a random key.
123    ///
124    /// This can be used when creating an object with a server-generated key.
125    pub fn random(context: ObjectContext) -> Self {
126        Self::optional(context, None)
127    }
128
129    /// Creates a new `ObjectId`, generating a key if none is provided.
130    ///
131    /// This creates a unique key like [`ObjectId::random`] if no `key` is provided, or otherwise
132    /// uses the provided `key`.
133    pub fn optional(context: ObjectContext, key: Option<String>) -> Self {
134        Self {
135            context,
136            key: key.unwrap_or_else(|| uuid::Uuid::new_v4().to_string()),
137        }
138    }
139
140    /// Parses an `ObjectId` from a storage path produced by [`ObjectId::as_storage_path`].
141    ///
142    /// Returns `None` if the path is not a valid storage path. The inverse of
143    /// `as_storage_path().to_string()`: parsing that string back must yield an
144    /// equivalent `ObjectId`.
145    pub fn from_storage_path(path: &str) -> Option<Self> {
146        // Split off the usecase (everything before the first `/`).
147        let (usecase, mut rest) = path.split_once('/')?;
148        if usecase.is_empty() {
149            return None;
150        }
151
152        let mut scopes_vec = Vec::new();
153        loop {
154            let (segment, after) = rest.split_once('/')?;
155            rest = after;
156
157            if segment == KEY_DELIMITER {
158                break;
159            }
160
161            let (name, value) = segment.split_once('.')?;
162            scopes_vec.push(Scope::create(name, value).ok()?);
163        }
164
165        match rest {
166            "" => None,
167            key => Some(ObjectId {
168                context: ObjectContext {
169                    usecase: usecase.to_owned(),
170                    scopes: Scopes::from_iter(scopes_vec),
171                },
172                key: key.to_owned(),
173            }),
174        }
175    }
176
177    /// Returns the key of the object.
178    ///
179    /// See [`key`](field@ObjectId::key) for more information.
180    pub fn key(&self) -> &str {
181        &self.key
182    }
183
184    /// Returns the context of the object.
185    ///
186    /// See [`context`](field@ObjectId::context) for more information.
187    pub fn context(&self) -> &ObjectContext {
188        &self.context
189    }
190
191    /// Returns the usecase of the object.
192    ///
193    /// See [`ObjectContext::usecase`] for more information.
194    pub fn usecase(&self) -> &str {
195        &self.context.usecase
196    }
197
198    /// Returns the scopes of the object.
199    ///
200    /// See [`ObjectContext::scopes`] for more information.
201    pub fn scopes(&self) -> &Scopes {
202        &self.context.scopes
203    }
204
205    /// Returns an iterator over all scopes of the object.
206    ///
207    /// See [`ObjectContext::scopes`] for more information.
208    pub fn iter_scopes(&self) -> impl Iterator<Item = &Scope> {
209        self.context.scopes.iter()
210    }
211
212    /// Returns a view that formats this ID as a storage path.
213    ///
214    /// This will format a hierarchical path in the format
215    /// `{usecase}/{scope1.key}.{scope1.value}/.../{key}` that is intended to be used by backends to
216    /// reference the object in a storage system.
217    pub fn as_storage_path(&self) -> AsStoragePath<'_> {
218        AsStoragePath { inner: self }
219    }
220}
221
222/// A view returned by [`ObjectId::as_storage_path`].
223#[derive(Debug)]
224pub struct AsStoragePath<'a> {
225    inner: &'a ObjectId,
226}
227
228impl fmt::Display for AsStoragePath<'_> {
229    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
230        write!(f, "{}/", self.inner.context.usecase)?;
231        if !self.inner.context.scopes.is_empty() {
232            write!(f, "{}/", self.inner.context.scopes.as_storage_path())?;
233        }
234        write!(f, "{}/{}", KEY_DELIMITER, self.inner.key)
235    }
236}
237
238#[cfg(test)]
239mod tests {
240    use super::*;
241
242    #[test]
243    fn test_storage_path() {
244        let object_id = ObjectId {
245            context: ObjectContext {
246                usecase: "testing".to_string(),
247                scopes: Scopes::from_iter([
248                    Scope::create("org", "12345").unwrap(),
249                    Scope::create("project", "1337").unwrap(),
250                ]),
251            },
252            key: "foo/bar".to_string(),
253        };
254
255        let path = object_id.as_storage_path().to_string();
256        assert_eq!(path, "testing/org.12345/project.1337/objects/foo/bar");
257    }
258
259    #[test]
260    fn test_storage_path_empty_scopes() {
261        let object_id = ObjectId {
262            context: ObjectContext {
263                usecase: "testing".to_string(),
264                scopes: Scopes::empty(),
265            },
266            key: "foo/bar".to_string(),
267        };
268
269        let path = object_id.as_storage_path().to_string();
270        assert_eq!(path, "testing/objects/foo/bar");
271    }
272
273    #[test]
274    fn test_storage_path_roundtrip() {
275        let cases = [
276            // Multiple scopes
277            ObjectId {
278                context: ObjectContext {
279                    usecase: "attachments".to_string(),
280                    scopes: Scopes::from_iter([
281                        Scope::create("org", "17").unwrap(),
282                        Scope::create("project", "42").unwrap(),
283                    ]),
284                },
285                key: "abc-123-def".to_string(),
286            },
287            // No scopes
288            ObjectId {
289                context: ObjectContext {
290                    usecase: "debug-files".to_string(),
291                    scopes: Scopes::empty(),
292                },
293                key: "simple-key".to_string(),
294            },
295            // Key containing slashes
296            ObjectId {
297                context: ObjectContext {
298                    usecase: "testing".to_string(),
299                    scopes: Scopes::from_iter([Scope::create("org", "12345").unwrap()]),
300                },
301                key: "foo/bar/baz".to_string(),
302            },
303            // Single scope
304            ObjectId {
305                context: ObjectContext {
306                    usecase: "replays".to_string(),
307                    scopes: Scopes::from_iter([Scope::create("org", "99").unwrap()]),
308                },
309                key: "some-uuid-key".to_string(),
310            },
311        ];
312
313        for id in &cases {
314            let path = id.as_storage_path().to_string();
315            let parsed = ObjectId::from_storage_path(&path)
316                .unwrap_or_else(|| panic!("failed to parse '{path}'"));
317            assert_eq!(&parsed, id, "roundtrip failed for path '{path}'");
318        }
319    }
320
321    #[test]
322    fn test_from_storage_path_invalid() {
323        // Missing "objects" segment
324        assert!(ObjectId::from_storage_path("usecase/org.17/mykey").is_none());
325        // Empty path
326        assert!(ObjectId::from_storage_path("").is_none());
327        // Only "objects" with no key
328        assert!(ObjectId::from_storage_path("usecase/objects").is_none());
329        // Invalid scope (missing dot)
330        assert!(ObjectId::from_storage_path("usecase/noscope/objects/key").is_none());
331    }
332}