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}