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::{Generatable, SafeRand, SeedableRng};
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;
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 Self::random(&mut rng).map_err(|_| WorkspaceIdGenerationError)
139 }
140
141 pub fn as_str(&self) -> &str {
142 self.0.as_str()
143 }
144}
145
146impl PartialEq<&str> for WorkspaceId {
147 fn eq(&self, other: &&str) -> bool {
148 self.0.as_str() == *other
149 }
150}
151
152impl PartialEq<String> for WorkspaceId {
153 fn eq(&self, other: &String) -> bool {
154 self.0.as_str() == other.as_str()
155 }
156}
157
158impl TryFrom<String> for WorkspaceId {
159 type Error = InvalidWorkspaceId;
160
161 fn try_from(value: String) -> Result<Self, Self::Error> {
162 value.as_str().try_into()
163 }
164}
165
166impl TryFrom<&str> for WorkspaceId {
167 type Error = InvalidWorkspaceId;
168
169 fn try_from(value: &str) -> Result<Self, Self::Error> {
170 if is_valid_workspace_id(value) {
171 let mut array_str = WorkspaceIdArrayString::new();
172 array_str.push_str(value);
173 Ok(Self(array_str))
174 } else {
175 Err(InvalidWorkspaceId(value.to_string()))
176 }
177 }
178}
179
180impl FromStr for WorkspaceId {
181 type Err = InvalidWorkspaceId;
182
183 fn from_str(value: &str) -> Result<Self, Self::Err> {
184 Self::try_from(value)
185 }
186}
187
188impl From<WorkspaceId> for String {
189 fn from(value: WorkspaceId) -> Self {
190 value.0.to_string()
191 }
192}
193
194impl Generatable for WorkspaceId {
195 fn random(rng: &mut vitaminc::SafeRand) -> Result<Self, vitaminc::RandomError> {
196 let buf: [u8; WORKSPACE_ID_BYTE_LEN] = Generatable::random(rng)?;
197 let id = base32::encode(ALPHABET, &buf);
198 let mut array_str = WorkspaceIdArrayString::new();
199 array_str.push_str(&id);
200 Ok(Self(array_str))
201 }
202}
203
204impl Display for WorkspaceId {
205 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
206 write!(f, "{}", self.0)
207 }
208}
209
210#[cfg(feature = "server")]
212impl TryInto<HeaderValue> for WorkspaceId {
213 type Error = http::header::InvalidHeaderValue;
214
215 fn try_into(self) -> Result<HeaderValue, Self::Error> {
216 HeaderValue::from_str(self.0.as_str())
217 }
218}
219
220fn is_valid_workspace_id(workspace_id: &str) -> bool {
223 if let Some(bytes) = base32::decode(ALPHABET, workspace_id) {
224 bytes.len() == WORKSPACE_ID_BYTE_LEN
225 } else {
226 false
227 }
228}
229
230#[cfg(feature = "test_utils")]
231mod testing {
232 use super::*;
233 use fake::Faker;
234 use rand::Rng;
235
236 impl fake::Dummy<Faker> for WorkspaceId {
237 fn dummy_with_rng<R: Rng + ?Sized>(_: &Faker, _: &mut R) -> Self {
238 WorkspaceId::generate().unwrap()
239 }
240 }
241}
242
243#[cfg(feature = "server")]
244mod sql_types {
245 use super::WorkspaceId;
246 use diesel::{
247 backend::Backend,
248 deserialize::{self, FromSql},
249 serialize::{self, Output, ToSql},
250 sql_types::Text,
251 };
252
253 impl<DB> ToSql<Text, DB> for WorkspaceId
254 where
255 DB: Backend,
256 str: ToSql<Text, DB>,
257 {
258 fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, DB>) -> serialize::Result {
259 self.0.to_sql(out)
260 }
261 }
262
263 impl<DB> FromSql<Text, DB> for WorkspaceId
264 where
265 DB: Backend,
266 String: FromSql<Text, DB>,
267 {
268 fn from_sql(bytes: DB::RawValue<'_>) -> deserialize::Result<Self> {
269 let raw = String::from_sql(bytes)?;
270 let workspace_id = WorkspaceId::try_from(raw)?;
271
272 Ok(workspace_id)
273 }
274 }
275}
276
277#[cfg(test)]
278mod tests {
279 use super::*;
280 use vitaminc::SeedableRng;
281
282 mod workspace_id {
283 use super::*;
284
285 #[test]
286 fn generation_is_valid() {
287 let mut rng = vitaminc::SafeRand::from_entropy();
288 let id = WorkspaceId::random(&mut rng).unwrap();
289 assert!(WorkspaceId::try_from(id.to_string()).is_ok());
290 }
291
292 #[test]
293 fn invalid_id() {
294 assert!(WorkspaceId::try_from("invalid-id").is_err());
295 }
296 }
297
298 mod workspace {
299 use super::*;
300
301 #[test]
302 fn serialize() -> anyhow::Result<()> {
303 let workspace = Workspace {
304 id: WorkspaceId::generate()?,
305 region: Region::new("us-west-1.aws")?,
306 name: "test-workspace".to_string(),
307 };
308
309 let serialized = serde_json::to_string(&workspace)?;
310 assert_eq!(
311 serialized,
312 format!(
313 "{{\"id\":\"{}\",\"region\":\"us-west-1.aws\",\"name\":\"test-workspace\"}}",
314 workspace.id
315 )
316 );
317
318 Ok(())
319 }
320
321 #[test]
322 fn desirialise_with_null_workspace_name() {
323 let mut rng = vitaminc::SafeRand::from_entropy();
324 let id = WorkspaceId::random(&mut rng).unwrap();
325 let serialised =
326 format!("{{\"id\":\"{id}\",\"region\":\"us-west-1.aws\",\"name\":null}}",);
327
328 let deserialized: Workspace = serde_json::from_str(&serialised).unwrap();
329 assert_eq!("unnamed workspace".to_string(), deserialized.name,);
330 }
331 }
332}