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