aep-core 0.2.0

Transport-independent protocol primitives for the Agent Enrollment Protocol.
Documentation
use std::{collections::HashSet, hash::Hash};

use serde::de::DeserializeOwned;

use crate::{ParseError, ValidationError, ValidationIssue};

pub(crate) fn parse_and_validate<T>(
    data: &[u8],
    document_type: &str,
    validate: impl FnOnce(&T) -> Result<(), ValidationError>,
) -> Result<T, ParseError>
where
    T: DeserializeOwned,
{
    let mut deserializer = serde_json::Deserializer::from_slice(data);
    let value = match serde_path_to_error::deserialize::<_, T>(&mut deserializer) {
        Ok(value) => value,
        Err(error) if error.inner().is_syntax() || error.inner().is_eof() => {
            return Err(ParseError::Json {
                document_type: document_type.to_owned(),
                source: error.into_inner(),
            });
        }
        Err(error) => {
            return Err(ValidationError {
                document_type: document_type.to_owned(),
                issues: vec![ValidationIssue {
                    path: json_path(error.path()),
                    message: error.inner().to_string(),
                }],
            }
            .into());
        }
    };
    deserializer.end().map_err(|source| ParseError::Json {
        document_type: document_type.to_owned(),
        source,
    })?;
    validate(&value)?;
    Ok(value)
}

pub(crate) fn deserialize_optional_non_null<'de, D, T>(
    deserializer: D,
) -> Result<Option<T>, D::Error>
where
    D: serde::Deserializer<'de>,
    T: serde::Deserialize<'de>,
{
    T::deserialize(deserializer).map(Some)
}

pub(crate) fn result(
    document_type: &str,
    issues: Vec<ValidationIssue>,
) -> Result<(), ValidationError> {
    if issues.is_empty() {
        Ok(())
    } else {
        Err(ValidationError {
            document_type: document_type.to_owned(),
            issues,
        })
    }
}

pub(crate) fn issue(path: impl Into<String>, message: impl Into<String>) -> ValidationIssue {
    ValidationIssue {
        path: path.into(),
        message: message.into(),
    }
}

pub(crate) fn require_non_empty(value: &str, path: &str, issues: &mut Vec<ValidationIssue>) {
    if value.is_empty() {
        issues.push(issue(path, "Expected a non-empty string."));
    }
}

pub(crate) fn validate_optional_non_empty(
    value: Option<&str>,
    path: &str,
    issues: &mut Vec<ValidationIssue>,
) {
    if value.is_some_and(str::is_empty) {
        issues.push(issue(path, "Expected a non-empty string."));
    }
}

pub(crate) fn require_unique<T>(values: &[T], path: &str, issues: &mut Vec<ValidationIssue>)
where
    T: Eq + Hash,
{
    let mut seen = HashSet::with_capacity(values.len());
    if values.iter().any(|value| !seen.insert(value)) {
        issues.push(issue(path, "Expected unique items."));
    }
}

pub(crate) fn validate_non_empty_strings(
    values: &[String],
    path: &str,
    require_items: bool,
    issues: &mut Vec<ValidationIssue>,
) {
    if require_items && values.is_empty() {
        issues.push(issue(path, "Expected at least one item."));
    }
    for (index, value) in values.iter().enumerate() {
        require_non_empty(value, &format!("{path}[{index}]"), issues);
    }
    require_unique(values, path, issues);
}

/// Renders a deserializer path in the JSONPath spelling every other issue in this crate uses, so
/// that a member reported by the parser and the same member reported by a validator name it the
/// same way.
fn json_path(path: &serde_path_to_error::Path) -> String {
    let mut rendered = String::from("$");
    for segment in path.iter() {
        match segment {
            serde_path_to_error::Segment::Seq { index } => {
                rendered.push_str(&format!("[{index}]"));
            }
            serde_path_to_error::Segment::Map { key } => {
                rendered.push('.');
                rendered.push_str(key);
            }
            // An enum variant and a segment the deserializer could not name are both rendered as
            // the member they stand in for.
            other => {
                rendered.push('.');
                rendered.push_str(&other.to_string());
            }
        }
    }
    rendered
}