kodumaro-http-cli 0.5.5

Kodumaro HTTP CLI inspired by HTTPie
Documentation
use std::{fmt::Display, sync::LazyLock};

use clap::error::{Error, ErrorKind};
use eyre::eyre;
use regex::Regex;
use reqwest::header::{HeaderName, HeaderValue};
use serde_json::Value;

use crate::{cli::json_helper::{append_object, extend_object}, request::Payload};

static HEADER_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r#"^([\w-]+):(.*)$"#).unwrap());
static QUERY_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r#"^([^=:]+)==(.*)$"#).unwrap());
static PAIR_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r#"^([^=:]+)=(.*)$"#).unwrap());

#[derive(Clone, Debug, PartialEq)]
pub enum Param {
    Header {
        name: HeaderName,
        content: HeaderValue,
    },
    Raw(String),
    Pair {
        key: String,
        value: String,
    },
    FileUpload(String),
    Query {
        key: String,
        value: String,
    },
}

impl std::str::FromStr for Param {

    type Err = Error;

    fn from_str(value: &str) -> Result<Self, Self::Err> {
        if let Some(filename) = value.strip_prefix("@") {
            Ok(Self::FileUpload(filename.to_string()))

        } else if let Some(pair) = QUERY_REGEX.captures(value) {
            let key = pair.get(1)
                .ok_or(to_error(format!("invalid query {}", value)))?
                .as_str();
            let value = pair.get(2)
                .ok_or(to_error(format!("invalid query {}", value)))?
                .as_str();
            Ok(Self::Query{ key: key.to_owned(), value: value.to_string() })

        } else if let Some(pair) = PAIR_REGEX.captures(value) {
            let key = pair.get(1)
                .ok_or(to_error(format!("invalid attribute {}", value)))?
                .as_str()
                .to_string();
            let value = pair.get(2)
                .ok_or(to_error(format!("invalid attribute {}", value)))?
                .as_str()
                .to_string();
            Ok(Self::Pair { key, value })

        } else if let Some(pair) = HEADER_REGEX.captures(value) {
            let key = pair.get(1)
                .ok_or(to_error(format!("invalid header {}", value)))?
                .as_str()
                .trim();
            let value = pair.get(2)
                .ok_or(to_error(format!("invalid header {}", value)))?
                .as_str()
                .trim();
            Ok(Self::Header {
                name: HeaderName::from_str(key).map_err(to_error)?,
                content: HeaderValue::from_str(value).map_err(to_error)?,
            })

        } else {
            Ok(Self::Raw(value.to_string()))
        }
    }
}

impl TryFrom<Vec<Param>> for Payload {

    type Error = eyre::Error;

    fn try_from(value: Vec<Param>) -> Result<Self, Self::Error> {
        // By using file upload, it disable all parameters
        for param in &value {
            if let Param::FileUpload(filename) = param {
                return Ok(Self::FileUpload(filename.to_owned()));
            }
        }

        let attributes: Vec<(&str, &str)> = value.iter()
            .filter_map(|param| match param {
                Param::Pair { key, value } => Some((key.as_str(), value.as_str())),
                _ => None,
            })
            .collect();
        let raw_strings: Vec<&str> = value.iter()
            .filter_map(|param| match param {
                Param::Raw(content) => Some(content.as_str()),
                _ => None,
            })
            .collect();

        // If the only parameter is a raw string, use it as payload
        if attributes.is_empty() {
            if raw_strings.is_empty() {
                return Ok(Self::NoContent);
            }
            if raw_strings.len() == 1 {
                return Ok(Self::Content(raw_strings.first().unwrap().to_string()));
            }
        }

        // In any other case, build a JSON object
        let mut res: Value = serde_json::from_str("{}").unwrap();
        if let Some(res) = res.as_object_mut() {
            for &raw in &raw_strings {
                if let Ok(json) = serde_json::from_str::<Value>(raw) {
                    if let Some(data) = json.as_object() {
                        extend_object(res, &data);
                    } else {
                        return Err(eyre!("{:?} is not an object", raw));
                    }
                } else {
                    return Err(eyre!("{} is not an object", raw));
                }
            }
            for &(key, value) in &attributes {
                let value = serde_json::from_str::<Value>(value)
                    .unwrap_or_else(|_| Value::String(value.into()));
                append_object(res, key, value);
            }
        }

        Ok(Self::Content(serde_json::to_string(&res)?))
    }
}

fn to_error(exc: impl Display) -> Error {
    clap::Error::raw(ErrorKind::InvalidValue, exc)
}