kodumaro-http-cli 0.5.5

Kodumaro HTTP CLI inspired by HTTPie
Documentation
use std::path::PathBuf;

use base64::engine::general_purpose;
use base64::Engine;
use clap::{ArgAction, Parser};
use chrono::prelude::*;
use eyre::eyre;
use mime_guess::from_ext;
use reqwest::header;
use reqwest::header::HeaderValue;
use reqwest::Url;

use crate::cli::utils::extension_from_mime;
use crate::request::{HttpRequest, Payload, Verb};
use crate::user_agent::DEFAULT_USER_AGENT;

use super::cli_bool::CliBool;
use super::param::Param;
use super::utils;

#[derive(Debug, Parser)]
#[command(arg_required_else_help = true)]
#[command(about, author, name = "http", version)]
#[command(long_about = "https://codeberg.org/cacilhas/microcli/src/branch/master/http")]
pub struct Cli {
    /// HTTP method
    #[arg(value_enum)]
    verb: Verb,

    /// URL to be requested
    #[arg()]
    url: Url,

    /// header:value, querystring==value, and/or payload=value,
    /// or @file-name;
    /// `!!str value` means string;
    /// anything else is interpreted as raw payload
    #[arg()]
    params: Vec<Param>,

    /// force overwriting existing files
    #[arg(short, long, action = ArgAction::SetTrue, env = "HTTP_FORCE")]
    force: CliBool,

    /// download response body to file instead of stdout
    #[arg(short, long, action = ArgAction::SetTrue)]
    download: bool,

    /// save output to file instead of stdout (implies --download)
    #[arg(short, long)]
    output: Option<String>,

    /// basic authentication (user[:password]) or bearer token;
    /// starting with `!!basic ` forces to use as basic authentication,
    /// and `!!bearer ` forces bearer token
    #[arg(short, long, env = "HTTP_AUTH")]
    auth: Option<String>,

    /// weak e-Tag
    #[arg(long)]
    etag: Option<String>,

    /// follows Location redirects
    #[arg(short = 'F', long, action = ArgAction::SetTrue, env = "HTTP_FOLLOW")]
    follow: CliBool,

    /// when following redirects, max redirects
    #[arg(short, long, default_value_t = 30, env = "HTTP_MAX_REDIRECTS")]
    max_redirects: usize,

    /// set to “no” or “false” to skip checking SSL certificate
    #[arg(long, default_value_t = CliBool::Yes, env = "HTTP_VERIFY", conflicts_with = "no_verify")]
    verify: CliBool,

    /// disable SSL certificate verification
    /// (equivalent to --verify=no)
    #[arg(short = 'X', long, action = ArgAction::SetTrue)]
    no_verify: bool,

    /// fail on error status code
    #[arg(long, action = ArgAction::SetTrue, env = "HTTP_FAIL")]
    fail: CliBool,

    /// Show protocol details
    #[arg(short, long, action = ArgAction::SetTrue, env = "HTTP_VERBOSE")]
    verbose: CliBool,

    /// dry run, do not send request (implies --verbose)
    #[arg(long, action = ArgAction::SetTrue)]
    dry_run: bool,
}

impl TryFrom<Cli> for HttpRequest {

    type Error = eyre::Error;

