use crate::core::{Document, ErrorKind, NodeId, NodeKind, XmlError, XmlResult};
use crate::query::{NamespaceContext, Query, QueryValue};
pub type CustomRule = Box<dyn Fn(&Document) -> XmlResult<Vec<ValidationIssue>>>;
pub struct XmlContract {
name: String,
namespaces: NamespaceContext,
rules: Vec<ContractRule>,
custom_rules: Vec<CustomRule>,
}
impl XmlContract {
pub fn new(name: impl Into<String>) -> Self {
Self {
name: name.into(),
namespaces: NamespaceContext::new(),
rules: Vec::new(),
custom_rules: Vec::new(),
}
}
pub fn name(&self) -> &str {
&self.name
}
pub fn with_namespace(
mut self,
alias: impl Into<String>,
uri: impl Into<String>,
) -> XmlResult<Self> {
self.namespaces = self.namespaces.with_alias(alias, uri)?;
Ok(self)
}
pub fn required(mut self, path: impl AsRef<str>) -> XmlResult<Self> {
self.rules
.push(ContractRule::Required(CompiledPath::new(path)?));
Ok(self)
}
pub fn cardinality(
mut self,
path: impl AsRef<str>,
min: usize,
max: Option<usize>,
) -> XmlResult<Self> {
if max.is_some_and(|max| min > max) {
return Err(schema_error(format!(
"invalid cardinality for `{}`: min cannot be greater than max",
path.as_ref()
)));
}
self.rules.push(ContractRule::Cardinality {
path: CompiledPath::new(path)?,
min,
max,
});
Ok(self)
}
pub fn text_type(mut self, path: impl AsRef<str>, value_type: ValueType) -> XmlResult<Self> {
self.rules.push(ContractRule::TextType {
path: CompiledPath::new(path)?,
value_type,
});
Ok(self)
}
pub fn enum_value(
mut self,
path: impl AsRef<str>,
values: impl IntoIterator<Item = impl Into<String>>,
) -> XmlResult<Self> {
let values = values.into_iter().map(Into::into).collect::<Vec<_>>();
if values.is_empty() {
return Err(schema_error(format!(
"enum rule for `{}` requires at least one value",
path.as_ref()
)));
}
self.rules.push(ContractRule::EnumValue {
path: CompiledPath::new(path)?,
values,
});
Ok(self)
}
pub fn rule(
mut self,
rule: impl Fn(&Document) -> XmlResult<Vec<ValidationIssue>> + 'static,
) -> Self {
self.custom_rules.push(Box::new(rule));
self
}
pub fn validate(&self, document: &Document) -> XmlResult<ValidationReport> {
let mut report = ValidationReport::new(self.name.clone());
for rule in &self.rules {
rule.validate(document, &self.namespaces, &mut report)?;
}
for rule in &self.custom_rules {
for issue in rule(document)? {
report.push(issue);
}
}
Ok(report)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ValueType {
String,
Integer,
Decimal,
Boolean,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ValidationReport {
contract_name: String,
issues: Vec<ValidationIssue>,
}
impl ValidationReport {
pub fn new(contract_name: impl Into<String>) -> Self {
Self {
contract_name: contract_name.into(),
issues: Vec::new(),
}
}
pub fn contract_name(&self) -> &str {
&self.contract_name
}
pub fn is_valid(&self) -> bool {
!self
.issues
.iter()
.any(|issue| issue.severity == ValidationSeverity::Error)
}
pub fn issues(&self) -> &[ValidationIssue] {
&self.issues
}
pub fn errors(&self) -> impl Iterator<Item = &ValidationIssue> {
self.issues
.iter()
.filter(|issue| issue.severity == ValidationSeverity::Error)
}
pub fn warnings(&self) -> impl Iterator<Item = &ValidationIssue> {
self.issues
.iter()
.filter(|issue| issue.severity == ValidationSeverity::Warning)
}
pub fn push(&mut self, issue: ValidationIssue) {
self.issues.push(issue);
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ValidationIssue {
severity: ValidationSeverity,
path: String,
message: String,
}
impl ValidationIssue {
pub fn error(path: impl Into<String>, message: impl Into<String>) -> Self {
Self {
severity: ValidationSeverity::Error,
path: path.into(),
message: message.into(),
}
}
pub fn warning(path: impl Into<String>, message: impl Into<String>) -> Self {
Self {
severity: ValidationSeverity::Warning,
path: path.into(),
message: message.into(),
}
}
pub fn severity(&self) -> &ValidationSeverity {
&self.severity
}
pub fn path(&self) -> &str {
&self.path
}
pub fn message(&self) -> &str {
&self.message
}
}
pub type ValidationError = ValidationIssue;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ValidationSeverity {
Error,
Warning,
}
pub trait XsdContractAdapter {
fn contract_name(&self) -> &str;
fn into_contract(self) -> XmlResult<XmlContract>;
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum ContractRule {
Required(CompiledPath),
Cardinality {
path: CompiledPath,
min: usize,
max: Option<usize>,
},
TextType {
path: CompiledPath,
value_type: ValueType,
},
EnumValue {
path: CompiledPath,
values: Vec<String>,
},
}
impl ContractRule {
fn validate(
&self,
document: &Document,
namespaces: &NamespaceContext,
report: &mut ValidationReport,
) -> XmlResult<()> {
match self {
Self::Required(path) => {
let result = path.query.evaluate_with_context(document, namespaces)?;
if result.is_empty() {
report.push(ValidationIssue::error(
path.source(),
format!("required path `{}` was not found", path.source()),
));
}
}
Self::Cardinality { path, min, max } => {
let count = path
.query
.evaluate_with_context(document, namespaces)?
.len();
if count < *min {
report.push(ValidationIssue::error(
path.source(),
format!(
"path `{}` expected at least {} match(es), found {}",
path.source(),
min,
count
),
));
}
if let Some(max) = max {
if count > *max {
report.push(ValidationIssue::error(
path.source(),
format!(
"path `{}` expected at most {} match(es), found {}",
path.source(),
max,
count
),
));
}
}
}
Self::TextType { path, value_type } => {
for value in text_values(document, path, namespaces)? {
if !value_type.matches(&value) {
report.push(ValidationIssue::error(
path.source(),
format!(
"value `{}` at `{}` is not a valid {:?}",
value,
path.source(),
value_type
),
));
}
}
}
Self::EnumValue { path, values } => {
for value in text_values(document, path, namespaces)? {
if !values.iter().any(|allowed| allowed == &value) {
report.push(ValidationIssue::error(
path.source(),
format!(
"value `{}` at `{}` is not one of [{}]",
value,
path.source(),
values.join(", ")
),
));
}
}
}
}
Ok(())
}
}
impl ValueType {
fn matches(&self, value: &str) -> bool {
let value = value.trim();
match self {
Self::String => true,
Self::Integer => value.parse::<i64>().is_ok(),
Self::Decimal => value.parse::<f64>().is_ok(),
Self::Boolean => matches!(value, "true" | "false" | "1" | "0"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct CompiledPath {
source: String,
query: Query,
}
impl CompiledPath {
fn new(path: impl AsRef<str>) -> XmlResult<Self> {
let source = path.as_ref().to_owned();
Ok(Self {
query: Query::parse(&source)?,
source,
})
}
fn source(&self) -> &str {
&self.source
}
}
fn text_values(
document: &Document,
path: &CompiledPath,
namespaces: &NamespaceContext,
) -> XmlResult<Vec<String>> {
let result = path.query.evaluate_with_context(document, namespaces)?;
let mut values = Vec::new();
for value in result.values() {
match value {
QueryValue::Text(value) | QueryValue::Attribute { value, .. } => {
values.push(value.clone());
}
QueryValue::Node(id) => values.push(direct_text(document, *id)?),
}
}
Ok(values)
}
fn direct_text(document: &Document, node_id: NodeId) -> XmlResult<String> {
let mut value = String::new();
let node = document.node(node_id)?;
match node.kind() {
NodeKind::Text(text) | NodeKind::CData(text) => value.push_str(text),
NodeKind::Element(element) => {
for child in element.children() {
match document.node(*child)?.kind() {
NodeKind::Text(text) | NodeKind::CData(text) => value.push_str(text),
_ => {}
}
}
}
NodeKind::Comment(_) | NodeKind::ProcessingInstruction { .. } => {}
}
Ok(value)
}
fn schema_error(message: impl Into<String>) -> XmlError {
XmlError::new(ErrorKind::Validation, message)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::parser;
use crate::query::DocumentQueryExt;
fn valid_document() -> XmlResult<Document> {
parser::parse_str(
r#"<Root>
<Header>
<ID>DOC-1</ID>
<Status>draft</Status>
</Header>
<Lines>
<Line code="A1"><Quantity>2</Quantity><Amount>10.50</Amount></Line>
<Line code="B2"><Quantity>4</Quantity><Amount>20.00</Amount></Line>
</Lines>
<Approved>true</Approved>
</Root>"#,
)
}
#[test]
fn schema_contract_validates_correct_document() -> XmlResult<()> {
let document = valid_document()?;
let contract = XmlContract::new("Example")
.required("/Root/Header/ID")?
.cardinality("/Root/Lines/Line", 1, Some(3))?
.text_type("/Root/Lines/Line/Quantity", ValueType::Integer)?
.text_type("/Root/Lines/Line/Amount", ValueType::Decimal)?
.text_type("/Root/Approved", ValueType::Boolean)?
.enum_value("/Root/Header/Status", ["draft", "final"])?;
let report = contract.validate(&document)?;
assert!(report.is_valid());
assert_eq!(report.contract_name(), "Example");
assert!(report.issues().is_empty());
Ok(())
}
#[test]
fn schema_required_reports_missing_path() -> XmlResult<()> {
let document = valid_document()?;
let contract = XmlContract::new("Example").required("/Root/Header/Missing")?;
let report = contract.validate(&document)?;
assert!(!report.is_valid());
let error = report.errors().next().expect("required error");
assert_eq!(error.path(), "/Root/Header/Missing");
assert!(error.message().contains("required path"));
Ok(())
}
#[test]
fn schema_cardinality_reports_minimum_and_maximum() -> XmlResult<()> {
let document = valid_document()?;
let contract = XmlContract::new("Example")
.cardinality("/Root/Lines/Line", 3, None)?
.cardinality("/Root/Lines/Line", 0, Some(1))?;
let report = contract.validate(&document)?;
let messages = report
.errors()
.map(ValidationIssue::message)
.collect::<Vec<_>>();
assert_eq!(messages.len(), 2);
assert!(messages
.iter()
.any(|message| message.contains("at least 3")));
assert!(messages.iter().any(|message| message.contains("at most 1")));
Ok(())
}
#[test]
fn schema_types_report_invalid_values() -> XmlResult<()> {
let document = parser::parse_str("<Root><Quantity>abc</Quantity></Root>")?;
let contract = XmlContract::new("Example")
.text_type("/Root/Quantity", ValueType::Integer)?
.text_type("/Root/Quantity", ValueType::String)?;
let report = contract.validate(&document)?;
assert!(!report.is_valid());
assert_eq!(report.errors().count(), 1);
assert!(report.issues()[0].message().contains("Integer"));
Ok(())
}
#[test]
fn schema_enum_reports_invalid_values() -> XmlResult<()> {
let document = parser::parse_str("<Root><Status>archived</Status></Root>")?;
let contract =
XmlContract::new("Example").enum_value("/Root/Status", ["draft", "final"])?;
let report = contract.validate(&document)?;
assert!(!report.is_valid());
assert!(report.issues()[0].message().contains("not one of"));
Ok(())
}
#[test]
fn schema_custom_rule_can_return_error_with_path() -> XmlResult<()> {
let document = valid_document()?;
let contract = XmlContract::new("Example").rule(|document| {
if document.query("/Root/Header/ID")?.is_empty() {
Ok(vec![ValidationIssue::error(
"/Root/Header/ID",
"ID must be present",
)])
} else {
Ok(vec![ValidationIssue::warning(
"/Root/Header/ID",
"custom rule was evaluated",
)])
}
});
let report = contract.validate(&document)?;
assert!(report.is_valid());
let warning = report.warnings().next().expect("custom warning");
assert_eq!(warning.path(), "/Root/Header/ID");
assert_eq!(warning.severity(), &ValidationSeverity::Warning);
Ok(())
}
#[test]
fn schema_namespaces_use_query_context() -> XmlResult<()> {
let document = parser::parse_str(
r#"<doc:Root xmlns:doc="urn:doc"><doc:ID>DOC-1</doc:ID></doc:Root>"#,
)?;
let contract = XmlContract::new("Namespaced")
.with_namespace("d", "urn:doc")?
.required("/d:Root/d:ID")?;
let report = contract.validate(&document)?;
assert!(report.is_valid());
Ok(())
}
#[test]
fn schema_invalid_cardinality_is_validation_error() {
let error = match XmlContract::new("Example").cardinality("/Root/Line", 2, Some(1)) {
Ok(_) => panic!("invalid cardinality must fail"),
Err(error) => error,
};
assert_eq!(error.kind(), &ErrorKind::Validation);
assert!(error.message().contains("min cannot be greater"));
}
}