Skip to main content

roas_http_validator/
report.rs

1//! What the validator says happened.
2//!
3//! Two kinds of answer, kept apart because a caller does different
4//! things with them. [`RoutingError`] means the description says nothing
5//! about this request — usually a 404, or a request that should be
6//! passed through untouched. A [`ValidationReport`] means the request
7//! was found and judged; its `errors` are the ones a 400 would name.
8//!
9//! Errors are collected rather than raised one at a time, the way
10//! `roas`'s own description validator collects them: a client that sent
11//! three bad parameters is better served by hearing about all three.
12
13use std::fmt::{self, Display, Formatter};
14
15/// Where in the request an error was found.
16#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
17#[non_exhaustive]
18pub enum Location {
19    /// A parameter with `in: path`.
20    Path,
21    /// A parameter with `in: query`.
22    Query,
23    /// A parameter with `in: querystring` — the whole query string as
24    /// one value, which OpenAPI 3.2 added.
25    Querystring,
26    /// A parameter with `in: header`.
27    Header,
28    /// A parameter with `in: cookie`.
29    Cookie,
30    /// The request body.
31    Body,
32    /// Not the request at all: the description itself could not be read
33    /// far enough to judge the request — an unresolvable `$ref` where a
34    /// Parameter Object should be, say. Reported rather than dropped,
35    /// because the parameter it named went unchecked.
36    Description,
37}
38
39impl Display for Location {
40    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
41        f.write_str(match self {
42            Location::Path => "path",
43            Location::Query => "query",
44            Location::Querystring => "querystring",
45            Location::Header => "header",
46            Location::Cookie => "cookie",
47            Location::Body => "body",
48            Location::Description => "description",
49        })
50    }
51}
52
53/// One thing wrong with the request.
54#[derive(Clone, Debug, PartialEq, Eq)]
55#[non_exhaustive]
56pub struct ValidationError {
57    /// Which part of the request this is about.
58    pub location: Location,
59    /// The parameter name, or empty for the body.
60    pub name: String,
61    /// Where inside the value, as an RFC 6901 JSON Pointer; empty when
62    /// the error is about the value as a whole.
63    ///
64    /// On the error rather than on one [`ErrorKind`], because *where*
65    /// is the same question whatever went wrong there: a `pattern` that
66    /// will not compile at `/user/name` needs pointing at exactly as
67    /// much as a type mismatch does.
68    pub pointer: String,
69    /// What is wrong.
70    pub kind: ErrorKind,
71}
72
73impl Display for ValidationError {
74    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
75        write!(f, "{}", self.location)?;
76        if !self.name.is_empty() {
77            write!(f, " parameter {:?}", self.name)?;
78        }
79        if !self.pointer.is_empty() {
80            write!(f, " at {}", self.pointer)?;
81        }
82        write!(f, ": {}", self.kind)
83    }
84}
85
86impl std::error::Error for ValidationError {}
87
88/// What was wrong with one parameter or with the body.
89#[derive(Clone, Debug, PartialEq, Eq)]
90#[non_exhaustive]
91pub enum ErrorKind {
92    /// A `required` parameter was not sent, or a required body was
93    /// absent.
94    Missing,
95
96    /// The value did not satisfy its Schema Object.
97    Schema(String),
98
99    /// A body arrived, but its media type is not one the Request Body
100    /// Object describes.
101    UnexpectedMediaType {
102        /// What the request said it was sending, if it said.
103        got: Option<String>,
104        /// The media types the operation accepts.
105        expected: Vec<String>,
106    },
107
108    /// The value could not be read as the media type or `style` said it
109    /// would be — malformed JSON, a form field that is not a number.
110    Malformed(String),
111
112    /// The description uses something this crate does not implement
113    /// yet, so the value was **not** checked. Reported rather than
114    /// skipped: "not validated" must never read as "valid".
115    Unsupported(String),
116
117    /// The description could be read but not applied faithfully, so the
118    /// value went **unchecked** — a `pattern` that will not compile, or
119    /// a bound whose digits were lost to floating point before this
120    /// crate ever saw it. Same guarantee as [`ErrorKind::Unsupported`]:
121    /// unchecked never reads as valid.
122    Unchecked(String),
123
124    /// A `$ref` in the description could not be resolved, so there was
125    /// no schema to judge the value against.
126    UnresolvedReference(String),
127
128    /// The request carried a query parameter the operation does not
129    /// describe. Only reported when
130    /// [`Options::reject_undescribed_query_parameters`](crate::Options::reject_undescribed_query_parameters)
131    /// asks for it.
132    Undescribed,
133}
134
135impl ErrorKind {
136    /// Whether this says a check could not be made, rather than that the
137    /// request broke a rule.
138    ///
139    /// The two want different responses. A violation is the client's
140    /// fault and answers with a 400; an unchecked result is a limit of
141    /// the description or of floating point, and a caller may reasonably
142    /// let it through, log it, or treat it as a 400 too — but it should
143    /// be that caller's decision, made knowingly.
144    #[must_use]
145    pub fn is_unchecked(&self) -> bool {
146        matches!(
147            self,
148            ErrorKind::Unsupported(_)
149                | ErrorKind::Unchecked(_)
150                // A `$ref` that names nothing left no schema to judge
151                // the value against, so nothing about the value was
152                // established — that is the description's defect, and
153                // reporting it as the client's would answer a broken
154                // document with a 400.
155                | ErrorKind::UnresolvedReference(_)
156        )
157    }
158}
159
160impl Display for ErrorKind {
161    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
162        match self {
163            ErrorKind::Missing => f.write_str("is required and was not sent"),
164            ErrorKind::Schema(message) => f.write_str(message),
165            ErrorKind::UnexpectedMediaType { got, expected } => {
166                let expected = expected.join(", ");
167                match got {
168                    Some(got) => write!(f, "media type {got:?} is not one of: {expected}"),
169                    None => write!(f, "no media type was sent; expected one of: {expected}"),
170                }
171            }
172            ErrorKind::Malformed(why) => write!(f, "cannot be read: {why}"),
173            ErrorKind::Unsupported(what) => {
174                write!(f, "was NOT checked — {what} is not implemented yet")
175            }
176            ErrorKind::Unchecked(why) => write!(f, "was NOT checked — {why}"),
177            ErrorKind::UnresolvedReference(reference) => {
178                write!(f, "has an unresolvable `$ref`: {reference}")
179            }
180            ErrorKind::Undescribed => f.write_str("is not described by this operation"),
181        }
182    }
183}
184
185/// The verdict on one request that the description does describe.
186#[derive(Clone, Debug, PartialEq, Eq)]
187#[non_exhaustive]
188pub struct ValidationReport {
189    /// The Path Item template the request matched, e.g. `/pets/{petId}`.
190    pub template: String,
191    /// The HTTP method token the matched operation describes — `GET`,
192    /// not the `get` that OpenAPI keys it under, and exactly as written
193    /// for one that came from `additionalOperations`.
194    pub method: String,
195    /// The matched operation's `operationId`, when it has one.
196    pub operation_id: Option<String>,
197    /// Path parameters as the template read them.
198    pub path_parameters: Vec<(String, String)>,
199    /// Everything wrong with the request. Empty means valid.
200    pub errors: Vec<ValidationError>,
201}
202
203impl ValidationReport {
204    /// Whether the request satisfied the description, with nothing left
205    /// unchecked.
206    ///
207    /// Both halves matter: see [`violations`](Self::violations) and
208    /// [`unchecked`](Self::unchecked) to tell them apart.
209    #[must_use]
210    pub fn is_valid(&self) -> bool {
211        self.errors.is_empty()
212    }
213
214    /// The errors that are definitely the request's fault.
215    pub fn violations(&self) -> impl Iterator<Item = &ValidationError> {
216        self.errors
217            .iter()
218            .filter(|error| !error.kind.is_unchecked())
219    }
220
221    /// The errors that say a check could not be made — nothing is known
222    /// to be wrong, and nothing is known to be right.
223    ///
224    /// A validator that reported these as violations would reject valid
225    /// requests; one that dropped them would call unexamined requests
226    /// valid. They are kept and labelled so the caller can choose.
227    pub fn unchecked(&self) -> impl Iterator<Item = &ValidationError> {
228        self.errors.iter().filter(|error| error.kind.is_unchecked())
229    }
230
231    /// The errors, as one `Err` when there are any.
232    ///
233    /// # Errors
234    ///
235    /// The report's own errors, for callers that would rather branch on
236    /// a `Result` than on [`is_valid`](Self::is_valid).
237    pub fn into_result(self) -> Result<Self, Vec<ValidationError>> {
238        if self.is_valid() {
239            Ok(self)
240        } else {
241            Err(self.errors)
242        }
243    }
244}
245
246impl Display for ValidationReport {
247    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
248        let operation = match &self.operation_id {
249            Some(id) => format!(" ({id})"),
250            None => String::new(),
251        };
252        write!(f, "{} {}{operation}: ", self.method, self.template)?;
253        if self.errors.is_empty() {
254            return f.write_str("valid");
255        }
256        writeln!(f, "{} error(s)", self.errors.len())?;
257        for (index, error) in self.errors.iter().enumerate() {
258            if index > 0 {
259                writeln!(f)?;
260            }
261            write!(f, "  - {error}")?;
262        }
263        Ok(())
264    }
265}
266
267/// The description does not describe this request at all.
268#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
269#[non_exhaustive]
270pub enum RoutingError {
271    /// No Path Item template matched the request path.
272    #[error("no path in the description matches {path:?}")]
273    PathNotFound {
274        /// The path that matched nothing.
275        path: String,
276    },
277
278    /// A template matched, but its Path Item Object could not be read:
279    /// a `$ref` that names nothing, points outside the document, or
280    /// closes a cycle.
281    ///
282    /// Neither "no such path" nor "no such method" — the description is
283    /// broken, and the request cannot be judged either way. Usually a
284    /// 500 rather than a 404 or a 405.
285    #[error("{template} references {reference}, which could not be resolved")]
286    Unresolved {
287        /// The template that matched.
288        template: String,
289        /// The reference that could not be followed.
290        reference: String,
291    },
292
293    /// A template matched, but it describes no such method. The methods
294    /// it does describe are named, which is what an `Allow` response
295    /// header needs.
296    #[error("{template} describes no {method} operation (it has: {})", allowed.join(", "))]
297    MethodNotAllowed {
298        /// The template that matched.
299        template: String,
300        /// The method token the request carried, exactly as it carried
301        /// it — a lowercase `get` is reported as `get`, because that is
302        /// why it was refused.
303        method: String,
304        /// The methods the Path Item Object does describe, as method
305        /// tokens rather than OpenAPI's lowercase keys — so this is
306        /// what an `Allow` header wants.
307        allowed: Vec<String>,
308    },
309}
310
311#[cfg(test)]
312mod tests {
313    use super::*;
314
315    fn error(location: Location, name: &str, kind: ErrorKind) -> ValidationError {
316        ValidationError {
317            location,
318            name: name.to_owned(),
319            pointer: String::new(),
320            kind,
321        }
322    }
323
324    fn error_at(location: Location, pointer: &str, kind: ErrorKind) -> ValidationError {
325        ValidationError {
326            location,
327            name: String::new(),
328            pointer: pointer.to_owned(),
329            kind,
330        }
331    }
332
333    fn report(errors: Vec<ValidationError>) -> ValidationReport {
334        ValidationReport {
335            template: "/pets/{petId}".to_owned(),
336            method: "GET".to_owned(),
337            operation_id: Some("getPet".to_owned()),
338            path_parameters: vec![("petId".to_owned(), "7".to_owned())],
339            errors,
340        }
341    }
342
343    #[test]
344    fn a_report_without_errors_is_valid() {
345        let report = report(Vec::new());
346        assert!(report.is_valid());
347        assert_eq!(report.to_string(), "GET /pets/{petId} (getPet): valid");
348        assert!(report.into_result().is_ok());
349    }
350
351    #[test]
352    fn a_report_lists_every_error_it_found() {
353        let report = report(vec![
354            error(Location::Query, "limit", ErrorKind::Missing),
355            error_at(
356                Location::Body,
357                "/name",
358                ErrorKind::Schema("expected string, got integer".to_owned()),
359            ),
360        ]);
361        assert!(!report.is_valid());
362        assert_eq!(
363            report.to_string(),
364            "GET /pets/{petId} (getPet): 2 error(s)\n  \
365             - query parameter \"limit\": is required and was not sent\n  \
366             - body at /name: expected string, got integer",
367        );
368        assert_eq!(report.into_result().unwrap_err().len(), 2);
369    }
370
371    #[test]
372    fn an_operation_without_an_id_is_named_by_its_template_alone() {
373        let mut report = report(Vec::new());
374        report.operation_id = None;
375        assert_eq!(report.to_string(), "GET /pets/{petId}: valid");
376    }
377
378    #[test]
379    fn a_report_tells_violations_apart_from_what_it_could_not_check() {
380        let report = report(vec![
381            error(Location::Query, "limit", ErrorKind::Missing),
382            error(
383                Location::Body,
384                "",
385                ErrorKind::Unchecked("the bound lost its digits".to_owned()),
386            ),
387            error(
388                Location::Body,
389                "",
390                ErrorKind::Unsupported("multipart bodies".to_owned()),
391            ),
392        ]);
393        assert!(!report.is_valid());
394        assert_eq!(report.violations().count(), 1);
395        assert_eq!(report.unchecked().count(), 2);
396        for definite in [
397            ErrorKind::Missing,
398            ErrorKind::Schema("wrong".to_owned()),
399            ErrorKind::Malformed("wrong".to_owned()),
400            ErrorKind::Undescribed,
401            ErrorKind::UnexpectedMediaType {
402                got: None,
403                expected: Vec::new(),
404            },
405        ] {
406            assert!(
407                !definite.is_unchecked(),
408                "{definite} is the request's fault"
409            );
410        }
411        for undecided in [
412            ErrorKind::Unchecked(String::new()),
413            ErrorKind::Unsupported(String::new()),
414            // Nothing was judged, so nothing was found wrong.
415            ErrorKind::UnresolvedReference("#/nope".to_owned()),
416        ] {
417            assert!(undecided.is_unchecked(), "{undecided} judged nothing");
418        }
419    }
420
421    #[test]
422    fn every_error_kind_says_what_it_means() {
423        let kinds = [
424            (ErrorKind::Missing, "is required and was not sent"),
425            (
426                ErrorKind::Schema("expected integer".to_owned()),
427                "expected integer",
428            ),
429            (
430                ErrorKind::UnexpectedMediaType {
431                    got: Some("text/plain".to_owned()),
432                    expected: vec!["application/json".to_owned()],
433                },
434                "media type \"text/plain\" is not one of: application/json",
435            ),
436            (
437                ErrorKind::UnexpectedMediaType {
438                    got: None,
439                    expected: vec!["application/json".to_owned()],
440                },
441                "no media type was sent; expected one of: application/json",
442            ),
443            (
444                ErrorKind::Malformed("trailing comma".to_owned()),
445                "cannot be read: trailing comma",
446            ),
447            (
448                ErrorKind::Unsupported("multipart bodies".to_owned()),
449                "was NOT checked — multipart bodies is not implemented yet",
450            ),
451            (
452                ErrorKind::UnresolvedReference("#/components/schemas/Gone".to_owned()),
453                "has an unresolvable `$ref`: #/components/schemas/Gone",
454            ),
455            (
456                ErrorKind::Unchecked("the bound lost its digits".to_owned()),
457                "was NOT checked — the bound lost its digits",
458            ),
459            (ErrorKind::Undescribed, "is not described by this operation"),
460        ];
461        for (kind, expected) in kinds {
462            assert_eq!(kind.to_string(), expected);
463        }
464    }
465
466    #[test]
467    fn a_location_names_itself() {
468        for (location, expected) in [
469            (Location::Path, "path"),
470            (Location::Query, "query"),
471            (Location::Querystring, "querystring"),
472            (Location::Header, "header"),
473            (Location::Cookie, "cookie"),
474            (Location::Body, "body"),
475            (Location::Description, "description"),
476        ] {
477            assert_eq!(location.to_string(), expected);
478        }
479    }
480
481    #[test]
482    fn a_routing_error_says_which_path_or_which_methods() {
483        assert_eq!(
484            RoutingError::PathNotFound {
485                path: "/nope".to_owned(),
486            }
487            .to_string(),
488            "no path in the description matches \"/nope\"",
489        );
490        assert_eq!(
491            RoutingError::Unresolved {
492                template: "/pets".to_owned(),
493                reference: "#/components/pathItems/Gone".to_owned(),
494            }
495            .to_string(),
496            "/pets references #/components/pathItems/Gone, which could not be resolved",
497        );
498        assert_eq!(
499            RoutingError::MethodNotAllowed {
500                template: "/pets".to_owned(),
501                method: "DELETE".to_owned(),
502                allowed: vec!["get".to_owned(), "post".to_owned()],
503            }
504            .to_string(),
505            "/pets describes no DELETE operation (it has: get, post)",
506        );
507    }
508}