roas-http-validator 0.2.0

Validates HTTP requests against an OpenAPI description, with adapters for axum, actix-web, poem, salvo and rocket
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
//! What the validator says happened.
//!
//! Two kinds of answer, kept apart because a caller does different
//! things with them. [`RoutingError`] means the description says nothing
//! about this request — usually a 404, or a request that should be
//! passed through untouched. A [`ValidationReport`] means the request
//! was found and judged; its `errors` are the ones a 400 would name.
//!
//! Errors are collected rather than raised one at a time, the way
//! `roas`'s own description validator collects them: a client that sent
//! three bad parameters is better served by hearing about all three.

use std::fmt::{self, Display, Formatter};

/// Where in the request an error was found.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[non_exhaustive]
pub enum Location {
    /// A parameter with `in: path`.
    Path,
    /// A parameter with `in: query`.
    Query,
    /// A parameter with `in: querystring` — the whole query string as
    /// one value, which OpenAPI 3.2 added.
    Querystring,
    /// A parameter with `in: header`.
    Header,
    /// A parameter with `in: cookie`.
    Cookie,
    /// The request body.
    Body,
    /// Not the request at all: the description itself could not be read
    /// far enough to judge the request — an unresolvable `$ref` where a
    /// Parameter Object should be, say. Reported rather than dropped,
    /// because the parameter it named went unchecked.
    Description,
}

impl Display for Location {
    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
        f.write_str(match self {
            Location::Path => "path",
            Location::Query => "query",
            Location::Querystring => "querystring",
            Location::Header => "header",
            Location::Cookie => "cookie",
            Location::Body => "body",
            Location::Description => "description",
        })
    }
}

/// One thing wrong with the request.
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub struct ValidationError {
    /// Which part of the request this is about.
    pub location: Location,
    /// The parameter name, or empty for the body.
    pub name: String,
    /// Where inside the value, as an RFC 6901 JSON Pointer; empty when
    /// the error is about the value as a whole.
    ///
    /// On the error rather than on one [`ErrorKind`], because *where*
    /// is the same question whatever went wrong there: a `pattern` that
    /// will not compile at `/user/name` needs pointing at exactly as
    /// much as a type mismatch does.
    pub pointer: String,
    /// What is wrong.
    pub kind: ErrorKind,
}

impl Display for ValidationError {
    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
        write!(f, "{}", self.location)?;
        if !self.name.is_empty() {
            write!(f, " parameter {:?}", self.name)?;
        }
        if !self.pointer.is_empty() {
            write!(f, " at {}", self.pointer)?;
        }
        write!(f, ": {}", self.kind)
    }
}

impl std::error::Error for ValidationError {}

/// What was wrong with one parameter or with the body.
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum ErrorKind {
    /// A `required` parameter was not sent, or a required body was
    /// absent.
    Missing,

    /// The value did not satisfy its Schema Object.
    Schema(String),

    /// A body arrived, but its media type is not one the Request Body
    /// Object describes.
    UnexpectedMediaType {
        /// What the request said it was sending, if it said.
        got: Option<String>,
        /// The media types the operation accepts.
        expected: Vec<String>,
    },

    /// The value could not be read as the media type or `style` said it
    /// would be — malformed JSON, a form field that is not a number.
    Malformed(String),

    /// The description uses something this crate does not implement
    /// yet, so the value was **not** checked. Reported rather than
    /// skipped: "not validated" must never read as "valid".
    Unsupported(String),

    /// The description could be read but not applied faithfully, so the
    /// value went **unchecked** — a `pattern` that will not compile, or
    /// a bound whose digits were lost to floating point before this
    /// crate ever saw it. Same guarantee as [`ErrorKind::Unsupported`]:
    /// unchecked never reads as valid.
    Unchecked(String),

    /// A `$ref` in the description could not be resolved, so there was
    /// no schema to judge the value against.
    UnresolvedReference(String),

    /// The request carried a query parameter the operation does not
    /// describe. Only reported when
    /// [`Options::reject_undescribed_query_parameters`](crate::Options::reject_undescribed_query_parameters)
    /// asks for it.
    Undescribed,
}

