helm-schema-k8s 0.0.7

Generate an accurate JSON schema for any helm chart
Documentation
use std::collections::HashMap;
use test_util::prelude::sim_assert_eq;

use serde_json::{Value, json};

use super::*;

#[test]
fn full_expansion_resolves_every_sibling_junctor() {
    let definitions = SchemaDoc::new(json!({
        "definitions": {
            "All": { "type": "object" },
            "Any": { "type": "string" },
            "One": { "type": "integer" }
        }
    }));
    let mut ctx = ResolveCtx::new(
        move |filename| (filename == "defs.json").then(|| definitions.clone()),
        "root.json".to_string(),
        SchemaDoc::new(json!({})),
    );
    let node = ResolvedSchemaNode::root(
        "root.json",
        json!({
            "title": "sibling junctors",
            "allOf": [{ "$ref": "defs.json#/definitions/All" }],
            "anyOf": [{ "$ref": "defs.json#/definitions/Any" }],
            "oneOf": [{ "$ref": "defs.json#/definitions/One" }]
        }),
    );

    let expanded = expand_schema_node_at(&mut ctx, node, 0).into_schema();

    sim_assert_eq!(
        have: expanded,
        want: json!({
            "title": "sibling junctors",
            "allOf": [{ "type": "object" }],
            "anyOf": [{ "type": "string" }],
            "oneOf": [{ "type": "integer" }]
        })
    );
}

#[test]
fn path_descent_reads_required_from_the_selected_junctor_parent() {
    let root = SchemaDoc::new(json!({
        "type": "object",
        "properties": {
            "spec": {
                "anyOf": [
                    {
                        "type": "object",
                        "required": ["selected"],
                        "properties": {
                            "selected": { "type": "string" }
                        }
                    },
                    {
                        "type": "object",
                        "properties": {
                            "fallback": { "type": "boolean" }
                        }
                    }
                ]
            }
        }
    }));
    let mut ctx = ResolveCtx::new(|_| None, "root.json".to_string(), root.clone());
    let path = ["spec".to_string(), "selected".to_string()];

    let actual =
        descend_schema_path_expanding_leaf_with_location(&mut ctx, "root.json", root.root(), &path)
            .map(|leaf| (leaf.schema().clone(), leaf.required_in_parent()));

    sim_assert_eq!(
        have: actual,
        want: Some((json!({ "type": "string" }), true))
    );
}

#[test]
fn terminal_collection_segments_are_not_required_leaves() {
    let root = SchemaDoc::new(json!({
        "type": "object",
        "required": ["items"],
        "properties": {
            "items": {
                "type": "array",
                "items": { "type": "string" }
            },
            "labels": {
                "type": "object",
                "additionalProperties": { "type": "string" }
            }
        }
    }));
    let mut ctx = ResolveCtx::new(|_| None, "root.json".to_string(), root.clone());
    let array_path = ["items[*]".to_string()];
    let array_required = descend_schema_path_expanding_leaf_with_location(
        &mut ctx,
        "root.json",
        root.root(),
        &array_path,
    )
    .map(|leaf| leaf.required_in_parent());

    let mut ctx = ResolveCtx::new(|_| None, "root.json".to_string(), root.clone());
    let dynamic_path = [
        "labels".to_string(),
        helm_schema_core::DYNAMIC_MAPPING_VALUE_SEGMENT.to_string(),
    ];
    let dynamic_required = descend_schema_path_expanding_leaf_with_location(
        &mut ctx,
        "root.json",
        root.root(),
        &dynamic_path,
    )
    .map(|leaf| leaf.required_in_parent());

    sim_assert_eq!(have: array_required, want: Some(false));
    sim_assert_eq!(have: dynamic_required, want: Some(false));
}

fn descend_schema_path(schema: &Value, path: &[String]) -> Option<Value> {
    let mut current = schema;
    for segment in path {
        current = descend_one(current, segment)?;
    }
    Some(current.clone())
}

fn descend_one<'a>(schema: &'a Value, segment: &str) -> Option<&'a Value> {
    for keyword in ["allOf", "anyOf", "oneOf"] {
        if let Some(branches) = schema.get(keyword).and_then(Value::as_array) {
            for branch in branches {
                if let Some(value) = descend_one(branch, segment) {
                    return Some(value);
                }
            }
        }
    }

    let (key, is_array_item) = segment
        .strip_suffix("[*]")
        .map_or((segment, false), |key| (key, true));

    let mut next = schema
        .get("properties")
        .and_then(|properties| properties.as_object())
        .and_then(|properties| properties.get(key))
        .or_else(|| {
            schema
                .get("additionalProperties")
                .filter(|&additional_properties| !additional_properties.is_boolean())
        })?;

    if is_array_item {
        next = next.get("items").or_else(|| {
            next.get("prefixItems")
                .and_then(Value::as_array)
                .and_then(|items| items.first())
        })?;
    }

    Some(next)
}

