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