use regress::Regex;
use serde_json::Value;
use crate::protocol::misuse::MisuseCode;
use super::json::JsonSchemaAccessorError;
use super::version::compare;
pub struct Validator {
remote_lookup: bool,
}
const SCHEMA_BASE_URL: &'static str = "https://raw.githubusercontent.com/versa-protocol/schema";
impl Validator {
async fn access_json_schema(
&self,
data_event_type: &str,
schema_version: &str,
) -> Result<serde_json::Value, crate::schema::json::JsonSchemaAccessorError> {
if let Ok(schema) =
crate::schema::json::access_json_schema_file(data_event_type, schema_version)
{
return Ok(schema);
}
if !self.remote_lookup {
return Err(crate::schema::json::JsonSchemaAccessorError::FileError);
}
let schema_url = if compare(schema_version, "2.2.9").unwrap().is_le() {
format!(
"{}/{}/data/{}.schema.json",
SCHEMA_BASE_URL, schema_version, data_event_type
)
} else {
format!(
"{}/{}/events/{}.schema.json",
SCHEMA_BASE_URL, schema_version, data_event_type
)
};
let schema: Value = match match reqwest::get(&schema_url).await {
Ok(res) => res,
Err(_) => return Err(JsonSchemaAccessorError::NetworkError),
}
.json()
.await
{
Ok(val) => val,
Err(_) => return Err(JsonSchemaAccessorError::JsonError),
};
Ok(schema)
}
pub fn new() -> Self {
Self {
remote_lookup: false,
}
}
pub fn allow_remote_lookup(mut self, allow: bool) -> Self {
self.remote_lookup = allow;
self
}
pub async fn validate(
&self,
event: &crate::protocol::webhook::TransactionEvent,
data: &Value,
) -> Result<(), (MisuseCode, String)> {
let schema_name = event.to_string();
let schema_version = match data.get("schema_version") {
Some(val) => match val.as_str() {
Some(version) => version,
None => {
return Err((
MisuseCode::SchemaVersionInvalid,
format!("Invalid schema_version: {}", val),
));
}
},
None => {
return Err((
MisuseCode::SchemaValidationFailed,
"Missing schema_version".to_string(),
));
}
};
let re = Regex::new(r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z\-\.]+))?(?:\+([0-9A-Za-z\-\.]+))?$").unwrap();
if re.find(schema_version).is_none() {
return Err((
MisuseCode::SchemaVersionInvalid,
format!("Invalid schema_version: {}", schema_version),
));
}
let schema = self
.access_json_schema(&schema_name, schema_version)
.await
.map_err(|_| {
(
MisuseCode::SchemaVersionUnknown,
"Schema not found".to_string(),
)
})?;
match jsonschema::validate(&schema, data) {
Ok(_) => Ok(()),
Err(e) => Err((MisuseCode::SchemaValidationFailed, e.to_string())),
}
}
}
#[cfg(test)]
mod tests {
use crate::protocol::webhook::TransactionEvent;
use super::*;
#[tokio::test]
async fn test_validation_should_succeed() {
let data = serde_json::json!({
"schema_version": "1.4.0",
"header": {
"invoice_number": "auth_1MzFN1K8F4fqH0lBmFq8CjbU",
"currency": "usd",
"total": 2212,
"subtotal": 1780,
"paid": 2212,
"invoiced_at": 1713295619,
"mcc": null,
"third_party": null,
"customer": null,
"location": null,
"invoice_asset_id": null,
"receipt_asset_id": null
},
"itemization": {
"general": {
"line_items": [
{
"description": "Widget",
"subtotal": 1780,
"quantity": 10,
"unit_cost": 178,
"unit": null,
"taxes": [
{
"amount": 432,
"rate": 0.0875,
"name": "GST"
}
],
"metadata": [],
"product_image": null,
"date": null,
"url": null,
"adjustments": []
}
],
"invoice_level_adjustments": []
},
"lodging": null,
"ecommerce": null,
"car_rental": null,
"transit_route": null,
"subscription": null,
"flight": null
},
"actions": [],
"payments": []
});
let validator = Validator::new().allow_remote_lookup(true);
assert!(
validator
.validate(&TransactionEvent::Receipt, &data)
.await
.is_ok()
);
}
#[tokio::test]
async fn test_validation_for_non_lts_version_should_fail_without_network() {
let data = serde_json::json!({
"schema_version": "1.4.0",
"header": {
"invoice_number": "auth_1MzFN1K8F4fqH0lBmFq8CjbU",
"currency": "usd",
"total": 2212,
"subtotal": 1780,
"paid": 2212,
"invoiced_at": 1713295619,
"mcc": null,
"third_party": null,
"customer": null,
"location": null,
"invoice_asset_id": null,
"receipt_asset_id": null
},
"itemization": {
"general": {
"line_items": [
{
"description": "Widget",
"subtotal": 1780,
"quantity": 10,
"unit_cost": 178,
"unit": null,
"taxes": [
{
"amount": 432,
"rate": 0.0875,
"name": "GST"
}
],
"metadata": [],
"product_image": null,
"date": null,
"url": null,
"adjustments": []
}
],
"invoice_level_adjustments": []
},
"lodging": null,
"ecommerce": null,
"car_rental": null,
"transit_route": null,
"subscription": null,
"flight": null
},
"actions": [],
"payments": []
});
let local_validator = Validator::new().allow_remote_lookup(false);
let Err((code, msg)) = local_validator
.validate(&TransactionEvent::Receipt, &data)
.await
else {
panic!("This test validation case should fail");
};
assert_eq!(code, MisuseCode::SchemaVersionUnknown);
assert_eq!(msg, "Schema not found");
}
#[tokio::test]
async fn test_validation_of_incomplete_receipt_should_fail() {
let data = serde_json::json!({
"schema_version": "1.11.0",
"header": {
"invoice_number": "auth_1MzFN1K8F4fqH0lBmFq8CjbU",
"currency": "usd",
"subtotal": 1780,
"paid": 2212,
"invoiced_at": 1713295619
},
"itemization": {
"general": {
"line_items": [
{
"description": "Widget",
"quantity": 10,
"adjustments": []
}
],
"invoice_level_adjustments": []
}
},
});
let validator = Validator::new().allow_remote_lookup(true);
let Err((code, msg)) = validator.validate(&TransactionEvent::Receipt, &data).await else {
panic!("This test validation case should fail");
};
assert_eq!(code, MisuseCode::SchemaValidationFailed);
assert_eq!(msg, "\"total\" is a required property");
}
#[tokio::test]
async fn test_validation_of_outdated_schema_version_should_fail() {
let data = serde_json::json!({
"schema_version": "1.0",
"header": {
"invoice_number": "auth_1MzFN1K8F4fqH0lBmFq8CjbU",
"currency": "usd",
"subtotal": 1780,
"paid": 2212,
"invoiced_at": 1713295619
},
"itemization": {
"general": {
"line_items": [
{
"description": "Widget",
"quantity": 10,
"adjustments": []
}
],
"invoice_level_adjustments": []
}
},
});
let validator = Validator::new().allow_remote_lookup(true);
let Err((code, msg)) = validator.validate(&TransactionEvent::Receipt, &data).await else {
panic!("This test validation case should fail");
};
assert_eq!(code, MisuseCode::SchemaVersionInvalid);
assert_eq!(msg, "Invalid schema_version: 1.0");
}
#[tokio::test]
async fn test_validation_of_itinerary_with_payment_should_fail() {
let data = serde_json::json!({
"schema_version": "1.11.0",
"header": {
"subtotal": 1780,
},
"itemization": {
"general": {
"line_items": [
{
"description": "Widget",
"quantity": 10,
"adjustments": []
}
],
"invoice_level_adjustments": []
}
},
});
let validator = Validator::new().allow_remote_lookup(true);
let Err((code, msg)) = validator
.validate(&TransactionEvent::Itinerary, &data)
.await
else {
panic!("This test validation case should fail");
};
assert_eq!(code, MisuseCode::SchemaValidationFailed);
assert_eq!(
msg,
"Additional properties are not allowed ('subtotal' was unexpected)"
);
}
}