1#[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#[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 pub value: String,
48 pub weak: bool,
50}
51
52impl ETag {
53 pub fn strong(value: impl Into<String>) -> Self {
64 Self {
65 value: value.into(),
66 weak: false,
67 }
68 }
69
70 pub fn weak(value: impl Into<String>) -> Self {
81 Self {
82 value: value.into(),
83 weak: true,
84 }
85 }
86
87 #[must_use]
107 pub fn matches(&self, other: &Self) -> bool {
108 !self.weak && !other.weak && self.value == other.value
109 }
110
111 #[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#[cfg(feature = "http")]
143#[derive(Debug, Clone, PartialEq, Eq)]
144pub enum ParseETagError {
145 Empty,
147 Unquoted,
149 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 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 pub fn parse_list(s: &str) -> Result<Vec<Self>, ParseETagError> {
231 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#[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 Any,
279 Tags(Vec<ETag>),
281}
282
283impl IfMatch {
284 #[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#[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 Any,
326 Tags(Vec<ETag>),
328}
329
330impl IfNoneMatch {
331 #[must_use]
350 pub fn matches(&self, current: &ETag) -> bool {
351 match self {
352 Self::Any => false,
354 Self::Tags(tags) => !tags.iter().any(|t| t.matches_weak(current)),
356 }
357 }
358}
359
360#[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#[cfg(test)]
446mod tests {
447 use super::*;
448
449 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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}