Skip to main content

armature_core/
conditional.rs

1//! ETag and conditional request handling.
2//!
3//! This module provides support for HTTP conditional requests, enabling
4//! efficient caching and optimistic concurrency control.
5//!
6//! # Supported Headers
7//!
8//! - `ETag` - Entity tag for resource versioning
9//! - `If-None-Match` - Conditional GET (return 304 if ETag matches)
10//! - `If-Match` - Conditional PUT/DELETE (fail if ETag doesn't match)
11//! - `If-Modified-Since` - Conditional GET based on modification time
12//! - `If-Unmodified-Since` - Conditional PUT/DELETE based on modification time
13//!
14//! # Examples
15//!
16//! ## Conditional GET with ETag
17//!
18//! ```
19//! use armature_core::conditional::{ETag, ConditionalRequest};
20//! use armature_core::HttpRequest;
21//!
22//! fn handle_get(request: &HttpRequest) -> Result<(), ()> {
23//!     let etag = ETag::strong("abc123");
24//!
25//!     // Check if client has current version
26//!     if request.if_none_match_matches(&etag) {
27//!         // Return 304 Not Modified
28//!         return Ok(());
29//!     }
30//!
31//!     // Return full response with ETag
32//!     Ok(())
33//! }
34//! ```
35//!
36//! ## Optimistic Concurrency with If-Match
37//!
38//! ```
39//! use armature_core::conditional::{ETag, ConditionalRequest};
40//! use armature_core::HttpRequest;
41//!
42//! fn handle_update(request: &HttpRequest, current_etag: &ETag) -> Result<(), ()> {
43//!     // Verify client has current version before updating
44//!     if !request.if_match_matches(current_etag) {
45//!         // Return 412 Precondition Failed
46//!         return Err(());
47//!     }
48//!
49//!     // Proceed with update
50//!     Ok(())
51//! }
52//! ```
53
54use crate::{Error, HttpRequest, HttpResponse};
55use bytes::Bytes;
56use std::fmt;
57use std::hash::{Hash, Hasher};
58use std::time::SystemTime;
59
60// ============================================================================
61// ETag
62// ============================================================================
63
64/// Represents an HTTP ETag (Entity Tag).
65///
66/// ETags come in two varieties:
67/// - **Strong ETags**: Byte-for-byte identical (`"abc123"`)
68/// - **Weak ETags**: Semantically equivalent (`W/"abc123"`)
69///
70/// # Examples
71///
72/// ```
73/// use armature_core::conditional::ETag;
74///
75/// // Create a strong ETag
76/// let strong = ETag::strong("abc123");
77/// assert_eq!(strong.to_header_value(), "\"abc123\"");
78///
79/// // Create a weak ETag
80/// let weak = ETag::weak("abc123");
81/// assert_eq!(weak.to_header_value(), "W/\"abc123\"");
82/// ```
83#[derive(Debug, Clone, PartialEq, Eq)]
84pub struct ETag {
85    /// The tag value (without quotes)
86    pub value: String,
87    /// Whether this is a weak ETag
88    pub weak: bool,
89}
90
91impl ETag {
92    /// Create a strong ETag.
93    ///
94    /// Strong ETags indicate byte-for-byte identity. Use for content
95    /// that must be exactly identical.
96    pub fn strong(value: impl Into<String>) -> Self {
97        Self {
98            value: value.into(),
99            weak: false,
100        }
101    }
102
103    /// Create a weak ETag.
104    ///
105    /// Weak ETags indicate semantic equivalence. Use when minor
106    /// variations (like whitespace) are acceptable.
107    pub fn weak(value: impl Into<String>) -> Self {
108        Self {
109            value: value.into(),
110            weak: true,
111        }
112    }
113
114    /// Parse an ETag from a header value.
115    ///
116    /// # Examples
117    ///
118    /// ```
119    /// use armature_core::conditional::ETag;
120    ///
121    /// let strong = ETag::parse("\"abc123\"").unwrap();
122    /// assert!(!strong.weak);
123    /// assert_eq!(strong.value, "abc123");
124    ///
125    /// let weak = ETag::parse("W/\"abc123\"").unwrap();
126    /// assert!(weak.weak);
127    /// assert_eq!(weak.value, "abc123");
128    /// ```
129    pub fn parse(s: &str) -> Option<Self> {
130        let s = s.trim();
131
132        let (weak, value_part) = if s.starts_with("W/") || s.starts_with("w/") {
133            (true, &s[2..])
134        } else {
135            (false, s)
136        };
137
138        // Extract value from quotes
139        let value = value_part.strip_prefix('"')?.strip_suffix('"')?.to_string();
140
141        Some(Self { value, weak })
142    }
143
144    /// Generate an ETag from bytes using a hash.
145    ///
146    /// # Examples
147    ///
148    /// ```
149    /// use armature_core::conditional::ETag;
150    ///
151    /// let data = b"Hello, World!";
152    /// let etag = ETag::from_bytes(data);
153    /// assert!(!etag.weak);
154    /// ```
155    pub fn from_bytes(data: &[u8]) -> Self {
156        use std::collections::hash_map::DefaultHasher;
157
158        let mut hasher = DefaultHasher::new();
159        data.hash(&mut hasher);
160        let hash = hasher.finish();
161
162        Self::strong(format!("{:x}", hash))
163    }
164
165    /// Generate a weak ETag from bytes.
166    pub fn weak_from_bytes(data: &[u8]) -> Self {
167        let mut etag = Self::from_bytes(data);
168        etag.weak = true;
169        etag
170    }
171
172    /// Generate an ETag from a string using a hash.
173    #[allow(clippy::should_implement_trait)]
174    pub fn from_str(s: &str) -> Self {
175        Self::from_bytes(s.as_bytes())
176    }
177
178    /// Generate an ETag from file metadata.
179    ///
180    /// Creates an ETag based on file size and modification time.
181    pub fn from_file_metadata(size: u64, modified: SystemTime) -> Self {
182        let modified_unix = modified
183            .duration_since(SystemTime::UNIX_EPOCH)
184            .map(|d| d.as_secs())
185            .unwrap_or(0);
186
187        Self::strong(format!("{:x}-{:x}", size, modified_unix))
188    }
189
190    /// Generate an ETag from a version number or revision.
191    pub fn from_version(version: u64) -> Self {
192        Self::strong(format!("v{}", version))
193    }
194
195    /// Get the header value representation.
196    pub fn to_header_value(&self) -> String {
197        if self.weak {
198            format!("W/\"{}\"", self.value)
199        } else {
200            format!("\"{}\"", self.value)
201        }
202    }
203
204    /// Check if this ETag matches another using strong comparison.
205    ///
206    /// Strong comparison: Both ETags must be strong and have identical values.
207    pub fn strong_match(&self, other: &ETag) -> bool {
208        !self.weak && !other.weak && self.value == other.value
209    }
210
211    /// Check if this ETag matches another using weak comparison.
212    ///
213    /// Weak comparison: Values must match (weak flag is ignored).
214    pub fn weak_match(&self, other: &ETag) -> bool {
215        self.value == other.value
216    }
217}
218
219impl fmt::Display for ETag {
220    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
221        write!(f, "{}", self.to_header_value())
222    }
223}
224
225// ============================================================================
226// ETag List (for If-None-Match, If-Match)
227// ============================================================================
228
229/// Represents a list of ETags from If-None-Match or If-Match headers.
230#[derive(Debug, Clone, Default)]
231pub struct ETagList {
232    /// The list of ETags
233    pub etags: Vec<ETag>,
234    /// Whether the header contains a wildcard "*"
235    pub any: bool,
236}
237
238impl ETagList {
239    /// Create an empty ETag list.
240    pub fn new() -> Self {
241        Self::default()
242    }
243
244    /// Create an ETag list that matches any ETag.
245    pub fn any() -> Self {
246        Self {
247            etags: Vec::new(),
248            any: true,
249        }
250    }
251
252    /// Parse an ETag list from a header value.
253    ///
254    /// # Examples
255    ///
256    /// ```
257    /// use armature_core::conditional::ETagList;
258    ///
259    /// let list = ETagList::parse("\"abc\", \"def\", W/\"ghi\"");
260    /// assert_eq!(list.etags.len(), 3);
261    ///
262    /// let any = ETagList::parse("*");
263    /// assert!(any.any);
264    /// ```
265    pub fn parse(header: &str) -> Self {
266        let header = header.trim();
267
268        // Check for wildcard
269        if header == "*" {
270            return Self::any();
271        }
272
273        let etags: Vec<ETag> = header
274            .split(',')
275            .filter_map(|s| ETag::parse(s.trim()))
276            .collect();
277
278        Self { etags, any: false }
279    }
280
281    /// Check if any ETag in the list matches (weak comparison).
282    pub fn contains_weak(&self, etag: &ETag) -> bool {
283        if self.any {
284            return true;
285        }
286        self.etags.iter().any(|e| e.weak_match(etag))
287    }
288
289    /// Check if any ETag in the list matches (strong comparison).
290    pub fn contains_strong(&self, etag: &ETag) -> bool {
291        if self.any {
292            // RFC 7232 §3.1: "*" matches whenever the resource has any
293            // current representation, regardless of ETag weakness.
294            return true;
295        }
296        self.etags.iter().any(|e| e.strong_match(etag))
297    }
298
299    /// Check if the list is empty (no ETags and not a wildcard).
300    pub fn is_empty(&self) -> bool {
301        !self.any && self.etags.is_empty()
302    }
303}
304
305// ============================================================================
306// Conditional Request Headers
307// ============================================================================
308
309/// Parsed conditional request headers.
310#[derive(Debug, Clone, Default)]
311pub struct ConditionalHeaders {
312    /// If-None-Match header (for conditional GET)
313    pub if_none_match: Option<ETagList>,
314    /// If-Match header (for conditional PUT/DELETE)
315    pub if_match: Option<ETagList>,
316    /// If-Modified-Since header
317    pub if_modified_since: Option<SystemTime>,
318    /// If-Unmodified-Since header
319    pub if_unmodified_since: Option<SystemTime>,
320}
321
322impl ConditionalHeaders {
323    /// Parse conditional headers from an HTTP request.
324    ///
325    /// Lookups use the canonical casing only: `HeaderMap::get` compares names
326    /// with `eq_ignore_ascii_case`, so a client sending `if-none-match` is
327    /// matched by the same call that matches `If-None-Match`.
328    pub fn from_request(request: &HttpRequest) -> Self {
329        // One lookup each: header names intern case-insensitively, so the
330        // lowercased retry was always redundant.
331        let if_none_match = request.headers.get("If-None-Match").map(ETagList::parse);
332
333        let if_match = request.headers.get("If-Match").map(ETagList::parse);
334
335        let if_modified_since = request
336            .headers
337            .get("If-Modified-Since")
338            .and_then(|h| httpdate::parse_http_date(h).ok());
339
340        let if_unmodified_since = request
341            .headers
342            .get("If-Unmodified-Since")
343            .and_then(|h| httpdate::parse_http_date(h).ok());
344
345        Self {
346            if_none_match,
347            if_match,
348            if_modified_since,
349            if_unmodified_since,
350        }
351    }
352
353    /// Check if the resource should return 304 Not Modified.
354    ///
355    /// Returns true if:
356    /// - If-None-Match contains a matching ETag (weak comparison), or
357    /// - If-Modified-Since is after the resource's last modification
358    pub fn is_not_modified(&self, etag: Option<&ETag>, last_modified: Option<SystemTime>) -> bool {
359        // Check If-None-Match first (takes precedence)
360        if let Some(ref if_none_match) = self.if_none_match
361            && let Some(etag) = etag
362        {
363            return if_none_match.contains_weak(etag);
364        }
365
366        // Check If-Modified-Since
367        if let (Some(if_modified_since), Some(last_modified)) =
368            (self.if_modified_since, last_modified)
369        {
370            return last_modified <= if_modified_since;
371        }
372
373        false
374    }
375
376    /// Check if the precondition fails (should return 412).
377    ///
378    /// Returns true if:
379    /// - If-Match is present and no ETag matches (strong comparison), or
380    /// - If-Unmodified-Since is before the resource's last modification
381    pub fn precondition_failed(
382        &self,
383        etag: Option<&ETag>,
384        last_modified: Option<SystemTime>,
385    ) -> bool {
386        // Check If-Match first (takes precedence)
387        if let Some(ref if_match) = self.if_match {
388            if let Some(etag) = etag {
389                return !if_match.contains_strong(etag);
390            } else {
391                // If-Match present but no ETag to compare - fail
392                return !if_match.any;
393            }
394        }
395
396        // Check If-Unmodified-Since
397        if let (Some(if_unmodified_since), Some(last_modified)) =
398            (self.if_unmodified_since, last_modified)
399        {
400            return last_modified > if_unmodified_since;
401        }
402
403        false
404    }
405}
406
407// ============================================================================
408// Request Extensions
409// ============================================================================
410
411/// Extension trait for conditional request handling on HttpRequest.
412pub trait ConditionalRequest {
413    /// Get parsed conditional headers from the request.
414    fn conditional_headers(&self) -> ConditionalHeaders;
415
416    /// Get the If-None-Match header as an ETag list.
417    fn if_none_match(&self) -> Option<ETagList>;
418
419    /// Get the If-Match header as an ETag list.
420    fn if_match(&self) -> Option<ETagList>;
421
422    /// Get the If-Modified-Since header as a SystemTime.
423    fn if_modified_since(&self) -> Option<SystemTime>;
424
425    /// Get the If-Unmodified-Since header as a SystemTime.
426    fn if_unmodified_since(&self) -> Option<SystemTime>;
427
428    /// Check if If-None-Match contains a matching ETag (weak comparison).
429    ///
430    /// Returns true if the request should get a 304 Not Modified response.
431    fn if_none_match_matches(&self, etag: &ETag) -> bool;
432
433    /// Check if If-Match contains a matching ETag (strong comparison).
434    ///
435    /// Returns false if the precondition fails (should return 412).
436    fn if_match_matches(&self, etag: &ETag) -> bool;
437
438    /// Check if If-Modified-Since indicates the resource hasn't changed.
439    fn not_modified_since(&self, last_modified: SystemTime) -> bool;
440
441    /// Check if If-Unmodified-Since precondition fails.
442    fn modified_since_precondition(&self, last_modified: SystemTime) -> bool;
443
444    /// Evaluate all conditional headers and return the appropriate response.
445    ///
446    /// Returns:
447    /// - `Some(304)` if resource is not modified
448    /// - `Some(412)` if precondition failed
449    /// - `None` if request should proceed normally
450    fn evaluate_conditionals(
451        &self,
452        etag: Option<&ETag>,
453        last_modified: Option<SystemTime>,
454    ) -> Option<u16>;
455}
456
457impl ConditionalRequest for HttpRequest {
458    fn conditional_headers(&self) -> ConditionalHeaders {
459        ConditionalHeaders::from_request(self)
460    }
461
462    // One lookup per accessor: header names intern case-insensitively, so the
463    // lowercased retry was always redundant.
464    fn if_none_match(&self) -> Option<ETagList> {
465        self.headers.get("If-None-Match").map(ETagList::parse)
466    }
467
468    fn if_match(&self) -> Option<ETagList> {
469        self.headers.get("If-Match").map(ETagList::parse)
470    }
471
472    fn if_modified_since(&self) -> Option<SystemTime> {
473        self.headers
474            .get("If-Modified-Since")
475            .and_then(|h| httpdate::parse_http_date(h).ok())
476    }
477
478    fn if_unmodified_since(&self) -> Option<SystemTime> {
479        self.headers
480            .get("If-Unmodified-Since")
481            .and_then(|h| httpdate::parse_http_date(h).ok())
482    }
483
484    fn if_none_match_matches(&self, etag: &ETag) -> bool {
485        self.if_none_match()
486            .map(|list| list.contains_weak(etag))
487            .unwrap_or(false)
488    }
489
490    fn if_match_matches(&self, etag: &ETag) -> bool {
491        match self.if_match() {
492            Some(list) => list.contains_strong(etag),
493            None => true, // No If-Match header means proceed
494        }
495    }
496
497    fn not_modified_since(&self, last_modified: SystemTime) -> bool {
498        self.if_modified_since()
499            .map(|since| last_modified <= since)
500            .unwrap_or(false)
501    }
502
503    fn modified_since_precondition(&self, last_modified: SystemTime) -> bool {
504        self.if_unmodified_since()
505            .map(|since| last_modified > since)
506            .unwrap_or(false)
507    }
508
509    fn evaluate_conditionals(
510        &self,
511        etag: Option<&ETag>,
512        last_modified: Option<SystemTime>,
513    ) -> Option<u16> {
514        let headers = self.conditional_headers();
515
516        // Check preconditions first (412)
517        if headers.precondition_failed(etag, last_modified) {
518            return Some(412);
519        }
520
521        // Only GET and HEAD get conditional-GET treatment; the other safe
522        // methods (OPTIONS/TRACE/QUERY) have no 304 semantics.
523        let is_safe = matches!(self.method, crate::Method::Get | crate::Method::Head);
524
525        if is_safe {
526            // Check not modified (304) - only for safe methods
527            if headers.is_not_modified(etag, last_modified) {
528                return Some(304);
529            }
530        } else if let (Some(if_none_match), Some(etag)) = (&headers.if_none_match, etag) {
531            // RFC 7232 §3.2: a matching If-None-Match on an unsafe method
532            // (PUT/POST/DELETE/...) must fail with 412 Precondition Failed.
533            if if_none_match.contains_weak(etag) {
534                return Some(412);
535            }
536        }
537
538        None
539    }
540}
541
542// ============================================================================
543// Response Extensions
544// ============================================================================
545
546/// Extension trait for conditional response handling on HttpResponse.
547pub trait ConditionalResponse {
548    /// Set the ETag header on the response.
549    fn with_etag(self, etag: &ETag) -> Self;
550
551    /// Set the Last-Modified header on the response.
552    fn with_last_modified(self, time: SystemTime) -> Self;
553
554    /// Create a 304 Not Modified response.
555    fn not_modified() -> Self;
556
557    /// Create a 304 Not Modified response with an ETag.
558    fn not_modified_with_etag(etag: &ETag) -> Self;
559
560    /// Create a 412 Precondition Failed response.
561    fn precondition_failed() -> Self;
562
563    /// Create a 412 Precondition Failed response with a message.
564    fn precondition_failed_with_message(message: &str) -> Self;
565}
566
567impl ConditionalResponse for HttpResponse {
568    fn with_etag(mut self, etag: &ETag) -> Self {
569        self.headers
570            .insert("ETag".to_string(), etag.to_header_value());
571        self
572    }
573
574    fn with_last_modified(mut self, time: SystemTime) -> Self {
575        let formatted = httpdate::fmt_http_date(time);
576        self.headers.insert("Last-Modified".to_string(), formatted);
577        self
578    }
579
580    fn not_modified() -> Self {
581        Self::new(304)
582    }
583
584    fn not_modified_with_etag(etag: &ETag) -> Self {
585        let mut response = Self::new(304);
586        response
587            .headers
588            .insert("ETag".to_string(), etag.to_header_value());
589        response
590    }
591
592    fn precondition_failed() -> Self {
593        Self::new(412)
594    }
595
596    fn precondition_failed_with_message(message: &str) -> Self {
597        let body = serde_json::json!({
598            "error": "Precondition Failed",
599            "message": message,
600            "status": 412
601        });
602
603        let mut response = Self::new(412);
604        if let Ok(body_bytes) = serde_json::to_vec(&body) {
605            response.body = Bytes::from(body_bytes);
606            response
607                .headers
608                .insert("Content-Type".to_string(), "application/json".to_string());
609        }
610        response
611    }
612}
613
614// ============================================================================
615// Helper Functions
616// ============================================================================
617
618/// Check conditional headers and return appropriate response or proceed.
619///
620/// This is a convenience function that handles the common pattern of:
621/// 1. Check preconditions (return 412 if failed)
622/// 2. Check not-modified (return 304 if not modified)
623/// 3. Continue with normal processing
624///
625/// # Example
626///
627/// ```ignore
628/// use armature_core::conditional::{check_conditionals, ETag};
629///
630/// #[get("/resource/:id")]
631/// async fn get_resource(request: HttpRequest) -> Result<HttpResponse, Error> {
632///     let resource = load_resource();
633///     let etag = ETag::from_version(resource.version);
634///     let last_modified = resource.updated_at;
635///
636///     // Check conditionals - returns early if 304 or 412
637///     if let Some(response) = check_conditionals(&request, Some(&etag), Some(last_modified)) {
638///         return Ok(response);
639///     }
640///
641///     // Normal response
642///     HttpResponse::ok()
643///         .with_etag(&etag)
644///         .with_last_modified(last_modified)
645///         .with_json(&resource)
646/// }
647/// ```
648pub fn check_conditionals(
649    request: &HttpRequest,
650    etag: Option<&ETag>,
651    last_modified: Option<SystemTime>,
652) -> Option<HttpResponse> {
653    match request.evaluate_conditionals(etag, last_modified) {
654        Some(304) => {
655            let mut response = HttpResponse::not_modified();
656            if let Some(etag) = etag {
657                response = response.with_etag(etag);
658            }
659            if let Some(lm) = last_modified {
660                response = response.with_last_modified(lm);
661            }
662            Some(response)
663        }
664        Some(412) => Some(HttpResponse::precondition_failed_with_message(
665            "Resource has been modified",
666        )),
667        _ => None,
668    }
669}
670
671/// Generate a cache-friendly response with ETag and Last-Modified headers.
672///
673/// # Example
674///
675/// ```ignore
676/// use armature_core::conditional::{cacheable_response, ETag};
677///
678/// let data = get_data();
679/// let etag = ETag::from_bytes(&serde_json::to_vec(&data)?);
680/// let response = cacheable_response(data, &etag, Some(last_modified))?;
681/// ```
682pub fn cacheable_response<T: serde::Serialize>(
683    data: &T,
684    etag: &ETag,
685    last_modified: Option<SystemTime>,
686) -> Result<HttpResponse, Error> {
687    let mut response = HttpResponse::ok().with_json(data)?.with_etag(etag);
688
689    if let Some(lm) = last_modified {
690        response = response.with_last_modified(lm);
691    }
692
693    // Add cache headers
694    response.headers.insert(
695        "Cache-Control".to_string(),
696        "private, must-revalidate".to_string(),
697    );
698    response
699        .headers
700        .insert("Vary".to_string(), "Accept, Accept-Encoding".to_string());
701
702    Ok(response)
703}
704
705// ============================================================================
706// Tests
707// ============================================================================
708
709#[cfg(test)]
710mod tests {
711    use super::*;
712
713    #[test]
714    fn test_etag_strong() {
715        let etag = ETag::strong("abc123");
716        assert!(!etag.weak);
717        assert_eq!(etag.value, "abc123");
718        assert_eq!(etag.to_header_value(), "\"abc123\"");
719    }
720
721    #[test]
722    fn test_etag_weak() {
723        let etag = ETag::weak("abc123");
724        assert!(etag.weak);
725        assert_eq!(etag.value, "abc123");
726        assert_eq!(etag.to_header_value(), "W/\"abc123\"");
727    }
728
729    #[test]
730    fn test_etag_parse_strong() {
731        let etag = ETag::parse("\"abc123\"").unwrap();
732        assert!(!etag.weak);
733        assert_eq!(etag.value, "abc123");
734    }
735
736    #[test]
737    fn test_etag_parse_weak() {
738        let etag = ETag::parse("W/\"abc123\"").unwrap();
739        assert!(etag.weak);
740        assert_eq!(etag.value, "abc123");
741    }
742
743    #[test]
744    fn test_etag_parse_weak_lowercase() {
745        let etag = ETag::parse("w/\"abc123\"").unwrap();
746        assert!(etag.weak);
747        assert_eq!(etag.value, "abc123");
748    }
749
750    #[test]
751    fn test_etag_from_bytes() {
752        let data = b"Hello, World!";
753        let etag1 = ETag::from_bytes(data);
754        let etag2 = ETag::from_bytes(data);
755        assert_eq!(etag1.value, etag2.value);
756        assert!(!etag1.weak);
757    }
758
759    #[test]
760    fn test_etag_from_version() {
761        let etag = ETag::from_version(42);
762        assert_eq!(etag.value, "v42");
763        assert!(!etag.weak);
764    }
765
766    #[test]
767    fn test_etag_strong_match() {
768        let e1 = ETag::strong("abc");
769        let e2 = ETag::strong("abc");
770        let e3 = ETag::weak("abc");
771
772        assert!(e1.strong_match(&e2));
773        assert!(!e1.strong_match(&e3)); // Weak doesn't strong-match
774    }
775
776    #[test]
777    fn test_etag_weak_match() {
778        let e1 = ETag::strong("abc");
779        let e2 = ETag::weak("abc");
780
781        assert!(e1.weak_match(&e2)); // Values match
782    }
783
784    #[test]
785    fn test_etag_list_parse() {
786        let list = ETagList::parse("\"abc\", \"def\", W/\"ghi\"");
787        assert_eq!(list.etags.len(), 3);
788        assert!(!list.any);
789    }
790
791    #[test]
792    fn test_etag_list_parse_wildcard() {
793        let list = ETagList::parse("*");
794        assert!(list.any);
795        assert!(list.etags.is_empty());
796    }
797
798    #[test]
799    fn test_etag_list_contains_weak() {
800        let list = ETagList::parse("\"abc\", W/\"def\"");
801        let strong_abc = ETag::strong("abc");
802        let weak_abc = ETag::weak("abc");
803        let strong_xyz = ETag::strong("xyz");
804
805        assert!(list.contains_weak(&strong_abc));
806        assert!(list.contains_weak(&weak_abc)); // Weak comparison
807        assert!(!list.contains_weak(&strong_xyz));
808    }
809
810    #[test]
811    fn test_etag_list_contains_strong() {
812        let list = ETagList::parse("\"abc\", W/\"def\"");
813        let strong_abc = ETag::strong("abc");
814        let weak_abc = ETag::weak("abc");
815        let strong_def = ETag::strong("def");
816
817        assert!(list.contains_strong(&strong_abc));
818        assert!(!list.contains_strong(&weak_abc)); // Weak ETag doesn't strong-match
819        assert!(!list.contains_strong(&strong_def)); // W/"def" doesn't strong-match "def"
820    }
821
822    #[test]
823    fn test_etag_list_wildcard_contains() {
824        let list = ETagList::any();
825        let etag = ETag::strong("anything");
826
827        assert!(list.contains_weak(&etag));
828        assert!(list.contains_strong(&etag));
829    }
830
831    #[test]
832    fn test_etag_list_wildcard_matches_weak_etag() {
833        // RFC 7232 §3.1: "*" succeeds whenever the resource exists,
834        // even if its current ETag is weak.
835        let list = ETagList::any();
836        let weak = ETag::weak("abc123");
837
838        assert!(list.contains_strong(&weak));
839        assert!(list.contains_weak(&weak));
840    }
841
842    #[test]
843    fn test_if_match_wildcard_with_weak_etag_succeeds() {
844        let mut request = HttpRequest::new("PUT", "/resource".to_string());
845        request.headers.insert("If-Match", "*".to_string());
846
847        let weak = ETag::weak("abc123");
848        assert_eq!(request.evaluate_conditionals(Some(&weak), None), None);
849    }
850
851    #[test]
852    fn test_if_none_match_unsafe_method_412() {
853        // RFC 7232 §3.2: matching If-None-Match on an unsafe method → 412
854        for method in ["PUT", "POST", "DELETE", "PATCH"] {
855            let mut request = HttpRequest::new(method.to_string(), "/resource".to_string());
856            request
857                .headers
858                .insert("If-None-Match", "\"abc123\"".to_string());
859
860            let etag = ETag::strong("abc123");
861            assert_eq!(
862                request.evaluate_conditionals(Some(&etag), None),
863                Some(412),
864                "expected 412 for {}",
865                method
866            );
867        }
868    }
869
870    #[test]
871    fn test_if_none_match_unsafe_method_no_match_proceeds() {
872        let mut request = HttpRequest::new("PUT", "/resource".to_string());
873        request
874            .headers
875            .insert("If-None-Match", "\"abc123\"".to_string());
876
877        let etag = ETag::strong("different");
878        assert_eq!(request.evaluate_conditionals(Some(&etag), None), None);
879    }
880
881    #[test]
882    fn test_conditional_headers_if_none_match() {
883        let mut request = HttpRequest::new("GET", "/resource".to_string());
884        request
885            .headers
886            .insert("If-None-Match", "\"abc123\"".to_string());
887
888        let headers = ConditionalHeaders::from_request(&request);
889        assert!(headers.if_none_match.is_some());
890
891        let etag = ETag::strong("abc123");
892        assert!(headers.is_not_modified(Some(&etag), None));
893    }
894
895    /// Clients are free to send header names in any casing, and HTTP/2 and
896    /// HTTP/3 send them lowercased on the wire, so parsing must not depend on
897    /// the canonical spelling used in the lookups above.
898    #[test]
899    fn test_conditional_headers_lowercase_header_names() {
900        let mut request = HttpRequest::new("GET".to_string(), "/resource".to_string());
901        request
902            .headers
903            .insert("if-none-match", "\"abc123\"".to_string());
904        request.headers.insert("if-match", "\"abc123\"".to_string());
905        request.headers.insert(
906            "if-modified-since",
907            "Sun, 06 Nov 1994 08:49:37 GMT".to_string(),
908        );
909        request.headers.insert(
910            "if-unmodified-since",
911            "Sun, 06 Nov 1994 08:49:37 GMT".to_string(),
912        );
913
914        let headers = ConditionalHeaders::from_request(&request);
915        assert!(headers.if_none_match.is_some());
916        assert!(headers.if_match.is_some());
917        assert!(headers.if_modified_since.is_some());
918        assert!(headers.if_unmodified_since.is_some());
919
920        let etag = ETag::strong("abc123");
921        assert!(headers.is_not_modified(Some(&etag), None));
922
923        // The ConditionalRequest accessors read the same headers directly.
924        assert!(request.if_none_match().is_some());
925        assert!(request.if_match().is_some());
926        assert!(request.if_modified_since().is_some());
927        assert!(request.if_unmodified_since().is_some());
928    }
929
930    #[test]
931    fn test_conditional_headers_if_match() {
932        let mut request = HttpRequest::new("PUT", "/resource".to_string());
933        request.headers.insert("If-Match", "\"abc123\"".to_string());
934
935        let headers = ConditionalHeaders::from_request(&request);
936        assert!(headers.if_match.is_some());
937
938        let matching = ETag::strong("abc123");
939        let non_matching = ETag::strong("xyz789");
940
941        assert!(!headers.precondition_failed(Some(&matching), None));
942        assert!(headers.precondition_failed(Some(&non_matching), None));
943    }
944
945    #[test]
946    fn test_request_if_none_match_matches() {
947        let mut request = HttpRequest::new("GET", "/resource".to_string());
948        request
949            .headers
950            .insert("If-None-Match", "\"abc123\"".to_string());
951
952        let matching = ETag::strong("abc123");
953        let non_matching = ETag::strong("xyz789");
954
955        assert!(request.if_none_match_matches(&matching));
956        assert!(!request.if_none_match_matches(&non_matching));
957    }
958
959    #[test]
960    fn test_request_if_match_matches() {
961        let mut request = HttpRequest::new("PUT", "/resource".to_string());
962        request.headers.insert("If-Match", "\"abc123\"".to_string());
963
964        let matching = ETag::strong("abc123");
965        let non_matching = ETag::strong("xyz789");
966
967        assert!(request.if_match_matches(&matching));
968        assert!(!request.if_match_matches(&non_matching));
969    }
970
971    #[test]
972    fn test_request_evaluate_conditionals_304() {
973        let mut request = HttpRequest::new("GET", "/resource".to_string());
974        request
975            .headers
976            .insert("If-None-Match", "\"abc123\"".to_string());
977
978        let etag = ETag::strong("abc123");
979        assert_eq!(request.evaluate_conditionals(Some(&etag), None), Some(304));
980    }
981
982    #[test]
983    fn test_request_evaluate_conditionals_412() {
984        let mut request = HttpRequest::new("PUT", "/resource".to_string());
985        request.headers.insert("If-Match", "\"abc123\"".to_string());
986
987        let etag = ETag::strong("xyz789");
988        assert_eq!(request.evaluate_conditionals(Some(&etag), None), Some(412));
989    }
990
991    #[test]
992    fn test_request_evaluate_conditionals_proceed() {
993        let request = HttpRequest::new("GET", "/resource".to_string());
994        let etag = ETag::strong("abc123");
995
996        // No conditional headers - should proceed
997        assert_eq!(request.evaluate_conditionals(Some(&etag), None), None);
998    }
999
1000    #[test]
1001    fn test_response_with_etag() {
1002        let etag = ETag::strong("abc123");
1003        let response = HttpResponse::ok().with_etag(&etag);
1004
1005        assert_eq!(
1006            response.headers.get("ETag"),
1007            Some(&"\"abc123\"".to_string())
1008        );
1009    }
1010
1011    #[test]
1012    fn test_response_not_modified() {
1013        let response = HttpResponse::not_modified();
1014        assert_eq!(response.status, 304);
1015    }
1016
1017    #[test]
1018    fn test_response_precondition_failed() {
1019        let response = HttpResponse::precondition_failed();
1020        assert_eq!(response.status, 412);
1021    }
1022
1023    #[test]
1024    fn test_check_conditionals_returns_304() {
1025        let mut request = HttpRequest::new("GET", "/resource".to_string());
1026        request
1027            .headers
1028            .insert("If-None-Match", "\"abc123\"".to_string());
1029
1030        let etag = ETag::strong("abc123");
1031        let response = check_conditionals(&request, Some(&etag), None);
1032
1033        assert!(response.is_some());
1034        assert_eq!(response.unwrap().status, 304);
1035    }
1036
1037    #[test]
1038    fn test_check_conditionals_returns_412() {
1039        let mut request = HttpRequest::new("PUT", "/resource".to_string());
1040        request.headers.insert("If-Match", "\"abc123\"".to_string());
1041
1042        let etag = ETag::strong("different");
1043        let response = check_conditionals(&request, Some(&etag), None);
1044
1045        assert!(response.is_some());
1046        assert_eq!(response.unwrap().status, 412);
1047    }
1048
1049    #[test]
1050    fn test_check_conditionals_returns_none() {
1051        let request = HttpRequest::new("GET", "/resource".to_string());
1052        let etag = ETag::strong("abc123");
1053
1054        let response = check_conditionals(&request, Some(&etag), None);
1055        assert!(response.is_none());
1056    }
1057}