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