use anyhow::{Context, Result, bail};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::path::Path;
use crate::config::StandardConfig;
use crate::graph::Product;
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct IyamlschemaConfig {
#[serde(default = "crate::config::default_true")]
pub check_ordering: bool,
#[serde(flatten)]
pub standard: StandardConfig,
}
impl Default for IyamlschemaConfig {
fn default() -> Self {
Self {
check_ordering: true,
standard: StandardConfig::default(),
}
}
}
struct WebCacheRetriever {
ttl_secs: u64,
}
impl jsonschema::Retrieve for WebCacheRetriever {
fn retrieve(
&self,
uri: &jsonschema::Uri<String>,
) -> std::result::Result<Value, Box<dyn std::error::Error + Send + Sync>> {
let url = uri.as_str();
let body = crate::webcache::fetch(url, self.ttl_secs)?;
let value: Value = serde_json::from_str(&body)?;
Ok(value)
}
}
pub struct IyamlschemaProcessor {
config: IyamlschemaConfig,
}
impl IyamlschemaProcessor {
pub const fn new(config: IyamlschemaConfig) -> Self {
Self { config }
}
fn execute_product(
&self,
ctx: &crate::build_context::BuildContext,
product: &Product,
) -> Result<()> {
self.check_files(ctx, &[product.primary_input()])
}
fn check_files(&self, ctx: &crate::build_context::BuildContext, files: &[&Path]) -> Result<()> {
let mut errors = Vec::new();
for file in files {
if let Err(e) = self.validate_file(ctx, file) {
errors.push(format!("{}: {}", file.display(), e));
}
}
if errors.is_empty() {
Ok(())
} else {
bail!("YAML schema validation failed:\n{}", errors.join("\n"))
}
}
fn validate_file(&self, ctx: &crate::build_context::BuildContext, path: &Path) -> Result<()> {
let ttl_secs = ctx.webcache_ttl_secs();
let contents = std::fs::read_to_string(path)
.with_context(|| format!("Failed to read {}", path.display()))?;
let data: Value = serde_yaml_ng::from_str(&contents)
.with_context(|| format!("Failed to parse YAML in {}", path.display()))?;
let schema_url = data
.get("$schema")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("no $schema field found"))?;
let schema_str = crate::webcache::fetch(schema_url, ttl_secs)
.with_context(|| format!("Failed to fetch schema {schema_url}"))?;
let schema: Value = serde_json::from_str(&schema_str)
.with_context(|| format!("Failed to parse schema from {schema_url}"))?;
let validator = jsonschema::options()
.with_retriever(WebCacheRetriever { ttl_secs })
.build(&schema)
.with_context(|| format!("Failed to compile schema from {schema_url}"))?;
let validation_errors: Vec<String> = validator
.iter_errors(&data)
.map(|e| format!(" {}: {}", e.instance_path(), e))
.collect();
if !validation_errors.is_empty() {
bail!(
"schema validation errors:\n{}",
validation_errors.join("\n")
);
}
if self.config.check_ordering {
let mut ordering_errors = Vec::new();
check_property_ordering(&data, &schema, "", &mut ordering_errors);
if !ordering_errors.is_empty() {
bail!("property ordering errors:\n{}", ordering_errors.join("\n"));
}
}
Ok(())
}
}
fn check_property_ordering(data: &Value, schema: &Value, path: &str, errors: &mut Vec<String>) {
match (data, schema) {
(Value::Object(data_map), Value::Object(schema_map)) => {
if let Some(Value::Array(expected_order)) = schema_map.get("propertyOrdering") {
let expected: Vec<&str> =
expected_order.iter().filter_map(|v| v.as_str()).collect();
let actual_keys: Vec<&str> =
data_map.keys().map(std::string::String::as_str).collect();
let actual_ordered: Vec<&str> = actual_keys
.iter()
.copied()
.filter(|k| expected.contains(k))
.collect();
let expected_ordered: Vec<&str> = expected
.iter()
.copied()
.filter(|k| actual_keys.contains(k))
.collect();
if actual_ordered != expected_ordered {
let display_path = if path.is_empty() { "root" } else { path };
errors.push(format!(
" {display_path}: expected key order {expected_ordered:?}, got {actual_ordered:?}",
));
}
}
if let Some(Value::Object(props)) = schema_map.get("properties") {
for (key, prop_schema) in props {
if let Some(value) = data_map.get(key) {
let child_path = if path.is_empty() {
key.clone()
} else {
format!("{path}.{key}")
};
check_property_ordering(value, prop_schema, &child_path, errors);
}
}
}
if let Some(items_schema) = schema_map.get("items")
&& let Value::Array(arr) = data
{
for (i, item) in arr.iter().enumerate() {
let child_path = format!("{path}[{i}]");
check_property_ordering(item, items_schema, &child_path, errors);
}
}
}
(Value::Array(arr), schema_val) => {
if let Some(items_schema) = schema_val.get("items") {
for (i, item) in arr.iter().enumerate() {
let child_path = if path.is_empty() {
format!("[{i}]")
} else {
format!("{path}[{i}]")
};
check_property_ordering(item, items_schema, &child_path, errors);
}
}
}
_ => {}
}
}
impl crate::processors::Processor for IyamlschemaProcessor {
fn scan_config(&self) -> &crate::config::StandardConfig {
&self.config.standard
}
fn config_json(&self) -> Option<String> {
crate::processors::ProcessorBase::config_json(&self.config)
}
fn auto_detect(&self, file_index: &crate::file_index::FileIndex) -> bool {
crate::processors::checker_auto_detect(&self.config.standard, file_index)
}
fn required_tools(&self) -> Vec<String> {
Vec::new()
}
fn discover(
&self,
graph: &mut crate::graph::BuildGraph,
file_index: &crate::file_index::FileIndex,
instance_name: &str,
) -> anyhow::Result<()> {
crate::processors::discover_checker_products(
graph,
&self.config.standard,
file_index,
&self.config.standard.dep_inputs,
&self.config.standard.dep_auto,
&self.config,
&crate::config::checksum_fields_of(instance_name),
instance_name,
)
}
fn execute(&self, ctx: &crate::build_context::BuildContext, product: &Product) -> Result<()> {
self.execute_product(ctx, product)
}
fn execute_batch(
&self,
ctx: &crate::build_context::BuildContext,
products: &[&Product],
) -> Vec<Result<()>> {
crate::processors::execute_checker_batch_per_file(products, |file| {
self.check_files(ctx, &[file])
})
}
}
fn plugin_create(toml: &toml::Value) -> anyhow::Result<Box<dyn crate::processors::Processor>> {
crate::registries::deserialize_and_create(toml, |cfg| Box::new(IyamlschemaProcessor::new(cfg)))
}
inventory::submit! {
crate::registries::ProcessorPlugin {
version: 1,
name: "iyamlschema",
processor_type: crate::processors::ProcessorType::Checker,
create: plugin_create,
fields: &[
crate::config::FieldSpec { name: "check_ordering", ty: crate::config::FieldType::Bool,
affects_output: true, required: false,
doc: "Require YAML keys to appear in the order defined by the schema" },
],
omit_standard_fields: &["command", "formats", "args", "output_dir"],
scan_defaults: Some(crate::config::ScanDefaultsData { src_dirs: &[], src_extensions: &[".yml", ".yaml"], src_exclude_dirs: &[] }),
defaults: None,
defconfig_json: crate::registries::default_config_json::<IyamlschemaConfig>,
keywords: &["yaml", "yml", "schema", "validator"],
description: "Validate YAML files against JSON schemas (in-process)",
is_native: true,
can_fix: false,
supports_batch: true,
max_jobs_cap: None,
}
}