use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DomainEntry {
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
pub scheme: DomainUrlScheme,
pub status: DomainStatus,
pub created_at: u64,
#[serde(default)]
pub default_domain: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub branding: Option<DomainBranding>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub witnesses: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub watchers: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub quota: Option<DomainQuota>,
#[serde(default)]
pub well_known_enabled: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub disabled_at: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub purge_at: Option<u64>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum DomainUrlScheme {
Https,
Http,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum DomainStatus {
Active,
Disabled,
}
impl DomainStatus {
pub fn is_active(self) -> bool {
matches!(self, DomainStatus::Active)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DomainBranding {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub display_name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub logo_url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tos_url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub contact_email: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct DomainQuota {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_dids_in_domain: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_total_size_in_domain: Option<u64>,
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_entry() -> DomainEntry {
DomainEntry {
name: "example.com".into(),
label: Some("Example Tenant".into()),
scheme: DomainUrlScheme::Https,
status: DomainStatus::Active,
created_at: 1_700_000_000,
default_domain: true,
branding: Some(DomainBranding {
display_name: Some("Example".into()),
logo_url: Some("https://cdn.example.com/logo.png".into()),
tos_url: None,
contact_email: Some("ops@example.com".into()),
}),
witnesses: Some(vec!["did:webvh:WIT1:witness.example.com".into()]),
watchers: None,
quota: Some(DomainQuota {
max_dids_in_domain: Some(1000),
max_total_size_in_domain: None,
}),
well_known_enabled: true,
disabled_at: None,
purge_at: None,
}
}
#[test]
fn round_trips_full_entry() {
let original = sample_entry();
let json = serde_json::to_string(&original).unwrap();
let back: DomainEntry = serde_json::from_str(&json).unwrap();
assert_eq!(original, back);
}
#[test]
fn round_trips_minimal_entry() {
let minimal = DomainEntry {
name: "tenant.example.com".into(),
label: None,
scheme: DomainUrlScheme::Https,
status: DomainStatus::Active,
created_at: 1_700_000_000,
default_domain: false,
branding: None,
witnesses: None,
watchers: None,
quota: None,
well_known_enabled: false,
disabled_at: None,
purge_at: None,
};
let json = serde_json::to_string(&minimal).unwrap();
let back: DomainEntry = serde_json::from_str(&json).unwrap();
assert_eq!(minimal, back);
}
#[test]
fn minimal_entry_omits_optional_fields() {
let minimal = DomainEntry {
name: "tenant.example.com".into(),
label: None,
scheme: DomainUrlScheme::Https,
status: DomainStatus::Active,
created_at: 0,
default_domain: false,
branding: None,
witnesses: None,
watchers: None,
quota: None,
well_known_enabled: false,
disabled_at: None,
purge_at: None,
};
let json = serde_json::to_string(&minimal).unwrap();
assert!(!json.contains("label"));
assert!(!json.contains("branding"));
assert!(!json.contains("witnesses"));
assert!(!json.contains("watchers"));
assert!(!json.contains("quota"));
assert!(!json.contains("disabled_at"));
assert!(!json.contains("purge_at"));
}
#[test]
fn legacy_entries_deserialize_with_defaults() {
let legacy = r#"{
"name": "example.com",
"scheme": "https",
"status": "active",
"created_at": 1700000000
}"#;
let e: DomainEntry = serde_json::from_str(legacy).unwrap();
assert_eq!(e.name, "example.com");
assert!(!e.default_domain);
assert!(!e.well_known_enabled);
assert!(e.branding.is_none());
}
#[test]
fn scheme_serialises_lowercase() {
let https = serde_json::to_string(&DomainUrlScheme::Https).unwrap();
assert_eq!(https, "\"https\"");
let http = serde_json::to_string(&DomainUrlScheme::Http).unwrap();
assert_eq!(http, "\"http\"");
}
#[test]
fn status_serialises_lowercase() {
assert_eq!(
serde_json::to_string(&DomainStatus::Active).unwrap(),
"\"active\""
);
assert_eq!(
serde_json::to_string(&DomainStatus::Disabled).unwrap(),
"\"disabled\""
);
}
#[test]
fn status_is_active_helper() {
assert!(DomainStatus::Active.is_active());
assert!(!DomainStatus::Disabled.is_active());
}
#[test]
fn branding_round_trips_with_all_fields() {
let b = DomainBranding {
display_name: Some("X".into()),
logo_url: Some("https://cdn/x.png".into()),
tos_url: Some("https://x/tos".into()),
contact_email: Some("a@x".into()),
};
let json = serde_json::to_string(&b).unwrap();
let back: DomainBranding = serde_json::from_str(&json).unwrap();
assert_eq!(b, back);
}
#[test]
fn quota_partial_unset_fields_round_trip() {
let q = DomainQuota {
max_dids_in_domain: Some(50),
max_total_size_in_domain: None,
};
let json = serde_json::to_string(&q).unwrap();
assert!(json.contains("max_dids_in_domain"));
assert!(!json.contains("max_total_size_in_domain"));
let back: DomainQuota = serde_json::from_str(&json).unwrap();
assert_eq!(q, back);
}
}