Skip to main content

schema_catalog/
lib.rs

1#![doc = include_str!("../README.md")]
2
3extern crate alloc;
4
5use alloc::collections::BTreeMap;
6
7use serde::{Deserialize, Serialize};
8
9/// A JSON Schema catalog following the `SchemaStore` catalog format.
10/// See: <https://json.schemastore.org/schema-catalog.json>
11#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct Catalog {
13    pub version: u32,
14    #[serde(default, skip_serializing_if = "Option::is_none")]
15    pub title: Option<String>,
16    pub schemas: Vec<SchemaEntry>,
17    #[serde(default, skip_serializing_if = "Vec::is_empty")]
18    pub groups: Vec<CatalogGroup>,
19}
20
21/// A group of related schemas in the catalog.
22///
23/// Groups provide richer metadata for catalog consumers that support them.
24/// Consumers that don't understand `groups` simply ignore the field.
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct CatalogGroup {
27    pub name: String,
28    pub description: String,
29    /// Schema names that belong to this group.
30    pub schemas: Vec<String>,
31}
32
33/// A single schema entry in the catalog.
34#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct SchemaEntry {
36    pub name: String,
37    pub description: String,
38    pub url: String,
39    #[serde(default, rename = "sourceUrl", skip_serializing_if = "Option::is_none")]
40    pub source_url: Option<String>,
41    #[serde(default, rename = "fileMatch", skip_serializing_if = "Vec::is_empty")]
42    pub file_match: Vec<String>,
43    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
44    pub versions: BTreeMap<String, String>,
45}
46
47/// Parse a catalog from a JSON string.
48///
49/// # Errors
50///
51/// Returns an error if the string is not valid JSON or does not match the catalog schema.
52pub fn parse_catalog(json: &str) -> Result<Catalog, serde_json::Error> {
53    serde_json::from_str(json)
54}
55
56/// Parse a catalog from a `serde_json::Value`.
57///
58/// # Errors
59///
60/// Returns an error if the value does not match the expected catalog schema.
61pub fn parse_catalog_value(value: serde_json::Value) -> Result<Catalog, serde_json::Error> {
62    serde_json::from_value(value)
63}
64
65#[cfg(test)]
66mod tests {
67    use super::*;
68
69    #[test]
70    fn round_trip_catalog() {
71        let catalog = Catalog {
72            version: 1,
73            title: None,
74            schemas: vec![SchemaEntry {
75                name: "Test Schema".into(),
76                description: "A test schema".into(),
77                url: "https://example.com/test.json".into(),
78                source_url: None,
79                file_match: vec!["*.test.json".into()],
80                versions: BTreeMap::new(),
81            }],
82            groups: vec![],
83        };
84        let json = serde_json::to_string_pretty(&catalog).expect("serialize");
85        let parsed: Catalog = serde_json::from_str(&json).expect("deserialize");
86        assert_eq!(parsed.version, 1);
87        assert_eq!(parsed.schemas.len(), 1);
88        assert_eq!(parsed.schemas[0].name, "Test Schema");
89        assert_eq!(parsed.schemas[0].file_match, vec!["*.test.json"]);
90    }
91
92    #[test]
93    fn parse_catalog_from_json_string() {
94        let json = r#"{"version":1,"schemas":[{"name":"test","description":"desc","url":"https://example.com/s.json","fileMatch":["*.json"]}]}"#;
95        let catalog = parse_catalog(json).expect("parse");
96        assert_eq!(catalog.schemas.len(), 1);
97        assert_eq!(catalog.schemas[0].name, "test");
98        assert_eq!(catalog.schemas[0].file_match, vec!["*.json"]);
99    }
100
101    #[test]
102    fn empty_file_match_omitted_in_serialization() {
103        let entry = SchemaEntry {
104            name: "No Match".into(),
105            description: "desc".into(),
106            url: "https://example.com/no.json".into(),
107            source_url: None,
108            file_match: vec![],
109            versions: BTreeMap::new(),
110        };
111        let json = serde_json::to_string(&entry).expect("serialize");
112        assert!(!json.contains("fileMatch"));
113        assert!(!json.contains("sourceUrl"));
114        assert!(!json.contains("versions"));
115    }
116
117    #[test]
118    fn source_url_serialized_as_camel_case() {
119        let entry = SchemaEntry {
120            name: "Test".into(),
121            description: "desc".into(),
122            url: "https://catalog.example.com/test.json".into(),
123            source_url: Some("https://upstream.example.com/test.json".into()),
124            file_match: vec![],
125            versions: BTreeMap::new(),
126        };
127        let json = serde_json::to_string(&entry).expect("serialize");
128        assert!(json.contains("\"sourceUrl\""));
129        assert!(json.contains("https://upstream.example.com/test.json"));
130
131        // Round-trip
132        let parsed: SchemaEntry = serde_json::from_str(&json).expect("deserialize");
133        assert_eq!(
134            parsed.source_url.as_deref(),
135            Some("https://upstream.example.com/test.json")
136        );
137    }
138
139    #[test]
140    fn deserialize_with_versions() {
141        let json = r#"{
142            "version": 1,
143            "schemas": [{
144                "name": "test",
145                "description": "desc",
146                "url": "https://example.com/s.json",
147                "versions": {"draft-07": "https://example.com/draft07.json"}
148            }]
149        }"#;
150        let catalog = parse_catalog(json).expect("parse");
151        assert_eq!(
152            catalog.schemas[0].versions.get("draft-07"),
153            Some(&"https://example.com/draft07.json".to_string())
154        );
155    }
156}