#[test]
fn lazy_path_descent_matches_full_expansion_for_cross_file_array_ref() {
    let root = SchemaDoc::new(json!({
        "type": "object",
        "properties": {
            "spec": {
                "$ref": "defs.json#/definitions/Spec"
            }
        }
    }));
    let definitions = SchemaDoc::new(json!({
        "definitions": {
            "Spec": {
                "type": "object",
                "properties": {
                    "containers": {
                        "type": "array",
                        "items": {
                            "$ref": "#/definitions/Container"
                        }
                    }
                }
            },
            "Container": {
                "type": "object",
                "properties": {
                    "env": {
                        "type": "object",
                        "additionalProperties": {
                            "type": "string"
                        }
                    }
                }
            }
        }
    }));
    let docs = HashMap::from([("defs.json".to_string(), definitions.clone())]);
    let path = vec![
        "spec".to_string(),
        "containers[*]".to_string(),
        "env".to_string(),
    ];

    let mut full_ctx = ResolveCtx::new(
        {
            let docs = docs.clone();
            move |filename| docs.get(filename).cloned()
        },
        "root.json".to_string(),
        root.clone(),
    );
    let expanded_root_doc = full_ctx
        .doc("root.json")
        .cloned()
        .expect("root doc should be present");
    let expanded_root = descend_schema_path_expanding_leaf_with_location(
        &mut full_ctx,
        "root.json",
        &expanded_root_doc,
        &[],
    )
    .expect("expanded root should be present")
    .schema()
    .clone();
    let expected =
        descend_schema_path(&expanded_root, &path).expect("expanded root should contain path");

    let mut lazy_ctx = ResolveCtx::new(
        move |filename| docs.get(filename).cloned(),
        "root.json".to_string(),
        root,
    );
    let lazy_root = lazy_ctx
        .doc("root.json")
        .cloned()
        .expect("root doc should be present");
    let actual = descend_schema_path_expanding_leaf_with_location(
        &mut lazy_ctx,
        "root.json",
        &lazy_root,
        &path,
    )
    .expect("lazy descent should contain path")
    .schema()
    .clone();

    sim_assert_eq!(have: actual, want: expected);
}

#[test]
fn lazy_path_descent_reports_leaf_source_location_after_cross_file_refs() {
    let root = SchemaDoc::new(json!({
        "type": "object",
        "properties": {
            "spec": {
                "$ref": "defs.json#/definitions/Spec"
            }
        }
    }));
    let definitions = SchemaDoc::new(json!({
        "definitions": {
            "Spec": {
                "type": "object",
                "properties": {
                    "containers": {
                        "type": "array",
                        "items": {
                            "$ref": "#/definitions/Container"
                        }
                    }
                }
            },
            "Container": {
                "type": "object",
                "properties": {
                    "env": {
                        "$ref": "#/definitions/StringMap"
                    }
                }
            },
            "StringMap": {
                "type": "object",
                "additionalProperties": {
                    "type": "string"
                }
            }
        }
    }));
    let docs = HashMap::from([("defs.json".to_string(), definitions)]);
    let path = vec![
        "spec".to_string(),
        "containers[*]".to_string(),
        "env".to_string(),
    ];

    let mut ctx = ResolveCtx::new(
        move |filename| docs.get(filename).cloned(),
        "root.json".to_string(),
        root,
    );
    let root_doc = ctx
        .doc("root.json")
        .cloned()
        .expect("root doc should be present");
    let actual =
        descend_schema_path_expanding_leaf_with_location(&mut ctx, "root.json", &root_doc, &path)
            .expect("lazy descent should contain path");

    sim_assert_eq!(have: actual.location().filename(), want: "defs.json");
    sim_assert_eq!(
        have: actual.location().pointer(),
        want: "/definitions/Container/properties/env"
    );
    sim_assert_eq!(
        have: actual.source_schema(),
        want: &json!({ "$ref": "#/definitions/StringMap" })
    );
    sim_assert_eq!(
        have: actual.schema(),
        want: &json!({
            "type": "object",
            "additionalProperties": {
                "type": "string"
            }
        })
    );
}