cts_common/
workspace.rs

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