use crate::media_spec::{MediaSpecDef, MediaSpecDefObject};
use crate::registry::RegistryConfig;
use include_dir::{include_dir, Dir};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
const CACHE_DURATION_HOURS: u64 = 24;
static STANDARD_MEDIA_SPECS: Dir = include_dir!("$CARGO_MANIFEST_DIR/standard/media");
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StoredMediaSpec {
pub urn: String,
pub media_type: String,
pub title: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub profile_uri: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub schema: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub validation: Option<crate::ArgumentValidation>,
#[serde(skip_serializing_if = "Option::is_none")]
pub metadata: Option<serde_json::Value>,
}
impl StoredMediaSpec {
pub fn to_media_spec_def(&self) -> MediaSpecDef {
MediaSpecDef::Object(MediaSpecDefObject {
media_type: self.media_type.clone(),
profile_uri: self.profile_uri.clone().unwrap_or_default(),
schema: self.schema.clone(),
title: Some(self.title.clone()),
description: self.description.clone(),
validation: self.validation.clone(),
metadata: self.metadata.clone(),
})
}
}
fn normalize_media_urn(urn: &str) -> String {
match crate::MediaUrn::from_string(urn) {
Ok(parsed) => parsed.to_string(),
Err(_) => urn.to_string(),
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct MediaCacheEntry {
spec: StoredMediaSpec,
cached_at: u64,
ttl_hours: u64,
}
impl MediaCacheEntry {
fn is_expired(&self) -> bool {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
now > self.cached_at + (self.ttl_hours * 3600)
}
}
#[derive(Debug)]
pub struct MediaUrnRegistry {
client: reqwest::Client,
cache_dir: PathBuf,
cached_specs: Arc<Mutex<HashMap<String, StoredMediaSpec>>>,
config: RegistryConfig,
}
impl MediaUrnRegistry {
pub async fn new() -> Result<Self, MediaRegistryError> {
Self::with_config(RegistryConfig::default()).await
}
pub async fn with_config(config: RegistryConfig) -> Result<Self, MediaRegistryError> {
let cache_dir = Self::get_cache_dir()?;
fs::create_dir_all(&cache_dir).map_err(|e| {
MediaRegistryError::CacheError(format!("Failed to create cache directory: {}", e))
})?;
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(10))
.build()
.map_err(|e| {
MediaRegistryError::HttpError(format!("Failed to create HTTP client: {}", e))
})?;
let cached_specs_map = Self::load_all_cached_specs(&cache_dir)?;
let cached_specs = Arc::new(Mutex::new(cached_specs_map));
let registry = Self {
client,
cache_dir,
cached_specs,
config,
};
registry.install_standard_specs().await?;
Ok(registry)
}
pub fn config(&self) -> &RegistryConfig {
&self.config
}
async fn install_standard_specs(&self) -> Result<(), MediaRegistryError> {
for file in STANDARD_MEDIA_SPECS.files() {
let filename = file
.path()
.file_stem()
.and_then(|s| s.to_str())
.ok_or_else(|| {
MediaRegistryError::CacheError(format!("Invalid filename: {:?}", file.path()))
})?;
let content = file.contents_utf8().ok_or_else(|| {
MediaRegistryError::CacheError(format!(
"File is not valid UTF-8: {:?}",
file.path()
))
})?;
let spec: StoredMediaSpec = serde_json::from_str(content).map_err(|e| {
MediaRegistryError::ParseError(format!(
"Failed to parse bundled media spec {}: {}",
filename, e
))
})?;
let normalized_urn = normalize_media_urn(&spec.urn);
let cache_file = self.cache_file_path(&normalized_urn);
if !cache_file.exists() {
let cache_entry = MediaCacheEntry {
spec: spec.clone(),
cached_at: SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs(),
ttl_hours: CACHE_DURATION_HOURS,
};
let cache_content = serde_json::to_string_pretty(&cache_entry).map_err(|e| {
MediaRegistryError::CacheError(format!(
"Failed to serialize media spec {}: {}",
filename, e
))
})?;
fs::write(&cache_file, cache_content).map_err(|e| {
MediaRegistryError::CacheError(format!(
"Failed to write media spec to cache {}: {}",
filename, e
))
})?;
if let Ok(mut cached_specs) = self.cached_specs.lock() {
cached_specs.insert(normalized_urn.clone(), spec);
}
eprintln!("Installed standard media spec: {}", normalized_urn);
}
}
Ok(())
}
pub fn get_standard_specs(&self) -> Result<Vec<StoredMediaSpec>, MediaRegistryError> {
let mut specs = Vec::new();
for file in STANDARD_MEDIA_SPECS.files() {
let content = file.contents_utf8().ok_or_else(|| {
MediaRegistryError::CacheError(format!(
"File is not valid UTF-8: {:?}",
file.path()
))
})?;
let spec: StoredMediaSpec = serde_json::from_str(content).map_err(|e| {
MediaRegistryError::ParseError(format!("Failed to parse bundled media spec: {}", e))
})?;
specs.push(spec);
}
Ok(specs)
}
pub async fn get_media_spec(&self, urn: &str) -> Result<StoredMediaSpec, MediaRegistryError> {
let normalized_urn = normalize_media_urn(urn);
{
let cached_specs = self.cached_specs.lock().map_err(|e| {
MediaRegistryError::CacheError(format!("Failed to lock cache: {}", e))
})?;
if let Some(spec) = cached_specs.get(&normalized_urn) {
return Ok(spec.clone());
}
}
let spec = self.fetch_from_registry(urn).await?;
{
let mut cached_specs = self.cached_specs.lock().map_err(|e| {
MediaRegistryError::CacheError(format!("Failed to lock cache for update: {}", e))
})?;
cached_specs.insert(normalized_urn.clone(), spec.clone());
}
Ok(spec)
}
pub async fn get_media_specs(
&self,
urns: &[&str],
) -> Result<Vec<StoredMediaSpec>, MediaRegistryError> {
let mut specs = Vec::new();
for urn in urns {
specs.push(self.get_media_spec(urn).await?);
}
Ok(specs)
}
pub async fn get_cached_specs(&self) -> Result<Vec<StoredMediaSpec>, MediaRegistryError> {
let cached_specs = self.cached_specs.lock().map_err(|e| {
MediaRegistryError::CacheError(format!("Failed to lock cache: {}", e))
})?;
Ok(cached_specs.values().cloned().collect())
}
fn get_cache_dir() -> Result<PathBuf, MediaRegistryError> {
let mut cache_dir = dirs::cache_dir().ok_or_else(|| {
MediaRegistryError::CacheError("Could not determine cache directory".to_string())
})?;
cache_dir.push("capns");
cache_dir.push("media");
Ok(cache_dir)
}
fn cache_key(&self, urn: &str) -> String {
let normalized_urn = normalize_media_urn(urn);
let mut hasher = Sha256::new();
hasher.update(normalized_urn.as_bytes());
format!("{:x}", hasher.finalize())
}
fn cache_file_path(&self, urn: &str) -> PathBuf {
let key = self.cache_key(urn);
self.cache_dir.join(format!("{}.json", key))
}
fn load_all_cached_specs(
cache_dir: &PathBuf,
) -> Result<HashMap<String, StoredMediaSpec>, MediaRegistryError> {
let mut specs = HashMap::new();
if !cache_dir.exists() {
return Ok(specs);
}
for entry in fs::read_dir(cache_dir).map_err(|e| {
MediaRegistryError::CacheError(format!("Failed to read cache directory: {}", e))
})? {
let entry = entry.map_err(|e| {
MediaRegistryError::CacheError(format!("Failed to read cache entry: {}", e))
})?;
let path = entry.path();
if let Some(extension) = path.extension() {
if extension == "json" {
let content = fs::read_to_string(&path).map_err(|e| {
MediaRegistryError::CacheError(format!(
"Failed to read cache file {:?}: {}",
path, e
))
})?;
let cache_entry: MediaCacheEntry =
serde_json::from_str(&content).map_err(|e| {
MediaRegistryError::CacheError(format!(
"Failed to parse cache file {:?}: {}",
path, e
))
})?;
if cache_entry.is_expired() {
fs::remove_file(&path).map_err(|e| {
MediaRegistryError::CacheError(format!(
"Failed to remove expired cache file {:?}: {}",
path, e
))
})?;
continue;
}
let normalized_urn = normalize_media_urn(&cache_entry.spec.urn);
specs.insert(normalized_urn, cache_entry.spec);
}
}
}
Ok(specs)
}
fn save_to_cache(&self, spec: &StoredMediaSpec) -> Result<(), MediaRegistryError> {
let cache_file = self.cache_file_path(&spec.urn);
let cache_entry = MediaCacheEntry {
spec: spec.clone(),
cached_at: SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs(),
ttl_hours: CACHE_DURATION_HOURS,
};
let content = serde_json::to_string_pretty(&cache_entry).map_err(|e| {
MediaRegistryError::CacheError(format!("Failed to serialize cache entry: {}", e))
})?;
fs::write(&cache_file, content).map_err(|e| {
MediaRegistryError::CacheError(format!("Failed to write cache file: {}", e))
})?;
Ok(())
}
async fn fetch_from_registry(&self, urn: &str) -> Result<StoredMediaSpec, MediaRegistryError> {
let normalized_urn = normalize_media_urn(urn);
let tags_part = normalized_urn
.strip_prefix("media:")
.unwrap_or(&normalized_urn);
let encoded_tags = urlencoding::encode(tags_part);
let url = format!("{}/media:{}", self.config.registry_base_url, encoded_tags);
let response = self.client.get(&url).send().await.map_err(|e| {
MediaRegistryError::HttpError(format!("Failed to fetch from registry: {}", e))
})?;
if !response.status().is_success() {
return Err(MediaRegistryError::NotFound(format!(
"Media spec '{}' not found in registry (HTTP {})",
urn,
response.status()
)));
}
let spec: StoredMediaSpec = response.json().await.map_err(|e| {
MediaRegistryError::ParseError(format!(
"Failed to parse registry response for '{}': {}",
urn, e
))
})?;
self.save_to_cache(&spec)?;
Ok(spec)
}
pub async fn media_spec_exists(&self, urn: &str) -> bool {
self.get_media_spec(urn).await.is_ok()
}
pub fn clear_cache(&self) -> Result<(), MediaRegistryError> {
{
let mut cached_specs = self.cached_specs.lock().map_err(|e| {
MediaRegistryError::CacheError(format!("Failed to lock cache for clearing: {}", e))
})?;
cached_specs.clear();
}
if self.cache_dir.exists() {
fs::remove_dir_all(&self.cache_dir).map_err(|e| {
MediaRegistryError::CacheError(format!("Failed to clear cache directory: {}", e))
})?;
fs::create_dir_all(&self.cache_dir).map_err(|e| {
MediaRegistryError::CacheError(format!(
"Failed to recreate cache directory: {}",
e
))
})?;
}
Ok(())
}
}
#[derive(Debug, thiserror::Error)]
pub enum MediaRegistryError {
#[error("HTTP error: {0}")]
HttpError(String),
#[error("Media spec not found in registry: {0}")]
NotFound(String),
#[error("Failed to parse registry response: {0}")]
ParseError(String),
#[error("Cache error: {0}")]
CacheError(String),
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
use tokio;
async fn registry_with_temp_cache() -> (MediaUrnRegistry, TempDir) {
let temp_dir = TempDir::new().unwrap();
let cache_dir = temp_dir.path().join("media");
fs::create_dir_all(&cache_dir).unwrap();
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(10))
.build()
.unwrap();
let registry = MediaUrnRegistry {
client,
cache_dir,
cached_specs: Arc::new(Mutex::new(HashMap::new())),
config: RegistryConfig::default(),
};
(registry, temp_dir)
}
#[tokio::test]
async fn test_registry_creation() {
let (registry, _temp_dir) = registry_with_temp_cache().await;
assert!(registry.cache_dir.exists());
}
#[tokio::test]
async fn test_cache_key_generation() {
let (registry, _temp_dir) = registry_with_temp_cache().await;
let key1 = registry.cache_key("media:string;textable;scalar");
let key2 = registry.cache_key("media:string;textable;scalar");
let key3 = registry.cache_key("media:integer;textable;scalar");
assert_eq!(key1, key2);
assert_ne!(key1, key3);
}
#[test]
fn test_stored_media_spec_to_def() {
let spec = StoredMediaSpec {
urn: "media:pdf;binary".to_string(),
media_type: "application/pdf".to_string(),
title: "PDF Document".to_string(),
profile_uri: Some("https://capns.org/schema/pdf".to_string()),
schema: None,
description: Some("PDF document data".to_string()),
validation: None,
metadata: None,
};
let def = spec.to_media_spec_def();
match def {
MediaSpecDef::Object(obj) => {
assert_eq!(obj.media_type, "application/pdf");
assert_eq!(obj.title, Some("PDF Document".to_string()));
assert_eq!(obj.description, Some("PDF document data".to_string()));
assert_eq!(obj.validation, None);
}
_ => panic!("Expected Object variant"),
}
}
#[test]
fn test_normalize_media_urn() {
let urn1 = normalize_media_urn("media:string");
let urn2 = normalize_media_urn("media:string");
assert!(!urn1.is_empty());
assert!(!urn2.is_empty());
}
}