Skip to main content

postproject_core/
id.rs

1//! Strong, globally unique identifiers.
2
3use std::{fmt, str::FromStr};
4
5use uuid::Uuid;
6
7use crate::{Error, ErrorKind, Result};
8
9macro_rules! strong_id {
10    ($(#[$metadata:meta])* $name:ident) => {
11        $(#[$metadata])*
12        #[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
13        pub struct $name(Uuid);
14
15        impl $name {
16            /// Creates a random RFC 9562 UUID version 4 identity.
17            #[must_use]
18            pub fn new() -> Self {
19                Self(Uuid::new_v4())
20            }
21
22            /// Creates an identity from its stable 16-byte representation.
23            #[must_use]
24            pub const fn from_bytes(bytes: [u8; 16]) -> Self {
25                Self(Uuid::from_bytes(bytes))
26            }
27
28            /// Returns the stable 16-byte representation.
29            #[must_use]
30            pub const fn into_bytes(self) -> [u8; 16] {
31                self.0.into_bytes()
32            }
33
34            /// Returns a reference to the stable 16-byte representation.
35            #[must_use]
36            pub const fn as_bytes(&self) -> &[u8; 16] {
37                self.0.as_bytes()
38            }
39        }
40
41        impl Default for $name {
42            fn default() -> Self {
43                Self::new()
44            }
45        }
46
47        impl fmt::Display for $name {
48            fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
49                self.0.fmt(formatter)
50            }
51        }
52
53        impl FromStr for $name {
54            type Err = Error;
55
56            fn from_str(value: &str) -> Result<Self> {
57                Uuid::parse_str(value).map(Self).map_err(|error| {
58                    Error::new(
59                        ErrorKind::InvalidArgument,
60                        format!("invalid {}: {error}", stringify!($name)),
61                    )
62                })
63            }
64        }
65    };
66}
67
68strong_id!(
69    /// Stable identity of a production.
70    ProductionId
71);
72strong_id!(
73    /// Stable logical identity of an asset.
74    AssetId
75);
76strong_id!(
77    /// Stable identity of an encoded or derived asset representation.
78    RepresentationId
79);
80strong_id!(
81    /// Stable identity of stored content used by a representation.
82    ResourceId
83);
84strong_id!(
85    /// Stable identity of one access route to a resource.
86    LocatorId
87);
88strong_id!(
89    /// Stable identity of a configured resolver search root.
90    MediaRootId
91);
92strong_id!(
93    /// Stable identity of a production activity.
94    ActivityId
95);
96strong_id!(
97    /// Stable identity of one durable production revision.
98    RevisionId
99);
100strong_id!(
101    /// Stable identity of a domain transaction.
102    TransactionId
103);
104
105/// A typed reference to an object that may carry extensible assertions.
106#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
107#[non_exhaustive]
108pub enum ObjectRef {
109    /// A production object.
110    Production(ProductionId),
111    /// A logical asset object.
112    Asset(AssetId),
113    /// A concrete asset representation.
114    Representation(RepresentationId),
115    /// A storage-level resource.
116    Resource(ResourceId),
117    /// A production activity.
118    Activity(ActivityId),
119}
120
121/// Portable reference from a host document to one production-scoped object.
122#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
123pub struct HostObjectBinding {
124    production_id: ProductionId,
125    object: ObjectRef,
126}
127
128impl HostObjectBinding {
129    /// Creates a binding from its authoritative identity tuple.
130    ///
131    /// # Errors
132    ///
133    /// Returns an error when a production object does not match the binding's
134    /// containing production identity.
135    pub fn new(production_id: ProductionId, object: ObjectRef) -> Result<Self> {
136        if let ObjectRef::Production(object_id) = object {
137            if object_id != production_id {
138                return Err(Error::new(
139                    ErrorKind::InvalidArgument,
140                    "a production binding must reference its containing production",
141                ));
142            }
143        }
144        Ok(Self {
145            production_id,
146            object,
147        })
148    }
149
150    /// Returns the production that scopes the referenced object identity.
151    #[must_use]
152    pub const fn production_id(self) -> ProductionId {
153        self.production_id
154    }
155
156    /// Returns the typed object reference within the production.
157    #[must_use]
158    pub const fn object(self) -> ObjectRef {
159        self.object
160    }
161}
162
163impl fmt::Display for HostObjectBinding {
164    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
165        let (kind, object_id) = match self.object {
166            ObjectRef::Production(id) => ("production", id.to_string()),
167            ObjectRef::Asset(id) => ("asset", id.to_string()),
168            ObjectRef::Representation(id) => ("representation", id.to_string()),
169            ObjectRef::Resource(id) => ("resource", id.to_string()),
170            ObjectRef::Activity(id) => ("activity", id.to_string()),
171        };
172        write!(
173            formatter,
174            "https://postproject.org/ref/v1/{}/{kind}/{object_id}",
175            self.production_id
176        )
177    }
178}
179
180impl FromStr for HostObjectBinding {
181    type Err = Error;
182
183    fn from_str(value: &str) -> Result<Self> {
184        let Some(path) = value.strip_prefix("https://postproject.org/ref/v1/") else {
185            return Err(invalid_binding());
186        };
187        let mut parts = path.split('/');
188        let (Some(production_id), Some(kind), Some(object_id)) =
189            (parts.next(), parts.next(), parts.next())
190        else {
191            return Err(invalid_binding());
192        };
193        if parts.next().is_some() {
194            return Err(invalid_binding());
195        }
196        let production_id = parse_canonical_id::<ProductionId>(production_id, "production UUID")?;
197        let object = match kind {
198            "production" => ObjectRef::Production(parse_canonical_id(object_id, "object UUID")?),
199            "asset" => ObjectRef::Asset(parse_canonical_id(object_id, "object UUID")?),
200            "representation" => {
201                ObjectRef::Representation(parse_canonical_id(object_id, "object UUID")?)
202            }
203            "resource" => ObjectRef::Resource(parse_canonical_id(object_id, "object UUID")?),
204            "activity" => ObjectRef::Activity(parse_canonical_id(object_id, "object UUID")?),
205            _ => return Err(invalid_binding()),
206        };
207        Self::new(production_id, object)
208    }
209}
210
211fn parse_canonical_id<T>(value: &str, label: &str) -> Result<T>
212where
213    T: FromStr<Err = Error> + fmt::Display,
214{
215    let parsed = T::from_str(value)?;
216    if parsed.to_string() != value {
217        return Err(Error::new(
218            ErrorKind::InvalidArgument,
219            format!("host binding {label} must use canonical lowercase UUID text"),
220        ));
221    }
222    Ok(parsed)
223}
224
225fn invalid_binding() -> Error {
226    Error::new(
227        ErrorKind::InvalidArgument,
228        "host binding must be https://postproject.org/ref/v1/<production UUID>/<object kind>/<object UUID>",
229    )
230}
231
232#[cfg(test)]
233mod tests {
234    use proptest::prelude::*;
235
236    use super::*;
237
238    proptest! {
239        #[test]
240        fn production_id_byte_and_text_round_trips(bytes in any::<[u8; 16]>()) {
241            let id = ProductionId::from_bytes(bytes);
242            prop_assert_eq!(ProductionId::from_str(&id.to_string()), Ok(id));
243            prop_assert_eq!(id.into_bytes(), bytes);
244        }
245    }
246
247    #[test]
248    fn identifier_types_are_not_interchangeable() {
249        let bytes = [7; 16];
250        let production = ProductionId::from_bytes(bytes);
251        let asset = AssetId::from_bytes(bytes);
252        let resource = ResourceId::from_bytes(bytes);
253        let locator = LocatorId::from_bytes(bytes);
254
255        assert_eq!(production.as_bytes(), asset.as_bytes());
256        assert_eq!(production.to_string(), asset.to_string());
257        assert_eq!(resource.as_bytes(), locator.as_bytes());
258    }
259
260    #[test]
261    fn object_references_preserve_identity_level() {
262        let bytes = [9; 16];
263        assert_ne!(
264            ObjectRef::Asset(AssetId::from_bytes(bytes)),
265            ObjectRef::Representation(RepresentationId::from_bytes(bytes))
266        );
267    }
268
269    #[test]
270    fn invalid_text_has_stable_error_kind() {
271        let error = AssetId::from_str("not-a-uuid").expect_err("text must be rejected");
272        assert_eq!(error.kind(), ErrorKind::InvalidArgument);
273    }
274
275    #[test]
276    fn host_bindings_round_trip_each_object_kind() {
277        let production_id = ProductionId::from_bytes([1; 16]);
278        let objects = [
279            ObjectRef::Production(production_id),
280            ObjectRef::Asset(AssetId::from_bytes([2; 16])),
281            ObjectRef::Representation(RepresentationId::from_bytes([3; 16])),
282            ObjectRef::Resource(ResourceId::from_bytes([4; 16])),
283            ObjectRef::Activity(ActivityId::from_bytes([5; 16])),
284        ];
285        for object in objects {
286            let binding = HostObjectBinding::new(production_id, object).expect("valid binding");
287            let encoded = binding.to_string();
288            assert_eq!(HostObjectBinding::from_str(&encoded), Ok(binding));
289        }
290    }
291
292    #[test]
293    fn host_bindings_reject_noncanonical_or_ambiguous_text() {
294        let production_id = ProductionId::from_bytes([1; 16]);
295        let asset_id = AssetId::from_bytes([2; 16]);
296        let valid = format!("https://postproject.org/ref/v1/{production_id}/asset/{asset_id}");
297        assert!(HostObjectBinding::from_str(&valid.to_uppercase()).is_err());
298        assert!(HostObjectBinding::from_str(&valid.replace("/v1/", "/v2/")).is_err());
299        assert!(HostObjectBinding::from_str(&format!("{valid}/fallback")).is_err());
300        assert!(HostObjectBinding::from_str(&valid.replace("/asset/", "/locator/")).is_err());
301        assert!(HostObjectBinding::from_str("postproject:v1:old:asset:old").is_err());
302        assert!(
303            HostObjectBinding::new(
304                production_id,
305                ObjectRef::Production(ProductionId::from_bytes([9; 16]))
306            )
307            .is_err()
308        );
309    }
310}