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#[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 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#[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 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
209impl 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
218fn 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}