use crate::error::Error;
use std::collections::HashMap;
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "persistence", derive(serde::Serialize, serde::Deserialize))]
pub struct Resource {
id: String,
resource_type: String,
name: Option<String>,
attributes: HashMap<String, String>,
path: Option<String>,
}
impl Resource {
pub fn new_checked(
id: impl Into<String>,
resource_type: impl Into<String>,
) -> Result<Self, Error> {
let id = id.into();
let resource_type = resource_type.into();
if id.contains("..") || id.contains('\0') {
return Err(Error::ValidationError {
field: "id".to_string(),
reason: "Resource ID cannot contain path traversal sequences or null characters"
.to_string(),
invalid_value: Some(id),
});
}
if resource_type.contains("..") || resource_type.contains('\0') {
return Err(Error::ValidationError {
field: "resource_type".to_string(),
reason: "Resource type cannot contain path traversal sequences or null characters"
.to_string(),
invalid_value: Some(resource_type),
});
}
Ok(Self {
id,
resource_type,
name: None,
attributes: HashMap::new(),
path: None,
})
}
pub fn new(id: impl Into<String>, resource_type: impl Into<String>) -> Self {
match Self::new_checked(id, resource_type) {
Ok(resource) => resource,
Err(e) => panic!("Resource validation failed: {}", e),
}
}
pub fn id(&self) -> &str {
&self.id
}
pub fn resource_type(&self) -> &str {
&self.resource_type
}
pub fn with_name(mut self, name: impl Into<String>) -> Self {
self.name = Some(name.into());
self
}
pub fn name(&self) -> Option<&str> {
self.name.as_deref()
}
pub fn set_name(&mut self, name: impl Into<String>) {
self.name = Some(name.into());
}
pub fn with_path(mut self, path: impl Into<String>) -> Self {
self.path = Some(path.into());
self
}
pub fn path(&self) -> Option<&str> {
self.path.as_deref()
}
pub fn set_path(&mut self, path: impl Into<String>) {
self.path = Some(path.into());
}
pub fn with_attribute(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.attributes.insert(key.into(), value.into());
self
}
pub fn set_attribute(&mut self, key: impl Into<String>, value: impl Into<String>) {
self.attributes.insert(key.into(), value.into());
}
pub fn attribute(&self, key: &str) -> Option<&str> {
self.attributes.get(key).map(|s| s.as_str())
}
pub fn attributes(&self) -> &HashMap<String, String> {
&self.attributes
}
pub fn remove_attribute(&mut self, key: &str) -> Option<String> {
self.attributes.remove(key)
}
pub fn has_attribute(&self, key: &str) -> bool {
self.attributes.contains_key(key)
}
pub fn effective_name(&self) -> &str {
self.name.as_deref().unwrap_or(&self.id)
}
pub fn matches_pattern(&self, pattern: &str) -> bool {
if pattern == "*" {
return true;
}
if pattern == self.id || pattern == self.resource_type {
return true;
}
if let Some(type_prefix) = pattern.strip_suffix("/*")
&& self.resource_type == type_prefix
{
return true;
}
if let Some(resource_path) = &self.path
&& self.matches_path_pattern(resource_path, pattern)
{
return true;
}
false
}
pub fn is_under_path(&self, parent_path: &str) -> bool {
if let Some(resource_path) = &self.path {
resource_path.starts_with(parent_path)
} else {
false
}
}
pub fn parent_path(&self) -> Option<String> {
self.path
.as_ref()
.and_then(|p| p.rfind('/').map(|i| p[..i].to_string()))
}
fn matches_path_pattern(&self, path: &str, pattern: &str) -> bool {
if pattern.contains('*') {
let parts: Vec<&str> = pattern.split('*').collect();
if parts.len() == 2 {
let prefix = parts[0];
let suffix = parts[1];
return path.starts_with(prefix) && path.ends_with(suffix);
}
}
path == pattern
}
}
impl std::fmt::Display for Resource {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match (&self.name, &self.path) {
(Some(name), Some(path)) => write!(
f,
"{} ({}:{} at {})",
name, self.resource_type, self.id, path
),
(Some(name), None) => write!(f, "{} ({}:{})", name, self.resource_type, self.id),
(None, Some(path)) => write!(f, "{}:{} at {}", self.resource_type, self.id, path),
(None, None) => write!(f, "{}:{}", self.resource_type, self.id),
}
}
}
#[derive(Debug, Default)]
pub struct ResourceBuilder {
id: Option<String>,
resource_type: Option<String>,
name: Option<String>,
path: Option<String>,
attributes: HashMap<String, String>,
}
impl ResourceBuilder {
pub fn new() -> Self {
Self::default()
}
pub fn id(mut self, id: impl Into<String>) -> Self {
self.id = Some(id.into());
self
}
pub fn resource_type(mut self, resource_type: impl Into<String>) -> Self {
self.resource_type = Some(resource_type.into());
self
}
pub fn name(mut self, name: impl Into<String>) -> Self {
self.name = Some(name.into());
self
}
pub fn path(mut self, path: impl Into<String>) -> Self {
self.path = Some(path.into());
self
}
pub fn attribute(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.attributes.insert(key.into(), value.into());
self
}
pub fn build(self) -> Result<Resource, String> {
let id = self.id.ok_or("Resource ID is required")?;
let resource_type = self.resource_type.ok_or("Resource type is required")?;
let mut resource = Resource::new(id, resource_type);
if let Some(name) = self.name {
resource = resource.with_name(name);
}
if let Some(path) = self.path {
resource = resource.with_path(path);
}
for (key, value) in self.attributes {
resource = resource.with_attribute(key, value);
}
Ok(resource)
}
}
pub mod types {
use super::Resource;
pub fn document(id: impl Into<String>) -> Resource {
Resource::new(id, "document")
}
pub fn user(id: impl Into<String>) -> Resource {
Resource::new(id, "user")
}
pub fn project(id: impl Into<String>) -> Resource {
Resource::new(id, "project")
}
pub fn file(id: impl Into<String>) -> Resource {
Resource::new(id, "file")
}
pub fn database(id: impl Into<String>) -> Resource {
Resource::new(id, "database")
}
pub fn api_endpoint(id: impl Into<String>) -> Resource {
Resource::new(id, "api_endpoint")
}
}
#[cfg(test)]
mod tests {
use super::types::*;
use super::*;
#[test]
fn test_resource_creation() {
let resource = Resource::new("doc123", "document")
.with_name("My Document")
.with_path("/projects/web-app/docs/readme.md")
.with_attribute("owner", "john@example.com")
.with_attribute("created", "2024-01-01");
assert_eq!(resource.id(), "doc123");
assert_eq!(resource.resource_type(), "document");
assert_eq!(resource.name(), Some("My Document"));
assert_eq!(resource.path(), Some("/projects/web-app/docs/readme.md"));
assert_eq!(resource.attribute("owner"), Some("john@example.com"));
assert_eq!(resource.effective_name(), "My Document");
}
#[test]
fn test_resource_pattern_matching() {
let resource =
Resource::new("doc1", "document").with_path("/projects/web-app/docs/readme.md");
assert!(resource.matches_pattern("*"));
assert!(resource.matches_pattern("doc1"));
assert!(resource.matches_pattern("document"));
assert!(resource.matches_pattern("document/*"));
assert!(!resource.matches_pattern("user"));
assert!(!resource.matches_pattern("users/*"));
}
#[test]
fn test_resource_path_operations() {
let resource =
Resource::new("doc1", "document").with_path("/projects/web-app/docs/readme.md");
assert!(resource.is_under_path("/projects"));
assert!(resource.is_under_path("/projects/web-app"));
assert!(!resource.is_under_path("/other"));
assert_eq!(
resource.parent_path(),
Some("/projects/web-app/docs".to_string())
);
}
#[test]
fn test_resource_builder() {
let resource = ResourceBuilder::new()
.id("test-id")
.resource_type("test-type")
.name("Test Resource")
.path("/test/path")
.attribute("key", "value")
.build()
.unwrap();
assert_eq!(resource.id(), "test-id");
assert_eq!(resource.resource_type(), "test-type");
assert_eq!(resource.name(), Some("Test Resource"));
assert_eq!(resource.path(), Some("/test/path"));
assert_eq!(resource.attribute("key"), Some("value"));
}
#[test]
fn test_common_resource_types() {
let doc = document("doc1");
let user_res = user("user1");
let proj = project("proj1");
assert_eq!(doc.resource_type(), "document");
assert_eq!(user_res.resource_type(), "user");
assert_eq!(proj.resource_type(), "project");
}
#[test]
fn test_resource_effective_name() {
let named_resource = Resource::new("r1", "type").with_name("Named Resource");
let unnamed_resource = Resource::new("r2", "type");
assert_eq!(named_resource.effective_name(), "Named Resource");
assert_eq!(unnamed_resource.effective_name(), "r2");
}
}