quest_cli/
builder.rs

1use anyhow::Result;
2use reqwest::blocking::{Client, ClientBuilder, RequestBuilder, Response};
3use secrecy::ExposeSecret;
4
5use crate::cli::{
6    AuthOptions, BodyOptions, CompressionOptions, HeaderOptions, ParamOptions, ProxyOptions,
7    RedirectOptions, RequestOptions, TimeoutOptions, TlsOptions,
8};
9
10// Generic trait for applying options to builders
11pub trait ApplyOptions<T> {
12    fn apply(&self, builder: T) -> Result<T>;
13}
14
15// Wrapper for ClientBuilder with Quest-specific options
16#[derive(Debug)]
17pub struct QuestClientBuilder(ClientBuilder);
18
19impl QuestClientBuilder {
20    pub fn new() -> Self {
21        Self(ClientBuilder::new())
22    }
23
24    pub fn apply<O: ApplyOptions<ClientBuilder>>(mut self, options: &O) -> Result<Self> {
25        self.0 = options.apply(self.0)?;
26        Ok(self)
27    }
28
29    pub fn build(self) -> Result<Client> {
30        Ok(self.0.build()?)
31    }
32}
33
34impl Default for QuestClientBuilder {
35    fn default() -> Self {
36        Self::new()
37    }
38}
39
40// Wrapper for RequestBuilder with Quest-specific options
41#[derive(Debug)]
42pub struct QuestRequestBuilder(RequestBuilder);
43
44impl QuestRequestBuilder {
45    pub fn from_request(inner: RequestBuilder) -> Self {
46        Self(inner)
47    }
48
49    pub fn apply<O: ApplyOptions<RequestBuilder>>(mut self, options: &O) -> Result<Self> {
50        self.0 = options.apply(self.0)?;
51        Ok(self)
52    }
53
54    pub fn send(self) -> Result<Response> {
55        Ok(self.0.send()?)
56    }
57}
58
59impl ApplyOptions<RequestBuilder> for AuthOptions {
60    fn apply(&self, mut builder: RequestBuilder) -> Result<RequestBuilder> {
61        // Handle --auth (user:pass format)
62        if let Some(auth) = &self.auth {
63            let auth_str = auth.expose_secret();
64            let (user, pass) = auth_str.split_once(':')
65                .ok_or_else(|| anyhow::anyhow!(
66                    "Invalid auth format. Expected format: 'username:password' (must contain a colon)"
67                ))?;
68
69            if user.is_empty() {
70                anyhow::bail!("Invalid auth format. Username cannot be empty");
71            }
72
73            builder = builder.basic_auth(user, Some(pass));
74        }
75
76        // Handle --basic (user:pass format)
77        if let Some(basic) = &self.basic {
78            let basic_str = basic.expose_secret();
79            let (user, pass) = basic_str.split_once(':')
80                .ok_or_else(|| anyhow::anyhow!(
81                    "Invalid basic auth format. Expected format: 'username:password' (must contain a colon)"
82                ))?;
83
84            if user.is_empty() {
85                anyhow::bail!("Invalid basic auth format. Username cannot be empty");
86            }
87
88            builder = builder.basic_auth(user, Some(pass));
89        }
90
91        // Handle --bearer
92        if let Some(bearer) = &self.bearer {
93            builder = builder.bearer_auth(bearer.expose_secret());
94        }
95
96        Ok(builder)
97    }
98}
99
100impl ApplyOptions<RequestBuilder> for HeaderOptions {
101    fn apply(&self, mut builder: RequestBuilder) -> Result<RequestBuilder> {
102        // Add custom headers
103        for header in &self.header {
104            let (key, value) = header.split_once(':')
105                .ok_or_else(|| anyhow::anyhow!(
106                    "Invalid header format: '{}'. Expected format: 'Key: Value' (must contain a colon)",
107                    header
108                ))?;
109
110            let key = key.trim();
111            let value = value.trim();
112
113            if key.is_empty() {
114                anyhow::bail!("Invalid header: '{}'. Header name cannot be empty", header);
115            }
116
117            builder = builder.header(key, value);
118        }
119
120        // Add specific headers
121        let user_agent = self.user_agent.as_deref().unwrap_or("quest/0.1.0");
122        builder = builder.header("User-Agent", user_agent);
123        if let Some(referer) = &self.referer {
124            builder = builder.header("Referer", referer);
125        }
126        if let Some(ct) = &self.content_type {
127            builder = builder.header("Content-Type", ct);
128        }
129        if let Some(accept) = &self.accept {
130            builder = builder.header("Accept", accept);
131        }
132
133        Ok(builder)
134    }
135}
136
137impl ApplyOptions<RequestBuilder> for ParamOptions {
138    fn apply(&self, builder: RequestBuilder) -> Result<RequestBuilder> {
139        let mut params: Vec<(&str, &str)> = Vec::new();
140
141        for param in &self.param {
142            let (key, value) = param.split_once('=')
143                .ok_or_else(|| anyhow::anyhow!(
144                    "Invalid parameter format: '{}'. Expected format: 'key=value' (must contain an equals sign)",
145                    param
146                ))?;
147
148            let key = key.trim();
149            let value = value.trim();
150
151            if key.is_empty() {
152                anyhow::bail!(
153                    "Invalid parameter: '{}'. Parameter name cannot be empty",
154                    param
155                );
156            }
157
158            params.push((key, value));
159        }
160
161        Ok(builder.query(&params))
162    }
163}
164
165impl ApplyOptions<RequestBuilder> for TimeoutOptions {
166    fn apply(&self, mut builder: RequestBuilder) -> Result<RequestBuilder> {
167        if let Some(timeout) = &self.timeout {
168            let duration: std::time::Duration = (*timeout).into();
169            builder = builder.timeout(duration);
170        }
171        // Note: connect_timeout is set on the Client, not RequestBuilder
172        // This will need to be applied when building the client
173        Ok(builder)
174    }
175}
176
177impl ApplyOptions<RequestBuilder> for BodyOptions {
178    fn apply(&self, builder: RequestBuilder) -> Result<RequestBuilder> {
179        // Handle JSON body
180        if let Some(json) = &self.json {
181            let data = json.resolve()?;
182            return Ok(builder
183                .body(data)
184                .header("Content-Type", "application/json"));
185        }
186
187        // Handle form data
188        if !self.form.is_empty() {
189            let mut form = reqwest::blocking::multipart::Form::new();
190            for field in &self.form {
191                let value = field.value.resolve()?;
192                form = form.text(
193                    field.name.clone(),
194                    String::from_utf8_lossy(&value).to_string(),
195                );
196            }
197            return Ok(builder.multipart(form));
198        }
199
200        // Handle raw data
201        if let Some(raw) = &self.raw {
202            let data = raw.resolve()?;
203            return Ok(builder.body(data));
204        }
205
206        // Handle binary data
207        if let Some(binary) = &self.binary {
208            let data = binary.resolve()?;
209            return Ok(builder
210                .body(data)
211                .header("Content-Type", "application/octet-stream"));
212        }
213
214        Ok(builder)
215    }
216}
217
218impl ApplyOptions<RequestBuilder> for CompressionOptions {
219    fn apply(&self, mut builder: RequestBuilder) -> Result<RequestBuilder> {
220        if self.compressed {
221            // Request compressed response (gzip, deflate, br)
222            builder = builder.header("Accept-Encoding", "gzip, deflate, br");
223        }
224        Ok(builder)
225    }
226}
227
228impl ApplyOptions<RequestBuilder> for RequestOptions {
229    fn apply(&self, builder: RequestBuilder) -> Result<RequestBuilder> {
230        let builder = self.authorization.apply(builder)?;
231        let builder = self.headers.apply(builder)?;
232        let builder = self.params.apply(builder)?;
233        let builder = self.timeouts.apply(builder)?;
234        self.compression.apply(builder)
235    }
236}
237
238impl ApplyOptions<ClientBuilder> for TimeoutOptions {
239    fn apply(&self, mut builder: ClientBuilder) -> Result<ClientBuilder> {
240        if let Some(timeout) = &self.timeout {
241            let duration: std::time::Duration = (*timeout).into();
242            builder = builder.timeout(duration);
243        }
244        if let Some(connect_timeout) = &self.connect_timeout {
245            let duration: std::time::Duration = (*connect_timeout).into();
246            builder = builder.connect_timeout(duration);
247        }
248        Ok(builder)
249    }
250}
251
252impl ApplyOptions<ClientBuilder> for RedirectOptions {
253    fn apply(&self, mut builder: ClientBuilder) -> Result<ClientBuilder> {
254        if self.location {
255            // Follow redirects (enabled by default in reqwest)
256            if let Some(max) = self.max_redirects {
257                builder = builder.redirect(reqwest::redirect::Policy::limited(max as usize));
258            }
259        } else {
260            // Disable redirects
261            builder = builder.redirect(reqwest::redirect::Policy::none());
262        }
263        Ok(builder)
264    }
265}
266
267impl ApplyOptions<ClientBuilder> for TlsOptions {
268    fn apply(&self, mut builder: ClientBuilder) -> Result<ClientBuilder> {
269        if self.insecure {
270            builder = builder.danger_accept_invalid_certs(true);
271        }
272
273        // Add client certificate if provided (both cert and key required)
274        if let (Some(cert_path), Some(key_path)) = (&self.cert, &self.key) {
275            let cert_pem = std::fs::read(cert_path)?;
276            let key_pem = std::fs::read(key_path)?;
277
278            // Concatenate cert and key PEM data
279            let mut pem_data = cert_pem;
280            pem_data.extend_from_slice(&key_pem);
281
282            let identity = reqwest::Identity::from_pem(&pem_data)?;
283            builder = builder.identity(identity);
284        }
285
286        // Add CA certificate if provided
287        if let Some(cacert_path) = &self.cacert {
288            let cacert_bytes = std::fs::read(cacert_path)?;
289            let cert = reqwest::Certificate::from_pem(&cacert_bytes)?;
290            builder = builder.add_root_certificate(cert);
291        }
292
293        Ok(builder)
294    }
295}
296
297impl ApplyOptions<ClientBuilder> for ProxyOptions {
298    fn apply(&self, mut builder: ClientBuilder) -> Result<ClientBuilder> {
299        if let Some(proxy_url) = &self.proxy {
300            let mut proxy = reqwest::Proxy::all(proxy_url.as_str())?;
301
302            // Add proxy authentication if provided
303            if let Some(auth) = &self.proxy_auth {
304                let auth_str = auth.expose_secret();
305                let (user, pass) = auth_str.split_once(':')
306                    .ok_or_else(|| anyhow::anyhow!(
307                        "Invalid proxy auth format. Expected format: 'username:password' (must contain a colon)"
308                    ))?;
309
310                if user.is_empty() {
311                    anyhow::bail!("Invalid proxy auth format. Username cannot be empty");
312                }
313
314                proxy = proxy.basic_auth(user, pass);
315            }
316
317            builder = builder.proxy(proxy);
318        }
319
320        Ok(builder)
321    }
322}
323
324impl ApplyOptions<ClientBuilder> for RequestOptions {
325    fn apply(&self, builder: ClientBuilder) -> Result<ClientBuilder> {
326        let builder = self.timeouts.apply(builder)?;
327        let builder = self.redirects.apply(builder)?;
328        let builder = self.tls.apply(builder)?;
329        self.proxy.apply(builder)
330    }
331}
332
333#[cfg(test)]
334mod tests {
335    use super::*;
336    use crate::cli::{AuthOptions, HeaderOptions, ParamOptions, ProxyOptions};
337    use secrecy::SecretString;
338
339    fn secret(s: &str) -> SecretString {
340        SecretString::new(s.to_string().into_boxed_str())
341    }
342
343    // Validation tests
344
345    #[test]
346    fn test_header_missing_colon_returns_error() {
347        let headers = HeaderOptions {
348            header: vec!["InvalidHeaderFormat".to_string()],
349            ..Default::default()
350        };
351
352        let client = ClientBuilder::new().build().unwrap();
353        let request = client.get("https://example.com");
354        let builder = QuestRequestBuilder::from_request(request);
355
356        let result = builder.apply(&headers);
357        assert!(result.is_err());
358        assert!(
359            result
360                .unwrap_err()
361                .to_string()
362                .contains("must contain a colon")
363        );
364    }
365
366    #[test]
367    fn test_header_empty_key_returns_error() {
368        let headers = HeaderOptions {
369            header: vec![": value".to_string()],
370            ..Default::default()
371        };
372
373        let client = ClientBuilder::new().build().unwrap();
374        let request = client.get("https://example.com");
375        let builder = QuestRequestBuilder::from_request(request);
376
377        let result = builder.apply(&headers);
378        assert!(result.is_err());
379        assert!(
380            result
381                .unwrap_err()
382                .to_string()
383                .contains("Header name cannot be empty")
384        );
385    }
386
387    #[test]
388    fn test_header_empty_value_is_allowed() {
389        let headers = HeaderOptions {
390            header: vec!["X-Custom:".to_string()],
391            ..Default::default()
392        };
393
394        let client = ClientBuilder::new().build().unwrap();
395        let request = client.get("https://example.com");
396        let builder = QuestRequestBuilder::from_request(request);
397
398        assert!(builder.apply(&headers).is_ok());
399    }
400
401    #[test]
402    fn test_parameter_missing_equals_returns_error() {
403        let params = ParamOptions {
404            param: vec!["invalid".to_string()],
405        };
406
407        let client = ClientBuilder::new().build().unwrap();
408        let request = client.get("https://example.com");
409        let builder = QuestRequestBuilder::from_request(request);
410
411        let result = builder.apply(&params);
412        assert!(result.is_err());
413        assert!(
414            result
415                .unwrap_err()
416                .to_string()
417                .contains("must contain an equals sign")
418        );
419    }
420
421    #[test]
422    fn test_parameter_empty_key_returns_error() {
423        let params = ParamOptions {
424            param: vec!["=value".to_string()],
425        };
426
427        let client = ClientBuilder::new().build().unwrap();
428        let request = client.get("https://example.com");
429        let builder = QuestRequestBuilder::from_request(request);
430
431        let result = builder.apply(&params);
432        assert!(result.is_err());
433        assert!(
434            result
435                .unwrap_err()
436                .to_string()
437                .contains("Parameter name cannot be empty")
438        );
439    }
440
441    #[test]
442    fn test_parameter_empty_value_is_allowed() {
443        let params = ParamOptions {
444            param: vec!["key=".to_string()],
445        };
446
447        let client = ClientBuilder::new().build().unwrap();
448        let request = client.get("https://example.com");
449        let builder = QuestRequestBuilder::from_request(request);
450
451        assert!(builder.apply(&params).is_ok());
452    }
453
454    #[test]
455    fn test_auth_missing_colon_returns_error() {
456        let auth = AuthOptions {
457            auth: Some(secret("invalid")),
458            ..Default::default()
459        };
460
461        let client = ClientBuilder::new().build().unwrap();
462        let request = client.get("https://example.com");
463        let builder = QuestRequestBuilder::from_request(request);
464
465        let result = builder.apply(&auth);
466        assert!(result.is_err());
467        assert!(
468            result
469                .unwrap_err()
470                .to_string()
471                .contains("must contain a colon")
472        );
473    }
474
475    #[test]
476    fn test_auth_empty_username_returns_error() {
477        let auth = AuthOptions {
478            auth: Some(secret(":password")),
479            ..Default::default()
480        };
481
482        let client = ClientBuilder::new().build().unwrap();
483        let request = client.get("https://example.com");
484        let builder = QuestRequestBuilder::from_request(request);
485
486        let result = builder.apply(&auth);
487        assert!(result.is_err());
488        assert!(
489            result
490                .unwrap_err()
491                .to_string()
492                .contains("Username cannot be empty")
493        );
494    }
495
496    #[test]
497    fn test_auth_empty_password_is_allowed() {
498        let auth = AuthOptions {
499            auth: Some(secret("user:")),
500            ..Default::default()
501        };
502
503        let client = ClientBuilder::new().build().unwrap();
504        let request = client.get("https://example.com");
505        let builder = QuestRequestBuilder::from_request(request);
506
507        assert!(builder.apply(&auth).is_ok());
508    }
509
510    #[test]
511    fn test_proxy_auth_validation() {
512        let proxy_opts = ProxyOptions {
513            proxy: Some(url::Url::parse("http://proxy.example.com:8080").unwrap()),
514            proxy_auth: Some(secret("invalid")),
515        };
516
517        let builder = QuestClientBuilder::new();
518        let result = builder.apply(&proxy_opts);
519
520        assert!(result.is_err());
521        assert!(
522            result
523                .unwrap_err()
524                .to_string()
525                .contains("must contain a colon")
526        );
527    }
528
529    #[test]
530    fn test_whitespace_trimming_in_headers() {
531        let headers = HeaderOptions {
532            header: vec!["  X-Custom  :  value  ".to_string()],
533            ..Default::default()
534        };
535
536        let client = ClientBuilder::new().build().unwrap();
537        let request = client.get("https://example.com");
538        let builder = QuestRequestBuilder::from_request(request);
539
540        assert!(builder.apply(&headers).is_ok());
541    }
542
543    #[test]
544    fn test_whitespace_trimming_results_in_empty_key() {
545        let headers = HeaderOptions {
546            header: vec!["   : value".to_string()],
547            ..Default::default()
548        };
549
550        let client = ClientBuilder::new().build().unwrap();
551        let request = client.get("https://example.com");
552        let builder = QuestRequestBuilder::from_request(request);
553
554        assert!(builder.apply(&headers).is_err());
555    }
556
557    // Option application tests
558
559    #[test]
560    fn test_apply_all_header_types() {
561        let headers = HeaderOptions {
562            header: vec!["X-Custom: value".to_string()],
563            user_agent: Some("TestAgent/1.0".to_string()),
564            referer: Some("https://example.com".to_string()),
565            content_type: Some("application/json".to_string()),
566            accept: Some("application/json".to_string()),
567        };
568
569        let client = ClientBuilder::new().build().unwrap();
570        let request = client.get("https://example.com");
571        let builder = QuestRequestBuilder::from_request(request);
572
573        assert!(builder.apply(&headers).is_ok());
574    }
575
576    #[test]
577    fn test_apply_bearer_auth() {
578        let auth = AuthOptions {
579            bearer: Some(secret("token123")),
580            ..Default::default()
581        };
582
583        let client = ClientBuilder::new().build().unwrap();
584        let request = client.get("https://example.com");
585        let builder = QuestRequestBuilder::from_request(request);
586
587        assert!(builder.apply(&auth).is_ok());
588    }
589
590    #[test]
591    fn test_apply_multiple_params() {
592        let params = ParamOptions {
593            param: vec!["foo=bar".to_string(), "baz=qux".to_string()],
594        };
595
596        let client = ClientBuilder::new().build().unwrap();
597        let request = client.get("https://example.com");
598        let builder = QuestRequestBuilder::from_request(request);
599
600        assert!(builder.apply(&params).is_ok());
601    }
602
603    #[test]
604    fn test_options_apply_in_sequence() {
605        let auth = AuthOptions {
606            basic: Some(secret("user:pass")),
607            ..Default::default()
608        };
609        let headers = HeaderOptions {
610            header: vec!["X-Custom: value".to_string()],
611            ..Default::default()
612        };
613        let params = ParamOptions {
614            param: vec!["key=value".to_string()],
615        };
616
617        let client = ClientBuilder::new().build().unwrap();
618        let request = client.get("https://example.com");
619        let mut builder = QuestRequestBuilder::from_request(request);
620
621        builder = builder.apply(&auth).unwrap();
622        builder = builder.apply(&headers).unwrap();
623        let _ = builder.apply(&params).unwrap();
624
625        // If we got here, all options applied successfully
626    }
627}