Skip to main content

fraiseql_core/validation/
id_policy.rs

1//! ID Policy validation for GraphQL ID scalar type
2//!
3//! This module provides validation for ID fields based on the configured ID policy.
4//!
5//! **Design Pattern**: `FraiseQL` supports two ID policies:
6//! 1. **UUID**: IDs must be valid UUIDs (`FraiseQL`'s opinionated default)
7//! 2. **OPAQUE**: IDs accept any string (GraphQL spec-compliant)
8//!
9//! This module enforces UUID format validation when `IDPolicy::UUID` is configured.
10//!
11//! # Example
12//!
13//! ```
14//! use fraiseql_core::validation::{IDPolicy, validate_id};
15//!
16//! // UUID policy: strict UUID validation
17//! let policy = IDPolicy::UUID;
18//! assert!(
19//!     validate_id("550e8400-e29b-41d4-a716-446655440000", policy).is_ok(),
20//!     "valid UUID should pass UUID policy"
21//! );
22//! assert!(
23//!     validate_id("not-a-uuid", policy).is_err(),
24//!     "non-UUID string should fail UUID policy"
25//! );
26//!
27//! // OPAQUE policy: any string accepted
28//! let policy = IDPolicy::OPAQUE;
29//! assert!(
30//!     validate_id("not-a-uuid", policy).is_ok(),
31//!     "OPAQUE policy should accept any string"
32//! );
33//! assert!(
34//!     validate_id("any-arbitrary-string", policy).is_ok(),
35//!     "OPAQUE policy should accept arbitrary strings"
36//! );
37//! ```
38
39use serde::{Deserialize, Serialize};
40
41/// ID Policy determines how GraphQL ID scalar type behaves
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
43#[non_exhaustive]
44pub enum IDPolicy {
45    /// IDs must be valid UUIDs (`FraiseQL`'s opinionated default)
46    #[serde(rename = "uuid")]
47    #[default]
48    UUID,
49
50    /// IDs accept any string (GraphQL specification compliant)
51    #[serde(rename = "opaque")]
52    OPAQUE,
53}
54
55impl IDPolicy {
56    /// Check if this policy enforces UUID format for IDs
57    #[must_use]
58    pub fn enforces_uuid(self) -> bool {
59        self == Self::UUID
60    }
61
62    /// Get the policy name as a string
63    #[must_use]
64    pub const fn as_str(self) -> &'static str {
65        match self {
66            Self::UUID => "uuid",
67            Self::OPAQUE => "opaque",
68        }
69    }
70}
71
72impl std::fmt::Display for IDPolicy {
73    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
74        write!(f, "{}", self.as_str())
75    }
76}
77
78/// Error type for ID validation failures
79#[derive(Debug, Clone, PartialEq, Eq)]
80pub struct IDValidationError {
81    /// The invalid ID value
82    pub value:   String,
83    /// The policy that was violated
84    pub policy:  IDPolicy,
85    /// Error message
86    pub message: String,
87}
88
89impl std::fmt::Display for IDValidationError {
90    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
91        write!(f, "{}", self.message)
92    }
93}
94
95impl std::error::Error for IDValidationError {}
96
97/// Validate an ID string against the configured ID policy
98///
99/// # Arguments
100///
101/// * `id` - The ID value to validate
102/// * `policy` - The ID policy to enforce
103///
104/// # Returns
105///
106/// `Ok(())` if the ID is valid for the policy, `Err(IDValidationError)` otherwise
107///
108/// # Errors
109///
110/// Returns `IDValidationError` if the ID does not conform to the specified policy.
111/// For `IDPolicy::UUID`, the ID must be a valid UUID. For `IDPolicy::OPAQUE`, any string is valid.
112///
113/// # Examples
114///
115/// ```
116/// use fraiseql_core::validation::{IDPolicy, validate_id};
117///
118/// // UUID policy enforces UUID format
119/// assert!(
120///     validate_id("550e8400-e29b-41d4-a716-446655440000", IDPolicy::UUID).is_ok(),
121///     "valid UUID should pass UUID policy"
122/// );
123/// assert!(
124///     validate_id("not-uuid", IDPolicy::UUID).is_err(),
125///     "non-UUID string should fail UUID policy"
126/// );
127///
128/// // OPAQUE policy accepts any string
129/// assert!(
130///     validate_id("anything", IDPolicy::OPAQUE).is_ok(),
131///     "OPAQUE policy should accept any string"
132/// );
133/// assert!(
134///     validate_id("", IDPolicy::OPAQUE).is_ok(),
135///     "OPAQUE policy should accept empty string"
136/// );
137/// ```
138///
139/// # Errors
140///
141/// Returns [`IDValidationError`] if `id` does not conform to `policy`
142/// (e.g., not a valid UUID when `IDPolicy::UUID` is used).
143pub fn validate_id(id: &str, policy: IDPolicy) -> Result<(), IDValidationError> {
144    match policy {
145        IDPolicy::UUID => validate_uuid_format(id),
146        IDPolicy::OPAQUE => Ok(()), // Opaque IDs accept any string
147    }
148}
149
150/// Validate that an ID is a valid UUID string
151///
152/// **Security Note**: This is a defense-in-depth check at the Rust runtime layer.
153/// The primary enforcement point is the CLI compiler (`fraiseql-cli compile`), which
154/// validates ID policy rules when producing `schema.compiled.json`.
155///
156/// UUID format validation requires:
157/// - 36 characters total
158/// - 8-4-4-4-12 hexadecimal digits separated by hyphens
159/// - Case-insensitive
160///
161/// # Arguments
162///
163/// * `id` - The ID string to validate
164///
165/// # Returns
166///
167/// `Ok(())` if valid UUID format, `Err(IDValidationError)` otherwise
168fn validate_uuid_format(id: &str) -> Result<(), IDValidationError> {
169    // UUID must be 36 characters: 8-4-4-4-12
170    if id.len() != 36 {
171        return Err(IDValidationError {
172            value:   id.to_string(),
173            policy:  IDPolicy::UUID,
174            message: format!(
175                "ID must be a valid UUID (36 characters), got {} characters",
176                id.len()
177            ),
178        });
179    }
180
181    // Check overall structure: 8-4-4-4-12
182    let parts: Vec<&str> = id.split('-').collect();
183    if parts.len() != 5 {
184        return Err(IDValidationError {
185            value:   id.to_string(),
186            policy:  IDPolicy::UUID,
187            message: "ID must be a valid UUID with format XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX"
188                .to_string(),
189        });
190    }
191
192    // Validate segment lengths
193    let expected_lengths = [8, 4, 4, 4, 12];
194    for (i, (part, &expected_len)) in parts.iter().zip(&expected_lengths).enumerate() {
195        if part.len() != expected_len {
196            return Err(IDValidationError {
197                value:   id.to_string(),
198                policy:  IDPolicy::UUID,
199                message: format!(
200                    "UUID segment {} has invalid length: expected {}, got {}",
201                    i,
202                    expected_len,
203                    part.len()
204                ),
205            });
206        }
207    }
208
209    // Validate all characters are hexadecimal
210    for (i, part) in parts.iter().enumerate() {
211        if !part.chars().all(|c| c.is_ascii_hexdigit()) {
212            return Err(IDValidationError {
213                value:   id.to_string(),
214                policy:  IDPolicy::UUID,
215                message: format!("UUID segment {i} contains non-hexadecimal characters: '{part}'"),
216            });
217        }
218    }
219
220    Ok(())
221}
222
223/// Validate multiple IDs against a policy
224///
225/// # Arguments
226///
227/// * `ids` - Slice of ID strings to validate
228/// * `policy` - The ID policy to enforce
229///
230/// # Returns
231///
232/// `Ok(())` if all IDs are valid, `Err(IDValidationError)` for the first invalid ID
233///
234/// # Examples
235///
236/// ```
237/// use fraiseql_core::validation::{validate_id, IDPolicy};
238///
239/// // Validate each ID individually
240/// assert!(
241///     validate_id("550e8400-e29b-41d4-a716-446655440000", IDPolicy::UUID).is_ok(),
242///     "first UUID should pass validation"
243/// );
244/// assert!(
245///     validate_id("6ba7b810-9dad-11d1-80b4-00c04fd430c8", IDPolicy::UUID).is_ok(),
246///     "second UUID should pass validation"
247/// );
248/// ```
249///
250/// # Errors
251///
252/// Returns `IDValidationError` if any ID fails validation.
253#[allow(dead_code)] // Reason: public API intended for external consumers; no in-crate callers yet
254pub fn validate_ids(ids: &[&str], policy: IDPolicy) -> Result<(), IDValidationError> {
255    for id in ids {
256        validate_id(id, policy)?;
257    }
258    Ok(())
259}
260
261// =============================================================================
262// Pluggable ID Validator Trait System
263// =============================================================================
264
265/// Trait for pluggable ID validation strategies
266///
267/// This trait enables users to implement custom ID validation logic
268/// beyond the built-in UUID and OPAQUE policies.
269///
270/// # Examples
271///
272/// ```no_run
273/// # // Requires: IdValidator and IDValidationError are not re-exported from the public API
274/// use fraiseql_core::validation::{IDPolicy, IDValidationError};
275///
276/// struct CustomIdValidator;
277///
278/// // impl IdValidator for CustomIdValidator { ... }
279/// ```
280pub trait IdValidator: Send + Sync {
281    /// Validate an ID value
282    fn validate(&self, value: &str) -> Result<(), IDValidationError>;
283
284    /// Human-readable name of the format (for error messages)
285    fn format_name(&self) -> &'static str;
286}
287
288/// UUID format validator
289#[derive(Debug, Clone, Copy)]
290pub struct UuidIdValidator;
291
292impl IdValidator for UuidIdValidator {
293    fn validate(&self, value: &str) -> Result<(), IDValidationError> {
294        validate_uuid_format(value)
295    }
296
297    fn format_name(&self) -> &'static str {
298        "UUID"
299    }
300}
301
302/// Numeric ID validator (integers)
303#[derive(Debug, Clone, Copy)]
304pub struct NumericIdValidator;
305
306impl IdValidator for NumericIdValidator {
307    fn validate(&self, value: &str) -> Result<(), IDValidationError> {
308        value.parse::<i64>().map_err(|_| IDValidationError {
309            value:   value.to_string(),
310            policy:  IDPolicy::OPAQUE,
311            message: format!(
312                "ID must be a valid {} (parseable as 64-bit integer)",
313                self.format_name()
314            ),
315        })?;
316        Ok(())
317    }
318
319    fn format_name(&self) -> &'static str {
320        "integer"
321    }
322}
323
324/// ULID format validator (Universally Unique Lexicographically Sortable Identifier)
325///
326/// ULIDs are 26 uppercase alphanumeric characters, providing sortable unique IDs.
327/// Example: `01ARZ3NDEKTSV4RRFFQ69G5FAV`
328#[derive(Debug, Clone, Copy)]
329pub struct UlidIdValidator;
330
331impl IdValidator for UlidIdValidator {
332    fn validate(&self, value: &str) -> Result<(), IDValidationError> {
333        if value.len() != 26 {
334            return Err(IDValidationError {
335                value:   value.to_string(),
336                policy:  IDPolicy::OPAQUE,
337                message: format!(
338                    "ID must be a valid {} ({} characters), got {}",
339                    self.format_name(),
340                    26,
341                    value.len()
342                ),
343            });
344        }
345
346        // ULIDs use Crockford base32 encoding (0-9, A-Z except I, L, O, U)
347        if !value.chars().all(|c| {
348            c.is_ascii_digit()
349                || (c.is_ascii_uppercase() && c != 'I' && c != 'L' && c != 'O' && c != 'U')
350        }) {
351            return Err(IDValidationError {
352                value:   value.to_string(),
353                policy:  IDPolicy::OPAQUE,
354                message: format!(
355                    "ID must be a valid {} (Crockford base32: 0-9, A-Z except I, L, O, U)",
356                    self.format_name()
357                ),
358            });
359        }
360
361        Ok(())
362    }
363
364    fn format_name(&self) -> &'static str {
365        "ULID"
366    }
367}
368
369/// Opaque ID validator (accepts any string)
370#[derive(Debug, Clone, Copy)]
371pub struct OpaqueIdValidator;
372
373impl IdValidator for OpaqueIdValidator {
374    fn validate(&self, _value: &str) -> Result<(), IDValidationError> {
375        Ok(()) // Accept any string
376    }
377
378    fn format_name(&self) -> &'static str {
379        "opaque"
380    }
381}
382
383/// ID validation profile for different use cases
384///
385/// Profiles provide preset ID validation configurations for common scenarios.
386/// Each profile includes a name and a validator instance.
387///
388/// # Built-in Profiles
389///
390/// - **UUID**: Strict UUID format validation (FraiseQL default)
391/// - **Numeric**: Integer-based IDs (suitable for sequential IDs)
392/// - **ULID**: Sortable unique identifiers (recommended for distributed systems)
393/// - **Opaque**: Any string accepted (GraphQL spec compliant)
394#[derive(Debug, Clone)]
395pub struct IDValidationProfile {
396    /// Profile name (e.g., "uuid", "ulid", "numeric")
397    pub name: String,
398
399    /// Validator instance for this profile
400    pub validator: ValidationProfileType,
401}
402
403/// Type of validation profile
404#[derive(Debug, Clone)]
405#[non_exhaustive]
406pub enum ValidationProfileType {
407    /// UUID format validation
408    Uuid(UuidIdValidator),
409
410    /// Numeric (integer) validation
411    Numeric(NumericIdValidator),
412
413    /// ULID format validation
414    Ulid(UlidIdValidator),
415
416    /// Opaque (any string) validation
417    Opaque(OpaqueIdValidator),
418}
419
420impl ValidationProfileType {
421    /// Get the validator as a trait object
422    #[must_use]
423    pub fn as_validator(&self) -> &dyn IdValidator {
424        match self {
425            Self::Uuid(v) => v,
426            Self::Numeric(v) => v,
427            Self::Ulid(v) => v,
428            Self::Opaque(v) => v,
429        }
430    }
431}
432
433impl IDValidationProfile {
434    /// Create a UUID validation profile (FraiseQL default)
435    #[must_use]
436    pub fn uuid() -> Self {
437        Self {
438            name:      "uuid".to_string(),
439            validator: ValidationProfileType::Uuid(UuidIdValidator),
440        }
441    }
442
443    /// Create a numeric (integer) validation profile
444    #[must_use]
445    pub fn numeric() -> Self {
446        Self {
447            name:      "numeric".to_string(),
448            validator: ValidationProfileType::Numeric(NumericIdValidator),
449        }
450    }
451
452    /// Create a ULID validation profile
453    #[must_use]
454    pub fn ulid() -> Self {
455        Self {
456            name:      "ulid".to_string(),
457            validator: ValidationProfileType::Ulid(UlidIdValidator),
458        }
459    }
460
461    /// Create an opaque (any string) validation profile
462    #[must_use]
463    pub fn opaque() -> Self {
464        Self {
465            name:      "opaque".to_string(),
466            validator: ValidationProfileType::Opaque(OpaqueIdValidator),
467        }
468    }
469
470    /// Get profile by name
471    ///
472    /// Returns a profile matching the given name, or None if not found.
473    ///
474    /// # Built-in Profile Names
475    ///
476    /// - "uuid" - UUID validation
477    /// - "numeric" - Integer validation
478    /// - "ulid" - ULID validation
479    /// - "opaque" - Any string validation
480    #[must_use]
481    pub fn by_name(name: &str) -> Option<Self> {
482        match name.to_lowercase().as_str() {
483            "uuid" => Some(Self::uuid()),
484            "numeric" | "integer" => Some(Self::numeric()),
485            "ulid" => Some(Self::ulid()),
486            "opaque" | "string" => Some(Self::opaque()),
487            _ => None,
488        }
489    }
490
491    /// Validate an ID using this profile.
492    ///
493    /// # Errors
494    ///
495    /// Returns [`IDValidationError`] if the value does not conform to this
496    /// profile's validator (e.g., not a valid UUID, ULID, or integer).
497    pub fn validate(&self, value: &str) -> Result<(), IDValidationError> {
498        self.validator.as_validator().validate(value)
499    }
500}