use std::fmt;
use jsonschema::error::ValidationErrorKind;
use jsonschema::{Keyword, ValidationError, Validator};
use serde_json::Value;
pub const MAX_SCHEMA_DEPTH: usize = 64;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Violation {
pub path: String,
pub reason: String,
kind: ViolationKind,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ViolationKind {
Schema,
JsonText,
}
impl Violation {
#[must_use]
pub const fn code(&self) -> &'static str {
match self.kind {
ViolationKind::Schema => "schema_refused",
ViolationKind::JsonText => "json_text_invalid",
}
}
}
impl fmt::Display for Violation {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(formatter, "{}: {}", self.path, self.reason)
}
}
impl std::error::Error for Violation {}
use self::Collection as CollectionSchema;
#[derive(Debug)]
pub struct Collection {
validator: Validator,
}
impl CollectionSchema {
pub fn compile(schema: &Value) -> Result<Self, Violation> {
check_keywords(schema, "", 0)?;
jsonschema::draft202012::meta::validate(schema).map_err(|error| violation(&error))?;
let validator = jsonschema::options()
.with_draft(jsonschema::Draft::Draft202012)
.with_keyword("contentMediaType", |_, _, _| Ok(Box::new(JsonText)))
.build(schema)
.map_err(|error| violation(&error))?;
Ok(Self { validator })
}
pub fn validate(&self, document: &Value) -> Result<(), Violation> {
self.validator
.validate(document)
.map_err(|error| violation(&error))
}
}
pub fn validate(shape: Option<&CollectionSchema>, document: &Value) -> Result<(), Violation> {
shape.map_or(Ok(()), |shape| shape.validate(document))
}
struct JsonText;
impl<'i> Keyword<'i> for JsonText {
fn validate(&self, instance: &'i Value) -> Result<(), ValidationError<'i>> {
let Some(text) = instance.as_str() else {
return Ok(());
};
serde_json::from_str::<Value>(text)
.map(|_| ())
.map_err(|error| ValidationError::custom(format!("value is not JSON: {error}")))
}
fn is_valid(&self, instance: &'i Value) -> bool {
self.validate(instance).is_ok()
}
}
fn violation(error: &ValidationError<'_>) -> Violation {
let mut path = error.instance_path().to_string();
if let ValidationErrorKind::Required { property } = error.kind()
&& let Some(name) = property.as_str()
{
path = pointer(&path, name);
}
let kind = match error.kind() {
ValidationErrorKind::Custom { keyword, .. } if keyword == "contentMediaType" => {
ViolationKind::JsonText
}
_ => ViolationKind::Schema,
};
Violation {
path,
reason: error.to_string(),
kind,
}
}
fn refusal(path: &str, reason: impl Into<String>) -> Violation {
Violation {
path: path.to_owned(),
reason: reason.into(),
kind: ViolationKind::Schema,
}
}
fn pointer(path: &str, segment: &str) -> String {
format!("{path}/{}", segment.replace('~', "~0").replace('/', "~1"))
}
fn check_keywords(schema: &Value, path: &str, depth: usize) -> Result<(), Violation> {
if depth > MAX_SCHEMA_DEPTH {
return Err(refusal(path, "schema nesting exceeds 64 levels"));
}
if schema.is_boolean() {
return Ok(());
}
let fields = schema
.as_object()
.ok_or_else(|| refusal(path, "schema must be an object or boolean"))?;
for (keyword, value) in fields {
let at = pointer(path, keyword);
match keyword.as_str() {
"type" | "required" | "enum" | "const" | "minimum" | "maximum" | "title"
| "description" | "default" | "examples" | "$comment" => {}
"contentMediaType" => {
if value.as_str() != Some("application/json") {
return Err(refusal(
&at,
"unsupported contentMediaType: only application/json is supported",
));
}
if fields.get("type").and_then(Value::as_str) != Some("string") {
return Err(refusal(
&at,
"contentMediaType is supported only beside type string",
));
}
}
"$schema" => {
if value.as_str() != Some("https://json-schema.org/draft/2020-12/schema") {
return Err(refusal(
&at,
"only the declared draft 2020-12 subset is supported",
));
}
}
"properties" => {
let properties = value
.as_object()
.ok_or_else(|| refusal(&at, "properties must be an object"))?;
for (name, child) in properties {
check_keywords(child, &pointer(&at, name), depth + 1)?;
}
}
"items" | "additionalProperties" => check_keywords(value, &at, depth + 1)?,
_ => {
return Err(refusal(
&at,
format!("unsupported schema keyword {keyword}"),
));
}
}
}
Ok(())
}
#[cfg(test)]
#[path = "schema_json_text_tests.rs"]
mod tests;