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}