use std::sync::OnceLock;
use indexmap::IndexMap;
use jsonschema::error::ValidationErrorKind;
use serde_json::{json, Map, Value};
use crate::curation::identity::splits_from_json;
use crate::curation::quality::{quality_from_json, quality_to_json};
use crate::curation::{Cohort, Deidentification, Identity, Provenance, QualityRecord, SplitClaim, Timeline, Timepoint};
use crate::json::{dumps_meta, repr_str};
use crate::labels::LabelSet;
use crate::{Error, Result};
pub const META_DATASET: &str = "meta";
const SCHEMA_TEXT: &str = include_str!("../data/medh5-sample-1.0.schema.json");
pub fn schema_text() -> &'static str {
SCHEMA_TEXT
}
pub fn schema() -> &'static Value {
static SCHEMA: OnceLock<Value> = OnceLock::new();
SCHEMA.get_or_init(|| serde_json::from_str(SCHEMA_TEXT).expect("bundled schema is valid JSON"))
}
pub(crate) fn compile(schema: &Value) -> jsonschema::Validator {
jsonschema::draft202012::options().should_validate_formats(false).build(schema).expect("bundled schema compiles")
}
fn validator() -> &'static jsonschema::Validator {
static VALIDATOR: OnceLock<jsonschema::Validator> = OnceLock::new();
VALIDATOR.get_or_init(|| compile(schema()))
}
pub fn projection_of(schema: &Value) -> Value {
match schema {
Value::Object(map) => Value::Object(
map.iter()
.filter(|(k, v)| {
!(k.as_str() == "enum"
|| ((k.as_str() == "additionalProperties" || k.as_str() == "unevaluatedProperties")
&& v.as_bool() == Some(false)))
})
.map(|(k, v)| (k.clone(), projection_of(v)))
.collect(),
),
Value::Array(items) => Value::Array(items.iter().map(projection_of).collect()),
other => other.clone(),
}
}
fn projection_validator() -> &'static jsonschema::Validator {
static VALIDATOR: OnceLock<jsonschema::Validator> = OnceLock::new();
VALIDATOR.get_or_init(|| compile(&projection_of(schema())))
}
pub fn validate_against_schema(doc: &Value) -> Vec<String> {
schema_messages(validator(), doc)
}
pub fn validate_document_version(doc: &Value, version: &str) -> (Vec<String>, Vec<String>) {
let exact = validate_against_schema(doc);
if !crate::version::is_projection(version) || exact.is_empty() {
return (exact, Vec::new());
}
let relaxed = schema_messages(projection_validator(), doc);
let tolerated = exact.into_iter().filter(|m| !relaxed.contains(m)).collect();
(relaxed, tolerated)
}
pub fn projected(parsed: &Value) -> std::result::Result<Value, String> {
let mut out = parsed.clone();
let mut dropped: Vec<(String, String)> = Vec::new();
for error in validator().iter_errors(parsed) {
let at = error.instance_path().as_str().to_string();
let unread = || at.split('/').filter(|s| !s.is_empty()).map(unescape).collect::<Vec<_>>().join("/");
match error.kind() {
ValidationErrorKind::AdditionalProperties { unexpected }
| ValidationErrorKind::UnevaluatedProperties { unexpected } => {
dropped.extend(unexpected.iter().map(|member| (at.clone(), member.clone())));
}
ValidationErrorKind::Enum { options } => {
if options.as_array().is_some_and(|o| o.iter().any(|v| v == "other")) {
*out.pointer_mut(&at).ok_or_else(unread)? = Value::from("other");
continue;
}
let (parent, segment) = at.rsplit_once('/').ok_or_else(unread)?;
let owner = error
.schema_path()
.as_str()
.strip_suffix(&format!("/properties/{segment}/enum"))
.ok_or_else(unread)?;
let member = unescape(segment);
let required = schema().pointer(&format!("{owner}/required"));
if required.and_then(Value::as_array).is_some_and(|r| r.iter().any(|v| v == member.as_str())) {
return Err(unread());
}
dropped.push((parent.to_string(), member));
}
_ => {}
}
}
for (parent, member) in dropped {
if let Some(Value::Object(map)) = out.pointer_mut(&parent) {
map.remove(&member);
}
}
Ok(out)
}
fn unescape(segment: &str) -> String {
segment.replace("~1", "/").replace("~0", "~")
}
pub(crate) fn schema_messages(validator: &jsonschema::Validator, doc: &Value) -> Vec<String> {
let mut errors: Vec<(Vec<String>, String)> = validator
.iter_errors(doc)
.map(|err| {
let path: Vec<String> = err
.instance_path()
.as_str()
.split('/')
.filter(|s| !s.is_empty())
.map(|s| s.replace("~1", "/").replace("~0", "~"))
.collect();
let message = python_message(&err);
(path, message)
})
.collect();
errors.sort_by(|a, b| a.0.cmp(&b.0));
errors
.into_iter()
.map(|(path, message)| {
let where_ = if path.is_empty() { "<root>".to_string() } else { path.join("/") };
format!("{where_}: {message}")
})
.collect()
}
fn python_message(err: &jsonschema::ValidationError<'_>) -> String {
use crate::json::repr;
use jsonschema::error::{TypeKind, ValidationErrorKind as K};
let instance = repr(err.instance());
let plural = |n: usize| if n == 1 { "was" } else { "were" };
match err.kind() {
K::Required { property } => format!("{} is a required property", repr(property)),
K::Enum { options } => format!("{instance} is not one of {}", repr(options)),
K::Constant { expected_value } => format!("{} was expected", repr(expected_value)),
K::Type { kind } => {
let names: Vec<String> = match kind {
TypeKind::Single(t) => vec![repr_str(&t.to_string())],
TypeKind::Multiple(set) => set.iter().map(|t| repr_str(&t.to_string())).collect(),
};
format!("{instance} is not of type {}", names.join(", "))
}
K::AdditionalProperties { unexpected } => format!(
"Additional properties are not allowed ({} {} unexpected)",
unexpected.iter().map(|u| repr_str(u)).collect::<Vec<_>>().join(", "),
plural(unexpected.len())
),
K::UnevaluatedProperties { unexpected } => format!(
"Unevaluated properties are not allowed ({} {} unexpected)",
unexpected.iter().map(|u| repr_str(u)).collect::<Vec<_>>().join(", "),
plural(unexpected.len())
),
K::Minimum { limit } => format!("{instance} is less than the minimum of {}", repr(limit)),
K::Maximum { limit } => format!("{instance} is greater than the maximum of {}", repr(limit)),
K::ExclusiveMinimum { limit } => format!("{instance} is less than or equal to the minimum of {}", repr(limit)),
K::ExclusiveMaximum { limit } => {
format!("{instance} is greater than or equal to the maximum of {}", repr(limit))
}
K::MinItems { limit } => {
format!("{instance} {}", if *limit == 1 { "should be non-empty" } else { "is too short" })
}
K::MaxItems { limit } => {
format!("{instance} {}", if *limit == 0 { "is expected to be empty" } else { "is too long" })
}
K::MinLength { limit } => {
format!("{instance} {}", if *limit == 1 { "should be non-empty" } else { "is too short" })
}
K::MaxLength { limit } => {
format!("{instance} {}", if *limit == 0 { "is expected to be empty" } else { "is too long" })
}
K::MinProperties { limit } => {
format!(
"{instance} {}",
if *limit == 1 { "should be non-empty" } else { "does not have enough properties" }
)
}
K::MaxProperties { limit } => {
format!("{instance} {}", if *limit == 0 { "is expected to be empty" } else { "has too many properties" })
}
K::Pattern { pattern } => format!("{instance} does not match {}", repr_str(pattern)),
K::UniqueItems => format!("{instance} has non-unique elements"),
K::AnyOf { .. } | K::OneOfNotValid { .. } => format!("{instance} is not valid under any of the given schemas"),
K::OneOfMultipleValid { .. } => format!("{instance} is valid under each of the given schemas"),
K::Not { schema } => format!("{instance} should not be valid under {}", repr(schema)),
K::FalseSchema => format!("False schema does not allow {instance}"),
_ => err.to_string(),
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct SampleDocument {
pub identity: Identity,
pub timepoints: Timeline,
pub cohort: Cohort,
pub label_set: Option<LabelSet>,
pub provenance: Provenance,
pub quality: IndexMap<String, QualityRecord>,
pub splits: Vec<SplitClaim>,
pub acquisition: Map<String, Value>,
pub deidentification: Option<Deidentification>,
pub extra: Map<String, Value>,
}
impl SampleDocument {
pub fn new(identity: Identity, timepoints: Timeline) -> Self {
SampleDocument {
identity,
timepoints,
cohort: Cohort::default(),
label_set: None,
provenance: Provenance::default(),
quality: IndexMap::new(),
splits: Vec::new(),
acquisition: Map::new(),
deidentification: None,
extra: Map::new(),
}
}
pub fn to_json(&self) -> Value {
let mut doc = Map::new();
doc.insert("identity".into(), self.identity.to_json());
doc.insert("timepoints".into(), self.timepoints.to_json());
let cohort = self.cohort.to_json();
if cohort.as_object().map(|m| !m.is_empty()).unwrap_or(false) {
doc.insert("cohort".into(), cohort);
}
if let Some(ls) = &self.label_set {
doc.insert("label_set".into(), ls.to_json(None));
}
if !self.provenance.is_empty() {
doc.insert("provenance".into(), self.provenance.to_json());
}
if !self.quality.is_empty() {
doc.insert("quality".into(), quality_to_json(&self.quality));
}
if !self.splits.is_empty() {
doc.insert("splits".into(), Value::Array(self.splits.iter().map(SplitClaim::to_json).collect()));
}
if !self.acquisition.is_empty() {
doc.insert("acquisition".into(), Value::Object(self.acquisition.clone()));
}
if let Some(d) = &self.deidentification {
doc.insert("deidentification".into(), d.to_json());
}
if !self.extra.is_empty() {
doc.insert("extra".into(), Value::Object(self.extra.clone()));
}
Value::Object(doc)
}
pub fn from_json(doc: &Value) -> Result<Self> {
let map = doc.as_object().ok_or_else(|| Error::Schema("`meta` must hold a JSON object".into()))?;
let identity = match map.get("identity") {
Some(v) => Identity::from_json(v)?,
None => return Err(missing_member("identity")),
};
let timepoints = match map.get("timepoints") {
Some(v) => Timeline::from_json(v)?,
None => return Err(missing_member("timepoints")),
};
let object_or_empty = |key: &str| -> Map<String, Value> {
match map.get(key) {
Some(Value::Object(m)) => m.clone(),
_ => Map::new(),
}
};
Ok(SampleDocument {
identity,
timepoints,
cohort: Cohort::from_json(map.get("cohort"))?,
label_set: LabelSet::from_json(map.get("label_set"))?,
provenance: Provenance::from_json(map.get("provenance"))?,
quality: quality_from_json(map.get("quality"))?,
splits: splits_from_json(map.get("splits"))?,
acquisition: object_or_empty("acquisition"),
deidentification: Deidentification::from_json(map.get("deidentification"))?,
extra: object_or_empty("extra"),
})
}
pub fn dumps(&self) -> String {
dumps_meta(&self.to_json())
}
pub fn dumps_indented(&self, indent: usize) -> String {
crate::json::dumps(&self.to_json(), crate::json::Style::META.with_indent(Some(indent)))
}
pub fn loads(text: &str) -> Result<Self> {
Self::from_json(&parse_object(text)?)
}
pub fn loads_version(text: &str, version: Option<&str>) -> Result<Self> {
let doc = parse_object(text)?;
let Some(later) = version.filter(|v| crate::version::is_projection(v)) else {
return Self::from_json(&doc);
};
match projected(&doc) {
Ok(read) => Self::from_json(&read),
Err(at) => Err(Error::coded(
"W913",
format!(
"`meta` holds at {at} a value only a later minor defines, in a member the schema requires: \
MEDH5 {} has nothing to read in its place, so this MEDH5 {later} document cannot be read as \
its projection (1.1 ยง2.2)",
crate::version::LATEST_VERSION
),
)),
}
}
pub fn check_schema(&self) -> Vec<String> {
validate_against_schema(&self.to_json())
}
pub fn subject_id(&self) -> &str {
&self.identity.subject_id
}
pub fn group_id(&self) -> &str {
self.cohort.grouping_key(&self.identity.subject_id)
}
pub fn quality_of(&self, key: Option<&str>) -> Option<&QualityRecord> {
key.filter(|k| !k.is_empty()).and_then(|k| self.quality.get(k))
}
pub fn summary(&self) -> Value {
let timepoints: Vec<Value> = self
.timepoints
.iter()
.map(|t| {
json!({
"id": t.id,
"index": t.index,
"label": t.label,
"days_from_baseline": t.days_from_baseline.clone().map(Value::Number).unwrap_or(Value::Null),
})
})
.collect();
let label_set = match &self.label_set {
Some(ls) => json!({"id": ls.id, "version": ls.version, "classes": ls.len(), "form": ls.form}),
None => Value::Null,
};
json!({
"sample_id": self.identity.sample_id,
"subject_id": self.identity.subject_id,
"timepoints": timepoints,
"label_set": label_set,
"agents": self.provenance.n_agents(),
"activities": self.provenance.n_activities(),
"quality_records": self.quality.len(),
"deidentified": self.deidentification.is_some(),
})
}
}
fn missing_member(name: &str) -> Error {
Error::Schema(format!("sample document is missing required member {}", repr_str(name)))
}
fn parse_object(text: &str) -> Result<Value> {
let (doc, _) =
crate::json::loads_lenient(text).map_err(|e| Error::Schema(format!("`meta` is not valid JSON: {e}")))?;
if !doc.is_object() {
return Err(Error::Schema("`meta` must hold a JSON object".into()));
}
Ok(doc)
}
pub fn new_document(
sample_id: &str,
subject_id: Option<&str>,
timepoints: Option<&[String]>,
) -> Result<SampleDocument> {
let timeline = match timepoints {
None => Timeline::single("tp0")?,
Some(ids) => Timeline::new(
ids.iter().enumerate().map(|(i, id)| Timepoint::new(id.clone(), i as i64)).collect::<Result<Vec<_>>>()?,
)?,
};
Ok(SampleDocument::new(Identity::new(sample_id, subject_id.unwrap_or(sample_id))?, timeline))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn roundtrip_and_schema() {
let doc = new_document("case_1", Some("subj"), None).unwrap();
let text = doc.dumps();
assert_eq!(
text,
r#"{"identity": {"sample_id": "case_1", "subject_id": "subj"}, "timepoints": [{"id": "tp0", "index": 0}]}"#
);
let back = SampleDocument::loads(&text).unwrap();
assert_eq!(back, doc);
assert!(doc.check_schema().is_empty());
let bad: Value = serde_json::json!({"identity": {"sample_id": "x"}, "timepoints": []});
let errors = validate_against_schema(&bad);
assert!(!errors.is_empty());
}
}