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//!
6//! # Two-phase dispatch
7//!
8//! [`PreparedRequest::from_operation`] builds a fully resolved request that can
9//! be inspected (dry-run, verbose logging) before [`PreparedRequest::send`]
10//! actually transmits it.  The convenience function [`dispatch`] chains both
11//! steps for callers that don't need the intermediate representation.
12
13use reqwest::blocking::Client;
14use reqwest::Method;
15use serde_json::Value;
16
17use crate::error::DispatchError;
18use crate::spec::{is_bool_schema, ApiOperation};
19
20/// Authentication method for API requests.
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22#[non_exhaustive]
23pub enum Auth<'a> {
24    /// No authentication.
25    None,
26    /// Bearer token (`Authorization: Bearer <token>`).
27    Bearer(&'a str),
28    /// Custom header (e.g. `X-API-Key: <value>`).
29    Header { name: &'a str, value: &'a str },
30    /// HTTP Basic authentication.
31    Basic {
32        username: &'a str,
33        password: Option<&'a str>,
34    },
35    /// API key sent as a query parameter (e.g. `?api_key=<value>`).
36    Query { name: &'a str, value: &'a str },
37}
38
39/// Owned authentication resolved from [`Auth`].
40///
41/// Held by [`PreparedRequest`] so the prepared request is `'static` and can be
42/// stored, logged, or sent across threads without lifetime constraints.
43#[derive(Debug, Clone, PartialEq, Eq)]
44#[non_exhaustive]
45pub enum ResolvedAuth {
46    /// No authentication.
47    None,
48    /// Bearer token.
49    Bearer(String),
50    /// Custom header.
51    Header { name: String, value: String },
52    /// HTTP Basic authentication.
53    Basic {
54        username: String,
55        password: Option<String>,
56    },
57    /// API key as query parameter.
58    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/// A fully resolved HTTP request ready to be sent or inspected.
83///
84/// Created by [`PreparedRequest::from_operation`], this struct holds all the
85/// data needed to execute an HTTP request.  Consumers can inspect the fields
86/// for dry-run display, verbose logging, or request modification before
87/// calling [`send`](PreparedRequest::send).
88///
89/// # Example
90///
91/// ```no_run
92/// # use openapi_clap::dispatch::{PreparedRequest, Auth};
93/// # use openapi_clap::spec::ApiOperation;
94/// # use reqwest::blocking::Client;
95/// # fn example(op: &ApiOperation, matches: &clap::ArgMatches) -> Result<(), Box<dyn std::error::Error>> {
96/// let prepared = PreparedRequest::from_operation(
97///     "https://api.example.com",
98///     &Auth::Bearer("token"),
99///     op,
100///     matches,
101/// )?;
102///
103/// // Inspect before sending (dry-run / verbose)
104/// eprintln!("{} {}", prepared.method, prepared.url);
105///
106/// let value = prepared.send(&Client::new())?;
107/// # Ok(())
108/// # }
109/// ```
110#[derive(Debug, Clone)]
111#[non_exhaustive]
112pub struct PreparedRequest {
113    /// HTTP method (GET, POST, etc.).
114    pub method: Method,
115    /// Fully resolved URL with path parameters substituted.
116    pub url: String,
117    /// Query parameters from the API operation.
118    ///
119    /// Auth query parameters (see [`ResolvedAuth::Query`]) are kept in the
120    /// [`auth`](Self::auth) field and applied separately during
121    /// [`send`](Self::send).
122    pub query_pairs: Vec<(String, String)>,
123    /// Headers from the API operation.
124    ///
125    /// Auth headers are kept in the [`auth`](Self::auth) field.
126    pub headers: Vec<(String, String)>,
127    /// JSON request body, if any.
128    pub body: Option<Value>,
129    /// Resolved authentication.
130    pub auth: ResolvedAuth,
131}
132
133impl PreparedRequest {
134    /// Build a prepared request from an API operation and clap matches.
135    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    /// Send the prepared request using the provided HTTP client.
163    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 { .. } => {} // applied after operation query params
178        }
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
196/// Execute an API operation based on clap matches.
197///
198/// Convenience wrapper around [`PreparedRequest::from_operation`] +
199/// [`PreparedRequest::send`].
200pub 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>(&param.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(&param.schema) {
225            if matches.get_flag(&param.name) {
226                pairs.push((param.name.clone(), "true".to_string()));
227            }
228        } else if let Some(val) = matches.get_one::<String>(&param.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>(&param.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    // --json takes precedence
267    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    // --field key=value pairs
273    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            // Try to parse as JSON value, fall back to string
283            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        // "2" should be parsed as JSON number
372        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        // --json should win over --field
439        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    // -- dispatch integration tests --
457
458    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        // Basic auth header: base64("user:pass") = "dXNlcjpwYXNz"
725        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    // -- PreparedRequest unit tests --
846
847    #[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        // Auth query param is NOT in query_pairs — it's in auth
1088        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        // Method must contain invalid HTTP token characters to fail parsing
1129        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}