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.body.apply(builder)?;
234        let builder = self.timeouts.apply(builder)?;
235        self.compression.apply(builder)
236    }
237}
238
239impl ApplyOptions<ClientBuilder> for TimeoutOptions {
240    fn apply(&self, mut builder: ClientBuilder) -> Result<ClientBuilder> {
241        if let Some(timeout) = &self.timeout {
242            let duration: std::time::Duration = (*timeout).into();
243            builder = builder.timeout(duration);
244        }
245        if let Some(connect_timeout) = &self.connect_timeout {
246            let duration: std::time::Duration = (*connect_timeout).into();
247            builder = builder.connect_timeout(duration);
248        }
249        Ok(builder)
250    }
251}
252
253impl ApplyOptions<ClientBuilder> for RedirectOptions {
254    fn apply(&self, mut builder: ClientBuilder) -> Result<ClientBuilder> {
255        if self.location {
256            // Follow redirects (enabled by default in reqwest)
257            if let Some(max) = self.max_redirects {
258                builder = builder.redirect(reqwest::redirect::Policy::limited(max as usize));
259            }
260        } else {
261            // Disable redirects
262            builder = builder.redirect(reqwest::redirect::Policy::none());
263        }
264        Ok(builder)
265    }
266}
267
268impl ApplyOptions<ClientBuilder> for TlsOptions {
269    fn apply(&self, mut builder: ClientBuilder) -> Result<ClientBuilder> {
270        if self.insecure {
271            builder = builder.danger_accept_invalid_certs(true);
272        }
273
274        // Add client certificate if provided (both cert and key required)
275        if let (Some(cert_path), Some(key_path)) = (&self.cert, &self.key) {
276            let cert_pem = std::fs::read(cert_path)?;
277            let key_pem = std::fs::read(key_path)?;
278
279            // Concatenate cert and key PEM data
280            let mut pem_data = cert_pem;
281            pem_data.extend_from_slice(&key_pem);
282
283            let identity = reqwest::Identity::from_pem(&pem_data)?;
284            builder = builder.identity(identity);
285        }
286
287        // Add CA certificate if provided
288        if let Some(cacert_path) = &self.cacert {
289            let cacert_bytes = std::fs::read(cacert_path)?;
290            let cert = reqwest::Certificate::from_pem(&cacert_bytes)?;
291            builder = builder.add_root_certificate(cert);
292        }
293
294        Ok(builder)
295    }
296}
297
298impl ApplyOptions<ClientBuilder> for ProxyOptions {
299    fn apply(&self, mut builder: ClientBuilder) -> Result<ClientBuilder> {
300        if let Some(proxy_url) = &self.proxy {
301            let mut proxy = reqwest::Proxy::all(proxy_url.as_str())?;
302
303            // Add proxy authentication if provided
304            if let Some(auth) = &self.proxy_auth {
305                let auth_str = auth.expose_secret();
306                let (user, pass) = auth_str.split_once(':')
307                    .ok_or_else(|| anyhow::anyhow!(
308                        "Invalid proxy auth format. Expected format: 'username:password' (must contain a colon)"
309                    ))?;
310
311                if user.is_empty() {
312                    anyhow::bail!("Invalid proxy auth format. Username cannot be empty");
313                }
314
315                proxy = proxy.basic_auth(user, pass);
316            }
317
318            builder = builder.proxy(proxy);
319        }
320
321        Ok(builder)
322    }
323}
324
325impl ApplyOptions<ClientBuilder> for RequestOptions {
326    fn apply(&self, builder: ClientBuilder) -> Result<ClientBuilder> {
327        let builder = self.timeouts.apply(builder)?;
328        let builder = self.redirects.apply(builder)?;
329        let builder = self.tls.apply(builder)?;
330        self.proxy.apply(builder)
331    }
332}
333
334#[cfg(test)]
335mod tests {
336    use super::*;
337    use crate::cli::{AuthOptions, HeaderOptions, ParamOptions, ProxyOptions};
338    use secrecy::SecretString;
339
340    fn secret(s: &str) -> SecretString {
341        SecretString::new(s.to_string().into_boxed_str())
342    }
343
344    // Validation tests
345
346    #[test]
347    fn test_header_missing_colon_returns_error() {
348        let headers = HeaderOptions {
349            header: vec!["InvalidHeaderFormat".to_string()],
350            ..Default::default()
351        };
352
353        let client = ClientBuilder::new().build().unwrap();
354        let request = client.get("https://example.com");
355        let builder = QuestRequestBuilder::from_request(request);
356
357        let result = builder.apply(&headers);
358        assert!(result.is_err());
359        assert!(
360            result
361                .unwrap_err()
362                .to_string()
363                .contains("must contain a colon")
364        );
365    }
366
367    #[test]
368    fn test_header_empty_key_returns_error() {
369        let headers = HeaderOptions {
370            header: vec![": value".to_string()],
371            ..Default::default()
372        };
373
374        let client = ClientBuilder::new().build().unwrap();
375        let request = client.get("https://example.com");
376        let builder = QuestRequestBuilder::from_request(request);
377
378        let result = builder.apply(&headers);
379        assert!(result.is_err());
380        assert!(
381            result
382                .unwrap_err()
383                .to_string()
384                .contains("Header name cannot be empty")
385        );
386    }
387
388    #[test]
389    fn test_header_empty_value_is_allowed() {
390        let headers = HeaderOptions {
391            header: vec!["X-Custom:".to_string()],
392            ..Default::default()
393        };
394
395        let client = ClientBuilder::new().build().unwrap();
396        let request = client.get("https://example.com");
397        let builder = QuestRequestBuilder::from_request(request);
398
399        assert!(builder.apply(&headers).is_ok());
400    }
401
402    #[test]
403    fn test_parameter_missing_equals_returns_error() {
404        let params = ParamOptions {
405            param: vec!["invalid".to_string()],
406        };
407
408        let client = ClientBuilder::new().build().unwrap();
409        let request = client.get("https://example.com");
410        let builder = QuestRequestBuilder::from_request(request);
411
412        let result = builder.apply(&params);
413        assert!(result.is_err());
414        assert!(
415            result
416                .unwrap_err()
417                .to_string()
418                .contains("must contain an equals sign")
419        );
420    }
421
422    #[test]
423    fn test_parameter_empty_key_returns_error() {
424        let params = ParamOptions {
425            param: vec!["=value".to_string()],
426        };
427
428        let client = ClientBuilder::new().build().unwrap();
429        let request = client.get("https://example.com");
430        let builder = QuestRequestBuilder::from_request(request);
431
432        let result = builder.apply(&params);
433        assert!(result.is_err());
434        assert!(
435            result
436                .unwrap_err()
437                .to_string()
438                .contains("Parameter name cannot be empty")
439        );
440    }
441
442    #[test]
443    fn test_parameter_empty_value_is_allowed() {
444        let params = ParamOptions {
445            param: vec!["key=".to_string()],
446        };
447
448        let client = ClientBuilder::new().build().unwrap();
449        let request = client.get("https://example.com");
450        let builder = QuestRequestBuilder::from_request(request);
451
452        assert!(builder.apply(&params).is_ok());
453    }
454
455    #[test]
456    fn test_auth_missing_colon_returns_error() {
457        let auth = AuthOptions {
458            auth: Some(secret("invalid")),
459            ..Default::default()
460        };
461
462        let client = ClientBuilder::new().build().unwrap();
463        let request = client.get("https://example.com");
464        let builder = QuestRequestBuilder::from_request(request);
465
466        let result = builder.apply(&auth);
467        assert!(result.is_err());
468        assert!(
469            result
470                .unwrap_err()
471                .to_string()
472                .contains("must contain a colon")
473        );
474    }
475
476    #[test]
477    fn test_auth_empty_username_returns_error() {
478        let auth = AuthOptions {
479            auth: Some(secret(":password")),
480            ..Default::default()
481        };
482
483        let client = ClientBuilder::new().build().unwrap();
484        let request = client.get("https://example.com");
485        let builder = QuestRequestBuilder::from_request(request);
486
487        let result = builder.apply(&auth);
488        assert!(result.is_err());
489        assert!(
490            result
491                .unwrap_err()
492                .to_string()
493                .contains("Username cannot be empty")
494        );
495    }
496
497    #[test]
498    fn test_auth_empty_password_is_allowed() {
499        let auth = AuthOptions {
500            auth: Some(secret("user:")),
501            ..Default::default()
502        };
503
504        let client = ClientBuilder::new().build().unwrap();
505        let request = client.get("https://example.com");
506        let builder = QuestRequestBuilder::from_request(request);
507
508        assert!(builder.apply(&auth).is_ok());
509    }
510
511    #[test]
512    fn test_proxy_auth_validation() {
513        let proxy_opts = ProxyOptions {
514            proxy: Some(url::Url::parse("http://proxy.example.com:8080").unwrap()),
515            proxy_auth: Some(secret("invalid")),
516        };
517
518        let builder = QuestClientBuilder::new();
519        let result = builder.apply(&proxy_opts);
520
521        assert!(result.is_err());
522        assert!(
523            result
524                .unwrap_err()
525                .to_string()
526                .contains("must contain a colon")
527        );
528    }
529
530    #[test]
531    fn test_whitespace_trimming_in_headers() {
532        let headers = HeaderOptions {
533            header: vec!["  X-Custom  :  value  ".to_string()],
534            ..Default::default()
535        };
536
537        let client = ClientBuilder::new().build().unwrap();
538        let request = client.get("https://example.com");
539        let builder = QuestRequestBuilder::from_request(request);
540
541        assert!(builder.apply(&headers).is_ok());
542    }
543
544    #[test]
545    fn test_whitespace_trimming_results_in_empty_key() {
546        let headers = HeaderOptions {
547            header: vec!["   : value".to_string()],
548            ..Default::default()
549        };
550
551        let client = ClientBuilder::new().build().unwrap();
552        let request = client.get("https://example.com");
553        let builder = QuestRequestBuilder::from_request(request);
554
555        assert!(builder.apply(&headers).is_err());
556    }
557
558    // Option application tests
559
560    #[test]
561    fn test_apply_all_header_types() {
562        let headers = HeaderOptions {
563            header: vec!["X-Custom: value".to_string()],
564            user_agent: Some("TestAgent/1.0".to_string()),
565            referer: Some("https://example.com".to_string()),
566            content_type: Some("application/json".to_string()),
567            accept: Some("application/json".to_string()),
568        };
569
570        let client = ClientBuilder::new().build().unwrap();
571        let request = client.get("https://example.com");
572        let builder = QuestRequestBuilder::from_request(request);
573
574        assert!(builder.apply(&headers).is_ok());
575    }
576
577    #[test]
578    fn test_apply_bearer_auth() {
579        let auth = AuthOptions {
580            bearer: Some(secret("token123")),
581            ..Default::default()
582        };
583
584        let client = ClientBuilder::new().build().unwrap();
585        let request = client.get("https://example.com");
586        let builder = QuestRequestBuilder::from_request(request);
587
588        assert!(builder.apply(&auth).is_ok());
589    }
590
591    #[test]
592    fn test_apply_multiple_params() {
593        let params = ParamOptions {
594            param: vec!["foo=bar".to_string(), "baz=qux".to_string()],
595        };
596
597        let client = ClientBuilder::new().build().unwrap();
598        let request = client.get("https://example.com");
599        let builder = QuestRequestBuilder::from_request(request);
600
601        assert!(builder.apply(&params).is_ok());
602    }
603
604    #[test]
605    fn test_options_apply_in_sequence() {
606        let auth = AuthOptions {
607            basic: Some(secret("user:pass")),
608            ..Default::default()
609        };
610        let headers = HeaderOptions {
611            header: vec!["X-Custom: value".to_string()],
612            ..Default::default()
613        };
614        let params = ParamOptions {
615            param: vec!["key=value".to_string()],
616        };
617
618        let client = ClientBuilder::new().build().unwrap();
619        let request = client.get("https://example.com");
620        let mut builder = QuestRequestBuilder::from_request(request);
621
622        builder = builder.apply(&auth).unwrap();
623        builder = builder.apply(&headers).unwrap();
624        let _ = builder.apply(&params).unwrap();
625
626        // If we got here, all options applied successfully
627    }
628}