1use reqwest::blocking::Client;
14use reqwest::Method;
15use serde_json::Value;
16
17use crate::error::DispatchError;
18use crate::spec::{is_bool_schema, ApiOperation};
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22#[non_exhaustive]
23pub enum Auth<'a> {
24 None,
26 Bearer(&'a str),
28 Header { name: &'a str, value: &'a str },
30 Basic {
32 username: &'a str,
33 password: Option<&'a str>,
34 },
35 Query { name: &'a str, value: &'a str },
37}
38
39#[derive(Debug, Clone, PartialEq, Eq)]
44#[non_exhaustive]
45pub enum ResolvedAuth {
46 None,
48 Bearer(String),
50 Header { name: String, value: String },
52 Basic {
54 username: String,
55 password: Option<String>,
56 },
57 Query { name: String, value: String },
59}
60
61impl From<&Auth<'_>> for ResolvedAuth {
62 fn from(auth: &Auth<'_>) -> Self {
63 match auth {
64 Auth::None => Self::None,
65 Auth::Bearer(token) => Self::Bearer(token.to_string()),
66 Auth::Header { name, value } => Self::Header {
67 name: name.to_string(),
68 value: value.to_string(),
69 },
70 Auth::Basic { username, password } => Self::Basic {
71 username: username.to_string(),
72 password: password.map(|p| p.to_string()),
73 },
74 Auth::Query { name, value } => Self::Query {
75 name: name.to_string(),
76 value: value.to_string(),
77 },
78 }
79 }
80}
81
82#[derive(Debug, Clone)]
111#[non_exhaustive]
112pub struct PreparedRequest {
113 pub method: Method,
115 pub url: String,
117 pub query_pairs: Vec<(String, String)>,
123 pub headers: Vec<(String, String)>,
127 pub body: Option<Value>,
129 pub auth: ResolvedAuth,
131}
132
133impl PreparedRequest {
134 pub fn from_operation(
136 base_url: &str,
137 auth: &Auth<'_>,
138 op: &ApiOperation,
139 matches: &clap::ArgMatches,
140 ) -> Result<Self, DispatchError> {
141 let url = build_url(base_url, op, matches);
142 let query_pairs = build_query_pairs(op, matches);
143 let body = build_body(op, matches)?;
144 let headers = collect_headers(op, matches);
145 let method: Method = op
146 .method
147 .parse()
148 .map_err(|_| DispatchError::UnsupportedMethod {
149 method: op.method.clone(),
150 })?;
151
152 Ok(Self {
153 method,
154 url,
155 query_pairs,
156 headers,
157 body,
158 auth: ResolvedAuth::from(auth),
159 })
160 }
161
162 pub fn send(&self, client: &Client) -> Result<Value, DispatchError> {
164 let mut req = client.request(self.method.clone(), &self.url);
165
166 match &self.auth {
167 ResolvedAuth::None => {}
168 ResolvedAuth::Bearer(token) => {
169 req = req.bearer_auth(token);
170 }
171 ResolvedAuth::Header { name, value } => {
172 req = req.header(name, value);
173 }
174 ResolvedAuth::Basic { username, password } => {
175 req = req.basic_auth(username, password.as_deref());
176 }
177 ResolvedAuth::Query { .. } => {} }
179 if !self.query_pairs.is_empty() {
180 req = req.query(&self.query_pairs);
181 }
182 if let ResolvedAuth::Query { name, value } = &self.auth {
183 req = req.query(&[(name, value)]);
184 }
185 for (name, val) in &self.headers {
186 req = req.header(name, val);
187 }
188 if let Some(body) = &self.body {
189 req = req.json(body);
190 }
191
192 send_request(req)
193 }
194}
195
196pub fn dispatch(
201 client: &Client,
202 base_url: &str,
203 auth: &Auth<'_>,
204 op: &ApiOperation,
205 matches: &clap::ArgMatches,
206) -> Result<Value, DispatchError> {
207 PreparedRequest::from_operation(base_url, auth, op, matches)?.send(client)
208}
209
210fn build_url(base_url: &str, op: &ApiOperation, matches: &clap::ArgMatches) -> String {
211 let base = base_url.trim_end_matches('/');
212 let mut url = format!("{}{}", base, op.path);
213 for param in &op.path_params {
214 if let Some(val) = matches.get_one::<String>(¶m.name) {
215 url = url.replace(&format!("{{{}}}", param.name), &urlencoding::encode(val));
216 }
217 }
218 url
219}
220
221fn build_query_pairs(op: &ApiOperation, matches: &clap::ArgMatches) -> Vec<(String, String)> {
222 let mut pairs = Vec::new();
223 for param in &op.query_params {
224 if is_bool_schema(¶m.schema) {
225 if matches.get_flag(¶m.name) {
226 pairs.push((param.name.clone(), "true".to_string()));
227 }
228 } else if let Some(val) = matches.get_one::<String>(¶m.name) {
229 pairs.push((param.name.clone(), val.clone()));
230 }
231 }
232 pairs
233}
234
235fn collect_headers(op: &ApiOperation, matches: &clap::ArgMatches) -> Vec<(String, String)> {
236 let mut headers = Vec::new();
237 for param in &op.header_params {
238 if let Some(val) = matches.get_one::<String>(¶m.name) {
239 headers.push((param.name.clone(), val.clone()));
240 }
241 }
242 headers
243}
244
245fn send_request(req: reqwest::blocking::RequestBuilder) -> Result<Value, DispatchError> {
246 let resp = req.send().map_err(DispatchError::RequestFailed)?;
247 let status = resp.status();
248 let text = resp.text().map_err(DispatchError::ResponseRead)?;
249
250 if !status.is_success() {
251 return Err(DispatchError::HttpError { status, body: text });
252 }
253
254 let value: Value = serde_json::from_str(&text).unwrap_or(Value::String(text));
255 Ok(value)
256}
257
258fn build_body(
259 op: &ApiOperation,
260 matches: &clap::ArgMatches,
261) -> Result<Option<Value>, DispatchError> {
262 if op.body_schema.is_none() {
263 return Ok(None);
264 }
265
266 if let Some(json_str) = matches.get_one::<String>("json-body") {
268 let val: Value = serde_json::from_str(json_str).map_err(DispatchError::InvalidJsonBody)?;
269 return Ok(Some(val));
270 }
271
272 if let Some(fields) = matches.get_many::<String>("field") {
274 let mut obj = serde_json::Map::new();
275 for field in fields {
276 let (key, val) =
277 field
278 .split_once('=')
279 .ok_or_else(|| DispatchError::InvalidFieldFormat {
280 field: field.to_string(),
281 })?;
282 let json_val = serde_json::from_str(val).unwrap_or(Value::String(val.to_string()));
284 obj.insert(key.to_string(), json_val);
285 }
286 return Ok(Some(Value::Object(obj)));
287 }
288
289 if op.body_required {
290 return Err(DispatchError::BodyRequired);
291 }
292
293 Ok(None)
294}
295
296#[cfg(test)]
297mod tests {
298 use super::*;
299 use crate::spec::{ApiOperation, Param};
300 use clap::{Arg, ArgAction, Command};
301 use reqwest::blocking::Client;
302 use serde_json::json;
303
304 fn make_op_with_body(body_schema: Option<Value>) -> ApiOperation {
305 ApiOperation {
306 operation_id: "TestOp".to_string(),
307 method: "POST".to_string(),
308 path: "/test".to_string(),
309 group: "Test".to_string(),
310 summary: String::new(),
311 path_params: Vec::new(),
312 query_params: Vec::new(),
313 header_params: Vec::new(),
314 body_schema,
315 body_required: false,
316 }
317 }
318
319 fn build_matches_with_args(args: &[&str], has_body: bool) -> clap::ArgMatches {
320 let mut cmd = Command::new("test");
321 if has_body {
322 cmd = cmd
323 .arg(
324 Arg::new("json-body")
325 .long("json")
326 .short('j')
327 .action(ArgAction::Set),
328 )
329 .arg(
330 Arg::new("field")
331 .long("field")
332 .short('f')
333 .action(ArgAction::Append),
334 );
335 }
336 cmd.try_get_matches_from(args).unwrap()
337 }
338
339 #[test]
340 fn build_body_returns_none_when_no_body_schema() {
341 let op = make_op_with_body(None);
342 let matches = build_matches_with_args(&["test"], false);
343
344 let result = build_body(&op, &matches).unwrap();
345 assert!(result.is_none());
346 }
347
348 #[test]
349 fn build_body_parses_json_flag() {
350 let op = make_op_with_body(Some(json!({"type": "object"})));
351 let matches =
352 build_matches_with_args(&["test", "--json", r#"{"name":"pod1","gpu":2}"#], true);
353
354 let result = build_body(&op, &matches).unwrap();
355 assert!(result.is_some());
356 let body = result.unwrap();
357 assert_eq!(body["name"], "pod1");
358 assert_eq!(body["gpu"], 2);
359 }
360
361 #[test]
362 fn build_body_parses_field_key_value() {
363 let op = make_op_with_body(Some(json!({"type": "object"})));
364 let matches =
365 build_matches_with_args(&["test", "--field", "name=pod1", "--field", "gpu=2"], true);
366
367 let result = build_body(&op, &matches).unwrap();
368 assert!(result.is_some());
369 let body = result.unwrap();
370 assert_eq!(body["name"], "pod1");
371 assert_eq!(body["gpu"], 2);
373 }
374
375 #[test]
376 fn build_body_field_string_fallback() {
377 let op = make_op_with_body(Some(json!({"type": "object"})));
378 let matches = build_matches_with_args(&["test", "--field", "name=hello world"], true);
379
380 let result = build_body(&op, &matches).unwrap();
381 let body = result.unwrap();
382 assert_eq!(body["name"], "hello world");
383 }
384
385 #[test]
386 fn build_body_returns_error_for_invalid_field_format() {
387 let op = make_op_with_body(Some(json!({"type": "object"})));
388 let matches = build_matches_with_args(&["test", "--field", "no-equals-sign"], true);
389
390 let result = build_body(&op, &matches);
391 assert!(result.is_err());
392 let err_msg = result.unwrap_err().to_string();
393 assert!(
394 err_msg.contains("invalid --field format"),
395 "error should mention invalid format, got: {err_msg}"
396 );
397 }
398
399 #[test]
400 fn build_body_returns_error_for_invalid_json() {
401 let op = make_op_with_body(Some(json!({"type": "object"})));
402 let matches = build_matches_with_args(&["test", "--json", "{invalid json}"], true);
403
404 let result = build_body(&op, &matches);
405 assert!(result.is_err());
406 let err_msg = result.unwrap_err().to_string();
407 assert!(
408 err_msg.contains("invalid JSON"),
409 "error should mention invalid JSON, got: {err_msg}"
410 );
411 }
412
413 #[test]
414 fn build_body_returns_none_when_schema_present_but_no_flags() {
415 let op = make_op_with_body(Some(json!({"type": "object"})));
416 let matches = build_matches_with_args(&["test"], true);
417
418 let result = build_body(&op, &matches).unwrap();
419 assert!(result.is_none());
420 }
421
422 #[test]
423 fn build_body_json_takes_precedence_over_field() {
424 let op = make_op_with_body(Some(json!({"type": "object"})));
425 let matches = build_matches_with_args(
426 &[
427 "test",
428 "--json",
429 r#"{"from":"json"}"#,
430 "--field",
431 "from=field",
432 ],
433 true,
434 );
435
436 let result = build_body(&op, &matches).unwrap();
437 let body = result.unwrap();
438 assert_eq!(body["from"], "json");
440 }
441
442 #[test]
443 fn build_body_returns_error_when_body_required_but_not_provided() {
444 let mut op = make_op_with_body(Some(json!({"type": "object"})));
445 op.body_required = true;
446 let matches = build_matches_with_args(&["test"], true);
447
448 let result = build_body(&op, &matches);
449 assert!(result.is_err());
450 assert!(result
451 .unwrap_err()
452 .to_string()
453 .contains("request body is required"));
454 }
455
456 fn make_full_op(
459 method: &str,
460 path: &str,
461 path_params: Vec<Param>,
462 query_params: Vec<Param>,
463 header_params: Vec<Param>,
464 body_schema: Option<serde_json::Value>,
465 ) -> ApiOperation {
466 ApiOperation {
467 operation_id: "TestOp".to_string(),
468 method: method.to_string(),
469 path: path.to_string(),
470 group: "Test".to_string(),
471 summary: String::new(),
472 path_params,
473 query_params,
474 header_params,
475 body_schema,
476 body_required: false,
477 }
478 }
479
480 #[test]
481 fn dispatch_sends_get_with_path_and_query_params() {
482 let mut server = mockito::Server::new();
483 let mock = server
484 .mock("GET", "/pods/123")
485 .match_query(mockito::Matcher::UrlEncoded(
486 "verbose".into(),
487 "true".into(),
488 ))
489 .match_header("authorization", "Bearer test-key")
490 .with_status(200)
491 .with_header("content-type", "application/json")
492 .with_body(r#"{"id":"123"}"#)
493 .create();
494
495 let op = make_full_op(
496 "GET",
497 "/pods/{podId}",
498 vec![Param {
499 name: "podId".into(),
500 description: String::new(),
501 required: true,
502 schema: json!({"type": "string"}),
503 }],
504 vec![Param {
505 name: "verbose".into(),
506 description: String::new(),
507 required: false,
508 schema: json!({"type": "boolean"}),
509 }],
510 Vec::new(),
511 None,
512 );
513
514 let cmd = Command::new("test")
515 .arg(Arg::new("podId").required(true))
516 .arg(
517 Arg::new("verbose")
518 .long("verbose")
519 .action(ArgAction::SetTrue),
520 );
521 let matches = cmd
522 .try_get_matches_from(["test", "123", "--verbose"])
523 .unwrap();
524
525 let client = Client::new();
526 let result = dispatch(
527 &client,
528 &server.url(),
529 &Auth::Bearer("test-key"),
530 &op,
531 &matches,
532 );
533 assert!(result.is_ok());
534 assert_eq!(result.unwrap()["id"], "123");
535 mock.assert();
536 }
537
538 #[test]
539 fn dispatch_sends_post_with_json_body() {
540 let mut server = mockito::Server::new();
541 let mock = server
542 .mock("POST", "/pods")
543 .match_header("content-type", "application/json")
544 .match_body(mockito::Matcher::Json(json!({"name": "pod1"})))
545 .with_status(200)
546 .with_header("content-type", "application/json")
547 .with_body(r#"{"id":"new"}"#)
548 .create();
549
550 let op = make_full_op(
551 "POST",
552 "/pods",
553 Vec::new(),
554 Vec::new(),
555 Vec::new(),
556 Some(json!({"type": "object"})),
557 );
558
559 let cmd = Command::new("test").arg(
560 Arg::new("json-body")
561 .long("json")
562 .short('j')
563 .action(ArgAction::Set),
564 );
565 let matches = cmd
566 .try_get_matches_from(["test", "--json", r#"{"name":"pod1"}"#])
567 .unwrap();
568
569 let client = Client::new();
570 let result = dispatch(&client, &server.url(), &Auth::Bearer("key"), &op, &matches);
571 assert!(result.is_ok());
572 assert_eq!(result.unwrap()["id"], "new");
573 mock.assert();
574 }
575
576 #[test]
577 fn dispatch_sends_header_params() {
578 let mut server = mockito::Server::new();
579 let mock = server
580 .mock("GET", "/test")
581 .match_header("X-Request-Id", "abc123")
582 .with_status(200)
583 .with_header("content-type", "application/json")
584 .with_body(r#"{"ok":true}"#)
585 .create();
586
587 let op = make_full_op(
588 "GET",
589 "/test",
590 Vec::new(),
591 Vec::new(),
592 vec![Param {
593 name: "X-Request-Id".into(),
594 description: String::new(),
595 required: false,
596 schema: json!({"type": "string"}),
597 }],
598 None,
599 );
600
601 let cmd = Command::new("test").arg(
602 Arg::new("X-Request-Id")
603 .long("X-Request-Id")
604 .action(ArgAction::Set),
605 );
606 let matches = cmd
607 .try_get_matches_from(["test", "--X-Request-Id", "abc123"])
608 .unwrap();
609
610 let client = Client::new();
611 let result = dispatch(&client, &server.url(), &Auth::Bearer("key"), &op, &matches);
612 assert!(result.is_ok());
613 mock.assert();
614 }
615
616 #[test]
617 fn dispatch_url_encodes_path_params() {
618 let mut server = mockito::Server::new();
619 let mock = server
620 .mock("GET", "/items/hello%20world")
621 .with_status(200)
622 .with_header("content-type", "application/json")
623 .with_body(r#"{"ok":true}"#)
624 .create();
625
626 let op = make_full_op(
627 "GET",
628 "/items/{itemId}",
629 vec![Param {
630 name: "itemId".into(),
631 description: String::new(),
632 required: true,
633 schema: json!({"type": "string"}),
634 }],
635 Vec::new(),
636 Vec::new(),
637 None,
638 );
639
640 let cmd = Command::new("test").arg(Arg::new("itemId").required(true));
641 let matches = cmd.try_get_matches_from(["test", "hello world"]).unwrap();
642
643 let client = Client::new();
644 let result = dispatch(&client, &server.url(), &Auth::Bearer("key"), &op, &matches);
645 assert!(result.is_ok());
646 mock.assert();
647 }
648
649 #[test]
650 fn dispatch_returns_error_on_non_success_status() {
651 let mut server = mockito::Server::new();
652 let _mock = server
653 .mock("GET", "/fail")
654 .with_status(404)
655 .with_body("not found")
656 .create();
657
658 let op = make_full_op("GET", "/fail", Vec::new(), Vec::new(), Vec::new(), None);
659
660 let cmd = Command::new("test");
661 let matches = cmd.try_get_matches_from(["test"]).unwrap();
662
663 let client = Client::new();
664 let result = dispatch(&client, &server.url(), &Auth::Bearer("key"), &op, &matches);
665 assert!(result.is_err());
666 let err_msg = result.unwrap_err().to_string();
667 assert!(
668 err_msg.contains("404"),
669 "error should contain status code, got: {err_msg}"
670 );
671 }
672
673 #[test]
674 fn dispatch_omits_auth_header_when_auth_none() {
675 let mut server = mockito::Server::new();
676 let mock = server
677 .mock("GET", "/test")
678 .match_header("authorization", mockito::Matcher::Missing)
679 .with_status(200)
680 .with_header("content-type", "application/json")
681 .with_body(r#"{"ok":true}"#)
682 .create();
683
684 let op = make_full_op("GET", "/test", Vec::new(), Vec::new(), Vec::new(), None);
685
686 let cmd = Command::new("test");
687 let matches = cmd.try_get_matches_from(["test"]).unwrap();
688
689 let client = Client::new();
690 let result = dispatch(&client, &server.url(), &Auth::None, &op, &matches);
691 assert!(result.is_ok());
692 mock.assert();
693 }
694
695 #[test]
696 fn dispatch_sends_custom_header_auth() {
697 let mut server = mockito::Server::new();
698 let mock = server
699 .mock("GET", "/test")
700 .match_header("X-API-Key", "my-secret")
701 .with_status(200)
702 .with_header("content-type", "application/json")
703 .with_body(r#"{"ok":true}"#)
704 .create();
705
706 let op = make_full_op("GET", "/test", Vec::new(), Vec::new(), Vec::new(), None);
707
708 let cmd = Command::new("test");
709 let matches = cmd.try_get_matches_from(["test"]).unwrap();
710
711 let client = Client::new();
712 let auth = Auth::Header {
713 name: "X-API-Key",
714 value: "my-secret",
715 };
716 let result = dispatch(&client, &server.url(), &auth, &op, &matches);
717 assert!(result.is_ok());
718 mock.assert();
719 }
720
721 #[test]
722 fn dispatch_sends_basic_auth() {
723 let mut server = mockito::Server::new();
724 let mock = server
726 .mock("GET", "/test")
727 .match_header("authorization", "Basic dXNlcjpwYXNz")
728 .with_status(200)
729 .with_header("content-type", "application/json")
730 .with_body(r#"{"ok":true}"#)
731 .create();
732
733 let op = make_full_op("GET", "/test", Vec::new(), Vec::new(), Vec::new(), None);
734
735 let cmd = Command::new("test");
736 let matches = cmd.try_get_matches_from(["test"]).unwrap();
737
738 let client = Client::new();
739 let auth = Auth::Basic {
740 username: "user",
741 password: Some("pass"),
742 };
743 let result = dispatch(&client, &server.url(), &auth, &op, &matches);
744 assert!(result.is_ok());
745 mock.assert();
746 }
747
748 #[test]
749 fn dispatch_sends_query_auth() {
750 let mut server = mockito::Server::new();
751 let mock = server
752 .mock("GET", "/test")
753 .match_query(mockito::Matcher::UrlEncoded(
754 "api_key".into(),
755 "my-secret".into(),
756 ))
757 .match_header("authorization", mockito::Matcher::Missing)
758 .with_status(200)
759 .with_header("content-type", "application/json")
760 .with_body(r#"{"ok":true}"#)
761 .create();
762
763 let op = make_full_op("GET", "/test", Vec::new(), Vec::new(), Vec::new(), None);
764
765 let cmd = Command::new("test");
766 let matches = cmd.try_get_matches_from(["test"]).unwrap();
767
768 let client = Client::new();
769 let auth = Auth::Query {
770 name: "api_key",
771 value: "my-secret",
772 };
773 let result = dispatch(&client, &server.url(), &auth, &op, &matches);
774 assert!(result.is_ok());
775 mock.assert();
776 }
777
778 #[test]
779 fn dispatch_query_auth_coexists_with_operation_query_params() {
780 let mut server = mockito::Server::new();
781 let mock = server
782 .mock("GET", "/test")
783 .match_query(mockito::Matcher::AllOf(vec![
784 mockito::Matcher::UrlEncoded("verbose".into(), "true".into()),
785 mockito::Matcher::UrlEncoded("api_key".into(), "secret".into()),
786 ]))
787 .match_header("authorization", mockito::Matcher::Missing)
788 .with_status(200)
789 .with_header("content-type", "application/json")
790 .with_body(r#"{"ok":true}"#)
791 .create();
792
793 let op = make_full_op(
794 "GET",
795 "/test",
796 Vec::new(),
797 vec![Param {
798 name: "verbose".into(),
799 description: String::new(),
800 required: false,
801 schema: json!({"type": "boolean"}),
802 }],
803 Vec::new(),
804 None,
805 );
806
807 let cmd = Command::new("test").arg(
808 Arg::new("verbose")
809 .long("verbose")
810 .action(ArgAction::SetTrue),
811 );
812 let matches = cmd.try_get_matches_from(["test", "--verbose"]).unwrap();
813
814 let client = Client::new();
815 let auth = Auth::Query {
816 name: "api_key",
817 value: "secret",
818 };
819 let result = dispatch(&client, &server.url(), &auth, &op, &matches);
820 assert!(result.is_ok());
821 mock.assert();
822 }
823
824 #[test]
825 fn dispatch_returns_string_value_for_non_json_response() {
826 let mut server = mockito::Server::new();
827 let _mock = server
828 .mock("GET", "/plain")
829 .with_status(200)
830 .with_header("content-type", "text/plain")
831 .with_body("plain text response")
832 .create();
833
834 let op = make_full_op("GET", "/plain", Vec::new(), Vec::new(), Vec::new(), None);
835
836 let cmd = Command::new("test");
837 let matches = cmd.try_get_matches_from(["test"]).unwrap();
838
839 let client = Client::new();
840 let result = dispatch(&client, &server.url(), &Auth::Bearer("key"), &op, &matches);
841 assert!(result.is_ok());
842 assert_eq!(result.unwrap(), Value::String("plain text response".into()));
843 }
844
845 #[test]
848 fn prepared_request_resolves_url_and_method() {
849 let op = make_full_op(
850 "GET",
851 "/pods/{podId}",
852 vec![Param {
853 name: "podId".into(),
854 description: String::new(),
855 required: true,
856 schema: json!({"type": "string"}),
857 }],
858 Vec::new(),
859 Vec::new(),
860 None,
861 );
862 let cmd = Command::new("test").arg(Arg::new("podId").required(true));
863 let matches = cmd.try_get_matches_from(["test", "abc"]).unwrap();
864
865 let prepared =
866 PreparedRequest::from_operation("https://api.example.com", &Auth::None, &op, &matches)
867 .unwrap();
868
869 assert_eq!(prepared.method, Method::GET);
870 assert_eq!(prepared.url, "https://api.example.com/pods/abc");
871 assert!(prepared.query_pairs.is_empty());
872 assert!(prepared.headers.is_empty());
873 assert!(prepared.body.is_none());
874 assert_eq!(prepared.auth, ResolvedAuth::None);
875 }
876
877 #[test]
878 fn prepared_request_collects_query_pairs() {
879 let op = make_full_op(
880 "GET",
881 "/test",
882 Vec::new(),
883 vec![
884 Param {
885 name: "limit".into(),
886 description: String::new(),
887 required: false,
888 schema: json!({"type": "integer"}),
889 },
890 Param {
891 name: "verbose".into(),
892 description: String::new(),
893 required: false,
894 schema: json!({"type": "boolean"}),
895 },
896 ],
897 Vec::new(),
898 None,
899 );
900 let cmd = Command::new("test")
901 .arg(Arg::new("limit").long("limit").action(ArgAction::Set))
902 .arg(
903 Arg::new("verbose")
904 .long("verbose")
905 .action(ArgAction::SetTrue),
906 );
907 let matches = cmd
908 .try_get_matches_from(["test", "--limit", "10", "--verbose"])
909 .unwrap();
910
911 let prepared =
912 PreparedRequest::from_operation("https://api.example.com", &Auth::None, &op, &matches)
913 .unwrap();
914
915 assert_eq!(
916 prepared.query_pairs,
917 vec![
918 ("limit".to_string(), "10".to_string()),
919 ("verbose".to_string(), "true".to_string()),
920 ]
921 );
922 }
923
924 #[test]
925 fn prepared_request_collects_headers() {
926 let op = make_full_op(
927 "GET",
928 "/test",
929 Vec::new(),
930 Vec::new(),
931 vec![Param {
932 name: "X-Request-Id".into(),
933 description: String::new(),
934 required: false,
935 schema: json!({"type": "string"}),
936 }],
937 None,
938 );
939 let cmd = Command::new("test").arg(
940 Arg::new("X-Request-Id")
941 .long("X-Request-Id")
942 .action(ArgAction::Set),
943 );
944 let matches = cmd
945 .try_get_matches_from(["test", "--X-Request-Id", "req-42"])
946 .unwrap();
947
948 let prepared =
949 PreparedRequest::from_operation("https://api.example.com", &Auth::None, &op, &matches)
950 .unwrap();
951
952 assert_eq!(
953 prepared.headers,
954 vec![("X-Request-Id".to_string(), "req-42".to_string())]
955 );
956 }
957
958 #[test]
959 fn prepared_request_resolves_body() {
960 let op = make_full_op(
961 "POST",
962 "/test",
963 Vec::new(),
964 Vec::new(),
965 Vec::new(),
966 Some(json!({"type": "object"})),
967 );
968 let cmd = Command::new("test").arg(
969 Arg::new("json-body")
970 .long("json")
971 .short('j')
972 .action(ArgAction::Set),
973 );
974 let matches = cmd
975 .try_get_matches_from(["test", "--json", r#"{"key":"val"}"#])
976 .unwrap();
977
978 let prepared =
979 PreparedRequest::from_operation("https://api.example.com", &Auth::None, &op, &matches)
980 .unwrap();
981
982 assert_eq!(prepared.body, Some(json!({"key": "val"})));
983 }
984
985 #[test]
986 fn prepared_request_resolves_bearer_auth() {
987 let op = make_full_op("GET", "/test", Vec::new(), Vec::new(), Vec::new(), None);
988 let cmd = Command::new("test");
989 let matches = cmd.try_get_matches_from(["test"]).unwrap();
990
991 let prepared = PreparedRequest::from_operation(
992 "https://api.example.com",
993 &Auth::Bearer("my-token"),
994 &op,
995 &matches,
996 )
997 .unwrap();
998
999 assert_eq!(prepared.auth, ResolvedAuth::Bearer("my-token".to_string()));
1000 }
1001
1002 #[test]
1003 fn prepared_request_resolves_basic_auth() {
1004 let op = make_full_op("GET", "/test", Vec::new(), Vec::new(), Vec::new(), None);
1005 let cmd = Command::new("test");
1006 let matches = cmd.try_get_matches_from(["test"]).unwrap();
1007
1008 let prepared = PreparedRequest::from_operation(
1009 "https://api.example.com",
1010 &Auth::Basic {
1011 username: "user",
1012 password: Some("pass"),
1013 },
1014 &op,
1015 &matches,
1016 )
1017 .unwrap();
1018
1019 assert_eq!(
1020 prepared.auth,
1021 ResolvedAuth::Basic {
1022 username: "user".to_string(),
1023 password: Some("pass".to_string()),
1024 }
1025 );
1026 }
1027
1028 #[test]
1029 fn prepared_request_resolves_header_auth() {
1030 let op = make_full_op("GET", "/test", Vec::new(), Vec::new(), Vec::new(), None);
1031 let cmd = Command::new("test");
1032 let matches = cmd.try_get_matches_from(["test"]).unwrap();
1033
1034 let prepared = PreparedRequest::from_operation(
1035 "https://api.example.com",
1036 &Auth::Header {
1037 name: "X-API-Key",
1038 value: "secret",
1039 },
1040 &op,
1041 &matches,
1042 )
1043 .unwrap();
1044
1045 assert_eq!(
1046 prepared.auth,
1047 ResolvedAuth::Header {
1048 name: "X-API-Key".to_string(),
1049 value: "secret".to_string(),
1050 }
1051 );
1052 }
1053
1054 #[test]
1055 fn prepared_request_resolves_query_auth_separate_from_query_pairs() {
1056 let op = make_full_op(
1057 "GET",
1058 "/test",
1059 Vec::new(),
1060 vec![Param {
1061 name: "verbose".into(),
1062 description: String::new(),
1063 required: false,
1064 schema: json!({"type": "boolean"}),
1065 }],
1066 Vec::new(),
1067 None,
1068 );
1069 let cmd = Command::new("test").arg(
1070 Arg::new("verbose")
1071 .long("verbose")
1072 .action(ArgAction::SetTrue),
1073 );
1074 let matches = cmd.try_get_matches_from(["test", "--verbose"]).unwrap();
1075
1076 let prepared = PreparedRequest::from_operation(
1077 "https://api.example.com",
1078 &Auth::Query {
1079 name: "api_key",
1080 value: "secret",
1081 },
1082 &op,
1083 &matches,
1084 )
1085 .unwrap();
1086
1087 assert_eq!(
1089 prepared.query_pairs,
1090 vec![("verbose".to_string(), "true".to_string())]
1091 );
1092 assert_eq!(
1093 prepared.auth,
1094 ResolvedAuth::Query {
1095 name: "api_key".to_string(),
1096 value: "secret".to_string(),
1097 }
1098 );
1099 }
1100
1101 #[test]
1102 fn prepared_request_url_encodes_path_params() {
1103 let op = make_full_op(
1104 "GET",
1105 "/items/{name}",
1106 vec![Param {
1107 name: "name".into(),
1108 description: String::new(),
1109 required: true,
1110 schema: json!({"type": "string"}),
1111 }],
1112 Vec::new(),
1113 Vec::new(),
1114 None,
1115 );
1116 let cmd = Command::new("test").arg(Arg::new("name").required(true));
1117 let matches = cmd.try_get_matches_from(["test", "hello world"]).unwrap();
1118
1119 let prepared =
1120 PreparedRequest::from_operation("https://api.example.com", &Auth::None, &op, &matches)
1121 .unwrap();
1122
1123 assert_eq!(prepared.url, "https://api.example.com/items/hello%20world");
1124 }
1125
1126 #[test]
1127 fn prepared_request_returns_error_for_unsupported_method() {
1128 let op = make_full_op(
1130 "NOT VALID",
1131 "/test",
1132 Vec::new(),
1133 Vec::new(),
1134 Vec::new(),
1135 None,
1136 );
1137 let cmd = Command::new("test");
1138 let matches = cmd.try_get_matches_from(["test"]).unwrap();
1139
1140 let result =
1141 PreparedRequest::from_operation("https://api.example.com", &Auth::None, &op, &matches);
1142 assert!(result.is_err());
1143 assert!(result
1144 .unwrap_err()
1145 .to_string()
1146 .contains("unsupported HTTP method"));
1147 }
1148}