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
10pub trait ApplyOptions<T> {
12 fn apply(&self, builder: T) -> Result<T>;
13}
14
15#[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#[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 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 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 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 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 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(¶ms))
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 Ok(builder)
174 }
175}
176
177impl ApplyOptions<RequestBuilder> for BodyOptions {
178 fn apply(&self, builder: RequestBuilder) -> Result<RequestBuilder> {
179 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 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 if let Some(raw) = &self.raw {
202 let data = raw.resolve()?;
203 return Ok(builder.body(data));
204 }
205
206 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 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 if let Some(max) = self.max_redirects {
258 builder = builder.redirect(reqwest::redirect::Policy::limited(max as usize));
259 }
260 } else {
261 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 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 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 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 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 #[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(¶ms);
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(¶ms);
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(¶ms).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 #[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(¶ms).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(¶ms).unwrap();
625
626 }
628}