#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "lowercase")]
pub enum TenantStatus {
Active,
Provisioning,
Degraded,
}
impl TenantStatus {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Active => "active",
Self::Provisioning => "provisioning",
Self::Degraded => "degraded",
}
}
#[must_use]
pub fn parse(status: &str) -> Self {
match status {
"active" => Self::Active,
"provisioning" => Self::Provisioning,
_ => Self::Degraded,
}
}
}
impl std::fmt::Display for TenantStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize)]
pub struct TenantId(String);
impl TenantId {
pub(crate) fn new(id: impl Into<String>) -> Self {
Self(id.into())
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl std::fmt::Display for TenantId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub struct Tenant {
id: TenantId,
status: TenantStatus,
}
impl Tenant {
pub(crate) fn new(id: impl Into<String>, status: TenantStatus) -> Self {
Self {
id: TenantId::new(id),
status,
}
}
#[must_use]
pub fn id(&self) -> &TenantId {
&self.id
}
#[must_use]
pub fn status(&self) -> TenantStatus {
self.status
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Resolution {
Found {
id: String,
status: TenantStatus,
},
Unknown,
}
impl Resolution {
pub(crate) fn admit(self) -> Result<Tenant, &'static crate::problems::ProblemDef> {
match self {
Self::Found { id, status } if status == TenantStatus::Active => {
Ok(Tenant::new(id, status))
}
Self::Found { .. } => Err(&crate::problems::SLUGS.tenant_degraded),
Self::Unknown => Err(&crate::problems::SLUGS.unknown_tenant),
}
}
}
pub trait ResolveTenant: Send + Sync {
fn resolve(&self, host: &str) -> Resolution;
}
#[async_trait::async_trait]
pub trait TenantDatabases: Send + Sync {
async fn database(
&self,
tenant: &Tenant,
) -> Result<std::sync::Arc<dyn crate::ports::Database>, TenantDbError>;
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TenantDbError {
Unreachable {
tenant: String,
},
}
impl std::fmt::Display for TenantDbError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Unreachable { tenant } => {
write!(f, "tenant `{tenant}`'s database is unreachable")
}
}
}
}
impl std::error::Error for TenantDbError {}
pub trait TenantRouting: ResolveTenant + TenantDatabases {}
impl<T: ResolveTenant + TenantDatabases> TenantRouting for T {}
pub const IMPLICIT_TENANT: &str = "default";
#[derive(Debug, Clone, Copy, Default)]
pub struct ImplicitTenant;
impl ResolveTenant for ImplicitTenant {
fn resolve(&self, _host: &str) -> Resolution {
Resolution::Found {
id: IMPLICIT_TENANT.to_owned(),
status: TenantStatus::Active,
}
}
}
#[cfg(test)]
mod tests {
use super::{Resolution, TenantStatus};
fn found(status: TenantStatus) -> Resolution {
Resolution::Found {
id: "acme".to_owned(),
status,
}
}
#[test]
fn the_implicit_tenant_answers_for_every_host() {
use super::{IMPLICIT_TENANT, ImplicitTenant, ResolveTenant};
for host in ["acme.example", "localhost:8787", ""] {
let tenant = ImplicitTenant
.resolve(host)
.admit()
.expect("the implicit tenant always serves");
assert_eq!(tenant.id().as_str(), IMPLICIT_TENANT);
assert_eq!(tenant.status(), TenantStatus::Active);
}
}
#[test]
fn only_an_active_tenant_is_admitted() {
let tenant = found(TenantStatus::Active)
.admit()
.expect("an active tenant serves");
assert_eq!(tenant.id().as_str(), "acme");
assert_eq!(tenant.status(), TenantStatus::Active);
}
#[test]
fn provisioning_is_refused_like_degraded_and_not_like_unknown() {
for status in [TenantStatus::Provisioning, TenantStatus::Degraded] {
let problem = found(status).admit().expect_err("must not serve");
assert_eq!(problem.slug, "tenant-degraded", "for {status}");
assert_eq!(problem.status.as_u16(), 503);
}
}
#[test]
fn an_unknown_host_is_a_404_not_a_503() {
let problem = Resolution::Unknown.admit().expect_err("must not serve");
assert_eq!(problem.slug, "unknown-tenant");
assert_eq!(problem.status.as_u16(), 404);
}
#[test]
fn a_status_this_binary_does_not_know_is_refused_not_guessed() {
let unknown = TenantStatus::parse("quiesced");
assert_eq!(unknown, TenantStatus::Degraded);
assert!(found(unknown).admit().is_err());
}
#[test]
fn serializes_as_the_registry_text_not_the_variant_name() {
for status in [
TenantStatus::Active,
TenantStatus::Provisioning,
TenantStatus::Degraded,
] {
let json = serde_json::to_string(&status).expect("a unit variant serializes");
assert_eq!(
json,
format!("\"{}\"", status.as_str()),
"the wire form is the registry's word, so a log field and a row agree"
);
}
}
#[test]
fn round_trips_through_the_registry_text() {
for status in [
TenantStatus::Active,
TenantStatus::Provisioning,
TenantStatus::Degraded,
] {
assert_eq!(TenantStatus::parse(status.as_str()), status);
}
}
#[test]
fn an_unknown_status_reads_as_degraded() {
assert_eq!(TenantStatus::parse("busy"), TenantStatus::Degraded);
assert_eq!(TenantStatus::parse(""), TenantStatus::Degraded);
}
}