1use std::fmt::{self, Display, Formatter};
14
15#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
17#[non_exhaustive]
18pub enum Location {
19 Path,
21 Query,
23 Querystring,
26 Header,
28 Cookie,
30 Body,
32 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#[derive(Clone, Debug, PartialEq, Eq)]
55#[non_exhaustive]
56pub struct ValidationError {
57 pub location: Location,
59 pub name: String,
61 pub pointer: String,
69 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#[derive(Clone, Debug, PartialEq, Eq)]
90#[non_exhaustive]
91pub enum ErrorKind {
92 Missing,
95
96 Schema(String),
98
99 UnexpectedMediaType {
102 got: Option<String>,
104 expected: Vec<String>,
106 },
107
108 Malformed(String),
111
112 Unsupported(String),
116
117 Unchecked(String),
123
124 UnresolvedReference(String),
127
128 Undescribed,
133}
134
135impl ErrorKind {
136 #[must_use]
145 pub fn is_unchecked(&self) -> bool {
146 matches!(
147 self,
148 ErrorKind::Unsupported(_)
149 | ErrorKind::Unchecked(_)
150 | 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#[derive(Clone, Debug, PartialEq, Eq)]
187#[non_exhaustive]
188pub struct ValidationReport {
189 pub template: String,
191 pub method: String,
195 pub operation_id: Option<String>,
197 pub path_parameters: Vec<(String, String)>,
199 pub errors: Vec<ValidationError>,
201}
202
203impl ValidationReport {
204 #[must_use]
210 pub fn is_valid(&self) -> bool {
211 self.errors.is_empty()
212 }
213
214 pub fn violations(&self) -> impl Iterator<Item = &ValidationError> {
216 self.errors
217 .iter()
218 .filter(|error| !error.kind.is_unchecked())
219 }
220
221 pub fn unchecked(&self) -> impl Iterator<Item = &ValidationError> {
228 self.errors.iter().filter(|error| error.kind.is_unchecked())
229 }
230
231 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#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
269#[non_exhaustive]
270pub enum RoutingError {
271 #[error("no path in the description matches {path:?}")]
273 PathNotFound {
274 path: String,
276 },
277
278 #[error("{template} references {reference}, which could not be resolved")]
286 Unresolved {
287 template: String,
289 reference: String,
291 },
292
293 #[error("{template} describes no {method} operation (it has: {})", allowed.join(", "))]
297 MethodNotAllowed {
298 template: String,
300 method: String,
304 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 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}