Skip to main content

raster_style/
schema.rs

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
13/// Exact identity of the embedded Raster Style 3.0 core schema.
14pub const CORE_SCHEMA_URL: &str =
15    "https://mapseekai.github.io/raster-style/spec/v3/raster-style-v3.schema.json";
16/// Exact identity of the embedded Mosaic 1.0.0 extension schema.
17pub const MOSAIC_SCHEMA_URL: &str =
18    "https://mapseekai.github.io/raster-style/extensions/raster/mosaic/v1.0.0/schema.json";
19/// Exact identity of the embedded Tile 1.0.0 extension schema.
20pub 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/// Preservation is not full validation when a declared schema is unavailable.
26#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
27#[serde(rename_all = "camelCase")]
28pub struct ValidationReport {
29    /// All declared resources resolved and passed validation at construction time.
30    pub complete: bool,
31    /// Unregistered declarations in their original order; no remote lookup is performed.
32    pub unresolved_schemas: Vec<String>,
33}
34
35/// Offline, reusable schema registry. A URL is an identity, never a field-name hint.
36/// Registration needs exclusive access; parsing and validation can run concurrently.
37///
38/// # Examples
39///
40/// ```
41/// use raster_style::{SchemaRegistry, TILE_SCHEMA_URL};
42/// let registry = SchemaRegistry::new()?;
43/// let style = registry.parse_json(r#"{
44///   "version":"3.0", "renderer":{"type":"gray","bidx":[1]},
45///   "extensions":["https://mapseekai.github.io/raster-style/extensions/raster/tile/v1.0.0/schema.json"],
46///   "raster:tile":{"format":"png"}
47/// }"#)?;
48/// assert!(style.validation().complete);
49/// style.require_extension_support(&[TILE_SCHEMA_URL])?;
50/// # Ok::<(), raster_style::Error>(())
51/// ```
52pub 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    /// Create an offline registry containing the core and official extension schemas.
61    ///
62    /// # Errors
63    /// Returns `E_CONFIG` if an embedded resource cannot be initialized.
64    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    /// Register an immutable draft 2020-12 resource. Register referenced resources separately.
96    /// Previously constructed styles retain their validation report snapshots.
97    ///
98    /// # Errors
99    /// Returns a JSON/budget error for invalid input, or `E_CONFIG` for an invalid
100    /// schema identity, draft, resource, duplicate registration or poisoned cache.
101    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    /// Explicit resolution only; callers own network policy, allowlists, budgets and timeouts.
134    /// The callback is invoked once and its returned `$id` must equal `url` exactly.
135    ///
136    /// # Errors
137    /// Propagates callback and registration failures; invalid or mismatched identities return `E_CONFIG`.
138    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    /// Parse and normalize a style without changing the input text.
156    /// Unknown declarations are preserved with `complete=false`; this does not authorize execution.
157    ///
158    /// # Errors
159    /// Returns JSON, budget, known-schema or static-semantic errors. A referenced
160    /// resource missing from an available schema returns `E_SCHEMA_UNRESOLVED`.
161    pub fn parse_json(&self, text: &str) -> Result<Style> {
162        Style::from_value_in_registry(parse_strict(text)?, self)
163    }
164
165    /// Parse a style and return an owned snapshot of its validation completeness.
166    ///
167    /// # Errors
168    /// Returns the same errors as [`Self::parse_json`]; known errors are never preserved as success.
169    pub fn inspect_json(&self, text: &str) -> Result<ValidationReport> {
170        Ok(self.parse_json(text)?.validation().clone())
171    }
172
173    /// Full validation includes all declared schemas and static cross-field semantics.
174    /// Execution support must still be checked with [`Style::require_extension_support`].
175    ///
176    /// # Errors
177    /// Returns parsing/validation errors, including `E_SCHEMA_UNRESOLVED` when any
178    /// declared schema or required reference is unavailable.
179    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                // Only per-key initialization is serialized; the cache mutex is released.
228                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
259/// Fully validate with the embedded offline registry.
260///
261/// # Errors
262/// Returns the errors documented on [`SchemaRegistry::validate_json`].
263pub 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            // An earlier request's resource error must not poison a later request.
360            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(&registry, &mut urls.clone(), 0, &mut count);
527        assert_eq!(count, 120);
528    }
529}