1use 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 #[must_use]
18 pub fn new() -> Self {
19 Self(Uuid::new_v4())
20 }
21
22 #[must_use]
24 pub const fn from_bytes(bytes: [u8; 16]) -> Self {
25 Self(Uuid::from_bytes(bytes))
26 }
27
28 #[must_use]
30 pub const fn into_bytes(self) -> [u8; 16] {
31 self.0.into_bytes()
32 }
33
34 #[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 ProductionId
71);
72strong_id!(
73 AssetId
75);
76strong_id!(
77 RepresentationId
79);
80strong_id!(
81 ResourceId
83);
84strong_id!(
85 LocatorId
87);
88strong_id!(
89 MediaRootId
91);
92strong_id!(
93 ActivityId
95);
96strong_id!(
97 RevisionId
99);
100strong_id!(
101 TransactionId
103);
104
105#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
107#[non_exhaustive]
108pub enum ObjectRef {
109 Production(ProductionId),
111 Asset(AssetId),
113 Representation(RepresentationId),
115 Resource(ResourceId),
117 Activity(ActivityId),
119}
120
121#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
123pub struct HostObjectBinding {
124 production_id: ProductionId,
125 object: ObjectRef,
126}
127
128impl HostObjectBinding {
129 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 #[must_use]
152 pub const fn production_id(self) -> ProductionId {
153 self.production_id
154 }
155
156 #[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}