graphddb_runtime 0.7.5

Rust runtime for GraphDDB — interprets the language-neutral IR (manifest.json + operations.json) and executes the validated access patterns against DynamoDB.
Documentation
//! Parameter validation and `{param}` template resolution — a port of
//! `python/graphddb_runtime/templates.py`.
//!
//! A template such as `EMAIL#{email}` or `USER#{userId}` contains zero or more
//! `{name}` placeholders bound from caller params. `{result.field}` placeholders
//! are relation-chain bindings; a template carrying one is handled by the caller
//! (they are resolved from prior results). A value with no `{...}` is a literal.

use serde_json::Value as Json;

use crate::errors::GraphDDBError;

/// Params are a JSON object (the caller's key + value map). We accept a map keyed
/// by string with `serde_json::Value` values throughout.
pub type Params = serde_json::Map<String, Json>;

/// Validate caller params against the spec's `params` declaration. Raises a
/// `ParameterValidationError` (before any DynamoDB call) on a missing required
/// param, an unknown param, a wrong scalar type, or a `literal` value outside the
/// allowed set.
pub fn validate_params(
    params: &Params,
    param_specs: &serde_json::Map<String, Json>,
    operation_id: &str,
) -> Result<(), GraphDDBError> {
    for (name, spec) in param_specs {
        let required = spec
            .get("required")
            .and_then(Json::as_bool)
            .unwrap_or(false);
        let present = params.get(name).map(|v| !v.is_null()).unwrap_or(false);
        if required && !present {
            return Err(GraphDDBError::parameter_validation(format!(
                "{operation_id}: missing required parameter '{name}'"
            )));
        }
        if !present {
            continue;
        }
        check_value(operation_id, name, spec, &params[name])?;
    }
    for name in params.keys() {
        if !param_specs.contains_key(name) {
            return Err(GraphDDBError::parameter_validation(format!(
                "{operation_id}: unknown parameter '{name}'"
            )));
        }
    }
    Ok(())
}

fn check_value(
    operation_id: &str,
    name: &str,
    spec: &Json,
    value: &Json,
) -> Result<(), GraphDDBError> {
    let kind = spec.get("type").and_then(Json::as_str).unwrap_or("string");
    match kind {
        "number" => {
            // A JSON bool is NOT a number (parity with Python `isinstance(bool)`).
            if !value.is_number() {
                return Err(GraphDDBError::parameter_validation(format!(
                    "{operation_id}: parameter '{name}' must be a number"
                )));
            }
        }
        "boolean" => {
            if !value.is_boolean() {
                return Err(GraphDDBError::parameter_validation(format!(
                    "{operation_id}: parameter '{name}' must be a boolean"
                )));
            }
        }
        "literal" => {
            let empty = vec![];
            let literals = spec
                .get("literals")
                .and_then(Json::as_array)
                .unwrap_or(&empty);
            if !literals.iter().any(|lit| lit == value) {
                let allowed = literals
                    .iter()
                    .map(py_repr_json)
                    .collect::<Vec<_>>()
                    .join(", ");
                return Err(GraphDDBError::parameter_validation(format!(
                    "{operation_id}: parameter '{name}' must be one of [{allowed}], got {}",
                    py_repr_json(value)
                )));
            }
        }
        "array" => {
            let items = value.as_array().ok_or_else(|| {
                GraphDDBError::parameter_validation(format!(
                    "{operation_id}: parameter '{name}' must be an array"
                ))
            })?;
            let empty = serde_json::Map::new();
            let element_specs = spec
                .get("element")
                .and_then(Json::as_object)
                .unwrap_or(&empty);
            for (index, element) in items.iter().enumerate() {
                let obj = element.as_object().ok_or_else(|| {
                    GraphDDBError::parameter_validation(format!(
                        "{operation_id}: parameter '{name}'[{index}] must be an object"
                    ))
                })?;
                for (field, field_spec) in element_specs {
                    let present = obj.get(field).map(|v| !v.is_null()).unwrap_or(false);
                    let field_required = field_spec
                        .get("required")
                        .and_then(Json::as_bool)
                        .unwrap_or(false);
                    if field_required && !present {
                        return Err(GraphDDBError::parameter_validation(format!(
                            "{operation_id}: parameter '{name}'[{index}] missing required field '{field}'"
                        )));
                    }
                    if present {
                        check_value(
                            operation_id,
                            &format!("{name}[{index}].{field}"),
                            field_spec,
                            &obj[field],
                        )?;
                    }
                }
            }
        }
        _ => {
            // 'string' and any other scalar default to string.
            if !value.is_string() {
                return Err(GraphDDBError::parameter_validation(format!(
                    "{operation_id}: parameter '{name}' must be a string"
                )));
            }
        }
    }
    Ok(())
}

/// Python `repr()` of a JSON scalar for error messages (strings single-quoted).
fn py_repr_json(v: &Json) -> String {
    match v {
        Json::String(s) => format!("'{s}'"),
        other => other.to_string(),
    }
}

