use std::borrow::Cow;
#[derive(Clone, Debug, Default, PartialEq, Eq)]
#[non_exhaustive]
pub struct RequestView<'a> {
pub method: Cow<'a, str>,
pub path: Cow<'a, str>,
pub query: Option<Cow<'a, str>>,
pub headers: Vec<(Cow<'a, str>, Cow<'a, str>)>,
pub body: Option<Cow<'a, [u8]>>,
}
impl<'a> RequestView<'a> {
#[must_use]
pub fn new(method: impl Into<Cow<'a, str>>, path: impl Into<Cow<'a, str>>) -> Self {
Self {
method: method.into(),
path: path.into(),
query: None,
headers: Vec::new(),
body: None,
}
}
#[must_use]
pub fn with_query(mut self, query: impl Into<Cow<'a, str>>) -> Self {
let query = query.into();
self.query = Some(match query {
Cow::Borrowed(query) => Cow::Borrowed(query.strip_prefix('?').unwrap_or(query)),
Cow::Owned(mut query) => {
if query.starts_with('?') {
query.remove(0);
}
Cow::Owned(query)
}
});
self
}
#[must_use]
pub fn with_header(
mut self,
name: impl Into<Cow<'a, str>>,
value: impl Into<Cow<'a, str>>,
) -> Self {
self.headers.push((name.into(), value.into()));
self
}
#[must_use]
pub fn with_headers<N, V>(mut self, headers: impl IntoIterator<Item = (N, V)>) -> Self
where
N: Into<Cow<'a, str>>,
V: Into<Cow<'a, str>>,
{
self.headers
.extend(headers.into_iter().map(|(n, v)| (n.into(), v.into())));
self
}
#[must_use]
pub fn with_body(mut self, body: impl Into<Cow<'a, [u8]>>) -> Self {
self.body = Some(body.into());
self
}
#[must_use]
pub fn header(&self, name: &str) -> Option<&str> {
self.headers
.iter()
.find(|(header, _)| header.eq_ignore_ascii_case(name))
.map(|(_, value)| value.as_ref())
}
pub fn header_values<'s>(&'s self, name: &'s str) -> impl Iterator<Item = &'s str> + 's {
self.headers
.iter()
.filter(move |(header, _)| header.eq_ignore_ascii_case(name))
.map(|(_, value)| value.as_ref())
}
#[must_use]
pub fn content_type(&self) -> Option<String> {
self.header("content-type").map(|value| {
value
.split(';')
.next()
.unwrap_or(value)
.trim()
.to_ascii_lowercase()
})
}
#[must_use]
pub fn query_pairs(&self) -> Vec<(String, String)> {
self.query_pairs_raw()
.into_iter()
.map(|(name, value)| (name, decode_form(&value)))
.collect()
}
pub(crate) fn query_pairs_raw(&self) -> Vec<(String, String)> {
self.query.as_deref().map(split_query).unwrap_or_default()
}
#[must_use]
pub fn cookies(&self) -> Vec<(String, String)> {
self.header_values("cookie")
.flat_map(|value| value.split(';'))
.filter_map(|pair| {
let pair = pair.trim();
if pair.is_empty() {
return None;
}
let (name, value) = pair.split_once('=')?;
Some((name.trim().to_owned(), value.trim().to_owned()))
})
.collect()
}
}
pub trait ToRequestView {
fn request_view(&self) -> RequestView<'_>;
}
pub(crate) fn split_query(query: &str) -> Vec<(String, String)> {
query
.split('&')
.filter(|pair| !pair.is_empty())
.map(|pair| match pair.split_once('=') {
Some((name, value)) => (decode_form(name), value.to_owned()),
None => (decode_form(pair), String::new()),
})
.collect()
}
pub(crate) fn decode_form(value: &str) -> String {
let plus_as_space = value.replace('+', " ");
percent_encoding::percent_decode_str(&plus_as_space)
.decode_utf8_lossy()
.into_owned()
}
pub(crate) fn decode_path_segment(segment: &str) -> String {
percent_encoding::percent_decode_str(segment)
.decode_utf8_lossy()
.into_owned()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_header_is_found_whatever_case_it_was_written_in() {
let request = RequestView::new("GET", "/").with_header("Content-Type", "application/json");
assert_eq!(request.header("content-type"), Some("application/json"));
assert_eq!(request.header("CONTENT-TYPE"), Some("application/json"));
assert_eq!(request.header("accept"), None);
}
#[test]
fn a_repeated_header_keeps_every_value() {
let request = RequestView::new("GET", "/")
.with_header("x-tag", "a")
.with_header("X-Tag", "b");
assert_eq!(request.header("x-tag"), Some("a"));
assert_eq!(
request.header_values("x-tag").collect::<Vec<_>>(),
["a", "b"]
);
}
#[test]
fn the_content_type_drops_its_parameters_and_case() {
let request = RequestView::new("POST", "/")
.with_header("content-type", "Application/JSON; charset=utf-8");
assert_eq!(request.content_type().as_deref(), Some("application/json"));
assert_eq!(RequestView::new("POST", "/").content_type(), None);
}
#[test]
fn a_query_string_keeps_order_and_repeats() {
let request = RequestView::new("GET", "/").with_query("tag=a&tag=b&limit=10");
assert_eq!(
request.query_pairs(),
[
("tag".to_owned(), "a".to_owned()),
("tag".to_owned(), "b".to_owned()),
("limit".to_owned(), "10".to_owned()),
]
);
}
#[test]
fn a_leading_question_mark_is_not_part_of_the_query() {
let borrowed = RequestView::new("GET", "/").with_query("?a=1");
let owned = RequestView::new("GET", "/").with_query("?a=1".to_owned());
assert_eq!(borrowed.query.as_deref(), Some("a=1"));
assert_eq!(owned.query.as_deref(), Some("a=1"));
}
#[test]
fn form_encoding_is_undone_in_the_query() {
let request = RequestView::new("GET", "/").with_query("q=a+b%20c&flag&empty=");
assert_eq!(
request.query_pairs(),
[
("q".to_owned(), "a b c".to_owned()),
("flag".to_owned(), String::new()),
("empty".to_owned(), String::new()),
]
);
}
#[test]
fn a_request_with_no_query_has_no_pairs() {
assert!(RequestView::new("GET", "/").query_pairs().is_empty());
}
#[test]
fn cookies_come_from_the_cookie_header() {
let request = RequestView::new("GET", "/")
.with_header("cookie", "session=abc; theme=dark")
.with_header("Cookie", "extra=1");
assert_eq!(
request.cookies(),
[
("session".to_owned(), "abc".to_owned()),
("theme".to_owned(), "dark".to_owned()),
("extra".to_owned(), "1".to_owned()),
]
);
}
#[test]
fn a_malformed_cookie_pair_is_skipped_rather_than_guessed_at() {
let request = RequestView::new("GET", "/").with_header("cookie", "novalue; ok=1; ");
assert_eq!(request.cookies(), [("ok".to_owned(), "1".to_owned())]);
}
#[test]
fn headers_can_be_added_in_bulk() {
let request = RequestView::new("GET", "/").with_headers([("a", "1"), ("b", "2")]);
assert_eq!(request.header("b"), Some("2"));
}
#[test]
fn a_body_is_whatever_bytes_the_caller_buffered() {
let request = RequestView::new("POST", "/").with_body(b"{}".as_slice());
assert_eq!(request.body.as_deref(), Some(b"{}".as_slice()));
assert_eq!(RequestView::new("POST", "/").body, None);
}
#[test]
fn raw_pairs_keep_their_values_encoded_so_delimiters_stay_distinguishable() {
let request = RequestView::new("GET", "/").with_query("tags=a%2Cb&q=x+y");
assert_eq!(
request.query_pairs_raw(),
[
("tags".to_owned(), "a%2Cb".to_owned()),
("q".to_owned(), "x+y".to_owned()),
],
);
assert_eq!(
request.query_pairs(),
[
("tags".to_owned(), "a,b".to_owned()),
("q".to_owned(), "x y".to_owned()),
],
);
}
#[test]
fn a_path_segment_decodes_percent_escapes_but_not_plus() {
assert_eq!(decode_path_segment("a%20b+c"), "a b+c");
}
}