    fn try_from(cli: Cli) -> Result<Self, Self::Error> {
        let mut request = HttpRequest::default();
        request.verb = cli.verb;
        request.url = cli.url.clone();
        let mut connection_set = false;
        let mut content_length_set = false;
        let mut accept = None;
        let mut content_type = None;
        let mut user_agent_set = false;
        for param in &cli.params {
            if let Param::Header { name, content } = param {
                request.headers.push((name.to_owned(), content.to_owned()));
                match *name {
                    header::USER_AGENT => user_agent_set = true,
                    header::CONNECTION => connection_set = true,
                    header::CONTENT_LENGTH => content_length_set = true,
                    header::ACCEPT => accept = content.to_str()
                        .map(|s| s.to_string())
                        .ok(),
                    header::CONTENT_TYPE => content_type = content.to_str()
                        .map(|s| s.to_string())
                        .ok(),
                    _ => (),
                }
            }
            if let Param::Query { key, value } = param {
                request.url.query_pairs_mut().append_pair(key, value);
            }
        }
        if !connection_set {
            request.headers.push((
                header::CONNECTION,
                HeaderValue::from_static("close"),
            ));
        }
        if !user_agent_set {
            request.headers.push((
                header::USER_AGENT,
                HeaderValue::from_str(DEFAULT_USER_AGENT.as_str())?,
            ));
        }
        if let Some(auth) = cli.auth {
            request.headers.push((
                header::AUTHORIZATION,
                HeaderValue::from_str(&convert_to_authorization(&auth))?,
            ));
        }
        if let Some(etag) = cli.etag {
            request.headers.push((
                header::IF_NONE_MATCH,
                HeaderValue::from_str(&format!("W/\"{}\"", etag))?,
            ));
        }

        if content_type.is_none() {
            for param in &cli.params {
                match param {
                    Param::Pair { .. } => {
                        content_type = Some("application/json".to_string());
                        break;
                    }
                    Param::FileUpload(filename) => {
                        if let Some(ext) = utils::extension_from_filename(filename) {
                            let ext = match ext.strip_prefix('.') {
                                Some(ext) => ext.to_string(),
                                None => ext,
                            };
                            content_type = Some(
                                from_ext(&ext)
                                    .first_or_octet_stream()
                                    .to_string()
                            );

                        } else {
                            content_type = Some("text/plain".to_string());
                        }
                        break;
                    }
                    _ => (),
                }
            }
            if let Some(ref content_type) = content_type {
                request.headers.push((
                    header::CONTENT_TYPE,
                    HeaderValue::from_str(content_type)?,
                ));
            }
        }

        request.output = cli.output;
        if request.output.is_none() && cli.download {
            request.output = PathBuf::from(request.url.path())
                .file_name()
                .map(|name| name.to_string_lossy().into_owned());

            if request.output.is_none() {
                let now = Local::now();
                let filename = now.format("http-%Y-%m-%d-%H%M%S")
                    .to_string();
                match accept {
                    Some(ref accept) => {
                        let ext = extension_from_mime(accept);
                        request.output = Some(format!("{}.{}", filename, ext));
                    }
                    None => {
                        request.output = Some(filename);
                    }
                }
            }
        }

        if let Some(output) = &request.output {
            let force: bool = cli.force.into();
            if !force && PathBuf::from(output).exists() {
                return Err(eyre!("File already exists: {}", output));
            }
        }

        request.follow = cli.follow.into();
        request.max_redirects = cli.max_redirects;
        request.verify = if cli.no_verify {
            false
        } else {
            cli.verify.into()
        };
        request.fail = cli.fail.into();
        request.verbose = cli.verbose.into();
        request.payload = cli.params.try_into()?;
        request.verbose = cli.verbose.into() || cli.dry_run;
        request.dry_run = cli.dry_run;

        if !content_length_set {
            match &request.payload {
                Payload::Content(content) => request.headers.push((
                    header::CONTENT_LENGTH,
                    HeaderValue::from_str(&content.len().to_string())?,
                )),
                Payload::FileUpload(filename) => {
                    let metadata = std::fs::metadata(filename)?;
                    request.headers.push((
                        header::CONTENT_LENGTH,
                        HeaderValue::from_str(&metadata.len().to_string())?,
                    ));
                }
                _ => (),
            }
        }

        Ok(request)
    }
}

fn convert_to_authorization(auth: &str) -> String {
    if let Some(auth) = auth.strip_prefix("!!basic ") {
        let auth = general_purpose::STANDARD.encode(auth.as_bytes());
        format!("Basic {}", auth)

    } else if let Some(auth) = auth.strip_prefix("!!bearer ") {
        format!("Bearer {}", auth)

    } else if auth.contains(":") {
        let auth = general_purpose::STANDARD.encode(auth.as_bytes());
        format!("Basic {}", auth)

    } else {
        format!("Bearer {}", auth)
    }
}

#[cfg(test)]
mod tests {

    use super::*;

    #[test]
    fn test_forced_basic_authorization() {
        let auth = convert_to_authorization("!!basic some random value");
        assert_eq!("Basic c29tZSByYW5kb20gdmFsdWU=", auth);
    }

    #[test]
    fn test_force_bearer_token() {
        let auth = convert_to_authorization("!!bearer user:pass");
        assert_eq!("Bearer user:pass", auth);
    }

    #[test]
    fn test_basic_authorization() {
        let auth = convert_to_authorization("user:pass");
        assert_eq!("Basic dXNlcjpwYXNz", auth);
    }

    #[test]
    fn test_bearer_token() {
        let auth = convert_to_authorization("ONXW2ZJAOJQW4ZDPNUQHMYLMOVSQ");
        assert_eq!("Bearer ONXW2ZJAOJQW4ZDPNUQHMYLMOVSQ", auth);
    }
}