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 /// Convert version to HTTP ETag header value.
251 ///
252 /// This generates a weak HTTP ETag header value that can be used in conditional
253 /// HTTP requests. SCIM resources use weak ETags since they represent semantic
254 /// equivalence rather than byte-for-byte identity. The returned value includes
255 /// the W/ prefix and surrounding quotes required by RFC 7232.
256 ///
257 /// # Examples
258 /// ```rust
259 /// use scim_server::resource::version::ScimVersion;
260 ///
261 /// let version = ScimVersion::from_hash("12345");
262 /// let etag = version.to_http_header();
263 /// assert_eq!(etag, "W/\"12345\"");
264 /// ```
265 pub fn to_http_header(&self) -> String {
266 format!("W/\"{}\"", self.opaque)
267 }
268
269 /// Check if this version matches another version.
270 ///
271 /// This is used for conditional operations to determine if the expected
272 /// version matches the current version of a resource.
273 ///
274 /// # Arguments
275 /// * `other` - The version to compare against
276 ///
277 /// # Examples
278 /// ```rust
279 /// use scim_server::resource::version::ScimVersion;
280 ///
281 /// let v1 = ScimVersion::from_hash("123");
282 /// let v2 = ScimVersion::from_hash("123");
283 /// let v3 = ScimVersion::from_hash("456");
284 ///
285 /// assert!(v1.matches(&v2));
286 /// assert!(!v1.matches(&v3));
287 /// ```
288 pub fn matches(&self, other: &ScimVersion) -> bool {
289 self.opaque == other.opaque
290 }
291
292 /// Get the opaque version string.
293 ///
294 /// This is primarily for internal use and debugging. The opaque string
295 /// should not be relied upon for any business logic.
296 pub fn as_str(&self) -> &str {
297 &self.opaque
298 }
299}
300
301impl fmt::Display for ScimVersion {
302 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
303 write!(f, "{}", self.opaque)
304 }
305}
306
307/// Result type for conditional SCIM operations.
308///
309/// Represents the outcome of a conditional operation that depends on
310/// resource versioning. This allows providers to indicate whether
311/// an operation succeeded, failed due to a version mismatch, or
312/// failed because the resource was not found.
313///
314/// # Examples
315///
316/// ```rust
317/// use scim_server::resource::version::{ConditionalResult, ScimVersion, VersionConflict};
318/// use serde_json::json;
319///
320/// // Successful operation
321/// let success = ConditionalResult::Success(json!({"id": "123"}));
322///
323/// // Version mismatch
324/// let expected = ScimVersion::from_hash("1");
325/// let current = ScimVersion::from_hash("2");
326/// let conflict: ConditionalResult<serde_json::Value> = ConditionalResult::VersionMismatch(VersionConflict {
327/// expected,
328/// current,
329/// message: "Resource was modified by another client".to_string(),
330/// });
331///
332/// // Resource not found
333/// let not_found: ConditionalResult<serde_json::Value> = ConditionalResult::NotFound;
334/// ```
335#[derive(Debug, Clone, PartialEq)]
336pub enum ConditionalResult<T> {
337 /// Operation completed successfully
338 Success(T),
339
340 /// Operation failed due to version mismatch
341 VersionMismatch(VersionConflict),
342
343 /// Operation failed because the resource was not found
344 NotFound,
345}
346
347impl<T> ConditionalResult<T> {
348 /// Check if the result represents a successful operation.
349 pub fn is_success(&self) -> bool {
350 matches!(self, ConditionalResult::Success(_))
351 }
352
353 /// Check if the result represents a version mismatch.
354 pub fn is_version_mismatch(&self) -> bool {
355 matches!(self, ConditionalResult::VersionMismatch(_))
356 }
357
358 /// Check if the result represents a not found error.
359 pub fn is_not_found(&self) -> bool {
360 matches!(self, ConditionalResult::NotFound)
361 }
362
363 /// Extract the success value, if present.
364 pub fn into_success(self) -> Option<T> {
365 match self {
366 ConditionalResult::Success(value) => Some(value),
367 _ => None,
368 }
369 }
370
371 /// Extract the version conflict, if present.
372 pub fn into_version_conflict(self) -> Option<VersionConflict> {
373 match self {
374 ConditionalResult::VersionMismatch(conflict) => Some(conflict),
375 _ => None,
376 }
377 }
378
379 /// Map the success value to a different type.
380 pub fn map<U, F>(self, f: F) -> ConditionalResult<U>
381 where
382 F: FnOnce(T) -> U,
383 {
384 match self {
385 ConditionalResult::Success(value) => ConditionalResult::Success(f(value)),
386 ConditionalResult::VersionMismatch(conflict) => {
387 ConditionalResult::VersionMismatch(conflict)
388 }
389 ConditionalResult::NotFound => ConditionalResult::NotFound,
390 }
391 }
392}
393
394/// Details about a version conflict during a conditional operation.
395///
396/// Provides information about the expected version (from the client)
397/// and the current version (from the server), along with a human-readable
398/// error message.
399///
400/// # Examples
401///
402/// ```rust
403/// use scim_server::resource::version::{VersionConflict, ScimVersion};
404///
405/// let conflict = VersionConflict {
406/// expected: ScimVersion::from_hash("1"),
407/// current: ScimVersion::from_hash("2"),
408/// message: "Resource was modified by another client".to_string(),
409/// };
410/// ```
411#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
412pub struct VersionConflict {
413 /// The version that was expected by the client
414 pub expected: ScimVersion,
415
416 /// The current version of the resource on the server
417 pub current: ScimVersion,
418
419 /// Human-readable error message describing the conflict
420 pub message: String,
421}
422
423impl VersionConflict {
424 /// Create a new version conflict.
425 ///
426 /// # Arguments
427 /// * `expected` - The version expected by the client
428 /// * `current` - The current version on the server
429 /// * `message` - Human-readable error message
430 pub fn new(expected: ScimVersion, current: ScimVersion, message: impl Into<String>) -> Self {
431 Self {
432 expected,
433 current,
434 message: message.into(),
435 }
436 }
437
438 /// Create a standard version conflict message.
439 ///
440 /// # Arguments
441 /// * `expected` - The version expected by the client
442 /// * `current` - The current version on the server
443 pub fn standard_message(expected: ScimVersion, current: ScimVersion) -> Self {
444 Self::new(
445 expected,
446 current,
447 "Resource was modified by another client. Please refresh and try again.",
448 )
449 }
450}
451
452impl fmt::Display for VersionConflict {
453 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
454 write!(
455 f,
456 "Version conflict: expected '{}', found '{}'. {}",
457 self.expected, self.current, self.message
458 )
459 }
460}
461
462impl std::error::Error for VersionConflict {}
463
464/// Errors that can occur during version operations.
465#[derive(Debug, Error, Clone, PartialEq)]
466pub enum VersionError {
467 /// Invalid ETag format provided
468 #[error("Invalid ETag format: {0}")]
469 InvalidEtagFormat(String),
470
471 /// Version parsing failed
472 #[error("Failed to parse version: {0}")]
473 ParseError(String),
474}
475
476#[cfg(test)]
477mod tests {
478 use super::*;
479
480 #[test]
481 fn test_version_from_content() {
482 let content = br#"{"id":"123","userName":"john.doe"}"#;
483 let version = ScimVersion::from_content(content);
484
485 // Version should be deterministic
486 let version2 = ScimVersion::from_content(content);
487 assert_eq!(version, version2);
488
489 // Different content should produce different versions
490 let different_content = br#"{"id":"123","userName":"jane.doe"}"#;
491 let different_version = ScimVersion::from_content(different_content);
492 assert_ne!(version, different_version);
493 }
494
495 #[test]
496 fn test_version_from_hash() {
497 let hash_string = "abc123def456";
498 let version = ScimVersion::from_hash(hash_string);
499 assert_eq!(version.as_str(), hash_string);
500 assert_eq!(version.to_http_header(), "W/\"abc123def456\"");
501
502 // Test with different hash strings
503 let version2 = ScimVersion::from_hash("different123");
504 assert_ne!(version, version2);
505 }
506
507 #[test]
508 fn test_version_parse_http_header() {
509 // Strong ETag
510 let version = ScimVersion::parse_http_header("\"abc123\"").unwrap();
511 assert_eq!(version.as_str(), "abc123");
512
513 // Weak ETag
514 let weak_version = ScimVersion::parse_http_header("W/\"abc123\"").unwrap();
515 assert_eq!(weak_version.as_str(), "abc123");
516
517 // Invalid formats
518 assert!(ScimVersion::parse_http_header("abc123").is_err());
519 assert!(ScimVersion::parse_http_header("\"\"").is_err());
520 assert!(ScimVersion::parse_http_header("").is_err());
521 }
522
523 #[test]
524 fn test_version_matches() {
525 let content = br#"{"id":"123","data":"test"}"#;
526 let v1 = ScimVersion::from_content(content);
527 let v2 = ScimVersion::from_content(content);
528 let v3 = ScimVersion::from_content(br#"{"id":"456","data":"test"}"#);
529
530 assert!(v1.matches(&v2));
531 assert!(!v1.matches(&v3));
532 }
533
534 #[test]
535 fn test_version_round_trip() {
536 let content = br#"{"id":"test","version":"round-trip"}"#;
537 let original = ScimVersion::from_content(content);
538 let etag = original.to_http_header();
539 let parsed = ScimVersion::parse_http_header(&etag).unwrap();
540
541 assert_eq!(original, parsed);
542 }
543
544 #[test]
545 fn test_conditional_result() {
546 let success: ConditionalResult<i32> = ConditionalResult::Success(42);
547 assert!(success.is_success());
548 assert_eq!(success.into_success(), Some(42));
549
550 let conflict = ConditionalResult::<i32>::VersionMismatch(VersionConflict::new(
551 ScimVersion::from_hash("version1"),
552 ScimVersion::from_hash("version2"),
553 "test conflict",
554 ));
555 assert!(conflict.is_version_mismatch());
556
557 let not_found: ConditionalResult<i32> = ConditionalResult::NotFound;
558 assert!(not_found.is_not_found());
559 }
560
561 #[test]
562 fn test_conditional_result_map() {
563 let success: ConditionalResult<i32> = ConditionalResult::Success(42);
564 let mapped = success.map(|x| x.to_string());
565 assert_eq!(mapped.into_success(), Some("42".to_string()));
566 }
567
568 #[test]
569 fn test_version_conflict() {
570 let conflict = VersionConflict::standard_message(
571 ScimVersion::from_hash("version1"),
572 ScimVersion::from_hash("version2"),
573 );
574
575 assert_eq!(conflict.expected.as_str(), "version1");
576 assert_eq!(conflict.current.as_str(), "version2");
577 assert!(!conflict.message.is_empty());
578 }
579
580 #[test]
581 fn test_version_conflict_display() {
582 let conflict = VersionConflict::new(
583 ScimVersion::from_hash("old-hash"),
584 ScimVersion::from_hash("new-hash"),
585 "Custom message",
586 );
587
588 let display = format!("{}", conflict);
589 assert!(display.contains("old-hash"));
590 assert!(display.contains("new-hash"));
591 assert!(display.contains("Custom message"));
592 }
593
594 #[test]
595 fn test_version_serialization() {
596 let content = br#"{"test":"serialization"}"#;
597 let version = ScimVersion::from_content(content);
598
599 // Test JSON serialization
600 let json = serde_json::to_string(&version).unwrap();
601 let deserialized: ScimVersion = serde_json::from_str(&json).unwrap();
602
603 assert_eq!(version, deserialized);
604 }
605
606 #[test]
607 fn test_version_conflict_serialization() {
608 let conflict = VersionConflict::new(
609 ScimVersion::from_hash("hash-v1"),
610 ScimVersion::from_hash("hash-v2"),
611 "Serialization test conflict",
612 );
613
614 // Test JSON serialization
615 let json = serde_json::to_string(&conflict).unwrap();
616 let deserialized: VersionConflict = serde_json::from_str(&json).unwrap();
617
618 assert_eq!(conflict, deserialized);
619 }
620}