Merge pull request #270 from http-rs/accept

Add `content::{Accept, ContentType}` headers
This commit is contained in:
Yoshua Wuyts 2020-12-18 16:11:30 +01:00 committed by GitHub
commit 9bafdd6376
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 792 additions and 2 deletions

433
src/content/accept.rs Normal file
View File

@ -0,0 +1,433 @@
//! Client header advertising which media types the client is able to understand.
use crate::content::{ContentType, MediaTypeProposal};
use crate::headers::{HeaderName, HeaderValue, Headers, ToHeaderValues, ACCEPT};
use crate::utils::sort_by_weight;
use crate::{Error, Mime, StatusCode};
use std::fmt::{self, Debug, Write};
use std::option;
use std::slice;
/// Client header advertising which media types the client is able to understand.
///
/// Using content negotiation, the server then selects one of the proposals, uses
/// it and informs the client of its choice with the `Content-Type` response
/// header. Browsers set adequate values for this header depending on the context
/// where the request is done: when fetching a CSS stylesheet a different value
/// is set for the request than when fetching an image, video or a script.
///
/// [MDN Documentation](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept)
///
/// # Specifications
///
/// - [RFC 7231, section 5.3.2: Accept](https://tools.ietf.org/html/rfc7231#section-5.3.2)
///
/// # Examples
///
/// ```
/// # fn main() -> http_types::Result<()> {
/// #
/// use http_types::content::{Accept, MediaTypeProposal};
/// use http_types::{mime, Response};
///
/// let mut accept = Accept::new();
/// accept.push(MediaTypeProposal::new(mime::HTML, Some(0.8))?);
/// accept.push(MediaTypeProposal::new(mime::XML, Some(0.4))?);
/// accept.push(mime::PLAIN);
///
/// let mut res = Response::new(200);
/// let content_type = accept.negotiate(&[mime::XML])?;
/// content_type.apply(&mut res);
///
/// assert_eq!(res["Content-Type"], "application/xml;charset=utf-8");
/// #
/// # Ok(()) }
/// ```
pub struct Accept {
wildcard: bool,
entries: Vec<MediaTypeProposal>,
}
impl Accept {
/// Create a new instance of `Accept`.
pub fn new() -> Self {
Self {
entries: vec![],
wildcard: false,
}
}
/// Create an instance of `Accept` from a `Headers` instance.
pub fn from_headers(headers: impl AsRef<Headers>) -> crate::Result<Option<Self>> {
let mut entries = vec![];
let headers = match headers.as_ref().get(ACCEPT) {
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();
// Handle empty strings, and wildcard directives.
if part.is_empty() {
continue;
} else if part == "*" {
wildcard = true;
continue;
}
// Try and parse a directive from a str. If the directive is
// unkown we skip it.
let entry = MediaTypeProposal::from_str(part)?;
entries.push(entry);
}
}
Ok(Some(Self { entries, wildcard }))
}
/// Push a directive into the list of entries.
pub fn push(&mut self, prop: impl Into<MediaTypeProposal>) {
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
}
/// Sort the header directives by weight.
///
/// Headers with a higher `q=` value will be returned first. If two
/// directives have the same weight, the directive that was declared later
/// will be returned first.
pub fn sort(&mut self) {
sort_by_weight(&mut self.entries);
}
/// Determine the most suitable `Content-Type` encoding.
///
/// # Errors
///
/// If no suitable encoding is found, an error with the status of `406` will be returned.
pub fn negotiate(&mut self, available: &[Mime]) -> crate::Result<ContentType> {
// Start by ordering the encodings.
self.sort();
// Try and find the first encoding that matches.
for accept in &self.entries {
if available.contains(&accept) {
return Ok(accept.media_type.clone().into());
}
}
// If no encoding matches and wildcard is set, send whichever encoding we got.
if self.wildcard {
if let Some(accept) = available.iter().next() {
return Ok(accept.clone().into());
}
}
let mut err = Error::new_adhoc("No suitable ContentEncoding found");
err.set_status(StatusCode::NotAcceptable);
Err(err)
}
/// Sets the `Accept-Encoding` header.
pub fn apply(&self, mut headers: impl AsMut<Headers>) {
headers.as_mut().insert(ACCEPT, self.value());
}
/// Get the `HeaderName`.
pub fn name(&self) -> HeaderName {
ACCEPT
}
/// Get the `HeaderValue`.
pub fn value(&self) -> 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()) }
}
/// 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 IntoIterator for Accept {
type Item = MediaTypeProposal;
type IntoIter = IntoIter;
#[inline]
fn into_iter(self) -> Self::IntoIter {
IntoIter {
inner: self.entries.into_iter(),
}
}
}
impl<'a> IntoIterator for &'a Accept {
type Item = &'a MediaTypeProposal;
type IntoIter = Iter<'a>;
#[inline]
fn into_iter(self) -> Self::IntoIter {
self.iter()
}
}
impl<'a> IntoIterator for &'a mut Accept {
type Item = &'a mut MediaTypeProposal;
type IntoIter = IterMut<'a>;
#[inline]
fn into_iter(self) -> Self::IntoIter {
self.iter_mut()
}
}
/// A borrowing iterator over entries in `Accept`.
#[derive(Debug)]
pub struct IntoIter {
inner: std::vec::IntoIter<MediaTypeProposal>,
}
impl Iterator for IntoIter {
type Item = MediaTypeProposal;
fn next(&mut self) -> Option<Self::Item> {
self.inner.next()
}
#[inline]
fn size_hint(&self) -> (usize, Option<usize>) {
self.inner.size_hint()
}
}
/// A lending iterator over entries in `Accept`.
#[derive(Debug)]
pub struct Iter<'a> {
inner: slice::Iter<'a, MediaTypeProposal>,
}
impl<'a> Iterator for Iter<'a> {
type Item = &'a MediaTypeProposal;
fn next(&mut self) -> Option<Self::Item> {
self.inner.next()
}
#[inline]
fn size_hint(&self) -> (usize, Option<usize>) {
self.inner.size_hint()
}
}
/// A mutable iterator over entries in `Accept`.
#[derive(Debug)]
pub struct IterMut<'a> {
inner: slice::IterMut<'a, MediaTypeProposal>,
}
impl<'a> Iterator for IterMut<'a> {
type Item = &'a mut MediaTypeProposal;
fn next(&mut self) -> Option<Self::Item> {
self.inner.next()
}
#[inline]
fn size_hint(&self) -> (usize, Option<usize>) {
self.inner.size_hint()
}
}
impl ToHeaderValues for Accept {
type Iter = option::IntoIter<HeaderValue>;
fn to_header_values(&self) -> crate::Result<Self::Iter> {
// A HeaderValue will always convert into itself.
Ok(self.value().to_header_values().unwrap())
}
}
impl Debug for Accept {
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()
}
}
#[cfg(test)]
mod test {
use super::*;
use crate::mime;
use crate::Response;
#[test]
fn smoke() -> crate::Result<()> {
let mut accept = Accept::new();
accept.push(mime::HTML);
let mut headers = Response::new(200);
accept.apply(&mut headers);
let accept = Accept::from_headers(headers)?.unwrap();
assert_eq!(accept.iter().next().unwrap(), mime::HTML);
Ok(())
}
#[test]
fn wildcard() -> crate::Result<()> {
let mut accept = Accept::new();
accept.set_wildcard(true);
let mut headers = Response::new(200);
accept.apply(&mut headers);
let accept = Accept::from_headers(headers)?.unwrap();
assert!(accept.wildcard());
Ok(())
}
#[test]
fn wildcard_and_header() -> crate::Result<()> {
let mut accept = Accept::new();
accept.push(mime::HTML);
accept.set_wildcard(true);
let mut headers = Response::new(200);
accept.apply(&mut headers);
let accept = Accept::from_headers(headers)?.unwrap();
assert!(accept.wildcard());
assert_eq!(accept.iter().next().unwrap(), mime::HTML);
Ok(())
}
#[test]
fn iter() -> crate::Result<()> {
let mut accept = Accept::new();
accept.push(mime::HTML);
accept.push(mime::XML);
let mut headers = Response::new(200);
accept.apply(&mut headers);
let accept = Accept::from_headers(headers)?.unwrap();
let mut accept = accept.iter();
assert_eq!(accept.next().unwrap(), mime::HTML);
assert_eq!(accept.next().unwrap(), mime::XML);
Ok(())
}
#[test]
fn reorder_based_on_weight() -> crate::Result<()> {
let mut accept = Accept::new();
accept.push(MediaTypeProposal::new(mime::HTML, Some(0.4))?);
accept.push(MediaTypeProposal::new(mime::XML, None)?);
accept.push(MediaTypeProposal::new(mime::PLAIN, Some(0.8))?);
let mut headers = Response::new(200);
accept.apply(&mut headers);
let mut accept = Accept::from_headers(headers)?.unwrap();
accept.sort();
let mut accept = accept.iter();
assert_eq!(accept.next().unwrap(), mime::PLAIN);
assert_eq!(accept.next().unwrap(), mime::HTML);
assert_eq!(accept.next().unwrap(), mime::XML);
Ok(())
}
#[test]
fn reorder_based_on_weight_and_location() -> crate::Result<()> {
let mut accept = Accept::new();
accept.push(MediaTypeProposal::new(mime::HTML, None)?);
accept.push(MediaTypeProposal::new(mime::XML, None)?);
accept.push(MediaTypeProposal::new(mime::PLAIN, Some(0.8))?);
let mut res = Response::new(200);
accept.apply(&mut res);
let mut accept = Accept::from_headers(res)?.unwrap();
accept.sort();
let mut accept = accept.iter();
assert_eq!(accept.next().unwrap(), mime::PLAIN);
assert_eq!(accept.next().unwrap(), mime::XML);
assert_eq!(accept.next().unwrap(), mime::HTML);
Ok(())
}
#[test]
fn negotiate() -> crate::Result<()> {
let mut accept = Accept::new();
accept.push(MediaTypeProposal::new(mime::HTML, Some(0.4))?);
accept.push(MediaTypeProposal::new(mime::PLAIN, Some(0.8))?);
accept.push(MediaTypeProposal::new(mime::XML, None)?);
assert_eq!(accept.negotiate(&[mime::HTML, mime::XML])?, mime::HTML);
Ok(())
}
#[test]
fn negotiate_not_acceptable() -> crate::Result<()> {
let mut accept = Accept::new();
let err = accept.negotiate(&[mime::JSON]).unwrap_err();
assert_eq!(err.status(), 406);
let mut accept = Accept::new();
accept.push(MediaTypeProposal::new(mime::JSON, Some(0.8))?);
let err = accept.negotiate(&[mime::XML]).unwrap_err();
assert_eq!(err.status(), 406);
Ok(())
}
#[test]
fn negotiate_wildcard() -> crate::Result<()> {
let mut accept = Accept::new();
accept.push(MediaTypeProposal::new(mime::JSON, Some(0.8))?);
accept.set_wildcard(true);
assert_eq!(accept.negotiate(&[mime::XML])?, mime::XML);
Ok(())
}
}

