cts_common/
workspace.rs

1use crate::Region;
2use arrayvec::ArrayString;
3use http::HeaderValue;
4use miette::Diagnostic;
5use serde::{Deserialize, Deserializer, Serialize};
6use std::fmt::Display;
7use thiserror::Error;
8use vitaminc::random::{Generatable, SafeRand, SeedableRng};
9
10const WORKSPACE_ID_BYTE_LEN: usize = 10;
11const WORKSPACE_ID_ENCODED_LEN: usize = 20;
12const ALPHABET: base32::Alphabet = base32::Alphabet::Rfc4648 { padding: false };
13
14type WorkspaceIdArrayString = ArrayString<WORKSPACE_ID_ENCODED_LEN>;
15
16#[derive(Error, Debug, Diagnostic)]
17#[error("Invalid workspace ID: {0}")]
18#[diagnostic(help = "Workspace IDs are 10-byte random strings formatted in base32.")]
19pub struct InvalidWorkspaceId(String);
20
21#[derive(Error, Debug)]
22#[error("Failed to generate workspace ID")]
23pub struct WorkspaceIdGenerationError;
24
25/// Defines a workspace.
26#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
27pub struct Workspace {
28    id: WorkspaceId,
29    region: Region,
30    #[serde(default = "default_workspace_name")]
31    #[serde(deserialize_with = "deserialize_workspace_name")]
32    name: String,
33}
34
35fn deserialize_workspace_name<'d, D>(deserializer: D) -> Result<String, D::Error>
36where
37    D: Deserializer<'d>,
38{
39    let opt = Option::deserialize(deserializer)?;
40    Ok(opt.unwrap_or("unnamed workspace".to_string()))
41}
42
43impl Workspace {
44    /// The unique identifier of the workspace.
45    /// See [WorkspaceId] for more information.
46    pub fn id(&self) -> WorkspaceId {
47        self.id
48    }
49
50    pub fn name(&self) -> &str {
51        self.name.as_str()
52    }
53
54    pub fn region(&self) -> Region {
55        self.region
56    }
57}
58
59fn default_workspace_name() -> String {
60    "Default".to_string()
61}
62
63/// A unique identifier for a workspace.
64/// Workspace IDs are 10-byte random strings formatted in base32.
65///
66/// Internally, the workspace ID is stored as an [ArrayString] with a maximum length of 20 characters.
67/// This means that values work entirely on the stack and implement the `Copy` trait.
68///
69/// # Example
70///
71/// ```
72/// use cts_common::WorkspaceId;
73///
74/// let workspace_id = WorkspaceId::generate().unwrap();
75/// println!("Workspace ID: {}", workspace_id);
76/// ```
77///
78/// A [WorkspaceId] can be converted from a string but will fail if the string is not a valid workspace ID.
79///
80/// ```
81/// use cts_common::WorkspaceId;
82/// let workspace_id = WorkspaceId::try_from("JBSWY3DPEHPK3PXP").unwrap();
83///
84/// // This will fail because the string is not a valid workspace ID
85/// let workspace_id = WorkspaceId::try_from("invalid-id").unwrap_err();
86/// ```
87///
88/// ## Comparison
89///
90/// Workspace IDs can be compared to strings.
91///
92/// ```
93/// use cts_common::WorkspaceId;
94/// let workspace_id = WorkspaceId::try_from("E4UMRN47WJNSMAKR").unwrap();
95/// assert_eq!(workspace_id, "E4UMRN47WJNSMAKR");
96/// ```
97///
98/// ## Use with Diesel
99///
100/// When the `server` feature is enabled, [WorkspaceId] can be used with Diesel in models and queries.
101/// The underlying data type is a `Text` column in the database.
102///
103#[derive(Debug, Copy, Clone, PartialEq, Serialize, Deserialize)]
104#[serde(transparent)]
105#[cfg_attr(
106    feature = "server",
107    derive(diesel::expression::AsExpression, diesel::deserialize::FromSqlRow)
108)]
109#[cfg_attr(feature = "server", diesel(sql_type = diesel::sql_types::Text))]
110pub struct WorkspaceId(WorkspaceIdArrayString);
111
112impl WorkspaceId {
113    /// Generate a new workspace ID with an entropy source.
114    /// To use a [SafeRand] instance, use the [`Generatable::random`] method instead.
115    pub fn generate() -> Result<Self, WorkspaceIdGenerationError> {
116        let mut rng = SafeRand::from_entropy();
117        Self::random(&mut rng).map_err(|_| WorkspaceIdGenerationError)
118    }
119
120    pub fn as_str(&self) -> &str {
121        self.0.as_str()
122    }
123}
124
125impl PartialEq<&str> for WorkspaceId {
126    fn eq(&self, other: &&str) -> bool {
127        self.0.as_str() == *other
128    }
129}
130
131impl PartialEq<String> for WorkspaceId {
132    fn eq(&self, other: &String) -> bool {
133        self.0.as_str() == other.as_str()
134    }
135}
136
137impl TryFrom<String> for WorkspaceId {
138    type Error = InvalidWorkspaceId;
139
140    fn try_from(value: String) -> Result<Self, Self::Error> {
141        value.as_str().try_into()
142    }
143}
144
145impl TryFrom<&str> for WorkspaceId {
146    type Error = InvalidWorkspaceId;
147
148    fn try_from(value: &str) -> Result<Self, Self::Error> {
149        if is_valid_workspace_id(value) {
150            let mut array_str = WorkspaceIdArrayString::new();
151            array_str.push_str(value);
152            Ok(Self(array_str))
153        } else {
154            Err(InvalidWorkspaceId(value.to_string()))
155        }
156    }
157}
158
159impl From<WorkspaceId> for String {
160    fn from(value: WorkspaceId) -> Self {
161        value.0.to_string()
162    }
163}
164
165impl Generatable for WorkspaceId {
166    fn random(rng: &mut vitaminc::random::SafeRand) -> Result<Self, vitaminc::random::RandomError> {
167        let buf: [u8; WORKSPACE_ID_BYTE_LEN] = Generatable::random(rng)?;
168        let id = base32::encode(ALPHABET, &buf);
169        let mut array_str = WorkspaceIdArrayString::new();
170        array_str.push_str(&id);
171        Ok(Self(array_str))
172    }
173}
174
175impl Display for WorkspaceId {
176    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
177        write!(f, "{}", self.0)
178    }
179}
180
181/// Workspace IDs can be converted into HTTP header values.
182impl TryInto<HeaderValue> for WorkspaceId {
183    type Error = http::header::InvalidHeaderValue;
184
185    fn try_into(self) -> Result<HeaderValue, Self::Error> {
186        HeaderValue::from_str(self.0.as_str())
187    }
188}
189
190/// Check if a workspace ID is valid.
191/// A valid workspace ID is a base32 encoded string with a length of 10 bytes.
192fn is_valid_workspace_id(workspace_id: &str) -> bool {
193    if let Some(bytes) = base32::decode(ALPHABET, workspace_id) {
194        bytes.len() == WORKSPACE_ID_BYTE_LEN
195    } else {
196        false
197    }
198}
199
200#[cfg(feature = "test_utils")]
201mod testing {
202    use super::*;
203    use fake::Faker;
204    use rand::Rng;
205
206    impl fake::Dummy<Faker> for WorkspaceId {
207        fn dummy_with_rng<R: Rng + ?Sized>(_: &Faker, _: &mut R) -> Self {
208            WorkspaceId::generate().unwrap()
209        }
210    }
211}
212
213#[cfg(feature = "server")]
214mod sql_types {
215    use super::WorkspaceId;
216    use diesel::{
217        backend::Backend,
218        deserialize::{self, FromSql},
219        serialize::{self, Output, ToSql},
220        sql_types::Text,
221    };
222
223    impl<DB> ToSql<Text, DB> for WorkspaceId
224    where
225        DB: Backend,
226        str: ToSql<Text, DB>,
227    {
228        fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, DB>) -> serialize::Result {
229            self.0.to_sql(out)
230        }
231    }
232
233    impl<DB> FromSql<Text, DB> for WorkspaceId
234    where
235        DB: Backend,
236        String: FromSql<Text, DB>,
237    {
238        fn from_sql(bytes: DB::RawValue<'_>) -> deserialize::Result<Self> {
239            let raw = String::from_sql(bytes)?;
240            let workspace_id = WorkspaceId::try_from(raw)?;
241
242            Ok(workspace_id)
243        }
244    }
245}
246
247#[cfg(test)]
248mod tests {
249    use super::*;
250    use vitaminc::random::SeedableRng;
251
252    mod workspace_id {
253        use super::*;
254
255        #[test]
256        fn generation_is_valid() {
257            let mut rng = vitaminc::random::SafeRand::from_entropy();
258            let id = WorkspaceId::random(&mut rng).unwrap();
259            assert!(WorkspaceId::try_from(id.to_string()).is_ok());
260        }
261
262        #[test]
263        fn invalid_id() {
264            assert!(WorkspaceId::try_from("invalid-id").is_err());
265        }
266    }
267
268    mod workspace {
269        use super::*;
270
271        #[test]
272        fn serialize() -> anyhow::Result<()> {
273            let workspace = Workspace {
274                id: WorkspaceId::generate()?,
275                region: Region::new("us-west-1.aws")?,
276                name: "test-workspace".to_string(),
277            };
278
279            let serialized = serde_json::to_string(&workspace)?;
280            assert_eq!(
281                serialized,
282                format!(
283                    "{{\"id\":\"{}\",\"region\":\"us-west-1.aws\",\"name\":\"test-workspace\"}}",
284                    workspace.id
285                )
286            );
287
288            Ok(())
289        }
290
291        #[test]
292        fn desirialise_with_null_workspace_name() {
293            let mut rng = vitaminc::random::SafeRand::from_entropy();
294            let id = WorkspaceId::random(&mut rng).unwrap();
295            let serialised = format!(
296                "{{\"id\":\"{}\",\"region\":\"us-west-1.aws\",\"name\":null}}",
297                id
298            );
299
300            let deserialized: Workspace = serde_json::from_str(&serialised).unwrap();
301            assert_eq!("unnamed workspace".to_string(), deserialized.name,);
302        }
303    }
304}