Skip to main content

zerokms_protocol/
identified_by.rs

1use std::{
2    fmt::{self, Display, Formatter},
3    ops::Deref,
4};
5
6use serde::{Deserialize, Serialize};
7use utoipa::ToSchema;
8use uuid::Uuid;
9
10/// A UUID or textual name that can uniquely identify a resource. Whereas a UUID is a global identifier, `name` is not
11/// implied to be *globally* unique, but unique within scope implied scope: e.g. a workspace.
12#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Clone, ToSchema)]
13pub enum IdentifiedBy {
14    /// A UUID that uniquely identifies a resource.
15    Uuid(Uuid),
16    /// A name that uniquely identifies a resource within an implied scope, e.g. a workspace.
17    Name(Name),
18}
19
20impl<'de> Deserialize<'de> for IdentifiedBy {
21    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
22    where
23        D: serde::Deserializer<'de>,
24    {
25        // This is the same as `IdentifiedBy` but is required to avoid a recursive Deserialize stack overflow.
26        #[derive(Deserialize)]
27        enum Stub {
28            Uuid(Uuid),
29            Name(Name),
30        }
31
32        // This representation is how backwards compatibility is dealt with on the wire.
33        #[derive(Deserialize)]
34        #[serde(untagged)]
35        enum Compat {
36            Uuid(Uuid),         // Old style (UUID only)
37            IdentifiedBy(Stub), // New style (UUID or Name)
38        }
39
40        if let Ok(id_compat) = <Compat as Deserialize>::deserialize(deserializer) {
41            match id_compat {
42                Compat::Uuid(uuid) => Ok(IdentifiedBy::Uuid(uuid)),
43                Compat::IdentifiedBy(stub) => match stub {
44                    Stub::Uuid(uuid) => Ok(IdentifiedBy::Uuid(uuid)),
45                    Stub::Name(name) => Ok(IdentifiedBy::Name(name)),
46                },
47            }
48        } else {
49            Err(serde::de::Error::custom(
50                "expected one of: a UUID, IdentifiedBy::Uuid or IdentifiedBy::Name",
51            ))
52        }
53    }
54}
55
56impl From<Uuid> for IdentifiedBy {
57    fn from(value: Uuid) -> Self {
58        Self::Uuid(value)
59    }
60}
61
62impl From<Name> for IdentifiedBy {
63    fn from(value: Name) -> Self {
64        Self::Name(value)
65    }
66}
67
68impl From<String> for Name {
69    fn from(value: String) -> Self {
70        Self { inner: value }
71    }
72}
73
74impl Display for IdentifiedBy {
75    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
76        match self {
77            IdentifiedBy::Uuid(uuid) => Display::fmt(uuid, f),
78            IdentifiedBy::Name(name) => Display::fmt(name, f),
79        }
80    }
81}
82
83/// The unique name of a resource (within some scope: e.g. a workspace).
84#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, Clone)]
85#[serde(transparent)]
86pub struct Name {
87    inner: String,
88}
89
90// `Name` serializes transparently as its inner string (`#[serde(transparent)]`), so its OpenAPI
91// schema is a plain string. A manual impl avoids the `{ inner }` object utoipa's derive produces
92// for a named-field newtype.
93impl utoipa::PartialSchema for Name {
94    fn schema() -> utoipa::openapi::RefOr<utoipa::openapi::schema::Schema> {
95        String::schema()
96    }
97}
98
99impl utoipa::ToSchema for Name {}
100
101impl Name {
102    /// Crates a new `Name` from an already persisted name. Does not run validations.
103    pub fn new_untrusted(name: &str) -> Self {
104        Self {
105            inner: name.to_owned(),
106        }
107    }
108}
109
110impl Deref for Name {
111    type Target = str;
112
113    fn deref(&self) -> &Self::Target {
114        &self.inner
115    }
116}
117
118const NAME_MAX_LEN: usize = 64;
119
120pub struct InvalidNameError(pub String);
121
122impl TryFrom<&str> for Name {
123    type Error = InvalidNameError;
124
125    fn try_from(value: &str) -> Result<Self, Self::Error> {
126        if !value.len() <= NAME_MAX_LEN {
127            return Err(InvalidNameError(format!(
128                "name length must be <= 64, got {}",
129                value.len()
130            )));
131        }
132
133        if value.is_empty() {
134            return Err(InvalidNameError(
135                "name must not be an empty string".to_string(),
136            ));
137        }
138
139        if !value
140            .chars()
141            .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '/')
142        {
143            return Err(InvalidNameError(
144                "name must consist of only these allowed characters: A-Z a-z 0-9 _ - /".to_string(),
145            ));
146        }
147
148        Ok(Name {
149            inner: value.to_string(),
150        })
151    }
152}
153
154impl Display for Name {
155    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
156        Display::fmt(&self.inner, f)
157    }
158}
159
160#[cfg(test)]
161mod test {
162    use super::*;
163
164    #[test]
165    fn name_validation() {
166        assert!(Name::try_from("alias").is_ok());
167        assert!(Name::try_from("foo/bar").is_ok());
168        assert!(Name::try_from(
169            "abcdefghijklmnopqrstuvqwxyzABCDEFGHIJKLMNOPQRSTUVQWXYZ0123456789-_/"
170        )
171        .is_ok());
172        assert!(Name::try_from(" leading-ws").is_err());
173        assert!(Name::try_from("trailing-ws ").is_err());
174        assert!(Name::try_from("punctuation%").is_err());
175    }
176}