zapreq 0.1.7

A fast, friendly HTTP client for the terminal
Documentation
use anyhow::{anyhow, Context, Result};
use std::collections::HashMap;
use std::time::Instant;

use crate::cli::SourceSelector;
use crate::collections::{
    list_workspace_requests, load_request, load_requests_from_import_path, load_workspace_request,
    CollectionEntry,
};
use crate::config::{merge_defaults, Config};
use crate::items::parse_request_items;
use crate::request::{RequestEngine, RequestSpec};
use crate::response::{RequestTrace, ResponseData};
use crate::utils::normalize_url;

#[derive(Clone, Debug)]
pub struct RequestRecord {
    pub name: String,
    pub method: String,
    pub url: String,
    pub items: Vec<String>,
    pub headers: HashMap<String, String>,
    pub source_label: String,
}

pub fn resolve_records(selector: &SourceSelector) -> Result<Vec<RequestRecord>> {
    if selector.request.is_some() && selector.workspace.is_none() {
        return Err(anyhow!("--request requires --workspace"));
    }
    let selected = selected_source_count(selector);
    if selected == 0 {
        return Err(anyhow!(
            "choose exactly one source: --alias, --workspace, or --file"
        ));
    }
    if selected > 1 {
        return Err(anyhow!(
            "source flags are mutually exclusive; use only one of --alias, --workspace, or --file"
        ));
    }

    if let Some(alias) = selector.alias.as_deref() {
        let entry = load_request(alias)
            .with_context(|| format!("failed to load saved request '{alias}'"))?;
        return Ok(vec![record_from_entry(
            entry,
            format!("alias:{alias}"),
            alias.to_string(),
        )?]);
    }

    if let Some(workspace) = selector.workspace.as_deref() {
        if let Some(request) = selector.request.as_deref() {
            let entry = load_workspace_request(workspace, request).with_context(|| {
                format!(
                    "failed to load request '{}' from workspace '{}'",
                    request, workspace
                )
            })?;
            return Ok(vec![record_from_entry(
                entry,
                format!("request:{workspace}/{request}"),
                request.to_string(),
            )?]);
        }

        let entries = list_workspace_requests(workspace)
            .with_context(|| format!("failed to list workspace '{}'", workspace))?;
        let out = entries
            .into_iter()
            .map(|entry| {
                record_from_entry(
                    CollectionEntry {
                        alias: entry.name.clone(),
                        method: entry.method,
                        url: entry.url,
                        items: entry.items,
                        headers: entry.headers,
                        headers_v2: entry.headers_v2,
                        created: entry.created,
                    },
                    format!("workspace:{workspace}/{}", entry.name),
                    entry.name,
                )
            })
            .collect::<Result<Vec<_>>>()?;
        return Ok(out);
    }

    let Some(path) = selector.file.as_deref() else {
        return Err(anyhow!("no source selected"));
    };
    let entries = load_requests_from_import_path(path)
        .with_context(|| format!("failed to resolve requests from import file '{path}'"))?;
    entries
        .into_iter()
        .map(|entry| {
            let name = entry.alias.clone();
            record_from_entry(entry, format!("file:{path}#{name}"), name)
        })
        .collect::<Result<Vec<_>>>()
}

pub fn resolve_single_record(selector: &SourceSelector) -> Result<RequestRecord> {
    let records = resolve_records(selector)?;
    if records.len() != 1 {
        return Err(anyhow!(
            "this operation requires exactly one request source, but {} request(s) were resolved",
            records.len()
        ));
    }
    Ok(records.into_iter().next().expect("record count checked"))
}

pub fn build_cli_for_record(
    record: &RequestRecord,
    config: &Config,
) -> Result<crate::cli::CliArgs> {
    let mut argv = vec![
        "zapreq".to_string(),
        record.method.clone(),
        record.url.clone(),
    ];
    argv.extend(record.items.clone());
    merge_defaults(config, &mut argv);
    let mut cli = crate::cli::parse_cli_from(argv).context("failed to build CLI for request")?;
    cli.command = None;
    Ok(cli)
}

pub fn build_spec_for_record(
    record: &RequestRecord,
    config: &Config,
) -> Result<(crate::cli::CliArgs, RequestSpec)> {
    let cli = build_cli_for_record(record, config)?;
    let usable_url = normalize_url(&record.url, &cli.default_scheme)
        .with_context(|| format!("failed to normalize URL '{}'", record.url))?;

    let resolved_items = record.items.clone();
    let parsed_items =
        parse_request_items(&resolved_items).context("failed to parse saved request items")?;
    let headers = crate::headers::build_headers_from_cli(
        &cli,
        &parsed_items,
        &std::collections::HashMap::new(),
    )?;
    let spec = RequestSpec {
        method: cli.method.clone(),
        url: usable_url,
        items: parsed_items,
        headers,
    };
    Ok((cli, spec))
}

pub fn execute_record(
    record: &RequestRecord,
    config: &Config,
) -> Result<(RequestTrace, ResponseData, u64)> {
    let (cli, spec) = build_spec_for_record(record, config)?;
    let engine = RequestEngine::new();
    let started = Instant::now();
    let (trace, response) = engine
        .send(&cli, &spec, None)
        .with_context(|| format!("request execution failed for '{}'", record.name))?;
    Ok((trace, response, started.elapsed().as_millis() as u64))
}

pub fn scope_label_for_notes(selector: &SourceSelector) -> Result<String> {
    if let Some(alias) = selector.alias.as_deref() {
        return Ok(format!("alias:{alias}"));
    }
    if let Some(workspace) = selector.workspace.as_deref() {
        if let Some(request) = selector.request.as_deref() {
            return Ok(format!("request:{workspace}/{request}"));
        }
        return Ok(format!("workspace:{workspace}"));
    }
    Err(anyhow!(
        "notes support --alias or --workspace [--request]; import files are not a stable note scope"
    ))
}

fn record_from_entry(
    entry: CollectionEntry,
    source_label: String,
    name: String,
) -> Result<RequestRecord> {
    let items = crate::collections::materialize_entry_items(&entry)?;
    Ok(RequestRecord {
        name,
        method: entry.method,
        url: entry.url,
        items,
        headers: entry.headers,
        source_label,
    })
}

fn selected_source_count(selector: &SourceSelector) -> usize {
    usize::from(selector.alias.is_some())
        + usize::from(selector.workspace.is_some())
        + usize::from(selector.file.is_some())
}