Skip to main content

eui_core/host/
package.rs

1//! Catalog negotiation and declarative app packages (VAPP-82: a manifest +
2//! A2UI templates + bindings, data never code).
3
4use std::collections::HashSet;
5
6use serde::{Deserialize, Serialize};
7use serde_json::{json, Map, Value};
8
9use super::policy::matches_pattern;
10use super::record_store::RECORDS_SCHEME;
11use super::records::{is_record_kind, validate_record_schema};
12use super::sources::parse_source;
13use super::tools::validate_tools;
14use crate::catalog::{A2UI_BASIC_CATALOG_ID, A2UI_VERSION, CORE_CATALOG_ID, CORE_LITE_CATALOG_ID};
15use crate::types::ExtensionDef;
16
17/// VAPP-161: the package format version this host reads
18/// (catalog/host.json `package.formatVersion`; docs/package-format.md). A
19/// package without `formatVersion` is version 1; every version-1 package
20/// is a valid version-2 package.
21pub const PACKAGE_FORMAT_VERSION: u32 = 2;
22/// `package.extensions.versionPattern`.
23pub const EXTENSION_VERSION_PATTERN: &str = "^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)$";
24
25/// VAPP-161: an extension as a host registered it ([`available_extensions`]).
26#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
27pub struct AvailableExtension {
28    pub id: String,
29    /// `None` = satisfies any version.
30    #[serde(default, skip_serializing_if = "Option::is_none")]
31    pub version: Option<String>,
32    /// `None` = not checked.
33    #[serde(default, skip_serializing_if = "Option::is_none")]
34    pub components: Option<Vec<String>>,
35}
36
37/// A host's extension catalogs as [`validate_package_with`] checks them:
38/// the version when set (an empty one is unset), the components then the
39/// macros.
40pub fn available_extensions(defs: &[ExtensionDef]) -> Vec<AvailableExtension> {
41    defs.iter()
42        .map(|d| AvailableExtension {
43            id: d.id.clone(),
44            version: d.version.clone().filter(|v| !v.is_empty()),
45            components: Some(d.components.keys().chain(d.macros.iter().flat_map(|m| m.keys())).cloned().collect()),
46        })
47        .collect()
48}
49
50/// `^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$` → the three parts (as JS
51/// `Number`s).
52fn semver(v: &str) -> Option<[f64; 3]> {
53    let mut parts = v.split('.');
54    let mut out = [0.0; 3];
55    for slot in &mut out {
56        let p = parts.next()?;
57        if p.is_empty() || !p.bytes().all(|b| b.is_ascii_digit()) || (p.len() > 1 && p.starts_with('0')) {
58            return None;
59        }
60        *slot = p.parse().ok()?;
61    }
62    parts.next().is_none().then_some(out)
63}
64
65/// Caret: `have` has `want`'s major and is not lower.
66pub fn version_satisfies(have: &str, want: &str) -> bool {
67    let (Some([h_major, h_minor, h_patch]), Some([w_major, w_minor, w_patch])) = (semver(have), semver(want)) else { return false };
68    h_major == w_major && (h_minor > w_minor || (h_minor == w_minor && h_patch >= w_patch))
69}
70
71/// The core, the core lite, the A2UI basic catalog, then every registered
72/// extension id in order, de-duplicated.
73pub fn supported_catalog_ids<S: AsRef<str>>(extension_ids: &[S]) -> Vec<String> {
74    let mut out: Vec<String> = Vec::new();
75    for id in [CORE_CATALOG_ID, CORE_LITE_CATALOG_ID, A2UI_BASIC_CATALOG_ID].into_iter().chain(extension_ids.iter().map(AsRef::as_ref)) {
76        if !out.iter().any(|x| x == id) {
77            out.push(id.to_string());
78        }
79    }
80    out
81}
82
83/// A2UI's `a2uiClientCapabilities`.
84pub fn client_capabilities<S: AsRef<str>>(extension_ids: &[S]) -> Value {
85    json!({"v0.9": {"supportedCatalogIds": supported_catalog_ids(extension_ids)}})
86}
87
88#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
89pub struct PackageIssue {
90    pub path: String,
91    pub message: String,
92}
93
94fn issue(path: impl Into<String>, message: impl Into<String>) -> PackageIssue {
95    PackageIssue { path: path.into(), message: message.into() }
96}
97
98/// `^(\/([^/~]|~[01])*)*$`: empty, or `/`-separated segments whose `~` is
99/// always `~0` / `~1`.
100fn is_pointer(s: &str) -> bool {
101    if s.is_empty() {
102        return true;
103    }
104    if !s.starts_with('/') {
105        return false;
106    }
107    let mut chars = s.chars().peekable();
108    while let Some(c) = chars.next() {
109        if c == '~' && !matches!(chars.next(), Some('0' | '1')) {
110            return false;
111        }
112    }
113    true
114}
115
116/// Every problem with a package, each with a JSON pointer into it.
117/// `catalog_ids` = the supported ids (default: [`supported_catalog_ids`] of
118/// none). A declared extension only has to be in `catalog_ids`
119/// ([`validate_package_with`] checks versions and components too).
120pub fn validate_package<S: AsRef<str>>(pkg: &Value, catalog_ids: &[S]) -> Vec<PackageIssue> {
121    validate_package_with(pkg, catalog_ids, None)
122}
123
124/// [`validate_package`] with the host's registered `extensions` (versions
125/// and components); `None` = a declared extension only has to be in
126/// `catalog_ids`.
127pub fn validate_package_with<S: AsRef<str>>(pkg: &Value, catalog_ids: &[S], extensions: Option<&[AvailableExtension]>) -> Vec<PackageIssue> {
128    let mut issues = Vec::new();
129    let Some(p) = pkg.as_object() else { return vec![issue("", "a package is an object")] };
130    if let Some(version) = p.get("formatVersion") {
131        match version.as_f64().filter(|v| v.fract() == 0.0 && *v >= 1.0) {
132            None => issues.push(issue("/formatVersion", "formatVersion is a positive integer")),
133            Some(v) if v > f64::from(PACKAGE_FORMAT_VERSION) => {
134                issues.push(issue("/formatVersion", format!("format version {} is newer than this host ({PACKAGE_FORMAT_VERSION})", crate::json::number_to_string(v))))
135            }
136            Some(_) => {}
137        }
138    }
139    for key in ["id", "name", "version", "catalogId"] {
140        if p.get(key).and_then(Value::as_str).is_none_or(str::is_empty) {
141            issues.push(issue(format!("/{key}"), format!("{key} is a required string")));
142        }
143    }
144    if let Some(catalog_id) = p.get("catalogId").and_then(Value::as_str).filter(|s| !s.is_empty()) {
145        if !catalog_ids.iter().any(|c| c.as_ref() == catalog_id) {
146            issues.push(issue("/catalogId", format!("unsupported catalog {catalog_id}")));
147        }
148    }
149    match p.get("templates").and_then(Value::as_object).filter(|t| !t.is_empty()) {
150        None => issues.push(issue("/templates", "templates is a non-empty object")),
151        Some(templates) => {
152            for (id, template) in templates {
153                let at = format!("/templates/{id}");
154                let Some(template) = template.as_object() else {
155                    issues.push(issue(at, "a template is an object"));
156                    continue;
157                };
158                match template.get("components").and_then(Value::as_array).filter(|c| !c.is_empty()) {
159                    None => issues.push(issue(format!("{at}/components"), "components is a non-empty array")),
160                    Some(components) => {
161                        let mut ids: HashSet<&str> = HashSet::new();
162                        for (i, c) in components.iter().enumerate() {
163                            let id = c.as_object().filter(|o| o.get("component").is_some_and(Value::is_string)).and_then(|o| o.get("id")).and_then(Value::as_str);
164                            match id {
165                                None => issues.push(issue(format!("{at}/components/{i}"), "a component needs an id and a component")),
166                                Some(id) if ids.contains(id) => issues.push(issue(format!("{at}/components/{i}/id"), format!("duplicate id {id}"))),
167                                Some(id) => {
168                                    ids.insert(id);
169                                }
170                            }
171                        }
172                        if !ids.contains("root") {
173                            issues.push(issue(format!("{at}/components"), "no component has the id root"));
174                        }
175                    }
176                }
177                if let Some(bindings) = template.get("bindings") {
178                    match bindings.as_array() {
179                        None => issues.push(issue(format!("{at}/bindings"), "bindings is an array")),
180                        Some(list) => {
181                            for (i, b) in list.iter().enumerate() {
182                                let bp = format!("{at}/bindings/{i}");
183                                let b = b.as_object();
184                                if b.and_then(|o| o.get("path")).and_then(Value::as_str).is_none_or(|p| !is_pointer(p)) {
185                                    issues.push(issue(format!("{bp}/path"), "path is a JSON pointer"));
186                                }
187                                if b.and_then(|o| o.get("source")).and_then(Value::as_str).is_none_or(|s| parse_source(s).is_none()) {
188                                    issues.push(issue(format!("{bp}/source"), "source is a scheme:name URI"));
189                                }
190                            }
191                        }
192                    }
193                }
194            }
195        }
196    }
197    if let Some(functions) = p.get("functions") {
198        let ok = functions.as_array().is_some_and(|list| list.iter().all(|f| f.as_str().is_some_and(|s| !s.is_empty())));
199        if !ok {
200            issues.push(issue("/functions", "functions is a list of names or prefix* patterns"));
201        }
202    }
203    // VAPP-122: `records` = the app's kinds, each with a schema.
204    if let Some(records) = p.get("records") {
205        match records.as_object() {
206            None => issues.push(issue("/records", "records is an object of kinds")),
207            Some(kinds) => {
208                let mut names: Vec<&String> = kinds.keys().collect();
209                names.sort();
210                for kind in names {
211                    let def = &kinds[kind];
212                    let at = format!("/records/{kind}");
213                    if !is_record_kind(kind) {
214                        issues.push(issue(&at, "a kind is lowercase letters, digits and underscores"));
215                    }
216                    match def.as_object() {
217                        None => issues.push(issue(&at, "a kind is {schema, title?}")),
218                        Some(def) => issues.extend(validate_record_schema(def.get("schema").unwrap_or(&Value::Null), &format!("{at}/schema"))),
219                    }
220                }
221            }
222        }
223    }
224    // VAPP-107: `sources` = the binding sources the package's surfaces may
225    // follow; a template binding outside it is an issue.
226    if let Some(sources) = p.get("sources") {
227        match sources.as_array().filter(|list| list.iter().all(|f| f.as_str().is_some_and(|s| !s.is_empty()))) {
228            None => issues.push(issue("/sources", "sources is a list of scheme:name sources or prefix* patterns")),
229            Some(list) => {
230                let allowed: Vec<String> = list.iter().filter_map(Value::as_str).map(str::to_string).collect();
231                for (id, template) in p.get("templates").and_then(Value::as_object).into_iter().flatten() {
232                    for (i, b) in template.get("bindings").and_then(Value::as_array).into_iter().flatten().enumerate() {
233                        if let Some(source) = b.get("source").and_then(Value::as_str).filter(|s| parse_source(s).is_some()) {
234                            if !source_allowed(Some(&allowed), source) {
235                                issues.push(issue(format!("/templates/{id}/bindings/{i}/source"), format!("source {source} is not in the package's sources")));
236                            }
237                        }
238                    }
239                }
240            }
241        }
242    }
243    // VAPP-161: a `records:<kind>` binding names a declared kind.
244    for (id, template) in p.get("templates").and_then(Value::as_object).into_iter().flatten() {
245        let Some(bindings) = template.as_object().and_then(|t| t.get("bindings")).and_then(Value::as_array) else { continue };
246        for (i, b) in bindings.iter().enumerate() {
247            let Some(parsed) = b.as_object().and_then(|o| o.get("source")).and_then(Value::as_str).and_then(parse_source) else { continue };
248            if parsed.scheme != RECORDS_SCHEME {
249                continue;
250            }
251            if !p.get("records").and_then(Value::as_object).is_some_and(|r| r.contains_key(&parsed.name)) {
252                issues.push(issue(format!("/templates/{id}/bindings/{i}/source"), format!("records:{} names no declared kind", parsed.name)));
253            }
254        }
255    }
256    if let Some(tools) = p.get("tools") {
257        issues.extend(validate_tools(tools));
258    }
259    if let Some(list) = p.get("extensions") {
260        issues.extend(validate_extensions(list, catalog_ids, extensions));
261    }
262    issues
263}
264
265fn validate_extensions<S: AsRef<str>>(list: &Value, catalog_ids: &[S], available: Option<&[AvailableExtension]>) -> Vec<PackageIssue> {
266    let Some(list) = list.as_array() else { return vec![issue("/extensions", "extensions is a list of {id, version, components?}")] };
267    let mut issues = Vec::new();
268    let mut seen: HashSet<&str> = HashSet::new();
269    for (i, ext) in list.iter().enumerate() {
270        let at = format!("/extensions/{i}");
271        let Some(ext) = ext.as_object() else {
272            issues.push(issue(at, "an extension is {id, version, components?}"));
273            continue;
274        };
275        let id = ext.get("id").and_then(Value::as_str).filter(|s| !s.is_empty());
276        if id.is_none() {
277            issues.push(issue(format!("{at}/id"), "id is a required string"));
278        }
279        let version = ext.get("version").and_then(Value::as_str).filter(|v| semver(v).is_some());
280        if version.is_none() {
281            issues.push(issue(format!("{at}/version"), "version is MAJOR.MINOR.PATCH"));
282        }
283        let components: Option<Vec<&str>> = match ext.get("components") {
284            None => Some(Vec::new()),
285            Some(c) => c.as_array().and_then(|l| l.iter().map(|c| c.as_str().filter(|s| !s.is_empty())).collect()),
286        };
287        if components.is_none() {
288            issues.push(issue(format!("{at}/components"), "components is a list of component names"));
289        }
290        let Some(id) = id else { continue };
291        if !seen.insert(id) {
292            issues.push(issue(format!("{at}/id"), format!("duplicate extension {id}")));
293            continue;
294        }
295        let have = available.and_then(|a| a.iter().find(|a| a.id == id));
296        if !catalog_ids.iter().any(|c| c.as_ref() == id) || (available.is_some() && have.is_none()) {
297            issues.push(issue(format!("{at}/id"), format!("extension {id} is not available on this host")));
298            continue;
299        }
300        let Some(have) = have else { continue };
301        if let (Some(version), Some(have_version)) = (version, have.version.as_deref()) {
302            if !version_satisfies(have_version, version) {
303                issues.push(issue(format!("{at}/version"), format!("extension {id} {have_version} does not satisfy ^{version}")));
304            }
305        }
306        if let Some(have_components) = &have.components {
307            for (j, name) in components.iter().flatten().enumerate() {
308                if !have_components.iter().any(|c| c == name) {
309                    issues.push(issue(format!("{at}/components/{j}"), format!("extension {id} has no component {name}")));
310                }
311            }
312        }
313    }
314    issues
315}
316
317/// VAPP-107: may a package whose `sources` list is `allowed` follow this
318/// source URI? `None` = yes; else the URI's `scheme:name` (scheme
319/// lower-cased, the query dropped) must match a listed source exactly or a
320/// `prefix*` pattern.
321pub fn source_allowed(allowed: Option<&[String]>, source: &str) -> bool {
322    let Some(allowed) = allowed else { return true };
323    let Some(parsed) = parse_source(source) else { return false };
324    let name = format!("{}:{}", parsed.scheme, parsed.name);
325    allowed.iter().any(|p| matches_pattern(p, &name) || matches_pattern(p, source))
326}
327
328/// A package's `sources` allowlist (`None` = unset: any source).
329pub fn package_sources(pkg: &Value) -> Option<Vec<String>> {
330    pkg.get("sources").and_then(Value::as_array).map(|l| l.iter().filter_map(Value::as_str).map(str::to_string).collect())
331}
332
333/// A template as the messages that create it: createSurface, the components,
334/// the merged initial data, one bindDataModel per binding. `None` when the
335/// package has no such template. `data` = the caller's data (`Some(Null)` is
336/// a value, unlike `None`).
337pub fn template_messages(pkg: &Value, template_id: &str, surface_id: &str, data: Option<&Value>) -> Option<Vec<Value>> {
338    let template = pkg.get("templates").and_then(|t| t.get(template_id)).filter(|t| super::js_truthy(t))?;
339    let version = A2UI_VERSION;
340    let mut create = Map::new();
341    create.insert("surfaceId".into(), Value::String(surface_id.into()));
342    if let Some(catalog_id) = pkg.get("catalogId") {
343        create.insert("catalogId".into(), catalog_id.clone());
344    }
345    let mut components = Map::new();
346    components.insert("surfaceId".into(), Value::String(surface_id.into()));
347    if let Some(list) = template.get("components") {
348        components.insert("components".into(), list.clone());
349    }
350    let mut out = vec![json!({"version": version, "createSurface": create}), json!({"version": version, "updateComponents": components})];
351    if let Some(merged) = merge_data(template.get("data"), data) {
352        out.push(json!({"version": version, "updateDataModel": {"surfaceId": surface_id, "path": "/", "value": merged}}));
353    }
354    for b in template.get("bindings").and_then(Value::as_array).into_iter().flatten() {
355        let mut bind = Map::new();
356        bind.insert("surfaceId".into(), Value::String(surface_id.into()));
357        for key in ["path", "source"] {
358            if let Some(v) = b.get(key) {
359                bind.insert(key.into(), v.clone());
360            }
361        }
362        out.push(json!({"version": version, "bindDataModel": bind}));
363    }
364    Some(out)
365}
366
367/// The template's data under the caller's (objects merge one level deep in
368/// key order; anything else: the caller's wins). `None` = undefined.
369pub fn merge_data(base: Option<&Value>, over: Option<&Value>) -> Option<Value> {
370    match (base, over) {
371        (base, None) => base.cloned(),
372        (Some(Value::Object(b)), Some(Value::Object(o))) => {
373            let mut merged = b.clone();
374            for (k, v) in o {
375                merged.insert(k.clone(), v.clone());
376            }
377            Some(Value::Object(merged))
378        }
379        (_, Some(over)) => Some(over.clone()),
380    }
381}