140
src/content/content_type.rs Normal file
View File

@ -0,0 +1,140 @@
use std::{convert::TryInto, str::FromStr};
use crate::headers::{HeaderName, HeaderValue, Headers, CONTENT_TYPE};
use crate::Mime;
/// Indicate the media type of a resource's content.
///
/// [MDN Documentation](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Type)
///
/// # Specifications
///
/// - [RFC 7231, section 3.1.1.5: Content-Type](https://tools.ietf.org/html/rfc7231#section-3.1.1.5)
/// - [RFC 7233, section 4.1: Content-Type in multipart](https://tools.ietf.org/html/rfc7233#section-4.1)
///
/// # Examples
///
/// ```
/// # fn main() -> http_types::Result<()> {
/// #
/// use http_types::content::ContentType;
/// use http_types::{Response, Mime};
/// use std::str::FromStr;
///
/// let content_type = ContentType::new("text/*");
///
/// let mut res = Response::new(200);
/// content_type.apply(&mut res);
///
/// let content_type = ContentType::from_headers(res)?.unwrap();
/// assert_eq!(content_type.value(), format!("{}", Mime::from_str("text/*")?).as_str());
/// #
/// # Ok(()) }
/// ```
#[derive(Debug)]
pub struct ContentType {
media_type: Mime,
}
impl ContentType {
/// Create a new instance.
pub fn new<U>(media_type: U) -> Self
where
U: TryInto<Mime>,
U::Error: std::fmt::Debug,
{
Self {
media_type: media_type
.try_into()
.expect("could not convert into a valid Mime type"),
}
}
/// Create a new instance from headers.
///
/// `Content-Type` headers can provide both full and partial URLs. In
/// order to always return fully qualified URLs, a base URL must be passed to
/// reference the current environment. In HTTP/1.1 and above this value can
/// always be determined from the request.
pub fn from_headers(headers: impl AsRef<Headers>) -> crate::Result<Option<Self>> {
let headers = match headers.as_ref().get(CONTENT_TYPE) {
Some(headers) => headers,
None => return Ok(None),
};
// If we successfully parsed the header then there's always at least one
// entry. We want the last entry.
let ctation = headers.iter().last().unwrap();
let media_type = Mime::from_str(ctation.as_str()).map_err(|mut e| {
e.set_status(400);
e
})?;
Ok(Some(Self { media_type }))
}
/// Sets the header.
pub fn apply(&self, mut headers: impl AsMut<Headers>) {
headers.as_mut().insert(self.name(), self.value());
}
/// Get the `HeaderName`.
pub fn name(&self) -> HeaderName {
CONTENT_TYPE
}
/// Get the `HeaderValue`.
pub fn value(&self) -> HeaderValue {
let output = format!("{}", self.media_type);
// SAFETY: the internal string is validated to be ASCII.
unsafe { HeaderValue::from_bytes_unchecked(output.into()) }
}
}
impl PartialEq<Mime> for ContentType {
fn eq(&self, other: &Mime) -> bool {
&self.media_type == other
}
}
impl PartialEq<&Mime> for ContentType {
fn eq(&self, other: &&Mime) -> bool {
&&self.media_type == other
}
}
impl From<Mime> for ContentType {
fn from(media_type: Mime) -> Self {
Self { media_type }
}
}
#[cfg(test)]
mod test {
use super::*;
use crate::headers::Headers;
#[test]
fn smoke() -> crate::Result<()> {
let ct = ContentType::new(Mime::from_str("text/*")?);
let mut headers = Headers::new();
ct.apply(&mut headers);
let ct = ContentType::from_headers(headers)?.unwrap();
assert_eq!(
ct.value(),
format!("{}", Mime::from_str("text/*")?).as_str()
);
Ok(())
}
#[test]
fn bad_request_on_parse_error() -> crate::Result<()> {
let mut headers = Headers::new();
headers.insert(CONTENT_TYPE, "<nori ate the tag. yum.>");
let err = ContentType::from_headers(headers).unwrap_err();
assert_eq!(err.status(), 400);
Ok(())
}
}

