scim_server/resource/value_objects/
external_id.rs

1//! ExternalId value object for SCIM external identifiers.
2//!
3//! This module provides a type-safe wrapper around external IDs with built-in validation.
4//! External IDs are optional identifiers that link SCIM resources to external systems.
5
6use crate::error::{ValidationError, ValidationResult};
7use crate::resource::value_objects::value_object_trait::{SchemaConstructible, ValueObject};
8use crate::schema::types::{AttributeDefinition, AttributeType};
9use serde::{Deserialize, Deserializer, Serialize, Serializer};
10use serde_json::Value;
11use std::any::Any;
12use std::fmt;
13
14/// A validated SCIM external identifier.
15///
16/// ExternalId represents an optional identifier that links a SCIM resource to an
17/// external system. It enforces validation rules at construction time, ensuring
18/// that only valid external IDs can exist in the system.
19///
20/// ## Validation Rules
21///
22/// - Must not be empty if provided
23/// - Must be a valid string
24/// - Null values are handled separately (`Option<ExternalId>`)
25///
26/// ## Examples
27///
28/// ```rust
29/// use scim_server::resource::value_objects::ExternalId;
30///
31/// fn main() -> Result<(), Box<dyn std::error::Error>> {
32///     // Valid external ID
33///     let ext_id = ExternalId::new("701984".to_string())?;
34///     println!("External ID: {}", ext_id.as_str());
35///
36///     // Invalid external ID - returns ValidationError
37///     let invalid = ExternalId::new("".to_string()); // Error
38///     assert!(invalid.is_err());
39///
40///     Ok(())
41/// }
42/// ```
43#[derive(Debug, Clone, PartialEq, Eq, Hash)]
44pub struct ExternalId(String);
45
46impl ExternalId {
47    /// Create a new ExternalId with validation.
48    ///
49    /// This is the primary constructor that enforces all validation rules.
50    /// Use this method when creating ExternalId instances from untrusted input.
51    ///
52    /// # Arguments
53    ///
54    /// * `value` - The string value to validate and wrap
55    ///
56    /// # Returns
57    ///
58    /// * `Ok(ExternalId)` - If the value is valid
59    /// * `Err(ValidationError)` - If the value violates validation rules
60    pub fn new(value: String) -> ValidationResult<Self> {
61        Self::validate_format(&value)?;
62        Ok(Self(value))
63    }
64
65    /// Get the string representation of the ExternalId.
66    pub fn as_str(&self) -> &str {
67        &self.0
68    }
69
70    /// Get the owned string value of the ExternalId.
71    pub fn into_string(self) -> String {
72        self.0
73    }
74
75    /// Validate the format of an external ID string.
76    ///
77    /// This function contains validation logic moved from SchemaRegistry.
78    fn validate_format(value: &str) -> ValidationResult<()> {
79        // External ID should not be empty if provided
80        if value.is_empty() {
81            return Err(ValidationError::InvalidExternalId);
82        }
83
84        // TODO: Add more sophisticated external ID format validation if needed
85        // For now, we accept any non-empty string as a valid external ID
86        // Future enhancements might include:
87        // - Character set restrictions
88        // - Length limits
89        // - Format-specific validation for different external systems
90
91        Ok(())
92    }
93}
94
95impl fmt::Display for ExternalId {
96    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
97        write!(f, "{}", self.0)
98    }
99}
100
101impl Serialize for ExternalId {
102    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
103    where
104        S: Serializer,
105    {
106        self.0.serialize(serializer)
107    }
108}
109
110impl<'de> Deserialize<'de> for ExternalId {
111    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
112    where
113        D: Deserializer<'de>,
114    {
115        let value = String::deserialize(deserializer)?;
116        Self::new(value).map_err(serde::de::Error::custom)
117    }
118}
119
120impl TryFrom<String> for ExternalId {
121    type Error = ValidationError;
122
123    fn try_from(value: String) -> ValidationResult<Self> {
124        Self::new(value)
125    }
126}
127
128impl TryFrom<&str> for ExternalId {
129    type Error = ValidationError;
130
131    fn try_from(value: &str) -> ValidationResult<Self> {
132        Self::new(value.to_string())
133    }
134}
135
136impl ValueObject for ExternalId {
137    fn attribute_type(&self) -> AttributeType {
138        AttributeType::String
139    }
140
141    fn attribute_name(&self) -> &str {
142        "externalId"
143    }
144
145    fn to_json(&self) -> ValidationResult<Value> {
146        Ok(Value::String(self.0.clone()))
147    }
148
149    fn validate_against_schema(&self, definition: &AttributeDefinition) -> ValidationResult<()> {
150        if definition.data_type != AttributeType::String {
151            return Err(ValidationError::InvalidAttributeType {
152                attribute: definition.name.clone(),
153                expected: "string".to_string(),
154                actual: format!("{:?}", definition.data_type),
155            });
156        }
157
158        if definition.name != "externalId" {
159            return Err(ValidationError::InvalidAttributeName {
160                actual: definition.name.clone(),
161                expected: "externalId".to_string(),
162            });
163        }
164
165        Ok(())
166    }
167
168    fn as_json_value(&self) -> Value {
169        Value::String(self.0.clone())
170    }
171
172    fn supports_definition(&self, definition: &AttributeDefinition) -> bool {
173        definition.data_type == AttributeType::String && definition.name == "externalId"
174    }
175
176    fn clone_boxed(&self) -> Box<dyn ValueObject> {
177        Box::new(self.clone())
178    }
179
180    fn as_any(&self) -> &dyn Any {
181        self
182    }
183}
184
185impl SchemaConstructible for ExternalId {
186    fn from_schema_and_value(
187        definition: &AttributeDefinition,
188        value: &Value,
189    ) -> ValidationResult<Self> {
190        if definition.name != "externalId" || definition.data_type != AttributeType::String {
191            return Err(ValidationError::UnsupportedAttributeType {
192                attribute: definition.name.clone(),
193                type_name: format!("{:?}", definition.data_type),
194            });
195        }
196
197        if let Some(ext_id_str) = value.as_str() {
198            Self::new(ext_id_str.to_string())
199        } else {
200            Err(ValidationError::InvalidAttributeType {
201                attribute: definition.name.clone(),
202                expected: "string".to_string(),
203                actual: "non-string".to_string(),
204            })
205        }
206    }
207
208    fn can_construct_from(definition: &AttributeDefinition) -> bool {
209        definition.name == "externalId" && definition.data_type == AttributeType::String
210    }
211
212    fn constructor_priority() -> u8 {
213        100 // High priority for exact name match
214    }
215}
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220    use serde_json;
221
222    #[test]
223    fn test_valid_external_id() {
224        let ext_id = ExternalId::new("701984".to_string());
225        assert!(ext_id.is_ok());
226        assert_eq!(ext_id.unwrap().as_str(), "701984");
227    }
228
229    #[test]
230    fn test_valid_external_id_alphanumeric() {
231        let ext_id = ExternalId::new("EXT-123-ABC".to_string());
232        assert!(ext_id.is_ok());
233        assert_eq!(ext_id.unwrap().as_str(), "EXT-123-ABC");
234    }
235
236    #[test]
237    fn test_empty_external_id() {
238        let result = ExternalId::new("".to_string());
239        assert!(result.is_err());
240
241        match result.unwrap_err() {
242            ValidationError::InvalidExternalId => {} // Expected
243            other => panic!("Expected InvalidExternalId error, got: {:?}", other),
244        }
245    }
246
247    #[test]
248    fn test_into_string() {
249        let ext_id = ExternalId::new("test-ext-id".to_string()).unwrap();
250        let string_value = ext_id.into_string();
251        assert_eq!(string_value, "test-ext-id");
252    }
253
254    #[test]
255    fn test_display() {
256        let ext_id = ExternalId::new("display-test".to_string()).unwrap();
257        assert_eq!(format!("{}", ext_id), "display-test");
258    }
259
260    #[test]
261    fn test_serialization() {
262        let ext_id = ExternalId::new("serialize-test".to_string()).unwrap();
263        let json = serde_json::to_string(&ext_id).unwrap();
264        assert_eq!(json, "\"serialize-test\"");
265    }
266
267    #[test]
268    fn test_deserialization_valid() {
269        let json = "\"deserialize-test\"";
270        let ext_id: ExternalId = serde_json::from_str(json).unwrap();
271        assert_eq!(ext_id.as_str(), "deserialize-test");
272    }
273
274    #[test]
275    fn test_deserialization_invalid() {
276        let json = "\"\""; // Empty string
277        let result: Result<ExternalId, _> = serde_json::from_str(json);
278        assert!(result.is_err());
279    }
280
281    #[test]
282    fn test_try_from_string() {
283        let result = ExternalId::try_from("try-from-test".to_string());
284        assert!(result.is_ok());
285        assert_eq!(result.unwrap().as_str(), "try-from-test");
286
287        let empty_result = ExternalId::try_from("".to_string());
288        assert!(empty_result.is_err());
289    }
290
291    #[test]
292    fn test_try_from_str() {
293        let result = ExternalId::try_from("try-from-str-test");
294        assert!(result.is_ok());
295        assert_eq!(result.unwrap().as_str(), "try-from-str-test");
296
297        let empty_result = ExternalId::try_from("");
298        assert!(empty_result.is_err());
299    }
300
301    #[test]
302    fn test_equality() {
303        let ext_id1 = ExternalId::new("same-ext-id".to_string()).unwrap();
304        let ext_id2 = ExternalId::new("same-ext-id".to_string()).unwrap();
305        let ext_id3 = ExternalId::new("different-ext-id".to_string()).unwrap();
306
307        assert_eq!(ext_id1, ext_id2);
308        assert_ne!(ext_id1, ext_id3);
309    }
310
311    #[test]
312    fn test_hash() {
313        use std::collections::HashMap;
314
315        let ext_id1 = ExternalId::new("hash-test-1".to_string()).unwrap();
316        let ext_id2 = ExternalId::new("hash-test-2".to_string()).unwrap();
317
318        let mut map = HashMap::new();
319        map.insert(ext_id1.clone(), "value1");
320        map.insert(ext_id2.clone(), "value2");
321
322        assert_eq!(map.get(&ext_id1), Some(&"value1"));
323        assert_eq!(map.get(&ext_id2), Some(&"value2"));
324    }
325
326    #[test]
327    fn test_clone() {
328        let ext_id = ExternalId::new("clone-test".to_string()).unwrap();
329        let cloned = ext_id.clone();
330
331        assert_eq!(ext_id, cloned);
332        assert_eq!(ext_id.as_str(), cloned.as_str());
333    }
334}