http-diff 0.0.5

http-diff - CLI tool to verify consistency across web server versions. Ideal for large-scale refactors, sanity tests and maintaining data integrity across versions.
use anyhow::Result;
use serde::{Deserialize, Serialize};
use serde_json::{json, to_string_pretty, Value};
use std::{
    collections::HashMap,
    fs::File,
    io::{Read, Write},
    path::Path,
};
use url::Url;

use crate::http_diff::types::{
    AppError, HeaderValue, HeadersMap, HttpMethod, PathVariable,
    PathVariableValue, VariableGenerator, VariablesMap,
};

#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
pub struct EndpointConfiguration {
    pub endpoint: String,
    pub variables: Option<VariablesMap>,
    pub http_method: Option<HttpMethod>,
    pub headers: Option<HeadersMap>,
    pub body: Option<Value>,
    pub response_processor: Option<Vec<String>>,
    pub request_builder: Option<Vec<String>>,
}

fn default_concurrent_jobs() -> usize {
    20
}

#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
pub struct UrlWithOptionalHeaders {
    pub domain: Url,
    pub headers: Option<HeadersMap>,
}

#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
#[serde(untagged)]
pub enum DomainVariant {
    Url(Url),
    UrlWithHeaders(UrlWithOptionalHeaders),
}

#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
pub struct Configuration {
    pub domains: Vec<DomainVariant>,
    pub endpoints: Vec<EndpointConfiguration>,
    pub variables: Option<VariablesMap>,
    #[serde(default = "default_concurrent_jobs")]
    pub concurrent_jobs: usize,
}

impl Configuration {
    pub fn default() -> Configuration {
        let mut second_domain_headers = HashMap::new();

        second_domain_headers.insert(
            "cookie".to_owned(),
            HeaderValue::String("auth=test".to_owned()),
        );

        let mut health_headers = HashMap::new();

        health_headers.insert(
            "x-test".to_owned(),
            HeaderValue::String("true".to_owned()),
        );

        let mut user_variables: VariablesMap = HashMap::new();

        user_variables.insert(
            "userId".to_string(),
            PathVariable::MultipleValues(vec![
                PathVariableValue::Int(123),
                PathVariableValue::String("true".to_string()),
                PathVariableValue::Generator(VariableGenerator::UUID),
            ]),
        );

        user_variables.insert(
            "skip".to_string(),
            PathVariable::SingleValue(PathVariableValue::Generator(
                VariableGenerator::UUID,
            )),
        );

        let create_user_payload = json!({ "username": "test" });

        Self {
            domains: vec![
                DomainVariant::Url(
                    Url::parse("http://localhost:3000").unwrap(),
                ),
                DomainVariant::UrlWithHeaders(UrlWithOptionalHeaders {
                    domain: Url::parse("http://localhost:3001").unwrap(),
                    headers: Some(second_domain_headers),
                }),
            ],
            endpoints: vec![
                EndpointConfiguration {
                    endpoint: "/health".to_string(),
                    variables: None,
                    http_method: None,
                    headers: Some(health_headers),
                    body: None,
                    response_processor: None,
                    request_builder: Some(vec![
                        "python3".to_owned(),
                        "script.py".to_owned(),
                    ]),
                },
                EndpointConfiguration {
                    endpoint: "/api/v1/users/<userId>?skip=<skip>".to_string(),
                    variables: Some(user_variables),
                    http_method: Some(HttpMethod::GET),
                    headers: None,
                    body: None,
                    response_processor: Some(vec![
                        "jq".to_owned(),
                        "del(.headers.auth)".to_owned(),
                    ]),
                    request_builder: None,
                },
                EndpointConfiguration {
                    endpoint: "/api/v1/users".to_string(),
                    variables: None,
                    http_method: Some(HttpMethod::POST),
                    headers: None,
                    body: Some(create_user_payload),
                    response_processor: Some(vec![
                        "jq".to_owned(),
                        "del(.headers.auth, .body.id)".to_owned(),
                    ]),
                    request_builder: None,
                },
            ],
            variables: None,
            concurrent_jobs: default_concurrent_jobs(),
        }
    }

    pub fn validate(&self) -> Result<(), AppError> {
        if self.domains.len() < 2 {
            return Err(AppError::ValidationError(
                "Minimum 2 domains required".to_string(),
            ));
        }

        if self.endpoints.is_empty() {
            return Err(AppError::ValidationError(
                "No endpoints were specified".to_string(),
            ));
        }

        Ok(())
    }

    pub fn save(&self, file_path: &Path) -> Result<()> {
        let stringified_config = to_string_pretty(self)?;

        let mut file = File::create(&file_path)?;

        file.write_all(stringified_config.as_bytes())?;

        Ok(())
    }
}

pub fn load_config_from_file(
    file_path: &str,
) -> Result<Configuration, AppError> {
    let mut file = File::open(file_path)
        .map_err(|_| AppError::FileNotFound(file_path.to_string()))?;

    let mut buffer = String::new();

    file.read_to_string(&mut buffer)
        .map_err(|error| AppError::FailedToParseConfig(error.to_string()))?;

    let configuration: Configuration = serde_json::from_str(&buffer)
        .map_err(|error| AppError::FailedToParseConfig(error.to_string()))?;

    configuration.validate()?;

    Ok(configuration)
}

pub const APP_VERSION: &str = env!("CARGO_PKG_VERSION");
pub const APP_NAME: &str = env!("CARGO_PKG_NAME");