use crate::{AsCrn, Crn, Region};
use arrayvec::ArrayString;
#[cfg(feature = "server")]
use http::HeaderValue;
use miette::Diagnostic;
use serde::{Deserialize, Deserializer, Serialize};
use std::{fmt::Display, str::FromStr};
use thiserror::Error;
use utoipa::ToSchema;
use vitaminc::encrypt::{Aad, IntoAad};
use vitaminc::random::{Generatable, SafeRand};
const WORKSPACE_ID_BYTE_LEN: usize = 10;
const WORKSPACE_ID_ENCODED_LEN: usize = 16;
const ALPHABET: base32::Alphabet = base32::Alphabet::Rfc4648 { padding: false };
type WorkspaceIdArrayString = ArrayString<WORKSPACE_ID_ENCODED_LEN>;
#[derive(Error, Debug, Diagnostic)]
#[error("Invalid workspace ID: {0}")]
#[diagnostic(help = "Workspace IDs are 10-byte random strings formatted in base32.")]
pub struct InvalidWorkspaceId(String);
#[derive(Error, Debug)]
#[error("Failed to generate workspace ID")]
pub struct WorkspaceIdGenerationError(#[from] vitaminc::random::RandomError);
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Workspace {
id: WorkspaceId,
region: Region,
#[serde(default = "default_workspace_name")]
#[serde(deserialize_with = "deserialize_workspace_name")]
name: String,
}
impl AsCrn for Workspace {
fn as_crn(&self) -> crate::Crn {
Crn::new(self.region, self.id)
}
}
fn deserialize_workspace_name<'d, D>(deserializer: D) -> Result<String, D::Error>
where
D: Deserializer<'d>,
{
let opt = Option::deserialize(deserializer)?;
Ok(opt.unwrap_or("unnamed workspace".to_string()))
}
impl Workspace {
pub fn new(id: WorkspaceId, region: Region, name: impl Into<String>) -> Self {
Self {
id,
region,
name: name.into(),
}
}
pub fn id(&self) -> WorkspaceId {
self.id
}
pub fn crn(&self) -> Crn {
Crn::new(self.region, self.id)
}
pub fn name(&self) -> &str {
self.name.as_str()
}
pub fn region(&self) -> Region {
self.region
}
}
fn default_workspace_name() -> String {
"Default".to_string()
}
#[derive(Debug, Copy, Clone, Eq, PartialEq, Serialize, Deserialize, ToSchema)]
#[serde(transparent)]
#[cfg_attr(
feature = "server",
derive(diesel::expression::AsExpression, diesel::deserialize::FromSqlRow)
)]
#[schema(value_type = String, example = "JBSWY3DPEHPK3PXP")]
#[cfg_attr(feature = "server", diesel(sql_type = diesel::sql_types::Text))]
pub struct WorkspaceId(WorkspaceIdArrayString);
impl WorkspaceId {
pub fn generate() -> Result<Self, WorkspaceIdGenerationError> {
let mut rng = SafeRand::from_entropy()?;
Ok(Self::random(&mut rng)?)
}
pub fn as_str(&self) -> &str {
self.0.as_str()
}
}
impl<'a> IntoAad<'a> for WorkspaceId {
fn into_aad(self) -> Aad<'a> {
Aad::new_owned(self.as_str().bytes())
}
}
impl PartialEq<&str> for WorkspaceId {
fn eq(&self, other: &&str) -> bool {
self.0.as_str() == *other
}
}
impl PartialEq<String> for WorkspaceId {
fn eq(&self, other: &String) -> bool {
self.0.as_str() == other.as_str()
}
}
impl TryFrom<String> for WorkspaceId {
type Error = InvalidWorkspaceId;
fn try_from(value: String) -> Result<Self, Self::Error> {
value.as_str().try_into()
}
}
impl TryFrom<&str> for WorkspaceId {
type Error = InvalidWorkspaceId;
fn try_from(value: &str) -> Result<Self, Self::Error> {
if is_valid_workspace_id(value) {
let mut array_str = WorkspaceIdArrayString::new();
array_str.push_str(value);
Ok(Self(array_str))
} else {
Err(InvalidWorkspaceId(value.to_string()))
}
}
}
impl FromStr for WorkspaceId {
type Err = InvalidWorkspaceId;
fn from_str(value: &str) -> Result<Self, Self::Err> {
Self::try_from(value)
}
}
impl From<WorkspaceId> for String {
fn from(value: WorkspaceId) -> Self {
value.0.to_string()
}
}
impl Generatable for WorkspaceId {
fn random(rng: &mut vitaminc::random::SafeRand) -> Result<Self, vitaminc::random::RandomError> {
let buf: [u8; WORKSPACE_ID_BYTE_LEN] = Generatable::random(rng)?;
let id = base32::encode(ALPHABET, &buf);
let mut array_str = WorkspaceIdArrayString::new();
array_str.push_str(&id);
Ok(Self(array_str))
}
}
impl Display for WorkspaceId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
#[cfg(feature = "server")]
impl TryInto<HeaderValue> for WorkspaceId {
type Error = http::header::InvalidHeaderValue;
fn try_into(self) -> Result<HeaderValue, Self::Error> {
HeaderValue::from_str(self.0.as_str())
}
}
fn is_valid_workspace_id(workspace_id: &str) -> bool {
if let Some(bytes) = base32::decode(ALPHABET, workspace_id) {
bytes.len() == WORKSPACE_ID_BYTE_LEN
} else {
false
}
}
#[cfg(feature = "test_utils")]
mod testing {
use super::*;
use fake::Faker;
use rand::Rng;
impl fake::Dummy<Faker> for WorkspaceId {
fn dummy_with_rng<R: Rng + ?Sized>(_: &Faker, _: &mut R) -> Self {
WorkspaceId::generate().unwrap()
}
}
}
#[cfg(feature = "server")]
mod sql_types {
use super::WorkspaceId;
use diesel::{
backend::Backend,
deserialize::{self, FromSql},
serialize::{self, Output, ToSql},
sql_types::Text,
};
impl<DB> ToSql<Text, DB> for WorkspaceId
where
DB: Backend,
str: ToSql<Text, DB>,
{
fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, DB>) -> serialize::Result {
self.0.to_sql(out)
}
}
impl<DB> FromSql<Text, DB> for WorkspaceId
where
DB: Backend,
String: FromSql<Text, DB>,
{
fn from_sql(bytes: DB::RawValue<'_>) -> deserialize::Result<Self> {
let raw = String::from_sql(bytes)?;
let workspace_id = WorkspaceId::try_from(raw)?;
Ok(workspace_id)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
mod workspace_id {
use super::*;
#[test]
fn generation_is_valid() {
let mut rng = vitaminc::random::SafeRand::from_entropy().unwrap();
let id = WorkspaceId::random(&mut rng).unwrap();
assert!(WorkspaceId::try_from(id.to_string()).is_ok());
}
#[test]
fn invalid_id() {
assert!(WorkspaceId::try_from("invalid-id").is_err());
}
}
mod workspace {
use super::*;
#[test]
fn serialize() -> anyhow::Result<()> {
let workspace = Workspace {
id: WorkspaceId::generate()?,
region: Region::new("us-west-1.aws")?,
name: "test-workspace".to_string(),
};
let serialized = serde_json::to_string(&workspace)?;
assert_eq!(
serialized,
format!(
"{{\"id\":\"{}\",\"region\":\"us-west-1.aws\",\"name\":\"test-workspace\"}}",
workspace.id
)
);
Ok(())
}
#[test]
fn desirialise_with_null_workspace_name() {
let mut rng = vitaminc::random::SafeRand::from_entropy().unwrap();
let id = WorkspaceId::random(&mut rng).unwrap();
let serialised =
format!("{{\"id\":\"{id}\",\"region\":\"us-west-1.aws\",\"name\":null}}",);
let deserialized: Workspace = serde_json::from_str(&serialised).unwrap();
assert_eq!("unnamed workspace".to_string(), deserialized.name,);
}
}
}