diff --git a/src/content/accept_language.rs b/src/content/accept_language.rs new file mode 100644 index 00000000..e6937db9 --- /dev/null +++ b/src/content/accept_language.rs @@ -0,0 +1,240 @@ +//! Client header advertising which languages the client is able to understand. + +use crate::content::LanguageProposal; +use crate::headers::{Header, HeaderValue, Headers, ACCEPT_LANGUAGE}; + +use std::fmt::{self, Debug, Write}; +use std::slice; + +/// Client header advertising which languages the client is able to understand. +pub struct AcceptLanguage { + wildcard: bool, + entries: Vec, +} + +impl AcceptLanguage { + /// Create a new instance of `AcceptLanguage`. + pub fn new() -> Self { + Self { + entries: vec![], + wildcard: false, + } + } + + /// Create an instance of `AcceptLanguage` from a `Headers` instance. + pub fn from_headers(headers: impl AsRef) -> crate::Result> { + let mut entries = vec![]; + let headers = match headers.as_ref().get(ACCEPT_LANGUAGE) { + Some(headers) => headers, + None => return Ok(None), + }; + + let mut wildcard = false; + + for value in headers { + for part in value.as_str().trim().split(',') { + let part = part.trim(); + + if part.is_empty() { + continue; + } else if part == "*" { + wildcard = true; + continue; + } + + let entry = LanguageProposal::from_str(part)?; + entries.push(entry); + } + } + + Ok(Some(Self { wildcard, entries })) + } + + /// Push a directive into the list of entries. + pub fn push(&mut self, prop: impl Into) { + self.entries.push(prop.into()) + } + + /// Returns `true` if a wildcard directive was passed. + pub fn wildcard(&self) -> bool { + self.wildcard + } + + /// Set the wildcard directive. + pub fn set_wildcard(&mut self, wildcard: bool) { + self.wildcard = wildcard + } + + /// An iterator visiting all entries. + pub fn iter(&self) -> Iter<'_> { + Iter { + inner: self.entries.iter(), + } + } + + /// An iterator visiting all entries. + pub fn iter_mut(&mut self) -> IterMut<'_> { + IterMut { + inner: self.entries.iter_mut(), + } + } +} + +impl Header for AcceptLanguage { + fn header_name(&self) -> crate::headers::HeaderName { + ACCEPT_LANGUAGE + } + + fn header_value(&self) -> crate::headers::HeaderValue { + let mut output = String::new(); + for (n, directive) in self.entries.iter().enumerate() { + let directive: HeaderValue = directive.clone().into(); + match n { + 0 => write!(output, "{}", directive).unwrap(), + _ => write!(output, ", {}", directive).unwrap(), + }; + } + + if self.wildcard { + match output.len() { + 0 => write!(output, "*").unwrap(), + _ => write!(output, ", *").unwrap(), + }; + } + + // SAFETY: the internal string is validated to be ASCII. + unsafe { HeaderValue::from_bytes_unchecked(output.into()) } + } +} + +/// A borrowing iterator over entries in `AcceptLanguage`. +#[derive(Debug)] +pub struct IntoIter { + inner: std::vec::IntoIter, +} + +impl Iterator for IntoIter { + type Item = LanguageProposal; + + fn next(&mut self) -> Option { + self.inner.next() + } + + #[inline] + fn size_hint(&self) -> (usize, Option) { + self.inner.size_hint() + } +} + +/// A lending iterator over entries in `AcceptLanguage`. +#[derive(Debug)] +pub struct Iter<'a> { + inner: slice::Iter<'a, LanguageProposal>, +} + +impl<'a> Iterator for Iter<'a> { + type Item = &'a LanguageProposal; + + fn next(&mut self) -> Option { + self.inner.next() + } + + #[inline] + fn size_hint(&self) -> (usize, Option) { + self.inner.size_hint() + } +} + +/// A mutable iterator over entries in `AcceptLanguage`. +#[derive(Debug)] +pub struct IterMut<'a> { + inner: slice::IterMut<'a, LanguageProposal>, +} + +impl<'a> Iterator for IterMut<'a> { + type Item = &'a mut LanguageProposal; + + fn next(&mut self) -> Option { + self.inner.next() + } + + #[inline] + fn size_hint(&self) -> (usize, Option) { + self.inner.size_hint() + } +} + +impl Debug for AcceptLanguage { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let mut list = f.debug_list(); + for directive in &self.entries { + list.entry(directive); + } + list.finish() + } +} + +impl IntoIterator for AcceptLanguage { + type Item = LanguageProposal; + type IntoIter = IntoIter; + + #[inline] + fn into_iter(self) -> Self::IntoIter { + IntoIter { + inner: self.entries.into_iter(), + } + } +} + +impl<'a> IntoIterator for &'a AcceptLanguage { + type Item = &'a LanguageProposal; + type IntoIter = Iter<'a>; + + #[inline] + fn into_iter(self) -> Self::IntoIter { + self.iter() + } +} + +impl<'a> IntoIterator for &'a mut AcceptLanguage { + type Item = &'a mut LanguageProposal; + type IntoIter = IterMut<'a>; + + #[inline] + fn into_iter(self) -> Self::IntoIter { + self.iter_mut() + } +} + +#[cfg(test)] +mod test { + use super::*; + use crate::Response; + + #[test] + fn smoke() -> crate::Result<()> { + let lang = LanguageProposal::new("en-CA", Some(1.0)).unwrap(); + let mut accept = AcceptLanguage::new(); + accept.push(lang.clone()); + + let mut headers = Response::new(200); + accept.apply_header(&mut headers); + + let accept = AcceptLanguage::from_headers(headers)?.unwrap(); + assert_eq!(accept.iter().next().unwrap(), &lang); + Ok(()) + } + + #[test] + fn wildcard() -> crate::Result<()> { + let mut accept = AcceptLanguage::new(); + accept.set_wildcard(true); + + let mut headers = Response::new(200); + accept.apply_header(&mut headers); + + let accept = AcceptLanguage::from_headers(headers)?.unwrap(); + assert!(accept.wildcard()); + Ok(()) + } +} diff --git a/src/content/language_range_proposal.rs b/src/content/language_range_proposal.rs new file mode 100644 index 00000000..1b4b85c7 --- /dev/null +++ b/src/content/language_range_proposal.rs @@ -0,0 +1,125 @@ +use crate::ensure; +use crate::headers::HeaderValue; +use crate::language::LanguageRange; +use crate::utils::parse_weight; + +use std::cmp::{Ordering, PartialEq}; +use std::ops::{Deref, DerefMut}; +use std::str::FromStr; + +/// A proposed `LanguageRange` in `AcceptLanguage`. +#[derive(Debug, Clone, PartialEq)] +pub struct LanguageProposal { + /// The proposed language. + pub(crate) language: LanguageRange, + + /// The weight of the proposal. + /// + /// This is a number between 0.0 and 1.0, and is max 3 decimal points. + weight: Option, +} + +impl LanguageProposal { + /// Create a new instance of `LanguageProposal`. + pub fn new(language: impl Into, weight: Option) -> crate::Result { + if let Some(weight) = weight { + ensure!( + weight.is_sign_positive() && weight <= 1.0, + "LanguageProposal should have a weight between 0.0 and 1.0" + ) + } + + Ok(Self { + language: language.into(), + weight, + }) + } + + /// Get the proposed language. + pub fn language_range(&self) -> &LanguageRange { + &self.language + } + + /// Get the weight of the proposal. + pub fn weight(&self) -> Option { + self.weight + } + + pub(crate) fn from_str(s: &str) -> crate::Result { + let mut parts = s.split(';'); + let language = LanguageRange::from_str(parts.next().unwrap())?; + let weight = parts.next().map(parse_weight).transpose()?; + Ok(Self::new(language, weight)?) + } +} + +impl From for LanguageProposal { + fn from(language: LanguageRange) -> Self { + Self { + language, + weight: None, + } + } +} + +impl PartialEq for LanguageProposal { + fn eq(&self, other: &LanguageRange) -> bool { + self.language == *other + } +} + +impl PartialEq for &LanguageProposal { + fn eq(&self, other: &LanguageRange) -> bool { + self.language == *other + } +} + +impl Deref for LanguageProposal { + type Target = LanguageRange; + fn deref(&self) -> &Self::Target { + &self.language + } +} + +impl DerefMut for LanguageProposal { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.language + } +} + +impl PartialOrd for LanguageProposal { + fn partial_cmp(&self, other: &Self) -> Option { + match (self.weight, other.weight) { + (Some(left), Some(right)) => left.partial_cmp(&right), + (Some(_), None) => Some(Ordering::Greater), + (None, Some(_)) => Some(Ordering::Less), + (None, None) => None, + } + } +} + +impl From for HeaderValue { + fn from(entry: LanguageProposal) -> HeaderValue { + let s = match entry.weight { + Some(weight) => format!("{};q={:.3}", entry.language, weight), + None => entry.language.to_string(), + }; + unsafe { HeaderValue::from_bytes_unchecked(s.into_bytes()) } + } +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn smoke() { + let _ = LanguageProposal::new("en", Some(1.0)).unwrap(); + } + + #[test] + fn error_code_500() { + let err = LanguageProposal::new("en", Some(1.1)).unwrap_err(); + assert_eq!(err.status(), 500); + } +} diff --git a/src/content/mod.rs b/src/content/mod.rs index aff9fb4b..fb5fd5b4 100644 --- a/src/content/mod.rs +++ b/src/content/mod.rs @@ -33,6 +33,7 @@ pub mod accept; pub mod accept_encoding; +pub mod accept_language; pub mod content_encoding; mod content_length; @@ -40,6 +41,7 @@ mod content_location; mod content_type; mod encoding; mod encoding_proposal; +mod language_range_proposal; mod media_type_proposal; #[doc(inline)] @@ -47,10 +49,13 @@ pub use accept::Accept; #[doc(inline)] pub use accept_encoding::AcceptEncoding; #[doc(inline)] +pub use accept_language::AcceptLanguage; +#[doc(inline)] pub use content_encoding::ContentEncoding; pub use content_length::ContentLength; pub use content_location::ContentLocation; pub use content_type::ContentType; pub use encoding::Encoding; pub use encoding_proposal::EncodingProposal; +pub use language_range_proposal::LanguageProposal; pub use media_type_proposal::MediaTypeProposal; diff --git a/src/language/mod.rs b/src/language/mod.rs new file mode 100644 index 00000000..45bcd045 --- /dev/null +++ b/src/language/mod.rs @@ -0,0 +1,172 @@ +//! RFC 4647 Language Ranges. +//! +//! [Read more](https://datatracker.ietf.org/doc/html/rfc4647) + +mod parse; + +use crate::headers::HeaderValue; +use std::{ + borrow::Cow, + fmt::{self, Display}, + slice, + str::FromStr, +}; + +/// An RFC 4647 language range. +#[derive(Debug, Clone, PartialEq)] +pub struct LanguageRange { + pub(crate) subtags: Vec>, +} + +impl LanguageRange { + /// An iterator visiting all entries. + pub fn iter(&self) -> Iter<'_> { + Iter { + inner: self.subtags.iter(), + } + } + + /// An iterator visiting all entries. + pub fn iter_mut(&mut self) -> IterMut<'_> { + IterMut { + inner: self.subtags.iter_mut(), + } + } +} + +impl Display for LanguageRange { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let mut tags = self.subtags.iter(); + if let Some(tag) = tags.next() { + write!(f, "{}", tag)?; + + for tag in tags { + write!(f, "-{}", tag)?; + } + } + Ok(()) + } +} + +/// A borrowing iterator over entries in `LanguageRange`. +#[derive(Debug)] +pub struct IntoIter { + inner: std::vec::IntoIter>, +} + +impl Iterator for IntoIter { + type Item = Cow<'static, str>; + + fn next(&mut self) -> Option { + self.inner.next() + } + + #[inline] + fn size_hint(&self) -> (usize, Option) { + self.inner.size_hint() + } +} + +/// A lending iterator over entries in `LanguageRange`. +#[derive(Debug)] +pub struct Iter<'a> { + inner: slice::Iter<'a, Cow<'static, str>>, +} + +impl<'a> Iterator for Iter<'a> { + type Item = &'a Cow<'static, str>; + + fn next(&mut self) -> Option { + self.inner.next() + } + + #[inline] + fn size_hint(&self) -> (usize, Option) { + self.inner.size_hint() + } +} + +/// A mutable iterator over entries in `LanguageRange`. +#[derive(Debug)] +pub struct IterMut<'a> { + inner: slice::IterMut<'a, Cow<'static, str>>, +} + +impl<'a> Iterator for IterMut<'a> { + type Item = &'a mut Cow<'static, str>; + + fn next(&mut self) -> Option { + self.inner.next() + } + + #[inline] + fn size_hint(&self) -> (usize, Option) { + self.inner.size_hint() + } +} + +impl IntoIterator for LanguageRange { + type Item = Cow<'static, str>; + type IntoIter = IntoIter; + + #[inline] + fn into_iter(self) -> Self::IntoIter { + IntoIter { + inner: self.subtags.into_iter(), + } + } +} + +impl<'a> IntoIterator for &'a LanguageRange { + type Item = &'a Cow<'static, str>; + type IntoIter = Iter<'a>; + + #[inline] + fn into_iter(self) -> Self::IntoIter { + self.iter() + } +} + +impl<'a> IntoIterator for &'a mut LanguageRange { + type Item = &'a mut Cow<'static, str>; + type IntoIter = IterMut<'a>; + + #[inline] + fn into_iter(self) -> Self::IntoIter { + self.iter_mut() + } +} + +impl From for HeaderValue { + fn from(language: LanguageRange) -> Self { + let s = language.to_string(); + unsafe { HeaderValue::from_bytes_unchecked(s.into_bytes()) } + } +} + +impl FromStr for LanguageRange { + type Err = crate::Error; + + fn from_str(s: &str) -> Result { + parse::parse(s) + } +} + +impl<'a> From<&'a str> for LanguageRange { + fn from(value: &'a str) -> Self { + Self::from_str(value).unwrap() + } +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn test_iter() -> crate::Result<()> { + let range: LanguageRange = "en-CA".parse().unwrap(); + let subtags: Vec<_> = range.iter().collect(); + assert_eq!(&subtags, &["en", "CA"]); + Ok(()) + } +} diff --git a/src/language/parse.rs b/src/language/parse.rs new file mode 100644 index 00000000..78c5c7e1 --- /dev/null +++ b/src/language/parse.rs @@ -0,0 +1,59 @@ +use std::borrow::Cow; + +use super::LanguageRange; + +fn split_tag(input: &str) -> Option<(&str, &str)> { + match input.find('-') { + Some(pos) if pos <= 8 => { + let (tag, rest) = input.split_at(pos); + Some((tag, &rest[1..])) + } + Some(_) => None, + None => (input.len() <= 8).then(|| (input, "")), + } +} + +// language-range = (1*8ALPHA *("-" 1*8alphanum)) / "*" +// alphanum = ALPHA / DIGIT +pub(crate) fn parse(input: &str) -> crate::Result { + let mut tags = Vec::new(); + + let (tag, mut input) = split_tag(input).ok_or_else(|| crate::format_err!("WIP error"))?; + crate::ensure!(!tag.is_empty(), "Language tag should not be empty"); + crate::ensure!( + tag.bytes() + .all(|b| (b'a'..=b'z').contains(&b) || (b'A'..=b'Z').contains(&b)), + "Language tag should be alpha" + ); + tags.push(Cow::from(tag.to_string())); + + while !input.is_empty() { + let (tag, rest) = split_tag(input).ok_or_else(|| crate::format_err!("WIP error"))?; + crate::ensure!(!tag.is_empty(), "Language tag should not be empty"); + crate::ensure!( + tag.bytes().all(|b| (b'a'..=b'z').contains(&b) + || (b'A'..=b'Z').contains(&b) + || (b'0'..=b'9').contains(&b)), + "Language tag should be alpha numeric" + ); + tags.push(Cow::from(tag.to_string())); + input = rest; + } + + Ok(LanguageRange { subtags: tags }) +} + +#[test] +fn test() { + let range = parse("en").unwrap(); + assert_eq!(&range.subtags, &["en"]); + + let range = parse("en-CA").unwrap(); + assert_eq!(&range.subtags, &["en", "CA"]); + + let range = parse("zh-Hant-CN-x-private1-private2").unwrap(); + assert_eq!( + &range.subtags, + &["zh", "Hant", "CN", "x", "private1", "private2"] + ); +} diff --git a/src/lib.rs b/src/lib.rs index c8821a41..1cf9825b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -123,6 +123,7 @@ pub mod cache; pub mod conditional; pub mod content; pub mod headers; +pub mod language; pub mod mime; pub mod other; pub mod proxies;