1use crate::Region;
2use arrayvec::ArrayString;
3use http::HeaderValue;
4use miette::Diagnostic;
5use serde::{Deserialize, Deserializer, Serialize};
6use std::fmt::Display;
7use thiserror::Error;
8use vitaminc::random::{Generatable, SafeRand, SeedableRng};
9
10const WORKSPACE_ID_BYTE_LEN: usize = 10;
11const WORKSPACE_ID_ENCODED_LEN: usize = 20;
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
35fn deserialize_workspace_name<'d, D>(deserializer: D) -> Result<String, D::Error>
36where
37 D: Deserializer<'d>,
38{
39 let opt = Option::deserialize(deserializer)?;
40 Ok(opt.unwrap_or("unnamed workspace".to_string()))
41}
42
43impl Workspace {
44 pub fn id(&self) -> WorkspaceId {
47 self.id
48 }
49
50 pub fn name(&self) -> &str {
51 self.name.as_str()
52 }
53
54 pub fn region(&self) -> Region {
55 self.region
56 }
57}
58
59fn default_workspace_name() -> String {
60 "Default".to_string()
61}
62
63#[derive(Debug, Copy, Clone, PartialEq, Serialize, Deserialize)]
104#[serde(transparent)]
105#[cfg_attr(
106 feature = "server",
107 derive(diesel::expression::AsExpression, diesel::deserialize::FromSqlRow)
108)]
109#[cfg_attr(feature = "server", diesel(sql_type = diesel::sql_types::Text))]
110pub struct WorkspaceId(WorkspaceIdArrayString);
111
112impl WorkspaceId {
113 pub fn generate() -> Result<Self, WorkspaceIdGenerationError> {
116 let mut rng = SafeRand::from_entropy();
117 Self::random(&mut rng).map_err(|_| WorkspaceIdGenerationError)
118 }
119
120 pub fn as_str(&self) -> &str {
121 self.0.as_str()
122 }
123}
124
125impl PartialEq<&str> for WorkspaceId {
126 fn eq(&self, other: &&str) -> bool {
127 self.0.as_str() == *other
128 }
129}
130
131impl PartialEq<String> for WorkspaceId {
132 fn eq(&self, other: &String) -> bool {
133 self.0.as_str() == other.as_str()
134 }
135}
136
137impl TryFrom<String> for WorkspaceId {
138 type Error = InvalidWorkspaceId;
139
140 fn try_from(value: String) -> Result<Self, Self::Error> {
141 value.as_str().try_into()
142 }
143}
144
145impl TryFrom<&str> for WorkspaceId {
146 type Error = InvalidWorkspaceId;
147
148 fn try_from(value: &str) -> Result<Self, Self::Error> {
149 if is_valid_workspace_id(value) {
150 let mut array_str = WorkspaceIdArrayString::new();
151 array_str.push_str(value);
152 Ok(Self(array_str))
153 } else {
154 Err(InvalidWorkspaceId(value.to_string()))
155 }
156 }
157}
158
159impl From<WorkspaceId> for String {
160 fn from(value: WorkspaceId) -> Self {
161 value.0.to_string()
162 }
163}
164
165impl Generatable for WorkspaceId {
166 fn random(rng: &mut vitaminc::random::SafeRand) -> Result<Self, vitaminc::random::RandomError> {
167 let buf: [u8; WORKSPACE_ID_BYTE_LEN] = Generatable::random(rng)?;
168 let id = base32::encode(ALPHABET, &buf);
169 let mut array_str = WorkspaceIdArrayString::new();
170 array_str.push_str(&id);
171 Ok(Self(array_str))
172 }
173}
174
175impl Display for WorkspaceId {
176 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
177 write!(f, "{}", self.0)
178 }
179}
180
181impl TryInto<HeaderValue> for WorkspaceId {
183 type Error = http::header::InvalidHeaderValue;
184
185 fn try_into(self) -> Result<HeaderValue, Self::Error> {
186 HeaderValue::from_str(self.0.as_str())
187 }
188}
189
190fn is_valid_workspace_id(workspace_id: &str) -> bool {
193 if let Some(bytes) = base32::decode(ALPHABET, workspace_id) {
194 bytes.len() == WORKSPACE_ID_BYTE_LEN
195 } else {
196 false
197 }
198}
199
200#[cfg(feature = "test_utils")]
201mod testing {
202 use super::*;
203 use fake::Faker;
204 use rand::Rng;
205
206 impl fake::Dummy<Faker> for WorkspaceId {
207 fn dummy_with_rng<R: Rng + ?Sized>(_: &Faker, _: &mut R) -> Self {
208 WorkspaceId::generate().unwrap()
209 }
210 }
211}
212
213#[cfg(feature = "server")]
214mod sql_types {
215 use super::WorkspaceId;
216 use diesel::{
217 backend::Backend,
218 deserialize::{self, FromSql},
219 serialize::{self, Output, ToSql},
220 sql_types::Text,
221 };
222
223 impl<DB> ToSql<Text, DB> for WorkspaceId
224 where
225 DB: Backend,
226 str: ToSql<Text, DB>,
227 {
228 fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, DB>) -> serialize::Result {
229 self.0.to_sql(out)
230 }
231 }
232
233 impl<DB> FromSql<Text, DB> for WorkspaceId
234 where
235 DB: Backend,
236 String: FromSql<Text, DB>,
237 {
238 fn from_sql(bytes: DB::RawValue<'_>) -> deserialize::Result<Self> {
239 let raw = String::from_sql(bytes)?;
240 let workspace_id = WorkspaceId::try_from(raw)?;
241
242 Ok(workspace_id)
243 }
244 }
245}
246
247#[cfg(test)]
248mod tests {
249 use super::*;
250 use vitaminc::random::SeedableRng;
251
252 mod workspace_id {
253 use super::*;
254
255 #[test]
256 fn generation_is_valid() {
257 let mut rng = vitaminc::random::SafeRand::from_entropy();
258 let id = WorkspaceId::random(&mut rng).unwrap();
259 assert!(WorkspaceId::try_from(id.to_string()).is_ok());
260 }
261
262 #[test]
263 fn invalid_id() {
264 assert!(WorkspaceId::try_from("invalid-id").is_err());
265 }
266 }
267
268 mod workspace {
269 use super::*;
270
271 #[test]
272 fn serialize() -> anyhow::Result<()> {
273 let workspace = Workspace {
274 id: WorkspaceId::generate()?,
275 region: Region::new("us-west-1.aws")?,
276 name: "test-workspace".to_string(),
277 };
278
279 let serialized = serde_json::to_string(&workspace)?;
280 assert_eq!(
281 serialized,
282 format!(
283 "{{\"id\":\"{}\",\"region\":\"us-west-1.aws\",\"name\":\"test-workspace\"}}",
284 workspace.id
285 )
286 );
287
288 Ok(())
289 }
290
291 #[test]
292 fn desirialise_with_null_workspace_name() {
293 let mut rng = vitaminc::random::SafeRand::from_entropy();
294 let id = WorkspaceId::random(&mut rng).unwrap();
295 let serialised = format!(
296 "{{\"id\":\"{}\",\"region\":\"us-west-1.aws\",\"name\":null}}",
297 id
298 );
299
300 let deserialized: Workspace = serde_json::from_str(&serialised).unwrap();
301 assert_eq!("unnamed workspace".to_string(), deserialized.name,);
302 }
303 }
304}