seaplane_oid/
lib.rs

1//! An Object ID (OID) is a base32 (no padding) encoded UUID with a prefix
2//! separated by a `-`.
3//!
4//! For example `tst-agc6amh7z527vijkv2cutplwaa`, by convention the prefix is three
5//! ASCII lowercase characters, however that is a hard constraint of OIDs in
6//! general. The current implementation limits prefixes to 3 characters, but prefix
7//! limit could be exposed as a tunable if that need arises.
8//!
9//! ## The Pitch
10//!
11//! OIDs allow a "human readable subject line" in the form of the prefix, where
12//! actual data is a UUID. This means while debugging or reviewing a system it's trivial to
13//! determine if an incorrect OID was passed in a particular location by looking at the prefix.
14//! This isn't easily achievable with bare UUIDs.
15//!
16//! Base32 encoding the UUID also allows compressing the data into a smaller and
17//! more familiar format for humans, akin to a commit hash.
18//!
19//! ## The Anti-Pitch
20//!
21//! The downside to OIDs is a layer of indirection when handling IDs and values,
22//! it's not immediately obvious that the OIDs are a prefixed UUID. Additionally,
23//! the prefixes must be controlled in some manner including migrated on changes
24//! which adds a layer of complexity at the application layer.
25//!
26//! There is also additional processing overhead compared to a bare UUID in order
27//! to encode/decode as well as handling the appending and removing the prefixes.
28//!
29//! However, we believe the drawbacks to pale in comparison to the benefits derived
30//! from the format.
31//!
32//! ## Example
33//!
34//! ```rust
35//! use seaplane_oid::{error::*, Oid};
36//! use uuid::Uuid;
37//!
38//! fn main() -> Result<()> {
39//!     // OIDs can be created with a given prefix alone, which generates a new
40//!     // UUID
41//!     let oid = Oid::new("exm")?;
42//!     println!("{oid}");
43//!
44//!     // OIDs can be parsed from strings, however the "value" must be a valid
45//!     // base32 encoded UUID
46//!     let oid: Oid = "tst-0ous781p4lu7v000pa2a2bn1gc".parse()?;
47//!     println!("{oid}");
48//!
49//!     // OIDs can also be created from the raw parts
50//!     let oid = Oid::with_uuid(
51//!         "exm",
52//!         "063dc3a0-3925-7c7f-8000-ca84a12ee183"
53//!             .parse::<Uuid>()
54//!             .unwrap(),
55//!     )?;
56//!
57//!     // One can retrieve the various parts of the OID if needed
58//!     println!("Prefix: {}", oid.prefix());
59//!     println!("Value: {}", oid.value());
60//!     println!("UUID: {}", oid.uuid());
61//!
62//!     Ok(())
63//! }
64//! ```
65//!
66//! ## License
67//!
68//! Licensed under the Apache License, Version 2.0, Copyright 2023 Seaplane IO, Inc.
69pub mod error;
70
71use std::{any::type_name, fmt, marker::PhantomData, str::FromStr};
72
73use data_encoding::Encoding;
74use data_encoding_macro::new_encoding;
75use uuid::Uuid;
76
77use crate::error::{Error, Result};
78
79const BASE32_LOWER: Encoding = new_encoding! {
80    symbols: "0123456789abcdefghijklmnopqrstuv",
81};
82
83fn uuid_from_str(s: &str) -> Result<Uuid> {
84    if s.is_empty() {
85        return Err(Error::MissingValue);
86    }
87    Ok(Uuid::from_slice(&BASE32_LOWER.decode(s.as_bytes())?)?)
88}
89
90/// An OID Prefix designed to be similar to a human readable "subject line" for the ID
91#[derive(Debug, Copy, Clone, PartialEq, Eq)]
92pub struct Prefix<const N: usize = 3> {
93    bytes: [u8; N],
94}
95
96impl<const N: usize> fmt::Display for Prefix<N> {
97    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
98        // SAFETY: self.bytes must not contain any invalid UTF-8. We don't expose the inner byte
99        // array for manipulation, and the only way to construct self checks for a subset of ASCII
100        // which itself is a subset of UTF-8
101        unsafe { write!(f, "{}", std::str::from_utf8_unchecked(self.bytes.as_slice())) }
102    }
103}
104
105impl<const N: usize> Prefix<N> {
106    /// Create a Prefix from a slice of bytes. The bytes must be lowercase ASCII values of `0-9` or
107    /// `a-z`, additionally the byte slice length must be equal to the prefix length.
108    pub fn from_slice(slice: &[u8]) -> Result<Self> {
109        // Checking for ASCII 0-9,a-z
110        if !slice
111            .iter()
112            .all(|&c| (c > b'/' && c < b':') || (c > b'`' && c < b'{'))
113        {
114            return Err(Error::InvalidPrefixChar);
115        }
116        if slice.len() != N {
117            return Err(Error::PrefixByteLength);
118        }
119        let mut pfx = Prefix { bytes: [0_u8; N] };
120        pfx.bytes.copy_from_slice(slice);
121        Ok(pfx)
122    }
123}
124
125impl<const N: usize> FromStr for Prefix<N> {
126    type Err = Error;
127
128    /// The string slice is converted to ASCII lowercase before creating the Prefix
129    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
130        Self::from_slice(s.to_ascii_lowercase().as_bytes())
131    }
132}
133
134impl<const N: usize> From<[u8; N]> for Prefix<N> {
135    fn from(arr: [u8; N]) -> Self { Self { bytes: arr } }
136}
137
138impl<const N: usize> TryFrom<&[u8]> for Prefix<N> {
139    type Error = Error;
140    fn try_from(slice: &[u8]) -> std::result::Result<Self, Self::Error> { Self::from_slice(slice) }
141}
142
143impl<const N: usize> TryFrom<&str> for Prefix<N> {
144    type Error = Error;
145    fn try_from(s: &str) -> std::result::Result<Self, Self::Error> { s.parse() }
146}
147
148#[cfg(test)]
149mod prefix_tests {
150    use super::*;
151
152    #[test]
153    fn prefix_from_str() {
154        let pfx = "frm".parse::<Prefix>();
155        assert!(pfx.is_ok());
156        assert_eq!(pfx.unwrap(), Prefix { bytes: [b'f', b'r', b'm'] });
157    }
158
159    #[test]
160    fn prefix_from_str_err_len() {
161        let pfx = "frmx".parse::<Prefix<3>>();
162        assert!(pfx.is_err());
163        assert_eq!(pfx.unwrap_err(), Error::PrefixByteLength);
164    }
165
166    #[test]
167    fn prefix_from_str_err_char() {
168        let pfx = "fr[".parse::<Prefix<3>>();
169        assert!(pfx.is_err());
170        assert_eq!(pfx.unwrap_err(), Error::InvalidPrefixChar);
171    }
172
173    #[test]
174    fn prefix_from_str_uppercase_ok() {
175        let pfx = "frM".parse::<Prefix>();
176        assert!(pfx.is_ok());
177        assert_eq!(pfx.unwrap(), Prefix { bytes: [b'f', b'r', b'm'] });
178    }
179
180    #[test]
181    fn prefix_from_slice() {
182        let arr: [u8; 3] = [b'f', b'r', b'm'];
183        let pfx = Prefix::from_slice(arr.as_slice());
184        assert!(pfx.is_ok());
185        assert_eq!(pfx.unwrap(), Prefix { bytes: arr });
186    }
187
188    #[test]
189    fn prefix_from_slice_err_len() {
190        let arr: [u8; 4] = [b'f', b'r', b'm', b'x'];
191        let pfx = Prefix::<3>::from_slice(arr.as_slice());
192        assert!(pfx.is_err());
193        assert_eq!(pfx.unwrap_err(), Error::PrefixByteLength);
194    }
195
196    #[test]
197    fn prefix_from_slice_err_char() {
198        let arr: [u8; 3] = [b'f', b'r', b']'];
199        let pfx = Prefix::<3>::from_slice(arr.as_slice());
200        assert!(pfx.is_err());
201        assert_eq!(pfx.unwrap_err(), Error::InvalidPrefixChar);
202    }
203
204    #[test]
205    fn prefix_from_slice_err_uppercase() {
206        let arr: [u8; 3] = [b'f', b'r', b'M'];
207        let pfx = Prefix::<3>::from_slice(arr.as_slice());
208        assert!(pfx.is_err());
209        assert_eq!(pfx.unwrap_err(), Error::InvalidPrefixChar);
210    }
211
212    #[test]
213    fn prefix_to_string() {
214        let pfx: Prefix = "frM".parse().unwrap();
215        assert_eq!("frm".to_string(), pfx.to_string());
216    }
217}
218
219/// An Object ID
220#[derive(Debug, Copy, Clone, PartialEq, Eq)]
221pub struct Oid {
222    prefix: Prefix<3>,
223    uuid: Uuid,
224}
225
226impl Oid {
227    /// Create a new OID with a given [`Prefix`] and generating a new UUID
228    ///
229    /// **NOTE:** The Prefix must be 3 ASCII characters (this restriction is arbitrary and could be
230    /// lifted in the future by exposing an API to tune the [`Prefix`] length)
231    pub fn new<P>(prefix: P) -> Result<Self>
232    where
233        P: TryInto<Prefix, Error = Error>,
234    {
235        Self::with_uuid(prefix, Uuid::new_v4())
236    }
237
238    /// Create a new OID with a given [`Prefix`] and a given UUID. If the UUID is not a version 7
239    /// an error isr returned.
240    ///
241    /// **NOTE:** The Prefix must be 3 ASCII characters (this restriction is arbitrary and could be
242    /// lifted in the future by exposing an API to tune the [`Prefix`] length)
243    pub fn with_uuid<P>(prefix: P, uuid: Uuid) -> Result<Self>
244    where
245        P: TryInto<Prefix, Error = Error>,
246    {
247        Ok(Self { prefix: prefix.try_into()?, uuid })
248    }
249
250    /// Get the [`Prefix`] of the OID
251    pub fn prefix(&self) -> Prefix { self.prefix }
252
253    /// Get the value portion of the  of the OID, which is the base32 encoded string following the
254    /// `-` separator
255    pub fn value(&self) -> String { BASE32_LOWER.encode(self.uuid.as_bytes()) }
256
257    /// Get the UUID of the OID
258    pub fn uuid(&self) -> &Uuid { &self.uuid }
259}
260
261impl FromStr for Oid {
262    type Err = Error;
263
264    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
265        if let Some((pfx, val)) = s.split_once('-') {
266            if pfx.is_empty() {
267                return Err(Error::MissingPrefix);
268            }
269
270            return Ok(Self { prefix: pfx.parse()?, uuid: uuid_from_str(val)? });
271        }
272
273        Err(Error::MissingSeparator)
274    }
275}
276
277impl fmt::Display for Oid {
278    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
279        write!(f, "{}-{}", self.prefix, self.value())
280    }
281}
282
283#[cfg(feature = "serde")]
284impl ::serde::Serialize for Oid {
285    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
286    where
287        S: ::serde::ser::Serializer,
288    {
289        serializer.collect_str(self)
290    }
291}
292
293#[cfg(feature = "serde")]
294impl<'de> ::serde::Deserialize<'de> for Oid {
295    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
296    where
297        D: ::serde::de::Deserializer<'de>,
298    {
299        String::deserialize(deserializer)?
300            .parse()
301            .map_err(::serde::de::Error::custom)
302    }
303}
304
305#[cfg(test)]
306mod oid_tests {
307    use wildmatch::WildMatch;
308
309    use super::*;
310
311    #[test]
312    fn oid_to_str() -> Result<()> {
313        let oid = Oid::new("tst")?;
314        assert!(WildMatch::new("tst-??????????????????????????").matches(&oid.to_string()));
315        Ok(())
316    }
317
318    #[test]
319    fn str_to_oid() {
320        let res = "tst-0oqpkoaadlruj000j7u2ugns2g".parse::<Oid>();
321        assert_eq!(
322            res.unwrap(),
323            Oid {
324                prefix: "tst".parse().unwrap(),
325                uuid: "06359a61-4a6d-77e9-8000-99fc2f42fc14".parse().unwrap(),
326            }
327        );
328    }
329
330    #[test]
331    fn str_to_oid_err_prefix() {
332        let res = "-0oqpkoaadlruj000j7u2ugns2g".parse::<Oid>();
333        assert!(res.is_err());
334        assert_eq!(res.unwrap_err(), Error::MissingPrefix);
335    }
336
337    #[test]
338    fn str_to_oid_err_value() {
339        let res = "tst-".parse::<Oid>();
340        assert!(res.is_err());
341        assert_eq!(res.unwrap_err(), Error::MissingValue);
342    }
343
344    #[test]
345    fn str_to_oid_err_decode() {
346        let res = "tst-&oqpkoaadlruj000j7u2ugns2g".parse::<Oid>();
347        assert!(res.is_err());
348        assert!(matches!(res.unwrap_err(), Error::Base32Decode(_)));
349    }
350
351    #[test]
352    fn str_to_oid_err_no_sep() {
353        let res = "0oqpkoaadlruj000j7u2ugns2g".parse::<Oid>();
354        assert!(res.is_err());
355        assert_eq!(res.unwrap_err(), Error::MissingSeparator);
356    }
357
358    #[test]
359    fn str_to_oid_err_two_sep() {
360        let res = "tst-0oqpkoaad-lruj000j7u2ugns2g".parse::<Oid>();
361        assert!(res.is_err());
362        assert!(matches!(res.unwrap_err(), Error::Base32Decode(_)));
363    }
364
365    #[test]
366    fn oid_to_uuid() {
367        let oid: Oid = "tst-0oqpkoaadlruj000j7u2ugns2g".parse().unwrap();
368        assert_eq!(
369            oid.uuid(),
370            &"06359a61-4a6d-77e9-8000-99fc2f42fc14"
371                .parse::<Uuid>()
372                .unwrap()
373        );
374    }
375}
376
377pub trait OidPrefix {
378    fn string_prefix() -> String {
379        type_name::<Self>()
380            .split(':')
381            .last()
382            .map(|s| s.to_ascii_lowercase())
383            .unwrap()
384    }
385
386    fn long_name() -> String { Self::string_prefix() }
387}
388
389/// A Typed Object ID where the Prefix is part of the type
390///
391/// # Examples
392///
393/// A nice property of this two different prefix are two different types, and thus the following
394/// fails to compile:
395///
396/// ```compile_fail
397/// struct A;
398/// impl OidPrefix for A {}
399///
400/// struct B;
401/// impl OidPrefix for B {}
402///
403/// // The same UUID for both
404/// let uuid = Uuid::new_v4();
405/// let oid_a: TypedOid<A> = TypedOid::with_uuid(uuid.clone());
406/// let oid_b: TypedOid<B> = TypedOid::with_uuid(uuid);
407///
408/// // This fails to compile because `TypedOid<A>` is a different type than `TypedOid<B>` and no
409/// // PartialEq or Eq is implemented between these two types. The same would hold as function
410/// // parameters, etc.
411/// oid_a == oid_b
412/// ```
413#[derive(Debug, Copy, Clone, PartialEq, Eq)]
414pub struct TypedOid<P: OidPrefix> {
415    uuid: Uuid,
416    _prefix: PhantomData<P>,
417}
418
419impl<P: OidPrefix> TypedOid<P> {
420    /// Create a new TypedOid with a random UUID
421    pub fn new() -> Self { Self::with_uuid(Uuid::new_v4()) }
422
423    /// Create a new TypedOid with a given UUID
424    pub fn with_uuid(uuid: Uuid) -> Self { Self { uuid, _prefix: PhantomData } }
425
426    /// Get the [`Prefix`] of the OID
427    ///
428    /// # Panics
429    ///
430    /// If the Type `P` translates to an invalid prefix
431    pub fn prefix(&self) -> Prefix {
432        Prefix::from_str(&P::string_prefix()).expect("Invalid Prefix")
433    }
434
435    /// Get the value portion of the  of the OID, which is the base32 encoded string following the
436    /// `-` separator
437    pub fn value(&self) -> String { BASE32_LOWER.encode(self.uuid.as_bytes()) }
438
439    /// Get the UUID of the OID
440    pub fn uuid(&self) -> &Uuid { &self.uuid }
441}
442
443impl<P: OidPrefix> Default for TypedOid<P> {
444    fn default() -> Self { Self::new() }
445}
446
447impl<P: OidPrefix> fmt::Display for TypedOid<P> {
448    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
449        write!(f, "{}-{}", P::string_prefix(), self.value())
450    }
451}
452
453impl<P: OidPrefix> FromStr for TypedOid<P> {
454    type Err = Error;
455
456    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
457        if let Some((pfx, val)) = s.split_once('-') {
458            if pfx.is_empty() {
459                return Err(Error::MissingPrefix);
460            }
461
462            if pfx != P::string_prefix() {
463                return Err(Error::InvalidPrefixChar);
464            }
465
466            return Ok(Self { uuid: uuid_from_str(val)?, _prefix: PhantomData });
467        }
468
469        Err(Error::MissingSeparator)
470    }
471}
472
473#[cfg(feature = "serde")]
474impl<P: OidPrefix> ::serde::Serialize for TypedOid<P> {
475    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
476    where
477        S: ::serde::ser::Serializer,
478    {
479        serializer.collect_str(self)
480    }
481}
482
483#[cfg(feature = "serde")]
484impl<'de, P: OidPrefix> ::serde::Deserialize<'de> for TypedOid<P> {
485    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
486    where
487        D: ::serde::de::Deserializer<'de>,
488    {
489        String::deserialize(deserializer)?
490            .parse()
491            .map_err(::serde::de::Error::custom)
492    }
493}
494
495#[cfg(feature = "schemars")]
496impl<P: OidPrefix> ::schemars::JsonSchema for TypedOid<P> {
497    fn schema_name() -> String { P::long_name() }
498
499    fn json_schema(_gen: &mut schemars::gen::SchemaGenerator) -> schemars::schema::Schema {
500        ::schemars::schema::SchemaObject {
501            instance_type: Some(::schemars::schema::InstanceType::String.into()),
502            string: Some(Box::new(::schemars::schema::StringValidation {
503                pattern: Some(format!("^{}-[0-9a-vA-V]{{26}}$", P::string_prefix())),
504                ..Default::default()
505            })),
506            ..Default::default()
507        }
508        .into()
509    }
510}
511
512#[cfg(test)]
513mod typed_oid_tests {
514    use wildmatch::WildMatch;
515
516    use super::*;
517
518    #[test]
519    fn typed_oid() {
520        #[derive(Debug)]
521        struct Tst;
522        impl OidPrefix for Tst {}
523
524        let oid: TypedOid<Tst> = TypedOid::new();
525        assert!(
526            WildMatch::new("tst-??????????????????????????").matches(&oid.to_string()),
527            "{oid}"
528        );
529
530        let res = "tst-0ous781p4lu7v000pa2a2bn1gc".parse::<TypedOid<Tst>>();
531        assert!(res.is_ok());
532        let oid: TypedOid<Tst> = res.unwrap();
533        assert_eq!(
534            oid.uuid(),
535            &"063dc3a0-3925-7c7f-8000-ca84a12ee183"
536                .parse::<Uuid>()
537                .unwrap()
538        );
539
540        let res = "frm-0ous781p4lu7v000pa2a2bn1gc".parse::<TypedOid<Tst>>();
541        assert!(res.is_err());
542        assert_eq!(res.unwrap_err(), Error::InvalidPrefixChar);
543    }
544}