this/core/
link.rs

1//! Link system for managing relationships between entities
2
3use crate::core::pluralize::Pluralizer;
4use chrono::{DateTime, Utc};
5use serde::{Deserialize, Serialize};
6use uuid::Uuid;
7
8/// Reference to an entity instance in a link
9#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
10pub struct EntityReference {
11    /// The unique ID of the entity
12    pub id: Uuid,
13
14    /// The type of entity (e.g., "user", "company", "car")
15    ///
16    /// CRITICAL: This is a String, not an enum, to maintain complete
17    /// decoupling from specific entity types
18    pub entity_type: String,
19}
20
21impl EntityReference {
22    /// Create a new entity reference
23    pub fn new(id: Uuid, entity_type: impl Into<String>) -> Self {
24        Self {
25            id,
26            entity_type: entity_type.into(),
27        }
28    }
29}
30
31/// A polymorphic link between two entities
32///
33/// Links are completely agnostic to the types of entities they connect.
34/// This allows the link system to work with any entity types without
35/// modification.
36#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct Link {
38    /// Unique identifier for this link
39    pub id: Uuid,
40
41    /// Tenant ID for multi-tenant isolation
42    pub tenant_id: Uuid,
43
44    /// The type of relationship (e.g., "owner", "driver", "worker")
45    ///
46    /// CRITICAL: This is a String, not an enum, to support any
47    /// relationship type without modifying the core framework
48    pub link_type: String,
49
50    /// The source entity in this relationship
51    pub source: EntityReference,
52
53    /// The target entity in this relationship
54    pub target: EntityReference,
55
56    /// Optional metadata for the relationship
57    ///
58    /// Can store additional context like:
59    /// - start_date / end_date for temporal relationships
60    /// - role for employment relationships
61    /// - permission level for access relationships
62    pub metadata: Option<serde_json::Value>,
63
64    /// When this link was created
65    pub created_at: DateTime<Utc>,
66
67    /// When this link was last updated
68    pub updated_at: DateTime<Utc>,
69}
70
71impl Link {
72    /// Create a new link
73    pub fn new(
74        tenant_id: Uuid,
75        link_type: impl Into<String>,
76        source: EntityReference,
77        target: EntityReference,
78        metadata: Option<serde_json::Value>,
79    ) -> Self {
80        let now = Utc::now();
81        Self {
82            id: Uuid::new_v4(),
83            tenant_id,
84            link_type: link_type.into(),
85            source,
86            target,
87            metadata,
88            created_at: now,
89            updated_at: now,
90        }
91    }
92}
93
94/// Authorization configuration for link operations
95///
96/// This allows fine-grained control over who can perform operations
97/// on specific link types, independent of entity-level permissions.
98#[derive(Debug, Clone, Serialize, Deserialize)]
99pub struct LinkAuthConfig {
100    /// Policy for listing links (GET /{source}/{id}/{route_name})
101    /// Examples: "authenticated", "owner", "public", "role:admin"
102    #[serde(default = "default_link_auth_policy")]
103    pub list: String,
104
105    /// Policy for getting a specific link by ID (GET /links/{link_id})
106    /// Examples: "authenticated", "owner", "source_owner_or_target_owner"
107    #[serde(default = "default_link_auth_policy")]
108    pub get: String,
109
110    /// Policy for creating a link (POST /{source}/{id}/{link_type}/{target}/{id})
111    /// Examples: "owner", "service_only", "role:manager", "source_owner"
112    #[serde(default = "default_link_auth_policy")]
113    pub create: String,
114
115    /// Policy for updating a link (PUT/PATCH /{source}/{id}/{link_type}/{target}/{id})
116    /// Examples: "owner", "source_owner", "source_owner_or_target_owner"
117    #[serde(default = "default_link_auth_policy")]
118    pub update: String,
119
120    /// Policy for deleting a link (DELETE /{source}/{id}/{link_type}/{target}/{id})
121    /// Examples: "owner", "admin_only", "source_owner_or_target_owner"
122    #[serde(default = "default_link_auth_policy")]
123    pub delete: String,
124}
125
126fn default_link_auth_policy() -> String {
127    "authenticated".to_string()
128}
129
130impl Default for LinkAuthConfig {
131    fn default() -> Self {
132        Self {
133            list: default_link_auth_policy(),
134            get: default_link_auth_policy(),
135            create: default_link_auth_policy(),
136            update: default_link_auth_policy(),
137            delete: default_link_auth_policy(),
138        }
139    }
140}
141
142/// Configuration for a specific type of link between two entity types
143///
144/// This defines how entities can be related and how those relationships
145/// are exposed through the API
146#[derive(Debug, Clone, Serialize, Deserialize)]
147pub struct LinkDefinition {
148    /// The type of link (e.g., "owner", "driver")
149    pub link_type: String,
150
151    /// The source entity type (e.g., "user")
152    pub source_type: String,
153
154    /// The target entity type (e.g., "car")
155    pub target_type: String,
156
157    /// Route name when navigating from source to target
158    ///
159    /// Example: "cars-owned" → /users/{id}/cars-owned
160    pub forward_route_name: String,
161
162    /// Route name when navigating from target to source
163    ///
164    /// Example: "users-owners" → /cars/{id}/users-owners
165    pub reverse_route_name: String,
166
167    /// Optional description of this link type
168    pub description: Option<String>,
169
170    /// Optional list of required metadata fields
171    pub required_fields: Option<Vec<String>>,
172
173    /// Authorization configuration specific to this link type
174    ///
175    /// When specified, these permissions override entity-level link permissions.
176    /// This allows different link types between the same entities to have
177    /// different permission requirements.
178    ///
179    /// Examples:
180    /// - order → invoice: create=service_only (auto-created by system)
181    /// - order → approval: create=owner (manually created by user)
182    #[serde(default)]
183    pub auth: Option<LinkAuthConfig>,
184}
185
186impl LinkDefinition {
187    /// Generate the default forward route name
188    ///
189    /// Format: {target_plural}-{link_type_plural}
190    /// Example: "cars-owned" for (target="car", link_type="owner")
191    pub fn default_forward_route_name(target_type: &str, link_type: &str) -> String {
192        format!(
193            "{}-{}",
194            Pluralizer::pluralize(target_type),
195            Pluralizer::pluralize(link_type)
196        )
197    }
198
199    /// Generate the default reverse route name
200    ///
201    /// Format: {source_plural}-{link_type_plural}
202    /// Example: "users-owners" for (source="user", link_type="owner")
203    pub fn default_reverse_route_name(source_type: &str, link_type: &str) -> String {
204        format!(
205            "{}-{}",
206            Pluralizer::pluralize(source_type),
207            Pluralizer::pluralize(link_type)
208        )
209    }
210}
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215
216    #[test]
217    fn test_entity_reference_creation() {
218        let user_id = Uuid::new_v4();
219        let reference = EntityReference::new(user_id, "user");
220
221        assert_eq!(reference.id, user_id);
222        assert_eq!(reference.entity_type, "user");
223    }
224
225    #[test]
226    fn test_link_creation() {
227        let tenant_id = Uuid::new_v4();
228        let user_id = Uuid::new_v4();
229        let car_id = Uuid::new_v4();
230
231        let link = Link::new(
232            tenant_id,
233            "owner",
234            EntityReference::new(user_id, "user"),
235            EntityReference::new(car_id, "car"),
236            None,
237        );
238
239        assert_eq!(link.tenant_id, tenant_id);
240        assert_eq!(link.link_type, "owner");
241        assert_eq!(link.source.id, user_id);
242        assert_eq!(link.target.id, car_id);
243        assert!(link.metadata.is_none());
244    }
245
246    #[test]
247    fn test_link_with_metadata() {
248        let tenant_id = Uuid::new_v4();
249        let user_id = Uuid::new_v4();
250        let company_id = Uuid::new_v4();
251
252        let metadata = serde_json::json!({
253            "role": "Senior Developer",
254            "start_date": "2024-01-01"
255        });
256
257        let link = Link::new(
258            tenant_id,
259            "worker",
260            EntityReference::new(user_id, "user"),
261            EntityReference::new(company_id, "company"),
262            Some(metadata.clone()),
263        );
264
265        assert_eq!(link.metadata, Some(metadata));
266    }
267
268    #[test]
269    fn test_default_route_names() {
270        let forward = LinkDefinition::default_forward_route_name("car", "owner");
271        assert_eq!(forward, "cars-owners");
272
273        let reverse = LinkDefinition::default_reverse_route_name("user", "owner");
274        assert_eq!(reverse, "users-owners");
275    }
276
277    #[test]
278    fn test_route_names_with_irregular_plurals() {
279        let forward = LinkDefinition::default_forward_route_name("company", "owner");
280        assert_eq!(forward, "companies-owners");
281
282        let reverse = LinkDefinition::default_reverse_route_name("company", "worker");
283        assert_eq!(reverse, "companies-workers");
284    }
285
286    #[test]
287    fn test_link_auth_config_default() {
288        let auth = LinkAuthConfig::default();
289        assert_eq!(auth.list, "authenticated");
290        assert_eq!(auth.get, "authenticated");
291        assert_eq!(auth.create, "authenticated");
292        assert_eq!(auth.update, "authenticated");
293        assert_eq!(auth.delete, "authenticated");
294    }
295
296    #[test]
297    fn test_link_definition_with_auth() {
298        let yaml = r#"
299            link_type: has_invoice
300            source_type: order
301            target_type: invoice
302            forward_route_name: invoices
303            reverse_route_name: order
304            auth:
305                list: authenticated
306                get: owner
307                create: service_only
308                update: owner
309                delete: admin_only
310        "#;
311
312        let def: LinkDefinition = serde_yaml::from_str(yaml).unwrap();
313        assert_eq!(def.link_type, "has_invoice");
314        assert_eq!(def.source_type, "order");
315        assert_eq!(def.target_type, "invoice");
316
317        let auth = def.auth.unwrap();
318        assert_eq!(auth.list, "authenticated");
319        assert_eq!(auth.get, "owner");
320        assert_eq!(auth.create, "service_only");
321        assert_eq!(auth.update, "owner");
322        assert_eq!(auth.delete, "admin_only");
323    }
324
325    #[test]
326    fn test_link_definition_without_auth() {
327        let yaml = r#"
328            link_type: payment
329            source_type: invoice
330            target_type: payment
331            forward_route_name: payments
332            reverse_route_name: invoice
333        "#;
334
335        let def: LinkDefinition = serde_yaml::from_str(yaml).unwrap();
336        assert_eq!(def.link_type, "payment");
337        assert!(def.auth.is_none());
338    }
339}