use crate::error::{ValidationError, ValidationResult};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::fmt;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Meta {
#[serde(rename = "resourceType")]
pub resource_type: String,
pub created: DateTime<Utc>,
#[serde(rename = "lastModified")]
pub last_modified: DateTime<Utc>,
pub location: Option<String>,
pub version: Option<String>,
}
impl Meta {
pub fn new(
resource_type: String,
created: DateTime<Utc>,
last_modified: DateTime<Utc>,
location: Option<String>,
version: Option<String>,
) -> ValidationResult<Self> {
Self::validate_resource_type(&resource_type)?;
Self::validate_timestamps(created, last_modified)?;
if let Some(ref location_val) = location {
Self::validate_location(location_val)?;
}
if let Some(ref version_val) = version {
Self::validate_version(version_val)?;
}
Ok(Self {
resource_type,
created,
last_modified,
location,
version,
})
}
pub fn new_simple(
resource_type: String,
created: DateTime<Utc>,
last_modified: DateTime<Utc>,
) -> ValidationResult<Self> {
Self::new(resource_type, created, last_modified, None, None)
}
pub fn new_for_creation(resource_type: String) -> ValidationResult<Self> {
let now = Utc::now();
Self::new_simple(resource_type, now, now)
}
pub fn resource_type(&self) -> &str {
&self.resource_type
}
pub fn created(&self) -> DateTime<Utc> {
self.created
}
pub fn last_modified(&self) -> DateTime<Utc> {
self.last_modified
}
pub fn location(&self) -> Option<&str> {
self.location.as_deref()
}
pub fn version(&self) -> Option<&str> {
self.version.as_deref()
}
pub fn with_updated_timestamp(&self) -> Self {
Self {
resource_type: self.resource_type.clone(),
created: self.created,
last_modified: Utc::now(),
location: self.location.clone(),
version: self.version.clone(),
}
}
pub fn with_location(mut self, location: String) -> ValidationResult<Self> {
Self::validate_location(&location)?;
self.location = Some(location);
Ok(self)
}
pub fn with_version(mut self, version: String) -> ValidationResult<Self> {
Self::validate_version(&version)?;
self.version = Some(version);
Ok(self)
}
pub fn generate_location(base_url: &str, resource_type: &str, resource_id: &str) -> String {
format!(
"{}/{}s/{}",
base_url.trim_end_matches('/'),
resource_type,
resource_id
)
}
fn validate_resource_type(resource_type: &str) -> ValidationResult<()> {
if resource_type.is_empty() {
return Err(ValidationError::MissingResourceType);
}
if !resource_type
.chars()
.all(|c| c.is_alphanumeric() || c == '_')
{
return Err(ValidationError::InvalidResourceType {
resource_type: resource_type.to_string(),
});
}
Ok(())
}
fn validate_timestamps(
created: DateTime<Utc>,
last_modified: DateTime<Utc>,
) -> ValidationResult<()> {
if last_modified < created {
return Err(ValidationError::Custom {
message: "Last modified timestamp cannot be before created timestamp".to_string(),
});
}
Ok(())
}
fn validate_location(location: &str) -> ValidationResult<()> {
if location.is_empty() {
return Err(ValidationError::InvalidLocationUri);
}
if !location.starts_with("http://") && !location.starts_with("https://") {
return Err(ValidationError::InvalidLocationUri);
}
Ok(())
}
fn validate_version(version: &str) -> ValidationResult<()> {
if version.is_empty() {
return Err(ValidationError::InvalidVersionFormat);
}
if !version.starts_with("W/\"") && !version.starts_with('"') {
return Err(ValidationError::InvalidVersionFormat);
}
if !version.ends_with('"') {
return Err(ValidationError::InvalidVersionFormat);
}
Ok(())
}
}
impl fmt::Display for Meta {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"Meta(resourceType={}, created={}, lastModified={})",
self.resource_type,
self.created.to_rfc3339(),
self.last_modified.to_rfc3339()
)
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::{TimeZone, Utc};
use serde_json;
#[test]
fn test_valid_meta_full() {
let created = Utc.with_ymd_and_hms(2023, 1, 1, 12, 0, 0).unwrap();
let modified = Utc.with_ymd_and_hms(2023, 1, 2, 12, 0, 0).unwrap();
let meta = Meta::new(
"User".to_string(),
created,
modified,
Some("https://example.com/Users/123".to_string()),
Some("W/\"123-456\"".to_string()),
);
assert!(meta.is_ok());
let meta = meta.unwrap();
assert_eq!(meta.resource_type(), "User");
assert_eq!(meta.created(), created);
assert_eq!(meta.last_modified(), modified);
assert_eq!(meta.location(), Some("https://example.com/Users/123"));
assert_eq!(meta.version(), Some("W/\"123-456\""));
}
#[test]
fn test_valid_meta_simple() {
let created = Utc.with_ymd_and_hms(2023, 1, 1, 12, 0, 0).unwrap();
let modified = Utc.with_ymd_and_hms(2023, 1, 1, 12, 30, 0).unwrap();
let meta = Meta::new_simple("Group".to_string(), created, modified);
assert!(meta.is_ok());
let meta = meta.unwrap();
assert_eq!(meta.resource_type(), "Group");
assert_eq!(meta.created(), created);
assert_eq!(meta.last_modified(), modified);
assert_eq!(meta.location(), None);
assert_eq!(meta.version(), None);
}
#[test]
fn test_new_for_creation() {
let meta = Meta::new_for_creation("User".to_string());
assert!(meta.is_ok());
let meta = meta.unwrap();
assert_eq!(meta.resource_type(), "User");
assert_eq!(meta.created(), meta.last_modified());
}
#[test]
fn test_empty_resource_type() {
let now = Utc::now();
let result = Meta::new_simple("".to_string(), now, now);
assert!(result.is_err());
match result.unwrap_err() {
ValidationError::MissingResourceType => {}
other => panic!("Expected MissingResourceType error, got: {:?}", other),
}
}
#[test]
fn test_invalid_resource_type() {
let now = Utc::now();
let result = Meta::new_simple("Invalid-Type!".to_string(), now, now);
assert!(result.is_err());
match result.unwrap_err() {
ValidationError::InvalidResourceType { resource_type } => {
assert_eq!(resource_type, "Invalid-Type!");
}
other => panic!("Expected InvalidResourceType error, got: {:?}", other),
}
}
#[test]
fn test_invalid_timestamps() {
let created = Utc.with_ymd_and_hms(2023, 1, 2, 12, 0, 0).unwrap();
let modified = Utc.with_ymd_and_hms(2023, 1, 1, 12, 0, 0).unwrap();
let result = Meta::new_simple("User".to_string(), created, modified);
assert!(result.is_err());
match result.unwrap_err() {
ValidationError::Custom { message } => {
assert!(message.contains("Last modified timestamp cannot be before created"));
}
other => panic!("Expected Custom error, got: {:?}", other),
}
}
#[test]
fn test_invalid_location() {
let now = Utc::now();
let result = Meta::new("User".to_string(), now, now, Some("".to_string()), None);
assert!(result.is_err());
let result = Meta::new(
"User".to_string(),
now,
now,
Some("not-a-uri".to_string()),
None,
);
assert!(result.is_err());
}
#[test]
fn test_invalid_version() {
let now = Utc::now();
let result = Meta::new("User".to_string(), now, now, None, Some("".to_string()));
assert!(result.is_err());
let result = Meta::new(
"User".to_string(),
now,
now,
None,
Some("invalid-etag".to_string()),
);
assert!(result.is_err());
match result.unwrap_err() {
ValidationError::InvalidVersionFormat => {}
other => panic!("Expected InvalidVersionFormat error, got: {:?}", other),
}
}
#[test]
fn test_with_updated_timestamp() {
let created = Utc.with_ymd_and_hms(2023, 1, 1, 12, 0, 0).unwrap();
let meta = Meta::new_simple("User".to_string(), created, created).unwrap();
std::thread::sleep(std::time::Duration::from_millis(10));
let updated_meta = meta.with_updated_timestamp();
assert_eq!(updated_meta.created(), created);
assert!(updated_meta.last_modified() > created);
assert_eq!(updated_meta.resource_type(), "User");
}
#[test]
fn test_with_location() {
let now = Utc::now();
let meta = Meta::new_simple("User".to_string(), now, now).unwrap();
let meta_with_location = meta
.clone()
.with_location("https://example.com/Users/123".to_string());
assert!(meta_with_location.is_ok());
let meta_with_location = meta_with_location.unwrap();
assert_eq!(
meta_with_location.location(),
Some("https://example.com/Users/123")
);
let invalid_result = meta.with_location("invalid-uri".to_string());
assert!(invalid_result.is_err());
}
#[test]
fn test_with_version() {
let now = Utc::now();
let meta = Meta::new_simple("User".to_string(), now, now).unwrap();
let meta_with_version = meta.clone().with_version("W/\"123-456\"".to_string());
assert!(meta_with_version.is_ok());
let meta_with_version = meta_with_version.unwrap();
assert_eq!(meta_with_version.version(), Some("W/\"123-456\""));
let invalid_result = meta.with_version("invalid-version".to_string());
assert!(invalid_result.is_err());
}
#[test]
fn test_generate_location() {
let location = Meta::generate_location("https://example.com", "User", "123");
assert_eq!(location, "https://example.com/Users/123");
let location = Meta::generate_location("https://example.com/", "Group", "456");
assert_eq!(location, "https://example.com/Groups/456");
}
#[test]
fn test_display() {
let created = Utc.with_ymd_and_hms(2023, 1, 1, 12, 0, 0).unwrap();
let modified = Utc.with_ymd_and_hms(2023, 1, 2, 12, 0, 0).unwrap();
let meta = Meta::new_simple("User".to_string(), created, modified).unwrap();
let display_str = format!("{}", meta);
assert!(display_str.contains("User"));
assert!(display_str.contains("2023-01-01T12:00:00"));
assert!(display_str.contains("2023-01-02T12:00:00"));
}
#[test]
fn test_serialization() {
let created = Utc.with_ymd_and_hms(2023, 1, 1, 12, 0, 0).unwrap();
let modified = Utc.with_ymd_and_hms(2023, 1, 2, 12, 0, 0).unwrap();
let meta = Meta::new(
"User".to_string(),
created,
modified,
Some("https://example.com/Users/123".to_string()),
Some("W/\"123-456\"".to_string()),
)
.unwrap();
let json = serde_json::to_string(&meta).unwrap();
assert!(json.contains("\"resourceType\":\"User\""));
assert!(json.contains("\"lastModified\""));
assert!(json.contains("\"location\":\"https://example.com/Users/123\""));
assert!(json.contains("\"version\":\"W/\\\"123-456\\\"\""));
}
#[test]
fn test_deserialization() {
let json = r#"{
"resourceType": "Group",
"created": "2023-01-01T12:00:00Z",
"lastModified": "2023-01-02T12:00:00Z",
"location": "https://example.com/Groups/456",
"version": "W/\"456-789\""
}"#;
let meta: Meta = serde_json::from_str(json).unwrap();
assert_eq!(meta.resource_type(), "Group");
assert_eq!(meta.location(), Some("https://example.com/Groups/456"));
assert_eq!(meta.version(), Some("W/\"456-789\""));
}
#[test]
fn test_equality() {
let created = Utc.with_ymd_and_hms(2023, 1, 1, 12, 0, 0).unwrap();
let modified = Utc.with_ymd_and_hms(2023, 1, 2, 12, 0, 0).unwrap();
let meta1 = Meta::new_simple("User".to_string(), created, modified).unwrap();
let meta2 = Meta::new_simple("User".to_string(), created, modified).unwrap();
let meta3 = Meta::new_simple("Group".to_string(), created, modified).unwrap();
assert_eq!(meta1, meta2);
assert_ne!(meta1, meta3);
}
#[test]
fn test_clone() {
let created = Utc.with_ymd_and_hms(2023, 1, 1, 12, 0, 0).unwrap();
let modified = Utc.with_ymd_and_hms(2023, 1, 2, 12, 0, 0).unwrap();
let meta = Meta::new(
"User".to_string(),
created,
modified,
Some("https://example.com/Users/123".to_string()),
Some("W/\"123-456\"".to_string()),
)
.unwrap();
let cloned = meta.clone();
assert_eq!(meta, cloned);
assert_eq!(meta.resource_type(), cloned.resource_type());
assert_eq!(meta.created(), cloned.created());
assert_eq!(meta.last_modified(), cloned.last_modified());
assert_eq!(meta.location(), cloned.location());
assert_eq!(meta.version(), cloned.version());
}
}