From d887e06d7c305de4a31d01d28c32b0a18cc62626 Mon Sep 17 00:00:00 2001 From: Mathias Vanden Auweele Date: Wed, 21 Oct 2026 18:21:29 +0000 Subject: [PATCH 1/8] feat(core): read JSON Schema, OpenAPI and AsyncAPI into the model Reads JSON Schema draft-04, -06, -07, 2019-09 and 2020-12 (picked by $schema, 2020-12 by default) from in-memory JSON and YAML files, and the schemas of OpenAPI 3.0/3.1 and AsyncAPI 2/3 documents (components.schemas and message payloads). $ref is resolved across files, JSON pointers, $id, $anchor and $dynamicAnchor; unresolved references become diagnostics. Named schemas become global types, properties local elements with a cardinality from required and minItems/maxItems, allOf with a $ref an extension, oneOf/anyOf choices or unions, enum/const enumerations and the validation keywords facets. Bundles carry language: jsonSchema and a json block per component (kind, pointer, title, examples, deprecated, ...). The diff treats JSON facets (minimum, maxItems, nullable, ...) by their direction and reports use JSON terms. --- Cargo.lock | 26 + crates/core/Cargo.toml | 3 +- crates/core/src/bundle.rs | 62 +- crates/core/src/diff.rs | 31 +- crates/core/src/json.rs | 1703 +++++++++++++++++++++++++++++++++++++ crates/core/src/lib.rs | 170 +++- crates/core/src/report.rs | 30 +- 7 files changed, 1984 insertions(+), 41 deletions(-) create mode 100644 crates/core/src/json.rs diff --git a/Cargo.lock b/Cargo.lock index f45b704..389b080 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -738,6 +738,12 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "same-file" version = "1.0.6" @@ -768,6 +774,7 @@ dependencies = [ "roxmltree", "serde", "serde_json", + "serde_yaml_ng", ] [[package]] @@ -831,6 +838,19 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_yaml_ng" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4db627b98b36d4203a7b458cf3573730f2bb591b28871d916dfa9efabfd41f" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -1019,6 +1039,12 @@ version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + [[package]] name = "url" version = "2.5.8" diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 1c47249..c92e4cc 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "schema-explorer-core" -description = "Reads XML Schema (XSD) sets into one indexed model for the schema-explorer viewer" +description = "Reads XML Schema (XSD) and JSON Schema (also OpenAPI and AsyncAPI) sets into one indexed model for the schema-explorer viewer" version.workspace = true edition.workspace = true authors.workspace = true @@ -12,5 +12,6 @@ categories = ["parser-implementations", "visualization"] [dependencies] roxmltree = "0.21.1" +serde_yaml_ng = "0.10" serde.workspace = true serde_json.workspace = true diff --git a/crates/core/src/bundle.rs b/crates/core/src/bundle.rs index bf22096..aa961a5 100644 --- a/crates/core/src/bundle.rs +++ b/crates/core/src/bundle.rs @@ -1,4 +1,4 @@ -//! The bundle: one XSD set read into components with small integer ids, plus +//! The bundle: one XSD or JSON Schema set read into components with small integer ids, plus //! the indexes the viewer needs ("where used", derived types, substitution //! group members, effective content models, search), as serde JSON. @@ -20,6 +20,9 @@ pub type NsId = u32; pub struct Bundle { pub format: u32, pub generator: String, + /// The schema language the set was read from. + #[serde(default, skip_serializing_if = "Language::is_xsd")] + pub language: Language, /// True when the bundle was written in the compact form: no /// `effective` content models and no `search` index (the viewer /// recomputes them). @@ -38,6 +41,25 @@ pub struct Bundle { pub search: BTreeMap>, } +/// The schema language of a bundle. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Language { + /// XML Schema (XSD). + #[default] + Xsd, + /// JSON Schema, including the schemas of OpenAPI and AsyncAPI documents. + /// Named schemas are complex or simple types, properties are local + /// elements; [`Component::json`] says what they are in JSON terms. + JsonSchema, +} + +impl Language { + pub fn is_xsd(&self) -> bool { + *self == Language::Xsd + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct FileInfo { @@ -55,6 +77,10 @@ pub struct FileInfo { pub prefixes: BTreeMap, #[serde(default, skip_serializing_if = "Option::is_none")] pub version: Option, + /// The schema language and version of a JSON file: `JSON Schema + /// 2020-12`, `OpenAPI 3.1.0`, `AsyncAPI 3.0.0`, ... + #[serde(default, skip_serializing_if = "Option::is_none")] + pub dialect: Option, #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub element_form_qualified: bool, #[serde(default, skip_serializing_if = "std::ops::Not::not")] @@ -195,6 +221,39 @@ pub struct Component { /// A built-in type or attribute (no source file). #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub builtin: bool, + /// JSON Schema details, for a bundle read from JSON Schema. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub json: Option>, +} + +/// What a component is in JSON Schema terms. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct JsonInfo { + /// For a type: the JSON value it describes (`object`, `array`, + /// `string`, `number`, `integer`, `boolean`, `null`, `any`) or how it + /// combines schemas (`oneOf`, `anyOf`, `union`). For a local element: + /// its role (`property`, `patternProperty`, `additionalProperties`, + /// `items`, `option`). + pub kind: String, + /// JSON pointer of the schema in its file. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub pointer: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub title: Option, + /// `examples` and `example`, as JSON text. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub examples: Vec, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub deprecated: bool, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub read_only: bool, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub write_only: bool, + /// A property whose value is an array: the element has the item type, + /// and its cardinality comes from `minItems`/`maxItems`. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub array: bool, } impl Component { @@ -218,6 +277,7 @@ impl Component { attribute_group: None, identity: None, builtin: false, + json: None, } } pub fn is_global(&self) -> bool { diff --git a/crates/core/src/diff.rs b/crates/core/src/diff.rs index 58122fe..1074ba6 100644 --- a/crates/core/src/diff.rs +++ b/crates/core/src/diff.rs @@ -287,6 +287,9 @@ pub struct SetInfo { pub struct SchemaDiff { pub format: u32, pub generator: String, + /// The schema language of the new version. + #[serde(default, skip_serializing_if = "Language::is_xsd")] + pub language: Language, pub options: DiffOptions, pub old: SetInfo, pub new: SetInfo, @@ -465,6 +468,7 @@ pub fn diff(old: &Bundle, new: &Bundle, opts: &DiffOptions) -> SchemaDiff { .collect(); SchemaDiff { format: DIFF_FORMAT, + language: new.language, generator: format!("schema-explorer-core {}", crate::version()), options: opts.clone(), old: set_info(old, ga.len()), @@ -1216,7 +1220,12 @@ impl<'a> Cx<'a> { (Some(a), None) => self.push( out, ChangeKind::FacetRemoved, - Impact::NonBreaking, + // JSON Schema `nullable`: no longer allowing null is breaking. + if k == "nullable" { + Impact::Breaking + } else { + Impact::NonBreaking + }, path, k, Some(a.join(" | ")), @@ -1225,7 +1234,11 @@ impl<'a> Cx<'a> { (None, Some(b)) => self.push( out, ChangeKind::FacetAdded, - Impact::Breaking, + if k == "nullable" { + Impact::NonBreaking + } else { + Impact::Breaking + }, path, k, None, @@ -2132,6 +2145,9 @@ fn facet_impact(kind: &str, a: &[String], b: &[String]) -> Impact { let tighter = match kind { "minLength" | "minInclusive" | "minExclusive" => y > x, "maxLength" | "maxInclusive" | "maxExclusive" | "totalDigits" | "fractionDigits" => y < x, + // JSON Schema. + "minimum" | "exclusiveMinimum" | "minItems" | "minProperties" | "minContains" => y > x, + "maximum" | "exclusiveMaximum" | "maxItems" | "maxProperties" | "maxContains" => y < x, _ => true, }; if tighter { @@ -2186,6 +2202,17 @@ pub fn kind_name(k: Kind) -> &'static str { } } +/// The name of a kind in the terms of a schema language: `complexType` +/// in XSD, `object` in JSON Schema, ... +pub fn kind_name_in(lang: Language, k: Kind) -> &'static str { + match (lang, k) { + (Language::JsonSchema, Kind::Element) => "property", + (Language::JsonSchema, Kind::ComplexType) => "object", + (Language::JsonSchema, Kind::SimpleType) => "scalar", + _ => kind_name(k), + } +} + /// A readable label of a change kind: "child added", ... pub fn change_label(k: ChangeKind) -> &'static str { use ChangeKind::*; diff --git a/crates/core/src/json.rs b/crates/core/src/json.rs new file mode 100644 index 0000000..ab8b084 --- /dev/null +++ b/crates/core/src/json.rs @@ -0,0 +1,1703 @@ +//! JSON Schema input: draft-04, -06, -07, 2019-09 and 2020-12 (picked by +//! `$schema`, 2020-12 by default), and the schemas of OpenAPI 3.x and +//! AsyncAPI 2/3 documents, in JSON or YAML. +//! +//! The schemas are mapped onto the XSD model so that the viewer, search, +//! where-used and the version comparison work unchanged: named schemas +//! (`$defs`, `definitions`, `components.schemas`, message payloads, the root +//! schema of a file and every other `$ref` target) become global complex or +//! simple types; object properties become local elements with a cardinality +//! from `required` and `minItems`/`maxItems`; a `$ref` in `allOf` is the base +//! type (extension); `oneOf`/`anyOf` are choices (or unions of simple +//! types); `enum`/`const` are enumerations and the validation keywords are +//! facets. [`JsonInfo`] keeps what each component is in JSON terms. + +use crate::bundle::*; +use crate::input::{self, Resolver, Source}; +use serde_json::{Map, Value}; +use std::collections::{HashMap, HashSet, VecDeque}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Flavor { + Schema, + OpenApi, + AsyncApi, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +enum Draft { + D4, + D6, + D7, + D2019, + D2020, +} + +pub(crate) struct JDoc { + pub name: String, + pub display: String, + pub input: bool, + pub value: Option, + flavor: Flavor, + draft: Draft, + /// `JSON Schema 2020-12`, `OpenAPI 3.1.0`, ... + pub label: String, + /// Other files named by a `$ref`: location and the file it was found as. + pub refs: Vec<(String, Option)>, +} + +/// A file name that the JSON reader takes: `.json`, `.yaml`, `.yml`. +pub fn is_json_name(name: &str) -> bool { + let l = name.to_ascii_lowercase(); + l.ends_with(".json") || l.ends_with(".yaml") || l.ends_with(".yml") +} + +/// Keywords whose values are data, not schemas: never searched for `$ref`, +/// `$id` or `$defs`. +const DATA: &[&str] = &["enum", "const", "default", "examples", "example"]; + +/// Keywords that only annotate: a `$ref` next to them is still a plain reference. +const ANNOTATIONS: &[&str] = &[ + "description", + "title", + "$comment", + "examples", + "example", + "deprecated", + "readOnly", + "writeOnly", + "default", + "nullable", + "$id", + "$schema", + "$anchor", + "id", + "externalDocs", + "xml", +]; + +fn is_annotation(k: &str) -> bool { + ANNOTATIONS.contains(&k) || k.starts_with("x-") +} + +/// Keywords that make a schema an object or array (a complex type). +const STRUCTURAL: &[&str] = &[ + "properties", + "patternProperties", + "additionalProperties", + "unevaluatedProperties", + "items", + "prefixItems", + "additionalItems", + "required", + "minProperties", + "maxProperties", + "dependentSchemas", + "propertyNames", + "discriminator", +]; + +fn parse(name: &str, bytes: &[u8]) -> Result { + let (text, _) = input::decode(bytes); + let t = text.trim_start_matches('\u{feff}'); + let l = name.to_ascii_lowercase(); + let yaml = l.ends_with(".yaml") || l.ends_with(".yml"); + if !yaml || t.trim_start().starts_with('{') { + match serde_json::from_str::(t) { + Ok(v) => return Ok(v), + Err(e) if !yaml => return Err((e.to_string(), e.line() as u32)), + Err(_) => {} + } + } + let mut y: serde_yaml_ng::Value = serde_yaml_ng::from_str(t).map_err(|e| { + let line = e.location().map_or(0, |l| l.line() as u32); + (e.to_string(), line) + })?; + y.apply_merge().map_err(|e| (e.to_string(), 0))?; + Ok(from_yaml(y)) +} + +fn from_yaml(y: serde_yaml_ng::Value) -> Value { + use serde_yaml_ng::Value as Y; + match y { + Y::Null => Value::Null, + Y::Bool(b) => Value::Bool(b), + Y::Number(n) => { + if let Some(i) = n.as_i64() { + Value::from(i) + } else if let Some(u) = n.as_u64() { + Value::from(u) + } else { + n.as_f64() + .and_then(serde_json::Number::from_f64) + .map_or(Value::Null, Value::Number) + } + } + Y::String(s) => Value::String(s), + Y::Sequence(s) => Value::Array(s.into_iter().map(from_yaml).collect()), + Y::Mapping(m) => Value::Object( + m.into_iter() + .map(|(k, v)| { + let k = match k { + Y::String(s) => s, + Y::Bool(b) => b.to_string(), + Y::Number(n) => n.to_string(), + Y::Null => "null".to_string(), + other => serde_yaml_ng::to_string(&other) + .unwrap_or_default() + .trim() + .to_string(), + }; + (k, from_yaml(v)) + }) + .collect(), + ), + Y::Tagged(t) => from_yaml(t.value), + } +} + +fn detect(v: &Value) -> (Flavor, Draft, String) { + let s = |k: &str| v.get(k).and_then(Value::as_str); + if let Some(ver) = s("openapi") { + let d = if ver.starts_with("3.0") { + Draft::D4 + } else { + Draft::D2020 + }; + return (Flavor::OpenApi, d, format!("OpenAPI {ver}")); + } + if let Some(ver) = s("asyncapi") { + return (Flavor::AsyncApi, Draft::D7, format!("AsyncAPI {ver}")); + } + if let Some(ver) = s("swagger") { + return (Flavor::Schema, Draft::D4, format!("Swagger {ver}")); + } + let d = match s("$schema") { + Some(u) if u.contains("draft-04") || u.contains("draft-03") => Draft::D4, + Some(u) if u.contains("draft-06") => Draft::D6, + Some(u) if u.contains("draft-07") => Draft::D7, + Some(u) if u.contains("2019-09") => Draft::D2019, + _ => Draft::D2020, + }; + let name = match d { + Draft::D4 => "draft-04", + Draft::D6 => "draft-06", + Draft::D7 => "draft-07", + Draft::D2019 => "2019-09", + Draft::D2020 => "2020-12", + }; + (Flavor::Schema, d, format!("JSON Schema {name}")) +} + +fn escape(seg: &str) -> String { + seg.replace('~', "~0").replace('/', "~1") +} + +fn unescape(seg: &str) -> String { + seg.replace("~1", "/").replace("~0", "~") +} + +fn child(ptr: &str, seg: &str) -> String { + format!("{ptr}/{}", escape(seg)) +} + +fn percent_decode(s: &str) -> String { + let b = s.as_bytes(); + let mut out = Vec::with_capacity(b.len()); + let mut i = 0; + while i < b.len() { + let hex = |c: u8| (c as char).to_digit(16); + if b[i] == b'%' + && i + 2 < b.len() + && let (Some(h), Some(l)) = (hex(b[i + 1]), hex(b[i + 2])) + { + out.push((h * 16 + l) as u8); + i += 3; + continue; + } + out.push(b[i]); + i += 1; + } + String::from_utf8_lossy(&out).into_owned() +} + +fn split_frag(r: &str) -> (&str, Option<&str>) { + match r.split_once('#') { + Some((u, f)) => (u, Some(f)), + None => (r, None), + } +} + +/// A URI with a scheme (`https:`, `urn:`, ...). +fn has_scheme(s: &str) -> bool { + match s.find(':') { + Some(i) if i > 1 => s[..i] + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.')), + _ => false, + } +} + +fn dot_segments(path: &str) -> String { + let mut out: Vec<&str> = Vec::new(); + for seg in path.split('/') { + match seg { + "." => {} + ".." => { + if out.len() > 1 { + out.pop(); + } + } + s => out.push(s), + } + } + out.join("/") +} + +/// Resolve `rel` against the base URI or file path `base`. +fn resolve_uri(base: &str, rel: &str) -> String { + if rel.is_empty() { + return base.to_string(); + } + if has_scheme(rel) { + return rel.to_string(); + } + if input::is_url(base) { + let start = base.find("://").map_or(0, |i| i + 3); + let host_end = base[start..].find('/').map_or(base.len(), |i| start + i); + if rel.starts_with('/') { + return format!("{}{}", &base[..host_end], dot_segments(rel)); + } + let dir = &base[..base + .rfind('/') + .filter(|&i| i >= host_end) + .map_or(base.len(), |i| i + 1)]; + let path = format!("{}{rel}", &dir[host_end..]); + let path = if path.starts_with('/') { + path + } else { + format!("/{path}") + }; + return format!("{}{}", &base[..host_end], dot_segments(&path)); + } + if has_scheme(base) { + return rel.to_string(); + } + input::join(base, rel) +} + +/// Every `$ref` string in `v` (not in data keywords), with the pointer of +/// the object that holds it. +fn refs<'v>(v: &'v Value, ptr: &str, out: &mut Vec<(String, &'v str)>) { + match v { + Value::Object(m) => { + if let Some(Value::String(r)) = refkw(m) { + out.push((ptr.to_string(), r)); + } + for (k, c) in m { + if !DATA.contains(&k.as_str()) && (c.is_object() || c.is_array()) { + refs(c, &child(ptr, k), out); + } + } + } + Value::Array(a) => { + for (i, c) in a.iter().enumerate() { + refs(c, &child(ptr, &i.to_string()), out); + } + } + _ => {} + } +} + +pub(crate) fn load( + sources: Vec, + mut resolver: Option<&mut Resolver<'_>>, + diags: &mut Vec, +) -> Vec { + let mut docs: Vec = Vec::new(); + let mut by_name: HashMap = HashMap::new(); + let add = |docs: &mut Vec, + by_name: &mut HashMap, + diags: &mut Vec, + name: String, + bytes: &[u8], + input: bool| { + let idx = docs.len(); + let value = match parse(&name, bytes) { + Ok(v) => Some(v), + Err((msg, line)) => { + diags.push(Diagnostic { + level: Level::Error, + code: "parse-error".into(), + message: msg, + file: Some(idx as FileId), + line, + }); + None + } + }; + let (flavor, draft, label) = value + .as_ref() + .map_or_else(|| (Flavor::Schema, Draft::D2020, String::new()), detect); + by_name.insert(name.clone(), idx); + docs.push(JDoc { + name, + display: String::new(), + input, + value, + flavor, + draft, + label, + refs: Vec::new(), + }); + idx + }; + for s in sources { + let name = input::normalize(&s.name); + if by_name.contains_key(&name) { + continue; + } + add(&mut docs, &mut by_name, diags, name, &s.bytes, true); + } + let mut queue: VecDeque = (0..docs.len()).collect(); + while let Some(i) = queue.pop_front() { + let Some(v) = &docs[i].value else { continue }; + let mut found = Vec::new(); + refs(v, "", &mut found); + let mut locations: Vec = Vec::new(); + for (_, r) in found { + let (u, _) = split_frag(r); + if !u.is_empty() && !locations.iter().any(|l| l == u) { + locations.push(u.to_string()); + } + } + let mut out = Vec::new(); + for loc in locations { + let target = if has_scheme(&loc) { + loc.clone() + } else { + input::join(&docs[i].name, &loc) + }; + let mut file = by_name.get(&target).copied(); + if file.is_none() + && let Some(r) = resolver.as_deref_mut() + && let Some(bytes) = r(&target) + { + let j = add(&mut docs, &mut by_name, diags, target, &bytes, false); + // A fragment file without `$schema` takes the dialect of + // the document that refers to it (OpenAPI 3.0, ...). + if docs[j].flavor == Flavor::Schema + && docs[j] + .value + .as_ref() + .is_some_and(|v| v.get("$schema").is_none() && v.get("swagger").is_none()) + { + docs[j].draft = docs[i].draft; + docs[j].label = docs[i].label.clone(); + } + queue.push_back(j); + file = Some(j); + } + out.push((loc, file)); + } + docs[i].refs = out; + } + let names: Vec<&str> = docs.iter().map(|f| f.name.as_str()).collect(); + let root = input::common_dir(&names); + for f in &mut docs { + f.display = f.name.strip_prefix(&root).unwrap_or(&f.name).to_string(); + } + docs +} + +/// `$id`s and anchors of all files. +#[derive(Default)] +struct Registry { + /// Absolute URI (or file name) of a schema resource to its location. + resources: HashMap, + /// `uri#anchor` to its location. + anchors: HashMap, + /// The base URI of the objects that set one with `$id`. + bases: HashMap<(usize, String), String>, +} + +impl Registry { + fn new(docs: &[JDoc]) -> Registry { + let mut reg = Registry::default(); + for (d, doc) in docs.iter().enumerate() { + reg.resources + .entry(doc.name.clone()) + .or_insert((d, String::new())); + if let Some(v) = &doc.value { + reg.walk(d, v, "", &doc.name, doc.draft); + } + } + reg + } + + fn walk(&mut self, d: usize, v: &Value, ptr: &str, base: &str, draft: Draft) { + match v { + Value::Object(m) => { + let mut base = base.to_string(); + let id = m.get("$id").or(if draft == Draft::D4 { + m.get("id") + } else { + None + }); + if let Some(Value::String(id)) = id { + let (u, frag) = split_frag(id); + if !u.is_empty() { + base = resolve_uri(&base, u); + self.resources + .entry(base.clone()) + .or_insert((d, ptr.to_string())); + self.bases.insert((d, ptr.to_string()), base.clone()); + } + if let Some(f) = frag.filter(|f| !f.is_empty()) { + self.anchors + .insert(format!("{base}#{f}"), (d, ptr.to_string())); + } + } + for k in ["$anchor", "$dynamicAnchor"] { + if let Some(Value::String(a)) = m.get(k) { + self.anchors + .entry(format!("{base}#{a}")) + .or_insert((d, ptr.to_string())); + } + } + for (k, c) in m { + if !DATA.contains(&k.as_str()) && (c.is_object() || c.is_array()) { + self.walk(d, c, &child(ptr, k), &base, draft); + } + } + } + Value::Array(a) => { + for (i, c) in a.iter().enumerate() { + self.walk(d, c, &child(ptr, &i.to_string()), base, draft); + } + } + _ => {} + } + } + + fn base_of(&self, docs: &[JDoc], d: usize, ptr: &str) -> String { + let mut p = ptr; + loop { + if let Some(b) = self.bases.get(&(d, p.to_string())) { + return b.clone(); + } + if p.is_empty() { + return docs[d].name.clone(); + } + p = &p[..p.rfind('/').unwrap_or(0)]; + } + } + + /// The location a `$ref` in the object at `ptr` of file `d` points to. + fn resolve(&self, docs: &[JDoc], d: usize, ptr: &str, r: &str) -> Option<(usize, String)> { + let base = self.base_of(docs, d, ptr); + let (u, frag) = split_frag(r); + let root = if u.is_empty() { + self.resources + .get(&base) + .cloned() + .unwrap_or((d, String::new())) + } else { + let abs = resolve_uri(&base, u); + self.resources + .get(&abs) + .or_else(|| self.resources.get(&input::join(&docs[d].name, u))) + .cloned()? + }; + let frag = percent_decode(frag.unwrap_or("")); + let loc = if frag.is_empty() { + root + } else if frag.starts_with('/') { + (root.0, format!("{}{frag}", root.1)) + } else { + let b = self.base_of(docs, root.0, &root.1); + self.anchors.get(&format!("{b}#{frag}")).cloned()? + }; + docs[loc.0].value.as_ref()?.pointer(&loc.1)?; + Some(loc) + } +} + +fn stem(name: &str) -> String { + let base = name.rsplit('/').next().unwrap_or(name); + let mut s = base; + for ext in [".json", ".yaml", ".yml"] { + if s.len() > ext.len() && s[s.len() - ext.len()..].eq_ignore_ascii_case(ext) { + s = &s[..s.len() - ext.len()]; + break; + } + } + s.strip_suffix(".schema").unwrap_or(s).to_string() +} + +const KEYWORD_SEGMENTS: &[&str] = &[ + "schema", + "items", + "additionalProperties", + "payload", + "content", + "properties", + "allOf", + "anyOf", + "oneOf", + "not", + "$defs", + "definitions", + "patternProperties", + "prefixItems", + "schemas", + "components", +]; + +/// A name for the schema at `ptr` that is not a named definition. +fn name_hint(ptr: &str) -> Option { + let segs: Vec = ptr.split('/').skip(1).map(unescape).collect(); + segs.into_iter().rev().find(|s| { + !s.is_empty() + && !s.contains('/') + && !s.chars().all(|c| c.is_ascii_digit()) + && !KEYWORD_SEGMENTS.contains(&s.as_str()) + }) +} + +fn text(v: &Value) -> String { + match v { + Value::String(s) => s.clone(), + other => other.to_string(), + } +} + +fn types(m: &Map) -> Vec { + match m.get("type") { + Some(Value::String(t)) => vec![t.clone()], + Some(Value::Array(a)) => a + .iter() + .filter_map(Value::as_str) + .map(str::to_string) + .collect(), + _ => Vec::new(), + } +} + +const REF_KEYWORDS: &[&str] = &["$ref", "$dynamicRef", "$recursiveRef"]; + +/// The `$ref` of a schema, or its `$dynamicRef` / `$recursiveRef` (taken +/// as a plain reference to the anchor or resource it names). +fn refkw(m: &Map) -> Option<&Value> { + REF_KEYWORDS.iter().find_map(|k| m.get(*k)) +} + +fn is_ref_only(m: &Map) -> bool { + refkw(m).is_some() + && m.keys() + .all(|k| REF_KEYWORDS.contains(&k.as_str()) || is_annotation(k)) +} + +/// A schema that only names a JSON type: `{"type": "string"}`. +fn plain_type(m: &Map) -> Option { + if !m.keys().all(|k| k == "type" || is_annotation(k)) + || m.get("nullable") == Some(&Value::Bool(true)) + { + return None; + } + match m.get("type") { + None => Some("any".into()), + Some(Value::String(t)) => Some(t.clone()), + _ => None, + } +} + +fn is_array_schema(m: &Map) -> bool { + let t = types(m); + t.iter().any(|t| t == "array") + || (t.is_empty() && (m.contains_key("items") || m.contains_key("prefixItems"))) +} + +/// What a complex type is: `object`, `array`, `oneOf`, `anyOf`. +fn shape(m: &Map) -> &'static str { + let t = types(m); + if t.iter().any(|t| t == "object") || m.contains_key("properties") { + "object" + } else if is_array_schema(m) { + "array" + } else if m.contains_key("oneOf") { + "oneOf" + } else if m.contains_key("anyOf") { + "anyOf" + } else { + "object" + } +} + +fn facets(m: &Map, draft: Draft) -> Vec { + let mut out = Vec::new(); + let f = |kind: &str, value: String| Facet { + kind: kind.to_string(), + value, + fixed: false, + }; + let excl = |k: &str| draft == Draft::D4 && m.get(k) == Some(&Value::Bool(true)); + for k in [ + "format", + "pattern", + "minLength", + "maxLength", + "minimum", + "maximum", + "exclusiveMinimum", + "exclusiveMaximum", + "multipleOf", + "minItems", + "maxItems", + "uniqueItems", + "minContains", + "maxContains", + "minProperties", + "maxProperties", + "contentEncoding", + "contentMediaType", + ] { + let Some(v) = m.get(k) else { continue }; + match (k, v) { + ("exclusiveMinimum" | "exclusiveMaximum" | "uniqueItems", Value::Bool(_)) => { + if k == "uniqueItems" && v == &Value::Bool(true) { + out.push(f(k, "true".into())); + } + } + ("minimum", _) if excl("exclusiveMinimum") => out.push(f("exclusiveMinimum", text(v))), + ("maximum", _) if excl("exclusiveMaximum") => out.push(f("exclusiveMaximum", text(v))), + _ => out.push(f(k, text(v))), + } + } + if m.get("nullable") == Some(&Value::Bool(true)) + || types(m).iter().any(|t| t == "null") && types(m).len() > 1 + { + out.push(f("nullable", "true".into())); + } + out +} + +fn enumerations(m: &Map) -> Vec { + let mut out = Vec::new(); + let descs = m + .get("x-enum-descriptions") + .or_else(|| m.get("x-enumDescriptions")); + if let Some(Value::Array(vals)) = m.get("enum") { + for (i, v) in vals.iter().enumerate() { + let value = text(v); + let doc = match descs { + Some(Value::Array(a)) => a.get(i).and_then(Value::as_str), + Some(Value::Object(o)) => o.get(&value).and_then(Value::as_str), + _ => None, + }; + out.push(Enumeration { + value, + docs: doc + .map(|t| Doc { + lang: None, + source: None, + text: t.to_string(), + }) + .into_iter() + .collect(), + appinfo: Vec::new(), + line: 0, + }); + } + } + if let Some(v) = m.get("const") { + out.push(Enumeration { + value: text(v), + docs: Vec::new(), + appinfo: Vec::new(), + line: 0, + }); + } + out +} + +fn doc(text: &str) -> Doc { + Doc { + lang: None, + source: None, + text: text.to_string(), + } +} + +/// What the properties of an object and its inline `allOf` parts add up to. +#[derive(Default)] +struct Parts { + base: Option, + mixins: Vec, + props: Vec<(String, String)>, + required: Vec, + pattern: Vec<(String, String)>, + additional: Option, + open: Option, + closed_unevaluated: bool, + choices: Vec<(bool, String)>, + facets: Vec, + enums: Vec, +} + +pub(crate) struct Builder<'a> { + docs: &'a [JDoc], + reg: Registry, + pub components: Vec, + pub usages: Vec<(Id, Usage)>, + pub diagnostics: Vec, + pub namespaces: Vec, + pub file_ns: Vec>, + pub file_globals: Vec, + pub unresolved_refs: u32, + globals: HashMap<(usize, String), Id>, + taken: HashSet<(Option, String)>, + builtins: HashMap, + queue: VecDeque<(Id, usize, String)>, + simple_memo: HashMap<(usize, String), bool>, + mixins: Vec<(Id, Id)>, +} + +impl<'a> Builder<'a> { + pub fn new(docs: &'a [JDoc]) -> Builder<'a> { + Builder { + docs, + reg: Registry::new(docs), + components: Vec::new(), + usages: Vec::new(), + diagnostics: Vec::new(), + namespaces: Vec::new(), + file_ns: vec![None; docs.len()], + file_globals: vec![0; docs.len()], + unresolved_refs: 0, + globals: HashMap::new(), + taken: HashSet::new(), + builtins: HashMap::new(), + queue: VecDeque::new(), + simple_memo: HashMap::new(), + mixins: Vec::new(), + } + } + + fn value(&self, d: usize, ptr: &str) -> Option<&'a Value> { + self.docs[d].value.as_ref()?.pointer(ptr) + } + + /// The named schemas of a file: pointer and name. + fn entries(&self, d: usize) -> Vec<(String, String)> { + let doc = &self.docs[d]; + let Some(v) = &doc.value else { + return Vec::new(); + }; + let mut out = Vec::new(); + let map = |ptr: String, out: &mut Vec<(String, String)>| { + if let Some(Value::Object(m)) = v.pointer(&ptr) { + for k in m.keys() { + out.push((child(&ptr, k), k.clone())); + } + } + }; + match doc.flavor { + Flavor::Schema => { + if let Value::Object(m) = v + && m.keys().any(|k| { + STRUCTURAL.contains(&k.as_str()) + || matches!( + k.as_str(), + "type" | "allOf" | "anyOf" | "oneOf" | "$ref" | "enum" | "const" + ) + }) + && !m.contains_key("swagger") + { + out.push((String::new(), stem(&doc.name))); + } + } + Flavor::OpenApi => map("/components/schemas".into(), &mut out), + Flavor::AsyncApi => { + map("/components/schemas".into(), &mut out); + if let Some(Value::Object(msgs)) = v.pointer("/components/messages") { + for (k, msg) in msgs { + let Some(p) = msg.get("payload") else { + continue; + }; + let ptr = format!("{}/payload", child("/components/messages", k)); + if p.get("schemaFormat").is_some() { + if p.get("schema").is_some() + && p["schemaFormat"].as_str().is_some_and(|f| { + f.contains("json") + || f.contains("asyncapi") + || f.contains("openapi") + }) + { + out.push((format!("{ptr}/schema"), k.clone())); + } + } else { + out.push((ptr, k.clone())); + } + } + } + } + } + defs(v, "", "", &mut out); + out + } + + pub fn run(&mut self) { + let entries: Vec> = + (0..self.docs.len()).map(|d| self.entries(d)).collect(); + // One namespace per file only when names would clash between files. + let mut seen: HashMap<&str, usize> = HashMap::new(); + let mut clash = false; + for (d, list) in entries.iter().enumerate() { + for (_, n) in list { + if *seen.entry(n.as_str()).or_insert(d) != d { + clash = true; + } + } + } + if clash { + let mut prefixes: HashSet = HashSet::new(); + for (d, doc) in self.docs.iter().enumerate() { + if doc.value.is_none() { + continue; + } + let uri = doc + .value + .as_ref() + .and_then(|v| v.get("$id").or_else(|| v.get("id"))) + .and_then(Value::as_str) + .map_or_else(|| doc.display.clone(), str::to_string); + let mut p: String = stem(&doc.display) + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.' { + c + } else { + '_' + } + }) + .collect(); + if p.is_empty() { + p = "ns".into(); + } + let mut q = p.clone(); + let mut n = 2; + while !prefixes.insert(q.clone()) { + q = format!("{p}{n}"); + n += 1; + } + self.file_ns[d] = Some(self.namespaces.len() as NsId); + self.namespaces.push(Namespace { uri, prefix: q }); + } + } + for (d, list) in entries.into_iter().enumerate() { + for (ptr, name) in list { + let Some(v) = self.value(d, &ptr) else { + continue; + }; + // A named schema that is only a `$ref` (OpenAPI components + // pointing into other files) names its target instead. + if let Value::Object(m) = v + && let Some(Value::String(r)) = refkw(m) + && is_ref_only(m) + && let Some(t) = self.reg.resolve(self.docs, d, &ptr, r) + && t.0 != d + && self.value(t.0, &t.1).is_some_and(Value::is_object) + { + let id = self.global(t.0, &t.1, &name); + self.globals.insert((d, ptr), id); + continue; + } + if v.is_object() || v.is_boolean() { + self.global(d, &ptr, &name); + } + } + } + while let Some((id, d, ptr)) = self.queue.pop_front() { + self.fill(id, d, &ptr); + } + // Mixins: the properties of the other `allOf` references. + for (id, from) in std::mem::take(&mut self.mixins) { + let extra = self.components[from as usize] + .complex_type + .as_ref() + .and_then(|c| c.content.clone()); + let Some(extra) = extra else { continue }; + let Some(ct) = self.components[id as usize].complex_type.as_mut() else { + continue; + }; + let mine = ct.content.take(); + let mut items = match extra { + Particle::All { items, .. } => items, + p => vec![p], + }; + match mine { + Some(Particle::All { items: own, .. }) => items.extend(own), + Some(p) => items.push(p), + None => {} + } + ct.content = Some(Particle::All { + items, + occurs: Occurs::default(), + }); + } + // External files that nothing was found for. + for (d, doc) in self.docs.iter().enumerate() { + for (loc, file) in &doc.refs { + if file.is_some() { + continue; + } + let base = self.reg.base_of(self.docs, d, ""); + if self.reg.resources.contains_key(&resolve_uri(&base, loc)) { + continue; + } + self.diagnostics.push(Diagnostic { + level: Level::Warning, + code: "unresolved-import".into(), + message: format!("schema {loc} referenced with $ref was not found"), + file: Some(d as FileId), + line: 0, + }); + } + } + } + + fn unique(&mut self, ns: Option, name: &str) -> String { + let mut n = name.to_string(); + let mut i = 2; + while !self.taken.insert((ns, n.clone())) { + n = format!("{name}_{i}"); + i += 1; + } + n + } + + fn global(&mut self, d: usize, ptr: &str, name: &str) -> Id { + if let Some(&id) = self.globals.get(&(d, ptr.to_string())) { + return id; + } + let simple = self.is_simple(d, ptr, 0); + let ns = self.file_ns[d]; + let name = self.unique(ns, name); + let mut c = Component::new( + if simple { + Kind::SimpleType + } else { + Kind::ComplexType + }, + name, + ); + c.ns = ns; + c.file = Some(d as FileId); + c.json = Some(Box::new(JsonInfo { + pointer: ptr.to_string(), + ..Default::default() + })); + let id = self.components.len() as Id; + self.components.push(c); + self.globals.insert((d, ptr.to_string()), id); + self.file_globals[d] += 1; + self.queue.push_back((id, d, ptr.to_string())); + id + } + + fn is_simple(&mut self, d: usize, ptr: &str, depth: u32) -> bool { + let Some(Value::Object(m)) = self.value(d, ptr) else { + return true; + }; + if depth > 12 { + return false; + } + if m.keys().any(|k| STRUCTURAL.contains(&k.as_str())) { + return false; + } + if types(m).iter().any(|t| t == "object" || t == "array") { + return false; + } + if let Some(Value::String(r)) = refkw(m) + && let Some(t) = self.reg.resolve(self.docs, d, ptr, r) + { + if let Some(&s) = self.simple_memo.get(&t) { + if !s { + return false; + } + } else { + self.simple_memo.insert(t.clone(), true); + let s = self.is_simple(t.0, &t.1, depth + 1); + self.simple_memo.insert(t, s); + if !s { + return false; + } + } + } + for k in ["allOf", "oneOf", "anyOf"] { + if let Some(Value::Array(a)) = m.get(k) { + for i in 0..a.len() { + let p = child(&child(ptr, k), &i.to_string()); + if !self.is_simple(d, &p, depth + 1) { + return false; + } + } + } + } + true + } + + fn builtin(&mut self, name: &str) -> Ref { + if let Some(&id) = self.builtins.get(name) { + return Ref::Id(id); + } + let mut c = Component::new(Kind::SimpleType, name.to_string()); + c.builtin = true; + c.simple_type = Some(SimpleInfo { + variety: Variety::Atomic, + base: None, + item: None, + members: Vec::new(), + facets: Vec::new(), + enumerations: Vec::new(), + r#final: None, + derived: Vec::new(), + }); + c.json = Some(Box::new(JsonInfo { + kind: name.to_string(), + ..Default::default() + })); + let id = self.components.len() as Id; + self.components.push(c); + self.builtins.insert(name.to_string(), id); + Ref::Id(id) + } + + fn uses(&mut self, r: &Ref, by: Id, how: UseKind) { + if let Ref::Id(t) = r + && !self.components[*t as usize].builtin + { + self.usages.push((*t, Usage { by, how })); + } + } + + /// Follow a `$ref` (held by the object at `ptr`) to its global type. + fn ref_to(&mut self, d: usize, ptr: &str, r: &str, by: Id, how: UseKind) -> Ref { + let target = self.reg.resolve(self.docs, d, ptr, r); + match target { + Some((td, tptr)) + if self + .value(td, &tptr) + .is_some_and(|v| v.is_object() || v.is_boolean()) => + { + let name = if tptr.is_empty() { + stem(&self.docs[td].name) + } else { + name_hint(&tptr).unwrap_or_else(|| stem(&self.docs[td].name)) + }; + let g = self.global(td, &tptr, &name); + let r = Ref::Id(g); + self.uses(&r, by, how); + r + } + _ => { + self.unresolved_refs += 1; + self.diagnostics.push(Diagnostic { + level: Level::Warning, + code: "unresolved-ref".into(), + message: format!( + "$ref {r} at {} not resolved", + if ptr.is_empty() { "/" } else { ptr } + ), + file: Some(d as FileId), + line: 0, + }); + Ref::Missing(r.to_string()) + } + } + } + + /// The type of the schema at `ptr`: a global, a built-in or a new + /// anonymous type owned by `owner`. + fn type_ref(&mut self, d: usize, ptr: &str, owner: Id, how: UseKind) -> Option { + if let Some(&g) = self.globals.get(&(d, ptr.to_string())) { + let r = Ref::Id(g); + self.uses(&r, owner, how); + return Some(r); + } + match self.value(d, ptr)? { + Value::Bool(_) => Some(self.builtin("any")), + Value::Object(m) => { + if let Some(Value::String(r)) = refkw(m) + && is_ref_only(m) + { + return Some(self.ref_to(d, ptr, r, owner, how)); + } + // `allOf: [{$ref}]` next to annotations only: a plain reference too. + if let Some(Value::Array(all)) = m.get("allOf") + && let [Value::Object(one)] = all.as_slice() + && let Some(Value::String(r)) = refkw(one) + && is_ref_only(one) + && m.keys().all(|k| k == "allOf" || is_annotation(k)) + { + let holder = child(&child(ptr, "allOf"), "0"); + return Some(self.ref_to(d, &holder, r, owner, how)); + } + if let Some(t) = plain_type(m) { + return Some(self.builtin(&t)); + } + let simple = self.is_simple(d, ptr, 0); + let mut c = Component::new( + if simple { + Kind::SimpleType + } else { + Kind::ComplexType + }, + String::new(), + ); + c.parent = Some(owner); + c.file = Some(d as FileId); + c.json = Some(Box::new(JsonInfo { + pointer: ptr.to_string(), + ..Default::default() + })); + let id = self.components.len() as Id; + self.components.push(c); + self.fill(id, d, ptr); + Some(Ref::Id(id)) + } + _ => None, + } + } + + fn annotate(&mut self, id: Id, v: &Value) { + let Value::Object(m) = v else { return }; + let c = &mut self.components[id as usize]; + let j = c.json.get_or_insert_with(Default::default); + let desc = m.get("description").and_then(Value::as_str); + let title = m.get("title").and_then(Value::as_str); + if let Some(t) = desc.or(title) + && !t.trim().is_empty() + { + c.docs.push(doc(t)); + } + if desc.is_some() { + j.title = title.map(str::to_string); + } + if let Some(Value::Array(a)) = m.get("examples") { + j.examples.extend(a.iter().map(Value::to_string)); + } + if let Some(e) = m.get("example") { + j.examples.push(e.to_string()); + } + j.deprecated = m.get("deprecated") == Some(&Value::Bool(true)); + j.read_only = m.get("readOnly") == Some(&Value::Bool(true)); + j.write_only = m.get("writeOnly") == Some(&Value::Bool(true)); + } + + fn fill(&mut self, id: Id, d: usize, ptr: &str) { + let Some(v) = self.value(d, ptr) else { return }; + self.annotate(id, v); + if self.components[id as usize].kind == Kind::SimpleType { + self.simple(id, d, ptr, v); + } else { + self.complex(id, d, ptr, v); + } + } + + fn simple(&mut self, id: Id, d: usize, ptr: &str, v: &'a Value) { + let mut info = SimpleInfo { + variety: Variety::Atomic, + base: None, + item: None, + members: Vec::new(), + facets: Vec::new(), + enumerations: Vec::new(), + r#final: None, + derived: Vec::new(), + }; + let mut kind = String::from("any"); + if let Value::Object(m) = v { + let draft = self.docs[d].draft; + let mut parts: Vec<(String, &Map)> = vec![(ptr.to_string(), m)]; + if let Some(Value::Array(a)) = m.get("allOf") { + for (i, p) in a.iter().enumerate() { + if let Value::Object(pm) = p { + parts.push((child(&child(ptr, "allOf"), &i.to_string()), pm)); + } + } + } + for (pp, pm) in &parts { + if let Some(Value::String(r)) = refkw(pm) { + let r = self.ref_to(d, pp, r, id, UseKind::Base); + if info.base.is_none() { + info.base = Some(r); + } + } + info.facets.extend(facets(pm, draft)); + info.enumerations.extend(enumerations(pm)); + } + let t: Vec = types(m).into_iter().filter(|t| t != "null").collect(); + if let Some(first) = t.first() { + kind = first.clone(); + } + if info.base.is_none() { + if t.len() == 1 { + info.base = Some(self.builtin(&t[0])); + } else if t.len() > 1 { + info.variety = Variety::Union; + kind = "union".into(); + for x in &t { + let r = self.builtin(x); + info.members.push(r); + } + } + } + for k in ["oneOf", "anyOf"] { + let Some(Value::Array(alts)) = m.get(k) else { + continue; + }; + if alts.iter().all(|a| a.get("const").is_some()) { + for a in alts { + let mut e = Enumeration { + value: text(&a["const"]), + docs: Vec::new(), + appinfo: Vec::new(), + line: 0, + }; + if let Some(t) = a + .get("description") + .or_else(|| a.get("title")) + .and_then(Value::as_str) + { + e.docs.push(doc(t)); + } + info.enumerations.push(e); + } + continue; + } + info.variety = Variety::Union; + kind = "union".into(); + for i in 0..alts.len() { + let ap = child(&child(ptr, k), &i.to_string()); + if let Some(r) = self.type_ref(d, &ap, id, UseKind::Member) { + info.members.push(r); + } + } + } + if info.base.is_none() && info.members.is_empty() { + let mut vals: Vec<&Value> = m + .get("enum") + .and_then(Value::as_array) + .map(|a| a.iter().collect()) + .unwrap_or_default(); + for k in ["oneOf", "anyOf"] { + if let Some(Value::Array(alts)) = m.get(k) { + vals.extend(alts.iter().filter_map(|a| a.get("const"))); + } + } + let guess = if !vals.is_empty() && vals.iter().all(|v| v.is_string()) { + "string" + } else if !vals.is_empty() && vals.iter().all(|v| v.is_number()) { + "number" + } else { + "any" + }; + if guess != "any" { + kind = guess.into(); + } + info.base = Some(self.builtin(guess)); + } + if kind == "any" + && let Some(Ref::Id(b)) = &info.base + && let Some(j) = &self.components[*b as usize].json + { + kind = j.kind.clone(); + } + } else { + info.base = Some(self.builtin("any")); + } + let c = &mut self.components[id as usize]; + if let Some(j) = c.json.as_mut() { + j.kind = kind; + } + c.simple_type = Some(info); + } + + fn gather( + &mut self, + id: Id, + d: usize, + ptr: &str, + m: &'a Map, + parts: &mut Parts, + depth: u32, + ) { + let draft = self.docs[d].draft; + if let Some(Value::String(r)) = refkw(m) { + let r = self.ref_to(d, ptr, r, id, UseKind::Base); + if parts.base.is_none() { + parts.base = Some(r); + } else if let Ref::Id(t) = r { + parts.mixins.push(t); + } + } + if let Some(Value::Object(props)) = m.get("properties") { + for k in props.keys() { + if !parts.props.iter().any(|(n, _)| n == k) { + parts + .props + .push((k.clone(), child(&child(ptr, "properties"), k))); + } + } + } + if let Some(Value::Array(req)) = m.get("required") { + for r in req.iter().filter_map(Value::as_str) { + if !parts.required.iter().any(|x| x == r) { + parts.required.push(r.to_string()); + } + } + } + if let Some(Value::Object(pp)) = m.get("patternProperties") { + for k in pp.keys() { + parts + .pattern + .push((k.clone(), child(&child(ptr, "patternProperties"), k))); + } + } + match m.get("additionalProperties") { + Some(Value::Bool(b)) => parts.open = Some(*b), + Some(Value::Object(_)) => parts.additional = Some(child(ptr, "additionalProperties")), + _ => {} + } + if m.get("unevaluatedProperties") == Some(&Value::Bool(false)) { + parts.closed_unevaluated = true; + } + for (k, any) in [("oneOf", false), ("anyOf", true)] { + if let Some(Value::Array(a)) = m.get(k) + && !a.is_empty() + { + parts.choices.push((any, child(ptr, k))); + } + } + parts.facets.extend(facets(m, draft)); + parts.enums.extend(enumerations(m)); + if let Some(Value::Array(all)) = m.get("allOf") + && depth < 8 + { + for (i, p) in all.iter().enumerate() { + let pp = child(&child(ptr, "allOf"), &i.to_string()); + if let Value::Object(pm) = p { + self.gather(id, d, &pp, pm, parts, depth + 1); + } + } + } + } + + fn new_element(&mut self, owner: Id, d: usize, ptr: &str, name: &str, role: &str) -> Id { + let mut c = Component::new(Kind::Element, name.to_string()); + c.parent = Some(owner); + c.file = Some(d as FileId); + c.json = Some(Box::new(JsonInfo { + kind: role.to_string(), + pointer: ptr.to_string(), + ..Default::default() + })); + let id = self.components.len() as Id; + self.components.push(c); + id + } + + /// A local element for the property (or other subschema) at `ptr`. + fn property( + &mut self, + owner: Id, + d: usize, + ptr: &str, + name: &str, + role: &str, + occurs: Occurs, + ) -> Particle { + let el = self.new_element(owner, d, ptr, name, role); + let v = self.value(d, ptr); + let mut info = ElementInfo::default(); + let mut occurs = occurs; + let mut tptr = ptr.to_string(); + if let Some(v) = v { + self.annotate(el, v); + if let Value::Object(m) = v { + if role == "property" + && refkw(m).is_none() + && !self.globals.contains_key(&(d, ptr.to_string())) + && is_array_schema(m) + && m.get("items") + .is_some_and(|i| i.is_object() || i.is_boolean()) + && !m.contains_key("prefixItems") + && !m + .keys() + .any(|k| matches!(k.as_str(), "properties" | "oneOf" | "anyOf" | "allOf")) + { + let n = |k: &str| { + m.get(k) + .and_then(Value::as_u64) + .map(|x| x.min(u32::MAX as u64) as u32) + }; + let min = n("minItems").unwrap_or(0); + occurs = Occurs { + min: if occurs.min > 0 { min.max(1) } else { 0 }, + max: n("maxItems"), + }; + tptr = child(ptr, "items"); + if let Some(j) = self.components[el as usize].json.as_mut() { + j.array = true; + } + } + info.default = m.get("default").map(text); + info.fixed = m.get("const").map(text); + } + } + info.r#type = self.type_ref(d, &tptr, el, UseKind::Type); + self.components[el as usize].element = Some(info); + Particle::Element { + el: Ref::Id(el), + occurs, + } + } + + fn complex(&mut self, id: Id, d: usize, ptr: &str, v: &'a Value) { + let mut ct = ComplexInfo::default(); + let mut kind = "object"; + if let Value::Object(m) = v { + kind = shape(m); + let mut parts = Parts::default(); + self.gather(id, d, ptr, m, &mut parts, 0); + if let Some(base) = parts.base.take() { + ct.derivation = Some(Derivation { + method: Method::Extension, + base, + simple: false, + }); + } + for t in &parts.mixins { + self.mixins.push((id, *t)); + } + let mut items: Vec = Vec::new(); + for (name, pp) in std::mem::take(&mut parts.props) { + let req = parts.required.contains(&name); + let occ = Occurs { + min: req as u32, + max: Some(1), + }; + items.push(self.property(id, d, &pp, &name, "property", occ)); + } + let many = Occurs { min: 0, max: None }; + for (name, pp) in std::mem::take(&mut parts.pattern) { + items.push(self.property(id, d, &pp, &name, "patternProperty", many)); + } + if let Some(pp) = parts.additional.take() { + items.push(self.property(id, d, &pp, "*", "additionalProperties", many)); + } + if parts.open == Some(true) { + items.push(Particle::Any { + wildcard: Wildcard::default(), + occurs: many, + }); + } + let f = |k: &str, v: &str| Facet { + kind: k.into(), + value: v.into(), + fixed: false, + }; + if parts.open == Some(false) { + parts.facets.push(f("additionalProperties", "false")); + } + if parts.closed_unevaluated { + parts.facets.push(f("unevaluatedProperties", "false")); + } + let own: HashSet = items + .iter() + .filter_map(|p| match p { + Particle::Element { el: Ref::Id(e), .. } => { + Some(self.components[*e as usize].name.clone()) + } + _ => None, + }) + .collect(); + for r in &parts.required { + if !own.contains(r) { + parts.facets.push(f("required", r)); + } + } + // Arrays: tuple items, then the item schema. + if is_array_schema(m) { + let tuple = if self.docs[d].draft >= Draft::D2020 || m.contains_key("prefixItems") { + "prefixItems" + } else { + "items" + }; + if let Some(Value::Array(a)) = m.get(tuple) { + for i in 0..a.len() { + let pp = child(&child(ptr, tuple), &i.to_string()); + items.push(self.property( + id, + d, + &pp, + &format!("[{i}]"), + "items", + Occurs::default(), + )); + } + } + let rest = if tuple == "items" && m.get("items").is_some_and(Value::is_array) { + "additionalItems" + } else { + "items" + }; + if m.get(rest) + .is_some_and(|x| x.is_object() || x == &Value::Bool(true)) + { + let n = |k: &str| { + m.get(k) + .and_then(Value::as_u64) + .map(|x| x.min(u32::MAX as u64) as u32) + }; + let occ = Occurs { + min: n("minItems").unwrap_or(0), + max: n("maxItems"), + }; + let pp = child(ptr, rest); + items.push(self.property(id, d, &pp, "items", "items", occ)); + } + } + for (any, cp) in std::mem::take(&mut parts.choices) { + let n = self + .value(d, &cp) + .and_then(Value::as_array) + .map_or(0, Vec::len); + let mut alts = Vec::new(); + for i in 0..n { + let ap = child(&cp, &i.to_string()); + let name = self.option_name(d, &ap, i); + alts.push(self.property(id, d, &ap, &name, "option", Occurs::default())); + } + items.push(Particle::Choice { + items: alts, + occurs: Occurs { + min: 1, + max: if any { None } else { Some(1) }, + }, + }); + } + ct.facets = parts.facets; + ct.enumerations = parts.enums; + ct.content = match items.len() { + 0 => None, + 1 if matches!(items[0], Particle::Choice { .. }) => items.pop(), + _ => Some(Particle::All { + items, + occurs: Occurs::default(), + }), + }; + } + let c = &mut self.components[id as usize]; + if let Some(j) = c.json.as_mut() { + j.kind = kind.to_string(); + } + c.complex_type = Some(ct); + } + + /// The name of a `oneOf`/`anyOf` alternative: the type it refers to, + /// its title, its JSON type or its position. + fn option_name(&mut self, d: usize, ptr: &str, i: usize) -> String { + let Some(Value::Object(m)) = self.value(d, ptr) else { + return format!("option{}", i + 1); + }; + if let Some(Value::String(r)) = refkw(m) + && let Some((td, tp)) = self.reg.resolve(self.docs, d, ptr, r) + { + return if tp.is_empty() { + stem(&self.docs[td].name) + } else if let Some(&g) = self.globals.get(&(td, tp.clone())) { + self.components[g as usize].name.clone() + } else { + name_hint(&tp).unwrap_or_else(|| stem(&self.docs[td].name)) + }; + } + if let Some(t) = m.get("title").and_then(Value::as_str) { + return t.to_string(); + } + if let Some(t) = types(m).first() { + return t.clone(); + } + format!("option{}", i + 1) + } +} + +/// Named definitions (`$defs`, `definitions`) anywhere in a file. +fn defs(v: &Value, ptr: &str, parent: &str, out: &mut Vec<(String, String)>) { + match v { + Value::Object(m) => { + if !matches!(parent, "properties" | "patternProperties") { + for k in ["$defs", "definitions"] { + if let Some(Value::Object(d)) = m.get(k) { + let dp = child(ptr, k); + for name in d.keys() { + let p = child(&dp, name); + if !out.iter().any(|(x, _)| *x == p) { + out.push((p, name.clone())); + } + } + } + } + } + for (k, c) in m { + if !DATA.contains(&k.as_str()) && (c.is_object() || c.is_array()) { + defs(c, &child(ptr, k), k, out); + } + } + } + Value::Array(a) => { + for (i, c) in a.iter().enumerate() { + defs(c, &child(ptr, &i.to_string()), "", out); + } + } + _ => {} + } +} + +/// The schema at `pointer` in a JSON or YAML file, as indented JSON (for +/// the source view of the viewer). +pub fn source(name: &str, bytes: &[u8], pointer: &str) -> Option { + let v = parse(name, bytes).ok()?; + serde_json::to_string_pretty(v.pointer(pointer)?).ok() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn uris() { + assert_eq!( + resolve_uri("https://x.org/a/b.json", "c.json"), + "https://x.org/a/c.json" + ); + assert_eq!( + resolve_uri("https://x.org/a/b.json", "../c.json"), + "https://x.org/c.json" + ); + assert_eq!( + resolve_uri("https://x.org/a/b.json", "/d/e.json"), + "https://x.org/d/e.json" + ); + assert_eq!(resolve_uri("dir/a.json", "b.yaml"), "dir/b.yaml"); + assert_eq!(resolve_uri("dir/a.json", "urn:x:y"), "urn:x:y"); + assert_eq!(percent_decode("/a%20b"), "/a b"); + assert_eq!( + name_hint("/components/responses/Err/content/application~1json/schema").as_deref(), + Some("Err") + ); + assert_eq!(stem("x/address.schema.json"), "address"); + } +} diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index c7544d2..e56cb55 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -1,5 +1,6 @@ -//! Core of schema-explorer: reads XML Schema (XSD) sets, and later JSON Schema, -//! into one indexed model for the viewer. +//! Core of schema-explorer: reads XML Schema (XSD) sets and JSON Schema +//! (draft-04 to 2020-12, also inside OpenAPI 3.x and AsyncAPI 2/3 +//! documents, in JSON or YAML) into one indexed model for the viewer. //! //! The input is a set of in-memory files (name and bytes), so the same code //! runs in the command line tool and, compiled to WebAssembly, in the @@ -12,6 +13,7 @@ pub mod bundle; pub mod diff; pub mod index; pub mod input; +pub mod json; pub mod report; mod xsd; @@ -33,8 +35,139 @@ pub fn version() -> &'static str { env!("CARGO_PKG_VERSION") } -/// Read an XSD set into a bundle. +/// True when the files are a JSON Schema set (JSON or YAML files, none +/// of them XML). +pub fn is_json_schema_set(sources: &[Source]) -> bool { + !sources.is_empty() + && sources.iter().all(|s| { + json::is_json_name(&s.name) && { + let start = s.bytes.iter().find(|b| !b.is_ascii_whitespace()); + start != Some(&b'<') && !s.bytes.starts_with(&[0xEF, 0xBB, 0xBF, b'<']) + } + }) +} + +fn generator(opts: &Options) -> String { + if opts.generator.is_empty() { + format!("schema-explorer-core {}", version()) + } else { + opts.generator.clone() + } +} + +/// Read a JSON Schema set (or OpenAPI / AsyncAPI documents) into a bundle. +pub fn index_json( + sources: Vec, + resolver: Option<&mut Resolver<'_>>, + opts: &Options, +) -> Bundle { + let mut diagnostics = Vec::new(); + let docs = json::load(sources, resolver, &mut diagnostics); + let mut b = json::Builder::new(&docs); + b.run(); + let file_infos = docs + .iter() + .enumerate() + .map(|(i, d)| bundle::FileInfo { + name: d.display.clone(), + target_namespace: b.file_ns[i].map(|n| b.namespaces[n as usize].uri.clone()), + namespace: b.file_ns[i], + prefixes: Default::default(), + version: d + .value + .as_ref() + .and_then(|v| v.pointer("/info/version")) + .and_then(|v| v.as_str()) + .map(str::to_string), + dialect: (!d.label.is_empty()).then(|| d.label.clone()), + element_form_qualified: false, + attribute_form_qualified: false, + input: d.input, + directives: d + .refs + .iter() + .map(|(loc, file)| bundle::Directive { + kind: bundle::DirectiveKind::Import, + namespace: None, + location: Some(loc.clone()), + file: file.map(|j| j as bundle::FileId), + via: None, + line: 0, + }) + .collect(), + docs: d + .value + .as_ref() + .and_then(|v| { + v.get("description") + .or_else(|| v.pointer("/info/description")) + }) + .and_then(|v| v.as_str()) + .map(|t| { + vec![bundle::Doc { + lang: None, + source: None, + text: t.to_string(), + }] + }) + .unwrap_or_default(), + components: b.file_globals[i], + }) + .collect(); + diagnostics.append(&mut b.diagnostics); + let mut components = std::mem::take(&mut b.components); + index::links(&mut components, std::mem::take(&mut b.usages)); + index::effective(&mut components, &mut diagnostics); + finish( + bundle::Language::JsonSchema, + file_infos, + std::mem::take(&mut b.namespaces), + components, + diagnostics, + b.unresolved_refs, + opts, + ) +} + +fn finish( + language: bundle::Language, + files: Vec, + namespaces: Vec, + components: Vec, + diagnostics: Vec, + unresolved_refs: u32, + opts: &Options, +) -> Bundle { + let search = if opts.compact { + Default::default() + } else { + index::search(&components) + }; + let mut bundle = Bundle { + format: FORMAT, + language, + generator: generator(opts), + compact: false, + files, + namespaces, + components, + diagnostics, + stats: Default::default(), + search, + }; + bundle.stats = index::stats(&bundle, unresolved_refs); + if opts.compact { + bundle.make_compact(); + } + bundle +} + +/// Read a schema set into a bundle: XSD, or JSON Schema when every file +/// is JSON or YAML (see [`is_json_schema_set`]). pub fn index(sources: Vec, resolver: Option<&mut Resolver<'_>>, opts: &Options) -> Bundle { + if is_json_schema_set(&sources) { + return index_json(sources, resolver, opts); + } let mut diagnostics = Vec::new(); let files = input::load(sources, resolver, &mut diagnostics); let docs: Vec> = files @@ -64,6 +197,7 @@ pub fn index(sources: Vec, resolver: Option<&mut Resolver<'_>>, opts: &O version: root_el .and_then(|r| r.attribute("version")) .map(str::to_string), + dialect: None, element_form_qualified: root_el .is_some_and(|r| r.attribute("elementFormDefault") == Some("qualified")), attribute_form_qualified: root_el @@ -90,31 +224,15 @@ pub fn index(sources: Vec, resolver: Option<&mut Resolver<'_>>, opts: &O let mut components = std::mem::take(&mut b.components); index::links(&mut components, std::mem::take(&mut b.usages)); index::effective(&mut components, &mut diagnostics); - let search = if opts.compact { - Default::default() - } else { - index::search(&components) - }; - let mut bundle = Bundle { - format: FORMAT, - generator: if opts.generator.is_empty() { - format!("schema-explorer-core {}", version()) - } else { - opts.generator.clone() - }, - compact: false, - files: file_infos, - namespaces: std::mem::take(&mut b.namespaces), + finish( + bundle::Language::Xsd, + file_infos, + std::mem::take(&mut b.namespaces), components, diagnostics, - stats: Default::default(), - search, - }; - bundle.stats = index::stats(&bundle, b.unresolved_refs); - if opts.compact { - bundle.make_compact(); - } - bundle + b.unresolved_refs, + opts, + ) } /// Restore what [`Bundle::make_compact`] stripped: the effective content diff --git a/crates/core/src/report.rs b/crates/core/src/report.rs index 08e1259..708859d 100644 --- a/crates/core/src/report.rs +++ b/crates/core/src/report.rs @@ -4,9 +4,9 @@ use std::collections::BTreeMap; use std::fmt::Write; -use crate::bundle::Kind; +use crate::bundle::{Kind, Language}; use crate::diff::{ - ChangeKind, ComponentDiff, Counts, Impact, SchemaDiff, Status, change_label, kind_name, + ChangeKind, ComponentDiff, Counts, Impact, SchemaDiff, Status, change_label, kind_name_in, }; /// Inline text: `code` and **bold** spans are rendered by each backend. @@ -39,7 +39,15 @@ pub fn marker(k: ChangeKind) -> &'static str { } } -fn plural(k: Kind) -> &'static str { +fn plural(lang: Language, k: Kind) -> &'static str { + if lang == Language::JsonSchema { + match k { + Kind::ComplexType => return "Objects", + Kind::SimpleType => return "Scalars", + Kind::Element => return "Properties", + _ => {} + } + } match k { Kind::ComplexType => "Complex types", Kind::SimpleType => "Simple types", @@ -96,8 +104,8 @@ fn change_line(c: &crate::diff::Change) -> String { s } -fn heading(d: &ComponentDiff) -> String { - let mut s = format!("{} {}", kind_name(d.kind), code(d.qname())); +fn heading(lang: Language, d: &ComponentDiff) -> String { + let mut s = format!("{} {}", kind_name_in(lang, d.kind), code(d.qname())); if let Some(o) = &d.old && d.new.is_some() && o.qname != d.qname() @@ -168,7 +176,7 @@ fn blocks(d: &SchemaDiff) -> Vec { .summary .by_kind .iter() - .map(|(k, c)| counts_row(plural(*k).into(), c)) + .map(|(k, c)| counts_row(plural(d.language, *k).into(), c)) .collect(); rows.push(counts_row("**Total**".into(), t)); out.push(Block::Table(head(), rows)); @@ -237,14 +245,14 @@ fn blocks(d: &SchemaDiff) -> Vec { for (k, list) in &removed { out.push(Block::P(format!( "**{}** ({}): {}", - plural(*k), + plural(d.language, *k), list.len(), list.join(", ") ))); } } for c in &breaking { - out.push(Block::H(3, heading(c))); + out.push(Block::H(3, heading(d.language, c))); out.push(Block::List( c.changes .iter() @@ -268,7 +276,7 @@ fn blocks(d: &SchemaDiff) -> Vec { for (k, list) in &added { out.push(Block::P(format!( "**{}** ({}): {}", - plural(*k), + plural(d.language, *k), list.len(), list.join(", ") ))); @@ -289,7 +297,7 @@ fn blocks(d: &SchemaDiff) -> Vec { .iter() .map(|r| { vec![ - kind_name(r.kind).into(), + kind_name_in(d.language, r.kind).into(), code(&r.old.qname), code(&r.new.qname), format!("{}%", r.score), @@ -305,7 +313,7 @@ fn blocks(d: &SchemaDiff) -> Vec { .collect(); out.push(Block::H(2, format!("Other changes ({})", other.len()))); for c in &other { - out.push(Block::H(3, heading(c))); + out.push(Block::H(3, heading(d.language, c))); out.push(Block::List( c.changes .iter() From cee0759a25fa3c382c758aaefc19ea4504c36a17 Mon Sep 17 00:00:00 2001 From: Mathias Vanden Auweele Date: Wed, 21 Oct 2026 18:21:29 +0000 Subject: [PATCH 2/8] test: cover the JSON Schema reader and JSON Schema version diffs Hand-written fixtures for draft 2020-12 (cross-file $ref into YAML, $anchor, allOf inheritance, oneOf, enums, pattern and additional properties), draft-04, OpenAPI 3.0 and AsyncAPI 2.6, plus a v1/v2 pair for the diff. OSDM is fetched as a real-world OpenAPI fixture. --- .github/workflows/ci.yml | 2 +- crates/core/tests/data/json/asyncapi.yaml | 21 ++ .../core/tests/data/json/legacy-draft04.json | 27 ++ crates/core/tests/data/json/openapi.yaml | 35 ++ crates/core/tests/data/json/v1/customer.yaml | 23 ++ .../core/tests/data/json/v1/order.schema.json | 64 ++++ crates/core/tests/data/json/v2/customer.yaml | 23 ++ .../core/tests/data/json/v2/order.schema.json | 144 ++++++++ crates/core/tests/fixtures.rs | 32 +- crates/core/tests/json.rs | 347 ++++++++++++++++++ scripts/fetch-fixtures.sh | 5 + 11 files changed, 721 insertions(+), 2 deletions(-) create mode 100644 crates/core/tests/data/json/asyncapi.yaml create mode 100644 crates/core/tests/data/json/legacy-draft04.json create mode 100644 crates/core/tests/data/json/openapi.yaml create mode 100644 crates/core/tests/data/json/v1/customer.yaml create mode 100644 crates/core/tests/data/json/v1/order.schema.json create mode 100644 crates/core/tests/data/json/v2/customer.yaml create mode 100644 crates/core/tests/data/json/v2/order.schema.json create mode 100644 crates/core/tests/json.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9cd4db7..a413339 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,7 +23,7 @@ jobs: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 - - name: Fetch the schema fixtures (NeTEx) + - name: Fetch the schema fixtures (NeTEx, OSDM) shell: bash run: scripts/fetch-fixtures.sh - name: Build diff --git a/crates/core/tests/data/json/asyncapi.yaml b/crates/core/tests/data/json/asyncapi.yaml new file mode 100644 index 0000000..576fd17 --- /dev/null +++ b/crates/core/tests/data/json/asyncapi.yaml @@ -0,0 +1,21 @@ +asyncapi: 2.6.0 +info: + title: Train events + version: 1.0.0 +channels: + train/position: + subscribe: + message: + $ref: '#/components/messages/TrainPosition' +components: + messages: + TrainPosition: + payload: + type: object + properties: + train: { $ref: '#/components/schemas/TrainId' } + lat: { type: number, minimum: -90, maximum: 90 } + schemas: + TrainId: + type: string + pattern: '^[0-9]{1,6}$' diff --git a/crates/core/tests/data/json/legacy-draft04.json b/crates/core/tests/data/json/legacy-draft04.json new file mode 100644 index 0000000..6118aa8 --- /dev/null +++ b/crates/core/tests/data/json/legacy-draft04.json @@ -0,0 +1,27 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "id": "http://example.com/legacy.json", + "definitions": { + "Price": { + "type": "number", + "minimum": 0, + "exclusiveMinimum": true + }, + "Product": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "price": { "$ref": "#/definitions/Price" }, + "kind": { "enum": ["book", "music"] }, + "dimensions": { + "type": "object", + "properties": { "width": { "type": "number" } } + } + }, + "required": ["name", "price"] + }, + "Book": { + "allOf": [{ "$ref": "#/definitions/Product" }, { "properties": { "isbn": { "type": "string" } } }] + } + } +} diff --git a/crates/core/tests/data/json/openapi.yaml b/crates/core/tests/data/json/openapi.yaml new file mode 100644 index 0000000..394381b --- /dev/null +++ b/crates/core/tests/data/json/openapi.yaml @@ -0,0 +1,35 @@ +openapi: 3.0.3 +info: + title: Pets + version: 1.0.0 + description: A small pet store. +paths: + /pets: + get: + responses: + 200: + description: The pets. + content: + application/json: + schema: + type: array + items: { $ref: '#/components/schemas/Pet' } +components: + schemas: + Pet: + type: object + required: [id, name] + properties: + id: { type: integer, format: int64, readOnly: true } + name: { type: string, nullable: true, example: Rex } + tag: { type: string } + Dog: + allOf: + - $ref: '#/components/schemas/Pet' + - type: object + properties: + bark: { type: boolean } + Pets: + type: array + items: { $ref: '#/components/schemas/Pet' } + maxItems: 100 diff --git a/crates/core/tests/data/json/v1/customer.yaml b/crates/core/tests/data/json/v1/customer.yaml new file mode 100644 index 0000000..2f42b9d --- /dev/null +++ b/crates/core/tests/data/json/v1/customer.yaml @@ -0,0 +1,23 @@ +$schema: https://json-schema.org/draft/2020-12/schema +$id: https://example.com/schemas/customer.yaml +title: Customer +type: object +properties: + name: + type: string + maxLength: 80 + address: + $ref: '#/$defs/Address' + kind: + oneOf: + - const: person + description: A natural person. + - const: company + description: A legal entity. +required: [name] +$defs: + Address: + type: object + properties: + street: { type: string } + country: { type: string, pattern: '^[A-Z]{2}$' } diff --git a/crates/core/tests/data/json/v1/order.schema.json b/crates/core/tests/data/json/v1/order.schema.json new file mode 100644 index 0000000..2abd1b0 --- /dev/null +++ b/crates/core/tests/data/json/v1/order.schema.json @@ -0,0 +1,64 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://example.com/schemas/order.schema.json", + "title": "Order", + "description": "A customer order.", + "type": "object", + "properties": { + "id": { "type": "string", "format": "uuid", "description": "Order identifier." }, + "customer": { "$ref": "customer.yaml" }, + "lines": { + "type": "array", + "items": { "$ref": "#/$defs/LineItem" }, + "minItems": 1, + "maxItems": 50 + }, + "status": { "$ref": "#/$defs/Status" }, + "payment": { + "oneOf": [{ "$ref": "#/$defs/CardPayment" }, { "$ref": "#/$defs/BankPayment" }] + }, + "note": { "type": "string", "deprecated": true }, + "tags": { "$ref": "#tag" }, + "extra": { "$ref": "#/$defs/Missing" } + }, + "required": ["id", "customer", "lines"], + "additionalProperties": false, + "$defs": { + "LineItem": { + "type": "object", + "properties": { + "sku": { "type": "string", "pattern": "^[A-Z]{3}-[0-9]+$" }, + "quantity": { "type": "integer", "minimum": 1 } + }, + "required": ["sku", "quantity"] + }, + "Status": { + "description": "Where the order is.", + "type": "string", + "enum": ["open", "paid", "cancelled"] + }, + "Payment": { + "type": "object", + "properties": { "amount": { "type": "number", "exclusiveMinimum": 0 } }, + "required": ["amount"] + }, + "CardPayment": { + "allOf": [ + { "$ref": "#/$defs/Payment" }, + { "properties": { "cardNumber": { "type": "string", "minLength": 12 } }, "required": ["cardNumber"] } + ] + }, + "BankPayment": { + "allOf": [ + { "$ref": "#/$defs/Payment" }, + { "properties": { "iban": { "type": "string" } } } + ] + }, + "Tags": { + "$anchor": "tag", + "type": "object", + "patternProperties": { "^x-": { "type": "string" } }, + "additionalProperties": { "type": "integer" } + } + } +} diff --git a/crates/core/tests/data/json/v2/customer.yaml b/crates/core/tests/data/json/v2/customer.yaml new file mode 100644 index 0000000..2f42b9d --- /dev/null +++ b/crates/core/tests/data/json/v2/customer.yaml @@ -0,0 +1,23 @@ +$schema: https://json-schema.org/draft/2020-12/schema +$id: https://example.com/schemas/customer.yaml +title: Customer +type: object +properties: + name: + type: string + maxLength: 80 + address: + $ref: '#/$defs/Address' + kind: + oneOf: + - const: person + description: A natural person. + - const: company + description: A legal entity. +required: [name] +$defs: + Address: + type: object + properties: + street: { type: string } + country: { type: string, pattern: '^[A-Z]{2}$' } diff --git a/crates/core/tests/data/json/v2/order.schema.json b/crates/core/tests/data/json/v2/order.schema.json new file mode 100644 index 0000000..bf1d6e1 --- /dev/null +++ b/crates/core/tests/data/json/v2/order.schema.json @@ -0,0 +1,144 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://example.com/schemas/order.schema.json", + "title": "Order", + "description": "A customer order.", + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "description": "Order identifier." + }, + "customer": { + "$ref": "customer.yaml" + }, + "lines": { + "type": "array", + "items": { + "$ref": "#/$defs/LineItem" + }, + "minItems": 1, + "maxItems": 50 + }, + "status": { + "$ref": "#/$defs/Status" + }, + "payment": { + "oneOf": [ + { + "$ref": "#/$defs/CardPayment" + }, + { + "$ref": "#/$defs/BankPayment" + } + ] + }, + "note": { + "type": "string", + "deprecated": true + }, + "tags": { + "$ref": "#tag" + }, + "extra": { + "$ref": "#/$defs/Missing" + }, + "currency": { + "type": "string", + "default": "EUR" + } + }, + "required": [ + "id", + "customer", + "lines", + "status" + ], + "additionalProperties": false, + "$defs": { + "LineItem": { + "type": "object", + "properties": { + "sku": { + "type": "string", + "pattern": "^[A-Z]{3}-[0-9]+$" + }, + "quantity": { + "type": "integer", + "minimum": 1, + "maximum": 100 + } + }, + "required": [ + "sku", + "quantity" + ] + }, + "Status": { + "description": "Where the order is.", + "type": "string", + "enum": [ + "open", + "paid", + "refunded" + ] + }, + "Payment": { + "type": "object", + "properties": { + "amount": { + "type": "number", + "exclusiveMinimum": 0 + } + }, + "required": [ + "amount" + ] + }, + "CardPayment": { + "allOf": [ + { + "$ref": "#/$defs/Payment" + }, + { + "properties": { + "cardNumber": { + "type": "string", + "minLength": 12 + } + }, + "required": [ + "cardNumber" + ] + } + ] + }, + "BankPayment": { + "allOf": [ + { + "$ref": "#/$defs/Payment" + }, + { + "properties": { + "iban": { + "type": "string" + } + } + } + ] + }, + "Tags": { + "$anchor": "tag", + "type": "object", + "patternProperties": { + "^x-": { + "type": "string" + } + }, + "additionalProperties": { + "type": "integer" + } + } + } +} \ No newline at end of file diff --git a/crates/core/tests/fixtures.rs b/crates/core/tests/fixtures.rs index c8c864f..884a9f0 100644 --- a/crates/core/tests/fixtures.rs +++ b/crates/core/tests/fixtures.rs @@ -5,7 +5,8 @@ //! repository root) and are skipped when a schema set is absent. Fetch the //! public ones with `scripts/fetch-fixtures.sh`; the railML and telematics //! sets are expected under `railml//{schema,codelists}` and -//! `telematics/{netex,taf,taf-v3.5.1,b5,b10}`. +//! `telematics/{netex,taf,taf-v3.5.1,b5,b10}`; OSDM (OpenAPI with JSON +//! Schema) under `OSDM`. //! //! Run with `--nocapture` (in release mode for meaningful timings) to see //! the counts and indexing times. @@ -366,3 +367,32 @@ fn diff_taf() { let hb = find(&d, Kind::Element, "HandBrake").expect("HandBrake"); assert!(hb.breaking()); } + +/// UIC OSDM: an OpenAPI 3.0 document whose schemas live in YAML files it +/// refers to (fetched by scripts/fetch-fixtures.sh). +#[test] +fn osdm_openapi() { + let Some(r) = run( + "OSDM (OpenAPI)", + &["OSDM/specification/OSDM-online-api.yml"], + ) else { + return; + }; + let b = &r.bundle; + assert_eq!( + b.language, + schema_explorer_core::bundle::Language::JsonSchema + ); + no_unresolved_imports(b); + assert_eq!(b.stats.unresolved_refs, 0); + at_least(b, 300, 80, 0); + let offer = b.find(Kind::ComplexType, "Offer").expect("Offer"); + assert!( + b.components[offer as usize] + .complex_type + .as_ref() + .unwrap() + .content + .is_some() + ); +} diff --git a/crates/core/tests/json.rs b/crates/core/tests/json.rs new file mode 100644 index 0000000..f0e1ca6 --- /dev/null +++ b/crates/core/tests/json.rs @@ -0,0 +1,347 @@ +//! Tests of the JSON Schema reader on the hand-written sets in +//! tests/data/json: cross-file `$ref` (JSON to YAML), `$anchor`, `allOf` +//! inheritance, `oneOf` choices, enumerations, drafts 04 and 2020-12, +//! OpenAPI and AsyncAPI documents, and a comparison of two versions. + +use schema_explorer_core::bundle::{Bundle, Kind, Language, Particle, Ref, Variety}; +use schema_explorer_core::diff::*; +use schema_explorer_core::{Options, Source, index}; +use std::path::Path; + +fn load(files: &[&str]) -> Bundle { + let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/data/json"); + index( + files + .iter() + .map(|f| Source::new(*f, std::fs::read(dir.join(f)).unwrap())) + .collect(), + None, + &Options::default(), + ) +} + +fn order(version: &str) -> Bundle { + load(&[ + &format!("{version}/order.schema.json"), + &format!("{version}/customer.yaml"), + ]) +} + +/// The names and cardinalities (`name min..max`) of the elements of a content model. +fn children(b: &Bundle, p: &Particle) -> Vec { + let mut out = Vec::new(); + p.walk(&mut |p| { + if let Particle::Element { + el: Ref::Id(e), + occurs, + } = p + { + out.push(format!( + "{} {}..{}", + b.components[*e as usize].name, + occurs.min, + occurs.max.map_or("*".to_string(), |m| m.to_string()) + )); + } + }); + out +} + +fn type_name(b: &Bundle, el: u32) -> String { + match b.components[el as usize] + .element + .as_ref() + .unwrap() + .r#type + .as_ref() + .unwrap() + { + Ref::Id(t) => b.qname(*t), + Ref::Missing(m) => format!("missing {m}"), + } +} + +fn child(b: &Bundle, ty: u32, name: &str) -> u32 { + let mut found = None; + b.components[ty as usize] + .complex_type + .as_ref() + .unwrap() + .content + .as_ref() + .unwrap() + .walk(&mut |p| { + if let Particle::Element { el: Ref::Id(e), .. } = p + && b.components[*e as usize].name == name + { + found = Some(*e); + } + }); + found.unwrap_or_else(|| panic!("no {name}")) +} + +#[test] +fn draft_2020_set() { + let b = order("v1"); + assert_eq!(b.language, Language::JsonSchema); + assert_eq!(b.files[0].dialect.as_deref(), Some("JSON Schema 2020-12")); + // Root schemas and $defs are global types; no namespaces when names are unique. + assert!(b.namespaces.is_empty()); + let o = b.find(Kind::ComplexType, "order").expect("root type"); + let ct = b.components[o as usize].complex_type.as_ref().unwrap(); + assert_eq!( + children(&b, ct.content.as_ref().unwrap()), + [ + "id 1..1", + "customer 1..1", + "lines 1..50", + "status 0..1", + "payment 0..1", + "note 0..1", + "tags 0..1", + "extra 0..1" + ] + ); + assert!( + ct.facets + .iter() + .any(|f| f.kind == "additionalProperties" && f.value == "false") + ); + assert_eq!(b.components[o as usize].docs[0].text, "A customer order."); + // Cross-file $ref into YAML, array items, $anchor. + assert_eq!(type_name(&b, child(&b, o, "customer")), "customer"); + let lines = child(&b, o, "lines"); + assert_eq!(type_name(&b, lines), "LineItem"); + assert!(b.components[lines as usize].json.as_ref().unwrap().array); + assert_eq!(type_name(&b, child(&b, o, "tags")), "Tags"); + assert!( + b.components[child(&b, o, "note") as usize] + .json + .as_ref() + .unwrap() + .deprecated + ); + // Unresolved $ref: a diagnostic and a missing reference, no panic. + assert_eq!( + type_name(&b, child(&b, o, "extra")), + "missing #/$defs/Missing" + ); + assert_eq!(b.stats.unresolved_refs, 1); + assert!(b.diagnostics.iter().any(|d| d.code == "unresolved-ref")); + // allOf with a $ref: extension of the base. + let card = b.find(Kind::ComplexType, "CardPayment").unwrap(); + let ct = b.components[card as usize].complex_type.as_ref().unwrap(); + let base = ct.derivation.as_ref().unwrap().base.id().unwrap(); + assert_eq!(b.qname(base), "Payment"); + assert_eq!( + children(&b, ct.content.as_ref().unwrap()), + ["cardNumber 1..1"] + ); + assert!( + b.components[base as usize] + .complex_type + .as_ref() + .unwrap() + .derived + .contains(&card) + ); + // oneOf: a choice. + let pay = child(&b, o, "payment"); + let anon = b.components[pay as usize] + .element + .as_ref() + .unwrap() + .r#type + .as_ref() + .unwrap() + .id() + .unwrap(); + assert!(matches!( + b.components[anon as usize] + .complex_type + .as_ref() + .unwrap() + .content, + Some(Particle::Choice { .. }) + )); + // Enumerations and facets. + let st = b.find(Kind::SimpleType, "Status").unwrap(); + let si = b.components[st as usize].simple_type.as_ref().unwrap(); + let vals: Vec<&str> = si.enumerations.iter().map(|e| e.value.as_str()).collect(); + assert_eq!(vals, ["open", "paid", "cancelled"]); + assert_eq!(b.qname(si.base.as_ref().unwrap().id().unwrap()), "string"); + // oneOf of consts: enumerations with documentation. + let cust = b.find(Kind::ComplexType, "customer").unwrap(); + let kind = child(&b, cust, "kind"); + let kt = b.components[kind as usize] + .element + .as_ref() + .unwrap() + .r#type + .as_ref() + .unwrap() + .id() + .unwrap(); + let e = &b.components[kt as usize] + .simple_type + .as_ref() + .unwrap() + .enumerations; + assert_eq!(e[1].value, "company"); + assert_eq!(e[1].docs[0].text, "A legal entity."); + // patternProperties and additionalProperties. + let tags = b.find(Kind::ComplexType, "Tags").unwrap(); + let ct = b.components[tags as usize].complex_type.as_ref().unwrap(); + assert_eq!( + children(&b, ct.content.as_ref().unwrap()), + ["^x- 0..*", "* 0..*"] + ); + // Where used. + let li = b.find(Kind::ComplexType, "LineItem").unwrap(); + assert!( + b.components[li as usize] + .used_by + .iter() + .any(|u| u.by == lines) + ); + // Search. + assert!( + b.search + .get("lineitem") + .is_some_and(|ids| ids.contains(&li)) + ); +} + +#[test] +fn draft_04() { + let b = load(&["legacy-draft04.json"]); + assert_eq!(b.files[0].dialect.as_deref(), Some("JSON Schema draft-04")); + let price = b.find(Kind::SimpleType, "Price").unwrap(); + let f = &b.components[price as usize] + .simple_type + .as_ref() + .unwrap() + .facets; + assert!( + f.iter() + .any(|f| f.kind == "exclusiveMinimum" && f.value == "0") + ); + assert!(!f.iter().any(|f| f.kind == "minimum")); + let book = b.find(Kind::ComplexType, "Book").unwrap(); + let ct = b.components[book as usize].complex_type.as_ref().unwrap(); + assert_eq!( + b.qname(ct.derivation.as_ref().unwrap().base.id().unwrap()), + "Product" + ); + let product = b.find(Kind::ComplexType, "Product").unwrap(); + assert_eq!(type_name(&b, child(&b, product, "price")), "Price"); + assert!(b.diagnostics.is_empty(), "{:?}", b.diagnostics); +} + +#[test] +fn openapi_and_asyncapi() { + let b = load(&["openapi.yaml"]); + assert_eq!(b.files[0].dialect.as_deref(), Some("OpenAPI 3.0.3")); + assert_eq!(b.files[0].version.as_deref(), Some("1.0.0")); + let pet = b.find(Kind::ComplexType, "Pet").unwrap(); + let name = child(&b, pet, "name"); + let nt = b.components[name as usize] + .element + .as_ref() + .unwrap() + .r#type + .as_ref() + .unwrap() + .id() + .unwrap(); + assert!( + b.components[nt as usize] + .simple_type + .as_ref() + .unwrap() + .facets + .iter() + .any(|f| f.kind == "nullable") + ); + let dog = b.find(Kind::ComplexType, "Dog").unwrap(); + assert_eq!( + b.qname( + b.components[dog as usize] + .complex_type + .as_ref() + .unwrap() + .derivation + .as_ref() + .unwrap() + .base + .id() + .unwrap() + ), + "Pet" + ); + let pets = b.find(Kind::ComplexType, "Pets").unwrap(); + let ct = b.components[pets as usize].complex_type.as_ref().unwrap(); + assert_eq!(children(&b, ct.content.as_ref().unwrap()), ["items 0..100"]); + + let b = load(&["asyncapi.yaml"]); + assert_eq!(b.files[0].dialect.as_deref(), Some("AsyncAPI 2.6.0")); + let msg = b + .find(Kind::ComplexType, "TrainPosition") + .expect("message payload"); + assert_eq!(type_name(&b, child(&b, msg, "train")), "TrainId"); + let id = b.find(Kind::SimpleType, "TrainId").unwrap(); + assert_eq!( + b.components[id as usize] + .simple_type + .as_ref() + .unwrap() + .variety, + Variety::Atomic + ); +} + +#[test] +fn files_named_alike_get_namespaces() { + let b = load(&[ + "v1/order.schema.json", + "v1/customer.yaml", + "v2/order.schema.json", + ]); + assert_eq!(b.namespaces.len(), 3); + assert!(b.find(Kind::ComplexType, "order:order").is_some()); +} + +fn has(c: &ComponentDiff, line: &str) { + let all: Vec = c + .changes + .iter() + .map(|x| format!("{:?} {} {:?}", x.kind, x.subject, x.impact)) + .collect(); + assert!(all.iter().any(|l| l == line), "{line} not in {all:#?}"); +} + +fn comp<'a>(d: &'a SchemaDiff, kind: Kind, name: &str) -> &'a ComponentDiff { + d.components + .iter() + .find(|c| c.kind == kind && c.name == name) + .unwrap_or_else(|| panic!("{kind:?} {name} not in the diff")) +} + +#[test] +fn diff_versions() { + let d = diff(&order("v1"), &order("v2"), &DiffOptions::default()); + let o = comp(&d, Kind::ComplexType, "order"); + // A property that becomes required is breaking; a new optional one is not. + has(o, "ChildCardinalityChanged status Breaking"); + has(o, "ChildAdded currency NonBreaking"); + let st = comp(&d, Kind::SimpleType, "Status"); + has(st, "EnumerationRemoved cancelled Breaking"); + has(st, "EnumerationAdded refunded NonBreaking"); + assert!(d.summary.total.breaking > 0); + // Property order does not matter in JSON. + assert!( + !o.changes + .iter() + .any(|c| c.kind == ChangeKind::ChildrenReordered) + ); +} diff --git a/scripts/fetch-fixtures.sh b/scripts/fetch-fixtures.sh index fcb2092..43ae7e8 100755 --- a/scripts/fetch-fixtures.sh +++ b/scripts/fetch-fixtures.sh @@ -12,4 +12,9 @@ if [ ! -d "$DIR/NeTEx/.git" ]; then else git -C "$DIR/NeTEx" pull --ff-only --depth 1 fi +if [ ! -d "$DIR/OSDM/.git" ]; then + git clone --depth 1 https://github.com/UnionInternationalCheminsdeFer/OSDM "$DIR/OSDM" +else + git -C "$DIR/OSDM" pull --ff-only --depth 1 +fi echo "fixtures ready in $DIR" From 519bd55bed35137d8c913f5d3de81258863927bf Mon Sep 17 00:00:00 2001 From: Mathias Vanden Auweele Date: Wed, 21 Oct 2026 18:21:29 +0000 Subject: [PATCH 3/8] feat(cli): accept JSON Schema, OpenAPI and AsyncAPI files in index, serve and diff .json, .yaml and .yml files and folders are indexed as JSON Schema when there is no .xsd file among them; a single .json side of diff is a bundle only when it has format and components. --- crates/cli/src/main.rs | 70 +++++++++++++++++++++++++++++------------ crates/cli/src/serve.rs | 12 ++++++- 2 files changed, 61 insertions(+), 21 deletions(-) diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index fd41988..ccf8bbb 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -35,7 +35,8 @@ enum Command { /// shows their source. Without files, the viewer opens empty and indexes /// what is dropped on it in the browser. Serve { - /// Schema files or directories (every .xsd file in them). + /// Schema files or directories (every .xsd file in them, or every + /// .json/.yaml/.yml file when there is no .xsd file). files: Vec, /// Address to listen on. #[arg(long, env = "SCHEMA_EXPLORER_BIND", default_value = "127.0.0.1:8080")] @@ -46,8 +47,10 @@ enum Command { }, /// Index schema files into a bundle for the viewer. /// - /// Directories are read recursively (every .xsd file); imports and - /// includes are followed from disk. + /// XML Schema (.xsd) or JSON Schema, OpenAPI and AsyncAPI (.json, + /// .yaml, .yml). Directories are read recursively (every .xsd file, or + /// every JSON and YAML file when there is no .xsd file); imports, + /// includes and `$ref`s to other files are followed from disk. Index { /// Schema files or directories. #[arg(required = true)] @@ -65,8 +68,8 @@ enum Command { }, /// Compare two versions of a schema set. /// - /// Each side is a schema file, a directory (every .xsd file in it) or a - /// bundle made with `index`; join several paths for one side with + /// Each side is a schema file (XSD, JSON Schema, OpenAPI or AsyncAPI), + /// a directory (every schema file in it) or a bundle made with `index`; join several paths for one side with /// commas. Components are matched by qualified name, and by kind and /// local name when the target namespace changed between the versions. Diff { @@ -149,8 +152,21 @@ fn main() -> Result<()> { } } -/// Every .xsd file under `path` (or `path` itself when it is a file). -fn collect(path: &Path, out: &mut Vec) -> Result<()> { +fn is_schema_file(p: &Path) -> bool { + p.extension().is_some_and(|x| { + ["xsd", "json", "yaml", "yml"] + .iter() + .any(|e| x.eq_ignore_ascii_case(e)) + }) +} + +fn is_xsd(p: &Path) -> bool { + p.extension().is_some_and(|x| x.eq_ignore_ascii_case("xsd")) +} + +/// Every schema file under `path` (or `path` itself when it is a file): +/// .xsd, .json, .yaml and .yml. `explicit` marks the files named directly. +fn collect(path: &Path, explicit: bool, out: &mut Vec<(PathBuf, bool)>) -> Result<()> { if path.is_dir() { let mut entries: Vec = std::fs::read_dir(path) .with_context(|| format!("reading {}", path.display()))? @@ -158,12 +174,12 @@ fn collect(path: &Path, out: &mut Vec) -> Result<()> { .collect::>()?; entries.sort(); for p in entries { - if p.is_dir() || p.extension().is_some_and(|x| x.eq_ignore_ascii_case("xsd")) { - collect(&p, out)?; + if p.is_dir() || is_schema_file(&p) { + collect(&p, false, out)?; } } } else if path.is_file() { - out.push(path.to_path_buf()); + out.push((path.to_path_buf(), explicit)); } else { bail!("{} not found", path.display()); } @@ -172,10 +188,17 @@ fn collect(path: &Path, out: &mut Vec) -> Result<()> { /// Read and index the schema files; also returns every file read from disk. pub(crate) fn build(inputs: &[PathBuf], compact: bool) -> Result<(Bundle, Vec)> { - let mut paths = Vec::new(); + let mut found = Vec::new(); for p in inputs { - collect(p, &mut paths)?; + collect(p, true, &mut found)?; } + // In directories with XSD files, the JSON and YAML files are not schemas. + let xsd = found.iter().any(|(p, _)| is_xsd(p)); + let paths: Vec = found + .into_iter() + .filter(|(p, explicit)| *explicit || !xsd || is_xsd(p)) + .map(|(p, _)| p) + .collect(); let sources = paths .iter() .map(|p| { @@ -209,10 +232,12 @@ pub(crate) fn report(bundle: &Bundle) { let file = d .file .map_or("", |f| bundle.files[f as usize].name.as_str()); - eprintln!( - "{:?}: {file}:{}: {} [{}]", - d.level, d.line, d.message, d.code - ); + let line = if d.line > 0 { + format!(":{}", d.line) + } else { + String::new() + }; + eprintln!("{:?}: {file}{line}: {} [{}]", d.level, d.message, d.code); } } @@ -255,10 +280,15 @@ fn load_side(spec: &str) -> Result { { let text = std::fs::read_to_string(p).with_context(|| format!("reading {}", p.display()))?; - let mut b: Bundle = serde_json::from_str(&text) - .with_context(|| format!("{} is not a schema-explorer bundle", p.display()))?; - schema_explorer_core::expand(&mut b); - return Ok(b); + // A bundle has `format` and `components`; any other JSON is a schema. + let value: serde_json::Value = serde_json::from_str(&text) + .with_context(|| format!("{} is not valid JSON", p.display()))?; + if value.get("format").is_some() && value.get("components").is_some_and(|c| c.is_array()) { + let mut b: Bundle = serde_json::from_value(value) + .with_context(|| format!("{} is not a schema-explorer bundle", p.display()))?; + schema_explorer_core::expand(&mut b); + return Ok(b); + } } let (b, _) = build(&paths, false)?; report(&b); diff --git a/crates/cli/src/serve.rs b/crates/cli/src/serve.rs index 0eaca51..553d97d 100644 --- a/crates/cli/src/serve.rs +++ b/crates/cli/src/serve.rs @@ -127,7 +127,17 @@ async fn source(State(s): State>, Path(file): Path) -> Respon return (StatusCode::NOT_FOUND, "no such file").into_response(); }; match tokio::fs::read(path).await { - Ok(bytes) => with_type(bytes.into_response(), "application/xml", "no-cache"), + Ok(bytes) => { + let xml = path + .extension() + .is_some_and(|x| x.eq_ignore_ascii_case("xsd")); + let mime = if xml { + "application/xml" + } else { + "text/plain; charset=utf-8" + }; + with_type(bytes.into_response(), mime, "no-cache") + } Err(e) => (StatusCode::NOT_FOUND, e.to_string()).into_response(), } } From 86ef912b0bf8215eca0340399f8eac2ff6d2058b Mon Sep 17 00:00:00 2001 From: Mathias Vanden Auweele Date: Wed, 21 Oct 2026 18:28:44 +0000 Subject: [PATCH 4/8] feat(wasm): expose the JSON source of a schema to the viewer --- crates/wasm/src/lib.rs | 11 ++++++++++- web/scripts/build-wasm.mjs | 1 + 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/crates/wasm/src/lib.rs b/crates/wasm/src/lib.rs index 4b850f3..bdba242 100644 --- a/crates/wasm/src/lib.rs +++ b/crates/wasm/src/lib.rs @@ -3,7 +3,8 @@ use schema_explorer_core::{Options, Source}; use wasm_bindgen::prelude::*; -/// Index an XSD set and return the bundle as JSON. +/// Index a schema set (XSD, or JSON Schema / OpenAPI / AsyncAPI in JSON +/// or YAML) and return the bundle as JSON. /// /// `names` and `contents` are parallel arrays: file paths (relative paths /// let imports and includes resolve; bare file names resolve by name) and @@ -76,6 +77,14 @@ pub fn diff_report(diff: &str, format: &str) -> Result { } } +/// The schema at a JSON pointer of a JSON or YAML file, as indented JSON +/// (the source view of a JSON Schema component). +#[wasm_bindgen] +pub fn json_source(name: &str, text: &str, pointer: &str) -> Result { + schema_explorer_core::json::source(name, text.as_bytes(), pointer) + .ok_or_else(|| err(format!("{pointer} not found in {name}"))) +} + /// The version of the core library. #[wasm_bindgen] pub fn version() -> String { diff --git a/web/scripts/build-wasm.mjs b/web/scripts/build-wasm.mjs index 87597fd..381771e 100644 --- a/web/scripts/build-wasm.mjs +++ b/web/scripts/build-wasm.mjs @@ -51,6 +51,7 @@ export function index(names: string[], contents: Uint8Array[], compact: boolean) export function expand(json: string): string; export function diff(old: string, cur: string, options: string): string; export function diff_report(diff: string, format: string): string; +export function json_source(name: string, text: string, pointer: string): string; export function version(): string; export function format(): number; ` From 1e02f88e0a71e3bbfe9a03f216db5ac3c4f79b77 Mon Sep 17 00:00:00 2001 From: Mathias Vanden Auweele Date: Wed, 21 Oct 2026 18:28:44 +0000 Subject: [PATCH 5/8] feat(web): JSON Schema in the viewer Drop zone, file pickers and URLs take .json, .yaml and .yml schemas (a single .json is a bundle only when it is one). JSON Schema bundles use JSON terms (objects, scalars, properties, oneOf, inline schemas), show the dialect, the root schemas of the files, deprecated/read-only badges and examples, and a JSON view of the component's schema (YAML shown as JSON) with $ref values linked, instead of the XSD snippet. --- web/src/app.ts | 48 ++++++++++++------------- web/src/diff.ts | 13 +++++-- web/src/element.ts | 4 +-- web/src/graph.ts | 2 +- web/src/loader.ts | 50 +++++++++++++++++++++----- web/src/model.ts | 66 +++++++++++++++++++++++++++++++--- web/src/source.ts | 75 +++++++++++++++++++++++++++++++++++++++ web/src/views/common.ts | 62 ++++++++++++++++++++++++++++++-- web/src/views/compare.ts | 2 +- web/src/views/dialogs.ts | 4 +-- web/src/views/home.ts | 27 ++++++++++---- web/src/views/overview.ts | 3 +- web/src/views/pages.ts | 13 ++++--- web/src/views/sidebar.ts | 6 ++-- web/src/wasm.worker.ts | 17 ++++++--- 15 files changed, 324 insertions(+), 68 deletions(-) diff --git a/web/src/app.ts b/web/src/app.ts index a9f4201..b0dc3c8 100644 --- a/web/src/app.ts +++ b/web/src/app.ts @@ -2,8 +2,8 @@ import type { AppCtx, Prefs, Route } from "./ctx"; import { button, copyText, h, icon, plural, type Child } from "./dom"; import { Compare } from "./diff"; -import { diffBundles, fetchBundleFromServer, indexFiles, loadUrl, memorySources, readDataTransfer, readFiles, serverSources, serverStatus, urlSources, type LoadProgress, type NamedBytes, type ServerStatus, type SourceProvider } from "./loader"; -import { KIND_SLUG, Model, type Bundle, type Id } from "./model"; +import { bundleFile, diffBundles, fetchBundleFromServer, indexFiles, loadUrl, memorySources, readDataTransfer, readFiles, schemaFiles, serverSources, serverStatus, urlSources, type LoadProgress, type NamedBytes, type ServerStatus, type SourceProvider } from "./loader"; +import { KIND_SLUG, Model, TERMS, type Bundle, type Id } from "./model"; import { matdataMark } from "./logo"; import { openAbout, openHelp } from "./views/dialogs"; import { homePage } from "./views/home"; @@ -347,18 +347,17 @@ export class App implements AppCtx { for (const k of ["src", "bundle", "sources"]) u.searchParams.delete(k); history.replaceState(history.state, "", u.toString()); } - if (named.length === 0) throw new Error("none of the files is an XML Schema (.xsd) or a bundle (.json)"); + if (named.length === 0) throw new Error("none of the files is a schema (.xsd, .json, .yaml) or a bundle (.json)"); // A single bundle file loads directly. - if (named.length === 1 && /\.json$/i.test(named[0].name)) { - const b = JSON.parse(new TextDecoder().decode(named[0].bytes)) as Bundle; - if (!b || !Array.isArray(b.components)) throw new Error("this JSON file is not a schema-explorer bundle"); + const single = bundleFile(named); + if (single) { this.source = { kind: "files" }; this.sources = null; - this.setBundle(b); + this.setBundle(single); return; } - const xsd = named.filter((f) => /\.xsd$/i.test(f.name)); - if (xsd.length === 0) throw new Error("no .xsd files among the files"); + const xsd = schemaFiles(named); + if (xsd.length === 0) throw new Error("no schema files (.xsd, .json, .yaml) among the files"); // Folders keep their relative paths, so imports and includes resolve. this.showProgress({ stage: "index", detail: xsd.length === 1 ? xsd[0].name : `${xsd.length} files` }); const started = performance.now(); @@ -376,15 +375,12 @@ export class App implements AppCtx { } } - /** Read picked or dropped files into a bundle: one bundle file, or .xsd files indexed here. */ + /** Read picked or dropped files into a bundle: one bundle file, or schema files indexed here. */ private async bundleFromFiles(named: NamedBytes[]): Promise { - if (named.length === 1 && /\.json$/i.test(named[0].name)) { - const b = JSON.parse(new TextDecoder().decode(named[0].bytes)) as Bundle; - if (!b || !Array.isArray(b.components)) throw new Error("this JSON file is not a schema-explorer bundle"); - return b; - } - const xsd = named.filter((f) => /\.xsd$/i.test(f.name)); - if (xsd.length === 0) throw new Error("no .xsd files among the files"); + const single = bundleFile(named); + if (single) return single; + const xsd = schemaFiles(named); + if (xsd.length === 0) throw new Error("no schema files (.xsd, .json, .yaml) among the files"); const b = await indexFiles(xsd, (p) => this.showProgress(p)); const top = new Set(xsd.map((f) => (f.name.includes("/") ? f.name.split("/")[0] : ""))); if (top.size === 1 && !top.has("")) b.title = [...top][0]; @@ -414,7 +410,7 @@ export class App implements AppCtx { this.sources = urlSources(newer.sources ? new URL(newer.sources, document.baseURI).href : abs); } else { this.source = { kind: "files" }; - this.sources = memorySources(newer.files.filter((f) => /\.xsd$/i.test(f.name))); + this.sources = memorySources(schemaFiles(newer.files)); } const oldUrl = "url" in older ? older.url : undefined; if (this.opts.routing) { @@ -450,9 +446,9 @@ export class App implements AppCtx { go.disabled = !(sides[0] && sides[1]); }; const pick = (files: FileList | null) => { - if (files?.length) void readFiles(files).then((f) => set(f.length ? { files: f } : null, f.length ? plural(f.length, "file") : "no .xsd files")); + if (files?.length) void readFiles(files).then((f) => set(f.length ? { files: f } : null, f.length ? plural(f.length, "file") : "no schema files")); }; - const fileInput = h("input", { type: "file", multiple: true, accept: ".xsd,.json", hidden: true, onChange: (ev: Event) => pick((ev.target as HTMLInputElement).files) }) as HTMLInputElement; + const fileInput = h("input", { type: "file", multiple: true, accept: ".xsd,.json,.yaml,.yml", hidden: true, onChange: (ev: Event) => pick((ev.target as HTMLInputElement).files) }) as HTMLInputElement; const dirInput = h("input", { type: "file", multiple: true, hidden: true, webkitdirectory: true, onChange: (ev: Event) => pick((ev.target as HTMLInputElement).files) }) as HTMLInputElement; const url = h("input", { class: "input", type: "url", placeholder: "or the address of a bundle", "aria-label": `${title}: address of a bundle or an XSD file`, onInput: (ev: Event) => { const v = (ev.target as HTMLInputElement).value.trim(); @@ -479,7 +475,7 @@ export class App implements AppCtx { ev.stopPropagation(); z.classList.remove("over"); status.textContent = "reading…"; - void readDataTransfer(ev.dataTransfer).then((f) => set(f.length ? { files: f } : null, f.length ? plural(f.length, "file") : "no .xsd files")); + void readDataTransfer(ev.dataTransfer).then((f) => set(f.length ? { files: f } : null, f.length ? plural(f.length, "file") : "no schema files")); }); return z; }; @@ -585,10 +581,10 @@ export class App implements AppCtx { ev.preventDefault(); if (this.model) this.goRoute({ view: "home" }); else this.showOpen(); - } }, matdataMark(), h("span", { class: "title-text" }, m?.title ?? "schema-explorer"), m ? h("small", { class: "hide-mobile" }, `${(m.b.stats.global.complexType ?? 0).toLocaleString()} complex types`) : null)); + } }, matdataMark(), h("span", { class: "title-text" }, m?.title ?? "schema-explorer"), m ? h("small", { class: "hide-mobile" }, `${(m.b.stats.global.complexType ?? 0).toLocaleString()} ${TERMS.complexTypes}`) : null)); t.appendChild(h("span", { class: "spacer" })); if (m && this._cmp) t.appendChild(button("Changes", () => this.goRoute({ view: "diff" }), { icon: "table", cls: "small", title: `What changed since ${this._cmp.oldTitle}` })); - if (m) t.appendChild(button("Map", () => this.goRoute({ view: "overview" }), { icon: "map", cls: "small hide-mobile", title: "Overview map of the complex types" })); + if (m) t.appendChild(button("Map", () => this.goRoute({ view: "overview" }), { icon: "map", cls: "small hide-mobile", title: `Overview map of the ${TERMS.complexTypes}` })); if (m) t.appendChild(button("Share", () => void this.share(), { icon: "share", cls: "small share-btn", title: "Copy a link to exactly this view" })); if (m) t.appendChild(button("Open…", () => this.showOpen(), { icon: "folder", cls: "small hide-mobile", title: "Load other schemas" })); const dark = this.isDark(); @@ -617,7 +613,7 @@ export class App implements AppCtx { // ---- open / progress / errors -------------------------------------------- showOpen(): void { - const fileInput = h("input", { type: "file", multiple: true, accept: ".xsd,.json", hidden: true, onChange: (ev: Event) => { + const fileInput = h("input", { type: "file", multiple: true, accept: ".xsd,.json,.yaml,.yml", hidden: true, onChange: (ev: Event) => { const files = (ev.target as HTMLInputElement).files; if (files?.length) void this.openFiles(files); } }) as HTMLInputElement; @@ -629,7 +625,7 @@ export class App implements AppCtx { "div", { class: "dropzone" }, icon("folder", "drop-icon"), - h("p", null, h("strong", null, "Drop .xsd files or folders here"), h("br"), "or ", h("button", { type: "button", class: "linkish", onClick: () => fileInput.click() }, "choose files"), " · ", h("button", { type: "button", class: "linkish", onClick: () => dirInput.click() }, "choose a folder")), + h("p", null, h("strong", null, "Drop .xsd, .json or .yaml files or folders here"), h("br"), "or ", h("button", { type: "button", class: "linkish", onClick: () => fileInput.click() }, "choose files"), " · ", h("button", { type: "button", class: "linkish", onClick: () => dirInput.click() }, "choose a folder")), h("p", { class: "muted small" }, "Include the files the schema imports and includes. A bundle made with ", h("code", null, "schema-explorer index"), " (.json) works too."), fileInput, dirInput @@ -652,7 +648,7 @@ export class App implements AppCtx { "div", { class: "open-grid" }, h("section", { class: "card open-card" }, h("h2", null, "From your computer"), drop), - h("section", { class: "card open-card" }, h("h2", null, "From the web"), h("p", { class: "muted" }, "The address of a bundle made with ", h("code", null, "schema-explorer index"), ", or of a single .xsd file."), form, h("p", { class: "muted small" }, "Add ", h("code", null, "?bundle="), " and the address to this page's link to open it straight away. The site must allow cross-origin requests.")) + h("section", { class: "card open-card" }, h("h2", null, "From the web"), h("p", { class: "muted" }, "The address of a bundle made with ", h("code", null, "schema-explorer index"), ", or of a single .xsd, JSON Schema, OpenAPI or AsyncAPI file."), form, h("p", { class: "muted small" }, "Add ", h("code", null, "?bundle="), " and the address to this page's link to open it straight away. The site must allow cross-origin requests.")) ), this.compareCard(), examples diff --git a/web/src/diff.ts b/web/src/diff.ts index 422f343..9653b99 100644 --- a/web/src/diff.ts +++ b/web/src/diff.ts @@ -1,7 +1,7 @@ // Comparison of two versions of a schema set: the diff made by the core // (see crates/core/src/diff.rs) and lookups for the views that highlight it. import type { GraphSpec } from "./graph"; -import { KIND_SLUG, type Id, type Kind, type Model } from "./model"; +import { KIND_SLUG, LANG, type Id, type Kind, type Model } from "./model"; export type Status = "added" | "removed" | "changed"; export type Impact = "breaking" | "nonBreaking" | "info"; @@ -119,7 +119,16 @@ const LABELS: Record = { docsChanged: "documentation changed", }; -export const changeLabel = (k: string): string => LABELS[k] ?? k; +const JSON_LABELS: Record = { + childAdded: "property added", + childRemoved: "property removed", + childRetyped: "property retyped", + childMoved: "property moved", + childrenReordered: "properties reordered", + substitutionMemberAdded: "substitution member added", + memberTypeAdded: "union member added", +}; +export const changeLabel = (k: string): string => (LANG.json ? JSON_LABELS[k] : undefined) ?? LABELS[k] ?? k; /** The marker of a change: + for additions, − for removals, ~ otherwise. */ export function changeMark(k: string): Status { diff --git a/web/src/element.ts b/web/src/element.ts index 0e83858..945528a 100644 --- a/web/src/element.ts +++ b/web/src/element.ts @@ -15,8 +15,8 @@ function styles(): CSSStyleSheet { /** * Attributes: - * - `src` URL of a bundle.json (or of a single .xsd file) - * - `sources` base URL of the .xsd files, for the source view (default: next to the bundle) + * - `src` URL of a bundle.json (or of a single .xsd, JSON Schema, OpenAPI or AsyncAPI file) + * - `sources` base URL of the schema files, for the source view (default: next to the bundle) * - `focus` component to open first: `prefix:Name` or `ct/prefix:Name` * - `theme` light | dark | auto * - `routing` present: use location.hash for navigation (the standalone app) diff --git a/web/src/graph.ts b/web/src/graph.ts index 2c9d9d8..a487edc 100644 --- a/web/src/graph.ts +++ b/web/src/graph.ts @@ -123,7 +123,7 @@ function stereo(m: Model, id: Id): string | undefined { const c = m.comp(id); const s: string[] = []; if (c.complexType?.abstract) s.push("abstract"); - if (!c.name) s.push("anonymous"); + if (!c.name) s.push(c.json ? "inline" : "anonymous"); if (c.complexType?.mixed) s.push("mixed"); return s.length ? `«${s.join(", ")}»` : undefined; } diff --git a/web/src/loader.ts b/web/src/loader.ts index cf77cf7..3dade0e 100644 --- a/web/src/loader.ts +++ b/web/src/loader.ts @@ -1,4 +1,4 @@ -// Getting a bundle from somewhere: a bundle URL, dropped .xsd files and +// Getting a bundle from somewhere: a bundle URL, dropped schema files and // folders (indexed in a WebAssembly worker), or the local `serve` server. import type { SchemaDiff } from "./diff"; import type { Bundle } from "./model"; @@ -44,7 +44,7 @@ export async function fetchBundleFromServer(progress?: (p: LoadProgress) => void return checkBundle(JSON.parse(new TextDecoder().decode(await readWithProgress(r, progress))), progress); } -/** Load a URL: a bundle (JSON) or a single .xsd file. */ +/** Load a URL: a bundle (JSON) or a single schema file (.xsd, JSON Schema, OpenAPI, AsyncAPI). */ export async function loadUrl(url: string, progress?: (p: LoadProgress) => void): Promise { const abs = new URL(url, document.baseURI).href; progress?.({ stage: "download", detail: abs }); @@ -60,12 +60,14 @@ export async function loadUrl(url: string, progress?: (p: LoadProgress) => void) if (/\.xsd$/i.test(name) || bytes[0] === 0x3c || (bytes[0] === 0xef && bytes[3] === 0x3c)) { return indexFiles([{ name, bytes }], progress); } + if (/\.ya?ml$/i.test(name)) return indexFiles([{ name, bytes }], progress); let parsed: unknown; try { parsed = JSON.parse(new TextDecoder().decode(bytes)); } catch { - throw new Error(`${abs} is neither a schema-explorer bundle (JSON) nor an XSD file`); + throw new Error(`${abs} is neither a schema-explorer bundle (JSON) nor a schema file`); } + if (!isBundle(parsed)) return indexFiles([{ name: /\.json$/i.test(name) ? name : "schema.json", bytes }], progress); return checkBundle(parsed, progress); } @@ -122,7 +124,7 @@ function call(msg: Record, transfer: Transferable[] = []): Prom }); } -/** Index XSD files in the browser (WebAssembly, in a worker). */ +/** Index schema files in the browser (WebAssembly, in a worker). */ export async function indexFiles(files: NamedBytes[], progress?: (p: LoadProgress) => void): Promise { progress?.({ stage: "index", detail: files.length === 1 ? files[0].name : `${files.length} files` }); // Copies go to the worker; the originals stay here for the source view. @@ -142,7 +144,37 @@ export function diffReport(d: SchemaDiff, format: "md" | "html"): Promise /\.xsd$/i.test(f.name)); + return xsd.length ? xsd : named.filter((f) => /\.(json|ya?ml)$/i.test(f.name)); +} + +/** The schema at a JSON pointer of a JSON or YAML file, as indented JSON. */ +export function jsonSource(name: string, text: string, pointer: string): Promise { + return call({ op: "source", name, text, pointer }); +} + +/** Read dropped or picked files; directories are walked for schema files. */ export async function readDataTransfer(dt: DataTransfer): Promise { const entries = Array.from(dt.items) .map((i) => (i.kind === "file" ? i.webkitGetAsEntry?.() : null)) @@ -151,7 +183,7 @@ export async function readDataTransfer(dt: DataTransfer): Promise const out: NamedBytes[] = []; const walk = async (e: FileSystemEntry, path: string): Promise => { if (e.isFile) { - if (!/\.(xsd|json)$/i.test(e.name)) return; + if (!SCHEMA_FILE.test(e.name)) return; const file = await new Promise((res, rej) => (e as FileSystemFileEntry).file(res, rej)); out.push({ name: path + e.name, bytes: new Uint8Array(await file.arrayBuffer()) }); } else if (e.isDirectory) { @@ -170,7 +202,7 @@ export async function readDataTransfer(dt: DataTransfer): Promise export async function readFiles(list: FileList | File[]): Promise { const out: NamedBytes[] = []; for (const f of Array.from(list)) { - if (!/\.(xsd|json)$/i.test(f.name)) continue; + if (!SCHEMA_FILE.test(f.name)) continue; out.push({ name: f.webkitRelativePath || f.name, bytes: new Uint8Array(await f.arrayBuffer()) }); } return out; @@ -204,7 +236,9 @@ export function urlSources(base: string): SourceProvider { const r = await fetch(new URL(name.split("/").map(encodeURIComponent).join("/"), base)); if (!r.ok) return null; const text = await r.text(); - return text.trimStart().startsWith("<") ? text : null; + // A web server answering with its HTML page is not the file. + const html = /^<(!doctype html|html)/i.test(text.trimStart()); + return text.trimStart().startsWith("<") || /\.(json|ya?ml)$/i.test(name) ? (html ? null : text) : null; } catch { return null; } diff --git a/web/src/model.ts b/web/src/model.ts index 639a853..5d3811f 100644 --- a/web/src/model.ts +++ b/web/src/model.ts @@ -139,6 +139,22 @@ export interface Component { attributeGroup?: { attributes?: AttrItem[]; anyAttribute?: Wildcard }; identity?: { selector: string; fields: string[]; refer?: Ref }; builtin?: boolean; + /** JSON Schema details (bundles with language "jsonSchema"). */ + json?: JsonInfo; +} + +export interface JsonInfo { + /** A type: object, array, string, ..., oneOf, anyOf, union. An element: property, patternProperty, additionalProperties, items, option. */ + kind: string; + /** JSON pointer in the file; absent for the root. */ + pointer?: string; + title?: string; + examples?: string[]; + deprecated?: boolean; + readOnly?: boolean; + writeOnly?: boolean; + /** A property whose value is an array of the element's type. */ + array?: boolean; } export interface Directive { @@ -156,6 +172,8 @@ export interface FileInfo { namespace?: number; prefixes?: Record; version?: string; + /** JSON files: "JSON Schema 2020-12", "OpenAPI 3.1.0", ... */ + dialect?: string; elementFormQualified?: boolean; attributeFormQualified?: boolean; input?: boolean; @@ -175,6 +193,8 @@ export interface Diagnostic { export interface Bundle { format: number; generator: string; + /** Absent for XML Schema. */ + language?: "jsonSchema"; compact?: boolean; files: FileInfo[]; namespaces: { uri: string; prefix: string }[]; @@ -205,6 +225,42 @@ export const KIND_LABEL: Record = { unique: "Unique", }; +const XSD_LABEL = { ...KIND_LABEL }; +const JSON_LABEL: Partial> = { element: "Property", complexType: "Object", simpleType: "Scalar" }; + +/** Words that differ between XML Schema and JSON Schema bundles. */ +export const TERMS = { + complexTypes: "complex types", + simpleTypes: "simple types", + elements: "elements", + typesTab: "Types", + simpleTab: "Simple", +}; +const XSD_TERMS = { ...TERMS }; +const JSON_TERMS: typeof TERMS = { complexTypes: "object schemas", simpleTypes: "scalar schemas", elements: "properties", typesTab: "Objects", simpleTab: "Scalars" }; + +/** The schema language of the bundle being shown. */ +export const LANG = { json: false }; + +/** Switch the labels to the terms of the bundle's schema language. */ +export function applyLanguage(b: Bundle): void { + LANG.json = b.language === "jsonSchema"; + Object.assign(KIND_LABEL, XSD_LABEL, b.language === "jsonSchema" ? JSON_LABEL : {}); + Object.assign(TERMS, b.language === "jsonSchema" ? JSON_TERMS : XSD_TERMS); +} + +const ROLE: Record = { property: "Property", patternProperty: "Pattern property", additionalProperties: "Additional properties", items: "Array items", option: "Option" }; + +/** The badge text of a JSON Schema component: "Object definition", "Property", ... */ +export function jsonLabel(c: Component): string | undefined { + const j = c.json; + if (!j) return undefined; + if (c.kind === "element") return ROLE[j.kind] ?? "Property"; + if (c.builtin) return "JSON type"; + const what = j.kind === "oneOf" || j.kind === "anyOf" ? j.kind : j.kind ? j.kind[0].toUpperCase() + j.kind.slice(1) : "Schema"; + return c.parent === undefined ? `${what} definition` : `Inline ${j.kind || "schema"}`; +} + /** Short kind names used in links (#/ct/prefix:Name). */ export const KIND_SLUG: Record = { element: "el", @@ -257,6 +313,7 @@ export class Model { readonly xsNs: number | undefined; constructor(b: Bundle) { + applyLanguage(b); this.b = b; this.c = b.components; this.xsNs = b.namespaces.findIndex((n) => n.uri === XS); @@ -284,7 +341,7 @@ export class Model { get title(): string { if (this.b.title) return this.b.title; const input = this.b.files.find((f) => f.input); - return input ? input.name.split("/").pop()! : "XML Schema"; + return input ? input.name.split("/").pop()! : this.b.language === "jsonSchema" ? "JSON Schema" : "XML Schema"; } comp(id: Id): Component { @@ -307,8 +364,9 @@ export class Model { label(id: Id): string { const c = this.c[id]; if (c.name) return c.name; - if (c.parent !== undefined) return `${this.label(c.parent)} (anonymous)`; - return "(anonymous)"; + const anon = c.json ? "inline" : "anonymous"; + if (c.parent !== undefined) return `${this.label(c.parent)} (${anon})`; + return `(${anon})`; } /** The text of a reference (resolved or not). */ @@ -328,7 +386,7 @@ export class Model { if (isId(r)) { const c = this.c[r]; if (c.builtin || (c.ns !== undefined && c.ns === this.xsNs)) return c.name ?? ""; - if (!c.name) return "(anonymous)"; + if (!c.name) return c.json?.kind || "(anonymous)"; return c.name; } return this.refName(r); diff --git a/web/src/source.ts b/web/src/source.ts index 2b92d29..6984dab 100644 --- a/web/src/source.ts +++ b/web/src/source.ts @@ -129,3 +129,78 @@ function resolve(m: Model, prefixes: Record, qname: string, kind } return undefined; } + +const JSON_TOKEN = /("(?:[^"\\]|\\.)*")(\s*:)?|(-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)|\b(true|false|null)\b/g; +const byPointer = new WeakMap>(); + +/** The component a `$ref` written in `file` points to, by file and JSON pointer. */ +export function refTarget(m: Model, file: number, ref: string): Id | undefined { + let map = byPointer.get(m); + if (!map) { + map = new Map(); + m.c.forEach((c, i) => { + if (c.json && c.file !== undefined && !map!.has(`${c.file}#${c.json.pointer ?? ""}`)) map!.set(`${c.file}#${c.json.pointer ?? ""}`, i); + }); + byPointer.set(m, map); + } + const hash = ref.indexOf("#"); + const path = hash < 0 ? ref : ref.slice(0, hash); + let ptr = hash < 0 ? "" : ref.slice(hash + 1); + try { + ptr = decodeURIComponent(ptr); + } catch { + /* keep as written */ + } + let f = file; + if (path) { + if (/^[a-z][\w+.-]+:/i.test(path)) { + const tail = path.split("/").pop(); + f = m.b.files.findIndex((x) => x.targetNamespace === path || (!!tail && x.name.split("/").pop() === tail)); + } else { + const parts = (m.b.files[file]?.name ?? "").split("/").slice(0, -1); + for (const s of path.split("/")) { + if (s === "..") parts.pop(); + else if (s && s !== ".") parts.push(s); + } + const want = parts.join("/"); + f = m.b.files.findIndex((x) => x.name === want); + } + if (f < 0) return undefined; + } + return map.get(`${f}#${ptr}`); +} + +/** Highlighted JSON with `$ref` values linked to their pages. */ +export function highlightJson(m: Model, file: number, text: string, link: (id: Id, text: string) => HTMLElement): HTMLElement { + const pre = document.createElement("pre"); + pre.className = "xml json"; + const span = (cls: string, t: string) => { + const s = document.createElement("span"); + s.className = cls; + s.textContent = t; + return s; + }; + let at = 0; + let lastKey = ""; + for (const t of text.matchAll(JSON_TOKEN)) { + if (t.index > at) pre.append(text.slice(at, t.index)); + at = t.index + t[0].length; + if (t[1] && t[2]) { + lastKey = t[1]; + pre.append(span("t-attr", t[1]), t[2]); + } else if (t[1]) { + const target = /^"\$(ref|dynamicRef|recursiveRef)"$/.test(lastKey) ? refTarget(m, file, JSON.parse(t[1]) as string) : undefined; + if (target !== undefined) { + const a = link(target, t[1]); + a.classList.add("t-iri"); + pre.append(a); + } else pre.append(span("t-str", t[1])); + lastKey = ""; + } else { + pre.append(span("t-kw", t[0])); + lastKey = ""; + } + } + pre.append(text.slice(at)); + return pre; +} diff --git a/web/src/views/common.ts b/web/src/views/common.ts index e3aedb6..8b6a939 100644 --- a/web/src/views/common.ts +++ b/web/src/views/common.ts @@ -1,11 +1,14 @@ // Shared building blocks of the detail pages. import type { AppCtx } from "../ctx"; import { button, copyText, h, type Child } from "../dom"; -import { KIND_LABEL, docsText, isId, type Doc, type Id, type Ref } from "../model"; -import { highlight, snippet } from "../source"; +import { jsonSource } from "../loader"; +import { KIND_LABEL, docsText, isId, jsonLabel, type Doc, type Id, type JsonInfo, type Ref } from "../model"; +import { highlight, highlightJson, snippet } from "../source"; export function kindBadge(ctx: AppCtx, id: Id): HTMLElement { const c = ctx.m.comp(id); + const json = jsonLabel(c); + if (json) return h("span", { class: `badge k-${c.kind}` }, json); return h("span", { class: `badge k-${c.kind}` }, `${c.parent !== undefined && c.kind !== "complexType" && c.kind !== "simpleType" ? "Local " + KIND_LABEL[c.kind].toLowerCase() : c.parent !== undefined ? "Anonymous " + KIND_LABEL[c.kind].toLowerCase() : KIND_LABEL[c.kind]}`); } @@ -87,7 +90,7 @@ export function pageHead(ctx: AppCtx, id: Id, badges: Child[] = []): HTMLElement "div", null, ...crumbs, - h("div", { class: "page-head" }, h("h1", { class: c.complexType?.abstract || c.element?.abstract ? "abstract" : "" }, m.label(id)), kindBadge(ctx, id), ...badges), + h("div", { class: "page-head" }, h("h1", { class: c.complexType?.abstract || c.element?.abstract ? "abstract" : "" }, m.label(id)), kindBadge(ctx, id), ...jsonBadges(c.json), ...badges), h( "div", { class: "iri-line" }, @@ -172,6 +175,7 @@ export function localPath(ctx: AppCtx, id: Id, owner: Id): HTMLElement { /** The component's XSD, cut from its file, loaded when opened. */ export function sourceSection(ctx: AppCtx, id: Id): HTMLElement | null { const c = ctx.m.comp(id); + if (c.json) return jsonSourceSection(ctx, id); if (c.file === undefined || !c.line) return null; const file = c.file; const body = h("div", { class: "xsd-body" }, h("p", { class: "faint small" }, "Loading…")); @@ -201,6 +205,58 @@ export function sourceSection(ctx: AppCtx, id: Id): HTMLElement | null { return det; } +function jsonBadges(j: JsonInfo | undefined): Child[] { + if (!j) return []; + return [j.deprecated ? h("span", { class: "badge", title: "deprecated: true" }, "deprecated") : null, j.readOnly ? h("span", { class: "badge" }, "read-only") : null, j.writeOnly ? h("span", { class: "badge" }, "write-only") : null, j.title ? h("span", { class: "muted small" }, j.title) : null]; +} + +/** The component's schema as JSON (also for YAML files), loaded when opened. */ +function jsonSourceSection(ctx: AppCtx, id: Id): HTMLElement | null { + const c = ctx.m.comp(id); + if (c.file === undefined || !c.json) return null; + const file = c.file; + const name = ctx.m.b.files[file].name; + const pointer = c.json.pointer ?? ""; + const body = h("div", { class: "xsd-body" }, h("p", { class: "faint small" }, "Loading…")); + const det = h("details", { class: "xsd-src", open: ctx.route.params.src === "1" || undefined }, h("summary", null, h("span", null, "JSON source"), h("span", { class: "faint small" }, ` ${name}#${pointer}`)), body); + let loaded = false; + const load = async () => { + if (loaded) return; + loaded = true; + const text = await ctx.sourceText(file); + if (text === null) { + body.replaceChildren(h("p", { class: "muted small" }, "The schema file is not available here: the bundle was loaded without its schema files.")); + return; + } + let json: string; + try { + json = await jsonSource(name, text, pointer); + } catch { + body.replaceChildren(h("p", { class: "muted small" }, "Could not find the schema in the file.")); + return; + } + const yaml = /\.ya?ml$/i.test(name); + const copy = button("Copy", () => copyText(json).then((ok) => ctx.toast(ok ? "JSON copied" : "Could not copy")), { icon: "copy", cls: "small" }); + body.replaceChildren(h("div", { class: "xsd-tools" }, h("span", { class: "faint small" }, yaml ? "shown as JSON" : `${json.split("\n").length} lines`), copy), highlightJson(ctx.m, file, json, (target, text) => link(ctx, target, text))); + }; + det.addEventListener("toggle", () => { + ctx.setParams({ src: det.open ? "1" : undefined }); + if (det.open) void load(); + }); + if (det.open) void load(); + const examples = c.json.examples ?? []; + if (!examples.length) return det; + return h("div", null, h("details", { class: "xsd-src" }, h("summary", null, h("span", null, examples.length === 1 ? "Example" : `Examples (${examples.length})`)), h("div", { class: "xsd-body" }, examples.map((e) => highlightJson(ctx.m, file, pretty(e), (target, text) => link(ctx, target, text))))), det); +} + +function pretty(json: string): string { + try { + return JSON.stringify(JSON.parse(json), null, 2); + } catch { + return json; + } +} + /** Facets as a definition list. */ export function facetList(facets: { kind: string; value: string; fixed?: boolean }[]): HTMLElement { const dl = h("dl", { class: "kv" }); diff --git a/web/src/views/compare.ts b/web/src/views/compare.ts index 3c7a64b..da74a2b 100644 --- a/web/src/views/compare.ts +++ b/web/src/views/compare.ts @@ -210,7 +210,7 @@ function rowsToLines(m: Model, rows: ReturnType, own: Id): let name: string; let detail: string; if (r.kind === "compositor") { - name = `${indent}${r.compositor}`; + name = `${indent}${m.b.language === "jsonSchema" ? (r.compositor === "choice" ? (r.max === null ? "anyOf" : "oneOf") : "properties") : r.compositor}`; detail = occursText(r.min, r.max); } else if (r.kind === "any") { name = `${indent}any`; diff --git a/web/src/views/dialogs.ts b/web/src/views/dialogs.ts index 55925c3..b9a900f 100644 --- a/web/src/views/dialogs.ts +++ b/web/src/views/dialogs.ts @@ -63,7 +63,7 @@ export function openHelp(host: HTMLElement): void { section( "Getting started", true, - ["Drop ", code(".xsd"), " files or whole folders on the open page, or ", b("choose files"), " or ", b("choose a folder"), ". Give every file the schema imports or includes: they are found by their relative path, or else by file name or namespace."], + ["Drop ", code(".xsd"), " files, JSON Schema, OpenAPI or AsyncAPI files (", code(".json"), ", ", code(".yaml"), ") or whole folders on the open page, or ", b("choose files"), " or ", b("choose a folder"), ". Give every file the schema imports, includes or names in a ", code("$ref"), ": they are found by their relative path, or else by file name or namespace."], ["Files are indexed in your browser (WebAssembly); nothing is uploaded. A bundle made with ", code("schema-explorer index"), " opens by its address, or with ", code("?bundle="), " added to this page's link."], [code("schema-explorer serve "), " opens the viewer on your own computer with the schemas already loaded."] ), @@ -79,7 +79,7 @@ export function openHelp(host: HTMLElement): void { ["The top card shows the ", b("derivation"), " chain (extension or restriction) up to the root, the derived types and the global elements of this type."], [b("Content model"), " lists the child elements in order with their type and cardinality. ", code("sequence"), ", ", code("choice"), " and ", code("all"), " appear as indented rows; group references are expanded under their name. Rows inherited from base types are grouped per base type and can be folded; ", b("inherited"), " shows or hides them."], [b("Attributes"), " lists every attribute, inherited and from attribute groups, with its type, use and default or fixed value."], - [b("Where used"), " lists the types, elements and groups that refer to the type, and ", b("XSD source"), " shows the declaration as written, with links on the type names."] + [b("Where used"), " lists the types, elements and groups that refer to the type, and ", b("XSD source"), " (or ", b("JSON source"), ") shows the declaration as written, with links on the type names."] ), section( "Diagrams", diff --git a/web/src/views/home.ts b/web/src/views/home.ts index fb65109..b536dd6 100644 --- a/web/src/views/home.ts +++ b/web/src/views/home.ts @@ -1,7 +1,7 @@ // The start page of a schema set: size, namespaces, where to begin, problems. import type { AppCtx } from "../ctx"; import { button, h, plural } from "../dom"; -import type { Id } from "../model"; +import { TERMS, type Id } from "../model"; import { chips, section } from "./common"; export function homePage(ctx: AppCtx): HTMLElement { @@ -9,7 +9,8 @@ export function homePage(ctx: AppCtx): HTMLElement { const b = m.b; const st = b.stats; const page = h("div"); - page.appendChild(h("div", { class: "page-head" }, h("h1", null, m.title), h("span", { class: "badge" }, "XML Schema"))); + const json = b.language === "jsonSchema"; + page.appendChild(h("div", { class: "page-head" }, h("h1", null, m.title), h("span", { class: "badge" }, json ? dialects(b.files) : "XML Schema"))); const inputs = b.files.filter((f) => f.input).map((f) => f.name); page.appendChild(h("p", { class: "muted small" }, `${plural(b.files.length, "file")}${inputs.length && inputs.length < b.files.length ? `, from ${inputs.slice(0, 3).join(", ")}${inputs.length > 3 ? ` and ${inputs.length - 3} more` : ""}` : ""} · indexed by ${b.generator}`)); const g = st.global; @@ -18,16 +19,21 @@ export function homePage(ctx: AppCtx): HTMLElement { h( "div", { class: "stats" }, - stat(g.complexType, "complex types", () => ctx.goRoute({ view: "overview" })), - stat(g.simpleType, "simple types"), + stat(g.complexType, TERMS.complexTypes, () => ctx.goRoute({ view: "overview" })), + stat(g.simpleType, TERMS.simpleTypes), stat(g.element, "global elements"), stat((g.group ?? 0) + (g.attributeGroup ?? 0), "groups"), - stat(st.local.element, "local elements"), + stat(st.local.element, json ? "properties" : "local elements"), stat(st.enumerations, "enumeration values"), stat(b.files.length, "files") ) ); page.appendChild(section("Start here", { tools: [button("Overview map", () => ctx.goRoute({ view: "overview" }), { icon: "map", cls: "small" })] })); + const fileRoots = json ? m.lists.complexType.concat(m.lists.simpleType).filter((id) => !m.c[id].json?.pointer) : []; + if (fileRoots.length) { + page.appendChild(h("p", { class: "muted small" }, "The root schemas of the files.")); + page.appendChild(chips(ctx, fileRoots, { max: 24 })); + } const roots = m.rootElements(); if (roots.length) { page.appendChild(h("p", { class: "muted small" }, "Global elements nothing else refers to: the likely document roots.")); @@ -39,7 +45,7 @@ export function homePage(ctx: AppCtx): HTMLElement { .slice(0, 24) .map((x) => x.id as Id); if (popular.length) { - page.appendChild(h("p", { class: "muted small" }, "The most used and most extended complex types.")); + page.appendChild(h("p", { class: "muted small" }, `The most used and most extended ${TERMS.complexTypes}.`)); page.appendChild(chips(ctx, popular, { max: 24 })); } // Namespaces. @@ -54,7 +60,7 @@ export function homePage(ctx: AppCtx): HTMLElement { h( "table", { class: "ptable compact" }, - h("thead", null, h("tr", null, h("th", null, "Prefix"), h("th", null, "Namespace"), h("th", null, "Complex"), h("th", null, "Simple"), h("th", null, "Elements"))), + h("thead", null, h("tr", null, h("th", null, "Prefix"), h("th", null, "Namespace"), h("th", null, json ? "Objects" : "Complex"), h("th", null, json ? "Scalars" : "Simple"), h("th", null, json ? "" : "Elements"))), h("tbody", null, nsRows.map((x) => h("tr", null, h("td", { class: "mono" }, x.n.prefix), h("td", { class: "mono small" }, x.n.uri), h("td", null, h("a", { href: ctx.hrefRoute({ view: "overview", params: { ns: String(x.i) } }), onClick: (ev: Event) => { ev.preventDefault(); ctx.goRoute({ view: "overview", params: { ns: String(x.i) } }); @@ -76,3 +82,10 @@ export function homePage(ctx: AppCtx): HTMLElement { } return page; } + +/** The schema languages of JSON files: "JSON Schema 2020-12", "OpenAPI 3.0.3", ... */ +function dialects(files: { dialect?: string; input?: boolean }[]): string { + const set = new Set(files.filter((f) => f.input && f.dialect).map((f) => f.dialect!)); + if (set.size === 0) for (const f of files) if (f.dialect) set.add(f.dialect); + return [...set].slice(0, 2).join(", ") || "JSON Schema"; +} diff --git a/web/src/views/overview.ts b/web/src/views/overview.ts index 8ca9286..edb6b7b 100644 --- a/web/src/views/overview.ts +++ b/web/src/views/overview.ts @@ -3,6 +3,7 @@ import type { AppCtx } from "../ctx"; import { Diagram } from "../diagram"; import { button, h } from "../dom"; import { buildOverview, folders, toMermaid, toPlantUml, type GraphSpec, type OverviewOptions } from "../graph"; +import { TERMS } from "../model"; import { boxActions, exportControls, fullscreenButton, parseView, viewParam } from "./diagramPanel"; export function overviewPage(ctx: AppCtx): HTMLElement { @@ -16,7 +17,7 @@ export function overviewPage(ctx: AppCtx): HTMLElement { let view = parseView(P.view); let restoreView = view !== undefined; const sync = () => ctx.setParams({ ns: o.ns === undefined ? "all" : String(o.ns), folder: o.folder, assoc: o.associations ? "1" : undefined, max: o.maxNodes !== 250 ? String(o.maxNodes) : undefined, view: viewParam(view ?? null) }); - page.appendChild(h("div", { class: "page-head" }, h("h1", null, "Overview map"), h("span", { class: "muted" }, "complex types and how they derive from each other"))); + page.appendChild(h("div", { class: "page-head" }, h("h1", null, "Overview map"), h("span", { class: "muted" }, `${TERMS.complexTypes} and how they derive from each other`))); const wrap = h("div", { class: "diagram tall" }); const statusText = h("span"); const status = h("div", { class: "status" }, statusText, h("div", { class: "legend hide-mobile" }, h("span", { class: "gen" }, "extends"), h("span", { class: "restr" }, "restricts"), h("span", { class: "comp" }, "contains"))); diff --git a/web/src/views/pages.ts b/web/src/views/pages.ts index 93144ed..b44ec15 100644 --- a/web/src/views/pages.ts +++ b/web/src/views/pages.ts @@ -66,6 +66,7 @@ function typeBody(ctx: AppCtx, tid: Id, page: HTMLElement): void { } const attrs = m.attributes(tid); const ownA = attrs.filter((a) => a.from === tid).length; + if (m.b.language === "jsonSchema" && !attrs.length) return; page.append(section("Attributes", { count: attrs.length, tools: attrs.length > ownA ? [h("span", { class: "faint small" }, `${attrs.length - ownA} inherited`)] : [] })); page.append(attrs.length || ct.effective?.anyAttribute || ct.anyAttribute ? attrTable(ctx, tid, attrs, ct.effective?.anyAttribute ?? ct.anyAttribute) : emptyNote("No attributes.")); if (ct.asserts?.length) page.append(section("Assertions", { count: ct.asserts.length }), h("ul", { class: "cons" }, ct.asserts.map((a) => h("li", null, h("code", null, a))))); @@ -79,7 +80,7 @@ function typeBody(ctx: AppCtx, tid: Id, page: HTMLElement): void { export function contentTable(ctx: AppCtx, tid: Id, rows: ContentRow[], showInherited: boolean): HTMLElement { const m = ctx.m; const table = h("table", { class: "ptable cm" }); - table.appendChild(h("thead", null, h("tr", null, h("th", null, "Element"), h("th", null, "Type"), h("th", null, "Card."), h("th", null, "Documentation")))); + table.appendChild(h("thead", null, h("tr", null, h("th", null, KIND_LABEL.element), h("th", null, "Type"), h("th", null, "Card."), h("th", null, "Documentation")))); let body = h("tbody"); table.appendChild(body); let from: Id | undefined; @@ -96,7 +97,7 @@ export function contentTable(ctx: AppCtx, tid: Id, rows: ContentRow[], showInher const t = (ev.currentTarget as HTMLElement).closest("tbody")!; const shut = t.classList.toggle("folded"); (ev.currentTarget as HTMLElement).setAttribute("aria-expanded", String(!shut)); - } }, "▾ ") : null, inherited ? "Inherited from " : "Declared in ", link(ctx, r.from), h("span", { class: "faint" }, ` · ${plural(n, "element")}`))); + } }, "▾ ") : null, inherited ? "Inherited from " : "Declared in ", link(ctx, r.from), h("span", { class: "faint" }, ` · ${plural(n, KIND_LABEL.element.toLowerCase())}`))); body.appendChild(head); table.appendChild(body); } @@ -112,14 +113,18 @@ function contentRow(ctx: AppCtx, r: ContentRow, inherited: boolean): HTMLElement const pad = { paddingLeft: `${10 + r.depth * 18}px` }; const card = occursText(r.min, r.max); const cls = `${inherited ? "inherited" : ""}${r.inChoice ? " in-choice" : ""}`; + const json = m.b.language === "jsonSchema"; if (r.kind === "compositor") { - return h("tr", { class: `${cls} comp-row` }, h("td", { style: pad, colspan: 2 }, h("span", { class: `compositor c-${r.compositor}` }, r.compositor)), h("td", { class: "mult" }, card), h("td", { class: "faint small" }, r.compositor === "choice" ? "one of the following" : r.compositor === "all" ? "all, in any order" : "")); + const name = json ? (r.compositor === "choice" ? (r.max === null ? "anyOf" : "oneOf") : "properties") : r.compositor; + const note = r.compositor === "choice" ? (json && r.max === null ? "one or more of the following" : "one of the following") : r.compositor === "all" ? (json ? "in any order" : "all, in any order") : ""; + return h("tr", { class: `${cls} comp-row` }, h("td", { style: pad, colspan: 2 }, h("span", { class: `compositor c-${r.compositor}` }, name)), h("td", { class: "mult" }, json && r.compositor === "choice" ? "" : card), h("td", { class: "faint small" }, note)); } if (r.kind === "group") { return h("tr", { class: `${cls} comp-row` }, h("td", { style: pad, colspan: 2 }, h("span", { class: "compositor c-group" }, "group "), r.id !== undefined ? link(ctx, r.id, m.qname(r.id)) : h("span", { class: "missing" }, m.refName(r.missing))), h("td", { class: "mult" }, card), h("td", null, r.id !== undefined ? shortDoc(m.comp(r.id).docs) : null)); } if (r.kind === "any") { const w = r.wildcard!; + if (json) return h("tr", { class: cls }, h("td", { style: pad }, h("span", { class: "mono" }, "*")), h("td", { class: "mono small" }, "any"), h("td", { class: "mult" }, card), h("td", { class: "faint small" }, "additional properties")); return h("tr", { class: cls }, h("td", { style: pad }, h("span", { class: "mono" }, "any")), h("td", { class: "mono small" }, w.namespace ?? "##any", w.processContents ? h("span", { class: "faint" }, ` (${w.processContents})`) : null), h("td", { class: "mult" }, card), h("td", null)); } const id = r.id; @@ -133,7 +138,7 @@ function contentRow(ctx: AppCtx, r: ContentRow, inherited: boolean): HTMLElement "tr", { class: cls }, h("td", { class: "pname", style: pad }, link(ctx, id, el.name ?? ""), isRef ? h("span", { class: "role", title: "a reference to a global element" }, "ref") : null, el.element?.abstract ? h("span", { class: "role" }, "abstract") : null, (el.element?.members?.length ?? 0) > 0 ? h("span", { class: "role", title: "elements of its substitution group may appear instead" }, `+${el.element!.members!.length} substitutes`) : null), - h("td", { class: "rng" }, t === undefined ? h("span", { class: "faint" }, "anyType") : isId(t) && !m.comp(t).name ? link(ctx, t, "anonymous") : refLink(ctx, t, true)), + h("td", { class: "rng" }, el.json?.array ? h("span", { class: "faint" }, "array of ") : null, t === undefined ? h("span", { class: "faint" }, json ? "any" : "anyType") : isId(t) && !m.comp(t).name ? link(ctx, t, json ? `inline ${m.comp(t).json?.kind || "schema"}` : "anonymous") : refLink(ctx, t, true), el.json?.deprecated ? h("span", { class: "role" }, "deprecated") : null, el.json?.readOnly ? h("span", { class: "role" }, "read-only") : null, el.json?.writeOnly ? h("span", { class: "role" }, "write-only") : null), h("td", { class: "mult" }, card, el.element?.nillable ? h("div", { class: "faint small" }, "nillable") : null), h("td", null, docs ? shortDoc(docs) : typeDoc ? h("div", { class: "faint" }, shortDoc(typeDoc)) : null) ); diff --git a/web/src/views/sidebar.ts b/web/src/views/sidebar.ts index 9922053..2359220 100644 --- a/web/src/views/sidebar.ts +++ b/web/src/views/sidebar.ts @@ -3,7 +3,7 @@ // types (NeTEx has about 2,600 complex types) scroll smoothly. import type { AppCtx } from "../ctx"; import { debounce, h, icon, plural } from "../dom"; -import { KIND_LABEL, type Id } from "../model"; +import { KIND_LABEL, TERMS, type Id } from "../model"; type Tab = "complexType" | "simpleType" | "element" | "groups" | "files"; @@ -132,8 +132,8 @@ export function sidebar(ctx: AppCtx): Sidebar { const groupsCount = m.lists.group.length + m.lists.attributeGroup.length + m.lists.attribute.length; const tabDefs: { id: Tab; label: string; count: number; title: string }[] = [ - { id: "complexType", label: "Types", count: m.lists.complexType.length, title: "Complex types" }, - { id: "simpleType", label: "Simple", count: m.lists.simpleType.length, title: "Simple types" }, + { id: "complexType", label: TERMS.typesTab, count: m.lists.complexType.length, title: TERMS.complexTypes[0].toUpperCase() + TERMS.complexTypes.slice(1) }, + { id: "simpleType", label: TERMS.simpleTab, count: m.lists.simpleType.length, title: TERMS.simpleTypes[0].toUpperCase() + TERMS.simpleTypes.slice(1) }, { id: "element", label: "Elements", count: m.lists.element.length, title: "Global elements" }, { id: "groups", label: "Groups", count: groupsCount, title: "Groups, attribute groups and global attributes" }, { id: "files", label: "Files", count: m.b.files.length, title: "Schema files" }, diff --git a/web/src/wasm.worker.ts b/web/src/wasm.worker.ts index f5fdb71..438ac4e 100644 --- a/web/src/wasm.worker.ts +++ b/web/src/wasm.worker.ts @@ -1,12 +1,13 @@ -// Indexes XSD files with the Rust core compiled to WebAssembly, off the main thread. -import init, { diff, diff_report, expand, index } from "./wasm/schema_explorer_wasm.js"; +// Indexes schema files with the Rust core compiled to WebAssembly, off the main thread. +import init, { diff, diff_report, expand, index, json_source } from "./wasm/schema_explorer_wasm.js"; import wasmUrl from "./wasm/schema_explorer_wasm_bg.wasm?url"; type Req = | { id: number; op: "index"; names: string[]; contents: Uint8Array[] } | { id: number; op: "expand"; json: string } | { id: number; op: "diff"; old: string; new: string; options: string } - | { id: number; op: "report"; json: string; format: string }; + | { id: number; op: "report"; json: string; format: string } + | { id: number; op: "source"; name: string; text: string; pointer: string }; let ready: Promise | null = null; @@ -17,7 +18,15 @@ self.onmessage = async (ev: MessageEvent) => { await ready; const started = performance.now(); const json = - req.op === "index" ? index(req.names, req.contents, false) : req.op === "expand" ? expand(req.json) : req.op === "diff" ? diff(req.old, req.new, req.options) : diff_report(req.json, req.format); + req.op === "index" + ? index(req.names, req.contents, false) + : req.op === "expand" + ? expand(req.json) + : req.op === "diff" + ? diff(req.old, req.new, req.options) + : req.op === "source" + ? json_source(req.name, req.text, req.pointer) + : diff_report(req.json, req.format); (self as unknown as Worker).postMessage({ id: req.id, json, ms: performance.now() - started }); } catch (e) { (self as unknown as Worker).postMessage({ id: req.id, error: String(e) }); From f7bbe1ddabda75347e7df693339bcaa8ba1c847c Mon Sep 17 00:00:00 2001 From: Mathias Vanden Auweele Date: Wed, 21 Oct 2026 18:29:37 +0000 Subject: [PATCH 6/8] ci: bump GitHub Actions to their current major versions checkout v7, cache v6, setup-node v7, upload-artifact v7, download-artifact v8, upload-pages-artifact v5, deploy-pages v5, the docker actions and action-gh-release v3: the Node 20 based majors are deprecated. --- .github/actions/build-web/action.yml | 2 +- .github/workflows/ci.yml | 6 +++--- .github/workflows/docker.yml | 12 ++++++------ .github/workflows/pages.yml | 8 ++++---- .github/workflows/release.yml | 22 +++++++++++----------- 5 files changed, 25 insertions(+), 25 deletions(-) diff --git a/.github/actions/build-web/action.yml b/.github/actions/build-web/action.yml index 5df3f64..16ce036 100644 --- a/.github/actions/build-web/action.yml +++ b/.github/actions/build-web/action.yml @@ -20,7 +20,7 @@ runs: if [ "$(wasm-bindgen --version 2>/dev/null | awk '{print $2}')" != "$V" ]; then cargo install wasm-bindgen-cli --version "$V" --locked --force fi - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v7 with: node-version: 22 cache: npm diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a413339..75bebf0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,7 +20,7 @@ jobs: matrix: os: [ubuntu-latest, macos-latest, windows-latest] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 - name: Fetch the schema fixtures (NeTEx, OSDM) @@ -35,7 +35,7 @@ jobs: name: Lint runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - uses: dtolnay/rust-toolchain@stable with: components: rustfmt, clippy @@ -49,7 +49,7 @@ jobs: name: Web viewer runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - uses: ./.github/actions/build-web with: lib: "true" diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 3ba3153..9868623 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -20,7 +20,7 @@ jobs: name: Build image runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Use the release version if: github.event_name == 'release' @@ -37,13 +37,13 @@ jobs: # requests and main build amd64 only. - name: Set up QEMU if: github.event_name == 'release' - uses: docker/setup-qemu-action@v3 + uses: docker/setup-qemu-action@v4 - - uses: docker/setup-buildx-action@v3 + - uses: docker/setup-buildx-action@v4 - name: Log in to GitHub Container Registry if: github.event_name != 'pull_request' - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: ghcr.io username: ${{ github.actor }} @@ -51,7 +51,7 @@ jobs: - name: Image tags id: meta - uses: docker/metadata-action@v5 + uses: docker/metadata-action@v6 with: images: ${{ env.IMAGE }} tags: | @@ -61,7 +61,7 @@ jobs: type=raw,value=latest,enable=${{ github.event_name == 'release' }} - name: Build and push - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7 with: context: . platforms: ${{ github.event_name == 'release' && 'linux/amd64,linux/arm64' || 'linux/amd64' }} diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 04c9cd6..cef7c62 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -27,10 +27,10 @@ jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - uses: ./.github/actions/build-web - name: Cache the example downloads - uses: actions/cache@v4 + uses: actions/cache@v6 with: path: tests/fixtures/downloads key: examples-${{ hashFiles('scripts/build-examples.sh') }} @@ -42,7 +42,7 @@ jobs: run: | cp THIRD_PARTY_NOTICES.md web/dist/ touch web/dist/.nojekyll - - uses: actions/upload-pages-artifact@v3 + - uses: actions/upload-pages-artifact@v5 with: path: web/dist @@ -54,4 +54,4 @@ jobs: url: ${{ steps.deployment.outputs.page_url }} steps: - id: deployment - uses: actions/deploy-pages@v4 + uses: actions/deploy-pages@v5 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d12d13d..bb37e83 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -22,17 +22,17 @@ jobs: runs-on: ubuntu-latest if: startsWith(github.ref, 'refs/tags/v') steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Use the release version run: scripts/set-version.sh "${GITHUB_REF#refs/tags/v}" - uses: ./.github/actions/build-web with: lib: "true" - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v7 with: name: web-dist path: web/dist - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v7 with: name: web-dist-lib path: web/dist-lib @@ -46,12 +46,12 @@ jobs: env: HAS_CARGO_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN != '' }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 - name: Use the release version run: scripts/set-version.sh "${GITHUB_REF#refs/tags/v}" - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@v8 with: name: web-dist path: crates/cli/web-dist @@ -86,7 +86,7 @@ jobs: - { target: aarch64-apple-darwin, os: macos-latest, archive: tar.gz } - { target: x86_64-pc-windows-msvc, os: windows-latest, archive: zip } steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - uses: dtolnay/rust-toolchain@stable with: targets: ${{ matrix.target }} @@ -100,7 +100,7 @@ jobs: sed -i.bak -E "s/^version = \"[^\"]+\"/version = \"$V\"/" Cargo.toml sed -i.bak -E "s/(schema-explorer-core = \{ path = \"[^\"]+\", version = \")[^\"]+\"/\1$V\"/" Cargo.toml crates/wasm/Cargo.toml rm -f Cargo.toml.bak crates/wasm/Cargo.toml.bak - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@v8 with: name: web-dist path: web/dist @@ -116,7 +116,7 @@ jobs: if [ "${{ matrix.archive }}" = zip ]; then 7z a "$NAME.zip" "$NAME" >/dev/null; echo "ASSET=$NAME.zip" >> "$GITHUB_ENV" else tar czf "$NAME.tar.gz" "$NAME"; echo "ASSET=$NAME.tar.gz" >> "$GITHUB_ENV"; fi - name: Attach to the release - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@v3 with: files: ${{ env.ASSET }} @@ -127,14 +127,14 @@ jobs: permissions: id-token: write # npm trusted publishing and provenance steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 with: node-version: 22 registry-url: https://registry.npmjs.org - name: Trusted publishing needs npm 11.5 or later run: npm install -g npm@latest - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@v8 with: name: web-dist-lib path: web/dist-lib From 1b4930f6dff5ee97bb6c7f71e39e70762e2f4271 Mon Sep 17 00:00:00 2001 From: Mathias Vanden Auweele Date: Wed, 21 Oct 2026 18:34:17 +0000 Subject: [PATCH 7/8] feat(pages): add the UIC OSDM OpenAPI and JSON Schema 2020-12 meta-schema examples Both are fetched at build time like the other examples (OSDM, Apache-2.0, from its repository; the meta-schemas, BSD-3-Clause, from json-schema.org) and keep their files under schema/ for the source view. --- scripts/build-examples.sh | 47 +++++++++++++++++++++++++++++++++++---- 1 file changed, 43 insertions(+), 4 deletions(-) diff --git a/scripts/build-examples.sh b/scripts/build-examples.sh index d0614bb..4ff8c46 100755 --- a/scripts/build-examples.sh +++ b/scripts/build-examples.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash # Builds the examples catalog of the Pages site into /examples/: -# a pre-indexed bundle per schema set, its .xsd files (for the source view) -# and examples.json, which the open page lists. +# a pre-indexed bundle per schema set, its schema files (for the source +# view) and examples.json, which the open page lists. # # Only freely redistributable schema sets go here, each fetched from its # publisher at build time (pinned URLs, cached in $WORK/downloads): @@ -10,6 +10,8 @@ # source acknowledged) # - OASIS UBL 2.4 (OASIS IPR Policy) # - W3C XML Schema for schemas (W3C document licence) +# - UIC OSDM, OpenAPI with JSON schemas (Apache-2.0), from its public repository +# - JSON Schema 2020-12 meta-schemas (BSD-3-Clause) # Never add railML (CC BY-NC-ND) or other schemas whose licence forbids # redistribution. An example whose download fails is left out with a warning. # @@ -29,11 +31,11 @@ fetch() { curl -fsSL --retry 4 --retry-delay 2 --retry-all-errors -o "$DL/$2.part" "$1" && mv "$DL/$2.part" "$DL/$2" } -# entry <description> <version> <publisher> <licence> <link> [focus] +# entry <id> <title> <description> <version> <publisher> <licence> <link> [focus] [sources dir] entry() { local focus="" [ -n "${8:-}" ] && focus=$(printf ',\n "focus": "%s"' "$8") - ENTRIES+=("$(printf ' {\n "id": "%s",\n "title": "%s",\n "description": "%s",\n "version": "%s",\n "publisher": "%s",\n "licence": "%s",\n "link": "%s",\n "src": "%s/bundle.json",\n "sources": "%s/xsd/"%s\n }' "$1" "$2" "$3" "$4" "$5" "$6" "$7" "$1" "$1" "$focus")") + ENTRIES+=("$(printf ' {\n "id": "%s",\n "title": "%s",\n "description": "%s",\n "version": "%s",\n "publisher": "%s",\n "licence": "%s",\n "link": "%s",\n "src": "%s/bundle.json",\n "sources": "%s/%s/"%s\n }' "$1" "$2" "$3" "$4" "$5" "$6" "$7" "$1" "$1" "${9:-xsd}" "$focus")") } # start <id>: a fresh output directory for an example. @@ -116,6 +118,43 @@ TXT "W3C" "W3C Document License" "https://www.w3.org/TR/xmlschema-1/" "ct/xs:complexType" else echo "warning: W3C XMLSchema.xsd not downloaded, example left out" >&2; fi +# UIC OSDM (Open Sales and Distribution Model): the OpenAPI 3.0 document of +# the online API and the YAML files with its JSON schemas. +if [ -d "$WORK/OSDM/.git" ] || git clone --depth 1 https://github.com/UnionInternationalCheminsdeFer/OSDM "$WORK/OSDM"; then + SPEC="$WORK/OSDM/specification" + OSDM_VERSION=$(sed -n 's/^ version: *//p' "$SPEC/OSDM-online-api.yml" | head -n 1) + start osdm + rmdir "$OUT/xsd" + mkdir -p "$OUT/schema" + cp -r "$SPEC/OSDM-online-api.yml" "$SPEC/schemas" "$SPEC/components" "$SPEC/paths" "$OUT/schema/" + cp "$WORK/OSDM/LICENSE" "$OUT/LICENSE" + "$CLI" index "$OUT/schema/OSDM-online-api.yml" -o "$OUT/bundle.json" + entry osdm "OSDM" "UIC Open Sales and Distribution Model: the OpenAPI 3.0 specification of the online API and its JSON schemas (OSDM-online-api.yml)" "${OSDM_VERSION:-latest}" \ + "UIC · OSDM working group" "Apache-2.0" "https://github.com/UnionInternationalCheminsdeFer/OSDM" "ct/Offer" schema +else echo "warning: OSDM not cloned, example left out" >&2; fi + +# JSON Schema 2020-12: the meta-schema and its vocabulary meta-schemas. +JS=https://json-schema.org/draft/2020-12 +META="core applicator unevaluated validation meta-data format-annotation content" +ok=1 +fetch $JS/schema jsonschema-2020-12-schema.json || ok=0 +for v in $META; do fetch "$JS/meta/$v" "jsonschema-2020-12-meta-$v.json" || ok=0; done +if [ "$ok" = 1 ]; then + start jsonschema + rmdir "$OUT/xsd" + mkdir -p "$OUT/schema/meta" + cp "$DL/jsonschema-2020-12-schema.json" "$OUT/schema/schema.json" + for v in $META; do cp "$DL/jsonschema-2020-12-meta-$v.json" "$OUT/schema/meta/$v.json"; done + if fetch https://raw.githubusercontent.com/json-schema-org/json-schema-spec/main/LICENSE jsonschema-LICENSE; then + cp "$DL/jsonschema-LICENSE" "$OUT/LICENSE" + else + echo "Copyright (c) 2022 JSON Schema Specification Authors. BSD-3-Clause, https://github.com/json-schema-org/json-schema-spec" > "$OUT/LICENSE" + fi + "$CLI" index "$OUT/schema" -o "$OUT/bundle.json" + entry jsonschema "JSON Schema 2020-12 (meta-schemas)" "The JSON Schema 2020-12 meta-schema that describes JSON Schema itself, with its vocabulary meta-schemas (core, applicator, validation, ...)" "2020-12" \ + "JSON Schema Specification Authors" "BSD-3-Clause" "https://json-schema.org/draft/2020-12" "ct/schema" schema +else echo "warning: JSON Schema meta-schemas not downloaded, example left out" >&2; fi + { echo "[" for i in "${!ENTRIES[@]}"; do From 31c859e4ce1a60342435517374f32c7fc78d2974 Mon Sep 17 00:00:00 2001 From: Mathias Vanden Auweele <mathias@matdata.eu> Date: Wed, 21 Oct 2026 18:34:17 +0000 Subject: [PATCH 8/8] docs: describe JSON Schema, OpenAPI and AsyncAPI support The intro, features, indexing and diff sections cover JSON Schema; the new examples are credited in the README and THIRD_PARTY_NOTICES. --- README.md | 61 +++++++++++++++++++++++++++++++++--------- THIRD_PARTY_NOTICES.md | 6 +++++ crates/cli/Cargo.toml | 2 +- publiccode.yml | 7 ++--- web/package.json | 2 +- 5 files changed, 60 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 03f6e85..3f9df04 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,11 @@ # schema-explorer -XML Schema (XSD) as UML-like diagrams, readable type pages and version diffs. +XML Schema (XSD) and JSON Schema (also inside OpenAPI and AsyncAPI) as +UML-like diagrams, readable type pages and version diffs. -Large schema sets such as NeTEx or railML are hard to read as hundreds of -`.xsd` files. schema-explorer reads a whole set, follows its imports and -includes, and shows it the way people are used to: class diagrams, content +Large schema sets such as NeTEx, railML or an OpenAPI specification are hard +to read as hundreds of `.xsd`, `.json` or `.yaml` files. schema-explorer +reads a whole set, follows its imports, includes and `$ref`s, and shows it the way people are used to: class diagrams, content trees, and one page per type and element with every child, attribute, cardinality and allowed value. It also compares two versions of a set and says what changed and what breaks. It is the schema counterpart of @@ -12,17 +13,30 @@ says what changed and what breaks. It is the schema counterpart of It runs entirely in the browser, so it can be published on GitHub Pages, and a small command-line tool pre-indexes large sets, serves the viewer locally -and writes diff reports. JSON Schema support is planned. +and writes diff reports. **Try it:** <https://matdata-eu.github.io/schema-explorer/> with NeTEx, -ERA TAF and TAP TSI, OASIS UBL 2.4 and the W3C schema for schemas, or drop -your own files on it. +ERA TAF and TAP TSI, OASIS UBL 2.4, the W3C schema for schemas, the UIC OSDM +OpenAPI specification and the JSON Schema 2020-12 meta-schemas, or drop your +own files on it. ![The page of a NeTEx complex type: derivation chain and class diagram](docs/screenshot-type.png) ## Features -- **Open** `.xsd` files or whole folders by drag and drop or the file picker: +- **XML Schema** 1.0 (tolerating 1.1 constructs) and **JSON Schema** + draft-04, -06, -07, 2019-09 and 2020-12 (picked by `$schema`), in JSON or + YAML, including the schemas of **OpenAPI** 3.0/3.1 (`components.schemas`) + and **AsyncAPI** 2/3 documents (schemas and message payloads). JSON Schema + maps onto the same model and is shown in JSON terms: named schemas + (`$defs`, `definitions`, components) are object or scalar definitions, + properties have a cardinality from `required` and `minItems`/`maxItems`, + a `$ref` in `allOf` is a generalisation, `oneOf`/`anyOf` are choices, + `enum`/`const` are enumerations, `format`, `pattern`, `minimum` and the + other validation keywords are facets, and `patternProperties` and + `additionalProperties` are listed. `$ref` is resolved across files, JSON + pointers, `$id` and `$anchor`. +- **Open** `.xsd`, `.json`, `.yaml` files or whole folders by drag and drop or the file picker: they are indexed in the browser (WebAssembly, in a worker), nothing is uploaded. Or open a pre-built bundle by its address (`?bundle=<url>`), or pick an example. @@ -35,7 +49,9 @@ your own files on it. `sequence` / `choice` / `all` and groups, inherited rows grouped per base type and foldable), attributes (type, use, default or fixed), facets and enumerations with their documentation, identity constraints, where used, - the source file and line, and the XSD of the component with linked names. + the source file and line, and the XSD of the component with linked names + (for JSON Schema: the component's schema as JSON, with `$ref`s linked, + its examples and deprecated/read-only flags). - **Element pages** (global and local): type, substitution group head and members, nillable, abstract, default or fixed, where used. - **Simple type pages**: restriction facets, enumeration table, list item and @@ -64,6 +80,7 @@ your own files on it. ```sh schema-explorer serve path/to/schemas/ # every .xsd file in the folder +schema-explorer serve openapi.yaml # JSON Schema, OpenAPI, AsyncAPI schema-explorer serve main.xsd --bind 0.0.0.0:8080 --no-open ``` @@ -78,8 +95,8 @@ release. Until then, build from source with `cargo build --release -p schema-exp ## Indexing a schema set -The core reads an XSD set (XSD 1.0, tolerating 1.1 constructs) into one JSON -bundle for the viewer: every global and local component keyed by qualified +The core reads an XSD set (XSD 1.0, tolerating 1.1 constructs) or a JSON +Schema set (JSON Schema, OpenAPI or AsyncAPI files) into one JSON bundle for the viewer: every global and local component keyed by qualified name and source file and line, with documentation, references resolved to ids, "where used" back-references, derived types, substitution group members, effective content models and a search index. @@ -87,8 +104,16 @@ members, effective content models and a search index. ```sh schema-explorer index path/to/NeTEx_publication.xsd -o bundle.json schema-explorer index schemas/ --compact -o bundle.json # directories are read recursively +schema-explorer index OSDM-online-api.yml -o bundle.json # OpenAPI with its schema files ``` +A directory gives its `.xsd` files, or every `.json`, `.yaml` and `.yml` +file when it has no `.xsd` file. JSON Schema bundles say +`"language": "jsonSchema"` and carry a `json` block per component (its JSON +kind, JSON pointer, title, examples, flags); `$ref`s to other files are +followed from disk, and a `$ref` that cannot be resolved becomes an +`unresolved-ref` diagnostic. + Imports and includes are followed from disk; a location that cannot be found falls back to an input file with the imported namespace or the same file name, and otherwise becomes an `unresolved-import` diagnostic in the @@ -116,8 +141,14 @@ turns that off). schema-explorer diff railml-3.2/schema/ railml-3.3/schema/ --format md -o CHANGES.md schema-explorer diff old.json new.json -o diff.json # bundles work too schema-explorer diff a.xsd,b.xsd c.xsd,d.xsd --format html -o diff.html --fail-on-breaking +schema-explorer diff api-v1.yaml api-v2.yaml --format md # JSON Schema / OpenAPI ``` +For JSON Schema, a property that becomes required, a removed property or +enum value, a new `additionalProperties: false`, a tighter `maximum` or +`maxItems`, or a dropped `nullable` are breaking; property order is not +compared. + Several paths for one side are joined with commas. The JSON diff format is versioned (`format`, see `crates/core/src/diff.rs`). In the viewer, "Compare two versions" on the open page takes two schema sets; a link @@ -139,14 +170,17 @@ and changed (~, amber). `scripts/build-examples.sh <cli> <dist>` shows how the website is built: it pre-indexes a schema set into `bundle.json` and puts its `.xsd` files next to it (for the source view). Any static host works: -`index.html?bundle=examples/netex/bundle.json&sources=examples/netex/xsd/`. +`index.html?bundle=examples/netex/bundle.json&sources=examples/netex/xsd/` +(JSON Schema examples keep their files under `schema/`). Only publish schemas whose licence allows it. The website's examples are fetched from their publishers at build time and keep their own terms: NeTEx (GPL-3.0, NeTEx-CEN); TAF TSI and TAP TSI B.10 schemas, source: European Union Agency for Railways (reproduction authorised, source acknowledged); UBL 2.4, copyright © OASIS Open 2024 (OASIS IPR -Policy); XMLSchema.xsd and xml.xsd, copyright © W3C (W3C Document License). +Policy); XMLSchema.xsd and xml.xsd, copyright © W3C (W3C Document License); +OSDM, UIC (Apache-2.0); JSON Schema 2020-12 meta-schemas, copyright © 2022 +JSON Schema Specification Authors (BSD-3-Clause). See [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md). ## Development @@ -163,6 +197,7 @@ cd web && npm ci && npm run build Tests on real-world schemas read them from the directory in `SCHEMA_FIXTURES` (default `tests/fixtures`, gitignored) and are skipped when absent. `scripts/fetch-fixtures.sh` fetches the public [NeTEx](https://github.com/NeTEx-CEN/NeTEx) +and [OSDM](https://github.com/UnionInternationalCheminsdeFer/OSDM) (OpenAPI) schemas there; the tests also look for `railml/<version>/{schema,codelists}` and `telematics/{netex,taf,taf-v3.5.1,b5,b10}`. For timings, run `cargo test --release --test fixtures -- --nocapture`. Schemas whose licence forbids redistribution (such as railML) diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index bad4f41..c45f518 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -41,3 +41,9 @@ they are not part of this repository or the binary: with <https://www.w3.org/2001/xml.xsd>. Copyright © World Wide Web Consortium. Used under the [W3C Document License](https://www.w3.org/copyright/document-license-2023/). +- **OSDM**, the UIC Open Sales and Distribution Model specification (OpenAPI + and JSON schemas), from + <https://github.com/UnionInternationalCheminsdeFer/OSDM>, Apache-2.0. +- **JSON Schema 2020-12** meta-schemas, <https://json-schema.org/draft/2020-12>. + Copyright © 2022 JSON Schema Specification Authors. BSD-3-Clause + (<https://github.com/json-schema-org/json-schema-spec>). diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 0d2df63..9bc2686 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "schema-explorer" -description = "Visual explorer for XML Schema (XSD) and, later, JSON Schema: UML-like diagrams, content-model trees and a readable page per type, in a browser" +description = "Visual explorer for XML Schema (XSD) and JSON Schema (also OpenAPI and AsyncAPI): UML-like diagrams, content-model trees and a readable page per type, in a browser" version.workspace = true edition.workspace = true authors.workspace = true diff --git a/publiccode.yml b/publiccode.yml index bffccf8..4d77d26 100644 --- a/publiccode.yml +++ b/publiccode.yml @@ -25,15 +25,16 @@ intendedAudience: - transportation description: en: - shortDescription: Shows XML Schema (XSD) sets as UML-like diagrams, content-model trees and readable + shortDescription: Shows XML Schema (XSD) and JSON Schema sets as UML-like diagrams, content-model trees and readable pages, in the browser. longDescription: | Large XML Schema sets, such as the ones used for data exchange in public transport and railways, are hard to read for most people. schema-explorer reads a set of XSD files, follows includes, imports and references across files and namespaces, and shows the result as UML-like diagrams, content-model trees and one readable page per type and element. - It runs entirely in the browser, can be published on GitHub Pages, embeds in other web applications as a web component, and comes with a command-line tool and Docker image. Support for JSON Schema and for comparing schema versions is planned. + It runs entirely in the browser, can be published on GitHub Pages, embeds in other web applications as a web component, and comes with a command-line tool and Docker image. It reads XML Schema and JSON Schema (also the schemas of OpenAPI and AsyncAPI documents) and compares schema versions. documentation: https://github.com/Matdata-eu/schema-explorer#readme features: - - UML-like diagrams of XML Schema types and elements + - UML-like diagrams of XML Schema and JSON Schema (OpenAPI, AsyncAPI) types + - JSON Schema draft-04 to 2020-12, OpenAPI 3.x and AsyncAPI 2/3 input - A page per type and element with its content model - Search and cross-file reference following - Version comparison diff --git a/web/package.json b/web/package.json index 85fddcd..9e587a3 100644 --- a/web/package.json +++ b/web/package.json @@ -1,7 +1,7 @@ { "name": "@matdata/schema-explorer", "version": "0.1.0", - "description": "Visual explorer for XML Schema (XSD) and, later, JSON Schema: UML-like diagrams, content-model trees and readable pages. Web app and <schema-explorer> web component.", + "description": "Visual explorer for XML Schema (XSD) and JSON Schema (also OpenAPI and AsyncAPI): UML-like diagrams, content-model trees and readable pages. Web app and <schema-explorer> web component.", "license": "Apache-2.0", "author": "Mathias Vanden Auweele <mathias@matdata.eu>", "homepage": "https://github.com/Matdata-eu/schema-explorer",