haematite_cli/serve/
schema.rs1use std::fmt;
8
9use jsonschema::error::ValidationErrorKind;
10use jsonschema::{Keyword, ValidationError, Validator};
11use serde_json::Value;
12
13pub const MAX_SCHEMA_DEPTH: usize = 64;
15
16#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct Violation {
19 pub path: String,
21 pub reason: String,
23 kind: ViolationKind,
24}
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27enum ViolationKind {
28 Schema,
29 JsonText,
30}
31
32impl Violation {
33 #[must_use]
34 pub const fn code(&self) -> &'static str {
35 match self.kind {
36 ViolationKind::Schema => "schema_refused",
37 ViolationKind::JsonText => "json_text_invalid",
38 }
39 }
40}
41
42impl fmt::Display for Violation {
43 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
44 write!(formatter, "{}: {}", self.path, self.reason)
45 }
46}
47
48impl std::error::Error for Violation {}
49
50use self::Collection as CollectionSchema;
51
52#[derive(Debug)]
54pub struct Collection {
55 validator: Validator,
56}
57
58impl CollectionSchema {
59 pub fn compile(schema: &Value) -> Result<Self, Violation> {
66 check_keywords(schema, "", 0)?;
67 jsonschema::draft202012::meta::validate(schema).map_err(|error| violation(&error))?;
68 let validator = jsonschema::options()
69 .with_draft(jsonschema::Draft::Draft202012)
70 .with_keyword("contentMediaType", |_, _, _| Ok(Box::new(JsonText)))
71 .build(schema)
72 .map_err(|error| violation(&error))?;
73 Ok(Self { validator })
74 }
75
76 pub fn validate(&self, document: &Value) -> Result<(), Violation> {
82 self.validator
83 .validate(document)
84 .map_err(|error| violation(&error))
85 }
86}
87
88pub fn validate(shape: Option<&CollectionSchema>, document: &Value) -> Result<(), Violation> {
94 shape.map_or(Ok(()), |shape| shape.validate(document))
95}
96
97struct JsonText;
98
99impl<'i> Keyword<'i> for JsonText {
100 fn validate(&self, instance: &'i Value) -> Result<(), ValidationError<'i>> {
101 let Some(text) = instance.as_str() else {
102 return Ok(());
103 };
104 serde_json::from_str::<Value>(text)
105 .map(|_| ())
106 .map_err(|error| ValidationError::custom(format!("value is not JSON: {error}")))
107 }
108
109 fn is_valid(&self, instance: &'i Value) -> bool {
110 self.validate(instance).is_ok()
111 }
112}
113
114fn violation(error: &ValidationError<'_>) -> Violation {
115 let mut path = error.instance_path().to_string();
116 if let ValidationErrorKind::Required { property } = error.kind()
117 && let Some(name) = property.as_str()
118 {
119 path = pointer(&path, name);
120 }
121 let kind = match error.kind() {
122 ValidationErrorKind::Custom { keyword, .. } if keyword == "contentMediaType" => {
123 ViolationKind::JsonText
124 }
125 _ => ViolationKind::Schema,
126 };
127 Violation {
128 path,
129 reason: error.to_string(),
130 kind,
131 }
132}
133
134fn refusal(path: &str, reason: impl Into<String>) -> Violation {
135 Violation {
136 path: path.to_owned(),
137 reason: reason.into(),
138 kind: ViolationKind::Schema,
139 }
140}
141
142fn pointer(path: &str, segment: &str) -> String {
143 format!("{path}/{}", segment.replace('~', "~0").replace('/', "~1"))
144}
145
146fn check_keywords(schema: &Value, path: &str, depth: usize) -> Result<(), Violation> {
147 if depth > MAX_SCHEMA_DEPTH {
148 return Err(refusal(path, "schema nesting exceeds 64 levels"));
149 }
150 if schema.is_boolean() {
151 return Ok(());
152 }
153 let fields = schema
154 .as_object()
155 .ok_or_else(|| refusal(path, "schema must be an object or boolean"))?;
156 for (keyword, value) in fields {
157 let at = pointer(path, keyword);
158 match keyword.as_str() {
159 "type" | "required" | "enum" | "const" | "minimum" | "maximum" | "title"
160 | "description" | "default" | "examples" | "$comment" => {}
161 "contentMediaType" => {
162 if value.as_str() != Some("application/json") {
163 return Err(refusal(
164 &at,
165 "unsupported contentMediaType: only application/json is supported",
166 ));
167 }
168 if fields.get("type").and_then(Value::as_str) != Some("string") {
169 return Err(refusal(
170 &at,
171 "contentMediaType is supported only beside type string",
172 ));
173 }
174 }
175 "$schema" => {
176 if value.as_str() != Some("https://json-schema.org/draft/2020-12/schema") {
177 return Err(refusal(
178 &at,
179 "only the declared draft 2020-12 subset is supported",
180 ));
181 }
182 }
183 "properties" => {
184 let properties = value
185 .as_object()
186 .ok_or_else(|| refusal(&at, "properties must be an object"))?;
187 for (name, child) in properties {
188 check_keywords(child, &pointer(&at, name), depth + 1)?;
189 }
190 }
191 "items" | "additionalProperties" => check_keywords(value, &at, depth + 1)?,
192 _ => {
193 return Err(refusal(
194 &at,
195 format!("unsupported schema keyword {keyword}"),
196 ));
197 }
198 }
199 }
200 Ok(())
201}
202
203#[cfg(test)]
204#[path = "schema_json_text_tests.rs"]
205mod tests;