1use reqwest::blocking::Client;
7use reqwest::Method;
8use serde_json::Value;
9
10use crate::error::DispatchError;
11use crate::spec::{is_bool_schema, ApiOperation};
12
13pub fn dispatch(
15 client: &Client,
16 base_url: &str,
17 api_key: &str,
18 op: &ApiOperation,
19 matches: &clap::ArgMatches,
20) -> Result<Value, DispatchError> {
21 let url = build_url(base_url, op, matches);
22 let query_pairs = build_query_pairs(op, matches);
23 let body = build_body(op, matches)?;
24 let headers = collect_headers(op, matches);
25
26 let method: Method = op
27 .method
28 .parse()
29 .map_err(|_| DispatchError::UnsupportedMethod {
30 method: op.method.clone(),
31 })?;
32
33 let mut req = client.request(method, &url);
34
35 if !api_key.is_empty() {
36 req = req.bearer_auth(api_key);
37 }
38 if !query_pairs.is_empty() {
39 req = req.query(&query_pairs);
40 }
41 for (name, val) in &headers {
42 req = req.header(name, val);
43 }
44 if let Some(body) = body {
45 req = req.json(&body);
46 }
47
48 send_request(req)
49}
50
51fn build_url(base_url: &str, op: &ApiOperation, matches: &clap::ArgMatches) -> String {
52 let base = base_url.trim_end_matches('/');
53 let mut url = format!("{}{}", base, op.path);
54 for param in &op.path_params {
55 if let Some(val) = matches.get_one::<String>(¶m.name) {
56 url = url.replace(&format!("{{{}}}", param.name), &urlencoding::encode(val));
57 }
58 }
59 url
60}
61
62fn build_query_pairs(op: &ApiOperation, matches: &clap::ArgMatches) -> Vec<(String, String)> {
63 let mut pairs = Vec::new();
64 for param in &op.query_params {
65 if is_bool_schema(¶m.schema) {
66 if matches.get_flag(¶m.name) {
67 pairs.push((param.name.clone(), "true".to_string()));
68 }
69 } else if let Some(val) = matches.get_one::<String>(¶m.name) {
70 pairs.push((param.name.clone(), val.clone()));
71 }
72 }
73 pairs
74}
75
76fn collect_headers(op: &ApiOperation, matches: &clap::ArgMatches) -> Vec<(String, String)> {
77 let mut headers = Vec::new();
78 for param in &op.header_params {
79 if let Some(val) = matches.get_one::<String>(¶m.name) {
80 headers.push((param.name.clone(), val.clone()));
81 }
82 }
83 headers
84}
85
86fn send_request(req: reqwest::blocking::RequestBuilder) -> Result<Value, DispatchError> {
87 let resp = req.send().map_err(DispatchError::RequestFailed)?;
88 let status = resp.status();
89 let text = resp.text().map_err(DispatchError::ResponseRead)?;
90
91 if !status.is_success() {
92 return Err(DispatchError::HttpError { status, body: text });
93 }
94
95 let value: Value = serde_json::from_str(&text).unwrap_or(Value::String(text));
96 Ok(value)
97}
98
99fn build_body(
100 op: &ApiOperation,
101 matches: &clap::ArgMatches,
102) -> Result<Option<Value>, DispatchError> {
103 if op.body_schema.is_none() {
104 return Ok(None);
105 }
106
107 if let Some(json_str) = matches.get_one::<String>("json-body") {
109 let val: Value = serde_json::from_str(json_str).map_err(DispatchError::InvalidJsonBody)?;
110 return Ok(Some(val));
111 }
112
113 if let Some(fields) = matches.get_many::<String>("field") {
115 let mut obj = serde_json::Map::new();
116 for field in fields {
117 let (key, val) =
118 field
119 .split_once('=')
120 .ok_or_else(|| DispatchError::InvalidFieldFormat {
121 field: field.to_string(),
122 })?;
123 let json_val = serde_json::from_str(val).unwrap_or(Value::String(val.to_string()));
125 obj.insert(key.to_string(), json_val);
126 }
127 return Ok(Some(Value::Object(obj)));
128 }
129
130 if op.body_required {
131 return Err(DispatchError::BodyRequired);
132 }
133
134 Ok(None)
135}
136
137#[cfg(test)]
138mod tests {
139 use super::*;
140 use crate::spec::{ApiOperation, Param};
141 use clap::{Arg, ArgAction, Command};
142 use reqwest::blocking::Client;
143 use serde_json::json;
144
145 fn make_op_with_body(body_schema: Option<Value>) -> ApiOperation {
146 ApiOperation {
147 operation_id: "TestOp".to_string(),
148 method: "POST".to_string(),
149 path: "/test".to_string(),
150 group: "Test".to_string(),
151 summary: String::new(),
152 path_params: Vec::new(),
153 query_params: Vec::new(),
154 header_params: Vec::new(),
155 body_schema,
156 body_required: false,
157 }
158 }
159
160 fn build_matches_with_args(args: &[&str], has_body: bool) -> clap::ArgMatches {
161 let mut cmd = Command::new("test");
162 if has_body {
163 cmd = cmd
164 .arg(
165 Arg::new("json-body")
166 .long("json")
167 .short('j')
168 .action(ArgAction::Set),
169 )
170 .arg(
171 Arg::new("field")
172 .long("field")
173 .short('f')
174 .action(ArgAction::Append),
175 );
176 }
177 cmd.try_get_matches_from(args).unwrap()
178 }
179
180 #[test]
181 fn build_body_returns_none_when_no_body_schema() {
182 let op = make_op_with_body(None);
183 let matches = build_matches_with_args(&["test"], false);
184
185 let result = build_body(&op, &matches).unwrap();
186 assert!(result.is_none());
187 }
188
189 #[test]
190 fn build_body_parses_json_flag() {
191 let op = make_op_with_body(Some(json!({"type": "object"})));
192 let matches =
193 build_matches_with_args(&["test", "--json", r#"{"name":"pod1","gpu":2}"#], true);
194
195 let result = build_body(&op, &matches).unwrap();
196 assert!(result.is_some());
197 let body = result.unwrap();
198 assert_eq!(body["name"], "pod1");
199 assert_eq!(body["gpu"], 2);
200 }
201
202 #[test]
203 fn build_body_parses_field_key_value() {
204 let op = make_op_with_body(Some(json!({"type": "object"})));
205 let matches =
206 build_matches_with_args(&["test", "--field", "name=pod1", "--field", "gpu=2"], true);
207
208 let result = build_body(&op, &matches).unwrap();
209 assert!(result.is_some());
210 let body = result.unwrap();
211 assert_eq!(body["name"], "pod1");
212 assert_eq!(body["gpu"], 2);
214 }
215
216 #[test]
217 fn build_body_field_string_fallback() {
218 let op = make_op_with_body(Some(json!({"type": "object"})));
219 let matches = build_matches_with_args(&["test", "--field", "name=hello world"], true);
220
221 let result = build_body(&op, &matches).unwrap();
222 let body = result.unwrap();
223 assert_eq!(body["name"], "hello world");
224 }
225
226 #[test]
227 fn build_body_returns_error_for_invalid_field_format() {
228 let op = make_op_with_body(Some(json!({"type": "object"})));
229 let matches = build_matches_with_args(&["test", "--field", "no-equals-sign"], true);
230
231 let result = build_body(&op, &matches);
232 assert!(result.is_err());
233 let err_msg = result.unwrap_err().to_string();
234 assert!(
235 err_msg.contains("invalid --field format"),
236 "error should mention invalid format, got: {err_msg}"
237 );
238 }
239
240 #[test]
241 fn build_body_returns_error_for_invalid_json() {
242 let op = make_op_with_body(Some(json!({"type": "object"})));
243 let matches = build_matches_with_args(&["test", "--json", "{invalid json}"], true);
244
245 let result = build_body(&op, &matches);
246 assert!(result.is_err());
247 let err_msg = result.unwrap_err().to_string();
248 assert!(
249 err_msg.contains("invalid JSON"),
250 "error should mention invalid JSON, got: {err_msg}"
251 );
252 }
253
254 #[test]
255 fn build_body_returns_none_when_schema_present_but_no_flags() {
256 let op = make_op_with_body(Some(json!({"type": "object"})));
257 let matches = build_matches_with_args(&["test"], true);
258
259 let result = build_body(&op, &matches).unwrap();
260 assert!(result.is_none());
261 }
262
263 #[test]
264 fn build_body_json_takes_precedence_over_field() {
265 let op = make_op_with_body(Some(json!({"type": "object"})));
266 let matches = build_matches_with_args(
267 &[
268 "test",
269 "--json",
270 r#"{"from":"json"}"#,
271 "--field",
272 "from=field",
273 ],
274 true,
275 );
276
277 let result = build_body(&op, &matches).unwrap();
278 let body = result.unwrap();
279 assert_eq!(body["from"], "json");
281 }
282
283 #[test]
284 fn build_body_returns_error_when_body_required_but_not_provided() {
285 let mut op = make_op_with_body(Some(json!({"type": "object"})));
286 op.body_required = true;
287 let matches = build_matches_with_args(&["test"], true);
288
289 let result = build_body(&op, &matches);
290 assert!(result.is_err());
291 assert!(result
292 .unwrap_err()
293 .to_string()
294 .contains("request body is required"));
295 }
296
297 fn make_full_op(
300 method: &str,
301 path: &str,
302 path_params: Vec<Param>,
303 query_params: Vec<Param>,
304 header_params: Vec<Param>,
305 body_schema: Option<serde_json::Value>,
306 ) -> ApiOperation {
307 ApiOperation {
308 operation_id: "TestOp".to_string(),
309 method: method.to_string(),
310 path: path.to_string(),
311 group: "Test".to_string(),
312 summary: String::new(),
313 path_params,
314 query_params,
315 header_params,
316 body_schema,
317 body_required: false,
318 }
319 }
320
321 #[test]
322 fn dispatch_sends_get_with_path_and_query_params() {
323 let mut server = mockito::Server::new();
324 let mock = server
325 .mock("GET", "/pods/123")
326 .match_query(mockito::Matcher::UrlEncoded(
327 "verbose".into(),
328 "true".into(),
329 ))
330 .match_header("authorization", "Bearer test-key")
331 .with_status(200)
332 .with_header("content-type", "application/json")
333 .with_body(r#"{"id":"123"}"#)
334 .create();
335
336 let op = make_full_op(
337 "GET",
338 "/pods/{podId}",
339 vec![Param {
340 name: "podId".into(),
341 description: String::new(),
342 required: true,
343 schema: json!({"type": "string"}),
344 }],
345 vec![Param {
346 name: "verbose".into(),
347 description: String::new(),
348 required: false,
349 schema: json!({"type": "boolean"}),
350 }],
351 Vec::new(),
352 None,
353 );
354
355 let cmd = Command::new("test")
356 .arg(Arg::new("podId").required(true))
357 .arg(
358 Arg::new("verbose")
359 .long("verbose")
360 .action(ArgAction::SetTrue),
361 );
362 let matches = cmd
363 .try_get_matches_from(["test", "123", "--verbose"])
364 .unwrap();
365
366 let client = Client::new();
367 let result = dispatch(&client, &server.url(), "test-key", &op, &matches);
368 assert!(result.is_ok());
369 assert_eq!(result.unwrap()["id"], "123");
370 mock.assert();
371 }
372
373 #[test]
374 fn dispatch_sends_post_with_json_body() {
375 let mut server = mockito::Server::new();
376 let mock = server
377 .mock("POST", "/pods")
378 .match_header("content-type", "application/json")
379 .match_body(mockito::Matcher::Json(json!({"name": "pod1"})))
380 .with_status(200)
381 .with_header("content-type", "application/json")
382 .with_body(r#"{"id":"new"}"#)
383 .create();
384
385 let op = make_full_op(
386 "POST",
387 "/pods",
388 Vec::new(),
389 Vec::new(),
390 Vec::new(),
391 Some(json!({"type": "object"})),
392 );
393
394 let cmd = Command::new("test").arg(
395 Arg::new("json-body")
396 .long("json")
397 .short('j')
398 .action(ArgAction::Set),
399 );
400 let matches = cmd
401 .try_get_matches_from(["test", "--json", r#"{"name":"pod1"}"#])
402 .unwrap();
403
404 let client = Client::new();
405 let result = dispatch(&client, &server.url(), "key", &op, &matches);
406 assert!(result.is_ok());
407 assert_eq!(result.unwrap()["id"], "new");
408 mock.assert();
409 }
410
411 #[test]
412 fn dispatch_sends_header_params() {
413 let mut server = mockito::Server::new();
414 let mock = server
415 .mock("GET", "/test")
416 .match_header("X-Request-Id", "abc123")
417 .with_status(200)
418 .with_header("content-type", "application/json")
419 .with_body(r#"{"ok":true}"#)
420 .create();
421
422 let op = make_full_op(
423 "GET",
424 "/test",
425 Vec::new(),
426 Vec::new(),
427 vec![Param {
428 name: "X-Request-Id".into(),
429 description: String::new(),
430 required: false,
431 schema: json!({"type": "string"}),
432 }],
433 None,
434 );
435
436 let cmd = Command::new("test").arg(
437 Arg::new("X-Request-Id")
438 .long("X-Request-Id")
439 .action(ArgAction::Set),
440 );
441 let matches = cmd
442 .try_get_matches_from(["test", "--X-Request-Id", "abc123"])
443 .unwrap();
444
445 let client = Client::new();
446 let result = dispatch(&client, &server.url(), "key", &op, &matches);
447 assert!(result.is_ok());
448 mock.assert();
449 }
450
451 #[test]
452 fn dispatch_url_encodes_path_params() {
453 let mut server = mockito::Server::new();
454 let mock = server
455 .mock("GET", "/items/hello%20world")
456 .with_status(200)
457 .with_header("content-type", "application/json")
458 .with_body(r#"{"ok":true}"#)
459 .create();
460
461 let op = make_full_op(
462 "GET",
463 "/items/{itemId}",
464 vec![Param {
465 name: "itemId".into(),
466 description: String::new(),
467 required: true,
468 schema: json!({"type": "string"}),
469 }],
470 Vec::new(),
471 Vec::new(),
472 None,
473 );
474
475 let cmd = Command::new("test").arg(Arg::new("itemId").required(true));
476 let matches = cmd.try_get_matches_from(["test", "hello world"]).unwrap();
477
478 let client = Client::new();
479 let result = dispatch(&client, &server.url(), "key", &op, &matches);
480 assert!(result.is_ok());
481 mock.assert();
482 }
483
484 #[test]
485 fn dispatch_returns_error_on_non_success_status() {
486 let mut server = mockito::Server::new();
487 let _mock = server
488 .mock("GET", "/fail")
489 .with_status(404)
490 .with_body("not found")
491 .create();
492
493 let op = make_full_op("GET", "/fail", Vec::new(), Vec::new(), Vec::new(), None);
494
495 let cmd = Command::new("test");
496 let matches = cmd.try_get_matches_from(["test"]).unwrap();
497
498 let client = Client::new();
499 let result = dispatch(&client, &server.url(), "key", &op, &matches);
500 assert!(result.is_err());
501 let err_msg = result.unwrap_err().to_string();
502 assert!(
503 err_msg.contains("404"),
504 "error should contain status code, got: {err_msg}"
505 );
506 }
507
508 #[test]
509 fn dispatch_omits_auth_header_when_api_key_empty() {
510 let mut server = mockito::Server::new();
511 let mock = server
512 .mock("GET", "/test")
513 .match_header("authorization", mockito::Matcher::Missing)
514 .with_status(200)
515 .with_header("content-type", "application/json")
516 .with_body(r#"{"ok":true}"#)
517 .create();
518
519 let op = make_full_op("GET", "/test", Vec::new(), Vec::new(), Vec::new(), None);
520
521 let cmd = Command::new("test");
522 let matches = cmd.try_get_matches_from(["test"]).unwrap();
523
524 let client = Client::new();
525 let result = dispatch(&client, &server.url(), "", &op, &matches);
526 assert!(result.is_ok());
527 mock.assert();
528 }
529
530 #[test]
531 fn dispatch_returns_string_value_for_non_json_response() {
532 let mut server = mockito::Server::new();
533 let _mock = server
534 .mock("GET", "/plain")
535 .with_status(200)
536 .with_header("content-type", "text/plain")
537 .with_body("plain text response")
538 .create();
539
540 let op = make_full_op("GET", "/plain", Vec::new(), Vec::new(), Vec::new(), None);
541
542 let cmd = Command::new("test");
543 let matches = cmd.try_get_matches_from(["test"]).unwrap();
544
545 let client = Client::new();
546 let result = dispatch(&client, &server.url(), "key", &op, &matches);
547 assert!(result.is_ok());
548 assert_eq!(result.unwrap(), Value::String("plain text response".into()));
549 }
550}