betula-schema 0.6.0

Rust types and validating parsers for the betula bioinformatics JSON schemas
Documentation
//! Rust types and validating parsers for the betula bioinformatics JSON schemas.
//!
//! [`types`] is generated by typify from the JSON Schemas; don't edit it.
//! typify doesn't enforce every JSON Schema keyword (e.g. `minItems`), so
//! [`parse`] and [`parse_str`] validate against the canonical schema first and
//! only then deserialize. Use them rather than `serde_json::from_*` directly.
//!
//! ```
//! let tree: betula_schema::Tree =
//!     betula_schema::parse_str(r#"{"name": "A", "length": 0.1, "children": []}"#).unwrap();
//! assert_eq!(tree.name, "A");
//!
//! assert!(betula_schema::parse_str::<betula_schema::Tree>(r#"{"name": "A", "length": -1, "children": []}"#).is_err());
//! ```

extern crate self as betula_schema;

use std::fmt;

use serde::de::DeserializeOwned;
use serde_json::Value;

pub mod types;

const BUNDLE: &str = include_str!("../schema/betula.bundle.schema.json");

/// GFF3 strand (column 7). Hand-written because typify can't derive variant
/// names from `+`/`-`/`.`; generated types refer to it as `betula_schema::Strand`.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
pub enum Strand {
    #[serde(rename = "+")]
    Forward,
    #[serde(rename = "-")]
    Reverse,
    #[serde(rename = ".")]
    Unstranded,
}

/// A betula schema kind: a generated type paired with the schema it must satisfy.
pub trait Kind: DeserializeOwned {
    /// The schema title, e.g. `"Tree"`.
    const NAME: &'static str;

    #[doc(hidden)]
    fn validator() -> &'static jsonschema::Validator;
}

/// Why parsing failed.
///
/// ```
/// match betula_schema::parse_str::<betula_schema::Tree>(r#"{"name": "A", "length": -1, "children": []}"#) {
///     Err(betula_schema::Error::Invalid { kind, errors }) => {
///         assert_eq!(kind, "Tree");
///         assert!(errors[0].starts_with("/length"));
///     }
///     other => panic!("expected Error::Invalid, got {other:?}"),
/// }
/// ```
#[derive(Debug)]
pub enum Error {
    /// The input wasn't JSON, or (a binding bug) schema-valid JSON failed to deserialize.
    Json(serde_json::Error),
    /// The JSON doesn't conform to the schema for `kind`.
    Invalid { kind: &'static str, errors: Vec<String> },
}

impl fmt::Display for Error {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Error::Json(err) => write!(f, "{err}"),
            Error::Invalid { kind, errors } => write!(f, "invalid {kind}: {}", errors.join("; ")),
        }
    }
}

impl std::error::Error for Error {
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        match self {
            Error::Json(err) => Some(err),
            Error::Invalid { .. } => None,
        }
    }
}

impl From<serde_json::Error> for Error {
    fn from(err: serde_json::Error) -> Self {
        Error::Json(err)
    }
}

/// Check `value` against the schema for `T` without deserializing it.
///
/// ```
/// use serde_json::json;
/// assert!(betula_schema::validate::<betula_schema::Identifier>(&json!("seq1")).is_ok());
/// assert!(betula_schema::validate::<betula_schema::Identifier>(&json!("")).is_err());
/// ```
pub fn validate<T: Kind>(value: &Value) -> Result<(), Error> {
    let errors: Vec<String> = T::validator()
        .iter_errors(value)
        .map(|err| format!("{}: {err}", err.instance_path()))
        .collect();
    if errors.is_empty() {
        Ok(())
    } else {
        Err(Error::Invalid { kind: T::NAME, errors })
    }
}

/// Validate already-decoded JSON against the schema for `T`, then deserialize it.
///
/// ```
/// use serde_json::json;
/// let seq: betula_schema::Sequence =
///     betula_schema::parse(json!({"type": "rna-sequence", "identifier": "s1", "sequence": "ACGN"})).unwrap();
/// // "ACGN" is valid DNA too; the `type` discriminator decides the variant.
/// assert!(matches!(seq, betula_schema::Sequence::RnaSequence(_)));
/// ```
pub fn parse<T: Kind>(value: Value) -> Result<T, Error> {
    validate::<T>(&value)?;
    Ok(serde_json::from_value(value)?)
}

/// Parse JSON text into `T`, validating against its schema first.
///
/// ```
/// let tree: betula_schema::Tree = betula_schema::parse_str(r#"{"name": "A", "length": 0.1, "children": []}"#).unwrap();
/// assert_eq!(tree.name, "A");
/// assert!(betula_schema::parse_str::<betula_schema::Tree>("not json").is_err());
/// ```
pub fn parse_str<T: Kind>(text: &str) -> Result<T, Error> {
    parse(serde_json::from_str(text)?)
}

#[doc(hidden)]
pub fn build_validator(kind: &str) -> jsonschema::Validator {
    let mut schema: Value = serde_json::from_str(BUNDLE).expect("embedded bundle is valid JSON");
    schema["$ref"] = Value::String(format!("#/$defs/{kind}"));
    jsonschema::draft202012::new(&schema).expect("embedded bundle is a valid schema")
}

macro_rules! impl_kinds {
    ($($name:ident),* $(,)?) => {
        $(
            pub use crate::types::$name;

            impl Kind for types::$name {
                const NAME: &'static str = stringify!($name);

                fn validator() -> &'static jsonschema::Validator {
                    static VALIDATOR: std::sync::OnceLock<jsonschema::Validator> = std::sync::OnceLock::new();
                    VALIDATOR.get_or_init(|| build_validator(stringify!($name)))
                }
            }
        )*

        /// Every betula kind name.
        pub const KINDS: &[&str] = &[$(stringify!($name)),*];

        /// Parse into the kind named `kind`, re-serialize, and re-parse (validating
        /// again); `Ok(true)` if that yields an equal value. `None` for an unknown name.
        #[doc(hidden)]
        pub fn round_trip_by_name(kind: &str, value: Value) -> Option<Result<bool, Error>> {
            match kind {
                $(stringify!($name) => Some((|| {
                    let parsed = parse::<types::$name>(value)?;
                    let reparsed = parse::<types::$name>(serde_json::to_value(&parsed)?)?;
                    Ok(parsed == reparsed)
                })()),)*
                _ => None,
            }
        }
    };
}

include!("kinds.rs");