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.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 if let Some(max) = self.max_redirects {
257 builder = builder.redirect(reqwest::redirect::Policy::limited(max as usize));
258 }
259 } else {
260 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 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 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 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 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 #[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(¶ms);
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(¶ms);
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(¶ms).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 #[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(¶ms).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(¶ms).unwrap();
624
625 }
627}