zapreq 0.1.7

A fast, friendly HTTP client for the terminal
Documentation
use anyhow::{anyhow, Context, Result};
use chrono::Utc;
use rusqlite::{params, OptionalExtension};
use serde::{Deserialize, Serialize};

use crate::cli::SourceSelector;
use crate::config::Config;
use crate::localdb::open_connection;
use crate::sources::{execute_record, resolve_single_record};
use crate::testing::{evaluate_response, render_text_report, TestOptions, TestReport};

#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct StoredTestCase {
    pub suite: String,
    pub name: String,
    pub source_label: String,
    pub method: String,
    pub url: String,
    pub items: Vec<String>,
    pub headers: Vec<(String, String)>,
    pub expect_status: Option<u16>,
    pub expect_headers: Vec<String>,
    pub expect_json: Vec<String>,
    pub expect_body_contains: Vec<String>,
    pub max_time_ms: Option<u64>,
    pub created_at: String,
    pub updated_at: String,
}

#[derive(Clone, Debug, Serialize)]
pub struct TestRunSummary {
    pub case_name: String,
    pub passed: bool,
    pub status: u16,
    pub elapsed_ms: u64,
}

#[derive(Clone, Debug, Serialize)]
pub struct SuiteRunReport {
    pub suite: String,
    pub generated_at: String,
    pub passed: usize,
    pub failed: usize,
    pub cases: Vec<TestRunSummary>,
}

pub fn add_test_case(
    suite: &str,
    name: &str,
    selector: &SourceSelector,
    options: &TestOptions,
) -> Result<()> {
    let record = resolve_single_record(selector)?;
    let conn = open_connection()?;
    let now = Utc::now().to_rfc3339();
    let headers = record
        .headers
        .iter()
        .map(|(key, value)| (key.clone(), value.clone()))
        .collect::<Vec<_>>();
    conn.execute(
        "INSERT INTO test_cases (
            suite, name, source_label, method, url, items_json, headers_json,
            expect_status, expect_headers_json, expect_json_json, expect_body_contains_json,
            max_time_ms, created_at, updated_at
        ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)
        ON CONFLICT(suite, name) DO UPDATE SET
            source_label=excluded.source_label,
            method=excluded.method,
            url=excluded.url,
            items_json=excluded.items_json,
            headers_json=excluded.headers_json,
            expect_status=excluded.expect_status,
            expect_headers_json=excluded.expect_headers_json,
            expect_json_json=excluded.expect_json_json,
            expect_body_contains_json=excluded.expect_body_contains_json,
            max_time_ms=excluded.max_time_ms,
            updated_at=excluded.updated_at",
        params![
            suite,
            name,
            record.source_label,
            record.method,
            record.url,
            serde_json::to_string(&record.items)?,
            serde_json::to_string(&headers)?,
            options.expect_status.map(i64::from),
            serde_json::to_string(&options.expect_headers)?,
            serde_json::to_string(&options.expect_json)?,
            serde_json::to_string(&options.expect_body_contains)?,
            options.max_time_ms.map(|value| value as i64),
            now,
            now,
        ],
    )
    .context("failed to save test case")?;
    Ok(())
}

pub fn list_test_cases(suite: Option<&str>) -> Result<Vec<StoredTestCase>> {
    let conn = open_connection()?;
    let mut out = Vec::new();
    if let Some(suite) = suite {
        let mut stmt = conn.prepare(
            "SELECT suite, name, source_label, method, url, items_json, headers_json,
                    expect_status, expect_headers_json, expect_json_json,
                    expect_body_contains_json, max_time_ms, created_at, updated_at
             FROM test_cases
             WHERE suite = ?1
             ORDER BY suite, name",
        )?;
        let rows = stmt.query_map([suite], read_case_row)?;
        for row in rows {
            out.push(row?);
        }
    } else {
        let mut stmt = conn.prepare(
            "SELECT suite, name, source_label, method, url, items_json, headers_json,
                    expect_status, expect_headers_json, expect_json_json,
                    expect_body_contains_json, max_time_ms, created_at, updated_at
             FROM test_cases
             ORDER BY suite, name",
        )?;
        let rows = stmt.query_map([], read_case_row)?;
        for row in rows {
            out.push(row?);
        }
    }
    Ok(out)
}

pub fn delete_test_case(suite: &str, name: &str) -> Result<bool> {
    let conn = open_connection()?;
    let affected = conn.execute(
        "DELETE FROM test_cases WHERE suite = ?1 AND name = ?2",
        params![suite, name],
    )?;
    Ok(affected > 0)
}

