Skip to main content

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");