View File

@ -0,0 +1,157 @@
use crate::ensure;
use crate::headers::HeaderValue;
use crate::Mime;
use std::ops::{Deref, DerefMut};
use std::{
cmp::{Ordering, PartialEq},
str::FromStr,
};
/// A proposed Media Type for the `Accept` header.
#[derive(Debug, Clone, PartialEq)]
pub struct MediaTypeProposal {
/// The proposed media_type.
pub(crate) media_type: Mime,
/// The weight of the proposal.
///
/// This is a number between 0.0 and 1.0, and is max 3 decimal points.
weight: Option<f32>,
}
impl MediaTypeProposal {
/// Create a new instance of `MediaTypeProposal`.
pub fn new(media_type: impl Into<Mime>, weight: Option<f32>) -> crate::Result<Self> {
if let Some(weight) = weight {
ensure!(
weight.is_sign_positive() && weight <= 1.0,
"MediaTypeProposal should have a weight between 0.0 and 1.0"
)
}
Ok(Self {
media_type: media_type.into(),
weight,
})
}
/// Get the proposed media_type.
pub fn media_type(&self) -> &Mime {
&self.media_type
}
/// Get the weight of the proposal.
pub fn weight(&self) -> Option<f32> {
self.weight
}
/// Parse a string into a media type proposal.
///
/// Because `;` and `q=0.0` are all valid values for in use in a media type,
/// we have to parse the full string to the media type first, and then see if
/// a `q` value has been set.
pub(crate) fn from_str(s: &str) -> crate::Result<Self> {
let mut media_type = Mime::from_str(s)?;
let weight = media_type
.remove_param("q")
.map(|param| param.as_str().parse())
.transpose()?;
Ok(Self::new(media_type, weight)?)
}
}
impl From<Mime> for MediaTypeProposal {
fn from(media_type: Mime) -> Self {
Self {
media_type,
weight: None,
}
}
}
impl From<MediaTypeProposal> for Mime {
fn from(accept: MediaTypeProposal) -> Self {
accept.media_type
}
}
impl PartialEq<Mime> for MediaTypeProposal {
fn eq(&self, other: &Mime) -> bool {
self.media_type == *other
}
}
impl PartialEq<Mime> for &MediaTypeProposal {
fn eq(&self, other: &Mime) -> bool {
self.media_type == *other
}
}
impl Deref for MediaTypeProposal {
type Target = Mime;
fn deref(&self) -> &Self::Target {
&self.media_type
}
}
impl DerefMut for MediaTypeProposal {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.media_type
}
}
// NOTE: For Accept-Encoding Firefox sends the values: `gzip, deflate, br`. This means
// when parsing media_types we should choose the last value in the list under
// equal weights. This impl doesn't know which value was passed later, so that
// behavior needs to be handled separately.
//
// NOTE: This comparison does not include a notion of `*` (any value is valid).
// that needs to be handled separately.
impl PartialOrd for MediaTypeProposal {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
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<MediaTypeProposal> for HeaderValue {
fn from(entry: MediaTypeProposal) -> HeaderValue {
let s = match entry.weight {
Some(weight) => format!("{};q={:.3}", entry.media_type, weight),
None => entry.media_type.to_string(),
};
unsafe { HeaderValue::from_bytes_unchecked(s.into_bytes()) }
}
}
#[cfg(test)]
mod test {
use super::*;
use crate::mime;
#[test]
fn smoke() -> crate::Result<()> {
let _ = MediaTypeProposal::new(mime::JSON, Some(0.0)).unwrap();
let _ = MediaTypeProposal::new(mime::XML, Some(0.5)).unwrap();
let _ = MediaTypeProposal::new(mime::HTML, Some(1.0)).unwrap();
Ok(())
}
#[test]
fn error_code_500() -> crate::Result<()> {
let err = MediaTypeProposal::new(mime::JSON, Some(1.1)).unwrap_err();
assert_eq!(err.status(), 500);
let err = MediaTypeProposal::new(mime::XML, Some(-0.1)).unwrap_err();
assert_eq!(err.status(), 500);
let err = MediaTypeProposal::new(mime::HTML, Some(-0.0)).unwrap_err();
assert_eq!(err.status(), 500);
Ok(())
}
}

View File

@ -4,20 +4,53 @@
//! about which content it prefers, and the server responds by sharing which
//! content it's chosen to share. This enables clients to receive resources with the
//! best available compression, in the preferred language, and more.
//!
//! # Further Reading
//!
//! - [MDN: Content Negotiation](https://developer.mozilla.org/en-US/docs/Web/HTTP/Content_negotiation)
//!
//! # Examples
//!
//! ```
//! # fn main() -> http_types::Result<()> {
//! #
//! use http_types::content::{Accept, MediaTypeProposal};
//! use http_types::{mime, Response};
//!
//! let mut accept = Accept::new();
//! accept.push(MediaTypeProposal::new(mime::HTML, Some(0.8))?);
//! accept.push(MediaTypeProposal::new(mime::XML, Some(0.4))?);
//! accept.push(mime::PLAIN);
//!
//! let mut res = Response::new(200);
//! let content_type = accept.negotiate(&[mime::XML])?;
//! content_type.apply(&mut res);
//!
//! assert_eq!(res["Content-Type"], "application/xml;charset=utf-8");
//! #
//! # Ok(()) }
//! ```
pub mod accept;
pub mod accept_encoding;
pub mod content_encoding;
mod content_length;
mod content_location;
mod content_type;
mod encoding;
mod encoding_proposal;
mod media_type_proposal;
#[doc(inline)]
pub use accept::Accept;
#[doc(inline)]
pub use accept_encoding::AcceptEncoding;
#[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 media_type_proposal::MediaTypeProposal;

View File

@ -109,6 +109,33 @@ impl Mime {
})
.flatten()
}
/// Remove a param from the set. Returns the `ParamValue` if it was contained within the set.
pub fn remove_param(&mut self, name: impl Into<ParamName>) -> Option<ParamValue> {
let name: ParamName = name.into();
let mut unset_params = false;
let ret = self
.params
.as_mut()
.map(|inner| match inner {
ParamKind::Vec(v) => match v.iter().position(|(k, _)| k == &name) {
Some(index) => Some(v.remove(index).1),
None => None,
},
ParamKind::Utf8 => match name {
ParamName(Cow::Borrowed("charset")) => {
unset_params = true;
Some(ParamValue(Cow::Borrowed("utf8")))
}
_ => None,
},
})
.flatten();
if unset_params {
self.params = None;
}
ret
}
}
impl PartialEq<Mime> for Mime {

View File

@ -32,8 +32,8 @@ pub(crate) fn parse_weight(s: &str) -> crate::Result<f32> {
/// Order proposals by weight. Try ordering by q value first. If equal or undefined,
/// order by index, favoring the latest provided value.
pub(crate) fn sort_by_weight<T: PartialOrd + Copy>(props: &mut Vec<T>) {
let mut arr: Vec<(usize, T)> = props.iter().copied().enumerate().collect();
pub(crate) fn sort_by_weight<T: PartialOrd + Clone>(props: &mut Vec<T>) {
let mut arr: Vec<(usize, T)> = props.iter().cloned().enumerate().collect();
arr.sort_unstable_by(|a, b| match b.1.partial_cmp(&a.1) {
None | Some(Ordering::Equal) => b.0.cmp(&a.0),
Some(ord) => ord,