impl ErrorKind {
    /// Whether this says a check could not be made, rather than that the
    /// request broke a rule.
    ///
    /// The two want different responses. A violation is the client's
    /// fault and answers with a 400; an unchecked result is a limit of
    /// the description or of floating point, and a caller may reasonably
    /// let it through, log it, or treat it as a 400 too — but it should
    /// be that caller's decision, made knowingly.
    #[must_use]
    pub fn is_unchecked(&self) -> bool {
        matches!(
            self,
            ErrorKind::Unsupported(_)
                | ErrorKind::Unchecked(_)
                // A `$ref` that names nothing left no schema to judge
                // the value against, so nothing about the value was
                // established — that is the description's defect, and
                // reporting it as the client's would answer a broken
                // document with a 400.
                | ErrorKind::UnresolvedReference(_)
        )
    }
}

impl Display for ErrorKind {
    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
        match self {
            ErrorKind::Missing => f.write_str("is required and was not sent"),
            ErrorKind::Schema(message) => f.write_str(message),
            ErrorKind::UnexpectedMediaType { got, expected } => {
                let expected = expected.join(", ");
                match got {
                    Some(got) => write!(f, "media type {got:?} is not one of: {expected}"),
                    None => write!(f, "no media type was sent; expected one of: {expected}"),
                }
            }
            ErrorKind::Malformed(why) => write!(f, "cannot be read: {why}"),
            ErrorKind::Unsupported(what) => {
                write!(f, "was NOT checked — {what} is not implemented yet")
            }
            ErrorKind::Unchecked(why) => write!(f, "was NOT checked — {why}"),
            ErrorKind::UnresolvedReference(reference) => {
                write!(f, "has an unresolvable `$ref`: {reference}")
            }
            ErrorKind::Undescribed => f.write_str("is not described by this operation"),
        }
    }
}

/// The verdict on one request that the description does describe.
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub struct ValidationReport {
    /// The Path Item template the request matched, e.g. `/pets/{petId}`.
    pub template: String,
    /// The HTTP method token the matched operation describes — `GET`,
    /// not the `get` that OpenAPI keys it under, and exactly as written
    /// for one that came from `additionalOperations`.
    pub method: String,
    /// The matched operation's `operationId`, when it has one.
    pub operation_id: Option<String>,
    /// Path parameters as the template read them.
    pub path_parameters: Vec<(String, String)>,
    /// Everything wrong with the request. Empty means valid.
    pub errors: Vec<ValidationError>,
}

impl ValidationReport {
    /// Whether the request satisfied the description, with nothing left
    /// unchecked.
    ///
    /// Both halves matter: see [`violations`](Self::violations) and
    /// [`unchecked`](Self::unchecked) to tell them apart.
    #[must_use]
    pub fn is_valid(&self) -> bool {
        self.errors.is_empty()
    }

    /// The errors that are definitely the request's fault.
    pub fn violations(&self) -> impl Iterator<Item = &ValidationError> {
        self.errors
            .iter()
            .filter(|error| !error.kind.is_unchecked())
    }

    /// The errors that say a check could not be made — nothing is known
    /// to be wrong, and nothing is known to be right.
    ///
    /// A validator that reported these as violations would reject valid
    /// requests; one that dropped them would call unexamined requests
    /// valid. They are kept and labelled so the caller can choose.
    pub fn unchecked(&self) -> impl Iterator<Item = &ValidationError> {
        self.errors.iter().filter(|error| error.kind.is_unchecked())
    }

    /// The errors, as one `Err` when there are any.
    ///
    /// # Errors
    ///
    /// The report's own errors, for callers that would rather branch on
    /// a `Result` than on [`is_valid`](Self::is_valid).
    pub fn into_result(self) -> Result<Self, Vec<ValidationError>> {
        if self.is_valid() {
            Ok(self)
        } else {
            Err(self.errors)
        }
    }
}

