1use std::collections::{HashMap, HashSet};
2use std::sync::{Arc, Mutex, OnceLock};
3
4use jsonschema::{Draft, Retrieve, Uri, Validator};
5use serde::Serialize;
6use serde_json::{json, Value};
7
8use crate::error::{Error, Result};
9use crate::json::{canonical_value, parse_strict};
10use crate::schema_cache::SchemaCache;
11use crate::style::Style;
12
13pub const CORE_SCHEMA_URL: &str =
15 "https://mapseekai.github.io/raster-style/spec/v3/raster-style-v3.schema.json";
16pub const MOSAIC_SCHEMA_URL: &str =
18 "https://mapseekai.github.io/raster-style/extensions/raster/mosaic/v1.0.0/schema.json";
19pub const TILE_SCHEMA_URL: &str =
21 "https://mapseekai.github.io/raster-style/extensions/raster/tile/v1.0.0/schema.json";
22const DRAFT: &str = "https://json-schema.org/draft/2020-12/schema";
23const CORE: &str = include_str!("../assets/raster-style-v3.schema.json");
24
25#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
27#[serde(rename_all = "camelCase")]
28pub struct ValidationReport {
29 pub complete: bool,
31 pub unresolved_schemas: Vec<String>,
33}
34
35pub struct SchemaRegistry {
53 resources: Arc<HashMap<String, Value>>,
54 validators: Mutex<SchemaCache>,
55 core: Validator,
56 core_fields: HashSet<String>,
57}
58
59impl SchemaRegistry {
60 pub fn new() -> Result<Self> {
65 let core: Value = serde_json::from_str(CORE)
66 .map_err(|error| Error::new("E_CONFIG", error.to_string()))?;
67 let validator = jsonschema::options()
68 .with_draft(Draft::Draft202012)
69 .with_keyword("multipleOf", crate::schema_number::multiple_of)
70 .with_keyword("pattern", crate::schema_pattern::pattern)
71 .with_retriever(OfflineRetriever::default())
72 .build(&core)
73 .map_err(|error| Error::new("E_CONFIG", error.to_string()))?;
74 let mut registry = Self {
75 core_fields: core["properties"]
76 .as_object()
77 .expect("embedded schema properties")
78 .keys()
79 .cloned()
80 .collect(),
81 resources: Arc::new(HashMap::new()),
82 validators: Mutex::new(SchemaCache::default()),
83 core: validator,
84 };
85 for text in [
86 CORE,
87 include_str!("../assets/mosaic-v1.0.0.schema.json"),
88 include_str!("../assets/tile-v1.0.0.schema.json"),
89 ] {
90 registry.register(parse_strict(text)?)?;
91 }
92 Ok(registry)
93 }
94
95 pub fn register(&mut self, schema: Value) -> Result<()> {
102 let schema = parse_strict(&canonical_value(&schema)?)?;
103 let id = schema["$id"]
104 .as_str()
105 .filter(|id| schema_url(id))
106 .ok_or_else(|| {
107 Error::new(
108 "E_CONFIG",
109 "Schema $id must be an absolute HTTP(S) URL without a fragment",
110 )
111 })?;
112 if self.resources.contains_key(id) {
113 return Err(Error::new("E_CONFIG", "Schema identity is already registered").at(id));
114 }
115 if schema.get("$schema").is_some_and(|draft| draft != DRAFT) {
116 return Err(Error::new(
117 "E_CONFIG",
118 "Extension composition requires JSON Schema draft 2020-12",
119 )
120 .at(id));
121 }
122 if !jsonschema::draft202012::meta::is_valid(&schema) {
123 return Err(Error::new("E_CONFIG", "Invalid JSON Schema resource").at(id));
124 }
125 self.validators
126 .get_mut()
127 .map_err(|_| Error::new("E_CONFIG", "Schema cache lock is poisoned"))?
128 .clear();
129 Arc::make_mut(&mut self.resources).insert(id.to_owned(), schema);
130 Ok(())
131 }
132
133 pub fn resolve(
139 &mut self,
140 url: &str,
141 resolver: impl FnOnce(&str) -> Result<Value>,
142 ) -> Result<()> {
143 if !schema_url(url) {
144 return Err(Error::new("E_CONFIG", "Invalid schema URL").at(url));
145 }
146 let schema = resolver(url)?;
147 if schema["$id"] != url {
148 return Err(
149 Error::new("E_CONFIG", "Resolved schema $id does not match the request").at(url),
150 );
151 }
152 self.register(schema)
153 }
154
155 pub fn parse_json(&self, text: &str) -> Result<Style> {
162 Style::from_value_in_registry(parse_strict(text)?, self)
163 }
164
165 pub fn inspect_json(&self, text: &str) -> Result<ValidationReport> {
170 Ok(self.parse_json(text)?.validation().clone())
171 }
172
173 pub fn validate_json(&self, text: &str) -> Result<()> {
180 let report = self.inspect_json(text)?;
181 if !report.complete {
182 return Err(Error::new(
183 "E_SCHEMA_UNRESOLVED",
184 "Complete validation requires every declared schema",
185 )
186 .at(report.unresolved_schemas.join(", ")));
187 }
188 Ok(())
189 }
190
191 pub(crate) fn is_core_field(&self, key: &str) -> bool {
192 self.core_fields.contains(key)
193 }
194
195 pub(crate) fn check(&self, value: &Value) -> Result<ValidationReport> {
196 if !crate::schema_pattern::is_valid(&self.core, value)? {
197 return Err(Error::new(
198 "E_SCHEMA",
199 "Raster style does not match the core schema",
200 ));
201 }
202 let mut known = Vec::new();
203 let mut report = ValidationReport {
204 complete: true,
205 unresolved_schemas: Vec::new(),
206 };
207 if let Some(declarations) = value["extensions"].as_array() {
208 for declaration in declarations {
209 let url = declaration.as_str().expect("core-validated schema URL");
210 if self.resources.contains_key(url) {
211 known.push(url);
212 } else {
213 report.complete = false;
214 report.unresolved_schemas.push(url.to_owned());
215 }
216 }
217 }
218 known.sort_unstable();
219 let key = canonical_value(&json!([known, report.complete]))?;
220 let slot = self
221 .validators
222 .lock()
223 .map_err(|_| Error::new("E_CONFIG", "Schema cache lock is poisoned"))?
224 .slot(key);
225 let schema = slot
226 .get_or_init(|| {
227 let mut refs = vec![json!({"$ref":CORE_SCHEMA_URL})];
229 refs.extend(known.iter().map(|url| json!({"$ref":url})));
230 let mut composition = json!({"$schema":DRAFT,"allOf":refs});
231 if report.complete {
232 composition["unevaluatedProperties"] = json!(false);
233 }
234 let retriever = OfflineRetriever {
235 resources: Arc::clone(&self.resources),
236 ..OfflineRetriever::default()
237 };
238 jsonschema::options()
239 .with_draft(Draft::Draft202012)
240 .with_keyword("multipleOf", crate::schema_number::multiple_of)
241 .with_keyword("pattern", crate::schema_pattern::pattern)
242 .with_retriever(retriever.clone())
243 .build(&composition)
244 .map(Arc::new)
245 .map_err(|error| retriever.classify(error.to_string()))
246 })
247 .as_ref()
248 .map_err(Clone::clone)?;
249 if !crate::schema_pattern::is_valid(schema, value)? {
250 return Err(Error::new(
251 "E_SCHEMA",
252 "Raster style does not match the composed schema",
253 ));
254 }
255 Ok(report)
256 }
257}
258
259pub fn validate_json(text: &str) -> Result<()> {
264 default_schemas()?.validate_json(text)
265}
266
267pub(crate) fn has_extension(style: &Value, url: &str) -> bool {
268 style["extensions"]
269 .as_array()
270 .is_some_and(|items| items.iter().any(|item| item == url))
271}
272
273fn schema_url(url: &str) -> bool {
274 (url.starts_with("https://") || url.starts_with("http://"))
275 && !url.chars().any(|c| c.is_whitespace() || c == '#')
276 && url
277 .split_once("://")
278 .is_some_and(|(_, rest)| !rest.is_empty())
279}
280
281#[derive(Clone, Default)]
282struct OfflineRetriever {
283 resources: Arc<HashMap<String, Value>>,
284 missing: Arc<Mutex<Option<String>>>,
285}
286impl OfflineRetriever {
287 fn classify(&self, message: String) -> Error {
288 match self.missing.lock().ok().and_then(|missing| missing.clone()) {
289 Some(url) => Error::new(
290 "E_SCHEMA_UNRESOLVED",
291 "A schema reference has not been registered",
292 )
293 .at(url),
294 None => Error::new(
295 "E_CONFIG",
296 format!("Schema composition could not be compiled: {message}"),
297 ),
298 }
299 }
300}
301impl Retrieve for OfflineRetriever {
302 fn retrieve(
303 &self,
304 uri: &Uri<String>,
305 ) -> std::result::Result<Value, Box<dyn std::error::Error + Send + Sync>> {
306 if let Some(schema) = self.resources.get(uri.as_str()) {
307 return Ok(schema.clone());
308 }
309 if let Ok(mut missing) = self.missing.lock() {
310 *missing = Some(uri.to_string());
311 }
312 Err(format!("Schema resource has not been registered: {uri}").into())
313 }
314}
315
316static DEFAULT_SCHEMAS: OnceLock<Result<SchemaRegistry>> = OnceLock::new();
317pub(crate) fn default_schemas() -> Result<&'static SchemaRegistry> {
318 DEFAULT_SCHEMAS
319 .get_or_init(SchemaRegistry::new)
320 .as_ref()
321 .map_err(|error| Error::new("E_CONFIG", error.to_string()))
322}
323
324#[cfg(test)]
325mod tests {
326 use super::*;
327 use crate::{decode_query, encode_query};
328
329 fn fixtures() -> Value {
330 serde_json::from_str(include_str!("../tests/data/schema-cases.json")).expect("fixture JSON")
331 }
332
333 #[test]
334 fn regex_execution_errors_cannot_satisfy_schema_combinations() {
335 let pattern = json!({"pattern": "^(?:(a|aa)+(?=b)|a+)$"});
336 for constraint in [
337 pattern.clone(),
338 json!({"not": pattern}),
339 json!({"anyOf": [pattern, true]}),
340 json!({"if": pattern, "then": true, "else": true}),
341 json!({"contains": pattern}),
342 ] {
343 let mut registry = SchemaRegistry::new().unwrap();
344 let url = "https://example.test/regex-budget/v1.0.0/schema.json";
345 registry
346 .register(json!({"$id":url,"properties":{"vendor":constraint}}))
347 .unwrap();
348 let value = if constraint.get("contains").is_some() {
349 json!(["a".repeat(28)])
350 } else {
351 json!("a".repeat(28))
352 };
353 let style = json!({"version":"3.0","renderer":{"type":"gray","bidx":[1]},"extensions":[url],"vendor":value});
354 assert_eq!(
355 registry.validate_json(&style.to_string()).unwrap_err().code,
356 "E_LIMIT",
357 "{constraint}"
358 );
359 registry
361 .validate_json(r#"{"version":"3.0","renderer":{"type":"gray","bidx":[1]}}"#)
362 .unwrap();
363 }
364 }
365
366 #[test]
367 fn schema_cases() {
368 let fixtures = fixtures();
369 for case in fixtures["cases"].as_array().unwrap() {
370 let run = || -> Result<Value> {
371 let mut registry = SchemaRegistry::new()?;
372 for name in case["schemas"].as_array().unwrap() {
373 registry.register(fixtures["resources"][name.as_str().unwrap()].clone())?;
374 }
375 let text = canonical_value(&case["style"])?;
376 let style = registry.parse_json(&text)?;
377 match case["op"].as_str().unwrap() {
378 "validate" => registry.validate_json(&text)?,
379 "support" => {
380 let supported: Vec<&str> = case["supported"]
381 .as_array()
382 .map(|values| values.iter().map(|v| v.as_str().unwrap()).collect())
383 .unwrap_or_default();
384 style.require_extension_support(&supported)?;
385 }
386 _ => {}
387 }
388 Ok(json!(style.validation()))
389 };
390 let result = run();
391 if let Some(code) = case["code"].as_str() {
392 assert_eq!(result.unwrap_err().code, code, "{}", case["name"]);
393 } else {
394 assert_eq!(result.unwrap(), case["expected"], "{}", case["name"]);
395 }
396 }
397 }
398
399 #[test]
400 fn explicit_resolution_and_identity() {
401 let fixtures = fixtures();
402 let case = fixtures["cases"]
403 .as_array()
404 .unwrap()
405 .iter()
406 .find(|c| c["name"] == "schema-defines-unprefixed-and-multiple-fields")
407 .unwrap();
408 let text = canonical_value(&case["style"]).unwrap();
409 let mut registry = SchemaRegistry::new().unwrap();
410 assert!(!registry.inspect_json(&text).unwrap().complete);
411 let url = fixtures["resources"]["multi"]["$id"].as_str().unwrap();
412 let mut calls = 0;
413 registry
414 .resolve(url, |request| {
415 calls += 1;
416 assert_eq!(request, url);
417 Ok(fixtures["resources"]["multi"].clone())
418 })
419 .unwrap();
420 registry.validate_json(&text).unwrap();
421 assert_eq!(calls, 1);
422 assert_eq!(
423 registry
424 .register(fixtures["resources"]["multi"].clone())
425 .unwrap_err()
426 .code,
427 "E_CONFIG"
428 );
429 assert_eq!(
430 registry
431 .resolve("https://example.test/wrong/v1/schema.json", |_| Ok(
432 fixtures["resources"]["multi"].clone()
433 ))
434 .unwrap_err()
435 .code,
436 "E_CONFIG"
437 );
438 }
439
440 #[test]
441 fn generic_extension_query() {
442 let text = r##"{"version":"3.0","renderer":{"type":"gray","bidx":[1]},"extensions":["https://example.test/unresolved/v7.2.3/schema.json"],"unrelated_field":{"color":"#ABCDEF","nested":[null,true,"δΈ+ζ",2]},"a.b/c~d":false,"__proto__":{"safe":true},"π":1,"\ue000":2}"##;
443 let style = Style::from_json(text).unwrap();
444 let query = encode_query(&style).unwrap();
445 let decoded = decode_query(&query).unwrap();
446 assert_eq!(style.to_json().unwrap(), decoded.to_json().unwrap());
447 assert!(!decoded.validation().complete);
448 }
449
450 #[test]
451 fn complete_reference_validation() {
452 let fixtures = fixtures();
453 let mut registry = SchemaRegistry::new().unwrap();
454 registry
455 .register(fixtures["resources"]["square"].clone())
456 .unwrap();
457 let vectors: Value =
458 serde_json::from_str(include_str!("../tests/data/rendering-vectors.json")).unwrap();
459 for vector in vectors.as_array().unwrap() {
460 registry
461 .validate_json(&canonical_value(&vector["style"]).unwrap())
462 .unwrap_or_else(|error| panic!("{}: {error}", vector["name"]));
463 }
464 }
465
466 #[test]
467 fn registered_extension_query_round_trip() {
468 let fixtures = fixtures();
469 for case in fixtures["cases"].as_array().unwrap() {
470 if case["expected"]["complete"] != true {
471 continue;
472 }
473 let mut registry = SchemaRegistry::new().unwrap();
474 for name in case["schemas"].as_array().unwrap() {
475 registry
476 .register(fixtures["resources"][name.as_str().unwrap()].clone())
477 .unwrap();
478 }
479 let original = registry
480 .parse_json(&canonical_value(&case["style"]).unwrap())
481 .unwrap();
482 let decoded = decode_query(&encode_query(&original).unwrap()).unwrap();
483 let restored = registry.parse_json(&decoded.to_json().unwrap()).unwrap();
484 assert!(restored.validation().complete, "{}", case["name"]);
485 assert_eq!(
486 original.to_json().unwrap(),
487 restored.to_json().unwrap(),
488 "{}",
489 case["name"]
490 );
491 }
492 }
493 #[test]
494 fn declaration_order_reuses_the_compiled_schema() {
495 let mut registry = SchemaRegistry::new().unwrap();
496 let urls: Vec<String> = (0..5)
497 .map(|i| format!("https://example.test/order/{i}/v1.0.0/schema.json"))
498 .collect();
499 for url in &urls {
500 registry
501 .register(json!({"$id":url,"type":"object"}))
502 .unwrap();
503 }
504 let mut count = 0;
505 fn permute(
506 registry: &SchemaRegistry,
507 urls: &mut [String],
508 position: usize,
509 count: &mut usize,
510 ) {
511 if position == urls.len() {
512 let value = json!({"version":"3.0","renderer":{"type":"gray","bidx":[1]},"extensions":urls});
513 let before = value.clone();
514 assert!(registry.check(&value).unwrap().complete);
515 assert_eq!(value, before);
516 assert_eq!(registry.validators.lock().unwrap().len(), 1);
517 *count += 1;
518 return;
519 }
520 for index in position..urls.len() {
521 urls.swap(position, index);
522 permute(registry, urls, position + 1, count);
523 urls.swap(position, index);
524 }
525 }
526 permute(®istry, &mut urls.clone(), 0, &mut count);
527 assert_eq!(count, 120);
528 }
529}