Skip to main content

api_bones/
etag.rs

1//! `ETag` and conditional request types (RFC 7232).
2//!
3//! [`ETag`] represents an HTTP entity tag with strong or weak variants.
4//! [`IfMatch`] and [`IfNoneMatch`] model the corresponding conditional request
5//! headers, supporting single values, multiple values, and the wildcard `*`.
6//!
7//! # Example
8//!
9//! ```rust
10//! use api_bones::etag::{ETag, IfMatch};
11//!
12//! let tag = ETag::strong("abc123");
13//! assert_eq!(tag.to_string(), "\"abc123\"");
14//!
15//! let weak = ETag::weak("xyz");
16//! assert_eq!(weak.to_string(), "W/\"xyz\"");
17//!
18//! assert!(tag.matches(&ETag::strong("abc123")));
19//! assert!(!tag.matches(&weak));
20//! ```
21
22#[cfg(all(not(feature = "std"), feature = "alloc"))]
23use alloc::{string::String, vec::Vec};
24use core::fmt;
25#[cfg(feature = "http")]
26use core::str::FromStr;
27#[cfg(feature = "serde")]
28use serde::{Deserialize, Serialize};
29
30// ---------------------------------------------------------------------------
31// ETag
32// ---------------------------------------------------------------------------
33
34/// An HTTP entity tag as defined by [RFC 7232 §2.3](https://www.rfc-editor.org/rfc/rfc7232#section-2.3).
35///
36/// An `ETag` is either **strong** (default) or **weak** (prefixed with `W/`).
37/// Strong `ETags` require byte-for-byte equality; weak `ETags` indicate semantic
38/// equivalence only.
39#[derive(Debug, Clone, PartialEq, Eq, Hash)]
40#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
41#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
42#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
43#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
44#[cfg_attr(feature = "proptest", derive(proptest_derive::Arbitrary))]
45pub struct ETag {
46    /// The opaque tag value (without surrounding quotes).
47    pub value: String,
48    /// Whether this is a weak `ETag`.
49    pub weak: bool,
50}
51
52impl ETag {
53    /// Construct a strong `ETag`.
54    ///
55    /// The serialized form is `"<value>"` (with surrounding double-quotes).
56    ///
57    /// ```
58    /// use api_bones::etag::ETag;
59    ///
60    /// let tag = ETag::strong("v1");
61    /// assert_eq!(tag.to_string(), "\"v1\"");
62    /// ```
63    pub fn strong(value: impl Into<String>) -> Self {
64        Self {
65            value: value.into(),
66            weak: false,
67        }
68    }
69
70    /// Construct a weak `ETag`.
71    ///
72    /// The serialized form is `W/"<value>"`.
73    ///
74    /// ```
75    /// use api_bones::etag::ETag;
76    ///
77    /// let tag = ETag::weak("v1");
78    /// assert_eq!(tag.to_string(), "W/\"v1\"");
79    /// ```
80    pub fn weak(value: impl Into<String>) -> Self {
81        Self {
82            value: value.into(),
83            weak: true,
84        }
85    }
86
87    /// Compare two `ETags` according to RFC 7232 §2.3 comparison rules.
88    ///
89    /// **Strong comparison**: both must be strong *and* have the same value.
90    /// **Weak comparison**: the values must match regardless of weakness.
91    ///
92    /// This method uses **strong comparison**: returns `true` only when both
93    /// `ETags` are strong and their values are identical.
94    ///
95    /// ```
96    /// use api_bones::etag::ETag;
97    ///
98    /// let a = ETag::strong("abc");
99    /// let b = ETag::strong("abc");
100    /// assert!(a.matches(&b));
101    ///
102    /// // Weak tags never match under strong comparison.
103    /// let w = ETag::weak("abc");
104    /// assert!(!a.matches(&w));
105    /// ```
106    #[must_use]
107    pub fn matches(&self, other: &Self) -> bool {
108        !self.weak && !other.weak && self.value == other.value
109    }
110
111    /// Weak comparison per RFC 7232 §2.3: values match regardless of strength.
112    ///
113    /// ```
114    /// use api_bones::etag::ETag;
115    ///
116    /// let strong = ETag::strong("abc");
117    /// let weak = ETag::weak("abc");
118    /// assert!(strong.matches_weak(&weak));
119    /// assert!(weak.matches_weak(&strong));
120    /// ```
121    #[must_use]
122    pub fn matches_weak(&self, other: &Self) -> bool {
123        self.value == other.value
124    }
125}
126
127impl fmt::Display for ETag {
128    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
129        if self.weak {
130            write!(f, "W/\"{}\"", self.value)
131        } else {
132            write!(f, "\"{}\"", self.value)
133        }
134    }
135}
136
137// ---------------------------------------------------------------------------
138// Parsing (RFC 7232 §2.3 wire format) — `http` feature
139// ---------------------------------------------------------------------------
140
141/// Error returned when parsing an [`ETag`] from its HTTP wire format fails.
142#[cfg(feature = "http")]
143#[derive(Debug, Clone, PartialEq, Eq)]
144pub enum ParseETagError {
145    /// The input was empty after trimming.
146    Empty,
147    /// The tag was not enclosed in double quotes.
148    Unquoted,
149    /// The input was otherwise malformed (e.g. stray `W` prefix, missing closing quote).
150    Malformed,
151}
152
153#[cfg(feature = "http")]
154impl fmt::Display for ParseETagError {
155    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
156        match self {
157            Self::Empty => f.write_str("ETag is empty"),
158            Self::Unquoted => f.write_str("ETag must be enclosed in double quotes"),
159            Self::Malformed => f.write_str("ETag is malformed"),
160        }
161    }
162}
163
164#[cfg(all(feature = "http", feature = "std"))]
165impl std::error::Error for ParseETagError {}
166
167#[cfg(feature = "http")]
168impl FromStr for ETag {
169    type Err = ParseETagError;
170
171    /// Parse an `ETag` from its RFC 7232 §2.3 wire format.
172    ///
173    /// Accepts `"<value>"` (strong) and `W/"<value>"` (weak). Leading and
174    /// trailing ASCII whitespace is trimmed.
175    ///
176    /// ```
177    /// use api_bones::etag::ETag;
178    ///
179    /// let strong: ETag = "\"v1\"".parse().unwrap();
180    /// assert_eq!(strong, ETag::strong("v1"));
181    ///
182    /// let weak: ETag = "W/\"v1\"".parse().unwrap();
183    /// assert_eq!(weak, ETag::weak("v1"));
184    /// ```
185    fn from_str(s: &str) -> Result<Self, Self::Err> {
186        let s = s.trim();
187        if s.is_empty() {
188            return Err(ParseETagError::Empty);
189        }
190
191        let (weak, rest) = if let Some(rest) = s.strip_prefix("W/") {
192            (true, rest)
193        } else {
194            (false, s)
195        };
196
197        let rest = rest.trim_start();
198        if !rest.starts_with('"') {
199            return Err(ParseETagError::Unquoted);
200        }
201        if rest.len() < 2 || !rest.ends_with('"') {
202            return Err(ParseETagError::Malformed);
203        }
204        let value = &rest[1..rest.len() - 1];
205        if value.contains('"') {
206            return Err(ParseETagError::Malformed);
207        }
208        Ok(Self {
209            value: value.into(),
210            weak,
211        })
212    }
213}
214
215#[cfg(feature = "http")]
216impl ETag {
217    /// Parse a comma-separated list of `ETag`s from a header value
218    /// (e.g. the body of an `If-Match` or `If-None-Match` header).
219    ///
220    /// Returns an error on the first malformed entry.
221    ///
222    /// ```
223    /// use api_bones::etag::ETag;
224    ///
225    /// let tags = ETag::parse_list("\"a\", W/\"b\", \"c\"").unwrap();
226    /// assert_eq!(tags.len(), 3);
227    /// assert_eq!(tags[0], ETag::strong("a"));
228    /// assert_eq!(tags[1], ETag::weak("b"));
229    /// ```
230    pub fn parse_list(s: &str) -> Result<Vec<Self>, ParseETagError> {
231        // Split on commas that are outside quoted sections.
232        let mut out = Vec::new();
233        let mut start = 0usize;
234        let mut in_quotes = false;
235        let bytes = s.as_bytes();
236        for (i, &b) in bytes.iter().enumerate() {
237            match b {
238                b'"' => in_quotes = !in_quotes,
239                b',' if !in_quotes => {
240                    let piece = &s[start..i];
241                    if !piece.trim().is_empty() {
242                        out.push(piece.parse::<Self>()?);
243                    }
244                    start = i + 1;
245                }
246                _ => {}
247            }
248        }
249        let tail = &s[start..];
250        if !tail.trim().is_empty() {
251            out.push(tail.parse::<Self>()?);
252        }
253        if out.is_empty() {
254            return Err(ParseETagError::Empty);
255        }
256        Ok(out)
257    }
258}
259
260// ---------------------------------------------------------------------------
261// IfMatch
262// ---------------------------------------------------------------------------
263
264/// Models the `If-Match` conditional request header (RFC 7232 §3.1).
265///
266/// A request with `If-Match: *` matches any existing representation.
267/// A request with a list of `ETags` matches if the current `ETag` is in the list
268/// (using strong comparison).
269#[derive(Debug, Clone, PartialEq, Eq)]
270#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
271#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
272#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
273#[cfg_attr(feature = "serde", serde(tag = "type", content = "tags"))]
274#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
275#[cfg_attr(feature = "proptest", derive(proptest_derive::Arbitrary))]
276pub enum IfMatch {
277    /// Matches any existing representation (`If-Match: *`).
278    Any,
279    /// Matches if the current `ETag` is in this list (strong comparison).
280    Tags(Vec<ETag>),
281}
282
283impl IfMatch {
284    /// Returns `true` if the given `current` `ETag` satisfies this `If-Match`
285    /// condition using strong comparison per RFC 7232.
286    ///
287    /// ```
288    /// use api_bones::etag::{ETag, IfMatch};
289    ///
290    /// // Wildcard matches everything.
291    /// assert!(IfMatch::Any.matches(&ETag::strong("v1")));
292    ///
293    /// // Tag list uses strong comparison.
294    /// let cond = IfMatch::Tags(vec![ETag::strong("v1"), ETag::strong("v2")]);
295    /// assert!(cond.matches(&ETag::strong("v1")));
296    /// assert!(!cond.matches(&ETag::strong("v3")));
297    /// ```
298    #[must_use]
299    pub fn matches(&self, current: &ETag) -> bool {
300        match self {
301            Self::Any => true,
302            Self::Tags(tags) => tags.iter().any(|t| t.matches(current)),
303        }
304    }
305}
306
307// ---------------------------------------------------------------------------
308// IfNoneMatch
309// ---------------------------------------------------------------------------
310
311/// Models the `If-None-Match` conditional request header (RFC 7232 §3.2).
312///
313/// A request with `If-None-Match: *` fails if *any* representation exists.
314/// A request with a list of `ETags` fails (i.e., condition is false) if the
315/// current `ETag` matches any of the listed `ETags` (weak comparison).
316#[derive(Debug, Clone, PartialEq, Eq)]
317#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
318#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
319#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
320#[cfg_attr(feature = "serde", serde(tag = "type", content = "tags"))]
321#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
322#[cfg_attr(feature = "proptest", derive(proptest_derive::Arbitrary))]
323pub enum IfNoneMatch {
324    /// Condition fails if any representation exists (`If-None-Match: *`).
325    Any,
326    /// Condition fails if the current `ETag` weakly matches any of these.
327    Tags(Vec<ETag>),
328}
329
330impl IfNoneMatch {
331    /// Returns `true` when the condition is **satisfied** (i.e., the server
332    /// should proceed with the request).
333    ///
334    /// Per RFC 7232 §3.2, the condition is satisfied when the current `ETag`
335    /// does **not** weakly match any tag in the list (or no representation
336    /// exists for `Any`).
337    ///
338    /// ```
339    /// use api_bones::etag::{ETag, IfNoneMatch};
340    ///
341    /// // Wildcard is never satisfied (any representation exists).
342    /// assert!(!IfNoneMatch::Any.matches(&ETag::strong("v1")));
343    ///
344    /// // Satisfied when current tag is NOT in the list.
345    /// let cond = IfNoneMatch::Tags(vec![ETag::strong("v1")]);
346    /// assert!(cond.matches(&ETag::strong("v2")));
347    /// assert!(!cond.matches(&ETag::strong("v1")));
348    /// ```
349    #[must_use]
350    pub fn matches(&self, current: &ETag) -> bool {
351        match self {
352            // * means "fail if anything exists" — condition NOT satisfied
353            Self::Any => false,
354            // condition satisfied only when current is NOT in the list
355            Self::Tags(tags) => !tags.iter().any(|t| t.matches_weak(current)),
356        }
357    }
358}
359
360// ---------------------------------------------------------------------------
361// Axum feature: TypedHeader support
362// ---------------------------------------------------------------------------
363
364#[cfg(feature = "axum")]
365#[allow(clippy::result_large_err)]
366mod axum_support {
367    use super::{ETag, IfMatch, IfNoneMatch};
368    use crate::error::ApiError;
369    use axum::extract::FromRequestParts;
370    use axum::http::HeaderValue;
371    use axum::http::request::Parts;
372    use axum::response::{IntoResponseParts, ResponseParts};
373
374    impl IntoResponseParts for ETag {
375        type Error = std::convert::Infallible;
376
377        fn into_response_parts(self, mut res: ResponseParts) -> Result<ResponseParts, Self::Error> {
378            let val = HeaderValue::from_str(&self.to_string())
379                .expect("ETag display value is always valid ASCII");
380            res.headers_mut().insert(axum::http::header::ETAG, val);
381            Ok(res)
382        }
383    }
384
385    fn header_str<'a>(
386        parts: &'a Parts,
387        name: &axum::http::HeaderName,
388    ) -> Result<&'a str, ApiError> {
389        parts
390            .headers
391            .get(name)
392            .ok_or_else(|| ApiError::bad_request(format!("missing {name} header")))?
393            .to_str()
394            .map_err(|_| ApiError::bad_request(format!("{name} header is not valid ASCII")))
395    }
396
397    fn parse_condition(raw: &str) -> Result<(bool, Vec<ETag>), ApiError> {
398        let trimmed = raw.trim();
399        if trimmed == "*" {
400            return Ok((true, Vec::new()));
401        }
402        let tags = ETag::parse_list(trimmed).map_err(|e| ApiError::bad_request(format!("{e}")))?;
403        Ok((false, tags))
404    }
405
406    impl<S: Send + Sync> FromRequestParts<S> for IfMatch {
407        type Rejection = ApiError;
408
409        async fn from_request_parts(
410            parts: &mut Parts,
411            _state: &S,
412        ) -> Result<Self, Self::Rejection> {
413            let raw = header_str(parts, &axum::http::header::IF_MATCH)?;
414            let (is_any, tags) = parse_condition(raw)?;
415            if is_any {
416                Ok(Self::Any)
417            } else {
418                Ok(Self::Tags(tags))
419            }
420        }
421    }
422
423    impl<S: Send + Sync> FromRequestParts<S> for IfNoneMatch {
424        type Rejection = ApiError;
425
426        async fn from_request_parts(
427            parts: &mut Parts,
428            _state: &S,
429        ) -> Result<Self, Self::Rejection> {
430            let raw = header_str(parts, &axum::http::header::IF_NONE_MATCH)?;
431            let (is_any, tags) = parse_condition(raw)?;
432            if is_any {
433                Ok(Self::Any)
434            } else {
435                Ok(Self::Tags(tags))
436            }
437        }
438    }
439}
440
441// ---------------------------------------------------------------------------
442// Tests
443// ---------------------------------------------------------------------------
444
445#[cfg(test)]
446mod tests {
447    use super::*;
448
449    // -----------------------------------------------------------------------
450    // ETag construction
451    // -----------------------------------------------------------------------
452
453    #[test]
454    fn etag_strong_construction() {
455        let t = ETag::strong("abc");
456        assert_eq!(t.value, "abc");
457        assert!(!t.weak);
458    }
459
460    #[test]
461    fn etag_weak_construction() {
462        let t = ETag::weak("xyz");
463        assert_eq!(t.value, "xyz");
464        assert!(t.weak);
465    }
466
467    // -----------------------------------------------------------------------
468    // Display / formatting
469    // -----------------------------------------------------------------------
470
471    #[test]
472    fn etag_strong_display() {
473        assert_eq!(ETag::strong("v1").to_string(), "\"v1\"");
474    }
475
476    #[test]
477    fn etag_weak_display() {
478        assert_eq!(ETag::weak("v1").to_string(), "W/\"v1\"");
479    }
480
481    // -----------------------------------------------------------------------
482    // RFC 7232 comparison
483    // -----------------------------------------------------------------------
484
485    #[test]
486    fn etag_strong_matches_same_strong() {
487        let a = ETag::strong("abc");
488        let b = ETag::strong("abc");
489        assert!(a.matches(&b));
490    }
491
492    #[test]
493    fn etag_strong_does_not_match_different_value() {
494        let a = ETag::strong("abc");
495        let b = ETag::strong("def");
496        assert!(!a.matches(&b));
497    }
498
499    #[test]
500    fn etag_strong_does_not_match_weak() {
501        let a = ETag::strong("abc");
502        let b = ETag::weak("abc");
503        assert!(!a.matches(&b));
504    }
505
506    #[test]
507    fn etag_weak_does_not_match_strong() {
508        let a = ETag::weak("abc");
509        let b = ETag::strong("abc");
510        assert!(!a.matches(&b));
511    }
512
513    #[test]
514    fn etag_weak_does_not_match_weak_strong_comparison() {
515        let a = ETag::weak("abc");
516        let b = ETag::weak("abc");
517        assert!(!a.matches(&b));
518    }
519
520    #[test]
521    fn etag_weak_matches_same_value_weak_comparison() {
522        let a = ETag::weak("abc");
523        let b = ETag::strong("abc");
524        assert!(a.matches_weak(&b));
525    }
526
527    #[test]
528    fn etag_weak_comparison_both_weak() {
529        let a = ETag::weak("abc");
530        let b = ETag::weak("abc");
531        assert!(a.matches_weak(&b));
532    }
533
534    #[test]
535    fn etag_weak_comparison_different_values() {
536        let a = ETag::weak("abc");
537        let b = ETag::weak("def");
538        assert!(!a.matches_weak(&b));
539    }
540
541    // -----------------------------------------------------------------------
542    // IfMatch
543    // -----------------------------------------------------------------------
544
545    #[test]
546    fn if_match_any_always_matches() {
547        assert!(IfMatch::Any.matches(&ETag::strong("x")));
548        assert!(IfMatch::Any.matches(&ETag::weak("x")));
549    }
550
551    #[test]
552    fn if_match_tags_strong_match() {
553        let cond = IfMatch::Tags(vec![ETag::strong("abc"), ETag::strong("def")]);
554        assert!(cond.matches(&ETag::strong("abc")));
555        assert!(cond.matches(&ETag::strong("def")));
556    }
557
558    #[test]
559    fn if_match_tags_no_match() {
560        let cond = IfMatch::Tags(vec![ETag::strong("abc")]);
561        assert!(!cond.matches(&ETag::strong("xyz")));
562    }
563
564    #[test]
565    fn if_match_tags_weak_etag_does_not_match() {
566        let cond = IfMatch::Tags(vec![ETag::strong("abc")]);
567        assert!(!cond.matches(&ETag::weak("abc")));
568    }
569
570    // -----------------------------------------------------------------------
571    // IfNoneMatch
572    // -----------------------------------------------------------------------
573
574    #[test]
575    fn if_none_match_any_never_satisfied() {
576        assert!(!IfNoneMatch::Any.matches(&ETag::strong("x")));
577    }
578
579    #[test]
580    fn if_none_match_tags_satisfied_when_not_present() {
581        let cond = IfNoneMatch::Tags(vec![ETag::strong("abc")]);
582        assert!(cond.matches(&ETag::strong("xyz")));
583    }
584
585    #[test]
586    fn if_none_match_tags_not_satisfied_when_present_strong() {
587        let cond = IfNoneMatch::Tags(vec![ETag::strong("abc")]);
588        assert!(!cond.matches(&ETag::strong("abc")));
589    }
590
591    #[test]
592    fn if_none_match_tags_not_satisfied_weak_comparison() {
593        let cond = IfNoneMatch::Tags(vec![ETag::weak("abc")]);
594        assert!(!cond.matches(&ETag::strong("abc")));
595    }
596
597    #[test]
598    fn if_none_match_tags_not_satisfied_both_weak() {
599        let cond = IfNoneMatch::Tags(vec![ETag::weak("abc")]);
600        assert!(!cond.matches(&ETag::weak("abc")));
601    }
602
603    // -----------------------------------------------------------------------
604    // Serde round-trips
605    // -----------------------------------------------------------------------
606
607    #[cfg(feature = "serde")]
608    #[test]
609    fn etag_serde_round_trip_strong() {
610        let t = ETag::strong("abc123");
611        let json = serde_json::to_value(&t).unwrap();
612        let back: ETag = serde_json::from_value(json).unwrap();
613        assert_eq!(back, t);
614    }
615
616    #[cfg(feature = "serde")]
617    #[test]
618    fn etag_serde_round_trip_weak() {
619        let t = ETag::weak("xyz");
620        let json = serde_json::to_value(&t).unwrap();
621        let back: ETag = serde_json::from_value(json).unwrap();
622        assert_eq!(back, t);
623    }
624
625    #[cfg(feature = "serde")]
626    #[test]
627    fn if_match_any_serde_round_trip() {
628        let cond = IfMatch::Any;
629        let json = serde_json::to_value(&cond).unwrap();
630        let back: IfMatch = serde_json::from_value(json).unwrap();
631        assert_eq!(back, cond);
632    }
633
634    #[cfg(feature = "serde")]
635    #[test]
636    fn if_match_tags_serde_round_trip() {
637        let cond = IfMatch::Tags(vec![ETag::strong("abc"), ETag::weak("def")]);
638        let json = serde_json::to_value(&cond).unwrap();
639        let back: IfMatch = serde_json::from_value(json).unwrap();
640        assert_eq!(back, cond);
641    }
642
643    #[cfg(feature = "serde")]
644    #[test]
645    fn if_none_match_any_serde_round_trip() {
646        let cond = IfNoneMatch::Any;
647        let json = serde_json::to_value(&cond).unwrap();
648        let back: IfNoneMatch = serde_json::from_value(json).unwrap();
649        assert_eq!(back, cond);
650    }
651
652    #[cfg(feature = "serde")]
653    #[test]
654    fn if_none_match_tags_serde_round_trip() {
655        let cond = IfNoneMatch::Tags(vec![ETag::strong("v1")]);
656        let json = serde_json::to_value(&cond).unwrap();
657        let back: IfNoneMatch = serde_json::from_value(json).unwrap();
658        assert_eq!(back, cond);
659    }
660
661    // -----------------------------------------------------------------------
662    // Axum integration
663    // -----------------------------------------------------------------------
664
665    #[cfg(feature = "axum")]
666    #[test]
667    fn etag_into_response_parts_sets_etag_header() {
668        use axum::response::IntoResponse;
669
670        let response = (ETag::strong("abc123"), axum::http::StatusCode::OK).into_response();
671        let etag_header = response.headers().get(axum::http::header::ETAG);
672        assert_eq!(etag_header.unwrap().to_str().unwrap(), "\"abc123\"");
673    }
674
675    // -----------------------------------------------------------------------
676    // FromStr / parse_list (http feature)
677    // -----------------------------------------------------------------------
678
679    #[cfg(feature = "http")]
680    #[test]
681    fn etag_from_str_strong() {
682        let t: ETag = "\"v1\"".parse().unwrap();
683        assert_eq!(t, ETag::strong("v1"));
684    }
685
686    #[cfg(feature = "http")]
687    #[test]
688    fn etag_from_str_weak() {
689        let t: ETag = "W/\"v1\"".parse().unwrap();
690        assert_eq!(t, ETag::weak("v1"));
691    }
692
693    #[cfg(feature = "http")]
694    #[test]
695    fn etag_from_str_rejects_unquoted() {
696        assert_eq!("v1".parse::<ETag>(), Err(ParseETagError::Unquoted));
697    }
698
699    #[cfg(feature = "http")]
700    #[test]
701    fn etag_from_str_rejects_empty() {
702        assert_eq!("".parse::<ETag>(), Err(ParseETagError::Empty));
703        assert_eq!("   ".parse::<ETag>(), Err(ParseETagError::Empty));
704    }
705
706    #[cfg(feature = "http")]
707    #[test]
708    fn etag_from_str_rejects_missing_closing_quote() {
709        assert!("\"v1".parse::<ETag>().is_err());
710    }
711
712    #[cfg(feature = "http")]
713    #[test]
714    fn etag_from_str_rejects_embedded_quote() {
715        assert_eq!("\"a\"b\"".parse::<ETag>(), Err(ParseETagError::Malformed));
716    }
717
718    #[cfg(feature = "http")]
719    #[test]
720    fn etag_from_str_trims_whitespace() {
721        let t: ETag = "  \"v1\"  ".parse().unwrap();
722        assert_eq!(t, ETag::strong("v1"));
723    }
724
725    #[cfg(feature = "http")]
726    #[test]
727    fn etag_round_trip_strong() {
728        let t = ETag::strong("abc123");
729        assert_eq!(t.to_string().parse::<ETag>(), Ok(t));
730    }
731
732    #[cfg(feature = "http")]
733    #[test]
734    fn etag_round_trip_weak() {
735        let t = ETag::weak("xyz");
736        assert_eq!(t.to_string().parse::<ETag>(), Ok(t));
737    }
738
739    #[cfg(feature = "http")]
740    #[test]
741    fn etag_parse_list_multiple() {
742        let tags = ETag::parse_list("\"a\", W/\"b\", \"c\"").unwrap();
743        assert_eq!(tags.len(), 3);
744        assert_eq!(tags[0], ETag::strong("a"));
745        assert_eq!(tags[1], ETag::weak("b"));
746        assert_eq!(tags[2], ETag::strong("c"));
747    }
748
749    #[cfg(feature = "http")]
750    #[test]
751    fn etag_parse_list_single() {
752        let tags = ETag::parse_list("\"only\"").unwrap();
753        assert_eq!(tags, vec![ETag::strong("only")]);
754    }
755
756    #[cfg(feature = "http")]
757    #[test]
758    fn etag_parse_list_empty_errors() {
759        assert!(ETag::parse_list("").is_err());
760        assert!(ETag::parse_list("   ").is_err());
761    }
762
763    #[cfg(feature = "http")]
764    #[test]
765    fn etag_parse_list_propagates_bad_entry() {
766        assert!(ETag::parse_list("\"a\", bad, \"c\"").is_err());
767    }
768
769    #[cfg(feature = "axum")]
770    mod axum_extractor_tests {
771        use super::super::*;
772        use axum::extract::FromRequestParts;
773        use axum::http::Request;
774
775        async fn extract_if_match(header: Option<&str>) -> Result<IfMatch, ApiError> {
776            let mut builder = Request::builder();
777            if let Some(v) = header {
778                builder = builder.header("if-match", v);
779            }
780            let req = builder.body(()).unwrap();
781            let (mut parts, ()) = req.into_parts();
782            IfMatch::from_request_parts(&mut parts, &()).await
783        }
784
785        async fn extract_if_none_match(header: Option<&str>) -> Result<IfNoneMatch, ApiError> {
786            let mut builder = Request::builder();
787            if let Some(v) = header {
788                builder = builder.header("if-none-match", v);
789            }
790            let req = builder.body(()).unwrap();
791            let (mut parts, ()) = req.into_parts();
792            IfNoneMatch::from_request_parts(&mut parts, &()).await
793        }
794
795        use crate::error::ApiError;
796
797        #[tokio::test]
798        async fn if_match_wildcard() {
799            let r = extract_if_match(Some("*")).await.unwrap();
800            assert_eq!(r, IfMatch::Any);
801        }
802
803        #[tokio::test]
804        async fn if_match_tag_list() {
805            let r = extract_if_match(Some("\"a\", W/\"b\"")).await.unwrap();
806            assert_eq!(r, IfMatch::Tags(vec![ETag::strong("a"), ETag::weak("b")]));
807        }
808
809        #[tokio::test]
810        async fn if_match_missing_header_is_bad_request() {
811            let err = extract_if_match(None).await.unwrap_err();
812            assert_eq!(err.status, 400);
813        }
814
815        #[tokio::test]
816        async fn if_match_malformed_is_bad_request() {
817            let err = extract_if_match(Some("not-a-tag")).await.unwrap_err();
818            assert_eq!(err.status, 400);
819        }
820
821        #[tokio::test]
822        async fn if_none_match_wildcard() {
823            let r = extract_if_none_match(Some("*")).await.unwrap();
824            assert_eq!(r, IfNoneMatch::Any);
825        }
826
827        #[tokio::test]
828        async fn if_none_match_tag_list() {
829            let r = extract_if_none_match(Some("\"v1\"")).await.unwrap();
830            assert_eq!(r, IfNoneMatch::Tags(vec![ETag::strong("v1")]));
831        }
832
833        #[tokio::test]
834        async fn if_match_non_ascii_header_rejected() {
835            // Header value bytes outside ASCII → to_str() fails → bad_request.
836            let req = Request::builder()
837                .header("if-match", &[0xFFu8][..])
838                .body(())
839                .unwrap();
840            let (mut parts, ()) = req.into_parts();
841            let err = IfMatch::from_request_parts(&mut parts, &())
842                .await
843                .unwrap_err();
844            assert_eq!(err.status, 400);
845        }
846
847        #[tokio::test]
848        async fn if_none_match_missing_is_bad_request() {
849            let err = extract_if_none_match(None).await.unwrap_err();
850            assert_eq!(err.status, 400);
851        }
852    }
853
854    #[cfg(feature = "axum")]
855    #[test]
856    fn etag_weak_into_response_parts_sets_etag_header() {
857        use axum::response::IntoResponse;
858
859        let response = (ETag::weak("xyz"), axum::http::StatusCode::OK).into_response();
860        let etag_header = response.headers().get(axum::http::header::ETAG);
861        assert_eq!(etag_header.unwrap().to_str().unwrap(), "W/\"xyz\"");
862    }
863}