Skip to main content

openapi_clap/
dispatch.rs

1//! ArgMatches → HTTP request dispatch
2//!
3//! Takes parsed clap matches and the matching ApiOperation, constructs an HTTP
4//! request, and returns the response.
5
6use reqwest::blocking::Client;
7use reqwest::Method;
8use serde_json::Value;
9
10use crate::error::DispatchError;
11use crate::spec::{is_bool_schema, ApiOperation};
12
13/// Execute an API operation based on clap matches.
14pub 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>(&param.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(&param.schema) {
66            if matches.get_flag(&param.name) {
67                pairs.push((param.name.clone(), "true".to_string()));
68            }
69        } else if let Some(val) = matches.get_one::<String>(&param.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>(&param.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    // --json takes precedence
108    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    // --field key=value pairs
114    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            // Try to parse as JSON value, fall back to string
124            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        // "2" should be parsed as JSON number
213        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        // --json should win over --field
280        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    // -- dispatch integration tests --
298
299    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}