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/// Authentication method for API requests.
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15#[non_exhaustive]
16pub enum Auth<'a> {
17    /// No authentication.
18    None,
19    /// Bearer token (`Authorization: Bearer <token>`).
20    Bearer(&'a str),
21    /// Custom header (e.g. `X-API-Key: <value>`).
22    Header { name: &'a str, value: &'a str },
23    /// HTTP Basic authentication.
24    Basic {
25        username: &'a str,
26        password: Option<&'a str>,
27    },
28    /// API key sent as a query parameter (e.g. `?api_key=<value>`).
29    Query { name: &'a str, value: &'a str },
30}
31
32/// Execute an API operation based on clap matches.
33pub 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 { .. } => {} // applied after operation query params
66    }
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>(&param.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(&param.schema) {
98            if matches.get_flag(&param.name) {
99                pairs.push((param.name.clone(), "true".to_string()));
100            }
101        } else if let Some(val) = matches.get_one::<String>(&param.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>(&param.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    // --json takes precedence
140    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    // --field key=value pairs
146    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            // Try to parse as JSON value, fall back to string
156            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        // "2" should be parsed as JSON number
245        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        // --json should win over --field
312        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    // -- dispatch integration tests --
330
331    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        // Basic auth header: base64("user:pass") = "dXNlcjpwYXNz"
598        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}