impl Display for ValidationReport {
    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
        let operation = match &self.operation_id {
            Some(id) => format!(" ({id})"),
            None => String::new(),
        };
        write!(f, "{} {}{operation}: ", self.method, self.template)?;
        if self.errors.is_empty() {
            return f.write_str("valid");
        }
        writeln!(f, "{} error(s)", self.errors.len())?;
        for (index, error) in self.errors.iter().enumerate() {
            if index > 0 {
                writeln!(f)?;
            }
            write!(f, "  - {error}")?;
        }
        Ok(())
    }
}

/// The description does not describe this request at all.
#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum RoutingError {
    /// No Path Item template matched the request path.
    #[error("no path in the description matches {path:?}")]
    PathNotFound {
        /// The path that matched nothing.
        path: String,
    },

    /// A template matched, but its Path Item Object could not be read:
    /// a `$ref` that names nothing, points outside the document, or
    /// closes a cycle.
    ///
    /// Neither "no such path" nor "no such method" — the description is
    /// broken, and the request cannot be judged either way. Usually a
    /// 500 rather than a 404 or a 405.
    #[error("{template} references {reference}, which could not be resolved")]
    Unresolved {
        /// The template that matched.
        template: String,
        /// The reference that could not be followed.
        reference: String,
    },

    /// A template matched, but it describes no such method. The methods
    /// it does describe are named, which is what an `Allow` response
    /// header needs.
    #[error("{template} describes no {method} operation (it has: {})", allowed.join(", "))]
    MethodNotAllowed {
        /// The template that matched.
        template: String,
        /// The method token the request carried, exactly as it carried
        /// it — a lowercase `get` is reported as `get`, because that is
        /// why it was refused.
        method: String,
        /// The methods the Path Item Object does describe, as method
        /// tokens rather than OpenAPI's lowercase keys — so this is
        /// what an `Allow` header wants.
        allowed: Vec<String>,
    },
}

#[cfg(test)]
mod tests {
    use super::*;

    fn error(location: Location, name: &str, kind: ErrorKind) -> ValidationError {
        ValidationError {
            location,
            name: name.to_owned(),
            pointer: String::new(),
            kind,
        }
    }

    fn error_at(location: Location, pointer: &str, kind: ErrorKind) -> ValidationError {
        ValidationError {
            location,
            name: String::new(),
            pointer: pointer.to_owned(),
            kind,
        }
    }

    fn report(errors: Vec<ValidationError>) -> ValidationReport {
        ValidationReport {
            template: "/pets/{petId}".to_owned(),
            method: "GET".to_owned(),
            operation_id: Some("getPet".to_owned()),
            path_parameters: vec![("petId".to_owned(), "7".to_owned())],
            errors,
        }
    }

    #[test]
    fn a_report_without_errors_is_valid() {
        let report = report(Vec::new());
        assert!(report.is_valid());
        assert_eq!(report.to_string(), "GET /pets/{petId} (getPet): valid");
        assert!(report.into_result().is_ok());
    }

    #[test]
    fn a_report_lists_every_error_it_found() {
        let report = report(vec![
            error(Location::Query, "limit", ErrorKind::Missing),
            error_at(
                Location::Body,
                "/name",
                ErrorKind::Schema("expected string, got integer".to_owned()),
            ),
        ]);
        assert!(!report.is_valid());
        assert_eq!(
            report.to_string(),
            "GET /pets/{petId} (getPet): 2 error(s)\n  \
             - query parameter \"limit\": is required and was not sent\n  \
             - body at /name: expected string, got integer",
        );
        assert_eq!(report.into_result().unwrap_err().len(), 2);
    }

    #[test]
    fn an_operation_without_an_id_is_named_by_its_template_alone() {
        let mut report = report(Vec::new());
        report.operation_id = None;
        assert_eq!(report.to_string(), "GET /pets/{petId}: valid");
    }

