diff --git a/src/cache/mod.rs b/src/cache/mod.rs index e954c58c..e4876d8e 100644 --- a/src/cache/mod.rs +++ b/src/cache/mod.rs @@ -7,7 +7,6 @@ //! # Further Reading //! //! - [MDN: HTTP Caching](https://developer.mozilla.org/en-US/docs/Web/HTTP/Caching) -//! - [MDN: HTTP Conditional Requests](https://developer.mozilla.org/en-US/docs/Web/HTTP/Conditional_requests) mod cache_control; diff --git a/src/conditional/etag.rs b/src/conditional/etag.rs new file mode 100644 index 00000000..99574845 --- /dev/null +++ b/src/conditional/etag.rs @@ -0,0 +1,183 @@ +use crate::headers::{HeaderName, HeaderValue, Headers, ToHeaderValues, ETAG}; +use crate::{Error, StatusCode}; + +use std::fmt::Debug; +use std::option; + +/// HTTP Entity Tags. +/// +/// ETags provide an ID for a particular resource, enabling clients and servers +/// to reason about caches and make conditional requests. +/// +/// # Specifications +/// +/// - [RFC 7232 HTTP/1.1: Conditional Requests](https://tools.ietf.org/html/rfc7232#section-2.3) +/// +/// # Examples +/// +/// ``` +/// # fn main() -> http_types::Result<()> { +/// # +/// use http_types::Response; +/// use http_types::conditional::ETag; +/// +/// let etag = ETag::new("0xcafebeef".to_string()); +/// +/// let mut res = Response::new(200); +/// etag.apply(&mut res); +/// +/// let etag = ETag::from_headers(res)?.unwrap(); +/// assert_eq!(etag, ETag::Strong(String::from("0xcafebeef"))); +/// # +/// # Ok(()) } +/// ``` +#[derive(Debug, Clone, Eq, PartialEq)] +pub enum ETag { + /// An ETag using strong validation. + Strong(String), + /// An ETag using weak validation. + Weak(String), +} + +impl ETag { + /// Create a new ETag that uses strong validation. + pub fn new(s: String) -> Self { + debug_assert!(!s.contains('\\'), "ETags ought to avoid backslash chars"); + Self::Strong(s) + } + + /// Create a new ETag that uses weak validation. + pub fn new_weak(s: String) -> Self { + debug_assert!(!s.contains('\\'), "ETags ought to avoid backslash chars"); + Self::Weak(s) + } + + /// Create a new instance from headers. + /// + /// Only a single ETag per resource is assumed to exist. If multiple ETag + /// headers are found the last one is used. + pub fn from_headers(headers: impl AsRef) -> crate::Result> { + let headers = match headers.as_ref().get(ETAG) { + Some(headers) => headers, + None => return Ok(None), + }; + + // If a header is returned we can assume at least one exists. + let s = headers.iter().last().unwrap().as_str(); + + let mut weak = false; + let s = match s.strip_prefix("W/") { + Some(s) => { + weak = true; + s + } + None => s, + }; + + let s = match s.strip_prefix('"').and_then(|s| s.strip_suffix('"')) { + Some(s) => s.to_owned(), + None => { + return Err(Error::from_str( + StatusCode::BadRequest, + "Invalid ETag header", + )) + } + }; + + let etag = if weak { Self::Weak(s) } else { Self::Strong(s) }; + Ok(Some(etag)) + } + + /// Sets the `ETag` header. + pub fn apply(&self, mut headers: impl AsMut) { + headers.as_mut().insert(ETAG, self.value()); + } + + /// Get the `HeaderName`. + pub fn name(&self) -> HeaderName { + ETAG + } + + /// Get the `HeaderValue`. + pub fn value(&self) -> HeaderValue { + let s = match self { + Self::Strong(s) => format!(r#""{}""#, s), + Self::Weak(s) => format!(r#"W/"{}""#, s), + }; + // SAFETY: the internal string is validated to be ASCII. + unsafe { HeaderValue::from_bytes_unchecked(s.into()) } + } + + /// Returns `true` if the ETag is a `Strong` value. + pub fn is_strong(&self) -> bool { + matches!(self, Self::Strong(_)) + } + + /// Returns `true` if the ETag is a `Weak` value. + pub fn is_weak(&self) -> bool { + matches!(self, Self::Weak(_)) + } +} + +impl ToHeaderValues for ETag { + type Iter = option::IntoIter; + fn to_header_values(&self) -> crate::Result { + // A HeaderValue will always convert into itself. + Ok(self.value().to_header_values().unwrap()) + } +} + +#[cfg(test)] +mod test { + use super::*; + use crate::headers::Headers; + + #[test] + fn smoke() -> crate::Result<()> { + let etag = ETag::new("0xcafebeef".to_string()); + + let mut headers = Headers::new(); + etag.apply(&mut headers); + + let etag = ETag::from_headers(headers)?.unwrap(); + assert_eq!(etag, ETag::Strong(String::from("0xcafebeef"))); + Ok(()) + } + + #[test] + fn smoke_weak() -> crate::Result<()> { + let etag = ETag::new_weak("0xcafebeef".to_string()); + + let mut headers = Headers::new(); + etag.apply(&mut headers); + + let etag = ETag::from_headers(headers)?.unwrap(); + assert_eq!(etag, ETag::Weak(String::from("0xcafebeef"))); + Ok(()) + } + + #[test] + fn bad_request_on_parse_error() -> crate::Result<()> { + let mut headers = Headers::new(); + headers.insert(ETAG, ""); + let err = ETag::from_headers(headers).unwrap_err(); + assert_eq!(err.status(), 400); + Ok(()) + } + + #[test] + fn validate_quotes() -> crate::Result<()> { + assert_entry_err(r#""hello"#, "Invalid ETag header"); + assert_entry_err(r#"hello""#, "Invalid ETag header"); + assert_entry_err(r#"/O"valid content""#, "Invalid ETag header"); + assert_entry_err(r#"/Wvalid content""#, "Invalid ETag header"); + Ok(()) + } + + fn assert_entry_err(s: &str, msg: &str) { + let mut headers = Headers::new(); + headers.insert(ETAG, s); + let err = ETag::from_headers(headers).unwrap_err(); + assert_eq!(format!("{}", err), msg); + } +} diff --git a/src/conditional/mod.rs b/src/conditional/mod.rs new file mode 100644 index 00000000..ca3db5ab --- /dev/null +++ b/src/conditional/mod.rs @@ -0,0 +1,13 @@ +//! HTTP conditional headers. +//! +//! Web page performance can be significantly improved by caching resources. +//! This submodule includes headers and types to communicate how and when to +//! cache resources. +//! +//! # Further Reading +//! +//! - [MDN: HTTP Conditional Requests](https://developer.mozilla.org/en-US/docs/Web/HTTP/Conditional_requests) + +mod etag; + +pub use etag::ETag; diff --git a/src/lib.rs b/src/lib.rs index d0554c59..36d8438f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -118,6 +118,7 @@ pub mod url { mod utils; pub mod cache; +pub mod conditional; pub mod headers; pub mod mime;