kynos-openapi 0.3.0

OpenAPI 3.1 and 3.2 document model, serialization and validation.
Documentation
//! Properties over a whole generated document.
//!
//! Round-tripping, determinism, the totality of validation, and the two
//! emission rules. The path-template properties are in `templates.rs`; the
//! generators both files draw on are in `support/`.

use kynos_openapi::{
    Document, Severity, SpecError, SpecVersion, Violation,
    emit::downgrade::three_two_only_constructs, validate::Validator,
};
use proptest::prelude::*;

#[path = "support/mod.rs"]
mod support;
use support::*;

// --- Helpers used by the properties -------------------------------------

fn to_json(document: &Document) -> String {
    document
        .to_json()
        .expect("every generated value is representable in JSON")
}

fn parse(json: &str) -> Document {
    serde_json::from_str(json).expect("what the model emits, the model reads")
}

/// Violations as a sorted multiset of their rendered form.
///
/// Two rules collect their inputs through a `HashSet`, so the order of the
/// violations they emit is not fixed; the set of them is what the caller acts
/// on.
fn rendered(violations: &[Violation]) -> Vec<String> {
    let mut rendered: Vec<String> = violations.iter().map(ToString::to_string).collect();
    rendered.sort();
    rendered
}

/// The rendered form of the error-severity violations only.
fn rendered_errors(violations: &[Violation]) -> Vec<String> {
    let mut rendered: Vec<String> = violations
        .iter()
        .filter(|violation| violation.severity == Severity::Error)
        .map(ToString::to_string)
        .collect();
    rendered.sort();
    rendered
}

/// Every place the emitted JSON holds an unchecked schema, as sorted pointers.
///
/// Read off the wire rather than the model, so the oracle shares no walk with
/// the validator: an object carrying `x-kynos-unchecked` anywhere, and `true`
/// wherever it is a Media Type Object's own `schema` or `itemSchema` — under a
/// `content` map, or as a 3.2 `components.mediaTypes` entry.
fn unchecked_pointers(document: &Document) -> Vec<String> {
    fn is_media_type_schema(tokens: &[String]) -> bool {
        match tokens {
            [.., parent, _, last] if parent == "content" => {
                last == "schema" || last == "itemSchema"
            }
            [components, media_types, _, last]
                if components == "components" && media_types == "mediaTypes" =>
            {
                last == "schema" || last == "itemSchema"
            }
            _ => false,
        }
    }

    fn walk(value: &serde_json::Value, tokens: &mut Vec<String>, found: &mut Vec<String>) {
        let pointer = |tokens: &[String]| {
            tokens.iter().fold("#".to_owned(), |pointer, token| {
                format!("{pointer}/{token}")
            })
        };
        match value {
            serde_json::Value::Object(map) => {
                if map.contains_key("x-kynos-unchecked") {
                    found.push(pointer(tokens));
                }
                for (key, child) in map {
                    tokens.push(key.replace('~', "~0").replace('/', "~1"));
                    walk(child, tokens, found);
                    tokens.pop();
                }
            }
            serde_json::Value::Array(items) => {
                for (index, child) in items.iter().enumerate() {
                    tokens.push(index.to_string());
                    walk(child, tokens, found);
                    tokens.pop();
                }
            }
            serde_json::Value::Bool(true) if is_media_type_schema(tokens) => {
                found.push(pointer(tokens));
            }
            _ => {}
        }
    }

    let json = serde_json::to_value(document).expect("every generated value is representable");
    let mut found = Vec::new();
    walk(&json, &mut Vec::new(), &mut found);
    found.sort();
    found
}

// --- Properties ----------------------------------------------------------

proptest! {
    #![proptest_config(ProptestConfig::with_cases(128))]

    /// Parsing what was emitted yields the document that was emitted.
    #[test]
    fn a_document_survives_a_json_round_trip(document in arb_document()) {
        prop_assert_eq!(parse(&to_json(&document)), document);
    }

    /// Serialization depends on nothing but the value.
    #[test]
    fn serialization_is_deterministic(document in arb_document()) {
        let json = to_json(&document);
        prop_assert_eq!(to_json(&document.clone()), json.clone());
        prop_assert_eq!(to_json(&parse(&json)), json);
    }

    /// YAML emission writes exactly what `serde_yaml_ng` writes for the model.
    ///
    /// `to_yaml` may take any route to its output, but in a build where
    /// `serde_json`'s numbers serialize as numbers that output is the model's
    /// own YAML, byte for byte. A route that reorders a key, restyles a scalar
    /// or drops a tag fails here rather than in a downstream diff.
    #[cfg(feature = "yaml")]
    #[test]
    fn yaml_emission_is_what_serde_yaml_ng_writes_for_the_model(document in arb_document()) {
        prop_assert_eq!(
            document.to_yaml().expect("every generated value is representable in YAML"),
            serde_yaml_ng::to_string(&document).expect("the model serializes to YAML")
        );
    }

    /// Validation terminates and reports the same thing every time, at every
    /// specification version, for any document at all.
    #[test]
    fn validation_is_total(document in arb_document()) {
        for &version in VERSIONS {
            let validator = Validator::new(version);
            let violations = validator.validate(&document);
            prop_assert_eq!(rendered(&validator.validate(&document)), rendered(&violations));

            let errors = document.validate(version).err().unwrap_or_default();
            prop_assert_eq!(rendered(&errors), rendered_errors(&violations));

            for violation in &violations {
                prop_assert!(violation.location.starts_with('#'));
            }
        }
    }

    /// Every unchecked schema a document holds is reported once, at its own
    /// pointer, whatever container it sits in and however deep.
    #[test]
    fn every_unchecked_schema_is_reported_once_where_it_sits(document in arb_document()) {
        let expected = unchecked_pointers(&document);
        for &version in VERSIONS {
            let mut reported: Vec<String> = Validator::new(version)
                .validate(&document)
                .into_iter()
                .filter(|violation| violation.error == SpecError::UncheckedSchema)
                .map(|violation| violation.location)
                .collect();
            reported.sort();
            prop_assert_eq!(&reported, &expected);
        }
    }

    /// Emitting as 3.1 fails exactly when a 3.2-only construct is in the way,
    /// and emitting at a fixed version is idempotent.
    #[test]
    fn emitting_refuses_a_lossy_downgrade(document in arb_document()) {
        let blockers = three_two_only_constructs(&document);
        match document.emit(SpecVersion::V3_1) {
            Ok(emitted) => {
                prop_assert!(blockers.is_empty());
                prop_assert_eq!(&emitted.openapi, "3.1.2");
                prop_assert_eq!(emitted.emit(SpecVersion::V3_1).ok(), Some(emitted.clone()));
                prop_assert_eq!(three_two_only_constructs(&emitted), blockers);
            }
            Err(error) => {
                prop_assert!(!blockers.is_empty());
                prop_assert_eq!(error, SpecError::RequiresV3_2 { blockers });
            }
        }
    }

    /// A document is emittable as the version it already declares.
    #[cfg(feature = "openapi32")]
    #[test]
    fn emitting_as_the_newer_version_always_succeeds(document in arb_document()) {
        let emitted = document.emit(SpecVersion::V3_2).expect("3.2 expresses everything");
        prop_assert_eq!(&emitted.openapi, "3.2.0");
        prop_assert_eq!(emitted.emit(SpecVersion::V3_2).ok(), Some(emitted.clone()));
        prop_assert_eq!(emitted.spec_version(), Some(SpecVersion::V3_2));
    }
}