scim_server/resource/version.rs
1//! Version control types for SCIM resources.
2//!
3//! This module provides types and functionality for handling resource versioning
4//! and conditional operations, enabling ETag-based concurrency control as specified
5//! in RFC 7644 (SCIM 2.0) and RFC 7232 (HTTP ETags).
6//!
7//! # ETag Concurrency Control
8//!
9//! The version system provides automatic optimistic concurrency control for SCIM
10//! resources, preventing lost updates when multiple clients modify the same resource
11//! simultaneously. All versions are computed deterministically from resource content
12//! using SHA-256 hashing.
13//!
14//! # Core Types
15//!
16//! * [`ScimVersion`] - Opaque version identifier for resources
17//! * [`ConditionalResult`] - Result type for conditional operations
18//! * [`VersionConflict`] - Error details for version mismatches
19//!
20//! # Basic Usage
21//!
22//! ```rust
23//! use scim_server::resource::version::{ScimVersion, ConditionalResult};
24//!
25//! // Create version from hash string (for provider-specific versioning)
26//! let version = ScimVersion::from_hash("db-sequence-123");
27//!
28//! // Create version from content hash (automatic versioning)
29//! let resource_json = br#"{"id":"123","userName":"john.doe","active":true}"#;
30//! let content_version = ScimVersion::from_content(resource_json);
31//!
32//! // Parse from HTTP weak ETag header (client-provided versions)
33//! let etag_version = ScimVersion::parse_http_header("W/\"abc123def\"").unwrap();
34//!
35//! // Convert to HTTP weak ETag header (for responses)
36//! let etag_header = version.to_http_header(); // Returns: "W/abc123def"
37//!
38//! // Check version equality (for conditional operations)
39//! let matches = version.matches(&etag_version);
40//! ```
41//!
42//! # Conditional Operations
43//!
44//! ```rust,no_run
45//! use scim_server::resource::version::{ConditionalResult, ScimVersion};
46//! use scim_server::resource::{ResourceProvider, RequestContext};
47//! use serde_json::json;
48//!
49//! # async fn example<P: ResourceProvider + Sync>(provider: &P) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
50//! let context = RequestContext::with_generated_id();
51//! let expected_version = ScimVersion::from_hash("current-version");
52//! let update_data = json!({"userName": "updated.name", "active": false});
53//!
54//! // Conditional update with version checking
55//! match provider.conditional_update("User", "123", update_data, &expected_version, &context).await? {
56//! ConditionalResult::Success(versioned_resource) => {
57//! println!("Update successful!");
58//! println!("New weak ETag: {}", versioned_resource.version().to_http_header());
59//! },
60//! ConditionalResult::VersionMismatch(conflict) => {
61//! println!("Version conflict detected!");
62//! println!("Expected: {}", conflict.expected);
63//! println!("Current: {}", conflict.current);
64//! println!("Message: {}", conflict.message);
65//! // Client should refresh and retry with current version
66//! },
67//! ConditionalResult::NotFound => {
68//! println!("Resource not found");
69//! // Handle missing resource scenario
70//! }
71//! }
72//! # Ok(())
73//! # }
74//! ```
75//!
76//! # HTTP Integration
77//!
78//! The version system integrates seamlessly with HTTP weak ETags:
79//!
80//! ```rust
81//! use scim_server::resource::version::ScimVersion;
82//!
83//! // Server generates weak ETag for response
84//! let resource_data = br#"{"id":"123","userName":"alice","active":true}"#;
85//! let version = ScimVersion::from_content(resource_data);
86//! let etag_header = version.to_http_header(); // "W/xyz789abc"
87//!
88//! // Client provides weak ETag in subsequent request (If-Match header)
89//! let client_etag = "W/\"xyz789abc\"";
90//! let client_version = ScimVersion::parse_http_header(client_etag).unwrap();
91//!
92//! // Server validates version before operation
93//! if version.matches(&client_version) {
94//! println!("Versions match - proceed with operation");
95//! } else {
96//! println!("Version mismatch - return 412 Precondition Failed");
97//! }
98//! ```
99//!
100//! # Version Properties
101//!
102//! - **Deterministic**: Same content always produces the same version
103//! - **Content-Based**: Any change to resource data changes the version
104//! - **Collision-Resistant**: SHA-256 based hashing prevents accidental conflicts
105//! - **Compact**: Base64 encoded for efficient transmission
106//! - **Opaque**: Internal representation prevents manipulation
107//! - **HTTP Compatible**: Direct integration with weak ETag headers
108
109use base64::{Engine, engine::general_purpose::STANDARD as BASE64};
110use serde::{Deserialize, Serialize};
111use sha2::{Digest, Sha256};
112use std::fmt;
113use thiserror::Error;
114
115/// Opaque version identifier for SCIM resources.
116///
117/// Represents a version of a resource that can be used for optimistic concurrency
118/// control. The internal representation is opaque to prevent direct manipulation
119/// and ensure version consistency across different provider implementations.
120///
121/// Versions can be created from:
122/// - Provider-specific identifiers (database sequence numbers, timestamps, etc.)
123/// - Content hashes (for stateless version generation)
124/// - HTTP ETag headers (for parsing client-provided versions)
125///
126/// # Examples
127///
128/// ```rust
129/// use scim_server::resource::version::ScimVersion;
130///
131/// // From hash string
132/// let version = ScimVersion::from_hash("12345");
133///
134/// // From content hash
135/// let content = br#"{"id":"123","name":"John Doe"}"#;
136/// let hash_version = ScimVersion::from_content(content);
137///
138/// // From HTTP ETag
139/// let etag_version = ScimVersion::parse_http_header("\"abc123def\"").unwrap();
140/// ```
141#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
142pub struct ScimVersion {
143 /// Opaque version identifier
144 opaque: String,
145}
146
147impl ScimVersion {
148 /// Create a version from resource content.
149 ///
150 /// This generates a deterministic hash-based version from the resource content,
151 /// ensuring universal compatibility across all provider implementations.
152 /// The version is based on the full resource content including all fields.
153 ///
154 /// # Arguments
155 /// * `content` - The complete resource content as bytes
156 ///
157 /// # Examples
158 /// ```rust
159 /// use scim_server::resource::version::ScimVersion;
160 ///
161 /// let resource_json = br#"{"id":"123","userName":"john.doe"}"#;
162 /// let version = ScimVersion::from_content(resource_json);
163 /// ```
164 pub fn from_content(content: &[u8]) -> Self {
165 let mut hasher = Sha256::new();
166 hasher.update(content);
167 let hash = hasher.finalize();
168 let encoded = BASE64.encode(&hash[..8]); // Use first 8 bytes for shorter ETags
169 Self { opaque: encoded }
170 }
171
172 /// Create a version from a pre-computed hash string.
173 ///
174 /// This is useful for provider-specific versioning schemes such as database
175 /// sequence numbers, timestamps, or UUIDs. The provider can use any string
176 /// as a version identifier.
177 ///
178 /// # Arguments
179 /// * `hash_string` - Provider-specific version identifier
180 ///
181 /// # Examples
182 /// ```rust
183 /// use scim_server::resource::version::ScimVersion;
184 ///
185 /// // Database sequence number
186 /// let db_version = ScimVersion::from_hash("seq_12345");
187 ///
188 /// // Timestamp-based version
189 /// let time_version = ScimVersion::from_hash("1703123456789");
190 ///
191 /// // UUID-based version
192 /// let uuid_version = ScimVersion::from_hash("550e8400-e29b-41d4-a716-446655440000");
193 /// ```
194 ///
195 /// # Examples
196 /// ```rust
197 /// use scim_server::resource::version::ScimVersion;
198 ///
199 /// let version = ScimVersion::from_hash("abc123def");
200 /// ```
201 pub fn from_hash(hash_string: impl AsRef<str>) -> Self {
202 Self {
203 opaque: hash_string.as_ref().to_string(),
204 }
205 }
206
207 /// Parse a version from an HTTP ETag header value.
208 ///
209 /// Accepts both weak and strong ETags as defined in RFC 7232.
210 /// Weak ETags (prefixed with "W/") are treated the same as strong ETags
211 /// for SCIM resource versioning purposes.
212 ///
213 /// # Arguments
214 /// * `etag_header` - The ETag header value (e.g., "\"abc123\"" or "W/\"abc123\"")
215 ///
216 /// # Returns
217 /// The parsed version or an error if the ETag format is invalid
218 ///
219 /// # Examples
220 /// ```rust
221 /// use scim_server::resource::version::ScimVersion;
222 ///
223 /// let version = ScimVersion::parse_http_header("\"abc123\"").unwrap();
224 /// let weak_version = ScimVersion::parse_http_header("W/\"abc123\"").unwrap();
225 /// ```
226 pub fn parse_http_header(etag_header: &str) -> Result<Self, VersionError> {
227 let trimmed = etag_header.trim();
228
229 // Handle weak ETags by removing W/ prefix
230 let etag_value = if trimmed.starts_with("W/") {
231 &trimmed[2..]
232 } else {
233 trimmed
234 };
235
236 // Remove surrounding quotes
237 if etag_value.len() < 2 || !etag_value.starts_with('"') || !etag_value.ends_with('"') {
238 return Err(VersionError::InvalidEtagFormat(etag_header.to_string()));
239 }
240
241 let opaque = etag_value[1..etag_value.len() - 1].to_string();
242
243 if opaque.is_empty() {
244 return Err(VersionError::InvalidEtagFormat(etag_header.to_string()));
245 }
246
247 Ok(Self { opaque })
248 }
249
250 /// Parse a version from a raw version string (MCP-native).
251 ///
252 /// This method accepts raw version strings directly without HTTP ETag formatting,
253 /// making it suitable for JSON-RPC protocols like MCP where HTTP semantics
254 /// are not applicable.
255 ///
256 /// # Arguments
257 /// * `version_str` - The raw version string (e.g., "abc123def")
258 ///
259 /// # Returns
260 /// The parsed version or an error if the version string is invalid
261 ///
262 /// # Examples
263 /// ```rust
264 /// use scim_server::resource::version::ScimVersion;
265 ///
266 /// let version = ScimVersion::parse_raw("abc123def").unwrap();
267 /// ```
268 pub fn parse_raw(version_str: &str) -> Result<Self, VersionError> {
269 let trimmed = version_str.trim();
270
271 if trimmed.is_empty() {
272 return Err(VersionError::ParseError("Version string cannot be empty".to_string()));
273 }
274
275 Ok(Self {
276 opaque: trimmed.to_string()
277 })
278 }
279
280 /// Convert version to HTTP ETag header value.
281 ///
282 /// This generates a weak HTTP ETag header value that can be used in conditional
283 /// HTTP requests. SCIM resources use weak ETags since they represent semantic
284 /// equivalence rather than byte-for-byte identity. The returned value includes
285 /// the W/ prefix and surrounding quotes required by RFC 7232.
286 ///
287 /// # Examples
288 /// ```rust
289 /// use scim_server::resource::version::ScimVersion;
290 ///
291 /// let version = ScimVersion::from_hash("12345");
292 /// let etag = version.to_http_header();
293 /// assert_eq!(etag, "W/\"12345\"");
294 /// ```
295 pub fn to_http_header(&self) -> String {
296 format!("W/\"{}\"", self.opaque)
297 }
298
299 /// Check if this version matches another version.
300 ///
301 /// This is used for conditional operations to determine if the expected
302 /// version matches the current version of a resource.
303 ///
304 /// # Arguments
305 /// * `other` - The version to compare against
306 ///
307 /// # Examples
308 /// ```rust
309 /// use scim_server::resource::version::ScimVersion;
310 ///
311 /// let v1 = ScimVersion::from_hash("123");
312 /// let v2 = ScimVersion::from_hash("123");
313 /// let v3 = ScimVersion::from_hash("456");
314 ///
315 /// assert!(v1.matches(&v2));
316 /// assert!(!v1.matches(&v3));
317 /// ```
318 pub fn matches(&self, other: &ScimVersion) -> bool {
319 self.opaque == other.opaque
320 }
321
322 /// Get the opaque version string.
323 ///
324 /// This is primarily for internal use and debugging. The opaque string
325 /// should not be relied upon for any business logic.
326 pub fn as_str(&self) -> &str {
327 &self.opaque
328 }
329}
330
331impl fmt::Display for ScimVersion {
332 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
333 write!(f, "{}", self.opaque)
334 }
335}
336
337/// Result type for conditional SCIM operations.
338///
339/// Represents the outcome of a conditional operation that depends on
340/// resource versioning. This allows providers to indicate whether
341/// an operation succeeded, failed due to a version mismatch, or
342/// failed because the resource was not found.
343///
344/// # Examples
345///
346/// ```rust
347/// use scim_server::resource::version::{ConditionalResult, ScimVersion, VersionConflict};
348/// use serde_json::json;
349///
350/// // Successful operation
351/// let success = ConditionalResult::Success(json!({"id": "123"}));
352///
353/// // Version mismatch
354/// let expected = ScimVersion::from_hash("1");
355/// let current = ScimVersion::from_hash("2");
356/// let conflict: ConditionalResult<serde_json::Value> = ConditionalResult::VersionMismatch(VersionConflict {
357/// expected,
358/// current,
359/// message: "Resource was modified by another client".to_string(),
360/// });
361///
362/// // Resource not found
363/// let not_found: ConditionalResult<serde_json::Value> = ConditionalResult::NotFound;
364/// ```
365#[derive(Debug, Clone, PartialEq)]
366pub enum ConditionalResult<T> {
367 /// Operation completed successfully
368 Success(T),
369
370 /// Operation failed due to version mismatch
371 VersionMismatch(VersionConflict),
372
373 /// Operation failed because the resource was not found
374 NotFound,
375}
376
377impl<T> ConditionalResult<T> {
378 /// Check if the result represents a successful operation.
379 pub fn is_success(&self) -> bool {
380 matches!(self, ConditionalResult::Success(_))
381 }
382
383 /// Check if the result represents a version mismatch.
384 pub fn is_version_mismatch(&self) -> bool {
385 matches!(self, ConditionalResult::VersionMismatch(_))
386 }
387
388 /// Check if the result represents a not found error.
389 pub fn is_not_found(&self) -> bool {
390 matches!(self, ConditionalResult::NotFound)
391 }
392
393 /// Extract the success value, if present.
394 pub fn into_success(self) -> Option<T> {
395 match self {
396 ConditionalResult::Success(value) => Some(value),
397 _ => None,
398 }
399 }
400
401 /// Extract the version conflict, if present.
402 pub fn into_version_conflict(self) -> Option<VersionConflict> {
403 match self {
404 ConditionalResult::VersionMismatch(conflict) => Some(conflict),
405 _ => None,
406 }
407 }
408
409 /// Map the success value to a different type.
410 pub fn map<U, F>(self, f: F) -> ConditionalResult<U>
411 where
412 F: FnOnce(T) -> U,
413 {
414 match self {
415 ConditionalResult::Success(value) => ConditionalResult::Success(f(value)),
416 ConditionalResult::VersionMismatch(conflict) => {
417 ConditionalResult::VersionMismatch(conflict)
418 }
419 ConditionalResult::NotFound => ConditionalResult::NotFound,
420 }
421 }
422}
423
424/// Details about a version conflict during a conditional operation.
425///
426/// Provides information about the expected version (from the client)
427/// and the current version (from the server), along with a human-readable
428/// error message.
429///
430/// # Examples
431///
432/// ```rust
433/// use scim_server::resource::version::{VersionConflict, ScimVersion};
434///
435/// let conflict = VersionConflict {
436/// expected: ScimVersion::from_hash("1"),
437/// current: ScimVersion::from_hash("2"),
438/// message: "Resource was modified by another client".to_string(),
439/// };
440/// ```
441#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
442pub struct VersionConflict {
443 /// The version that was expected by the client
444 pub expected: ScimVersion,
445
446 /// The current version of the resource on the server
447 pub current: ScimVersion,
448
449 /// Human-readable error message describing the conflict
450 pub message: String,
451}
452
453impl VersionConflict {
454 /// Create a new version conflict.
455 ///
456 /// # Arguments
457 /// * `expected` - The version expected by the client
458 /// * `current` - The current version on the server
459 /// * `message` - Human-readable error message
460 pub fn new(expected: ScimVersion, current: ScimVersion, message: impl Into<String>) -> Self {
461 Self {
462 expected,
463 current,
464 message: message.into(),
465 }
466 }
467
468 /// Create a standard version conflict message.
469 ///
470 /// # Arguments
471 /// * `expected` - The version expected by the client
472 /// * `current` - The current version on the server
473 pub fn standard_message(expected: ScimVersion, current: ScimVersion) -> Self {
474 Self::new(
475 expected,
476 current,
477 "Resource was modified by another client. Please refresh and try again.",
478 )
479 }
480}
481
482impl fmt::Display for VersionConflict {
483 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
484 write!(
485 f,
486 "Version conflict: expected '{}', found '{}'. {}",
487 self.expected, self.current, self.message
488 )
489 }
490}
491
492impl std::error::Error for VersionConflict {}
493
494/// Errors that can occur during version operations.
495#[derive(Debug, Error, Clone, PartialEq)]
496pub enum VersionError {
497 /// Invalid ETag format provided
498 #[error("Invalid ETag format: {0}")]
499 InvalidEtagFormat(String),
500
501 /// Version parsing failed
502 #[error("Failed to parse version: {0}")]
503 ParseError(String),
504}
505
506#[cfg(test)]
507mod tests {
508 use super::*;
509
510 #[test]
511 fn test_version_from_content() {
512 let content = br#"{"id":"123","userName":"john.doe"}"#;
513 let version = ScimVersion::from_content(content);
514
515 // Version should be deterministic
516 let version2 = ScimVersion::from_content(content);
517 assert_eq!(version, version2);
518
519 // Different content should produce different versions
520 let different_content = br#"{"id":"123","userName":"jane.doe"}"#;
521 let different_version = ScimVersion::from_content(different_content);
522 assert_ne!(version, different_version);
523 }
524
525 #[test]
526 fn test_version_from_hash() {
527 let hash_string = "abc123def456";
528 let version = ScimVersion::from_hash(hash_string);
529 assert_eq!(version.as_str(), hash_string);
530 assert_eq!(version.to_http_header(), "W/\"abc123def456\"");
531
532 // Test with different hash strings
533 let version2 = ScimVersion::from_hash("different123");
534 assert_ne!(version, version2);
535 }
536
537 #[test]
538 fn test_version_parse_http_header() {
539 // Strong ETag
540 let version = ScimVersion::parse_http_header("\"abc123\"").unwrap();
541 assert_eq!(version.as_str(), "abc123");
542
543 // Weak ETag
544 let weak_version = ScimVersion::parse_http_header("W/\"abc123\"").unwrap();
545 assert_eq!(weak_version.as_str(), "abc123");
546
547 // Invalid formats
548 assert!(ScimVersion::parse_http_header("abc123").is_err());
549 assert!(ScimVersion::parse_http_header("\"\"").is_err());
550 assert!(ScimVersion::parse_http_header("").is_err());
551 }
552
553 #[test]
554 fn test_version_parse_raw() {
555 // Valid raw version
556 let version = ScimVersion::parse_raw("abc123def").unwrap();
557 assert_eq!(version.as_str(), "abc123def");
558
559 // Whitespace handling
560 let trimmed_version = ScimVersion::parse_raw(" xyz789 ").unwrap();
561 assert_eq!(trimmed_version.as_str(), "xyz789");
562
563 // Empty string should fail
564 assert!(ScimVersion::parse_raw("").is_err());
565 assert!(ScimVersion::parse_raw(" ").is_err());
566
567 // Compare with HTTP header parsing - raw should be simpler
568 let raw_version = ScimVersion::parse_raw("test123").unwrap();
569 let http_version = ScimVersion::parse_http_header("W/\"test123\"").unwrap();
570 assert!(raw_version.matches(&http_version));
571 }
572
573 #[test]
574 fn test_version_matches() {
575 let content = br#"{"id":"123","data":"test"}"#;
576 let v1 = ScimVersion::from_content(content);
577 let v2 = ScimVersion::from_content(content);
578 let v3 = ScimVersion::from_content(br#"{"id":"456","data":"test"}"#);
579
580 assert!(v1.matches(&v2));
581 assert!(!v1.matches(&v3));
582 }
583
584 #[test]
585 fn test_version_round_trip() {
586 let content = br#"{"id":"test","version":"round-trip"}"#;
587 let original = ScimVersion::from_content(content);
588 let etag = original.to_http_header();
589 let parsed = ScimVersion::parse_http_header(&etag).unwrap();
590
591 assert_eq!(original, parsed);
592 }
593
594 #[test]
595 fn test_conditional_result() {
596 let success: ConditionalResult<i32> = ConditionalResult::Success(42);
597 assert!(success.is_success());
598 assert_eq!(success.into_success(), Some(42));
599
600 let conflict = ConditionalResult::<i32>::VersionMismatch(VersionConflict::new(
601 ScimVersion::from_hash("version1"),
602 ScimVersion::from_hash("version2"),
603 "test conflict",
604 ));
605 assert!(conflict.is_version_mismatch());
606
607 let not_found: ConditionalResult<i32> = ConditionalResult::NotFound;
608 assert!(not_found.is_not_found());
609 }
610
611 #[test]
612 fn test_conditional_result_map() {
613 let success: ConditionalResult<i32> = ConditionalResult::Success(42);
614 let mapped = success.map(|x| x.to_string());
615 assert_eq!(mapped.into_success(), Some("42".to_string()));
616 }
617
618 #[test]
619 fn test_version_conflict() {
620 let conflict = VersionConflict::standard_message(
621 ScimVersion::from_hash("version1"),
622 ScimVersion::from_hash("version2"),
623 );
624
625 assert_eq!(conflict.expected.as_str(), "version1");
626 assert_eq!(conflict.current.as_str(), "version2");
627 assert!(!conflict.message.is_empty());
628 }
629
630 #[test]
631 fn test_version_conflict_display() {
632 let conflict = VersionConflict::new(
633 ScimVersion::from_hash("old-hash"),
634 ScimVersion::from_hash("new-hash"),
635 "Custom message",
636 );
637
638 let display = format!("{}", conflict);
639 assert!(display.contains("old-hash"));
640 assert!(display.contains("new-hash"));
641 assert!(display.contains("Custom message"));
642 }
643
644 #[test]
645 fn test_version_serialization() {
646 let content = br#"{"test":"serialization"}"#;
647 let version = ScimVersion::from_content(content);
648
649 // Test JSON serialization
650 let json = serde_json::to_string(&version).unwrap();
651 let deserialized: ScimVersion = serde_json::from_str(&json).unwrap();
652
653 assert_eq!(version, deserialized);
654 }
655
656 #[test]
657 fn test_version_conflict_serialization() {
658 let conflict = VersionConflict::new(
659 ScimVersion::from_hash("hash-v1"),
660 ScimVersion::from_hash("hash-v2"),
661 "Serialization test conflict",
662 );
663
664 // Test JSON serialization
665 let json = serde_json::to_string(&conflict).unwrap();
666 let deserialized: VersionConflict = serde_json::from_str(&json).unwrap();
667
668 assert_eq!(conflict, deserialized);
669 }
670}