use enumset::{EnumSet, EnumSetType, enum_set};
use lazy_regex::regex;
use std::collections::HashSet;
use std::fmt::{self, Display};
use thiserror::Error as ThisError;
use crate::loader::Loader;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct ValidationError {
pub path: String,
pub message: String,
}
impl ValidationError {
pub(crate) fn new(path: String, message: String) -> Self {
Self { path, message }
}
pub fn contains(&self, needle: &str) -> bool {
if self.path.contains(needle) || self.message.contains(needle) {
return true;
}
self.to_string().contains(needle)
}
}
impl PartialEq<str> for ValidationError {
fn eq(&self, other: &str) -> bool {
let (sep, tail) = if let Some(rest) = self.message.strip_prefix('.') {
(".", rest)
} else {
(": ", self.message.as_str())
};
let plen = self.path.len();
let slen = sep.len();
other.len() == plen + slen + tail.len()
&& other.starts_with(&self.path)
&& other[plen..].starts_with(sep)
&& other[plen + slen..] == *tail
}
}
impl PartialEq<&str> for ValidationError {
fn eq(&self, other: &&str) -> bool {
<ValidationError as PartialEq<str>>::eq(self, other)
}
}
impl PartialEq<String> for ValidationError {
fn eq(&self, other: &String) -> bool {
<ValidationError as PartialEq<str>>::eq(self, other.as_str())
}
}
impl Display for ValidationError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
if let Some(rest) = self.message.strip_prefix('.') {
write!(f, "{}.{}", self.path, rest)
} else {
write!(f, "{}: {}", self.path, self.message)
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct Error {
pub errors: Vec<ValidationError>,
}
pub trait ValidationErrorsExt {
fn mentions(&self, needle: &str) -> bool;
fn mentions_all(&self, needles: &[&str]) -> bool;
fn has_exact(&self, expected: &str) -> bool;
}
impl ValidationErrorsExt for [ValidationError] {
fn mentions(&self, needle: &str) -> bool {
self.iter().any(|e| e.contains(needle))
}
fn mentions_all(&self, needles: &[&str]) -> bool {
self.iter().any(|e| needles.iter().all(|n| e.contains(n)))
}
fn has_exact(&self, expected: &str) -> bool {
self.iter()
.any(|e| <ValidationError as PartialEq<str>>::eq(e, expected))
}
}
impl ValidationErrorsExt for Vec<ValidationError> {
fn mentions(&self, needle: &str) -> bool {
self.as_slice().mentions(needle)
}
fn mentions_all(&self, needles: &[&str]) -> bool {
self.as_slice().mentions_all(needles)
}
fn has_exact(&self, expected: &str) -> bool {
self.as_slice().has_exact(expected)
}
}
impl ValidationErrorsExt for Error {
fn mentions(&self, needle: &str) -> bool {
self.errors.mentions(needle)
}
fn mentions_all(&self, needles: &[&str]) -> bool {
self.errors.mentions_all(needles)
}
fn has_exact(&self, expected: &str) -> bool {
self.errors.has_exact(expected)
}
}
impl Display for Error {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
writeln!(f, "{} errors found:", self.errors.len())?;
for error in &self.errors {
writeln!(f, "- {error}")?;
}
Ok(())
}
}
#[derive(EnumSetType, Debug)]
pub enum Options {
IgnoreMissingTags,
IgnoreExternalReferences,
IgnoreInvalidUrls,
IgnoreNonUniqOperationIDs,
IgnoreUnusedPathItems,
IgnoreUnusedTags,
IgnoreUnusedSchemas,
IgnoreUnusedParameters,
IgnoreUnusedResponses,
IgnoreUnusedServerVariables,
IgnoreUnusedExamples,
IgnoreUnusedRequestBodies,
IgnoreUnusedHeaders,
IgnoreUnusedSecuritySchemes,
IgnoreUnusedLinks,
IgnoreUnusedCallbacks,
IgnoreUnusedMediaTypes,
IgnoreEmptyInfoTitle,
IgnoreEmptyInfoVersion,
IgnoreEmptyResponseDescription,
IgnoreEmptyExternalDocumentationUrl,
}
pub const IGNORE_UNUSED: EnumSet<Options> = enum_set!(
Options::IgnoreUnusedTags
| Options::IgnoreUnusedSchemas
| Options::IgnoreUnusedParameters
| Options::IgnoreUnusedResponses
| Options::IgnoreUnusedServerVariables
| Options::IgnoreUnusedExamples
| Options::IgnoreUnusedRequestBodies
| Options::IgnoreUnusedHeaders
| Options::IgnoreUnusedSecuritySchemes
| Options::IgnoreUnusedLinks
| Options::IgnoreUnusedCallbacks
| Options::IgnoreUnusedMediaTypes
);
pub const IGNORE_EMPTY_REQUIRED_FIELDS: EnumSet<Options> = enum_set!(
Options::IgnoreEmptyInfoTitle
| Options::IgnoreEmptyInfoVersion
| Options::IgnoreEmptyResponseDescription
| Options::IgnoreEmptyExternalDocumentationUrl
);
impl Options {
pub fn new() -> EnumSet<Options> {
EnumSet::empty() | Options::IgnoreUnusedPathItems
}
pub fn empty() -> EnumSet<Options> {
EnumSet::empty()
}
pub fn only(&self) -> EnumSet<Options> {
EnumSet::only(*self)
}
}
pub trait Validate {
fn validate(&self, options: EnumSet<Options>, loader: Option<&mut Loader>)
-> Result<(), Error>;
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, ThisError)]
#[error("component name {name:?} must match pattern `^[a-zA-Z0-9.\\-_]+$`")]
pub struct InvalidComponentName {
pub name: String,
}
pub fn check_component_name(name: &str) -> Result<(), InvalidComponentName> {
let r = regex!(r"^[a-zA-Z0-9.\-_]+$");
if r.is_match(name) {
Ok(())
} else {
Err(InvalidComponentName {
name: name.to_owned(),
})
}
}
pub(crate) trait ValidateWithContext<T> {
fn validate_with_context(&self, ctx: &mut Context<T>, path: String);
}
pub(crate) struct Context<'a, T> {
pub spec: &'a T,
pub loader: Option<&'a mut Loader>,
pub visited: HashSet<String>,
pub errors: Vec<ValidationError>,
pub options: EnumSet<Options>,
}
pub(crate) trait PushError<T> {
fn error(&mut self, path: String, args: T);
}
impl<T> PushError<&str> for Context<'_, T> {
fn error(&mut self, path: String, msg: &str) {
self.errors.push(make_validation_error_from_str(path, msg));
}
}
impl<T> PushError<String> for Context<'_, T> {
fn error(&mut self, path: String, msg: String) {
self.errors
.push(make_validation_error_from_string(path, msg));
}
}
impl<T> PushError<fmt::Arguments<'_>> for Context<'_, T> {
fn error(&mut self, path: String, args: fmt::Arguments<'_>) {
self.errors
.push(make_validation_error_from_string(path, args.to_string()));
}
}
fn make_validation_error_from_string(mut path: String, msg: String) -> ValidationError {
if let Some((segment, tail)) = split_leading_dot(&msg) {
path.push_str(segment);
return ValidationError::new(path, tail.to_owned());
}
ValidationError::new(path, msg)
}
fn make_validation_error_from_str(mut path: String, msg: &str) -> ValidationError {
if let Some((segment, tail)) = split_leading_dot(msg) {
path.push_str(segment);
return ValidationError::new(path, tail.to_owned());
}
ValidationError::new(path, msg.to_owned())
}
fn split_leading_dot(msg: &str) -> Option<(&str, &str)> {
if msg.starts_with('.')
&& let Some(sep_at) = msg[1..].find(": ")
{
let segment_end = 1 + sep_at;
let tail_start = segment_end + 2;
return Some((&msg[..segment_end], &msg[tail_start..]));
}
None
}
impl<T> Context<'_, T> {
pub fn visit(&mut self, path: String) -> bool {
self.visited.insert(path)
}
pub fn is_visited(&self, path: &str) -> bool {
self.visited.contains(path)
}
pub fn is_option(&self, option: Options) -> bool {
self.options.contains(option)
}
}
impl Context<'_, ()> {
pub fn new<'a, T>(spec: &'a T, options: EnumSet<Options>) -> Context<'a, T> {
Context {
spec,
loader: None,
visited: HashSet::new(),
errors: Vec::new(),
options,
}
}
}
impl<'a, T> Context<'a, T> {
pub fn with_loader(mut self, loader: &'a mut Loader) -> Self {
self.loader = Some(loader);
self
}
}
impl<T: fmt::Debug> fmt::Debug for Context<'_, T> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Context")
.field("spec", &self.spec)
.field(
"loader",
&if self.loader.is_some() {
"Some(<loader>)"
} else {
"None"
},
)
.field("visited", &self.visited)
.field("errors", &self.errors)
.field("options", &self.options)
.finish()
}
}
impl<'a, T> From<Context<'a, T>> for Result<(), Error> {
fn from(val: Context<'a, T>) -> Self {
if val.errors.is_empty() {
Ok(())
} else {
Err(Error { errors: val.errors })
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn push_error_splits_leading_dot_message_into_path_extension() {
let mut ctx: Context<()> = Context::new(&(), Options::new());
ctx.error("#.x".into(), ".allOf: must not be empty");
assert_eq!(ctx.errors.len(), 1);
let e = &ctx.errors[0];
assert_eq!(e.path, "#.x.allOf");
assert_eq!(e.message, "must not be empty");
assert_eq!(e.to_string(), "#.x.allOf: must not be empty");
}
#[test]
fn push_error_keeps_message_verbatim_without_leading_dot() {
let mut ctx: Context<()> = Context::new(&(), Options::new());
ctx.error("#.x".into(), "must not be empty");
let e = &ctx.errors[0];
assert_eq!(e.path, "#.x");
assert_eq!(e.message, "must not be empty");
}
#[test]
fn validation_error_contains_matches_across_boundary() {
let e = ValidationError::new("#.x.items".into(), "is required".into());
assert!(e.contains("items: is required"));
assert!(e.contains("#.x"));
assert!(e.contains("is required"));
assert!(!e.contains("not in either"));
}
#[test]
fn validation_error_partial_eq_against_str_string_and_ref_str_terminates() {
let e = ValidationError::new("#.info.title".into(), "must not be empty".into());
let literal: &str = "#.info.title: must not be empty";
let owned: String = literal.to_owned();
assert!(e == *literal);
assert!(e == literal);
assert!(e == owned);
assert!(e != "different");
let e = ValidationError::new("#.x".into(), ".foo: bad".into());
assert!(e == "#.x.foo: bad");
}
#[test]
fn error_display_formats_with_count_and_bulleted_messages() {
let err = Error {
errors: vec![
ValidationError::new("#.a".into(), "first".into()),
ValidationError::new("#.b".into(), "second".into()),
],
};
assert_eq!(
format!("{err}"),
"2 errors found:\n- #.a: first\n- #.b: second\n"
);
}
#[test]
fn error_display_zero_errors_still_renders_header() {
let err = Error { errors: vec![] };
assert_eq!(format!("{err}"), "0 errors found:\n");
}
#[test]
fn check_component_name_accepts_pattern_and_rejects_others() {
assert!(check_component_name("Foo.Bar-1_2").is_ok());
let err = check_component_name("has space").unwrap_err();
assert_eq!(err.name, "has space");
assert!(err.to_string().contains("has space"));
assert!(err.to_string().contains("a-zA-Z0-9.\\-_"));
}
#[test]
fn context_with_loader_attaches_loader() {
let mut loader = Loader::new();
let ctx = Context::new(&(), Options::new()).with_loader(&mut loader);
assert!(ctx.loader.is_some());
}
#[test]
fn context_debug_marks_loader_presence_without_printing_it() {
let ctx: Context<()> = Context::new(&(), Options::new());
let s = format!("{ctx:?}");
assert!(s.contains("Context"), "debug includes type name: {s}");
assert!(
s.contains("None"),
"no-loader Context debug must say `None`: {s}"
);
let mut loader = Loader::new();
let ctx = Context::new(&(), Options::new()).with_loader(&mut loader);
let s = format!("{ctx:?}");
assert!(
s.contains("Some(<loader>)"),
"attached-loader Context debug must mark presence: {s}"
);
}
#[test]
fn context_from_returns_ok_when_empty_err_when_not() {
let ctx: Context<()> = Context::new(&(), Options::new());
let r: Result<(), Error> = ctx.into();
assert!(r.is_ok());
let mut ctx: Context<()> = Context::new(&(), Options::new());
ctx.error("#".into(), "kaboom");
let r: Result<(), Error> = ctx.into();
let err = r.unwrap_err();
assert!(err.has_exact("#: kaboom"));
}
#[test]
fn push_error_routes_dot_prefixed_messages_without_separator() {
let mut ctx: Context<()> = Context::new(&(), Options::new());
ctx.error("#.foo".into(), ".bar: must not be empty");
ctx.error("#.baz".into(), "must match pattern");
assert_eq!(
ctx.errors,
vec![
"#.foo.bar: must not be empty".to_string(),
"#.baz: must match pattern".to_string(),
]
);
}
#[test]
fn push_error_accepts_string_and_format_args() {
let mut ctx: Context<()> = Context::new(&(), Options::new());
ctx.error("#.a".into(), String::from("from string"));
ctx.error("#.b".into(), format_args!("from {} args", "format"));
assert_eq!(
ctx.errors,
vec![
"#.a: from string".to_string(),
"#.b: from format args".to_string(),
]
);
}
}