1use reqwest::blocking::Client;
7use reqwest::Method;
8use serde_json::Value;
9
10use crate::error::DispatchError;
11use crate::spec::{is_bool_schema, ApiOperation};
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15#[non_exhaustive]
16pub enum Auth<'a> {
17 None,
19 Bearer(&'a str),
21 Header { name: &'a str, value: &'a str },
23 Basic {
25 username: &'a str,
26 password: Option<&'a str>,
27 },
28 Query { name: &'a str, value: &'a str },
30}
31
32pub fn dispatch(
34 client: &Client,
35 base_url: &str,
36 auth: &Auth<'_>,
37 op: &ApiOperation,
38 matches: &clap::ArgMatches,
39) -> Result<Value, DispatchError> {
40 let url = build_url(base_url, op, matches);
41 let query_pairs = build_query_pairs(op, matches);
42 let body = build_body(op, matches)?;
43 let headers = collect_headers(op, matches);
44
45 let method: Method = op
46 .method
47 .parse()
48 .map_err(|_| DispatchError::UnsupportedMethod {
49 method: op.method.clone(),
50 })?;
51
52 let mut req = client.request(method, &url);
53
54 match auth {
55 Auth::None => {}
56 Auth::Bearer(token) => {
57 req = req.bearer_auth(token);
58 }
59 Auth::Header { name, value } => {
60 req = req.header(*name, *value);
61 }
62 Auth::Basic { username, password } => {
63 req = req.basic_auth(*username, *password);
64 }
65 Auth::Query { .. } => {} }
67 if !query_pairs.is_empty() {
68 req = req.query(&query_pairs);
69 }
70 if let Auth::Query { name, value } = auth {
71 req = req.query(&[(*name, *value)]);
72 }
73 for (name, val) in &headers {
74 req = req.header(name, val);
75 }
76 if let Some(body) = body {
77 req = req.json(&body);
78 }
79
80 send_request(req)
81}
82
83fn build_url(base_url: &str, op: &ApiOperation, matches: &clap::ArgMatches) -> String {
84 let base = base_url.trim_end_matches('/');
85 let mut url = format!("{}{}", base, op.path);
86 for param in &op.path_params {
87 if let Some(val) = matches.get_one::<String>(¶m.name) {
88 url = url.replace(&format!("{{{}}}", param.name), &urlencoding::encode(val));
89 }
90 }
91 url
92}
93
94fn build_query_pairs(op: &ApiOperation, matches: &clap::ArgMatches) -> Vec<(String, String)> {
95 let mut pairs = Vec::new();
96 for param in &op.query_params {
97 if is_bool_schema(¶m.schema) {
98 if matches.get_flag(¶m.name) {
99 pairs.push((param.name.clone(), "true".to_string()));
100 }
101 } else if let Some(val) = matches.get_one::<String>(¶m.name) {
102 pairs.push((param.name.clone(), val.clone()));
103 }
104 }
105 pairs
106}
107
108fn collect_headers(op: &ApiOperation, matches: &clap::ArgMatches) -> Vec<(String, String)> {
109 let mut headers = Vec::new();
110 for param in &op.header_params {
111 if let Some(val) = matches.get_one::<String>(¶m.name) {
112 headers.push((param.name.clone(), val.clone()));
113 }
114 }
115 headers
116}
117
118fn send_request(req: reqwest::blocking::RequestBuilder) -> Result<Value, DispatchError> {
119 let resp = req.send().map_err(DispatchError::RequestFailed)?;
120 let status = resp.status();
121 let text = resp.text().map_err(DispatchError::ResponseRead)?;
122
123 if !status.is_success() {
124 return Err(DispatchError::HttpError { status, body: text });
125 }
126
127 let value: Value = serde_json::from_str(&text).unwrap_or(Value::String(text));
128 Ok(value)
129}
130
131fn build_body(
132 op: &ApiOperation,
133 matches: &clap::ArgMatches,
134) -> Result<Option<Value>, DispatchError> {
135 if op.body_schema.is_none() {
136 return Ok(None);
137 }
138
139 if let Some(json_str) = matches.get_one::<String>("json-body") {
141 let val: Value = serde_json::from_str(json_str).map_err(DispatchError::InvalidJsonBody)?;
142 return Ok(Some(val));
143 }
144
145 if let Some(fields) = matches.get_many::<String>("field") {
147 let mut obj = serde_json::Map::new();
148 for field in fields {
149 let (key, val) =
150 field
151 .split_once('=')
152 .ok_or_else(|| DispatchError::InvalidFieldFormat {
153 field: field.to_string(),
154 })?;
155 let json_val = serde_json::from_str(val).unwrap_or(Value::String(val.to_string()));
157 obj.insert(key.to_string(), json_val);
158 }
159 return Ok(Some(Value::Object(obj)));
160 }
161
162 if op.body_required {
163 return Err(DispatchError::BodyRequired);
164 }
165
166 Ok(None)
167}
168
169#[cfg(test)]
170mod tests {
171 use super::*;
172 use crate::spec::{ApiOperation, Param};
173 use clap::{Arg, ArgAction, Command};
174 use reqwest::blocking::Client;
175 use serde_json::json;
176
177 fn make_op_with_body(body_schema: Option<Value>) -> ApiOperation {
178 ApiOperation {
179 operation_id: "TestOp".to_string(),
180 method: "POST".to_string(),
181 path: "/test".to_string(),
182 group: "Test".to_string(),
183 summary: String::new(),
184 path_params: Vec::new(),
185 query_params: Vec::new(),
186 header_params: Vec::new(),
187 body_schema,
188 body_required: false,
189 }
190 }
191
192 fn build_matches_with_args(args: &[&str], has_body: bool) -> clap::ArgMatches {
193 let mut cmd = Command::new("test");
194 if has_body {
195 cmd = cmd
196 .arg(
197 Arg::new("json-body")
198 .long("json")
199 .short('j')
200 .action(ArgAction::Set),
201 )
202 .arg(
203 Arg::new("field")
204 .long("field")
205 .short('f')
206 .action(ArgAction::Append),
207 );
208 }
209 cmd.try_get_matches_from(args).unwrap()
210 }
211
212 #[test]
213 fn build_body_returns_none_when_no_body_schema() {
214 let op = make_op_with_body(None);
215 let matches = build_matches_with_args(&["test"], false);
216
217 let result = build_body(&op, &matches).unwrap();
218 assert!(result.is_none());
219 }
220
221 #[test]
222 fn build_body_parses_json_flag() {
223 let op = make_op_with_body(Some(json!({"type": "object"})));
224 let matches =
225 build_matches_with_args(&["test", "--json", r#"{"name":"pod1","gpu":2}"#], true);
226
227 let result = build_body(&op, &matches).unwrap();
228 assert!(result.is_some());
229 let body = result.unwrap();
230 assert_eq!(body["name"], "pod1");
231 assert_eq!(body["gpu"], 2);
232 }
233
234 #[test]
235 fn build_body_parses_field_key_value() {
236 let op = make_op_with_body(Some(json!({"type": "object"})));
237 let matches =
238 build_matches_with_args(&["test", "--field", "name=pod1", "--field", "gpu=2"], true);
239
240 let result = build_body(&op, &matches).unwrap();
241 assert!(result.is_some());
242 let body = result.unwrap();
243 assert_eq!(body["name"], "pod1");
244 assert_eq!(body["gpu"], 2);
246 }
247
248 #[test]
249 fn build_body_field_string_fallback() {
250 let op = make_op_with_body(Some(json!({"type": "object"})));
251 let matches = build_matches_with_args(&["test", "--field", "name=hello world"], true);
252
253 let result = build_body(&op, &matches).unwrap();
254 let body = result.unwrap();
255 assert_eq!(body["name"], "hello world");
256 }
257
258 #[test]
259 fn build_body_returns_error_for_invalid_field_format() {
260 let op = make_op_with_body(Some(json!({"type": "object"})));
261 let matches = build_matches_with_args(&["test", "--field", "no-equals-sign"], true);
262
263 let result = build_body(&op, &matches);
264 assert!(result.is_err());
265 let err_msg = result.unwrap_err().to_string();
266 assert!(
267 err_msg.contains("invalid --field format"),
268 "error should mention invalid format, got: {err_msg}"
269 );
270 }
271
272 #[test]
273 fn build_body_returns_error_for_invalid_json() {
274 let op = make_op_with_body(Some(json!({"type": "object"})));
275 let matches = build_matches_with_args(&["test", "--json", "{invalid json}"], true);
276
277 let result = build_body(&op, &matches);
278 assert!(result.is_err());
279 let err_msg = result.unwrap_err().to_string();
280 assert!(
281 err_msg.contains("invalid JSON"),
282 "error should mention invalid JSON, got: {err_msg}"
283 );
284 }
285
286 #[test]
287 fn build_body_returns_none_when_schema_present_but_no_flags() {
288 let op = make_op_with_body(Some(json!({"type": "object"})));
289 let matches = build_matches_with_args(&["test"], true);
290
291 let result = build_body(&op, &matches).unwrap();
292 assert!(result.is_none());
293 }
294
295 #[test]
296 fn build_body_json_takes_precedence_over_field() {
297 let op = make_op_with_body(Some(json!({"type": "object"})));
298 let matches = build_matches_with_args(
299 &[
300 "test",
301 "--json",
302 r#"{"from":"json"}"#,
303 "--field",
304 "from=field",
305 ],
306 true,
307 );
308
309 let result = build_body(&op, &matches).unwrap();
310 let body = result.unwrap();
311 assert_eq!(body["from"], "json");
313 }
314
315 #[test]
316 fn build_body_returns_error_when_body_required_but_not_provided() {
317 let mut op = make_op_with_body(Some(json!({"type": "object"})));
318 op.body_required = true;
319 let matches = build_matches_with_args(&["test"], true);
320
321 let result = build_body(&op, &matches);
322 assert!(result.is_err());
323 assert!(result
324 .unwrap_err()
325 .to_string()
326 .contains("request body is required"));
327 }
328
329 fn make_full_op(
332 method: &str,
333 path: &str,
334 path_params: Vec<Param>,
335 query_params: Vec<Param>,
336 header_params: Vec<Param>,
337 body_schema: Option<serde_json::Value>,
338 ) -> ApiOperation {
339 ApiOperation {
340 operation_id: "TestOp".to_string(),
341 method: method.to_string(),
342 path: path.to_string(),
343 group: "Test".to_string(),
344 summary: String::new(),
345 path_params,
346 query_params,
347 header_params,
348 body_schema,
349 body_required: false,
350 }
351 }
352
353 #[test]
354 fn dispatch_sends_get_with_path_and_query_params() {
355 let mut server = mockito::Server::new();
356 let mock = server
357 .mock("GET", "/pods/123")
358 .match_query(mockito::Matcher::UrlEncoded(
359 "verbose".into(),
360 "true".into(),
361 ))
362 .match_header("authorization", "Bearer test-key")
363 .with_status(200)
364 .with_header("content-type", "application/json")
365 .with_body(r#"{"id":"123"}"#)
366 .create();
367
368 let op = make_full_op(
369 "GET",
370 "/pods/{podId}",
371 vec![Param {
372 name: "podId".into(),
373 description: String::new(),
374 required: true,
375 schema: json!({"type": "string"}),
376 }],
377 vec![Param {
378 name: "verbose".into(),
379 description: String::new(),
380 required: false,
381 schema: json!({"type": "boolean"}),
382 }],
383 Vec::new(),
384 None,
385 );
386
387 let cmd = Command::new("test")
388 .arg(Arg::new("podId").required(true))
389 .arg(
390 Arg::new("verbose")
391 .long("verbose")
392 .action(ArgAction::SetTrue),
393 );
394 let matches = cmd
395 .try_get_matches_from(["test", "123", "--verbose"])
396 .unwrap();
397
398 let client = Client::new();
399 let result = dispatch(
400 &client,
401 &server.url(),
402 &Auth::Bearer("test-key"),
403 &op,
404 &matches,
405 );
406 assert!(result.is_ok());
407 assert_eq!(result.unwrap()["id"], "123");
408 mock.assert();
409 }
410
411 #[test]
412 fn dispatch_sends_post_with_json_body() {
413 let mut server = mockito::Server::new();
414 let mock = server
415 .mock("POST", "/pods")
416 .match_header("content-type", "application/json")
417 .match_body(mockito::Matcher::Json(json!({"name": "pod1"})))
418 .with_status(200)
419 .with_header("content-type", "application/json")
420 .with_body(r#"{"id":"new"}"#)
421 .create();
422
423 let op = make_full_op(
424 "POST",
425 "/pods",
426 Vec::new(),
427 Vec::new(),
428 Vec::new(),
429 Some(json!({"type": "object"})),
430 );
431
432 let cmd = Command::new("test").arg(
433 Arg::new("json-body")
434 .long("json")
435 .short('j')
436 .action(ArgAction::Set),
437 );
438 let matches = cmd
439 .try_get_matches_from(["test", "--json", r#"{"name":"pod1"}"#])
440 .unwrap();
441
442 let client = Client::new();
443 let result = dispatch(&client, &server.url(), &Auth::Bearer("key"), &op, &matches);
444 assert!(result.is_ok());
445 assert_eq!(result.unwrap()["id"], "new");
446 mock.assert();
447 }
448
449 #[test]
450 fn dispatch_sends_header_params() {
451 let mut server = mockito::Server::new();
452 let mock = server
453 .mock("GET", "/test")
454 .match_header("X-Request-Id", "abc123")
455 .with_status(200)
456 .with_header("content-type", "application/json")
457 .with_body(r#"{"ok":true}"#)
458 .create();
459
460 let op = make_full_op(
461 "GET",
462 "/test",
463 Vec::new(),
464 Vec::new(),
465 vec![Param {
466 name: "X-Request-Id".into(),
467 description: String::new(),
468 required: false,
469 schema: json!({"type": "string"}),
470 }],
471 None,
472 );
473
474 let cmd = Command::new("test").arg(
475 Arg::new("X-Request-Id")
476 .long("X-Request-Id")
477 .action(ArgAction::Set),
478 );
479 let matches = cmd
480 .try_get_matches_from(["test", "--X-Request-Id", "abc123"])
481 .unwrap();
482
483 let client = Client::new();
484 let result = dispatch(&client, &server.url(), &Auth::Bearer("key"), &op, &matches);
485 assert!(result.is_ok());
486 mock.assert();
487 }
488
489 #[test]
490 fn dispatch_url_encodes_path_params() {
491 let mut server = mockito::Server::new();
492 let mock = server
493 .mock("GET", "/items/hello%20world")
494 .with_status(200)
495 .with_header("content-type", "application/json")
496 .with_body(r#"{"ok":true}"#)
497 .create();
498
499 let op = make_full_op(
500 "GET",
501 "/items/{itemId}",
502 vec![Param {
503 name: "itemId".into(),
504 description: String::new(),
505 required: true,
506 schema: json!({"type": "string"}),
507 }],
508 Vec::new(),
509 Vec::new(),
510 None,
511 );
512
513 let cmd = Command::new("test").arg(Arg::new("itemId").required(true));
514 let matches = cmd.try_get_matches_from(["test", "hello world"]).unwrap();
515
516 let client = Client::new();
517 let result = dispatch(&client, &server.url(), &Auth::Bearer("key"), &op, &matches);
518 assert!(result.is_ok());
519 mock.assert();
520 }
521
522 #[test]
523 fn dispatch_returns_error_on_non_success_status() {
524 let mut server = mockito::Server::new();
525 let _mock = server
526 .mock("GET", "/fail")
527 .with_status(404)
528 .with_body("not found")
529 .create();
530
531 let op = make_full_op("GET", "/fail", Vec::new(), Vec::new(), Vec::new(), None);
532
533 let cmd = Command::new("test");
534 let matches = cmd.try_get_matches_from(["test"]).unwrap();
535
536 let client = Client::new();
537 let result = dispatch(&client, &server.url(), &Auth::Bearer("key"), &op, &matches);
538 assert!(result.is_err());
539 let err_msg = result.unwrap_err().to_string();
540 assert!(
541 err_msg.contains("404"),
542 "error should contain status code, got: {err_msg}"
543 );
544 }
545
546 #[test]
547 fn dispatch_omits_auth_header_when_auth_none() {
548 let mut server = mockito::Server::new();
549 let mock = server
550 .mock("GET", "/test")
551 .match_header("authorization", mockito::Matcher::Missing)
552 .with_status(200)
553 .with_header("content-type", "application/json")
554 .with_body(r#"{"ok":true}"#)
555 .create();
556
557 let op = make_full_op("GET", "/test", Vec::new(), Vec::new(), Vec::new(), None);
558
559 let cmd = Command::new("test");
560 let matches = cmd.try_get_matches_from(["test"]).unwrap();
561
562 let client = Client::new();
563 let result = dispatch(&client, &server.url(), &Auth::None, &op, &matches);
564 assert!(result.is_ok());
565 mock.assert();
566 }
567
568 #[test]
569 fn dispatch_sends_custom_header_auth() {
570 let mut server = mockito::Server::new();
571 let mock = server
572 .mock("GET", "/test")
573 .match_header("X-API-Key", "my-secret")
574 .with_status(200)
575 .with_header("content-type", "application/json")
576 .with_body(r#"{"ok":true}"#)
577 .create();
578
579 let op = make_full_op("GET", "/test", Vec::new(), Vec::new(), Vec::new(), None);
580
581 let cmd = Command::new("test");
582 let matches = cmd.try_get_matches_from(["test"]).unwrap();
583
584 let client = Client::new();
585 let auth = Auth::Header {
586 name: "X-API-Key",
587 value: "my-secret",
588 };
589 let result = dispatch(&client, &server.url(), &auth, &op, &matches);
590 assert!(result.is_ok());
591 mock.assert();
592 }
593
594 #[test]
595 fn dispatch_sends_basic_auth() {
596 let mut server = mockito::Server::new();
597 let mock = server
599 .mock("GET", "/test")
600 .match_header("authorization", "Basic dXNlcjpwYXNz")
601 .with_status(200)
602 .with_header("content-type", "application/json")
603 .with_body(r#"{"ok":true}"#)
604 .create();
605
606 let op = make_full_op("GET", "/test", Vec::new(), Vec::new(), Vec::new(), None);
607
608 let cmd = Command::new("test");
609 let matches = cmd.try_get_matches_from(["test"]).unwrap();
610
611 let client = Client::new();
612 let auth = Auth::Basic {
613 username: "user",
614 password: Some("pass"),
615 };
616 let result = dispatch(&client, &server.url(), &auth, &op, &matches);
617 assert!(result.is_ok());
618 mock.assert();
619 }
620
621 #[test]
622 fn dispatch_sends_query_auth() {
623 let mut server = mockito::Server::new();
624 let mock = server
625 .mock("GET", "/test")
626 .match_query(mockito::Matcher::UrlEncoded(
627 "api_key".into(),
628 "my-secret".into(),
629 ))
630 .match_header("authorization", mockito::Matcher::Missing)
631 .with_status(200)
632 .with_header("content-type", "application/json")
633 .with_body(r#"{"ok":true}"#)
634 .create();
635
636 let op = make_full_op("GET", "/test", Vec::new(), Vec::new(), Vec::new(), None);
637
638 let cmd = Command::new("test");
639 let matches = cmd.try_get_matches_from(["test"]).unwrap();
640
641 let client = Client::new();
642 let auth = Auth::Query {
643 name: "api_key",
644 value: "my-secret",
645 };
646 let result = dispatch(&client, &server.url(), &auth, &op, &matches);
647 assert!(result.is_ok());
648 mock.assert();
649 }
650
651 #[test]
652 fn dispatch_query_auth_coexists_with_operation_query_params() {
653 let mut server = mockito::Server::new();
654 let mock = server
655 .mock("GET", "/test")
656 .match_query(mockito::Matcher::AllOf(vec![
657 mockito::Matcher::UrlEncoded("verbose".into(), "true".into()),
658 mockito::Matcher::UrlEncoded("api_key".into(), "secret".into()),
659 ]))
660 .match_header("authorization", mockito::Matcher::Missing)
661 .with_status(200)
662 .with_header("content-type", "application/json")
663 .with_body(r#"{"ok":true}"#)
664 .create();
665
666 let op = make_full_op(
667 "GET",
668 "/test",
669 Vec::new(),
670 vec![Param {
671 name: "verbose".into(),
672 description: String::new(),
673 required: false,
674 schema: json!({"type": "boolean"}),
675 }],
676 Vec::new(),
677 None,
678 );
679
680 let cmd = Command::new("test").arg(
681 Arg::new("verbose")
682 .long("verbose")
683 .action(ArgAction::SetTrue),
684 );
685 let matches = cmd.try_get_matches_from(["test", "--verbose"]).unwrap();
686
687 let client = Client::new();
688 let auth = Auth::Query {
689 name: "api_key",
690 value: "secret",
691 };
692 let result = dispatch(&client, &server.url(), &auth, &op, &matches);
693 assert!(result.is_ok());
694 mock.assert();
695 }
696
697 #[test]
698 fn dispatch_returns_string_value_for_non_json_response() {
699 let mut server = mockito::Server::new();
700 let _mock = server
701 .mock("GET", "/plain")
702 .with_status(200)
703 .with_header("content-type", "text/plain")
704 .with_body("plain text response")
705 .create();
706
707 let op = make_full_op("GET", "/plain", Vec::new(), Vec::new(), Vec::new(), None);
708
709 let cmd = Command::new("test");
710 let matches = cmd.try_get_matches_from(["test"]).unwrap();
711
712 let client = Client::new();
713 let result = dispatch(&client, &server.url(), &Auth::Bearer("key"), &op, &matches);
714 assert!(result.is_ok());
715 assert_eq!(result.unwrap(), Value::String("plain text response".into()));
716 }
717}