pub fn run_suite(suite: &str, config: &Config) -> Result<SuiteRunReport> {
    let cases = list_test_cases(Some(suite))?;
    if cases.is_empty() {
        return Err(anyhow!("no test cases found in suite '{suite}'"));
    }

    let conn = open_connection()?;
    let mut passed = 0usize;
    let mut failed = 0usize;
    let mut summaries = Vec::new();
    let generated_at = Utc::now().to_rfc3339();

    for case in cases {
        let record = crate::sources::RequestRecord {
            name: case.name.clone(),
            method: case.method.clone(),
            url: case.url.clone(),
            items: case.items.clone(),
            headers: case.headers.iter().cloned().collect(),
            source_label: case.source_label.clone(),
        };
        let (trace, response, elapsed_ms) = execute_record(&record, config)?;
        let opts = TestOptions {
            expect_status: case.expect_status,
            expect_headers: case.expect_headers.clone(),
            expect_json: case.expect_json.clone(),
            expect_body_contains: case.expect_body_contains.clone(),
            max_time_ms: case.max_time_ms,
        };
        let report = evaluate_response(&trace.method, &trace.url, &response, elapsed_ms, &opts);
        if report.passed {
            passed += 1;
        } else {
            failed += 1;
        }
        persist_run(&conn, suite, &case.name, &report)?;
        summaries.push(TestRunSummary {
            case_name: case.name,
            passed: report.passed,
            status: report.status,
            elapsed_ms: report.elapsed_ms,
        });
    }

    Ok(SuiteRunReport {
        suite: suite.to_string(),
        generated_at,
        passed,
        failed,
        cases: summaries,
    })
}

pub fn latest_run_report(suite: &str, case_name: &str) -> Result<Option<TestReport>> {
    let conn = open_connection()?;
    let json: Option<String> = conn
        .query_row(
            "SELECT report_json FROM test_runs
             WHERE suite = ?1 AND case_name = ?2
             ORDER BY id DESC LIMIT 1",
            params![suite, case_name],
            |row| row.get(0),
        )
        .optional()?;
    json.map(|raw| serde_json::from_str(&raw).context("failed to parse stored test report"))
        .transpose()
}

pub fn render_suite_report(report: &SuiteRunReport) -> String {
    let mut out = String::new();
    out.push_str(&format!(
        "Suite {} [{}]\nPassed: {}  Failed: {}\n",
        report.suite, report.generated_at, report.passed, report.failed
    ));
    for case in &report.cases {
        let state = if case.passed { "PASS" } else { "FAIL" };
        out.push_str(&format!(
            "[{}] {} -> status={} time={}ms\n",
            state, case.case_name, case.status, case.elapsed_ms
        ));
    }
    out
}

pub fn render_case_history(suite: &str, case_name: &str) -> Result<String> {
    let conn = open_connection()?;
    let mut stmt = conn.prepare(
        "SELECT passed, status_code, elapsed_ms, created_at
         FROM test_runs
         WHERE suite = ?1 AND case_name = ?2
         ORDER BY id DESC
         LIMIT 10",
    )?;
    let mut rows = stmt.query(params![suite, case_name])?;
    let mut out = format!("Recent runs for {suite}/{case_name}\n");
    let mut any = false;
    while let Some(row) = rows.next()? {
        any = true;
        let passed: i64 = row.get(0)?;
        let status: i64 = row.get(1)?;
        let elapsed_ms: i64 = row.get(2)?;
        let created_at: String = row.get(3)?;
        let marker = if passed == 1 { "PASS" } else { "FAIL" };
        out.push_str(&format!(
            "[{}] status={} elapsed={}ms at {}\n",
            marker, status, elapsed_ms, created_at
        ));
    }
    if !any {
        out.push_str("No run history.\n");
    }
    Ok(out)
}

pub fn render_latest_case_report(suite: &str, case_name: &str) -> Result<String> {
    if let Some(report) = latest_run_report(suite, case_name)? {
        Ok(render_text_report(&report))
    } else {
        Ok(format!("No stored run report for {suite}/{case_name}\n"))
    }
}

fn persist_run(
    conn: &rusqlite::Connection,
    suite: &str,
    case_name: &str,
    report: &TestReport,
) -> Result<()> {
    conn.execute(
        "INSERT INTO test_runs (suite, case_name, passed, status_code, elapsed_ms, report_json, created_at)
         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
        params![
            suite,
            case_name,
            if report.passed { 1 } else { 0 },
            i64::from(report.status),
            report.elapsed_ms as i64,
            serde_json::to_string_pretty(report)?,
            Utc::now().to_rfc3339(),
        ],
    )
    .context("failed to persist test run")?;
    Ok(())
}

fn read_case_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<StoredTestCase> {
    let items_json: String = row.get(5)?;
    let headers_json: String = row.get(6)?;
    let expect_headers_json: String = row.get(8)?;
    let expect_json_json: String = row.get(9)?;
    let expect_body_contains_json: String = row.get(10)?;
    Ok(StoredTestCase {
        suite: row.get(0)?,
        name: row.get(1)?,
        source_label: row.get(2)?,
        method: row.get(3)?,
        url: row.get(4)?,
        items: serde_json::from_str(&items_json).unwrap_or_default(),
        headers: serde_json::from_str(&headers_json).unwrap_or_default(),
        expect_status: row.get::<_, Option<i64>>(7)?.map(|value| value as u16),
        expect_headers: serde_json::from_str(&expect_headers_json).unwrap_or_default(),
        expect_json: serde_json::from_str(&expect_json_json).unwrap_or_default(),
        expect_body_contains: serde_json::from_str(&expect_body_contains_json).unwrap_or_default(),
        max_time_ms: row.get::<_, Option<i64>>(11)?.map(|value| value as u64),
        created_at: row.get(12)?,
        updated_at: row.get(13)?,
    })
}