Skip to main content

roas_http_validator/
request.rs

1//! What the validator is handed: one HTTP request, borrowed.
2//!
3//! Every Rust web framework has its own request type, and none of them
4//! is the one a validator wants. `http::Request` is generic over a body
5//! that is usually a stream; `actix_web::HttpRequest` and
6//! `rocket::Request` carry no body at all; and the `http` crate itself
7//! is version-split — actix-web 4 is still on `http` 0.2 while hyper 1,
8//! axum 0.8 and reqwest are on 1.x, so the two `HeaderMap`s are
9//! different types that cannot be passed to the same function.
10//!
11//! So this crate takes none of them. [`RequestView`] is the small set of
12//! things an OpenAPI description actually talks about — a method, a
13//! path, a query string, headers and some bytes — and each framework
14//! gets a [`ToRequestView`] impl behind its own feature. That keeps the
15//! core compiling for every framework at once, and testable with no
16//! server at all.
17//!
18//! The body is bytes, deliberately. A framework body is a stream, and
19//! validating one means buffering it; buffering is the caller's
20//! decision — how much, and whether at all — so the adapters convert
21//! the head and leave [`RequestView::with_body`] to whoever is willing
22//! to pay for it.
23
24use std::borrow::Cow;
25
26/// One HTTP request, as much of it as an OpenAPI description describes.
27///
28/// Built directly, or from a framework's own request type through
29/// [`ToRequestView`]:
30///
31/// ```
32/// use roas_http_validator::RequestView;
33///
34/// let request = RequestView::new("GET", "/pets/7")
35///     .with_query("limit=10")
36///     .with_header("accept", "application/json");
37///
38/// assert_eq!(request.header("Accept"), Some("application/json"));
39/// ```
40#[derive(Clone, Debug, Default, PartialEq, Eq)]
41#[non_exhaustive]
42pub struct RequestView<'a> {
43    /// The HTTP method, as the request carried it.
44    ///
45    /// Matched case-sensitively, as
46    /// [RFC 9110 §9.1](https://www.rfc-editor.org/rfc/rfc9110#section-9.1)
47    /// requires of a method token: `GET` finds the `get` a Path Item
48    /// Object keys it under, and `get` finds nothing.
49    pub method: Cow<'a, str>,
50
51    /// The path, without the query string. Percent-encoding is kept as
52    /// it arrived — a `%2F` in a path parameter is not a separator, and
53    /// decoding here would make it one.
54    pub path: Cow<'a, str>,
55
56    /// The raw query string, without the leading `?`.
57    pub query: Option<Cow<'a, str>>,
58
59    /// Headers in the order they arrived, names as written. A header
60    /// may repeat, so this is a list rather than a map.
61    pub headers: Vec<(Cow<'a, str>, Cow<'a, str>)>,
62
63    /// The body, already buffered.
64    ///
65    /// `None` means no body was supplied; `Some(&[])` means one was and
66    /// it was empty. Validation keeps them apart — an empty JSON body is
67    /// malformed where an absent optional one is fine — so a caller that
68    /// means "no body" leaves this `None` rather than passing no bytes.
69    pub body: Option<Cow<'a, [u8]>>,
70}
71
72impl<'a> RequestView<'a> {
73    /// A request with a method and a path and nothing else.
74    #[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    /// Set the query string. A leading `?` is accepted and dropped, so
86    /// both halves of `uri.query()` and `"?" + query` work.
87    #[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    /// Add one header. Repeating a name adds a second value rather than
103    /// replacing the first.
104    #[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    /// Add many headers at once.
115    #[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    /// Supply the buffered body.
127    #[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    /// The first value of `name`, matched case-insensitively as
134    /// [RFC 9110 §5.1](https://www.rfc-editor.org/rfc/rfc9110#name-field-names)
135    /// requires.
136    #[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    /// Every value of `name`, in order.
145    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    /// The media type from `Content-Type`, lowercased, with any
153    /// parameters (`; charset=utf-8`) dropped.
154    #[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    /// The query string decoded into name/value pairs, keeping order
167    /// and repeats — `?tag=a&tag=b` is two pairs, not one.
168    #[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    /// The same pairs with their **values** left encoded.
177    ///
178    /// Which is what validation needs: a delimiter that arrived
179    /// percent-encoded is data, not a separator, so `tags=a%2Cb` must
180    /// still be one item when `style` splits it on commas. Names are
181    /// decoded, because a name is never split.
182    pub(crate) fn query_pairs_raw(&self) -> Vec<(String, String)> {
183        self.query.as_deref().map(split_query).unwrap_or_default()
184    }
185
186    /// The cookies from the `Cookie` header, in the order sent.
187    #[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
203/// A framework's own request type, seen as a [`RequestView`].
204///
205/// One impl per framework, each behind its own feature. The body is not
206/// part of it — see the module documentation for why.
207pub trait ToRequestView {
208    /// Borrow this request as a [`RequestView`].
209    fn request_view(&self) -> RequestView<'_>;
210}
211
212/// Split a query string into pairs, decoding the names and leaving the
213/// values as they arrived.
214pub(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
225/// Percent-decode one form field, lossily: a byte sequence that is not
226/// UTF-8 becomes replacement characters rather than an error, because a
227/// malformed byte in one parameter should be reported by the schema
228/// that parameter is judged against, not by refusing the whole request.
229pub(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
236/// Percent-decode one path segment. `+` is a literal plus here — the
237/// form encoding does not apply to paths.
238pub(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        // The public accessor still hands back what a reader expects.
360        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}