hen 0.9.0

Run API collections from the command line.
use http::Method;
use reqwest::header::HeaderMap;
use serde_json::Value;
use std::{collections::HashMap, path::PathBuf};

use crate::parser::{self, context};

mod assertion;
mod planner;
mod response_capture;
mod runner;

pub use assertion::Assertion;
pub use planner::RequestPlanner;
pub use response_capture::{ResponseCapture, ResponseSnapshot};
pub use runner::{
    execute_plan as execute_request_plan, ExecutionOptions, ExecutionRecord, RequestFailure,
    RequestFailureKind,
};

#[derive(Debug, Clone)]
pub enum FormDataType {
    Text(String),
    File(PathBuf),
}

#[derive(Debug, Clone)]
pub struct Request {
    pub description: String,
    pub method: Method,
    pub url: String,
    pub headers: HashMap<String, String>,
    pub query_params: HashMap<String, String>,
    pub form_data: HashMap<String, FormDataType>,
    pub body: Option<String>,
    pub body_content_type: Option<String>,
    pub callback_src: Vec<String>,
    pub response_captures: Vec<ResponseCapture>,
    pub assertions: Vec<Assertion>,
    pub dependencies: Vec<String>,
    pub context: HashMap<String, String>,
    pub working_dir: PathBuf,
}

#[derive(Debug, Clone)]
pub struct RequestExecution {
    pub output: String,
    pub export_env: HashMap<String, String>,
    pub snapshot: ResponseSnapshot,
}

impl Request {
    // builds a reqwest request then sends it.
    pub async fn exec(
        &self,
        inherited_context: &HashMap<String, String>,
        dependency_snapshots: &HashMap<String, ResponseSnapshot>,
    ) -> Result<RequestExecution, Box<dyn std::error::Error>> {
        // assemble the request using reqwest
        let client = reqwest::Client::new();

        let mut context_map = inherited_context.clone();
        for (key, value) in &self.context {
            context_map.insert(key.clone(), value.clone());
        }

        let resolved_url = resolve_with_context(&self.url, &context_map);
        let mut request = client.request(
            self.method.clone(),
            context::inject_from_prompt(&resolved_url),
        );

        // assemble a HeaderMap
        let mut header_map = HeaderMap::new();

        // if self.body_content_type is set, add it to the headers
        if let Some(content_type) = &self.body_content_type {
            let resolved = resolve_with_context(content_type, &context_map);
            header_map.insert(
                reqwest::header::CONTENT_TYPE,
                context::inject_from_prompt(&resolved)
                    .parse::<reqwest::header::HeaderValue>()
                    .unwrap(),
            );
        }

        for (key, value) in &self.headers {
            let resolved_value = resolve_with_context(value, &context_map);
            header_map.insert(
                key.parse::<reqwest::header::HeaderName>()
                    .expect(format!("Invalid header name: {}", key).as_str()),
                context::inject_from_prompt(&resolved_value)
                    .parse::<reqwest::header::HeaderValue>()
                    .unwrap(),
            );
        }

        // assemble multipart form data
        let mut form = reqwest::multipart::Form::new();
        for (key, value) in &self.form_data {
            // form = form.text(key.clone(), value.clone());
            match value {
                FormDataType::Text(text) => {
                    let resolved = resolve_with_context(text, &context_map);
                    form = form.text(key.clone(), context::inject_from_prompt(&resolved));
                }
                FormDataType::File(filepath) => {
                    let file = reqwest::multipart::Part::bytes(std::fs::read(filepath)?)
                        .file_name(filepath.file_name().unwrap().to_str().unwrap().to_string());
                    form = form.part(key.clone(), file);
                }
            }
        }

        // inject from prompt for query params
        let mut query_params: HashMap<String, String> = HashMap::new();
        self.query_params.iter().for_each(|(key, value)| {
            let resolved = resolve_with_context(value, &context_map);
            query_params.insert(key.clone(), context::inject_from_prompt(&resolved));
        });

        request = match self.form_data.len() > 0 {
            true => request
                .headers(header_map)
                .query(&query_params)
                .multipart(form),
            false => request.headers(header_map).query(&query_params),
        };

        if let Some(body) = &self.body {
            let resolved = resolve_with_context(body, &context_map);
            request = request.body(context::inject_from_prompt(&resolved));
        }

        log::debug!("REQUEST\n{:#?}", request);

        let resp = request.send().await?;

        log::debug!("RESPONSE\n{:#?}", resp);

        let status = resp.status();
        let headers = resp.headers().clone();
        let resp_text = resp.text().await.unwrap();
        let json_body = serde_json::from_str::<Value>(&resp_text).ok();

        let sanitized_response = resp_text.replace('\0', "");
        let snapshot = ResponseSnapshot {
            status,
            headers: headers.clone(),
            body: sanitized_response.clone(),
            json: json_body.clone(),
        };

        let mut captured_values: HashMap<String, String> = HashMap::new();

        for capture in &self.response_captures {
            let source_snapshot = match &capture.source {
                response_capture::CaptureSource::Current => &snapshot,
                response_capture::CaptureSource::Dependency(name) => {
                    dependency_snapshots.get(name).ok_or_else(|| {
                        Box::<dyn std::error::Error>::from(CaptureDependencyError {
                            dependency: name.clone(),
                            request: self.description.clone(),
                        })
                    })?
                }
            };

            match capture.extract_from_snapshot(source_snapshot) {
                Ok(Some((name, value))) => {
                    captured_values.insert(name, value);
                }
                Ok(None) => {}
                Err(e) => return Err(Box::new(e)),
            }
        }

        if !captured_values.is_empty() {
            log::debug!("CAPTURED VALUES\n{:#?}", captured_values);
        }

        let status_code = status.as_str().to_string();
        let mut export_env: HashMap<String, String> = context_map.clone();
        export_env.insert("RESPONSE".to_string(), sanitized_response.clone());
        export_env.insert("STATUS".to_string(), status_code.clone());
        if let Some(reason) = status.canonical_reason() {
            export_env.insert("STATUS_TEXT".to_string(), reason.to_string());
        }
        export_env.insert("DESCRIPTION".to_string(), self.description.clone());

        for (key, value) in &captured_values {
            export_env.insert(key.clone(), value.replace('\0', ""));
        }

        for assertion in &self.assertions {
            assertion
                .evaluate(&export_env, &snapshot, &dependency_snapshots)
                .map_err(|e| -> Box<dyn std::error::Error> { Box::new(e) })?;
        }

        // execute the callback if it is set
        let mut callback_resp: Vec<String> = vec![];

        if self.callback_src.len() > 0 {
            // for each source in the callback_src, evaluate it
            for src in &self.callback_src {
                callback_resp.push(parser::eval_shell_script(
                    src,
                    &self.working_dir,
                    Some(export_env.clone()),
                ));
            }

            log::debug!("CALLBACK RESPONSE\n{:#?}", callback_resp);
        }

        let output = if callback_resp.len() > 0 {
            callback_resp.join("\n")
        } else {
            resp_text
        };

        Ok(RequestExecution {
            output,
            export_env,
            snapshot,
        })
    }

