kodumaro_http_cli/cli/
mod.rs

1mod cli_bool;
2mod param;
3pub mod util;
4
5use std::{
6    collections::HashMap,
7    env::consts,
8    path::Path,
9    str::FromStr,
10    sync::LazyLock,
11};
12
13use base64::{engine, Engine};
14use clap::{ArgAction, Args, Parser, Subcommand};
15use cli_bool::CliBool;
16use eyre::eyre;
17pub use param::Param;
18use reqwest::{
19    header::{HeaderName, HeaderValue},
20    redirect::Policy,
21    Method,
22    Request,
23    Url,
24};
25use serde_json::Value;
26use util::parse_string;
27
28static DEFAULT_USER_AGENT: LazyLock<String> = LazyLock::new(||
29    format!(
30        "Mozilla/5.0 ({} {}) AppleWebKit/537.36 (KHTML like Gecko) {}/{} Chrome/129.0.0.0 Safari/537.36",
31        consts::OS,
32        consts::ARCH,
33        env!("CARGO_PKG_NAME"),
34        env!("CARGO_PKG_VERSION"),
35    )
36);
37
38
39pub trait CLParameters {
40
41    fn output(&self) -> Option<String>;
42    fn payload(&self) -> Result<Value, Option<eyre::ErrReport>>;
43    fn policy(&self) -> Policy;
44    fn request(&self) -> eyre::Result<Request>;
45    fn url(&self) -> &Url;
46    fn verify(&self) -> bool;
47    fn verbose(&self) -> bool;
48    fn fail(&self) -> bool;
49}
50
51
52#[derive(Debug, Parser)]
53#[command(about, author, name = "http", version)]
54pub struct Cli {
55
56    #[command(subcommand)]
57    verb: Verb,
58
59    #[arg(skip = reqwest::Url::parse("http://localhost/").unwrap())]
60    url: Url,
61}
62
63#[derive(Args, Debug)]
64struct VerbArgs {
65
66    /// the URL to connect to
67    #[arg()]
68    url: Url,
69
70    /// header:value, querystring==value, and/or payload=value; @value means value from file content, str!value force value to be string
71    #[arg()]
72    params: Vec<Param>,
73
74    // /// data items from the command line are serialized as a JSON object
75    // #[arg(short, long, action = ArgAction::SetTrue, default_value_t = true)]
76    // pub json: bool,
77
78    // /// data items from the command line are serialized as form fields
79    // #[arg(short, long, action = ArgAction::SetTrue)]
80    // pub form: bool,
81
82    /// allows you to pass raw request data without extra processing
83    #[arg(long)]
84    raw: Option<String>,
85
86    /// save output to file instead of stdout [default: URL path file name]
87    #[arg(short, long)]
88    output: Option<String>,
89
90    /// do not print the response body to stdout; rather, download it and store it in a file
91    #[arg(short, long, env = "HTTP_DOWNLOAD")]
92    download: bool,
93
94    // TODO: support --continue (-c)
95    // TODO: support --session
96
97    /// basic authentication (user[:password]) or bearer token
98    #[arg(short, long, env = "HTTP_AUTH")]
99    auth: Option<String>,
100
101    /// follows Location redirects
102    #[arg(short = 'F', long, action = ArgAction::SetTrue, env = "HTTP_FOLLOW")]
103    follow: bool,
104
105    /// when following redirects, max redirects
106    #[arg(long, default_value_t = 30, env = "HTTP_MAX_REDIRECTS")]
107    max_redirects: usize,
108
109    /// set to "no" to skip checking the host's SSL certificate
110    #[arg(long, default_value_t = CliBool::Yes, env = "HTTP_VERIFY")]
111    verify: CliBool,
112
113    /// fail on error status code
114    #[arg(long, action = ArgAction::SetTrue, env = "HTTP_FAIL")]
115    fail: CliBool,
116
117    /// Show headers
118    #[arg(short, long, action = ArgAction::SetTrue, env = "HTTP_VERBOSE")]
119    verbose: CliBool,
120}
121
122#[derive(Debug, Subcommand)]
123enum Verb {
124    /// performs a CONNECT request
125    #[command(aliases = ["Connect", "CONNECT"])]
126    Connect(VerbArgs),
127    /// performs a DELETE request
128    #[command(aliases = ["Delete", "DELETE"])]
129    Delete(VerbArgs),
130    /// performs a GET request
131    #[command(aliases = ["Get", "GET"])]
132    Get(VerbArgs),
133    /// performs a HEAD request
134    #[command(aliases = ["Head", "HEAD"])]
135    Head(VerbArgs),
136    /// performs a OPTION request
137    #[command(aliases = ["Option", "OPTION"])]
138    Option(VerbArgs),
139    /// performs a PATCH request
140    #[command(aliases = ["Patch", "PATCH"])]
141    Patch(VerbArgs),
142    /// performs a POST request
143    #[command(aliases = ["Post", "POST"])]
144    Post(VerbArgs),
145    /// performs a PUT request
146    #[command(aliases = ["Put", "PUT"])]
147    Put(VerbArgs),
148    /// performs a TRACE request
149    #[command(aliases = ["Trace", "TRACE"])]
150    Trace(VerbArgs),
151}
152
153impl CLParameters for Cli {
154
155    fn output(&self) -> Option<String> {
156        let args = self.verb.args();
157        match args.output.clone() {
158            Some(output) => Some(output),
159            None => {
160                if args.download {
161                    let path = Path::new(self.url().path());
162                    path.file_name().map(|path| path.to_string_lossy().to_string())
163                } else {
164                    None
165                }
166            }
167        }
168    }
169
170    fn payload(&self) -> Result<Value, Option<eyre::ErrReport>> {
171        let args = self.verb.args();
172
173        if let Some(raw) = &args.raw {
174            return Ok(Value::String(parse_string(raw.to_string())?));
175        }
176
177        let mut payload: Option<Value> = None;
178        for param in args.params.iter() {
179            if let Param::Payload(param) = param {
180                match payload {
181                    None => drop(payload.insert(param.clone())),
182
183                    Some(Value::Object(ref mut payload)) =>
184                        match param {
185                            Value::Object(param) =>
186                                {
187                                    payload.extend(
188                                        param.iter()
189                                            .map(|(k, v)| (k.to_owned(), v.clone()))
190                                    );
191                                },
192                            _ => return Err(Some(eyre!("invalid payload"))),
193                        },
194
195                    Some(_) => return Err(Some(eyre!("invalid payload"))),
196                }
197            }
198        }
199
200        match payload {
201            Some(payload) => Ok(payload),
202            None => Err(None),
203        }
204    }
205
206    #[inline]
207    #[must_use]
208    fn policy(&self) -> Policy {
209        self.into()
210    }
211
212    #[inline]
213    fn request(&self) -> eyre::Result<Request> {
214        self.try_into()
215    }
216
217    #[inline]
218    #[must_use]
219    fn url(&self) -> &Url {
220        &self.url
221    }
222
223    #[inline]
224    #[must_use]
225    fn verify(&self) -> bool {
226        self.verb.args().verify.into()
227    }
228
229    #[inline]
230    #[must_use]
231    fn verbose(&self) -> bool {
232        self.verb.args().verbose.into()
233    }
234
235    #[inline]
236    #[must_use]
237    fn fail(&self) -> bool {
238        self.verb.args().fail.into()
239    }
240}
241
242impl Cli {
243
244    fn headers(&self) -> HashMap<String, String> {
245
246        let args = self.verb.args();
247        let mut headers: HashMap<String, String> = HashMap::new();
248        headers.insert(reqwest::header::CONNECTION.to_string(), "close".to_string());
249        headers.insert(reqwest::header::USER_AGENT.to_string(), DEFAULT_USER_AGENT.to_owned());
250
251        for param in args.params.iter() {
252            if let Param::Header(name, value) = param {
253                let entry = headers.entry(name.to_lowercase()).or_default();
254                *entry = value.to_owned();
255            }
256        }
257
258        headers
259    }
260
261    fn build_request(&self) -> eyre::Result<Request> {
262        let method: Method = (&self.verb).into();
263        let headers = self.headers();
264        let mut request = Request::new(method, self.url().clone());
265        for (name, value) in headers.iter() {
266            let _ = request.headers_mut().insert(
267                HeaderName::from_str(name)?,
268                HeaderValue::from_str(value)?,
269            );
270        }
271        Ok(self.set_authorization(request))
272    }
273
274    fn set_authorization(&self, mut request: Request) -> Request {
275        if let Some(auth) = &self.verb.args().auth {
276            if let Some((username, password)) = auth.split_once(':') {
277                let auth = format!("{}:{}", username, password);
278                let engine = engine::general_purpose::STANDARD;
279                let auth = engine.encode(auth.into_bytes());
280                let _ = request.headers_mut().insert(
281                    reqwest::header::AUTHORIZATION,
282                    HeaderValue::from_str(&format!("Basic {}", auth)).unwrap(),
283                );
284            } else {
285                let _ = request.headers_mut().insert(
286                    reqwest::header::AUTHORIZATION,
287                    HeaderValue::from_str(&format!("Bearer {}", auth)).unwrap(),
288                );
289            }
290        }
291        request
292    }
293
294    pub fn initialize(&mut self) -> eyre::Result<()> {
295        let args = self.verb.args();
296        let mut url = args.url.clone();
297
298        for param in args.params.iter() {
299            if let Param::Query(key, value) = param {
300                let _ = url.query_pairs_mut().append_pair(key, value);
301            }
302        }
303
304        self.url = url;
305
306        Ok(())
307    }
308}
309
310impl TryFrom<&Cli> for Request {
311
312    type Error = eyre::Error;
313
314    fn try_from(value: &Cli) -> Result<Self, Self::Error> {
315        value.build_request()
316    }
317}
318
319impl From<&Cli> for Policy {
320
321    fn from(value: &Cli) -> Self {
322        let args = value.verb.args();
323        if args.follow {
324            Policy::limited(args.max_redirects)
325        } else {
326            Policy::none()
327        }
328    }
329}
330
331impl Verb {
332
333    #[inline]
334    #[must_use]
335    fn args(&self) -> &VerbArgs {
336        match self {
337            Verb::Connect(args) => args,
338            Verb::Delete(args)  => args,
339            Verb::Get(args)     => args,
340            Verb::Head(args)    => args,
341            Verb::Option(args)  => args,
342            Verb::Patch(args)   => args,
343            Verb::Post(args)    => args,
344            Verb::Put(args)     => args,
345            Verb::Trace(args)   => args,
346        }
347    }
348}
349
350impl From<&Verb> for Method {
351
352    #[inline]
353    fn from(value: &Verb) -> Self {
354        match value {
355            Verb::Connect(_) => Method::CONNECT,
356            Verb::Delete(_)  => Method::DELETE,
357            Verb::Get(_)     => Method::GET,
358            Verb::Head(_)    => Method::HEAD,
359            Verb::Option(_)  => Method::OPTIONS,
360            Verb::Patch(_)   => Method::PATCH,
361            Verb::Post(_)    => Method::POST,
362            Verb::Put(_)     => Method::PUT,
363            Verb::Trace(_)   => Method::TRACE,
364        }
365    }
366}