Skip to main content

devops_validate/schema/
registry.rs

1//! Schema registry for fetching and caching JSON Schemas
2//!
3//! Provides schema URL resolution, remote fetching with caching, and
4//! fallback to bundled schemas for offline mode.
5
6use std::collections::HashMap;
7
8use serde_json::Value;
9
10use super::bundled;
11use super::resolver::YamlType;
12
13/// Schema URL templates for known YAML types
14/// Format: (schema_key, url_template)
15///
16/// URL templates can contain `{version}` placeholder for K8s version.
17pub const SCHEMA_URLS: &[(&str, &str)] = &[
18    // Kubernetes (using kubernetesjsonschema.dev)
19    ("k8s/deployment", "https://kubernetesjsonschema.dev/v{version}/_definitions.json#/definitions/io.k8s.api.apps.v1.Deployment"),
20    ("k8s/service", "https://kubernetesjsonschema.dev/v{version}/service.json"),
21    ("k8s/configmap", "https://kubernetesjsonschema.dev/v{version}/configmap.json"),
22    ("k8s/secret", "https://kubernetesjsonschema.dev/v{version}/secret.json"),
23    ("k8s/ingress", "https://kubernetesjsonschema.dev/v{version}/ingress.json"),
24    ("k8s/horizontalpodautoscaler", "https://kubernetesjsonschema.dev/v{version}/horizontalpodautoscaler.json"),
25    ("k8s/cronjob", "https://kubernetesjsonschema.dev/v{version}/cronjob.json"),
26    ("k8s/job", "https://kubernetesjsonschema.dev/v{version}/job.json"),
27    ("k8s/persistentvolumeclaim", "https://kubernetesjsonschema.dev/v{version}/persistentvolumeclaim.json"),
28    ("k8s/networkpolicy", "https://kubernetesjsonschema.dev/v{version}/networkpolicy.json"),
29    ("k8s/statefulset", "https://kubernetesjsonschema.dev/v{version}/statefulset.json"),
30    ("k8s/daemonset", "https://kubernetesjsonschema.dev/v{version}/daemonset.json"),
31    ("k8s/role", "https://kubernetesjsonschema.dev/v{version}/role.json"),
32    ("k8s/clusterrole", "https://kubernetesjsonschema.dev/v{version}/clusterrole.json"),
33    ("k8s/rolebinding", "https://kubernetesjsonschema.dev/v{version}/rolebinding.json"),
34    ("k8s/clusterrolebinding", "https://kubernetesjsonschema.dev/v{version}/clusterrolebinding.json"),
35    ("k8s/serviceaccount", "https://kubernetesjsonschema.dev/v{version}/serviceaccount.json"),
36    ("k8s/generic", "https://kubernetesjsonschema.dev/v{version}/_definitions.json"),
37
38    // CI/CD (using schemastore.org)
39    ("gitlab-ci", "https://json.schemastore.org/gitlab-ci.json"),
40    ("github-actions", "https://json.schemastore.org/github-workflow.json"),
41    ("docker-compose", "https://json.schemastore.org/docker-compose.json"),
42
43    // Monitoring
44    ("prometheus", "https://json.schemastore.org/prometheus.json"),
45    ("alertmanager", "https://json.schemastore.org/alertmanager.json"),
46
47    // Configuration
48    ("helm-values", "https://json.schemastore.org/chart.json"),
49    ("ansible", "https://json.schemastore.org/ansible-playbook.json"),
50    ("openapi", "https://json.schemastore.org/openapi-3.0.json"),
51];
52
53/// Default Kubernetes schema version
54pub const DEFAULT_K8S_VERSION: &str = "1.30.0";
55
56/// Error type for schema operations
57#[derive(Debug, Clone)]
58pub enum SchemaError {
59    /// Schema not found in registry
60    NotFound(String),
61    /// Failed to fetch schema from remote
62    FetchFailed(String, String),
63    /// Failed to parse schema JSON
64    ParseFailed(String),
65    /// Schema validation failed
66    ValidationFailed(String),
67}
68
69impl std::fmt::Display for SchemaError {
70    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
71        match self {
72            SchemaError::NotFound(key) => write!(f, "Schema not found: {}", key),
73            SchemaError::FetchFailed(url, err) => {
74                write!(f, "Failed to fetch schema from {}: {}", url, err)
75            }
76            SchemaError::ParseFailed(err) => write!(f, "Failed to parse schema: {}", err),
77            SchemaError::ValidationFailed(err) => write!(f, "Schema validation failed: {}", err),
78        }
79    }
80}
81
82impl std::error::Error for SchemaError {}
83
84/// Schema registry with in-memory caching and bundled offline fallbacks.
85///
86/// Use [`SchemaRegistry::new`] for the default configuration (K8s 1.30, bundled
87/// schemas enabled). Call [`get_schema_sync`](SchemaRegistry::get_schema_sync)
88/// to retrieve a [`serde_json::Value`] schema by type key (e.g. `"k8s/deployment"`).
89///
90/// # Example
91///
92/// ```rust
93/// use devops_validate::schema::SchemaRegistry;
94///
95/// let mut registry = SchemaRegistry::new();
96///
97/// // Retrieve from bundled fallback (works offline)
98/// let schema = registry.get_schema_sync("k8s/deployment").unwrap();
99/// assert!(schema.is_object());
100///
101/// // Resolve remote URL (for async fetch — registry only provides the URL)
102/// let url = registry.get_schema_url("gitlab-ci").unwrap();
103/// assert!(url.contains("schemastore.org"));
104/// ```
105pub struct SchemaRegistry {
106    /// In-memory cache of compiled schemas
107    cache: HashMap<String, Value>,
108    /// Kubernetes version for schema URLs
109    k8s_version: String,
110    /// Whether to use bundled fallback schemas when offline
111    use_bundled_fallback: bool,
112}
113
114impl Default for SchemaRegistry {
115    fn default() -> Self {
116        Self::new()
117    }
118}
119
120impl SchemaRegistry {
121    /// Create a schema registry with default settings.
122    ///
123    /// Defaults: Kubernetes version `1.30.0`, bundled fallback schemas enabled.
124    pub fn new() -> Self {
125        Self {
126            cache: HashMap::new(),
127            k8s_version: DEFAULT_K8S_VERSION.to_string(),
128            use_bundled_fallback: true,
129        }
130    }
131
132    /// Create a registry targeting a specific Kubernetes version.
133    ///
134    /// The version is used when constructing K8s schema URLs (e.g.
135    /// `"1.28.0"` → `kubernetesjsonschema.dev/v1.28.0/...`).
136    pub fn with_k8s_version(version: String) -> Self {
137        Self {
138            cache: HashMap::new(),
139            k8s_version: version,
140            use_bundled_fallback: true,
141        }
142    }
143
144    /// Set whether to use bundled fallback schemas
145    pub fn set_bundled_fallback(&mut self, enabled: bool) {
146        self.use_bundled_fallback = enabled;
147    }
148
149    /// Set Kubernetes version
150    pub fn set_k8s_version(&mut self, version: String) {
151        self.k8s_version = version;
152        // Clear cache since URLs will change
153        self.cache.clear();
154    }
155
156/// Look up a schema by type key (e.g. `"k8s/deployment"`, `"gitlab-ci"`).
157    ///
158    /// Resolution order:
159    /// 1. **In-memory cache** — instant return if previously fetched.
160    /// 2. **Bundled fallback** — minimal embedded schema (works fully offline).
161    ///
162    /// For remote schema fetching, retrieve the URL with
163    /// [`get_schema_url`](SchemaRegistry::get_schema_url), perform the HTTP
164    /// request externally, then insert the result via
165    /// [`cache_schema`](SchemaRegistry::cache_schema).
166    ///
167    /// # Errors
168    ///
169    /// Returns [`SchemaError::NotFound`] if `schema_type` is not in the
170    /// built-in registry **and** no bundled fallback exists for it (e.g.
171    /// for a custom schema key).
172    ///
173    /// # Example
174    ///
175    /// ```rust
176    /// use devops_validate::schema::SchemaRegistry;
177    ///
178    /// let mut r = SchemaRegistry::new();
179    /// let schema = r.get_schema_sync("k8s/deployment").unwrap();
180    /// assert_eq!(schema["type"], "object");
181    ///
182    /// assert!(r.get_schema_sync("nonexistent/type").is_err());
183    /// ```
184    pub fn get_schema_sync(&mut self, schema_type: &str) -> Result<Value, SchemaError> {
185        // 1. Check in-memory cache
186        if let Some(cached) = self.cache.get(schema_type) {
187            return Ok(cached.clone());
188        }
189
190        // 2. Get bundled fallback (for now, remote fetch will be async via WASM bridge)
191        if self.use_bundled_fallback
192            && let Some(bundled) = bundled::get_bundled_schema(schema_type) {
193                self.cache.insert(schema_type.to_string(), bundled.clone());
194                return Ok(bundled);
195            }
196
197        // 3. Return error if not found
198        Err(SchemaError::NotFound(schema_type.to_string()))
199    }
200
201    /// Resolve the remote URL for a schema type.
202    ///
203    /// Returns `None` if `schema_type` is not in [`SCHEMA_URLS`].
204    /// For Kubernetes schemas the URL contains the configured K8s version.
205    ///
206    /// # Example
207    ///
208    /// ```rust
209    /// use devops_validate::schema::SchemaRegistry;
210    ///
211    /// let r = SchemaRegistry::with_k8s_version("1.28.0".to_string());
212    /// let url = r.get_schema_url("k8s/deployment").unwrap();
213    /// assert!(url.contains("1.28.0"));
214    /// assert!(r.get_schema_url("unknown").is_none());
215    /// ```
216    pub fn get_schema_url(&self, schema_type: &str) -> Option<String> {
217        for (key, url_template) in SCHEMA_URLS {
218            if *key == schema_type {
219                let url = if url_template.contains("{version}") {
220                    url_template.replace("{version}", &self.k8s_version)
221                } else {
222                    url_template.to_string()
223                };
224                return Some(url);
225            }
226        }
227        None
228    }
229
230    /// Insert a remotely-fetched schema into the in-memory cache.
231    ///
232    /// Call this after fetching the schema JSON from the URL returned by
233    /// [`get_schema_url`](SchemaRegistry::get_schema_url).
234    pub fn cache_schema(&mut self, schema_type: &str, schema: Value) {
235        self.cache.insert(schema_type.to_string(), schema);
236    }
237
238    /// Evict all in-memory cached schemas.
239    pub fn clear_cache(&mut self) {
240        self.cache.clear();
241    }
242
243    /// Convenience wrapper around [`get_schema_sync`](SchemaRegistry::get_schema_sync)
244    /// that accepts a [`YamlType`] instead of a string key.
245    pub fn get_schema_for_type(&mut self, yaml_type: YamlType) -> Result<Value, SchemaError> {
246        self.get_schema_sync(yaml_type.to_schema_key())
247    }
248
249    /// Return all schema type keys known to this registry.
250    ///
251    /// These are the valid arguments to [`get_schema_sync`](SchemaRegistry::get_schema_sync)
252    /// and [`get_schema_url`](SchemaRegistry::get_schema_url).
253    pub fn list_schema_types(&self) -> Vec<&'static str> {
254        SCHEMA_URLS.iter().map(|(key, _)| *key).collect()
255    }
256}
257
258#[cfg(test)]
259mod tests {
260    use super::*;
261
262    #[test]
263    fn test_get_schema_url_k8s() {
264        let registry = SchemaRegistry::new();
265        let url = registry.get_schema_url("k8s/deployment").unwrap();
266        assert!(url.contains("kubernetesjsonschema.dev"));
267        assert!(url.contains("1.30.0"));
268    }
269
270    #[test]
271    fn test_get_schema_url_gitlab_ci() {
272        let registry = SchemaRegistry::new();
273        let url = registry.get_schema_url("gitlab-ci").unwrap();
274        assert_eq!(url, "https://json.schemastore.org/gitlab-ci.json");
275    }
276
277    #[test]
278    fn test_get_schema_url_unknown() {
279        let registry = SchemaRegistry::new();
280        let url = registry.get_schema_url("unknown/type");
281        assert!(url.is_none());
282    }
283
284    #[test]
285    fn test_custom_k8s_version() {
286        let registry = SchemaRegistry::with_k8s_version("1.28.0".to_string());
287        let url = registry.get_schema_url("k8s/service").unwrap();
288        assert!(url.contains("1.28.0"));
289    }
290
291    #[test]
292    fn test_list_schema_types() {
293        let registry = SchemaRegistry::new();
294        let types = registry.list_schema_types();
295        assert!(types.contains(&"k8s/deployment"));
296        assert!(types.contains(&"gitlab-ci"));
297        assert!(types.contains(&"docker-compose"));
298    }
299
300    #[test]
301    fn test_bundled_fallback() {
302        let mut registry = SchemaRegistry::new();
303        // Should return bundled schema for k8s/deployment
304        let result = registry.get_schema_sync("k8s/deployment");
305        assert!(result.is_ok());
306    }
307}