/// Substitute every `{param}` placeholder in `template` with the string form of
/// its bound param. A stray / unbound placeholder raises a
/// `ParameterValidationError`. The whole value is returned as a string (key /
/// sort-key values are strings in the single-table layout).
pub fn resolve_template(template: &str, params: &Params) -> Result<String, GraphDDBError> {
    resolve_with(template, |name| {
        params.get(name).and_then(|v| {
            if v.is_null() {
                None
            } else {
                Some(json_to_template_string(v))
            }
        })
    })
    .map_err(|name| {
        GraphDDBError::parameter_validation(format!(
            "template '{template}' references unbound parameter '{name}'"
        ))
    })
}

/// True if the template references a prior result (`{result.*}`).
pub fn has_result_placeholder(template: &str) -> bool {
    each_placeholder(template).any(|name| name.starts_with("result."))
}

/// The string form used when a JSON param is substituted into a template,
/// matching Python `str(value)` on the deserialized value.
pub fn json_to_template_string(v: &Json) -> String {
    match v {
        Json::String(s) => s.clone(),
        Json::Bool(b) => {
            if *b {
                "True".to_string()
            } else {
                "False".to_string()
            }
        }
        Json::Null => "None".to_string(),
        Json::Number(n) => n.to_string(),
        other => other.to_string(),
    }
}

/// Resolve `{name}` placeholders using `lookup`; on a missing name, returns
/// `Err(name)`. `{result.*}` and any other name are treated uniformly by
/// `lookup` (the caller decides).
pub(crate) fn resolve_with<F>(template: &str, mut lookup: F) -> Result<String, String>
where
    F: FnMut(&str) -> Option<String>,
{
    let mut out = String::new();
    let bytes = template.as_bytes();
    let mut i = 0;
    while i < bytes.len() {
        if bytes[i] == b'{' {
            if let Some(close) = template[i + 1..].find('}') {
                let name = &template[i + 1..i + 1 + close];
                // A placeholder name contains no braces (parity with the regex
                // `\{[^{}]+\}`); an empty name is not a placeholder.
                if !name.is_empty() && !name.contains('{') {
                    match lookup(name) {
                        Some(v) => out.push_str(&v),
                        None => return Err(name.to_string()),
                    }
                    i += 1 + close + 1;
                    continue;
                }
            }
        }
        out.push(bytes[i] as char);
        i += 1;
    }
    Ok(out)
}

/// Collect the placeholder names in a template (regex `\{[^{}]+\}` equivalent) —
/// public for the entity / transaction modules.
pub fn each_placeholder_pub(template: &str) -> Vec<String> {
    each_placeholder(template).collect()
}

/// Iterate the placeholder names in a template (regex `\{[^{}]+\}` equivalent).
pub(crate) fn each_placeholder(template: &str) -> impl Iterator<Item = String> + '_ {
    let bytes = template.as_bytes();
    let mut i = 0;
    std::iter::from_fn(move || {
        while i < bytes.len() {
            if bytes[i] == b'{' {
                if let Some(close) = template[i + 1..].find('}') {
                    let name = &template[i + 1..i + 1 + close];
                    if !name.is_empty() && !name.contains('{') {
                        let name = name.to_string();
                        i += 1 + close + 1;
                        return Some(name);
                    }
                }
            }
            i += 1;
        }
        None
    })
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;

    fn params(v: Json) -> Params {
        v.as_object().unwrap().clone()
    }

    #[test]
    fn resolves_placeholders() {
        let p = params(json!({"userId": "alice"}));
        assert_eq!(resolve_template("USER#{userId}", &p).unwrap(), "USER#alice");
        assert_eq!(resolve_template("PROFILE", &p).unwrap(), "PROFILE");
    }

    #[test]
    fn unbound_placeholder_errors() {
        let p = params(json!({}));
        assert!(resolve_template("USER#{userId}", &p).is_err());
    }

    #[test]
    fn validate_required_and_unknown_and_type() {
        let specs = json!({"userId": {"type": "string", "required": true}});
        let specs = specs.as_object().unwrap();
        assert!(validate_params(&params(json!({})), specs, "q").is_err());
        assert!(validate_params(&params(json!({"x": "1"})), specs, "q").is_err());
        assert!(validate_params(&params(json!({"userId": 5})), specs, "q").is_err());
        assert!(validate_params(&params(json!({"userId": "a"})), specs, "q").is_ok());
    }

    #[test]
    fn literal_membership() {
        let specs = json!({"order": {"type": "literal", "literals": ["asc", "desc"]}});
        let specs = specs.as_object().unwrap();
        assert!(validate_params(&params(json!({"order": "asc"})), specs, "q").is_ok());
        assert!(validate_params(&params(json!({"order": "up"})), specs, "q").is_err());
    }
}