    pub fn as_curl(&self) -> String {
        let mut curl = String::new();

        // collect the headers
        let headers = self
            .headers
            .iter()
            .map(|(key, value)| format!("-H '{}: {}'", key, value))
            .collect::<Vec<String>>()
            .join(" ");

        // if self.body_content_type is set, add it as a header
        let headers = match &self.body_content_type {
            Some(content_type) => format!("-H 'Content-Type: {}' {}", content_type, headers),
            None => headers,
        };

        // collect the query params
        let query_params = self
            .query_params
            .iter()
            .map(|(key, value)| format!("{}={}", key, value))
            .collect::<Vec<String>>()
            .join("&");

        // append the '?' to the query params if it is not empty
        let query_params = match query_params.len() {
            0 => "".to_string(),
            _ => format!("?{}", query_params),
        };

        // collect the form data
        let form_data = self
            .form_data
            .iter()
            .map(|(key, value)| match value {
                FormDataType::Text(text) => format!("-F '{}={}'", key, text),
                FormDataType::File(filepath) => format!("-F '{}=@{}'", key, filepath.display()),
            })
            .collect::<Vec<String>>()
            .join(" ");

        // collect the body
        let body = match &self.body {
            Some(body) => format!("-d ' {}'", body),
            None => "".to_string(),
        };

        // assemble the curl command
        curl.push_str(&format!(
            "curl -X {} '{}{}' {} {} {}",
            self.method, self.url, query_params, headers, form_data, body
        ));
        curl
    }
}

#[derive(Debug)]
struct CaptureDependencyError {
    dependency: String,
    request: String,
}

impl std::fmt::Display for CaptureDependencyError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(
            f,
            "Request '{}' references dependency '{}' in a capture but it has not executed yet.",
            self.request, self.dependency
        )
    }
}

impl std::error::Error for CaptureDependencyError {}

fn resolve_with_context(input: &str, context: &HashMap<String, String>) -> String {
    let mut current = context::inject_from_variable(input, context);
    loop {
        let next = context::inject_from_variable(current.as_str(), context);
        if next == current {
            return current;
        }
        current = next;
    }
}