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