assay-lua 0.10.3

General-purpose enhanced Lua runtime. Batteries-included scripting, automation, and web services.
Documentation
use crate::config::CheckConfig;
use crate::output::CheckResult;
use anyhow::{Context, Result, bail};
use std::time::Duration;

pub struct HttpCheck;

impl HttpCheck {
    pub async fn execute(
        &self,
        config: &CheckConfig,
        client: &reqwest::Client,
    ) -> Result<CheckResult> {
        let url = config
            .url
            .as_deref()
            .context("http check requires a 'url' field")?;

        // Build a no-redirect client when follow_redirects is false
        let no_redirect_client;
        let effective_client = if config.follow_redirects {
            client
        } else {
            no_redirect_client = reqwest::Client::builder()
                .redirect(reqwest::redirect::Policy::none())
                .timeout(Duration::from_secs(30))
                .build()
                .context("building no-redirect HTTP client")?;
            &no_redirect_client
        };

        let resp = effective_client
            .get(url)
            .send()
            .await
            .with_context(|| format!("HTTP GET {url}"))?;

        let status = resp.status().as_u16();
        let body = resp
            .text()
            .await
            .with_context(|| format!("reading response body from {url}"))?;

        let expect = match &config.expect {
            Some(e) => e,
            None => {
                let passed = (200..300).contains(&status);
                return Ok(CheckResult {
                    name: config.name.clone(),
                    passed,
                    duration_ms: 0,
                    message: if passed {
                        None
                    } else {
                        Some(format!("expected 2xx, got {status}"))
                    },
                });
            }
        };

        if let Some(expected_status) = expect.status
            && status != expected_status
        {
            return Ok(CheckResult {
                name: config.name.clone(),
                passed: false,
                duration_ms: 0,
                message: Some(format!("expected status {expected_status}, got {status}")),
            });
        }

        if let Some(ref expected_body) = expect.body
            && !body.contains(expected_body)
        {
            return Ok(CheckResult {
                name: config.name.clone(),
                passed: false,
                duration_ms: 0,
                message: Some(format!("body does not contain {expected_body:?}")),
            });
        }

        if let Some(ref json_expr) = expect.json {
            let passed = evaluate_json_expression(&body, json_expr)?;
            if !passed {
                return Ok(CheckResult {
                    name: config.name.clone(),
                    passed: false,
                    duration_ms: 0,
                    message: Some(format!("JSON expression failed: {json_expr}")),
                });
            }
        }

        Ok(CheckResult {
            name: config.name.clone(),
            passed: true,
            duration_ms: 0,
            message: None,
        })
    }
}

fn evaluate_json_expression(body: &str, expr: &str) -> Result<bool> {
    let parsed: serde_json::Value =
        serde_json::from_str(body).context("response body is not valid JSON")?;

    let expr = expr.trim();
    let parts: Vec<&str> = expr.splitn(2, "==").collect();
    if parts.len() != 2 {
        bail!("unsupported JSON expression syntax: {expr:?} (expected '.path == value')");
    }

    let path = parts[0].trim();
    let expected_str = parts[1].trim();

    let actual = navigate_json_path(&parsed, path)?;

    let expected: serde_json::Value =
        if expected_str.starts_with('"') && expected_str.ends_with('"') {
            serde_json::Value::String(expected_str[1..expected_str.len() - 1].to_string())
        } else if expected_str == "true" {
            serde_json::Value::Bool(true)
        } else if expected_str == "false" {
            serde_json::Value::Bool(false)
        } else if expected_str == "null" {
            serde_json::Value::Null
        } else if let Ok(n) = expected_str.parse::<i64>() {
            serde_json::Value::Number(n.into())
        } else if let Ok(n) = expected_str.parse::<f64>() {
            serde_json::json!(n)
        } else {
            serde_json::Value::String(expected_str.to_string())
        };

    Ok(actual == &expected)
}

fn navigate_json_path<'a>(
    value: &'a serde_json::Value,
    path: &str,
) -> Result<&'a serde_json::Value> {
    let path = path.trim_start_matches('.');
    let mut current = value;
    for segment in path.split('.') {
        let segment = segment.trim();
        if segment.is_empty() {
            continue;
        }
        current = current
            .get(segment)
            .with_context(|| format!("JSON path '.{segment}' not found in response"))?;
    }
    Ok(current)
}