    #[test]
    fn a_report_tells_violations_apart_from_what_it_could_not_check() {
        let report = report(vec![
            error(Location::Query, "limit", ErrorKind::Missing),
            error(
                Location::Body,
                "",
                ErrorKind::Unchecked("the bound lost its digits".to_owned()),
            ),
            error(
                Location::Body,
                "",
                ErrorKind::Unsupported("multipart bodies".to_owned()),
            ),
        ]);
        assert!(!report.is_valid());
        assert_eq!(report.violations().count(), 1);
        assert_eq!(report.unchecked().count(), 2);
        for definite in [
            ErrorKind::Missing,
            ErrorKind::Schema("wrong".to_owned()),
            ErrorKind::Malformed("wrong".to_owned()),
            ErrorKind::Undescribed,
            ErrorKind::UnexpectedMediaType {
                got: None,
                expected: Vec::new(),
            },
        ] {
            assert!(
                !definite.is_unchecked(),
                "{definite} is the request's fault"
            );
        }
        for undecided in [
            ErrorKind::Unchecked(String::new()),
            ErrorKind::Unsupported(String::new()),
            // Nothing was judged, so nothing was found wrong.
            ErrorKind::UnresolvedReference("#/nope".to_owned()),
        ] {
            assert!(undecided.is_unchecked(), "{undecided} judged nothing");
        }
    }

    #[test]
    fn every_error_kind_says_what_it_means() {
        let kinds = [
            (ErrorKind::Missing, "is required and was not sent"),
            (
                ErrorKind::Schema("expected integer".to_owned()),
                "expected integer",
            ),
            (
                ErrorKind::UnexpectedMediaType {
                    got: Some("text/plain".to_owned()),
                    expected: vec!["application/json".to_owned()],
                },
                "media type \"text/plain\" is not one of: application/json",
            ),
            (
                ErrorKind::UnexpectedMediaType {
                    got: None,
                    expected: vec!["application/json".to_owned()],
                },
                "no media type was sent; expected one of: application/json",
            ),
            (
                ErrorKind::Malformed("trailing comma".to_owned()),
                "cannot be read: trailing comma",
            ),
            (
                ErrorKind::Unsupported("multipart bodies".to_owned()),
                "was NOT checked — multipart bodies is not implemented yet",
            ),
            (
                ErrorKind::UnresolvedReference("#/components/schemas/Gone".to_owned()),
                "has an unresolvable `$ref`: #/components/schemas/Gone",
            ),
            (
                ErrorKind::Unchecked("the bound lost its digits".to_owned()),
                "was NOT checked — the bound lost its digits",
            ),
            (ErrorKind::Undescribed, "is not described by this operation"),
        ];
        for (kind, expected) in kinds {
            assert_eq!(kind.to_string(), expected);
        }
    }

    #[test]
    fn a_location_names_itself() {
        for (location, expected) in [
            (Location::Path, "path"),
            (Location::Query, "query"),
            (Location::Querystring, "querystring"),
            (Location::Header, "header"),
            (Location::Cookie, "cookie"),
            (Location::Body, "body"),
            (Location::Description, "description"),
        ] {
            assert_eq!(location.to_string(), expected);
        }
    }

    #[test]
    fn a_routing_error_says_which_path_or_which_methods() {
        assert_eq!(
            RoutingError::PathNotFound {
                path: "/nope".to_owned(),
            }
            .to_string(),
            "no path in the description matches \"/nope\"",
        );
        assert_eq!(
            RoutingError::Unresolved {
                template: "/pets".to_owned(),
                reference: "#/components/pathItems/Gone".to_owned(),
            }
            .to_string(),
            "/pets references #/components/pathItems/Gone, which could not be resolved",
        );
        assert_eq!(
            RoutingError::MethodNotAllowed {
                template: "/pets".to_owned(),
                method: "DELETE".to_owned(),
                allowed: vec!["get".to_owned(), "post".to_owned()],
            }
            .to_string(),
            "/pets describes no DELETE operation (it has: get, post)",
        );
    }
}