versa 1.9.0-rc3

Versa types and utilities for developing Versa client applications in Rust
Documentation
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
      )
    };

    // get schema from URL using reqwest and turn into serde Value
    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),
      ));
    }

    // get schema from URL using reqwest and turn into serde Value
    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": []
    });

    // Should fail work without network lookup as well
    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)"
    );
  }
}