fiberplane_models/
names.rs

1#[cfg(feature = "fp-bindgen")]
2use fp_bindgen::prelude::Serializable;
3use serde::{
4    de::{self, Visitor},
5    Deserialize, Serialize,
6};
7use std::fmt::{self, Display};
8use std::str::FromStr;
9use std::{convert::TryFrom, ops::Deref};
10use thiserror::Error;
11
12const MAX_LENGTH: usize = 63;
13const MIN_LENGTH: usize = 1;
14
15#[derive(Debug, Error, PartialEq, Eq)]
16#[cfg_attr(
17    feature = "fp-bindgen",
18    derive(Serializable),
19    fp(rust_module = "fiberplane_models::names")
20)]
21#[non_exhaustive]
22pub enum InvalidName {
23    #[error("name is too long")]
24    TooLong,
25    #[error("name contains invalid characters (names can only include lowercase ASCII letters, numbers, and dashes)")]
26    InvalidCharacters,
27    #[error("name cannot be an empty string")]
28    TooShort,
29    #[error("name must start and end with an alphanumeric character")]
30    NonAlphanumericStartOrEnd,
31}
32
33/// This is a user-specified name for a Fiberplane resource.
34///
35/// Names must:
36/// - be between 1 and 63 characters long
37/// - start and end with an alphanumeric character
38/// - contain only lowercase alphanumeric ASCII characters and dashes
39///
40/// Names must be unique within a namespace such as a Workspace.
41#[derive(Debug, Clone, Serialize, Hash, PartialEq, Eq)]
42#[cfg_attr(feature = "sqlx", derive(sqlx::Type), sqlx(transparent))]
43#[cfg_attr(
44    feature = "fp-bindgen",
45    derive(Serializable),
46    fp(rust_module = "fiberplane_models::names")
47)]
48#[non_exhaustive]
49pub struct Name(String);
50
51impl Name {
52    /// creates a new instance of `Name` while validating the input at the same time.
53    /// the equivalent without checking is [`new_unchecked`][Self::new_unchecked]
54    pub fn new(name: impl Into<String>) -> Result<Self, InvalidName> {
55        let name = name.into();
56        Self::validate(&name).map(|()| Name(name))
57    }
58
59    /// creates a new instance of `Name` without validating the input.
60    /// passing a invalid name is considered undefined behaviour and may cause very weird bugs.
61    /// please exercise caution when using this function, and if in doubt, use [`new`](Self::new)
62    pub fn new_unchecked(name: impl Into<String>) -> Self {
63        Name(name.into())
64    }
65
66    pub fn into_string(self) -> String {
67        self.0
68    }
69
70    pub fn as_str(&self) -> &str {
71        &self.0
72    }
73
74    /// Creates a name from a static string.
75    ///
76    /// # Panics
77    ///
78    /// This function panics if the name is invalid.
79    pub fn from_static(name: &'static str) -> Self {
80        Name::new(name).expect("Invalid name")
81    }
82
83    pub fn validate(name: &str) -> Result<(), InvalidName> {
84        // Check the length
85        if name.len() < MIN_LENGTH {
86            return Err(InvalidName::TooShort);
87        }
88        if name.len() > MAX_LENGTH {
89            return Err(InvalidName::TooLong);
90        }
91
92        // Check the characters
93        if name
94            .chars()
95            .any(|c| !c.is_ascii_lowercase() && !c.is_numeric() && c != '-')
96        {
97            return Err(InvalidName::InvalidCharacters);
98        }
99
100        // Check the first and last characters
101        let first = name.chars().next().unwrap();
102        let last = name.chars().last().unwrap();
103        if !first.is_ascii_alphanumeric() || !last.is_ascii_alphanumeric() {
104            return Err(InvalidName::NonAlphanumericStartOrEnd);
105        }
106
107        Ok(())
108    }
109}
110
111struct NameVisitor;
112
113impl<'de> Visitor<'de> for NameVisitor {
114    type Value = Name;
115
116    fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
117        formatter.write_str("a valid name to identify the resource")
118    }
119
120    fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
121    where
122        E: serde::de::Error,
123    {
124        match Name::validate(value) {
125            Ok(()) => Ok(Name(value.to_owned())),
126            Err(error) => Err(de::Error::custom(error.to_string())),
127        }
128    }
129}
130
131impl<'de> Deserialize<'de> for Name {
132    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
133    where
134        D: serde::Deserializer<'de>,
135    {
136        deserializer.deserialize_str(NameVisitor)
137    }
138}
139
140impl Display for Name {
141    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
142        f.write_str(&self.0)
143    }
144}
145
146impl TryFrom<String> for Name {
147    type Error = InvalidName;
148
149    fn try_from(value: String) -> Result<Self, Self::Error> {
150        Self::new(value)
151    }
152}
153
154impl TryFrom<&str> for Name {
155    type Error = InvalidName;
156
157    fn try_from(value: &str) -> Result<Self, Self::Error> {
158        Self::new(value)
159    }
160}
161
162impl FromStr for Name {
163    type Err = InvalidName;
164
165    fn from_str(s: &str) -> Result<Self, Self::Err> {
166        Self::new(s)
167    }
168}
169
170impl From<Name> for String {
171    fn from(name: Name) -> Self {
172        name.0
173    }
174}
175
176impl Deref for Name {
177    type Target = String;
178
179    fn deref(&self) -> &Self::Target {
180        &self.0
181    }
182}
183
184impl PartialEq<str> for Name {
185    fn eq(&self, other: &str) -> bool {
186        self.0 == other
187    }
188}
189
190impl PartialEq<&str> for Name {
191    fn eq(&self, other: &&str) -> bool {
192        self.0 == *other
193    }
194}
195
196impl PartialEq<Name> for &str {
197    fn eq(&self, other: &Name) -> bool {
198        *self == other.0
199    }
200}
201
202impl PartialEq<Name> for str {
203    fn eq(&self, other: &Name) -> bool {
204        self == other.0
205    }
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211
212    #[test]
213    fn name_creation() {
214        assert!(Name::new("abcdefghijklmnopqrstuvwxyz-1234567890").is_ok());
215        assert!(Name::new("a".repeat(63)).is_ok());
216
217        assert_eq!(Name::new("a".repeat(64)), Err(InvalidName::TooLong));
218        assert_eq!(Name::new(""), Err(InvalidName::TooShort));
219        assert_eq!(Name::new("a_b"), Err(InvalidName::InvalidCharacters));
220        assert_eq!(Name::new("ABC"), Err(InvalidName::InvalidCharacters));
221        assert_eq!(Name::new("hi\n there"), Err(InvalidName::InvalidCharacters));
222        assert_eq!(Name::new("hi:there"), Err(InvalidName::InvalidCharacters));
223        assert_eq!(Name::new("a\u{00A7}b"), Err(InvalidName::InvalidCharacters));
224        assert_eq!(
225            Name::new("-hi-there"),
226            Err(InvalidName::NonAlphanumericStartOrEnd)
227        );
228    }
229
230    #[test]
231    fn name_serialization_deserialization() {
232        let name = Name::new("abcdefghijklmnopqrstuvwxyz-1234567890").unwrap();
233        let serialized = serde_json::to_string(&name).unwrap();
234        let deserialized: Name = serde_json::from_str(&serialized).unwrap();
235        assert_eq!(name, deserialized);
236
237        serde_json::from_str::<Name>("\"hi:there\"").unwrap_err();
238        serde_json::from_str::<Name>(r#""hi_there""#).unwrap_err();
239    }
240
241    #[test]
242    fn name_deserialization_error() {
243        assert_eq!(
244            serde_json::from_str::<Name>("\"-hi-there\"").map_err(|error| error.to_string()),
245            Err(
246                "name must start and end with an alphanumeric character at line 1 column 11"
247                    .to_owned()
248            )
249        );
250    }
251}