use std::sync::LazyLock;
use foldhash::{HashMap, HashMapExt};
use jsonschema::Validator;
use serde_json::Value;
use super::types::AppDataDoc;
const SUPPORTED_VERSIONS: &[(&str, &str)] = &[
("1.0.0", include_str!("../../specs/app-data/v1.0.0.json")),
("1.5.0", include_str!("../../specs/app-data/v1.5.0.json")),
("1.6.0", include_str!("../../specs/app-data/v1.6.0.json")),
("1.10.0", include_str!("../../specs/app-data/v1.10.0.json")),
("1.13.0", include_str!("../../specs/app-data/v1.13.0.json")),
("1.14.0", include_str!("../../specs/app-data/v1.14.0.json")),
];
pub const LATEST_VERSION: &str = "1.14.0";
pub const APP_DATA_SCHEMA: &str = include_str!("../../specs/app-data/v1.14.0.json");
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SchemaViolation {
pub path: String,
pub message: String,
}
impl std::fmt::Display for SchemaViolation {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
if self.path.is_empty() {
f.write_str(&self.message)
} else {
write!(f, "{} at {}", self.message, self.path)
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SchemaError {
UnsupportedVersion {
requested: String,
supported: Vec<String>,
},
Violations(Vec<SchemaViolation>),
}
impl std::fmt::Display for SchemaError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::UnsupportedVersion { requested, supported } => {
write!(
f,
"AppData version `{requested}` is not supported by this build \
(known versions: {})",
supported.join(", ")
)
}
Self::Violations(errs) => {
write!(f, "AppData schema validation failed with {} error(s)", errs.len())?;
for e in errs {
write!(f, "\n - {e}")?;
}
Ok(())
}
}
}
}
impl std::error::Error for SchemaError {}
#[allow(
clippy::expect_used,
clippy::panic,
reason = "bundled compile-time schemas; a parse/compile failure indicates \
a broken build artifact and must panic loudly at startup"
)]
static VALIDATORS: LazyLock<HashMap<&'static str, Validator>> = LazyLock::new(|| {
let mut map = HashMap::with_capacity(SUPPORTED_VERSIONS.len());
for (version, source) in SUPPORTED_VERSIONS {
let schema: Value = serde_json::from_str(source)
.unwrap_or_else(|e| panic!("bundled AppData schema v{version} is not valid JSON: {e}"));
let validator = Validator::new(&schema)
.unwrap_or_else(|e| panic!("bundled AppData schema v{version} does not compile: {e}"));
map.insert(*version, validator);
}
map
});
#[must_use]
pub fn supported_versions() -> Vec<&'static str> {
SUPPORTED_VERSIONS.iter().map(|(v, _)| *v).collect()
}
fn validator_for(version: &str) -> Option<&'static Validator> {
VALIDATORS.get(version)
}
pub fn validate_json_with(value: &Value, version: &str) -> Result<(), SchemaError> {
let Some(validator) = validator_for(version) else {
return Err(SchemaError::UnsupportedVersion {
requested: version.to_owned(),
supported: supported_versions().into_iter().map(str::to_owned).collect(),
});
};
let errors: Vec<SchemaViolation> = validator
.iter_errors(value)
.map(|e| SchemaViolation { path: e.instance_path.to_string(), message: e.to_string() })
.collect();
if errors.is_empty() { Ok(()) } else { Err(SchemaError::Violations(errors)) }
}
pub fn validate_json(value: &Value) -> Result<(), SchemaError> {
let version = value
.get("version")
.and_then(Value::as_str)
.map_or_else(|| LATEST_VERSION.to_owned(), str::to_owned);
validate_json_with(value, &version)
}
#[allow(
clippy::expect_used,
reason = "AppDataDoc serialisation is total; failure would be a bug in serde"
)]
pub fn validate_with(doc: &AppDataDoc, version: &str) -> Result<(), SchemaError> {
let value = serde_json::to_value(doc).expect("AppDataDoc serialises without failure");
validate_json_with(&value, version)
}
#[allow(
clippy::expect_used,
reason = "AppDataDoc serialisation is total; failure would be a bug in serde"
)]
pub fn validate(doc: &AppDataDoc) -> Result<(), SchemaError> {
let value = serde_json::to_value(doc).expect("AppDataDoc serialises without failure");
validate_json(&value)
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::*;
use crate::app_data::{
CowHook, Metadata, OrderClassKind, OrderInteractionHooks, PartnerFee, PartnerFeeEntry,
Quote, Referrer, Utm, Widget,
};
fn must_validate(doc: &AppDataDoc) {
if let Err(e) = validate(doc) {
panic!("doc should validate but failed: {e}");
}
}
#[test]
fn every_registered_version_compiles() {
for version in supported_versions() {
assert!(
validator_for(version).is_some(),
"registered version {version} failed to compile into a validator"
);
}
}
#[test]
fn latest_version_is_registered() {
assert!(
supported_versions().contains(&LATEST_VERSION),
"LATEST_VERSION ({LATEST_VERSION}) must appear in SUPPORTED_VERSIONS"
);
}
#[test]
fn default_appdatadoc_uses_a_registered_version() {
let doc = AppDataDoc::new("test");
assert!(
supported_versions().contains(&doc.version.as_str()),
"AppDataDoc::new sets version `{}` but no bundled schema covers it; \
update `LATEST_APP_DATA_VERSION` or register a new schema",
doc.version
);
}
#[test]
fn minimal_doc_validates() {
must_validate(&AppDataDoc::new("TestApp"));
}
#[test]
fn doc_with_environment_validates() {
must_validate(&AppDataDoc::new("TestApp").with_environment("production"));
}
#[test]
fn doc_with_code_referrer_validates_under_latest() {
must_validate(&AppDataDoc::new("TestApp").with_referrer(Referrer::code("COWRS-PARTNER")));
}
#[test]
fn doc_with_address_referrer_validates_under_v1_13_0() {
let mut doc = AppDataDoc::new("TestApp")
.with_referrer(Referrer::address("0xb6BAd41ae76A11D10f7b0E664C5007b908bC77C9"));
doc.version = "1.13.0".to_owned();
validate(&doc).expect("address-flavoured referrer validates under v1.13.0");
}
#[test]
fn address_referrer_fails_under_latest() {
let doc = AppDataDoc::new("TestApp")
.with_referrer(Referrer::address("0xb6BAd41ae76A11D10f7b0E664C5007b908bC77C9"));
let err = validate(&doc).expect_err("address referrer under v1.14.0 must fail");
assert!(matches!(err, SchemaError::Violations(_)));
}
#[test]
fn doc_with_utm_validates() {
must_validate(&AppDataDoc::new("TestApp").with_utm(Utm {
utm_source: Some("twitter".into()),
utm_medium: Some("social".into()),
utm_campaign: Some("launch".into()),
utm_content: Some("banner".into()),
utm_term: Some("cow".into()),
}));
}
#[test]
fn doc_with_quote_validates() {
let mut doc = AppDataDoc::new("TestApp");
doc.metadata = doc.metadata.with_quote(Quote::new(50));
must_validate(&doc);
}
#[test]
fn doc_with_each_order_class_validates() {
for kind in [
OrderClassKind::Market,
OrderClassKind::Limit,
OrderClassKind::Liquidity,
OrderClassKind::Twap,
] {
must_validate(&AppDataDoc::new("TestApp").with_order_class(kind));
}
}
#[test]
fn doc_with_hooks_validates() {
let hook =
CowHook::new("0x0000000000000000000000000000000000000001", "0xdeadbeef", "100000");
let hooks = OrderInteractionHooks::new(vec![hook.clone()], vec![hook]);
must_validate(&AppDataDoc::new("TestApp").with_hooks(hooks));
}
#[test]
fn doc_with_widget_validates() {
let mut doc = AppDataDoc::new("TestApp");
doc.metadata = doc.metadata.with_widget(Widget::new("WidgetApp"));
must_validate(&doc);
}
#[test]
fn doc_with_partner_fee_validates() {
let fee = PartnerFee::Single(PartnerFeeEntry::volume(
50,
"0x0000000000000000000000000000000000000001",
));
must_validate(&AppDataDoc::new("TestApp").with_partner_fee(fee));
}
#[test]
fn doc_with_replaced_order_validates() {
let uid = format!("0x{}", "ab".repeat(56));
must_validate(&AppDataDoc::new("TestApp").with_replaced_order(uid));
}
#[test]
fn doc_with_signer_validates() {
must_validate(
&AppDataDoc::new("TestApp").with_signer("0x0000000000000000000000000000000000000001"),
);
}
#[test]
fn fully_populated_doc_validates() {
let hook =
CowHook::new("0x0000000000000000000000000000000000000001", "0xdeadbeef", "100000");
let metadata = Metadata::default()
.with_referrer(Referrer::code("COWRS-PARTNER"))
.with_utm(Utm {
utm_source: Some("src".into()),
utm_medium: Some("med".into()),
utm_campaign: Some("camp".into()),
utm_content: None,
utm_term: None,
})
.with_quote(Quote::new(100))
.with_hooks(OrderInteractionHooks::new(vec![hook.clone()], vec![hook]))
.with_widget(Widget::new("MyWidget"))
.with_partner_fee(PartnerFee::Single(PartnerFeeEntry::volume(
25,
"0x0000000000000000000000000000000000000002",
)));
let mut doc = AppDataDoc::new("FullApp")
.with_environment("production")
.with_order_class(OrderClassKind::Limit)
.with_signer("0x0000000000000000000000000000000000000003");
doc.metadata = metadata;
must_validate(&doc);
}
#[test]
fn doc_with_malformed_code_referrer_is_rejected() {
let doc = AppDataDoc::new("TestApp").with_referrer(Referrer::code("abc"));
let err = validate(&doc).expect_err("malformed code must fail");
let SchemaError::Violations(errs) = err else {
panic!("expected Violations, got {err:?}");
};
assert!(
errs.iter().any(|e| e.path.contains("referrer")),
"expected a violation on /metadata/referrer, got: {errs:?}"
);
}
#[test]
fn schema_rejects_unknown_top_level_fields() {
let bad = json!({
"version": LATEST_VERSION,
"metadata": {},
"unknownField": "should fail",
});
assert!(validate_json(&bad).is_err());
}
#[test]
fn schema_rejects_unknown_metadata_fields() {
let bad = json!({
"version": LATEST_VERSION,
"metadata": { "unknownMetadata": {} },
});
assert!(validate_json(&bad).is_err());
}
#[test]
fn schema_requires_version_and_metadata() {
let no_version = json!({ "metadata": {} });
assert!(validate_json(&no_version).is_err());
let no_metadata = json!({ "version": LATEST_VERSION });
assert!(validate_json(&no_metadata).is_err());
}
#[test]
fn unknown_version_is_reported_as_unsupported() {
let bad = json!({ "version": "99.0.0", "metadata": {} });
let err = validate_json(&bad).expect_err("unknown version must fail");
let SchemaError::UnsupportedVersion { requested, supported } = err else {
panic!("expected UnsupportedVersion, got {err:?}");
};
assert_eq!(requested, "99.0.0");
assert!(!supported.is_empty(), "supported list should not be empty");
assert!(
supported.iter().any(|s| s == LATEST_VERSION),
"LATEST_VERSION must appear in the supported list"
);
}
#[test]
fn validate_with_ignores_doc_version() {
let doc = AppDataDoc::new("CrossVersion");
validate_with(&doc, "1.0.0").expect("minimal doc validates under v1.0.0");
validate_with(&doc, LATEST_VERSION).expect("minimal doc validates under latest");
}
#[test]
fn validate_with_errors_on_unknown_version() {
let doc = AppDataDoc::new("TestApp");
let err = validate_with(&doc, "42.42.42").expect_err("unknown version must fail");
assert!(matches!(err, SchemaError::UnsupportedVersion { .. }));
}
#[test]
fn validator_cache_is_shared_across_calls() {
let doc = AppDataDoc::new("cache-test");
let a = validate(&doc).is_ok();
let b = validate(&doc).is_ok();
assert!(a && b, "cached validator must yield stable results");
}
#[test]
fn schema_violation_display_format() {
let v = SchemaViolation {
path: "/metadata/referrer".to_owned(),
message: "missing required field".to_owned(),
};
assert_eq!(v.to_string(), "missing required field at /metadata/referrer");
let v = SchemaViolation { path: String::new(), message: "root-level error".to_owned() };
assert_eq!(v.to_string(), "root-level error");
}
#[test]
fn supported_versions_contains_every_registered_entry() {
let versions = supported_versions();
assert_eq!(versions.len(), SUPPORTED_VERSIONS.len());
for (reg, _) in SUPPORTED_VERSIONS {
assert!(versions.contains(reg), "missing {reg}");
}
}
#[test]
fn schema_error_display_unsupported_version() {
let err = SchemaError::UnsupportedVersion {
requested: "99.0.0".to_owned(),
supported: vec!["1.0.0".to_owned(), "1.6.0".to_owned()],
};
let s = format!("{err}");
assert!(s.contains("99.0.0"));
assert!(s.contains("1.0.0, 1.6.0"));
}
#[test]
fn schema_error_display_violations() {
let err = SchemaError::Violations(vec![
SchemaViolation { path: "/metadata".to_owned(), message: "missing field".to_owned() },
SchemaViolation { path: String::new(), message: "top-level error".to_owned() },
]);
let s = format!("{err}");
assert!(s.contains("2 error(s)"));
assert!(s.contains("missing field at /metadata"));
assert!(s.contains("top-level error"));
}
#[test]
fn schema_error_is_error_trait() {
let err =
SchemaError::UnsupportedVersion { requested: "0.0.0".to_owned(), supported: vec![] };
let _: &dyn std::error::Error = &err;
}
#[test]
fn validate_json_with_unsupported_version() {
let val = json!({ "version": "1.13.0", "metadata": {} });
let err = validate_json_with(&val, "42.0.0").expect_err("unsupported version must fail");
assert!(matches!(err, SchemaError::UnsupportedVersion { .. }));
}
#[test]
fn validate_json_missing_version_uses_latest() {
let val = json!({ "metadata": {} });
let err = validate_json(&val).expect_err("missing version in document should fail");
assert!(matches!(err, SchemaError::Violations(_)));
}
#[test]
fn validate_with_valid_doc_and_each_version() {
for version in supported_versions() {
let doc = AppDataDoc::new("TestApp");
let _result = validate_with(&doc, version);
}
}
}