1use crate::{AsCrn, Crn, Region};
2use arrayvec::ArrayString;
3#[cfg(feature = "server")]
4use http::HeaderValue;
5use miette::Diagnostic;
6use serde::{Deserialize, Deserializer, Serialize};
7use std::{fmt::Display, str::FromStr};
8use thiserror::Error;
9use utoipa::ToSchema;
10use vitaminc::{Aad, Generatable, IntoAad, SafeRand};
11
12const WORKSPACE_ID_BYTE_LEN: usize = 10;
13const WORKSPACE_ID_ENCODED_LEN: usize = 16;
14const ALPHABET: base32::Alphabet = base32::Alphabet::Rfc4648 { padding: false };
15
16type WorkspaceIdArrayString = ArrayString<WORKSPACE_ID_ENCODED_LEN>;
17
18#[derive(Error, Debug, Diagnostic)]
19#[error("Invalid workspace ID: {0}")]
20#[diagnostic(help = "Workspace IDs are 10-byte random strings formatted in base32.")]
21pub struct InvalidWorkspaceId(String);
22
23#[derive(Error, Debug)]
24#[error("Failed to generate workspace ID")]
25pub struct WorkspaceIdGenerationError(#[from] vitaminc::RandomError);
26
27#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
29pub struct Workspace {
30 id: WorkspaceId,
31 region: Region,
32 #[serde(default = "default_workspace_name")]
33 #[serde(deserialize_with = "deserialize_workspace_name")]
34 name: String,
35}
36
37impl AsCrn for Workspace {
38 fn as_crn(&self) -> crate::Crn {
39 Crn::new(self.region, self.id)
40 }
41}
42
43fn deserialize_workspace_name<'d, D>(deserializer: D) -> Result<String, D::Error>
44where
45 D: Deserializer<'d>,
46{
47 let opt = Option::deserialize(deserializer)?;
48 Ok(opt.unwrap_or("unnamed workspace".to_string()))
49}
50
51impl Workspace {
52 pub fn new(id: WorkspaceId, region: Region, name: impl Into<String>) -> Self {
53 Self {
54 id,
55 region,
56 name: name.into(),
57 }
58 }
59
60 pub fn id(&self) -> WorkspaceId {
63 self.id
64 }
65
66 pub fn crn(&self) -> Crn {
67 Crn::new(self.region, self.id)
68 }
69
70 pub fn name(&self) -> &str {
71 self.name.as_str()
72 }
73
74 pub fn region(&self) -> Region {
75 self.region
76 }
77}
78
79fn default_workspace_name() -> String {
80 "Default".to_string()
81}
82
83#[derive(Debug, Copy, Clone, Eq, PartialEq, Serialize, Deserialize, ToSchema)]
124#[serde(transparent)]
125#[cfg_attr(
126 feature = "server",
127 derive(diesel::expression::AsExpression, diesel::deserialize::FromSqlRow)
128)]
129#[schema(value_type = String, example = "JBSWY3DPEHPK3PXP")]
130#[cfg_attr(feature = "server", diesel(sql_type = diesel::sql_types::Text))]
131pub struct WorkspaceId(WorkspaceIdArrayString);
132
133impl WorkspaceId {
134 pub fn generate() -> Result<Self, WorkspaceIdGenerationError> {
137 let mut rng = SafeRand::from_entropy()?;
138 Ok(Self::random(&mut rng)?)
139 }
140
141 pub fn as_str(&self) -> &str {
142 self.0.as_str()
143 }
144}
145
146impl<'a> IntoAad<'a> for WorkspaceId {
154 fn into_aad(self) -> Aad<'a> {
155 Aad::new_owned(self.as_str().bytes())
156 }
157}
158
159impl PartialEq<&str> for WorkspaceId {
160 fn eq(&self, other: &&str) -> bool {
161 self.0.as_str() == *other
162 }
163}
164
165impl PartialEq<String> for WorkspaceId {
166 fn eq(&self, other: &String) -> bool {
167 self.0.as_str() == other.as_str()
168 }
169}
170
171impl TryFrom<String> for WorkspaceId {
172 type Error = InvalidWorkspaceId;
173
174 fn try_from(value: String) -> Result<Self, Self::Error> {
175 value.as_str().try_into()
176 }
177}
178
179impl TryFrom<&str> for WorkspaceId {
180 type Error = InvalidWorkspaceId;
181
182 fn try_from(value: &str) -> Result<Self, Self::Error> {
183 if is_valid_workspace_id(value) {
184 let mut array_str = WorkspaceIdArrayString::new();
185 array_str.push_str(value);
186 Ok(Self(array_str))
187 } else {
188 Err(InvalidWorkspaceId(value.to_string()))
189 }
190 }
191}
192
193impl FromStr for WorkspaceId {
194 type Err = InvalidWorkspaceId;
195
196 fn from_str(value: &str) -> Result<Self, Self::Err> {
197 Self::try_from(value)
198 }
199}
200
201impl From<WorkspaceId> for String {
202 fn from(value: WorkspaceId) -> Self {
203 value.0.to_string()
204 }
205}
206
207impl Generatable for WorkspaceId {
208 fn random(rng: &mut vitaminc::SafeRand) -> Result<Self, vitaminc::RandomError> {
209 let buf: [u8; WORKSPACE_ID_BYTE_LEN] = Generatable::random(rng)?;
210 let id = base32::encode(ALPHABET, &buf);
211 let mut array_str = WorkspaceIdArrayString::new();
212 array_str.push_str(&id);
213 Ok(Self(array_str))
214 }
215}
216
217impl Display for WorkspaceId {
218 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
219 write!(f, "{}", self.0)
220 }
221}
222
223#[cfg(feature = "server")]
225impl TryInto<HeaderValue> for WorkspaceId {
226 type Error = http::header::InvalidHeaderValue;
227
228 fn try_into(self) -> Result<HeaderValue, Self::Error> {
229 HeaderValue::from_str(self.0.as_str())
230 }
231}
232
233fn is_valid_workspace_id(workspace_id: &str) -> bool {
236 if let Some(bytes) = base32::decode(ALPHABET, workspace_id) {
237 bytes.len() == WORKSPACE_ID_BYTE_LEN
238 } else {
239 false
240 }
241}
242
243#[cfg(feature = "test_utils")]
244mod testing {
245 use super::*;
246 use fake::Faker;
247 use rand::Rng;
248
249 impl fake::Dummy<Faker> for WorkspaceId {
250 fn dummy_with_rng<R: Rng + ?Sized>(_: &Faker, _: &mut R) -> Self {
251 WorkspaceId::generate().unwrap()
252 }
253 }
254}
255
256#[cfg(feature = "server")]
257mod sql_types {
258 use super::WorkspaceId;
259 use diesel::{
260 backend::Backend,
261 deserialize::{self, FromSql},
262 serialize::{self, Output, ToSql},
263 sql_types::Text,
264 };
265
266 impl<DB> ToSql<Text, DB> for WorkspaceId
267 where
268 DB: Backend,
269 str: ToSql<Text, DB>,
270 {
271 fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, DB>) -> serialize::Result {
272 self.0.to_sql(out)
273 }
274 }
275
276 impl<DB> FromSql<Text, DB> for WorkspaceId
277 where
278 DB: Backend,
279 String: FromSql<Text, DB>,
280 {
281 fn from_sql(bytes: DB::RawValue<'_>) -> deserialize::Result<Self> {
282 let raw = String::from_sql(bytes)?;
283 let workspace_id = WorkspaceId::try_from(raw)?;
284
285 Ok(workspace_id)
286 }
287 }
288}
289
290#[cfg(test)]
291mod tests {
292 use super::*;
293
294 mod workspace_id {
295 use super::*;
296
297 #[test]
298 fn generation_is_valid() {
299 let mut rng = vitaminc::SafeRand::from_entropy().unwrap();
300 let id = WorkspaceId::random(&mut rng).unwrap();
301 assert!(WorkspaceId::try_from(id.to_string()).is_ok());
302 }
303
304 #[test]
305 fn invalid_id() {
306 assert!(WorkspaceId::try_from("invalid-id").is_err());
307 }
308 }
309
310 mod workspace {
311 use super::*;
312
313 #[test]
314 fn serialize() -> anyhow::Result<()> {
315 let workspace = Workspace {
316 id: WorkspaceId::generate()?,
317 region: Region::new("us-west-1.aws")?,
318 name: "test-workspace".to_string(),
319 };
320
321 let serialized = serde_json::to_string(&workspace)?;
322 assert_eq!(
323 serialized,
324 format!(
325 "{{\"id\":\"{}\",\"region\":\"us-west-1.aws\",\"name\":\"test-workspace\"}}",
326 workspace.id
327 )
328 );
329
330 Ok(())
331 }
332
333 #[test]
334 fn desirialise_with_null_workspace_name() {
335 let mut rng = vitaminc::SafeRand::from_entropy().unwrap();
336 let id = WorkspaceId::random(&mut rng).unwrap();
337 let serialised =
338 format!("{{\"id\":\"{id}\",\"region\":\"us-west-1.aws\",\"name\":null}}",);
339
340 let deserialized: Workspace = serde_json::from_str(&serialised).unwrap();
341 assert_eq!("unnamed workspace".to_string(), deserialized.name,);
342 }
343 }
344}