use crate::error::Result;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use tokio::sync::RwLock;
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Directory {
#[serde(rename = "newNonce")]
pub new_nonce: String,
#[serde(rename = "newAccount")]
pub new_account: String,
#[serde(rename = "newOrder")]
pub new_order: String,
#[serde(rename = "revokeCert")]
pub revoke_cert: String,
#[serde(rename = "keyChange")]
pub key_change: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub meta: Option<DirectoryMeta>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct DirectoryMeta {
#[serde(rename = "termsOfService")]
pub terms_of_service: Option<String>,
pub website: Option<String>,
#[serde(rename = "caaIdentities")]
pub caa_identities: Option<Vec<String>>,
#[serde(rename = "externalAccountRequired")]
pub external_account_required: Option<bool>,
}
pub struct DirectoryManager {
url: String,
directory: Arc<RwLock<Option<Directory>>>,
http_client: reqwest::Client,
}
impl DirectoryManager {
pub fn new(url: impl Into<String>, http_client: reqwest::Client) -> Self {
let url = url.into();
tracing::debug!("Initializing DirectoryManager for URL: {}", url);
Self {
url,
directory: Arc::new(RwLock::new(None)),
http_client,
}
}
pub async fn fetch(&self) -> Result<Directory> {
tracing::info!("Fetching ACME directory from: {}", self.url);
let response = self.http_client.get(&self.url).send().await.map_err(|e| {
tracing::error!("Failed to connect to ACME directory: {}", e);
crate::error::AcmeError::transport(format!("Failed to fetch directory: {}", e))
})?;
if !response.status().is_success() {
tracing::error!(
"ACME directory request failed with status: {}",
response.status()
);
return Err(crate::error::AcmeError::protocol(format!(
"Failed to fetch directory: HTTP {}",
response.status()
)));
}
let directory: Directory = response.json().await.map_err(|e| {
tracing::error!("Failed to parse ACME directory JSON: {}", e);
crate::error::AcmeError::protocol(format!("Failed to parse directory: {}", e))
})?;
tracing::debug!("Successfully fetched and parsed ACME directory");
let mut cached = self.directory.write().await;
*cached = Some(directory.clone());
Ok(directory)
}
pub async fn get(&self) -> Result<Directory> {
{
let cached = self.directory.read().await;
if let Some(dir) = cached.clone() {
tracing::debug!("Using cached ACME directory");
return Ok(dir);
}
}
self.fetch().await
}
pub async fn clear_cache(&self) {
tracing::debug!("Clearing ACME directory cache");
let mut cached = self.directory.write().await;
*cached = None;
}
pub fn url(&self) -> &str {
&self.url
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_directory_parsing() {
let json = r#"{
"newNonce": "https://example.com/acme/new-nonce",
"newAccount": "https://example.com/acme/new-account",
"newOrder": "https://example.com/acme/new-order",
"revokeCert": "https://example.com/acme/revoke-cert",
"keyChange": "https://example.com/acme/key-change"
}"#;
let dir: Directory = serde_json::from_str(json).expect("Failed to parse directory");
assert_eq!(dir.new_nonce, "https://example.com/acme/new-nonce");
assert_eq!(dir.new_account, "https://example.com/acme/new-account");
}
#[test]
fn test_directory_with_meta() {
let json = r#"{
"newNonce": "https://example.com/acme/new-nonce",
"newAccount": "https://example.com/acme/new-account",
"newOrder": "https://example.com/acme/new-order",
"revokeCert": "https://example.com/acme/revoke-cert",
"keyChange": "https://example.com/acme/key-change",
"meta": {
"termsOfService": "https://example.com/tos",
"website": "https://example.com",
"caaIdentities": ["example.com"],
"externalAccountRequired": false
}
}"#;
let dir: Directory = serde_json::from_str(json).expect("Failed to parse directory");
assert!(dir.meta.is_some());
let meta = dir.meta.unwrap();
assert_eq!(
meta.terms_of_service,
Some("https://example.com/tos".to_string())
);
assert_eq!(meta.external_account_required, Some(false));
}
}