1use std::time::Instant;
21
22use reqwest::blocking::Client;
23use reqwest::Method;
24use serde_json::Value;
25
26use crate::error::DispatchError;
27use crate::spec::{is_bool_schema, ApiOperation};
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31#[non_exhaustive]
32pub enum Auth<'a> {
33 None,
35 Bearer(&'a str),
37 Header { name: &'a str, value: &'a str },
39 Basic {
41 username: &'a str,
42 password: Option<&'a str>,
43 },
44 Query { name: &'a str, value: &'a str },
46}
47
48#[derive(Debug, Clone, PartialEq, Eq)]
53#[non_exhaustive]
54pub enum ResolvedAuth {
55 None,
57 Bearer(String),
59 Header { name: String, value: String },
61 Basic {
63 username: String,
64 password: Option<String>,
65 },
66 Query { name: String, value: String },
68}
69
70impl From<&Auth<'_>> for ResolvedAuth {
71 fn from(auth: &Auth<'_>) -> Self {
72 match auth {
73 Auth::None => Self::None,
74 Auth::Bearer(token) => Self::Bearer(token.to_string()),
75 Auth::Header { name, value } => Self::Header {
76 name: name.to_string(),
77 value: value.to_string(),
78 },
79 Auth::Basic { username, password } => Self::Basic {
80 username: username.to_string(),
81 password: password.map(|p| p.to_string()),
82 },
83 Auth::Query { name, value } => Self::Query {
84 name: name.to_string(),
85 value: value.to_string(),
86 },
87 }
88 }
89}
90
91#[derive(Debug)]
98#[non_exhaustive]
99pub struct SendResponse {
100 pub status: reqwest::StatusCode,
102 pub headers: reqwest::header::HeaderMap,
104 pub body: String,
106 pub elapsed: std::time::Duration,
108}
109
110impl SendResponse {
111 pub fn into_json(self) -> Result<Value, DispatchError> {
116 if !self.status.is_success() {
117 return Err(DispatchError::HttpError {
118 status: self.status,
119 body: self.body,
120 });
121 }
122 Ok(serde_json::from_str(&self.body).unwrap_or(Value::String(self.body)))
123 }
124
125 pub fn json(&self) -> Value {
129 serde_json::from_str(&self.body).unwrap_or_else(|_| Value::String(self.body.clone()))
130 }
131}
132
133#[derive(Debug, Clone)]
163#[non_exhaustive]
164pub struct PreparedRequest {
165 pub method: Method,
167 pub url: String,
169 pub query_pairs: Vec<(String, String)>,
175 pub headers: Vec<(String, String)>,
179 pub body: Option<Value>,
181 pub auth: ResolvedAuth,
183}
184
185impl PreparedRequest {
186 pub fn new(method: Method, url: impl Into<String>) -> Self {
203 Self {
204 method,
205 url: url.into(),
206 query_pairs: Vec::new(),
207 headers: Vec::new(),
208 body: None,
209 auth: ResolvedAuth::None,
210 }
211 }
212
213 pub fn query(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
215 self.query_pairs.push((name.into(), value.into()));
216 self
217 }
218
219 pub fn header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
221 self.headers.push((name.into(), value.into()));
222 self
223 }
224
225 pub fn body(mut self, body: Value) -> Self {
227 self.body = Some(body);
228 self
229 }
230
231 pub fn auth(mut self, auth: ResolvedAuth) -> Self {
233 self.auth = auth;
234 self
235 }
236
237 pub fn clear_query(mut self) -> Self {
239 self.query_pairs.clear();
240 self
241 }
242
243 pub fn clear_headers(mut self) -> Self {
245 self.headers.clear();
246 self
247 }
248
249 pub fn from_operation(
256 base_url: &str,
257 auth: &Auth<'_>,
258 op: &ApiOperation,
259 matches: &clap::ArgMatches,
260 ) -> Result<Self, DispatchError> {
261 let url = build_url(base_url, op, matches);
262 let query_pairs = build_query_pairs(op, matches);
263 let headers = collect_headers(op, matches);
264 let method: Method = op
265 .method
266 .parse()
267 .map_err(|_| DispatchError::UnsupportedMethod {
268 method: op.method.clone(),
269 })?;
270
271 Ok(Self {
272 method,
273 url,
274 query_pairs,
275 headers,
276 body: None,
277 auth: ResolvedAuth::from(auth),
278 })
279 }
280
281 pub fn send(&self, client: &Client) -> Result<SendResponse, DispatchError> {
286 let mut req = client.request(self.method.clone(), &self.url);
287
288 match &self.auth {
289 ResolvedAuth::None => {}
290 ResolvedAuth::Bearer(token) => {
291 req = req.bearer_auth(token);
292 }
293 ResolvedAuth::Header { name, value } => {
294 req = req.header(name, value);
295 }
296 ResolvedAuth::Basic { username, password } => {
297 req = req.basic_auth(username, password.as_deref());
298 }
299 ResolvedAuth::Query { .. } => {} }
301 if !self.query_pairs.is_empty() {
302 req = req.query(&self.query_pairs);
303 }
304 if let ResolvedAuth::Query { name, value } = &self.auth {
305 req = req.query(&[(name, value)]);
306 }
307 for (name, val) in &self.headers {
308 req = req.header(name, val);
309 }
310 if let Some(body) = &self.body {
311 req = req.json(body);
312 }
313
314 let start = Instant::now();
315 let resp = req.send().map_err(DispatchError::RequestFailed)?;
316 let status = resp.status();
317 let headers = resp.headers().clone();
318 let text = resp.text().map_err(DispatchError::ResponseRead)?;
319 let elapsed = start.elapsed();
320
321 Ok(SendResponse {
322 status,
323 headers,
324 body: text,
325 elapsed,
326 })
327 }
328}
329
330pub fn dispatch(
335 client: &Client,
336 base_url: &str,
337 auth: &Auth<'_>,
338 op: &ApiOperation,
339 matches: &clap::ArgMatches,
340) -> Result<Value, DispatchError> {
341 let mut req = PreparedRequest::from_operation(base_url, auth, op, matches)?;
342 if let Some(body) = build_body(op, matches)? {
343 req = req.body(body);
344 }
345 req.send(client)?.into_json()
346}
347
348fn build_url(base_url: &str, op: &ApiOperation, matches: &clap::ArgMatches) -> String {
349 let base = base_url.trim_end_matches('/');
350 let mut url = format!("{}{}", base, op.path);
351 for param in &op.path_params {
352 if let Some(val) = matches.get_one::<String>(¶m.name) {
353 url = url.replace(&format!("{{{}}}", param.name), &urlencoding::encode(val));
354 }
355 }
356 url
357}
358
359fn build_query_pairs(op: &ApiOperation, matches: &clap::ArgMatches) -> Vec<(String, String)> {
360 let mut pairs = Vec::new();
361 for param in &op.query_params {
362 if is_bool_schema(¶m.schema) {
363 if matches.get_flag(¶m.name) {
364 pairs.push((param.name.clone(), "true".to_string()));
365 }
366 } else if let Some(val) = matches.get_one::<String>(¶m.name) {
367 pairs.push((param.name.clone(), val.clone()));
368 }
369 }
370 pairs
371}
372
373fn collect_headers(op: &ApiOperation, matches: &clap::ArgMatches) -> Vec<(String, String)> {
374 let mut headers = Vec::new();
375 for param in &op.header_params {
376 if let Some(val) = matches.get_one::<String>(¶m.name) {
377 headers.push((param.name.clone(), val.clone()));
378 }
379 }
380 headers
381}
382
383pub fn build_body(
392 op: &ApiOperation,
393 matches: &clap::ArgMatches,
394) -> Result<Option<Value>, DispatchError> {
395 if op.body_schema.is_none() {
396 return Ok(None);
397 }
398
399 if let Some(json_str) = matches.get_one::<String>("json-body") {
401 let val: Value = serde_json::from_str(json_str).map_err(DispatchError::InvalidJsonBody)?;
402 return Ok(Some(val));
403 }
404
405 if let Some(fields) = matches.get_many::<String>("field") {
407 let mut obj = serde_json::Map::new();
408 for field in fields {
409 let (key, val) =
410 field
411 .split_once('=')
412 .ok_or_else(|| DispatchError::InvalidFieldFormat {
413 field: field.to_string(),
414 })?;
415 let json_val = serde_json::from_str(val).unwrap_or(Value::String(val.to_string()));
417 obj.insert(key.to_string(), json_val);
418 }
419 return Ok(Some(Value::Object(obj)));
420 }
421
422 if op.body_required {
423 return Err(DispatchError::BodyRequired);
424 }
425
426 Ok(None)
427}
428
429#[cfg(test)]
430mod tests {
431 use super::*;
432 use crate::spec::{ApiOperation, Param};
433 use clap::{Arg, ArgAction, Command};
434 use reqwest::blocking::Client;
435 use serde_json::json;
436
437 fn make_op_with_body(body_schema: Option<Value>) -> ApiOperation {
438 ApiOperation {
439 operation_id: "TestOp".to_string(),
440 method: "POST".to_string(),
441 path: "/test".to_string(),
442 group: "Test".to_string(),
443 summary: String::new(),
444 path_params: Vec::new(),
445 query_params: Vec::new(),
446 header_params: Vec::new(),
447 body_schema,
448 body_required: false,
449 }
450 }
451
452 fn build_matches_with_args(args: &[&str], has_body: bool) -> clap::ArgMatches {
453 let mut cmd = Command::new("test");
454 if has_body {
455 cmd = cmd
456 .arg(
457 Arg::new("json-body")
458 .long("json")
459 .short('j')
460 .action(ArgAction::Set),
461 )
462 .arg(
463 Arg::new("field")
464 .long("field")
465 .short('f')
466 .action(ArgAction::Append),
467 );
468 }
469 cmd.try_get_matches_from(args).unwrap()
470 }
471
472 #[test]
473 fn build_body_returns_none_when_no_body_schema() {
474 let op = make_op_with_body(None);
475 let matches = build_matches_with_args(&["test"], false);
476
477 let result = build_body(&op, &matches).unwrap();
478 assert!(result.is_none());
479 }
480
481 #[test]
482 fn build_body_parses_json_flag() {
483 let op = make_op_with_body(Some(json!({"type": "object"})));
484 let matches =
485 build_matches_with_args(&["test", "--json", r#"{"name":"pod1","gpu":2}"#], true);
486
487 let result = build_body(&op, &matches).unwrap();
488 assert!(result.is_some());
489 let body = result.unwrap();
490 assert_eq!(body["name"], "pod1");
491 assert_eq!(body["gpu"], 2);
492 }
493
494 #[test]
495 fn build_body_parses_field_key_value() {
496 let op = make_op_with_body(Some(json!({"type": "object"})));
497 let matches =
498 build_matches_with_args(&["test", "--field", "name=pod1", "--field", "gpu=2"], true);
499
500 let result = build_body(&op, &matches).unwrap();
501 assert!(result.is_some());
502 let body = result.unwrap();
503 assert_eq!(body["name"], "pod1");
504 assert_eq!(body["gpu"], 2);
506 }
507
508 #[test]
509 fn build_body_field_string_fallback() {
510 let op = make_op_with_body(Some(json!({"type": "object"})));
511 let matches = build_matches_with_args(&["test", "--field", "name=hello world"], true);
512
513 let result = build_body(&op, &matches).unwrap();
514 let body = result.unwrap();
515 assert_eq!(body["name"], "hello world");
516 }
517
518 #[test]
519 fn build_body_returns_error_for_invalid_field_format() {
520 let op = make_op_with_body(Some(json!({"type": "object"})));
521 let matches = build_matches_with_args(&["test", "--field", "no-equals-sign"], true);
522
523 let result = build_body(&op, &matches);
524 assert!(result.is_err());
525 let err_msg = result.unwrap_err().to_string();
526 assert!(
527 err_msg.contains("invalid --field format"),
528 "error should mention invalid format, got: {err_msg}"
529 );
530 }
531
532 #[test]
533 fn build_body_returns_error_for_invalid_json() {
534 let op = make_op_with_body(Some(json!({"type": "object"})));
535 let matches = build_matches_with_args(&["test", "--json", "{invalid json}"], true);
536
537 let result = build_body(&op, &matches);
538 assert!(result.is_err());
539 let err_msg = result.unwrap_err().to_string();
540 assert!(
541 err_msg.contains("invalid JSON"),
542 "error should mention invalid JSON, got: {err_msg}"
543 );
544 }
545
546 #[test]
547 fn build_body_returns_none_when_schema_present_but_no_flags() {
548 let op = make_op_with_body(Some(json!({"type": "object"})));
549 let matches = build_matches_with_args(&["test"], true);
550
551 let result = build_body(&op, &matches).unwrap();
552 assert!(result.is_none());
553 }
554
555 #[test]
556 fn build_body_json_takes_precedence_over_field() {
557 let op = make_op_with_body(Some(json!({"type": "object"})));
558 let matches = build_matches_with_args(
559 &[
560 "test",
561 "--json",
562 r#"{"from":"json"}"#,
563 "--field",
564 "from=field",
565 ],
566 true,
567 );
568
569 let result = build_body(&op, &matches).unwrap();
570 let body = result.unwrap();
571 assert_eq!(body["from"], "json");
573 }
574
575 #[test]
576 fn build_body_returns_error_when_body_required_but_not_provided() {
577 let mut op = make_op_with_body(Some(json!({"type": "object"})));
578 op.body_required = true;
579 let matches = build_matches_with_args(&["test"], true);
580
581 let result = build_body(&op, &matches);
582 assert!(result.is_err());
583 assert!(result
584 .unwrap_err()
585 .to_string()
586 .contains("request body is required"));
587 }
588
589 fn make_full_op(
592 method: &str,
593 path: &str,
594 path_params: Vec<Param>,
595 query_params: Vec<Param>,
596 header_params: Vec<Param>,
597 body_schema: Option<serde_json::Value>,
598 ) -> ApiOperation {
599 ApiOperation {
600 operation_id: "TestOp".to_string(),
601 method: method.to_string(),
602 path: path.to_string(),
603 group: "Test".to_string(),
604 summary: String::new(),
605 path_params,
606 query_params,
607 header_params,
608 body_schema,
609 body_required: false,
610 }
611 }
612
613 #[test]
614 fn dispatch_sends_get_with_path_and_query_params() {
615 let mut server = mockito::Server::new();
616 let mock = server
617 .mock("GET", "/pods/123")
618 .match_query(mockito::Matcher::UrlEncoded(
619 "verbose".into(),
620 "true".into(),
621 ))
622 .match_header("authorization", "Bearer test-key")
623 .with_status(200)
624 .with_header("content-type", "application/json")
625 .with_body(r#"{"id":"123"}"#)
626 .create();
627
628 let op = make_full_op(
629 "GET",
630 "/pods/{podId}",
631 vec![Param {
632 name: "podId".into(),
633 description: String::new(),
634 required: true,
635 schema: json!({"type": "string"}),
636 }],
637 vec![Param {
638 name: "verbose".into(),
639 description: String::new(),
640 required: false,
641 schema: json!({"type": "boolean"}),
642 }],
643 Vec::new(),
644 None,
645 );
646
647 let cmd = Command::new("test")
648 .arg(Arg::new("podId").required(true))
649 .arg(
650 Arg::new("verbose")
651 .long("verbose")
652 .action(ArgAction::SetTrue),
653 );
654 let matches = cmd
655 .try_get_matches_from(["test", "123", "--verbose"])
656 .unwrap();
657
658 let client = Client::new();
659 let result = dispatch(
660 &client,
661 &server.url(),
662 &Auth::Bearer("test-key"),
663 &op,
664 &matches,
665 );
666 assert!(result.is_ok());
667 assert_eq!(result.unwrap()["id"], "123");
668 mock.assert();
669 }
670
671 #[test]
672 fn dispatch_sends_post_with_json_body() {
673 let mut server = mockito::Server::new();
674 let mock = server
675 .mock("POST", "/pods")
676 .match_header("content-type", "application/json")
677 .match_body(mockito::Matcher::Json(json!({"name": "pod1"})))
678 .with_status(200)
679 .with_header("content-type", "application/json")
680 .with_body(r#"{"id":"new"}"#)
681 .create();
682
683 let op = make_full_op(
684 "POST",
685 "/pods",
686 Vec::new(),
687 Vec::new(),
688 Vec::new(),
689 Some(json!({"type": "object"})),
690 );
691
692 let cmd = Command::new("test").arg(
693 Arg::new("json-body")
694 .long("json")
695 .short('j')
696 .action(ArgAction::Set),
697 );
698 let matches = cmd
699 .try_get_matches_from(["test", "--json", r#"{"name":"pod1"}"#])
700 .unwrap();
701
702 let client = Client::new();
703 let result = dispatch(&client, &server.url(), &Auth::Bearer("key"), &op, &matches);
704 assert!(result.is_ok());
705 assert_eq!(result.unwrap()["id"], "new");
706 mock.assert();
707 }
708
709 #[test]
710 fn dispatch_sends_header_params() {
711 let mut server = mockito::Server::new();
712 let mock = server
713 .mock("GET", "/test")
714 .match_header("X-Request-Id", "abc123")
715 .with_status(200)
716 .with_header("content-type", "application/json")
717 .with_body(r#"{"ok":true}"#)
718 .create();
719
720 let op = make_full_op(
721 "GET",
722 "/test",
723 Vec::new(),
724 Vec::new(),
725 vec![Param {
726 name: "X-Request-Id".into(),
727 description: String::new(),
728 required: false,
729 schema: json!({"type": "string"}),
730 }],
731 None,
732 );
733
734 let cmd = Command::new("test").arg(
735 Arg::new("X-Request-Id")
736 .long("X-Request-Id")
737 .action(ArgAction::Set),
738 );
739 let matches = cmd
740 .try_get_matches_from(["test", "--X-Request-Id", "abc123"])
741 .unwrap();
742
743 let client = Client::new();
744 let result = dispatch(&client, &server.url(), &Auth::Bearer("key"), &op, &matches);
745 assert!(result.is_ok());
746 mock.assert();
747 }
748
749 #[test]
750 fn dispatch_url_encodes_path_params() {
751 let mut server = mockito::Server::new();
752 let mock = server
753 .mock("GET", "/items/hello%20world")
754 .with_status(200)
755 .with_header("content-type", "application/json")
756 .with_body(r#"{"ok":true}"#)
757 .create();
758
759 let op = make_full_op(
760 "GET",
761 "/items/{itemId}",
762 vec![Param {
763 name: "itemId".into(),
764 description: String::new(),
765 required: true,
766 schema: json!({"type": "string"}),
767 }],
768 Vec::new(),
769 Vec::new(),
770 None,
771 );
772
773 let cmd = Command::new("test").arg(Arg::new("itemId").required(true));
774 let matches = cmd.try_get_matches_from(["test", "hello world"]).unwrap();
775
776 let client = Client::new();
777 let result = dispatch(&client, &server.url(), &Auth::Bearer("key"), &op, &matches);
778 assert!(result.is_ok());
779 mock.assert();
780 }
781
782 #[test]
783 fn dispatch_returns_error_on_non_success_status() {
784 let mut server = mockito::Server::new();
785 let _mock = server
786 .mock("GET", "/fail")
787 .with_status(404)
788 .with_body("not found")
789 .create();
790
791 let op = make_full_op("GET", "/fail", Vec::new(), Vec::new(), Vec::new(), None);
792
793 let cmd = Command::new("test");
794 let matches = cmd.try_get_matches_from(["test"]).unwrap();
795
796 let client = Client::new();
797 let result = dispatch(&client, &server.url(), &Auth::Bearer("key"), &op, &matches);
798 assert!(result.is_err());
799 let err_msg = result.unwrap_err().to_string();
800 assert!(
801 err_msg.contains("404"),
802 "error should contain status code, got: {err_msg}"
803 );
804 }
805
806 #[test]
807 fn dispatch_omits_auth_header_when_auth_none() {
808 let mut server = mockito::Server::new();
809 let mock = server
810 .mock("GET", "/test")
811 .match_header("authorization", mockito::Matcher::Missing)
812 .with_status(200)
813 .with_header("content-type", "application/json")
814 .with_body(r#"{"ok":true}"#)
815 .create();
816
817 let op = make_full_op("GET", "/test", Vec::new(), Vec::new(), Vec::new(), None);
818
819 let cmd = Command::new("test");
820 let matches = cmd.try_get_matches_from(["test"]).unwrap();
821
822 let client = Client::new();
823 let result = dispatch(&client, &server.url(), &Auth::None, &op, &matches);
824 assert!(result.is_ok());
825 mock.assert();
826 }
827
828 #[test]
829 fn dispatch_sends_custom_header_auth() {
830 let mut server = mockito::Server::new();
831 let mock = server
832 .mock("GET", "/test")
833 .match_header("X-API-Key", "my-secret")
834 .with_status(200)
835 .with_header("content-type", "application/json")
836 .with_body(r#"{"ok":true}"#)
837 .create();
838
839 let op = make_full_op("GET", "/test", Vec::new(), Vec::new(), Vec::new(), None);
840
841 let cmd = Command::new("test");
842 let matches = cmd.try_get_matches_from(["test"]).unwrap();
843
844 let client = Client::new();
845 let auth = Auth::Header {
846 name: "X-API-Key",
847 value: "my-secret",
848 };
849 let result = dispatch(&client, &server.url(), &auth, &op, &matches);
850 assert!(result.is_ok());
851 mock.assert();
852 }
853
854 #[test]
855 fn dispatch_sends_basic_auth() {
856 let mut server = mockito::Server::new();
857 let mock = server
859 .mock("GET", "/test")
860 .match_header("authorization", "Basic dXNlcjpwYXNz")
861 .with_status(200)
862 .with_header("content-type", "application/json")
863 .with_body(r#"{"ok":true}"#)
864 .create();
865
866 let op = make_full_op("GET", "/test", Vec::new(), Vec::new(), Vec::new(), None);
867
868 let cmd = Command::new("test");
869 let matches = cmd.try_get_matches_from(["test"]).unwrap();
870
871 let client = Client::new();
872 let auth = Auth::Basic {
873 username: "user",
874 password: Some("pass"),
875 };
876 let result = dispatch(&client, &server.url(), &auth, &op, &matches);
877 assert!(result.is_ok());
878 mock.assert();
879 }
880
881 #[test]
882 fn dispatch_sends_query_auth() {
883 let mut server = mockito::Server::new();
884 let mock = server
885 .mock("GET", "/test")
886 .match_query(mockito::Matcher::UrlEncoded(
887 "api_key".into(),
888 "my-secret".into(),
889 ))
890 .match_header("authorization", mockito::Matcher::Missing)
891 .with_status(200)
892 .with_header("content-type", "application/json")
893 .with_body(r#"{"ok":true}"#)
894 .create();
895
896 let op = make_full_op("GET", "/test", Vec::new(), Vec::new(), Vec::new(), None);
897
898 let cmd = Command::new("test");
899 let matches = cmd.try_get_matches_from(["test"]).unwrap();
900
901 let client = Client::new();
902 let auth = Auth::Query {
903 name: "api_key",
904 value: "my-secret",
905 };
906 let result = dispatch(&client, &server.url(), &auth, &op, &matches);
907 assert!(result.is_ok());
908 mock.assert();
909 }
910
911 #[test]
912 fn dispatch_query_auth_coexists_with_operation_query_params() {
913 let mut server = mockito::Server::new();
914 let mock = server
915 .mock("GET", "/test")
916 .match_query(mockito::Matcher::AllOf(vec![
917 mockito::Matcher::UrlEncoded("verbose".into(), "true".into()),
918 mockito::Matcher::UrlEncoded("api_key".into(), "secret".into()),
919 ]))
920 .match_header("authorization", mockito::Matcher::Missing)
921 .with_status(200)
922 .with_header("content-type", "application/json")
923 .with_body(r#"{"ok":true}"#)
924 .create();
925
926 let op = make_full_op(
927 "GET",
928 "/test",
929 Vec::new(),
930 vec![Param {
931 name: "verbose".into(),
932 description: String::new(),
933 required: false,
934 schema: json!({"type": "boolean"}),
935 }],
936 Vec::new(),
937 None,
938 );
939
940 let cmd = Command::new("test").arg(
941 Arg::new("verbose")
942 .long("verbose")
943 .action(ArgAction::SetTrue),
944 );
945 let matches = cmd.try_get_matches_from(["test", "--verbose"]).unwrap();
946
947 let client = Client::new();
948 let auth = Auth::Query {
949 name: "api_key",
950 value: "secret",
951 };
952 let result = dispatch(&client, &server.url(), &auth, &op, &matches);
953 assert!(result.is_ok());
954 mock.assert();
955 }
956
957 #[test]
958 fn dispatch_returns_string_value_for_non_json_response() {
959 let mut server = mockito::Server::new();
960 let _mock = server
961 .mock("GET", "/plain")
962 .with_status(200)
963 .with_header("content-type", "text/plain")
964 .with_body("plain text response")
965 .create();
966
967 let op = make_full_op("GET", "/plain", Vec::new(), Vec::new(), Vec::new(), None);
968
969 let cmd = Command::new("test");
970 let matches = cmd.try_get_matches_from(["test"]).unwrap();
971
972 let client = Client::new();
973 let result = dispatch(&client, &server.url(), &Auth::Bearer("key"), &op, &matches);
974 assert!(result.is_ok());
975 assert_eq!(result.unwrap(), Value::String("plain text response".into()));
976 }
977
978 #[test]
981 fn prepared_request_resolves_url_and_method() {
982 let op = make_full_op(
983 "GET",
984 "/pods/{podId}",
985 vec![Param {
986 name: "podId".into(),
987 description: String::new(),
988 required: true,
989 schema: json!({"type": "string"}),
990 }],
991 Vec::new(),
992 Vec::new(),
993 None,
994 );
995 let cmd = Command::new("test").arg(Arg::new("podId").required(true));
996 let matches = cmd.try_get_matches_from(["test", "abc"]).unwrap();
997
998 let prepared =
999 PreparedRequest::from_operation("https://api.example.com", &Auth::None, &op, &matches)
1000 .unwrap();
1001
1002 assert_eq!(prepared.method, Method::GET);
1003 assert_eq!(prepared.url, "https://api.example.com/pods/abc");
1004 assert!(prepared.query_pairs.is_empty());
1005 assert!(prepared.headers.is_empty());
1006 assert!(prepared.body.is_none());
1007 assert_eq!(prepared.auth, ResolvedAuth::None);
1008 }
1009
1010 #[test]
1011 fn prepared_request_collects_query_pairs() {
1012 let op = make_full_op(
1013 "GET",
1014 "/test",
1015 Vec::new(),
1016 vec![
1017 Param {
1018 name: "limit".into(),
1019 description: String::new(),
1020 required: false,
1021 schema: json!({"type": "integer"}),
1022 },
1023 Param {
1024 name: "verbose".into(),
1025 description: String::new(),
1026 required: false,
1027 schema: json!({"type": "boolean"}),
1028 },
1029 ],
1030 Vec::new(),
1031 None,
1032 );
1033 let cmd = Command::new("test")
1034 .arg(Arg::new("limit").long("limit").action(ArgAction::Set))
1035 .arg(
1036 Arg::new("verbose")
1037 .long("verbose")
1038 .action(ArgAction::SetTrue),
1039 );
1040 let matches = cmd
1041 .try_get_matches_from(["test", "--limit", "10", "--verbose"])
1042 .unwrap();
1043
1044 let prepared =
1045 PreparedRequest::from_operation("https://api.example.com", &Auth::None, &op, &matches)
1046 .unwrap();
1047
1048 assert_eq!(
1049 prepared.query_pairs,
1050 vec![
1051 ("limit".to_string(), "10".to_string()),
1052 ("verbose".to_string(), "true".to_string()),
1053 ]
1054 );
1055 }
1056
1057 #[test]
1058 fn prepared_request_collects_headers() {
1059 let op = make_full_op(
1060 "GET",
1061 "/test",
1062 Vec::new(),
1063 Vec::new(),
1064 vec![Param {
1065 name: "X-Request-Id".into(),
1066 description: String::new(),
1067 required: false,
1068 schema: json!({"type": "string"}),
1069 }],
1070 None,
1071 );
1072 let cmd = Command::new("test").arg(
1073 Arg::new("X-Request-Id")
1074 .long("X-Request-Id")
1075 .action(ArgAction::Set),
1076 );
1077 let matches = cmd
1078 .try_get_matches_from(["test", "--X-Request-Id", "req-42"])
1079 .unwrap();
1080
1081 let prepared =
1082 PreparedRequest::from_operation("https://api.example.com", &Auth::None, &op, &matches)
1083 .unwrap();
1084
1085 assert_eq!(
1086 prepared.headers,
1087 vec![("X-Request-Id".to_string(), "req-42".to_string())]
1088 );
1089 }
1090
1091 #[test]
1092 fn from_operation_does_not_set_body() {
1093 let op = make_full_op(
1094 "POST",
1095 "/test",
1096 Vec::new(),
1097 Vec::new(),
1098 Vec::new(),
1099 Some(json!({"type": "object"})),
1100 );
1101 let cmd = Command::new("test").arg(
1102 Arg::new("json-body")
1103 .long("json")
1104 .short('j')
1105 .action(ArgAction::Set),
1106 );
1107 let matches = cmd
1108 .try_get_matches_from(["test", "--json", r#"{"key":"val"}"#])
1109 .unwrap();
1110
1111 let prepared =
1112 PreparedRequest::from_operation("https://api.example.com", &Auth::None, &op, &matches)
1113 .unwrap();
1114
1115 assert_eq!(prepared.body, None);
1117
1118 let body = build_body(&op, &matches).unwrap();
1120 assert_eq!(body, Some(json!({"key": "val"})));
1121 }
1122
1123 #[test]
1124 fn prepared_request_resolves_bearer_auth() {
1125 let op = make_full_op("GET", "/test", Vec::new(), Vec::new(), Vec::new(), None);
1126 let cmd = Command::new("test");
1127 let matches = cmd.try_get_matches_from(["test"]).unwrap();
1128
1129 let prepared = PreparedRequest::from_operation(
1130 "https://api.example.com",
1131 &Auth::Bearer("my-token"),
1132 &op,
1133 &matches,
1134 )
1135 .unwrap();
1136
1137 assert_eq!(prepared.auth, ResolvedAuth::Bearer("my-token".to_string()));
1138 }
1139
1140 #[test]
1141 fn prepared_request_resolves_basic_auth() {
1142 let op = make_full_op("GET", "/test", Vec::new(), Vec::new(), Vec::new(), None);
1143 let cmd = Command::new("test");
1144 let matches = cmd.try_get_matches_from(["test"]).unwrap();
1145
1146 let prepared = PreparedRequest::from_operation(
1147 "https://api.example.com",
1148 &Auth::Basic {
1149 username: "user",
1150 password: Some("pass"),
1151 },
1152 &op,
1153 &matches,
1154 )
1155 .unwrap();
1156
1157 assert_eq!(
1158 prepared.auth,
1159 ResolvedAuth::Basic {
1160 username: "user".to_string(),
1161 password: Some("pass".to_string()),
1162 }
1163 );
1164 }
1165
1166 #[test]
1167 fn prepared_request_resolves_header_auth() {
1168 let op = make_full_op("GET", "/test", Vec::new(), Vec::new(), Vec::new(), None);
1169 let cmd = Command::new("test");
1170 let matches = cmd.try_get_matches_from(["test"]).unwrap();
1171
1172 let prepared = PreparedRequest::from_operation(
1173 "https://api.example.com",
1174 &Auth::Header {
1175 name: "X-API-Key",
1176 value: "secret",
1177 },
1178 &op,
1179 &matches,
1180 )
1181 .unwrap();
1182
1183 assert_eq!(
1184 prepared.auth,
1185 ResolvedAuth::Header {
1186 name: "X-API-Key".to_string(),
1187 value: "secret".to_string(),
1188 }
1189 );
1190 }
1191
1192 #[test]
1193 fn prepared_request_resolves_query_auth_separate_from_query_pairs() {
1194 let op = make_full_op(
1195 "GET",
1196 "/test",
1197 Vec::new(),
1198 vec![Param {
1199 name: "verbose".into(),
1200 description: String::new(),
1201 required: false,
1202 schema: json!({"type": "boolean"}),
1203 }],
1204 Vec::new(),
1205 None,
1206 );
1207 let cmd = Command::new("test").arg(
1208 Arg::new("verbose")
1209 .long("verbose")
1210 .action(ArgAction::SetTrue),
1211 );
1212 let matches = cmd.try_get_matches_from(["test", "--verbose"]).unwrap();
1213
1214 let prepared = PreparedRequest::from_operation(
1215 "https://api.example.com",
1216 &Auth::Query {
1217 name: "api_key",
1218 value: "secret",
1219 },
1220 &op,
1221 &matches,
1222 )
1223 .unwrap();
1224
1225 assert_eq!(
1227 prepared.query_pairs,
1228 vec![("verbose".to_string(), "true".to_string())]
1229 );
1230 assert_eq!(
1231 prepared.auth,
1232 ResolvedAuth::Query {
1233 name: "api_key".to_string(),
1234 value: "secret".to_string(),
1235 }
1236 );
1237 }
1238
1239 #[test]
1240 fn prepared_request_url_encodes_path_params() {
1241 let op = make_full_op(
1242 "GET",
1243 "/items/{name}",
1244 vec![Param {
1245 name: "name".into(),
1246 description: String::new(),
1247 required: true,
1248 schema: json!({"type": "string"}),
1249 }],
1250 Vec::new(),
1251 Vec::new(),
1252 None,
1253 );
1254 let cmd = Command::new("test").arg(Arg::new("name").required(true));
1255 let matches = cmd.try_get_matches_from(["test", "hello world"]).unwrap();
1256
1257 let prepared =
1258 PreparedRequest::from_operation("https://api.example.com", &Auth::None, &op, &matches)
1259 .unwrap();
1260
1261 assert_eq!(prepared.url, "https://api.example.com/items/hello%20world");
1262 }
1263
1264 #[test]
1265 fn prepared_request_returns_error_for_unsupported_method() {
1266 let op = make_full_op(
1268 "NOT VALID",
1269 "/test",
1270 Vec::new(),
1271 Vec::new(),
1272 Vec::new(),
1273 None,
1274 );
1275 let cmd = Command::new("test");
1276 let matches = cmd.try_get_matches_from(["test"]).unwrap();
1277
1278 let result =
1279 PreparedRequest::from_operation("https://api.example.com", &Auth::None, &op, &matches);
1280 assert!(result.is_err());
1281 assert!(result
1282 .unwrap_err()
1283 .to_string()
1284 .contains("unsupported HTTP method"));
1285 }
1286
1287 #[test]
1290 fn builder_new_sets_method_and_url() {
1291 let req = PreparedRequest::new(Method::GET, "https://api.example.com/test");
1292
1293 assert_eq!(req.method, Method::GET);
1294 assert_eq!(req.url, "https://api.example.com/test");
1295 assert!(req.query_pairs.is_empty());
1296 assert!(req.headers.is_empty());
1297 assert!(req.body.is_none());
1298 assert_eq!(req.auth, ResolvedAuth::None);
1299 }
1300
1301 #[test]
1302 fn builder_query_appends_pairs() {
1303 let req = PreparedRequest::new(Method::GET, "https://example.com")
1304 .query("limit", "10")
1305 .query("offset", "0");
1306
1307 assert_eq!(
1308 req.query_pairs,
1309 vec![
1310 ("limit".to_string(), "10".to_string()),
1311 ("offset".to_string(), "0".to_string()),
1312 ]
1313 );
1314 }
1315
1316 #[test]
1317 fn builder_header_appends_headers() {
1318 let req = PreparedRequest::new(Method::GET, "https://example.com")
1319 .header("X-Request-Id", "abc123")
1320 .header("Accept", "application/json");
1321
1322 assert_eq!(
1323 req.headers,
1324 vec![
1325 ("X-Request-Id".to_string(), "abc123".to_string()),
1326 ("Accept".to_string(), "application/json".to_string()),
1327 ]
1328 );
1329 }
1330
1331 #[test]
1332 fn builder_clear_query_removes_all_pairs() {
1333 let req = PreparedRequest::new(Method::GET, "https://example.com")
1334 .query("a", "1")
1335 .query("b", "2")
1336 .clear_query();
1337
1338 assert!(req.query_pairs.is_empty());
1339 }
1340
1341 #[test]
1342 fn builder_clear_headers_removes_all_headers() {
1343 let req = PreparedRequest::new(Method::GET, "https://example.com")
1344 .header("X-Foo", "bar")
1345 .header("X-Baz", "qux")
1346 .clear_headers();
1347
1348 assert!(req.headers.is_empty());
1349 }
1350
1351 #[test]
1352 fn builder_body_sets_json() {
1353 let req = PreparedRequest::new(Method::POST, "https://example.com")
1354 .body(json!({"input": {"prompt": "hello"}}));
1355
1356 assert_eq!(req.body, Some(json!({"input": {"prompt": "hello"}})));
1357 }
1358
1359 #[test]
1360 fn builder_auth_sets_resolved_auth() {
1361 let req = PreparedRequest::new(Method::POST, "https://example.com")
1362 .auth(ResolvedAuth::Bearer("my-token".into()));
1363
1364 assert_eq!(req.auth, ResolvedAuth::Bearer("my-token".to_string()));
1365 }
1366
1367 #[test]
1368 fn builder_chaining_all_fields() {
1369 let req = PreparedRequest::new(Method::POST, "https://api.runpod.ai/v2/abc/run")
1370 .auth(ResolvedAuth::Bearer("key".into()))
1371 .body(json!({"input": {}}))
1372 .query("wait", "90000")
1373 .header("X-Custom", "value");
1374
1375 assert_eq!(req.method, Method::POST);
1376 assert_eq!(req.url, "https://api.runpod.ai/v2/abc/run");
1377 assert_eq!(req.auth, ResolvedAuth::Bearer("key".to_string()));
1378 assert_eq!(req.body, Some(json!({"input": {}})));
1379 assert_eq!(
1380 req.query_pairs,
1381 vec![("wait".to_string(), "90000".to_string())]
1382 );
1383 assert_eq!(
1384 req.headers,
1385 vec![("X-Custom".to_string(), "value".to_string())]
1386 );
1387 }
1388
1389 #[test]
1390 fn builder_send_executes_request() {
1391 let mut server = mockito::Server::new();
1392 let mock = server
1393 .mock("POST", "/v2/abc/run")
1394 .match_header("authorization", "Bearer test-key")
1395 .match_header("content-type", "application/json")
1396 .match_body(mockito::Matcher::Json(json!({"input": {"prompt": "hi"}})))
1397 .with_status(200)
1398 .with_header("content-type", "application/json")
1399 .with_body(r#"{"id":"job-123","status":"IN_QUEUE"}"#)
1400 .create();
1401
1402 let req = PreparedRequest::new(Method::POST, format!("{}/v2/abc/run", server.url()))
1403 .auth(ResolvedAuth::Bearer("test-key".into()))
1404 .body(json!({"input": {"prompt": "hi"}}));
1405
1406 let client = Client::new();
1407 let resp = req.send(&client).expect("send should succeed");
1408 assert!(resp.status.is_success());
1409 let val = resp.into_json().expect("should parse JSON");
1410 assert_eq!(val["id"], "job-123");
1411 assert_eq!(val["status"], "IN_QUEUE");
1412 mock.assert();
1413 }
1414
1415 #[test]
1416 fn builder_send_with_query_auth() {
1417 let mut server = mockito::Server::new();
1418 let mock = server
1419 .mock("GET", "/health")
1420 .match_query(mockito::Matcher::UrlEncoded(
1421 "api_key".into(),
1422 "secret".into(),
1423 ))
1424 .with_status(200)
1425 .with_header("content-type", "application/json")
1426 .with_body(r#"{"ok":true}"#)
1427 .create();
1428
1429 let req = PreparedRequest::new(Method::GET, format!("{}/health", server.url())).auth(
1430 ResolvedAuth::Query {
1431 name: "api_key".into(),
1432 value: "secret".into(),
1433 },
1434 );
1435
1436 let client = Client::new();
1437 let resp = req.send(&client).expect("send should succeed");
1438 let val = resp.into_json().expect("should parse JSON");
1439 assert_eq!(val["ok"], true);
1440 mock.assert();
1441 }
1442}