betula_schema/lib.rs
1//! Rust types and validating parsers for the betula bioinformatics JSON schemas.
2//!
3//! [`types`] is generated by typify from the JSON Schemas; don't edit it.
4//! typify doesn't enforce every JSON Schema keyword (e.g. `minItems`), so
5//! [`parse`] and [`parse_str`] validate against the canonical schema first and
6//! only then deserialize. Use them rather than `serde_json::from_*` directly.
7//!
8//! ```
9//! let tree: betula_schema::Tree =
10//! betula_schema::parse_str(r#"{"name": "A", "length": 0.1, "children": []}"#).unwrap();
11//! assert_eq!(tree.name, "A");
12//!
13//! assert!(betula_schema::parse_str::<betula_schema::Tree>(r#"{"name": "A", "length": -1, "children": []}"#).is_err());
14//! ```
15
16extern crate self as betula_schema;
17
18use std::fmt;
19
20use serde::de::DeserializeOwned;
21use serde_json::Value;
22
23pub mod types;
24
25const BUNDLE: &str = include_str!("../schema/betula.bundle.schema.json");
26
27/// GFF3 strand (column 7). Hand-written because typify can't derive variant
28/// names from `+`/`-`/`.`; generated types refer to it as `betula_schema::Strand`.
29#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
30pub enum Strand {
31 #[serde(rename = "+")]
32 Forward,
33 #[serde(rename = "-")]
34 Reverse,
35 #[serde(rename = ".")]
36 Unstranded,
37}
38
39/// A betula schema kind: a generated type paired with the schema it must satisfy.
40pub trait Kind: DeserializeOwned {
41 /// The schema title, e.g. `"Tree"`.
42 const NAME: &'static str;
43
44 #[doc(hidden)]
45 fn validator() -> &'static jsonschema::Validator;
46}
47
48/// Why parsing failed.
49///
50/// ```
51/// match betula_schema::parse_str::<betula_schema::Tree>(r#"{"name": "A", "length": -1, "children": []}"#) {
52/// Err(betula_schema::Error::Invalid { kind, errors }) => {
53/// assert_eq!(kind, "Tree");
54/// assert!(errors[0].starts_with("/length"));
55/// }
56/// other => panic!("expected Error::Invalid, got {other:?}"),
57/// }
58/// ```
59#[derive(Debug)]
60pub enum Error {
61 /// The input wasn't JSON, or (a binding bug) schema-valid JSON failed to deserialize.
62 Json(serde_json::Error),
63 /// The JSON doesn't conform to the schema for `kind`.
64 Invalid { kind: &'static str, errors: Vec<String> },
65}
66
67impl fmt::Display for Error {
68 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
69 match self {
70 Error::Json(err) => write!(f, "{err}"),
71 Error::Invalid { kind, errors } => write!(f, "invalid {kind}: {}", errors.join("; ")),
72 }
73 }
74}
75
76impl std::error::Error for Error {
77 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
78 match self {
79 Error::Json(err) => Some(err),
80 Error::Invalid { .. } => None,
81 }
82 }
83}
84
85impl From<serde_json::Error> for Error {
86 fn from(err: serde_json::Error) -> Self {
87 Error::Json(err)
88 }
89}
90
91/// Check `value` against the schema for `T` without deserializing it.
92///
93/// ```
94/// use serde_json::json;
95/// assert!(betula_schema::validate::<betula_schema::Identifier>(&json!("seq1")).is_ok());
96/// assert!(betula_schema::validate::<betula_schema::Identifier>(&json!("")).is_err());
97/// ```
98pub fn validate<T: Kind>(value: &Value) -> Result<(), Error> {
99 let errors: Vec<String> = T::validator()
100 .iter_errors(value)
101 .map(|err| format!("{}: {err}", err.instance_path()))
102 .collect();
103 if errors.is_empty() {
104 Ok(())
105 } else {
106 Err(Error::Invalid { kind: T::NAME, errors })
107 }
108}
109
110/// Validate already-decoded JSON against the schema for `T`, then deserialize it.
111///
112/// ```
113/// use serde_json::json;
114/// let seq: betula_schema::Sequence =
115/// betula_schema::parse(json!({"type": "rna-sequence", "identifier": "s1", "sequence": "ACGN"})).unwrap();
116/// // "ACGN" is valid DNA too; the `type` discriminator decides the variant.
117/// assert!(matches!(seq, betula_schema::Sequence::RnaSequence(_)));
118/// ```
119pub fn parse<T: Kind>(value: Value) -> Result<T, Error> {
120 validate::<T>(&value)?;
121 Ok(serde_json::from_value(value)?)
122}
123
124/// Parse JSON text into `T`, validating against its schema first.
125///
126/// ```
127/// let tree: betula_schema::Tree = betula_schema::parse_str(r#"{"name": "A", "length": 0.1, "children": []}"#).unwrap();
128/// assert_eq!(tree.name, "A");
129/// assert!(betula_schema::parse_str::<betula_schema::Tree>("not json").is_err());
130/// ```
131pub fn parse_str<T: Kind>(text: &str) -> Result<T, Error> {
132 parse(serde_json::from_str(text)?)
133}
134
135#[doc(hidden)]
136pub fn build_validator(kind: &str) -> jsonschema::Validator {
137 let mut schema: Value = serde_json::from_str(BUNDLE).expect("embedded bundle is valid JSON");
138 schema["$ref"] = Value::String(format!("#/$defs/{kind}"));
139 jsonschema::draft202012::new(&schema).expect("embedded bundle is a valid schema")
140}
141
142macro_rules! impl_kinds {
143 ($($name:ident),* $(,)?) => {
144 $(
145 pub use crate::types::$name;
146
147 impl Kind for types::$name {
148 const NAME: &'static str = stringify!($name);
149
150 fn validator() -> &'static jsonschema::Validator {
151 static VALIDATOR: std::sync::OnceLock<jsonschema::Validator> = std::sync::OnceLock::new();
152 VALIDATOR.get_or_init(|| build_validator(stringify!($name)))
153 }
154 }
155 )*
156
157 /// Every betula kind name.
158 pub const KINDS: &[&str] = &[$(stringify!($name)),*];
159
160 /// Parse into the kind named `kind`, re-serialize, and re-parse (validating
161 /// again); `Ok(true)` if that yields an equal value. `None` for an unknown name.
162 #[doc(hidden)]
163 pub fn round_trip_by_name(kind: &str, value: Value) -> Option<Result<bool, Error>> {
164 match kind {
165 $(stringify!($name) => Some((|| {
166 let parsed = parse::<types::$name>(value)?;
167 let reparsed = parse::<types::$name>(serde_json::to_value(&parsed)?)?;
168 Ok(parsed == reparsed)
169 })()),)*
170 _ => None,
171 }
172 }
173 };
174}
175
176include!("kinds.rs");