kodumaro_http_cli/cli/
cli.rs

1use std::path::PathBuf;
2
3use base64::engine::general_purpose;
4use base64::Engine;
5use clap::{ArgAction, Parser};
6use chrono::prelude::*;
7use eyre::eyre;
8use mime_guess::from_ext;
9use reqwest::header;
10use reqwest::header::HeaderValue;
11use reqwest::Url;
12
13use crate::cli::utils::extension_from_mime;
14use crate::request::{HttpRequest, Payload, Verb};
15use crate::user_agent::DEFAULT_USER_AGENT;
16
17use super::cli_bool::CliBool;
18use super::param::Param;
19use super::utils;
20
21#[derive(Debug, Parser)]
22#[command(arg_required_else_help = true)]
23#[command(about, author, name = "http", version)]
24#[command(long_about = "https://codeberg.org/cacilhas/microcli/src/branch/master/http")]
25pub struct Cli {
26    /// HTTP method
27    #[arg(value_enum)]
28    verb: Verb,
29
30    /// URL to be requested
31    #[arg()]
32    url: Url,
33
34    /// header:value, querystring==value, and/or payload=value,
35    /// or @file-name;
36    /// `!!str value` means string;
37    /// anything else is interpreted as raw payload
38    #[arg()]
39    params: Vec<Param>,
40
41    /// force overwriting existing files
42    #[arg(short, long, action = ArgAction::SetTrue, env = "HTTP_FORCE")]
43    force: CliBool,
44
45    /// download response body to file instead of stdout
46    #[arg(short, long, action = ArgAction::SetTrue)]
47    download: bool,
48
49    /// save output to file instead of stdout (implies --download)
50    #[arg(short, long)]
51    output: Option<String>,
52
53    /// basic authentication (user[:password]) or bearer token;
54    /// starting with `!!basic ` forces to use as basic authentication,
55    /// and `!!bearer ` forces bearer token
56    #[arg(short, long, env = "HTTP_AUTH")]
57    auth: Option<String>,
58
59    /// weak e-Tag
60    #[arg(long)]
61    etag: Option<String>,
62
63    /// follows Location redirects
64    #[arg(short = 'F', long, action = ArgAction::SetTrue, env = "HTTP_FOLLOW")]
65    follow: CliBool,
66
67    /// when following redirects, max redirects
68    #[arg(short, long, default_value_t = 30, env = "HTTP_MAX_REDIRECTS")]
69    max_redirects: usize,
70
71    /// set to “no” or “false” to skip checking SSL certificate
72    #[arg(long, default_value_t = CliBool::Yes, env = "HTTP_VERIFY", conflicts_with = "no_verify")]
73    verify: CliBool,
74
75    /// disable SSL certificate verification
76    /// (equivalent to --verify=no)
77    #[arg(short = 'X', long, action = ArgAction::SetTrue)]
78    no_verify: bool,
79
80    /// fail on error status code
81    #[arg(long, action = ArgAction::SetTrue, env = "HTTP_FAIL")]
82    fail: CliBool,
83
84    /// Show protocol details
85    #[arg(short, long, action = ArgAction::SetTrue, env = "HTTP_VERBOSE")]
86    verbose: CliBool,
87
88    /// dry run, do not send request (implies --verbose)
89    #[arg(long, action = ArgAction::SetTrue)]
90    dry_run: bool,
91}
92
93impl TryFrom<Cli> for HttpRequest {
94
95    type Error = eyre::Error;
96
97    fn try_from(cli: Cli) -> Result<Self, Self::Error> {
98        let mut request = HttpRequest::default();
99        request.verb = cli.verb;
100        request.url = cli.url.clone();
101        let mut connection_set = false;
102        let mut content_length_set = false;
103        let mut accept = None;
104        let mut content_type = None;
105        let mut user_agent_set = false;
106        for param in &cli.params {
107            if let Param::Header { name, content } = param {
108                request.headers.push((name.to_owned(), content.to_owned()));
109                match *name {
110                    header::USER_AGENT => user_agent_set = true,
111                    header::CONNECTION => connection_set = true,
112                    header::CONTENT_LENGTH => content_length_set = true,
113                    header::ACCEPT => accept = content.to_str()
114                        .map(|s| s.to_string())
115                        .ok(),
116                    header::CONTENT_TYPE => content_type = content.to_str()
117                        .map(|s| s.to_string())
118                        .ok(),
119                    _ => (),
120                }
121            }
122            if let Param::Query { key, value } = param {
123                request.url.query_pairs_mut().append_pair(key, value);
124            }
125        }
126        if !connection_set {
127            request.headers.push((
128                header::CONNECTION,
129                HeaderValue::from_static("close"),
130            ));
131        }
132        if !user_agent_set {
133            request.headers.push((
134                header::USER_AGENT,
135                HeaderValue::from_str(DEFAULT_USER_AGENT.as_str())?,
136            ));
137        }
138        if let Some(auth) = cli.auth {
139            request.headers.push((
140                header::AUTHORIZATION,
141                HeaderValue::from_str(&convert_to_authorization(&auth))?,
142            ));
143        }
144        if let Some(etag) = cli.etag {
145            request.headers.push((
146                header::IF_NONE_MATCH,
147                HeaderValue::from_str(&format!("W/\"{}\"", etag))?,
148            ));
149        }
150
151        if content_type.is_none() {
152            for param in &cli.params {
153                match param {
154                    Param::Pair { .. } => {
155                        content_type = Some("application/json".to_string());
156                        break;
157                    }
158                    Param::FileUpload(filename) => {
159                        if let Some(ext) = utils::extension_from_filename(filename) {
160                            let ext = match ext.strip_prefix('.') {
161                                Some(ext) => ext.to_string(),
162                                None => ext,
163                            };
164                            content_type = Some(
165                                from_ext(&ext)
166                                    .first_or_octet_stream()
167                                    .to_string()
168                            );
169
170                        } else {
171                            content_type = Some("text/plain".to_string());
172                        }
173                        break;
174                    }
175                    _ => (),
176                }
177            }
178            if let Some(ref content_type) = content_type {
179                request.headers.push((
180                    header::CONTENT_TYPE,
181                    HeaderValue::from_str(content_type)?,
182                ));
183            }
184        }
185
186        request.output = cli.output;
187        if request.output.is_none() && cli.download {
188            request.output = PathBuf::from(request.url.path())
189                .file_name()
190                .map(|name| name.to_string_lossy().into_owned());
191
192            if request.output.is_none() {
193                let now = Local::now();
194                let filename = now.format("http-%Y-%m-%d-%H%M%S")
195                    .to_string();
196                match accept {
197                    Some(ref accept) => {
198                        let ext = extension_from_mime(accept);
199                        request.output = Some(format!("{}.{}", filename, ext));
200                    }
201                    None => {
202                        request.output = Some(filename);
203                    }
204                }
205            }
206        }
207
208        if let Some(output) = &request.output {
209            let force: bool = cli.force.into();
210            if !force && PathBuf::from(output).exists() {
211                return Err(eyre!("File already exists: {}", output));
212            }
213        }
214
215        request.follow = cli.follow.into();
216        request.max_redirects = cli.max_redirects;
217        request.verify = if cli.no_verify {
218            false
219        } else {
220            cli.verify.into()
221        };
222        request.fail = cli.fail.into();
223        request.verbose = cli.verbose.into();
224        request.payload = cli.params.try_into()?;
225        request.verbose = cli.verbose.into() || cli.dry_run;
226        request.dry_run = cli.dry_run;
227
228        if !content_length_set {
229            match &request.payload {
230                Payload::Content(content) => request.headers.push((
231                    header::CONTENT_LENGTH,
232                    HeaderValue::from_str(&content.len().to_string())?,
233                )),
234                Payload::FileUpload(filename) => {
235                    let metadata = std::fs::metadata(filename)?;
236                    request.headers.push((
237                        header::CONTENT_LENGTH,
238                        HeaderValue::from_str(&metadata.len().to_string())?,
239                    ));
240                }
241                _ => (),
242            }
243        }
244
245        Ok(request)
246    }
247}
248
249fn convert_to_authorization(auth: &str) -> String {
250    if let Some(auth) = auth.strip_prefix("!!basic ") {
251        let auth = general_purpose::STANDARD.encode(auth.as_bytes());
252        format!("Basic {}", auth)
253
254    } else if let Some(auth) = auth.strip_prefix("!!bearer ") {
255        format!("Bearer {}", auth)
256
257    } else if auth.contains(":") {
258        let auth = general_purpose::STANDARD.encode(auth.as_bytes());
259        format!("Basic {}", auth)
260
261    } else {
262        format!("Bearer {}", auth)
263    }
264}
265
266#[cfg(test)]
267mod tests {
268
269    use super::*;
270
271    #[test]
272    fn test_forced_basic_authorization() {
273        let auth = convert_to_authorization("!!basic some random value");
274        assert_eq!("Basic c29tZSByYW5kb20gdmFsdWU=", auth);
275    }
276
277    #[test]
278    fn test_force_bearer_token() {
279        let auth = convert_to_authorization("!!bearer user:pass");
280        assert_eq!("Bearer user:pass", auth);
281    }
282
283    #[test]
284    fn test_basic_authorization() {
285        let auth = convert_to_authorization("user:pass");
286        assert_eq!("Basic dXNlcjpwYXNz", auth);
287    }
288
289    #[test]
290    fn test_bearer_token() {
291        let auth = convert_to_authorization("ONXW2ZJAOJQW4ZDPNUQHMYLMOVSQ");
292        assert_eq!("Bearer ONXW2ZJAOJQW4ZDPNUQHMYLMOVSQ", auth);
293    }
294}