1use std::borrow::Cow;
25
26#[derive(Clone, Debug, Default, PartialEq, Eq)]
41#[non_exhaustive]
42pub struct RequestView<'a> {
43 pub method: Cow<'a, str>,
50
51 pub path: Cow<'a, str>,
55
56 pub query: Option<Cow<'a, str>>,
58
59 pub headers: Vec<(Cow<'a, str>, Cow<'a, str>)>,
62
63 pub body: Option<Cow<'a, [u8]>>,
70}
71
72impl<'a> RequestView<'a> {
73 #[must_use]
75 pub fn new(method: impl Into<Cow<'a, str>>, path: impl Into<Cow<'a, str>>) -> Self {
76 Self {
77 method: method.into(),
78 path: path.into(),
79 query: None,
80 headers: Vec::new(),
81 body: None,
82 }
83 }
84
85 #[must_use]
88 pub fn with_query(mut self, query: impl Into<Cow<'a, str>>) -> Self {
89 let query = query.into();
90 self.query = Some(match query {
91 Cow::Borrowed(query) => Cow::Borrowed(query.strip_prefix('?').unwrap_or(query)),
92 Cow::Owned(mut query) => {
93 if query.starts_with('?') {
94 query.remove(0);
95 }
96 Cow::Owned(query)
97 }
98 });
99 self
100 }
101
102 #[must_use]
105 pub fn with_header(
106 mut self,
107 name: impl Into<Cow<'a, str>>,
108 value: impl Into<Cow<'a, str>>,
109 ) -> Self {
110 self.headers.push((name.into(), value.into()));
111 self
112 }
113
114 #[must_use]
116 pub fn with_headers<N, V>(mut self, headers: impl IntoIterator<Item = (N, V)>) -> Self
117 where
118 N: Into<Cow<'a, str>>,
119 V: Into<Cow<'a, str>>,
120 {
121 self.headers
122 .extend(headers.into_iter().map(|(n, v)| (n.into(), v.into())));
123 self
124 }
125
126 #[must_use]
128 pub fn with_body(mut self, body: impl Into<Cow<'a, [u8]>>) -> Self {
129 self.body = Some(body.into());
130 self
131 }
132
133 #[must_use]
137 pub fn header(&self, name: &str) -> Option<&str> {
138 self.headers
139 .iter()
140 .find(|(header, _)| header.eq_ignore_ascii_case(name))
141 .map(|(_, value)| value.as_ref())
142 }
143
144 pub fn header_values<'s>(&'s self, name: &'s str) -> impl Iterator<Item = &'s str> + 's {
146 self.headers
147 .iter()
148 .filter(move |(header, _)| header.eq_ignore_ascii_case(name))
149 .map(|(_, value)| value.as_ref())
150 }
151
152 #[must_use]
155 pub fn content_type(&self) -> Option<String> {
156 self.header("content-type").map(|value| {
157 value
158 .split(';')
159 .next()
160 .unwrap_or(value)
161 .trim()
162 .to_ascii_lowercase()
163 })
164 }
165
166 #[must_use]
169 pub fn query_pairs(&self) -> Vec<(String, String)> {
170 self.query_pairs_raw()
171 .into_iter()
172 .map(|(name, value)| (name, decode_form(&value)))
173 .collect()
174 }
175
176 pub(crate) fn query_pairs_raw(&self) -> Vec<(String, String)> {
183 self.query.as_deref().map(split_query).unwrap_or_default()
184 }
185
186 #[must_use]
188 pub fn cookies(&self) -> Vec<(String, String)> {
189 self.header_values("cookie")
190 .flat_map(|value| value.split(';'))
191 .filter_map(|pair| {
192 let pair = pair.trim();
193 if pair.is_empty() {
194 return None;
195 }
196 let (name, value) = pair.split_once('=')?;
197 Some((name.trim().to_owned(), value.trim().to_owned()))
198 })
199 .collect()
200 }
201}
202
203pub trait ToRequestView {
208 fn request_view(&self) -> RequestView<'_>;
210}
211
212pub(crate) fn split_query(query: &str) -> Vec<(String, String)> {
215 query
216 .split('&')
217 .filter(|pair| !pair.is_empty())
218 .map(|pair| match pair.split_once('=') {
219 Some((name, value)) => (decode_form(name), value.to_owned()),
220 None => (decode_form(pair), String::new()),
221 })
222 .collect()
223}
224
225pub(crate) fn decode_form(value: &str) -> String {
230 let plus_as_space = value.replace('+', " ");
231 percent_encoding::percent_decode_str(&plus_as_space)
232 .decode_utf8_lossy()
233 .into_owned()
234}
235
236pub(crate) fn decode_path_segment(segment: &str) -> String {
239 percent_encoding::percent_decode_str(segment)
240 .decode_utf8_lossy()
241 .into_owned()
242}
243
244#[cfg(test)]
245mod tests {
246 use super::*;
247
248 #[test]
249 fn a_header_is_found_whatever_case_it_was_written_in() {
250 let request = RequestView::new("GET", "/").with_header("Content-Type", "application/json");
251 assert_eq!(request.header("content-type"), Some("application/json"));
252 assert_eq!(request.header("CONTENT-TYPE"), Some("application/json"));
253 assert_eq!(request.header("accept"), None);
254 }
255
256 #[test]
257 fn a_repeated_header_keeps_every_value() {
258 let request = RequestView::new("GET", "/")
259 .with_header("x-tag", "a")
260 .with_header("X-Tag", "b");
261 assert_eq!(request.header("x-tag"), Some("a"));
262 assert_eq!(
263 request.header_values("x-tag").collect::<Vec<_>>(),
264 ["a", "b"]
265 );
266 }
267
268 #[test]
269 fn the_content_type_drops_its_parameters_and_case() {
270 let request = RequestView::new("POST", "/")
271 .with_header("content-type", "Application/JSON; charset=utf-8");
272 assert_eq!(request.content_type().as_deref(), Some("application/json"));
273 assert_eq!(RequestView::new("POST", "/").content_type(), None);
274 }
275
276 #[test]
277 fn a_query_string_keeps_order_and_repeats() {
278 let request = RequestView::new("GET", "/").with_query("tag=a&tag=b&limit=10");
279 assert_eq!(
280 request.query_pairs(),
281 [
282 ("tag".to_owned(), "a".to_owned()),
283 ("tag".to_owned(), "b".to_owned()),
284 ("limit".to_owned(), "10".to_owned()),
285 ]
286 );
287 }
288
289 #[test]
290 fn a_leading_question_mark_is_not_part_of_the_query() {
291 let borrowed = RequestView::new("GET", "/").with_query("?a=1");
292 let owned = RequestView::new("GET", "/").with_query("?a=1".to_owned());
293 assert_eq!(borrowed.query.as_deref(), Some("a=1"));
294 assert_eq!(owned.query.as_deref(), Some("a=1"));
295 }
296
297 #[test]
298 fn form_encoding_is_undone_in_the_query() {
299 let request = RequestView::new("GET", "/").with_query("q=a+b%20c&flag&empty=");
300 assert_eq!(
301 request.query_pairs(),
302 [
303 ("q".to_owned(), "a b c".to_owned()),
304 ("flag".to_owned(), String::new()),
305 ("empty".to_owned(), String::new()),
306 ]
307 );
308 }
309
310 #[test]
311 fn a_request_with_no_query_has_no_pairs() {
312 assert!(RequestView::new("GET", "/").query_pairs().is_empty());
313 }
314
315 #[test]
316 fn cookies_come_from_the_cookie_header() {
317 let request = RequestView::new("GET", "/")
318 .with_header("cookie", "session=abc; theme=dark")
319 .with_header("Cookie", "extra=1");
320 assert_eq!(
321 request.cookies(),
322 [
323 ("session".to_owned(), "abc".to_owned()),
324 ("theme".to_owned(), "dark".to_owned()),
325 ("extra".to_owned(), "1".to_owned()),
326 ]
327 );
328 }
329
330 #[test]
331 fn a_malformed_cookie_pair_is_skipped_rather_than_guessed_at() {
332 let request = RequestView::new("GET", "/").with_header("cookie", "novalue; ok=1; ");
333 assert_eq!(request.cookies(), [("ok".to_owned(), "1".to_owned())]);
334 }
335
336 #[test]
337 fn headers_can_be_added_in_bulk() {
338 let request = RequestView::new("GET", "/").with_headers([("a", "1"), ("b", "2")]);
339 assert_eq!(request.header("b"), Some("2"));
340 }
341
342 #[test]
343 fn a_body_is_whatever_bytes_the_caller_buffered() {
344 let request = RequestView::new("POST", "/").with_body(b"{}".as_slice());
345 assert_eq!(request.body.as_deref(), Some(b"{}".as_slice()));
346 assert_eq!(RequestView::new("POST", "/").body, None);
347 }
348
349 #[test]
350 fn raw_pairs_keep_their_values_encoded_so_delimiters_stay_distinguishable() {
351 let request = RequestView::new("GET", "/").with_query("tags=a%2Cb&q=x+y");
352 assert_eq!(
353 request.query_pairs_raw(),
354 [
355 ("tags".to_owned(), "a%2Cb".to_owned()),
356 ("q".to_owned(), "x+y".to_owned()),
357 ],
358 );
359 assert_eq!(
361 request.query_pairs(),
362 [
363 ("tags".to_owned(), "a,b".to_owned()),
364 ("q".to_owned(), "x y".to_owned()),
365 ],
366 );
367 }
368
369 #[test]
370 fn a_path_segment_decodes_percent_escapes_but_not_plus() {
371 assert_eq!(decode_path_segment("a%20b+c"), "a b+c");
372 }
373}