1use 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::exposes::{validate_package_exposes, validate_upstreams};
14use super::tools::validate_tools;
15use crate::catalog::{A2UI_BASIC_CATALOG_ID, A2UI_VERSION, CORE_CATALOG_ID, CORE_LITE_CATALOG_ID};
16use crate::types::ExtensionDef;
17
18pub const PACKAGE_FORMAT_VERSION: u32 = 2;
23pub const EXTENSION_VERSION_PATTERN: &str = "^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)$";
25
26#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
28pub struct AvailableExtension {
29 pub id: String,
30 #[serde(default, skip_serializing_if = "Option::is_none")]
32 pub version: Option<String>,
33 #[serde(default, skip_serializing_if = "Option::is_none")]
35 pub components: Option<Vec<String>>,
36}
37
38pub fn available_extensions(defs: &[ExtensionDef]) -> Vec<AvailableExtension> {
42 defs.iter()
43 .map(|d| AvailableExtension {
44 id: d.id.clone(),
45 version: d.version.clone().filter(|v| !v.is_empty()),
46 components: Some(d.components.keys().chain(d.macros.iter().flat_map(|m| m.keys())).cloned().collect()),
47 })
48 .collect()
49}
50
51fn semver(v: &str) -> Option<[f64; 3]> {
54 let mut parts = v.split('.');
55 let mut out = [0.0; 3];
56 for slot in &mut out {
57 let p = parts.next()?;
58 if p.is_empty() || !p.bytes().all(|b| b.is_ascii_digit()) || (p.len() > 1 && p.starts_with('0')) {
59 return None;
60 }
61 *slot = p.parse().ok()?;
62 }
63 parts.next().is_none().then_some(out)
64}
65
66pub fn version_satisfies(have: &str, want: &str) -> bool {
68 let (Some([h_major, h_minor, h_patch]), Some([w_major, w_minor, w_patch])) = (semver(have), semver(want)) else { return false };
69 h_major == w_major && (h_minor > w_minor || (h_minor == w_minor && h_patch >= w_patch))
70}
71
72pub fn supported_catalog_ids<S: AsRef<str>>(extension_ids: &[S]) -> Vec<String> {
75 let mut out: Vec<String> = Vec::new();
76 for id in [CORE_CATALOG_ID, CORE_LITE_CATALOG_ID, A2UI_BASIC_CATALOG_ID].into_iter().chain(extension_ids.iter().map(AsRef::as_ref)) {
77 if !out.iter().any(|x| x == id) {
78 out.push(id.to_string());
79 }
80 }
81 out
82}
83
84pub fn client_capabilities<S: AsRef<str>>(extension_ids: &[S]) -> Value {
86 json!({"v0.9": {"supportedCatalogIds": supported_catalog_ids(extension_ids)}})
87}
88
89#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
90pub struct PackageIssue {
91 pub path: String,
92 pub message: String,
93}
94
95fn issue(path: impl Into<String>, message: impl Into<String>) -> PackageIssue {
96 PackageIssue { path: path.into(), message: message.into() }
97}
98
99fn is_pointer(s: &str) -> bool {
102 if s.is_empty() {
103 return true;
104 }
105 if !s.starts_with('/') {
106 return false;
107 }
108 let mut chars = s.chars().peekable();
109 while let Some(c) = chars.next() {
110 if c == '~' && !matches!(chars.next(), Some('0' | '1')) {
111 return false;
112 }
113 }
114 true
115}
116
117pub fn validate_package<S: AsRef<str>>(pkg: &Value, catalog_ids: &[S]) -> Vec<PackageIssue> {
122 validate_package_with(pkg, catalog_ids, None)
123}
124
125pub fn validate_package_with<S: AsRef<str>>(pkg: &Value, catalog_ids: &[S], extensions: Option<&[AvailableExtension]>) -> Vec<PackageIssue> {
129 let mut issues = Vec::new();
130 let Some(p) = pkg.as_object() else { return vec![issue("", "a package is an object")] };
131 if let Some(version) = p.get("formatVersion") {
132 match version.as_f64().filter(|v| v.fract() == 0.0 && *v >= 1.0) {
133 None => issues.push(issue("/formatVersion", "formatVersion is a positive integer")),
134 Some(v) if v > f64::from(PACKAGE_FORMAT_VERSION) => {
135 issues.push(issue("/formatVersion", format!("format version {} is newer than this host ({PACKAGE_FORMAT_VERSION})", crate::json::number_to_string(v))))
136 }
137 Some(_) => {}
138 }
139 }
140 for key in ["id", "name", "version", "catalogId"] {
141 if p.get(key).and_then(Value::as_str).is_none_or(str::is_empty) {
142 issues.push(issue(format!("/{key}"), format!("{key} is a required string")));
143 }
144 }
145 if let Some(catalog_id) = p.get("catalogId").and_then(Value::as_str).filter(|s| !s.is_empty()) {
146 if !catalog_ids.iter().any(|c| c.as_ref() == catalog_id) {
147 issues.push(issue("/catalogId", format!("unsupported catalog {catalog_id}")));
148 }
149 }
150 match p.get("templates").and_then(Value::as_object).filter(|t| !t.is_empty()) {
151 None => issues.push(issue("/templates", "templates is a non-empty object")),
152 Some(templates) => {
153 for (id, template) in templates {
154 let at = format!("/templates/{id}");
155 let Some(template) = template.as_object() else {
156 issues.push(issue(at, "a template is an object"));
157 continue;
158 };
159 match template.get("components").and_then(Value::as_array).filter(|c| !c.is_empty()) {
160 None => issues.push(issue(format!("{at}/components"), "components is a non-empty array")),
161 Some(components) => {
162 let mut ids: HashSet<&str> = HashSet::new();
163 for (i, c) in components.iter().enumerate() {
164 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);
165 match id {
166 None => issues.push(issue(format!("{at}/components/{i}"), "a component needs an id and a component")),
167 Some(id) if ids.contains(id) => issues.push(issue(format!("{at}/components/{i}/id"), format!("duplicate id {id}"))),
168 Some(id) => {
169 ids.insert(id);
170 }
171 }
172 }
173 if !ids.contains("root") {
174 issues.push(issue(format!("{at}/components"), "no component has the id root"));
175 }
176 }
177 }
178 if let Some(bindings) = template.get("bindings") {
179 match bindings.as_array() {
180 None => issues.push(issue(format!("{at}/bindings"), "bindings is an array")),
181 Some(list) => {
182 for (i, b) in list.iter().enumerate() {
183 let bp = format!("{at}/bindings/{i}");
184 let b = b.as_object();
185 if b.and_then(|o| o.get("path")).and_then(Value::as_str).is_none_or(|p| !is_pointer(p)) {
186 issues.push(issue(format!("{bp}/path"), "path is a JSON pointer"));
187 }
188 if b.and_then(|o| o.get("source")).and_then(Value::as_str).is_none_or(|s| parse_source(s).is_none()) {
189 issues.push(issue(format!("{bp}/source"), "source is a scheme:name URI"));
190 }
191 }
192 }
193 }
194 }
195 }
196 }
197 }
198 if let Some(functions) = p.get("functions") {
199 let ok = functions.as_array().is_some_and(|list| list.iter().all(|f| f.as_str().is_some_and(|s| !s.is_empty())));
200 if !ok {
201 issues.push(issue("/functions", "functions is a list of names or prefix* patterns"));
202 }
203 }
204 if let Some(records) = p.get("records") {
206 match records.as_object() {
207 None => issues.push(issue("/records", "records is an object of kinds")),
208 Some(kinds) => {
209 let mut names: Vec<&String> = kinds.keys().collect();
210 names.sort();
211 for kind in names {
212 let def = &kinds[kind];
213 let at = format!("/records/{kind}");
214 if !is_record_kind(kind) {
215 issues.push(issue(&at, "a kind is lowercase letters, digits and underscores"));
216 }
217 match def.as_object() {
218 None => issues.push(issue(&at, "a kind is {schema, title?}")),
219 Some(def) => issues.extend(validate_record_schema(def.get("schema").unwrap_or(&Value::Null), &format!("{at}/schema"))),
220 }
221 }
222 }
223 }
224 }
225 if let Some(sources) = p.get("sources") {
228 match sources.as_array().filter(|list| list.iter().all(|f| f.as_str().is_some_and(|s| !s.is_empty()))) {
229 None => issues.push(issue("/sources", "sources is a list of scheme:name sources or prefix* patterns")),
230 Some(list) => {
231 let allowed: Vec<String> = list.iter().filter_map(Value::as_str).map(str::to_string).collect();
232 for (id, template) in p.get("templates").and_then(Value::as_object).into_iter().flatten() {
233 for (i, b) in template.get("bindings").and_then(Value::as_array).into_iter().flatten().enumerate() {
234 if let Some(source) = b.get("source").and_then(Value::as_str).filter(|s| parse_source(s).is_some()) {
235 if !source_allowed(Some(&allowed), source) {
236 issues.push(issue(format!("/templates/{id}/bindings/{i}/source"), format!("source {source} is not in the package's sources")));
237 }
238 }
239 }
240 }
241 }
242 }
243 }
244 for (id, template) in p.get("templates").and_then(Value::as_object).into_iter().flatten() {
246 let Some(bindings) = template.as_object().and_then(|t| t.get("bindings")).and_then(Value::as_array) else { continue };
247 for (i, b) in bindings.iter().enumerate() {
248 let Some(parsed) = b.as_object().and_then(|o| o.get("source")).and_then(Value::as_str).and_then(parse_source) else { continue };
249 if parsed.scheme != RECORDS_SCHEME {
250 continue;
251 }
252 if !p.get("records").and_then(Value::as_object).is_some_and(|r| r.contains_key(&parsed.name)) {
253 issues.push(issue(format!("/templates/{id}/bindings/{i}/source"), format!("records:{} names no declared kind", parsed.name)));
254 }
255 }
256 }
257 if let Some(tools) = p.get("tools") {
258 issues.extend(validate_tools(tools));
259 }
260 if let Some(upstreams) = p.get("upstreams") {
261 issues.extend(validate_upstreams(upstreams));
262 }
263 if let Some(exposes) = p.get("exposes") {
264 issues.extend(validate_package_exposes(exposes, p.get("records"), p.get("upstreams")));
265 }
266 if let Some(list) = p.get("extensions") {
267 issues.extend(validate_extensions(list, catalog_ids, extensions));
268 }
269 issues
270}
271
272fn validate_extensions<S: AsRef<str>>(list: &Value, catalog_ids: &[S], available: Option<&[AvailableExtension]>) -> Vec<PackageIssue> {
273 let Some(list) = list.as_array() else { return vec![issue("/extensions", "extensions is a list of {id, version, components?}")] };
274 let mut issues = Vec::new();
275 let mut seen: HashSet<&str> = HashSet::new();
276 for (i, ext) in list.iter().enumerate() {
277 let at = format!("/extensions/{i}");
278 let Some(ext) = ext.as_object() else {
279 issues.push(issue(at, "an extension is {id, version, components?}"));
280 continue;
281 };
282 let id = ext.get("id").and_then(Value::as_str).filter(|s| !s.is_empty());
283 if id.is_none() {
284 issues.push(issue(format!("{at}/id"), "id is a required string"));
285 }
286 let version = ext.get("version").and_then(Value::as_str).filter(|v| semver(v).is_some());
287 if version.is_none() {
288 issues.push(issue(format!("{at}/version"), "version is MAJOR.MINOR.PATCH"));
289 }
290 let components: Option<Vec<&str>> = match ext.get("components") {
291 None => Some(Vec::new()),
292 Some(c) => c.as_array().and_then(|l| l.iter().map(|c| c.as_str().filter(|s| !s.is_empty())).collect()),
293 };
294 if components.is_none() {
295 issues.push(issue(format!("{at}/components"), "components is a list of component names"));
296 }
297 let Some(id) = id else { continue };
298 if !seen.insert(id) {
299 issues.push(issue(format!("{at}/id"), format!("duplicate extension {id}")));
300 continue;
301 }
302 let have = available.and_then(|a| a.iter().find(|a| a.id == id));
303 if !catalog_ids.iter().any(|c| c.as_ref() == id) || (available.is_some() && have.is_none()) {
304 issues.push(issue(format!("{at}/id"), format!("extension {id} is not available on this host")));
305 continue;
306 }
307 let Some(have) = have else { continue };
308 if let (Some(version), Some(have_version)) = (version, have.version.as_deref()) {
309 if !version_satisfies(have_version, version) {
310 issues.push(issue(format!("{at}/version"), format!("extension {id} {have_version} does not satisfy ^{version}")));
311 }
312 }
313 if let Some(have_components) = &have.components {
314 for (j, name) in components.iter().flatten().enumerate() {
315 if !have_components.iter().any(|c| c == name) {
316 issues.push(issue(format!("{at}/components/{j}"), format!("extension {id} has no component {name}")));
317 }
318 }
319 }
320 }
321 issues
322}
323
324pub fn source_allowed(allowed: Option<&[String]>, source: &str) -> bool {
329 let Some(allowed) = allowed else { return true };
330 let Some(parsed) = parse_source(source) else { return false };
331 let name = format!("{}:{}", parsed.scheme, parsed.name);
332 allowed.iter().any(|p| matches_pattern(p, &name) || matches_pattern(p, source))
333}
334
335pub fn package_sources(pkg: &Value) -> Option<Vec<String>> {
337 pkg.get("sources").and_then(Value::as_array).map(|l| l.iter().filter_map(Value::as_str).map(str::to_string).collect())
338}
339
340pub fn template_messages(pkg: &Value, template_id: &str, surface_id: &str, data: Option<&Value>) -> Option<Vec<Value>> {
345 let template = pkg.get("templates").and_then(|t| t.get(template_id)).filter(|t| super::js_truthy(t))?;
346 let version = A2UI_VERSION;
347 let mut create = Map::new();
348 create.insert("surfaceId".into(), Value::String(surface_id.into()));
349 if let Some(catalog_id) = pkg.get("catalogId") {
350 create.insert("catalogId".into(), catalog_id.clone());
351 }
352 let mut components = Map::new();
353 components.insert("surfaceId".into(), Value::String(surface_id.into()));
354 if let Some(list) = template.get("components") {
355 components.insert("components".into(), list.clone());
356 }
357 let mut out = vec![json!({"version": version, "createSurface": create}), json!({"version": version, "updateComponents": components})];
358 if let Some(merged) = merge_data(template.get("data"), data) {
359 out.push(json!({"version": version, "updateDataModel": {"surfaceId": surface_id, "path": "/", "value": merged}}));
360 }
361 for b in template.get("bindings").and_then(Value::as_array).into_iter().flatten() {
362 let mut bind = Map::new();
363 bind.insert("surfaceId".into(), Value::String(surface_id.into()));
364 for key in ["path", "source"] {
365 if let Some(v) = b.get(key) {
366 bind.insert(key.into(), v.clone());
367 }
368 }
369 out.push(json!({"version": version, "bindDataModel": bind}));
370 }
371 Some(out)
372}
373
374pub fn merge_data(base: Option<&Value>, over: Option<&Value>) -> Option<Value> {
377 match (base, over) {
378 (base, None) => base.cloned(),
379 (Some(Value::Object(b)), Some(Value::Object(o))) => {
380 let mut merged = b.clone();
381 for (k, v) in o {
382 merged.insert(k.clone(), v.clone());
383 }
384 Some(Value::Object(merged))
385 }
386 (_, Some(over)) => Some(over.clone()),
387 }
388}