zapreq 0.1.7

A fast, friendly HTTP client for the terminal
Documentation
use anyhow::{Context, Result};
use chrono::Utc;
use rusqlite::{params, Connection};
use std::path::PathBuf;

use crate::config::config_root_dir;

pub fn open_connection() -> Result<Connection> {
    let path = db_path()?;
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent)
            .with_context(|| format!("failed to create local db dir: {}", parent.display()))?;
    }
    let conn = Connection::open(&path)
        .with_context(|| format!("failed to open local sqlite db: {}", path.display()))?;
    init_schema(&conn)?;
    Ok(conn)
}

pub fn reports_dir() -> Result<PathBuf> {
    Ok(config_root_dir()?.join("reports"))
}

pub fn db_path() -> Result<PathBuf> {
    Ok(config_root_dir()?.join("zapreq.sqlite3"))
}

pub fn record_report(
    conn: &Connection,
    module: &str,
    name: &str,
    summary: &str,
    payload_json: &str,
) -> Result<i64> {
    let now = Utc::now().to_rfc3339();
    conn.execute(
        "INSERT INTO reports (module, name, summary, payload_json, created_at)
         VALUES (?1, ?2, ?3, ?4, ?5)",
        params![module, name, summary, payload_json, now],
    )
    .context("failed to persist report")?;
    Ok(conn.last_insert_rowid())
}

fn init_schema(conn: &Connection) -> Result<()> {
    conn.execute_batch(
        "
        CREATE TABLE IF NOT EXISTS reports (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            module TEXT NOT NULL,
            name TEXT NOT NULL,
            summary TEXT NOT NULL,
            payload_json TEXT NOT NULL,
            created_at TEXT NOT NULL
        );

        CREATE TABLE IF NOT EXISTS test_cases (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            suite TEXT NOT NULL,
            name TEXT NOT NULL,
            source_label TEXT NOT NULL,
            method TEXT NOT NULL,
            url TEXT NOT NULL,
            items_json TEXT NOT NULL,
            headers_json TEXT NOT NULL,
            expect_status INTEGER,
            expect_headers_json TEXT NOT NULL,
            expect_json_json TEXT NOT NULL,
            expect_body_contains_json TEXT NOT NULL,
            max_time_ms INTEGER,
            created_at TEXT NOT NULL,
            updated_at TEXT NOT NULL,
            UNIQUE(suite, name)
        );

        CREATE TABLE IF NOT EXISTS test_runs (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            suite TEXT NOT NULL,
            case_name TEXT NOT NULL,
            passed INTEGER NOT NULL,
            status_code INTEGER NOT NULL,
            elapsed_ms INTEGER NOT NULL,
            report_json TEXT NOT NULL,
            created_at TEXT NOT NULL
        );

        CREATE TABLE IF NOT EXISTS notes (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            scope TEXT NOT NULL,
            title TEXT NOT NULL,
            body TEXT NOT NULL,
            tags_json TEXT NOT NULL,
            created_at TEXT NOT NULL,
            updated_at TEXT NOT NULL
        );

        CREATE TABLE IF NOT EXISTS note_revisions (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            note_id INTEGER NOT NULL,
            version INTEGER NOT NULL,
            title TEXT NOT NULL,
            body TEXT NOT NULL,
            tags_json TEXT NOT NULL,
            created_at TEXT NOT NULL,
            FOREIGN KEY(note_id) REFERENCES notes(id)
        );
        ",
    )
    .context("failed to initialize sqlite schema")?;
    ensure_report_http_columns(conn)?;
    Ok(())
}

fn ensure_report_http_columns(conn: &Connection) -> Result<()> {
    let mut stmt = conn.prepare("PRAGMA table_info(reports)")?;
    let columns = stmt
        .query_map([], |row| row.get::<_, String>(1))?
        .collect::<std::result::Result<Vec<_>, _>>()?;

    for (name, sql_type) in [
        ("method", "TEXT"),
        ("url", "TEXT"),
        ("final_url", "TEXT"),
        ("status", "INTEGER"),
        ("reason", "TEXT"),
        ("elapsed_ms", "INTEGER"),
        ("size_bytes", "INTEGER"),
        ("content_type", "TEXT"),
    ] {
        if !columns.iter().any(|column| column == name) {
            conn.execute(
                &format!("ALTER TABLE reports ADD COLUMN {name} {sql_type}"),
                [],
            )?;
        }
    }

    Ok(())
}

#[allow(clippy::too_many_arguments)]
pub fn record_http_report(
    module: &str,
    method: &str,
    url: &str,
    final_url: &str,
    status: u16,
    reason: &str,
    elapsed_ms: u64,
    size_bytes: usize,
    content_type: Option<&str>,
    headers: &[(String, String)],
    body: &str,
) -> Result<()> {
    let conn = open_connection()?;
    let summary = format!("{method} {url} -> {status} {reason} ({elapsed_ms} ms)");
    let masked_headers: Vec<(String, String)> = headers
        .iter()
        .map(|(k, v)| (k.clone(), crate::headers::mask_header_value(k, v)))
        .collect();
    let payload = serde_json::json!({
        "method": method,
        "url": url,
        "final_url": final_url,
        "status": status,
        "reason": reason,
        "elapsed_ms": elapsed_ms,
        "size_bytes": size_bytes,
        "content_type": content_type,
        "headers": masked_headers,
        "body": body,
    });
    let payload_json = serde_json::to_string_pretty(&payload)?;
    let now = Utc::now().to_rfc3339();
    conn.execute(
        "INSERT INTO reports (
            module, name, summary, payload_json, created_at,
            method, url, final_url, status, reason, elapsed_ms, size_bytes, content_type
         )
         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
        params![
            module,
            url,
            summary,
            payload_json,
            now,
            method,
            url,
            final_url,
            status,
            reason,
            elapsed_ms as i64,
            size_bytes as i64,
            content_type,
        ],
    )
    .context("failed to persist HTTP report")?;
    Ok(())
}