use serde::de::IntoDeserializer;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use url::Url;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ServiceType {
ZeroKms,
Secrets,
}
impl ServiceType {
pub fn as_str(&self) -> &'static str {
match self {
ServiceType::ZeroKms => "zerokms",
ServiceType::Secrets => "secrets",
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Serialize)]
#[serde(transparent)]
pub struct Services(BTreeMap<ServiceType, Url>);
impl<'de> Deserialize<'de> for Services {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let raw: BTreeMap<String, Url> = BTreeMap::deserialize(deserializer)?;
let mut services = BTreeMap::new();
for (key, url) in raw {
match ServiceType::deserialize(
<&str as IntoDeserializer<serde::de::value::Error>>::into_deserializer(
key.as_str(),
),
) {
Ok(service_type) => {
services.insert(service_type, url);
}
Err(_) => {
tracing::warn!(service_type = %key, "Unknown service type in token; ignoring");
}
}
}
Ok(Services(services))
}
}
impl Services {
pub fn new() -> Self {
Self(BTreeMap::new())
}
pub fn insert(&mut self, service_type: ServiceType, url: Url) {
self.0.insert(service_type, url);
}
pub fn get(&self, service_type: ServiceType) -> Option<&Url> {
self.0.get(&service_type)
}
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
pub fn iter(&self) -> impl Iterator<Item = (&ServiceType, &Url)> {
self.0.iter()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_services_roundtrip() {
let mut services = Services::new();
services.insert(
ServiceType::ZeroKms,
Url::parse("https://us-east-1.aws.viturhosted.net/").unwrap(),
);
let json = serde_json::to_string(&services).unwrap();
assert_eq!(
json,
r#"{"zerokms":"https://us-east-1.aws.viturhosted.net/"}"#
);
let deserialized: Services = serde_json::from_str(&json).unwrap();
assert_eq!(
deserialized.get(ServiceType::ZeroKms).map(|u| u.as_str()),
Some("https://us-east-1.aws.viturhosted.net/")
);
}
#[test]
fn test_empty_services_not_serialized_as_null() {
let services = Services::new();
let json = serde_json::to_string(&services).unwrap();
assert_eq!(json, "{}");
}
#[test]
fn test_default_is_empty() {
let services = Services::default();
assert!(services.is_empty());
}
#[test]
fn test_missing_key_returns_none() {
let services = Services::new();
assert_eq!(services.get(ServiceType::ZeroKms), None);
}
#[test]
fn test_service_type_as_str() {
assert_eq!(ServiceType::ZeroKms.as_str(), "zerokms");
assert_eq!(ServiceType::Secrets.as_str(), "secrets");
}
#[test]
fn test_services_roundtrip_with_multiple_services() {
let mut services = Services::new();
services.insert(
ServiceType::Secrets,
Url::parse("https://dashboard.cipherstash.com/").unwrap(),
);
services.insert(
ServiceType::ZeroKms,
Url::parse("https://us-east-1.aws.viturhosted.net/").unwrap(),
);
let json = serde_json::to_string(&services).unwrap();
let deserialized: Services = serde_json::from_str(&json).unwrap();
assert_eq!(
deserialized.get(ServiceType::Secrets).map(|u| u.as_str()),
Some("https://dashboard.cipherstash.com/"),
"secrets endpoint should roundtrip through serde"
);
assert_eq!(
deserialized.get(ServiceType::ZeroKms).map(|u| u.as_str()),
Some("https://us-east-1.aws.viturhosted.net/"),
"zerokms endpoint should roundtrip through serde"
);
}
#[test]
fn test_iter_populated_services() {
let mut services = Services::new();
let url = Url::parse("https://zerokms.example.com/").unwrap();
services.insert(ServiceType::ZeroKms, url.clone());
let entries: Vec<_> = services.iter().collect();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0], (&ServiceType::ZeroKms, &url));
}
#[test]
fn test_iter_empty_services() {
let services = Services::new();
assert_eq!(services.iter().count(), 0);
}
#[test]
fn test_unknown_service_type_is_skipped() {
let json = r#"{"zerokms":"https://us-east-1.aws.viturhosted.net/","newservice":"https://new.example.com/"}"#;
let services: Services = serde_json::from_str(json).unwrap();
assert_eq!(
services.get(ServiceType::ZeroKms).map(|u| u.as_str()),
Some("https://us-east-1.aws.viturhosted.net/"),
"known service should be preserved"
);
assert!(
services.iter().all(|(k, _)| *k != ServiceType::Secrets),
"unknown service should not appear as a known variant"
);
assert_eq!(
services.iter().count(),
1,
"only the known service should be present"
);
}
#[test]
fn test_all_unknown_services_produces_empty() {
let json = r#"{"foo":"https://foo.example.com/"}"#;
let services: Services = serde_json::from_str(json).unwrap();
assert!(services.is_empty());
}
}