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,
}
#[cfg(feature = "clap")]
impl clap::ValueEnum for Options {
fn value_variants<'a>() -> &'a [Self] {
&[
Options::IgnoreMissingTags,
Options::IgnoreExternalReferences,
Options::IgnoreInvalidUrls,
Options::IgnoreNonUniqOperationIDs,
Options::IgnoreUnusedPathItems,
Options::IgnoreUnusedTags,
Options::IgnoreUnusedSchemas,
Options::IgnoreUnusedParameters,
Options::IgnoreUnusedResponses,
Options::IgnoreUnusedServerVariables,
Options::IgnoreUnusedExamples,
Options::IgnoreUnusedRequestBodies,
Options::IgnoreUnusedHeaders,
Options::IgnoreUnusedSecuritySchemes,
Options::IgnoreUnusedLinks,
Options::IgnoreUnusedCallbacks,
Options::IgnoreUnusedMediaTypes,
Options::IgnoreEmptyInfoTitle,
Options::IgnoreEmptyInfoVersion,
Options::IgnoreEmptyResponseDescription,
Options::IgnoreEmptyExternalDocumentationUrl,
]
}
fn to_possible_value(&self) -> Option<clap::builder::PossibleValue> {
let (name, help) = match self {
Options::IgnoreMissingTags => (
"missing-tags",
"Skip the `tag referenced but not declared` check (v2.0, v3.0, v3.1, v3.2)",
),
Options::IgnoreExternalReferences => (
"external-references",
"Don't error on external `$ref`s (v2.0, v3.0, v3.1, v3.2)",
),
Options::IgnoreInvalidUrls => (
"invalid-urls",
"Skip URL syntax validation (v2.0, v3.0, v3.1, v3.2)",
),
Options::IgnoreNonUniqOperationIDs => (
"non-uniq-operation-ids",
"Allow duplicate `operationId` values (v2.0, v3.0, v3.1, v3.2)",
),
Options::IgnoreUnusedPathItems => (
"unused-path-items",
"Allow declared-but-unreferenced path items (v3.1, v3.2)",
),
Options::IgnoreUnusedTags => (
"unused-tags",
"Skip the `tag declared but not used` check (v2.0, v3.0, v3.1, v3.2)",
),
Options::IgnoreUnusedSchemas => (
"unused-schemas",
"Allow unused schemas / definitions (v2.0, v3.0, v3.1, v3.2)",
),
Options::IgnoreUnusedParameters => (
"unused-parameters",
"Allow unused components / parameters (v2.0, v3.0, v3.1, v3.2)",
),
Options::IgnoreUnusedResponses => (
"unused-responses",
"Allow unused components / responses (v2.0, v3.0, v3.1, v3.2)",
),
Options::IgnoreUnusedServerVariables => (
"unused-server-variables",
"Allow unused server variables (v3.0, v3.1, v3.2)",
),
Options::IgnoreUnusedExamples => (
"unused-examples",
"Allow unused components / examples (v3.0, v3.1, v3.2)",
),
Options::IgnoreUnusedRequestBodies => (
"unused-request-bodies",
"Allow unused components / request bodies (v3.0, v3.1, v3.2)",
),
Options::IgnoreUnusedHeaders => (
"unused-headers",
"Allow unused components / headers (v3.0, v3.1, v3.2)",
),
Options::IgnoreUnusedSecuritySchemes => (
"unused-security-schemes",
"Allow unused components / security schemes (v2.0, v3.0, v3.1, v3.2)",
),
Options::IgnoreUnusedLinks => (
"unused-links",
"Allow unused components / links (v3.0, v3.1, v3.2)",
),
Options::IgnoreUnusedCallbacks => (
"unused-callbacks",
"Allow unused components / callbacks (v3.0, v3.1, v3.2)",
),
Options::IgnoreUnusedMediaTypes => (
"unused-media-types",
"Allow unused components / media types (v3.2)",
),
Options::IgnoreEmptyInfoTitle => (
"empty-info-title",
"Allow empty `info.title` (v2.0, v3.0, v3.1, v3.2)",
),
Options::IgnoreEmptyInfoVersion => (
"empty-info-version",
"Allow empty `info.version` (v2.0, v3.0, v3.1, v3.2)",
),
Options::IgnoreEmptyResponseDescription => (
"empty-response-description",
"Allow empty response `description` (v2.0, v3.0, v3.1, v3.2)",
),
Options::IgnoreEmptyExternalDocumentationUrl => (
"empty-external-documentation-url",
"Allow empty `externalDocs.url` (v2.0, v3.0, v3.1, v3.2)",
),
};
Some(clap::builder::PossibleValue::new(name).help(help))
}
}
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(all(test, feature = "clap"))]
mod clap_value_enum_tests {
use super::*;
use clap::ValueEnum;
#[test]
fn value_variants_covers_every_options_variant_exactly_once() {
let variants = <Options as ValueEnum>::value_variants();
let listed: EnumSet<Options> = variants.iter().copied().collect();
assert_eq!(listed.len(), variants.len(), "duplicate variant listed");
assert_eq!(listed, EnumSet::<Options>::all());
}
#[test]
fn possible_value_names_are_unique_and_kebab_case() {
let mut seen: Vec<String> = Vec::new();
for variant in <Options as ValueEnum>::value_variants() {
let pv = variant
.to_possible_value()
.expect("every variant must have a possible value");
let name = pv.get_name().to_string();
assert!(
name.bytes().all(|b| b.is_ascii_lowercase() || b == b'-'),
"name `{name}` is not kebab-case",
);
assert!(!seen.contains(&name), "duplicate name `{name}`");
seen.push(name);
}
}
#[test]
fn from_str_round_trips_for_every_variant() {
for variant in <Options as ValueEnum>::value_variants() {
let pv = variant.to_possible_value().unwrap();
let name = pv.get_name();
let parsed = <Options as ValueEnum>::from_str(name, false)
.expect("name must parse back to a variant");
assert_eq!(parsed, *variant);
}
}
}
#[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(),
]
);
}
}