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//! [`Usecase`] validates the name when it is constructed or deserialized.
16//!
17//! ## Scopes
18//!
19//! [`Scopes`] are ordered key-value pairs that
20//! form a hierarchy within a usecase — for example,
21//! `organization=17, project=42`. They serve as both an organizational structure
22//! and an authorization boundary. See the
23//! [`objectstore-types` docs](objectstore_types) for details on scope validation
24//! and formatting.
25//!
26//! ## Storage paths
27//!
28//! An [`ObjectId`] is converted to a storage path for backends via
29//! [`ObjectId::as_storage_path`]:
30//!
31//! ```text
32//! {usecase}/{scope1_key}.{scope1_value}/{scope2_key}.{scope2_value}/objects/{key}
33//! ```
34//!
35//! For example: `attachments/org.17/project.42/objects/abc123`
36
37use std::fmt;
38
39use objectstore_types::scope::{Scope, Scopes};
40use objectstore_types::usecase::Usecase;
41
42/// The fixed path segment that separates scopes from the object key in storage paths.
43const KEY_DELIMITER: &str = "objects";
44
45/// Defines where an object, or batch of objects, belongs within the object store.
46///
47/// This is part of the full object identifier for single objects, see [`ObjectId`].
48#[derive(Debug, Clone, PartialEq, Eq, Hash)]
49pub struct ObjectContext {
50 /// The usecase, or "product" this object belongs to.
51 ///
52 /// This can be defined on-the-fly by the client, but special server logic
53 /// (such as the concrete backend/bucket) can be tied to this as well.
54 pub usecase: Usecase,
55
56 /// The scopes of the object, used for compartmentalization and authorization.
57 ///
58 /// Scopes are hierarchical key-value pairs that act as containers for objects. The first,
59 /// top-level scope can contain sub scopes, like a structured nested folder system. As such,
60 /// scopes are used for isolation and access authorization.
61 ///
62 /// # Ordering
63 ///
64 /// Note that the order of scopes matters! For example, `organization=17,project=42` indicates
65 /// that project _42_ is part of organization _17_. If an object were created with these scopes
66 /// reversed, it counts as a different object.
67 ///
68 /// Not every object within a usecase needs to have the same scopes. It is perfectly valid to
69 /// create objects with disjunct or a subset of scopes. However, by convention, we recommend to
70 /// use the same scopes for all objects within a usecase where possible.
71 ///
72 /// # Creation
73 ///
74 /// To create scopes, collect from an iterator of [`Scope`]s. Since scopes must be validated,
75 /// you must use [`Scope::create`] to create them:
76 ///
77 /// ```
78 /// use objectstore_service::id::ObjectContext;
79 /// use objectstore_types::scope::{Scope, Scopes};
80 ///
81 /// let object_id = ObjectContext {
82 /// usecase: "my_usecase".parse().unwrap(),
83 /// scopes: Scopes::from_iter([
84 /// Scope::create("organization", "17").unwrap(),
85 /// Scope::create("project", "42").unwrap(),
86 /// ]),
87 /// };
88 /// ```
89 pub scopes: Scopes,
90}
91
92/// The fully qualified identifier of an object.
93///
94/// This consists of a usecase and the scopes, which make up the object's context and define where
95/// the object belongs within objectstore, as well as the unique key within the context.
96#[derive(Debug, Clone, PartialEq, Eq, Hash)]
97pub struct ObjectId {
98 /// The usecase and scopes this object belongs to.
99 pub context: ObjectContext,
100
101 /// This key uniquely identifies the object within its usecase and scopes.
102 ///
103 /// Note that keys can be reused across different contexts. Only in combination with the context
104 /// a key makes a unique identifier.
105 ///
106 /// Keys can be assigned by the service. For this, use [`ObjectId::random`].
107 pub key: ObjectKey,
108}
109
110/// A key that uniquely identifies an object within its usecase and scopes.
111pub type ObjectKey = String;
112
113impl ObjectId {
114 /// Creates a new `ObjectId` with the given `context` and `key`.
115 pub fn new(context: ObjectContext, key: String) -> Self {
116 Self::optional(context, Some(key))
117 }
118
119 /// Creates a new `ObjectId` from all of its parts.
120 pub fn from_parts(usecase: Usecase, scopes: Scopes, key: String) -> Self {
121 Self::new(ObjectContext { usecase, scopes }, key)
122 }
123
124 /// Creates a unique `ObjectId` with a random key.
125 ///
126 /// This can be used when creating an object with a server-generated key.
127 pub fn random(context: ObjectContext) -> Self {
128 Self::optional(context, None)
129 }
130
131 /// Creates a new `ObjectId`, generating a key if none is provided.
132 ///
133 /// This creates a unique key like [`ObjectId::random`] if no `key` is provided, or otherwise
134 /// uses the provided `key`.
135 pub fn optional(context: ObjectContext, key: Option<String>) -> Self {
136 Self {
137 context,
138 key: key.unwrap_or_else(|| uuid::Uuid::new_v4().to_string()),
139 }
140 }
141
142 /// Parses an `ObjectId` from a storage path produced by [`ObjectId::as_storage_path`].
143 ///
144 /// Returns `None` if the path is not a valid storage path. The inverse of
145 /// `as_storage_path().to_string()`: parsing that string back must yield an
146 /// equivalent `ObjectId`.
147 pub fn from_storage_path(path: &str) -> Option<Self> {
148 // Split off the usecase (everything before the first `/`).
149 let (usecase, mut rest) = path.split_once('/')?;
150 let usecase = Usecase::new(usecase).ok()?;
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,
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) -> &Usecase {
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 {
219 inner: self,
220 namespace: KEY_DELIMITER,
221 }
222 }
223
224 /// Returns the internal upload-marker path for this revision, outside the object namespace.
225 pub(crate) fn as_upload_path(&self) -> AsStoragePath<'_> {
226 AsStoragePath {
227 inner: self,
228 namespace: "uploads",
229 }
230 }
231}
232
233/// A view returned by [`ObjectId::as_storage_path`].
234#[derive(Debug)]
235pub struct AsStoragePath<'a> {
236 inner: &'a ObjectId,
237 namespace: &'static str,
238}
239
240impl fmt::Display for AsStoragePath<'_> {
241 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
242 write!(f, "{}/", self.inner.context.usecase)?;
243 if !self.inner.context.scopes.is_empty() {
244 write!(f, "{}/", self.inner.context.scopes.as_storage_path())?;
245 }
246 write!(f, "{}/{}", self.namespace, self.inner.key)
247 }
248}
249
250#[cfg(test)]
251mod tests {
252 use super::*;
253
254 #[test]
255 fn test_storage_path() {
256 let object_id = ObjectId {
257 context: ObjectContext {
258 usecase: "testing".parse().unwrap(),
259 scopes: Scopes::from_iter([
260 Scope::create("org", "12345").unwrap(),
261 Scope::create("project", "1337").unwrap(),
262 ]),
263 },
264 key: "foo/bar".to_string(),
265 };
266
267 let path = object_id.as_storage_path().to_string();
268 assert_eq!(path, "testing/org.12345/project.1337/objects/foo/bar");
269 }
270
271 #[test]
272 fn test_storage_path_empty_scopes() {
273 let object_id = ObjectId {
274 context: ObjectContext {
275 usecase: "testing".parse().unwrap(),
276 scopes: Scopes::empty(),
277 },
278 key: "foo/bar".to_string(),
279 };
280
281 let path = object_id.as_storage_path().to_string();
282 assert_eq!(path, "testing/objects/foo/bar");
283 }
284
285 #[test]
286 fn test_storage_path_roundtrip() {
287 let cases = [
288 // Multiple scopes
289 ObjectId {
290 context: ObjectContext {
291 usecase: "attachments".parse().unwrap(),
292 scopes: Scopes::from_iter([
293 Scope::create("org", "17").unwrap(),
294 Scope::create("project", "42").unwrap(),
295 ]),
296 },
297 key: "abc-123-def".to_string(),
298 },
299 // No scopes
300 ObjectId {
301 context: ObjectContext {
302 usecase: "debug-files".parse().unwrap(),
303 scopes: Scopes::empty(),
304 },
305 key: "simple-key".to_string(),
306 },
307 // Key containing slashes
308 ObjectId {
309 context: ObjectContext {
310 usecase: "testing".parse().unwrap(),
311 scopes: Scopes::from_iter([Scope::create("org", "12345").unwrap()]),
312 },
313 key: "foo/bar/baz".to_string(),
314 },
315 // Single scope
316 ObjectId {
317 context: ObjectContext {
318 usecase: "replays".parse().unwrap(),
319 scopes: Scopes::from_iter([Scope::create("org", "99").unwrap()]),
320 },
321 key: "some-uuid-key".to_string(),
322 },
323 ];
324
325 for id in &cases {
326 let path = id.as_storage_path().to_string();
327 let parsed = ObjectId::from_storage_path(&path)
328 .unwrap_or_else(|| panic!("failed to parse '{path}'"));
329 assert_eq!(&parsed, id, "roundtrip failed for path '{path}'");
330 }
331 }
332
333 #[test]
334 fn test_from_storage_path_invalid() {
335 // Invalid usecase names must not bypass validation through storage paths.
336 for name in ["", "_", "ab", "test.name", "café", &"a".repeat(65)] {
337 assert!(ObjectId::from_storage_path(&format!("{name}/objects/key")).is_none());
338 }
339 // Missing "objects" segment
340 assert!(ObjectId::from_storage_path("usecase/org.17/mykey").is_none());
341 // Empty path
342 assert!(ObjectId::from_storage_path("").is_none());
343 // Only "objects" with no key
344 assert!(ObjectId::from_storage_path("usecase/objects").is_none());
345 // Invalid scope (missing dot)
346 assert!(ObjectId::from_storage_path("usecase/noscope/objects/key").is_none());
347 }
348}