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//! # Three-phase dispatch
7//!
8//! 1. **Prepare** – [`PreparedRequest::from_operation`] resolves URL, parameters,
9//!    headers, and authentication from an [`ApiOperation`] and clap matches.
10//!    [`build_body`] resolves the request body from `--json` / `--field` arguments.
11//! 2. **Send** – [`PreparedRequest::send`] transmits the request and returns a
12//!    [`SendResponse`] containing full response metadata (status, headers, body,
13//!    elapsed time).
14//! 3. **Consume** – [`SendResponse::into_json`] checks the status code and parses
15//!    the body as JSON.
16//!
17//! The convenience function [`dispatch`] chains all three steps for callers that
18//! don't need the intermediate representations.
19
20use std::time::Instant;
21
22use reqwest::blocking::Client;
23use reqwest::Method;
24use serde_json::Value;
25
26use crate::error::DispatchError;
27use crate::spec::{is_bool_schema, ApiOperation};
28
29/// Authentication method for API requests.
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31#[non_exhaustive]
32pub enum Auth<'a> {
33    /// No authentication.
34    None,
35    /// Bearer token (`Authorization: Bearer <token>`).
36    Bearer(&'a str),
37    /// Custom header (e.g. `X-API-Key: <value>`).
38    Header { name: &'a str, value: &'a str },
39    /// HTTP Basic authentication.
40    Basic {
41        username: &'a str,
42        password: Option<&'a str>,
43    },
44    /// API key sent as a query parameter (e.g. `?api_key=<value>`).
45    Query { name: &'a str, value: &'a str },
46}
47
48/// Owned authentication resolved from [`Auth`].
49///
50/// Held by [`PreparedRequest`] so the prepared request is `'static` and can be
51/// stored, logged, or sent across threads without lifetime constraints.
52#[derive(Debug, Clone, PartialEq, Eq)]
53#[non_exhaustive]
54pub enum ResolvedAuth {
55    /// No authentication.
56    None,
57    /// Bearer token.
58    Bearer(String),
59    /// Custom header.
60    Header { name: String, value: String },
61    /// HTTP Basic authentication.
62    Basic {
63        username: String,
64        password: Option<String>,
65    },
66    /// API key as query parameter.
67    Query { name: String, value: String },
68}
69
70impl From<&Auth<'_>> for ResolvedAuth {
71    fn from(auth: &Auth<'_>) -> Self {
72        match auth {
73            Auth::None => Self::None,
74            Auth::Bearer(token) => Self::Bearer(token.to_string()),
75            Auth::Header { name, value } => Self::Header {
76                name: name.to_string(),
77                value: value.to_string(),
78            },
79            Auth::Basic { username, password } => Self::Basic {
80                username: username.to_string(),
81                password: password.map(|p| p.to_string()),
82            },
83            Auth::Query { name, value } => Self::Query {
84                name: name.to_string(),
85                value: value.to_string(),
86            },
87        }
88    }
89}
90
91/// HTTP response from [`PreparedRequest::send`].
92///
93/// Contains the full response metadata (status, headers, body text, elapsed
94/// time).  Use [`into_json`](Self::into_json) for the common path that checks
95/// the status code and parses JSON, or access individual fields for verbose
96/// logging and dry-run display.
97#[derive(Debug)]
98#[non_exhaustive]
99pub struct SendResponse {
100    /// HTTP status code.
101    pub status: reqwest::StatusCode,
102    /// Response headers.
103    pub headers: reqwest::header::HeaderMap,
104    /// Raw response body text.
105    pub body: String,
106    /// Time elapsed from request start to response body fully read.
107    pub elapsed: std::time::Duration,
108}
109
110impl SendResponse {
111    /// Check for a success status and parse the body as JSON.
112    ///
113    /// Returns [`DispatchError::HttpError`] for non-2xx status codes.
114    /// Falls back to [`Value::String`] if the body is not valid JSON.
115    pub fn into_json(self) -> Result<Value, DispatchError> {
116        if !self.status.is_success() {
117            return Err(DispatchError::HttpError {
118                status: self.status,
119                body: self.body,
120            });
121        }
122        Ok(serde_json::from_str(&self.body).unwrap_or(Value::String(self.body)))
123    }
124
125    /// Parse the body as JSON without checking the status code.
126    ///
127    /// Falls back to [`Value::String`] if the body is not valid JSON.
128    pub fn json(&self) -> Value {
129        serde_json::from_str(&self.body).unwrap_or_else(|_| Value::String(self.body.clone()))
130    }
131}
132
133/// A fully resolved HTTP request ready to be sent or inspected.
134///
135/// Created by [`PreparedRequest::from_operation`], this struct holds all the
136/// data needed to execute an HTTP request.  Consumers can inspect the fields
137/// for dry-run display, verbose logging, or request modification before
138/// calling [`send`](PreparedRequest::send).
139///
140/// # Example
141///
142/// ```no_run
143/// # use openapi_clap::dispatch::{PreparedRequest, Auth};
144/// # use openapi_clap::spec::ApiOperation;
145/// # use reqwest::blocking::Client;
146/// # fn example(op: &ApiOperation, matches: &clap::ArgMatches) -> Result<(), Box<dyn std::error::Error>> {
147/// let prepared = PreparedRequest::from_operation(
148///     "https://api.example.com",
149///     &Auth::Bearer("token"),
150///     op,
151///     matches,
152/// )?;
153///
154/// // Inspect before sending (dry-run / verbose)
155/// eprintln!("{} {}", prepared.method, prepared.url);
156///
157/// let resp = prepared.send(&Client::new())?;
158/// let value = resp.into_json()?;
159/// # Ok(())
160/// # }
161/// ```
162#[derive(Debug, Clone)]
163#[non_exhaustive]
164pub struct PreparedRequest {
165    /// HTTP method (GET, POST, etc.).
166    pub method: Method,
167    /// Fully resolved URL with path parameters substituted.
168    pub url: String,
169    /// Query parameters from the API operation.
170    ///
171    /// Auth query parameters (see [`ResolvedAuth::Query`]) are kept in the
172    /// [`auth`](Self::auth) field and applied separately during
173    /// [`send`](Self::send).
174    pub query_pairs: Vec<(String, String)>,
175    /// Headers from the API operation.
176    ///
177    /// Auth headers are kept in the [`auth`](Self::auth) field.
178    pub headers: Vec<(String, String)>,
179    /// JSON request body, if any.
180    pub body: Option<Value>,
181    /// Resolved authentication.
182    pub auth: ResolvedAuth,
183}
184
185impl PreparedRequest {
186    /// Create a new prepared request with the given HTTP method and URL.
187    ///
188    /// Use the builder methods ([`query`](Self::query), [`header`](Self::header),
189    /// [`body`](Self::body), [`auth`](Self::auth)) to set additional fields,
190    /// then call [`send`](Self::send) to execute.
191    ///
192    /// # Example
193    ///
194    /// ```no_run
195    /// # use openapi_clap::dispatch::{PreparedRequest, ResolvedAuth};
196    /// # use reqwest::Method;
197    /// # use serde_json::json;
198    /// let req = PreparedRequest::new(Method::POST, "https://api.example.com/v2/abc/run")
199    ///     .auth(ResolvedAuth::Bearer("my-token".into()))
200    ///     .body(json!({"input": {"prompt": "hello"}}));
201    /// ```
202    pub fn new(method: Method, url: impl Into<String>) -> Self {
203        Self {
204            method,
205            url: url.into(),
206            query_pairs: Vec::new(),
207            headers: Vec::new(),
208            body: None,
209            auth: ResolvedAuth::None,
210        }
211    }
212
213    /// Add a query parameter.
214    pub fn query(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
215        self.query_pairs.push((name.into(), value.into()));
216        self
217    }
218
219    /// Add a header.
220    pub fn header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
221        self.headers.push((name.into(), value.into()));
222        self
223    }
224
225    /// Set the JSON request body.
226    pub fn body(mut self, body: Value) -> Self {
227        self.body = Some(body);
228        self
229    }
230
231    /// Set the authentication method.
232    pub fn auth(mut self, auth: ResolvedAuth) -> Self {
233        self.auth = auth;
234        self
235    }
236
237    /// Remove all query parameters.
238    pub fn clear_query(mut self) -> Self {
239        self.query_pairs.clear();
240        self
241    }
242
243    /// Remove all headers.
244    pub fn clear_headers(mut self) -> Self {
245        self.headers.clear();
246        self
247    }
248
249    /// Resolve URL, parameters, headers, and authentication from an API
250    /// operation and clap matches.
251    ///
252    /// The request body is **not** included — use [`build_body`] to resolve
253    /// it separately, then chain with [`.body()`](Self::body).  The
254    /// convenience function [`dispatch`] handles this automatically.
255    pub fn from_operation(
256        base_url: &str,
257        auth: &Auth<'_>,
258        op: &ApiOperation,
259        matches: &clap::ArgMatches,
260    ) -> Result<Self, DispatchError> {
261        let url = build_url(base_url, op, matches);
262        let query_pairs = build_query_pairs(op, matches);
263        let headers = collect_headers(op, matches);
264        let method: Method = op
265            .method
266            .parse()
267            .map_err(|_| DispatchError::UnsupportedMethod {
268                method: op.method.clone(),
269            })?;
270
271        Ok(Self {
272            method,
273            url,
274            query_pairs,
275            headers,
276            body: None,
277            auth: ResolvedAuth::from(auth),
278        })
279    }
280
281    /// Send the prepared request and return full response metadata.
282    ///
283    /// Use [`SendResponse::into_json`] to check the status and parse the body,
284    /// or access [`SendResponse`] fields directly for verbose logging.
285    pub fn send(&self, client: &Client) -> Result<SendResponse, DispatchError> {
286        let mut req = client.request(self.method.clone(), &self.url);
287
288        match &self.auth {
289            ResolvedAuth::None => {}
290            ResolvedAuth::Bearer(token) => {
291                req = req.bearer_auth(token);
292            }
293            ResolvedAuth::Header { name, value } => {
294                req = req.header(name, value);
295            }
296            ResolvedAuth::Basic { username, password } => {
297                req = req.basic_auth(username, password.as_deref());
298            }
299            ResolvedAuth::Query { .. } => {} // applied after operation query params
300        }
301        if !self.query_pairs.is_empty() {
302            req = req.query(&self.query_pairs);
303        }
304        if let ResolvedAuth::Query { name, value } = &self.auth {
305            req = req.query(&[(name, value)]);
306        }
307        for (name, val) in &self.headers {
308            req = req.header(name, val);
309        }
310        if let Some(body) = &self.body {
311            req = req.json(body);
312        }
313
314        let start = Instant::now();
315        let resp = req.send().map_err(DispatchError::RequestFailed)?;
316        let status = resp.status();
317        let headers = resp.headers().clone();
318        let text = resp.text().map_err(DispatchError::ResponseRead)?;
319        let elapsed = start.elapsed();
320
321        Ok(SendResponse {
322            status,
323            headers,
324            body: text,
325            elapsed,
326        })
327    }
328}
329
330/// Execute an API operation based on clap matches.
331///
332/// Convenience wrapper that chains [`PreparedRequest::from_operation`] +
333/// [`build_body`] + [`PreparedRequest::send`] + [`SendResponse::into_json`].
334pub fn dispatch(
335    client: &Client,
336    base_url: &str,
337    auth: &Auth<'_>,
338    op: &ApiOperation,
339    matches: &clap::ArgMatches,
340) -> Result<Value, DispatchError> {
341    let mut req = PreparedRequest::from_operation(base_url, auth, op, matches)?;
342    if let Some(body) = build_body(op, matches)? {
343        req = req.body(body);
344    }
345    req.send(client)?.into_json()
346}
347
348fn build_url(base_url: &str, op: &ApiOperation, matches: &clap::ArgMatches) -> String {
349    let base = base_url.trim_end_matches('/');
350    let mut url = format!("{}{}", base, op.path);
351    for param in &op.path_params {
352        if let Some(val) = matches.get_one::<String>(&param.name) {
353            url = url.replace(&format!("{{{}}}", param.name), &urlencoding::encode(val));
354        }
355    }
356    url
357}
358
359fn build_query_pairs(op: &ApiOperation, matches: &clap::ArgMatches) -> Vec<(String, String)> {
360    let mut pairs = Vec::new();
361    for param in &op.query_params {
362        if is_bool_schema(&param.schema) {
363            if matches.get_flag(&param.name) {
364                pairs.push((param.name.clone(), "true".to_string()));
365            }
366        } else if let Some(val) = matches.get_one::<String>(&param.name) {
367            pairs.push((param.name.clone(), val.clone()));
368        }
369    }
370    pairs
371}
372
373fn collect_headers(op: &ApiOperation, matches: &clap::ArgMatches) -> Vec<(String, String)> {
374    let mut headers = Vec::new();
375    for param in &op.header_params {
376        if let Some(val) = matches.get_one::<String>(&param.name) {
377            headers.push((param.name.clone(), val.clone()));
378        }
379    }
380    headers
381}
382
383/// Resolve the request body from `--json` / `--field` clap arguments.
384///
385/// Returns `Ok(None)` when the operation has no body schema or no body
386/// arguments were provided (and the body is not required).
387///
388/// This is separated from [`PreparedRequest::from_operation`] so that
389/// downstream crates can substitute their own body resolution (e.g.
390/// `@file` / stdin support) while reusing URL and parameter resolution.
391pub fn build_body(
392    op: &ApiOperation,
393    matches: &clap::ArgMatches,
394) -> Result<Option<Value>, DispatchError> {
395    if op.body_schema.is_none() {
396        return Ok(None);
397    }
398
399    // --json takes precedence
400    if let Some(json_str) = matches.get_one::<String>("json-body") {
401        let val: Value = serde_json::from_str(json_str).map_err(DispatchError::InvalidJsonBody)?;
402        return Ok(Some(val));
403    }
404
405    // --field key=value pairs
406    if let Some(fields) = matches.get_many::<String>("field") {
407        let mut obj = serde_json::Map::new();
408        for field in fields {
409            let (key, val) =
410                field
411                    .split_once('=')
412                    .ok_or_else(|| DispatchError::InvalidFieldFormat {
413                        field: field.to_string(),
414                    })?;
415            // Try to parse as JSON value, fall back to string
416            let json_val = serde_json::from_str(val).unwrap_or(Value::String(val.to_string()));
417            obj.insert(key.to_string(), json_val);
418        }
419        return Ok(Some(Value::Object(obj)));
420    }
421
422    if op.body_required {
423        return Err(DispatchError::BodyRequired);
424    }
425
426    Ok(None)
427}
428
429#[cfg(test)]
430mod tests {
431    use super::*;
432    use crate::spec::{ApiOperation, Param};
433    use clap::{Arg, ArgAction, Command};
434    use reqwest::blocking::Client;
435    use serde_json::json;
436
437    fn make_op_with_body(body_schema: Option<Value>) -> ApiOperation {
438        ApiOperation {
439            operation_id: "TestOp".to_string(),
440            method: "POST".to_string(),
441            path: "/test".to_string(),
442            group: "Test".to_string(),
443            summary: String::new(),
444            path_params: Vec::new(),
445            query_params: Vec::new(),
446            header_params: Vec::new(),
447            body_schema,
448            body_required: false,
449        }
450    }
451
452    fn build_matches_with_args(args: &[&str], has_body: bool) -> clap::ArgMatches {
453        let mut cmd = Command::new("test");
454        if has_body {
455            cmd = cmd
456                .arg(
457                    Arg::new("json-body")
458                        .long("json")
459                        .short('j')
460                        .action(ArgAction::Set),
461                )
462                .arg(
463                    Arg::new("field")
464                        .long("field")
465                        .short('f')
466                        .action(ArgAction::Append),
467                );
468        }
469        cmd.try_get_matches_from(args).unwrap()
470    }
471
472    #[test]
473    fn build_body_returns_none_when_no_body_schema() {
474        let op = make_op_with_body(None);
475        let matches = build_matches_with_args(&["test"], false);
476
477        let result = build_body(&op, &matches).unwrap();
478        assert!(result.is_none());
479    }
480
481    #[test]
482    fn build_body_parses_json_flag() {
483        let op = make_op_with_body(Some(json!({"type": "object"})));
484        let matches =
485            build_matches_with_args(&["test", "--json", r#"{"name":"pod1","gpu":2}"#], true);
486
487        let result = build_body(&op, &matches).unwrap();
488        assert!(result.is_some());
489        let body = result.unwrap();
490        assert_eq!(body["name"], "pod1");
491        assert_eq!(body["gpu"], 2);
492    }
493
494    #[test]
495    fn build_body_parses_field_key_value() {
496        let op = make_op_with_body(Some(json!({"type": "object"})));
497        let matches =
498            build_matches_with_args(&["test", "--field", "name=pod1", "--field", "gpu=2"], true);
499
500        let result = build_body(&op, &matches).unwrap();
501        assert!(result.is_some());
502        let body = result.unwrap();
503        assert_eq!(body["name"], "pod1");
504        // "2" should be parsed as JSON number
505        assert_eq!(body["gpu"], 2);
506    }
507
508    #[test]
509    fn build_body_field_string_fallback() {
510        let op = make_op_with_body(Some(json!({"type": "object"})));
511        let matches = build_matches_with_args(&["test", "--field", "name=hello world"], true);
512
513        let result = build_body(&op, &matches).unwrap();
514        let body = result.unwrap();
515        assert_eq!(body["name"], "hello world");
516    }
517
518    #[test]
519    fn build_body_returns_error_for_invalid_field_format() {
520        let op = make_op_with_body(Some(json!({"type": "object"})));
521        let matches = build_matches_with_args(&["test", "--field", "no-equals-sign"], true);
522
523        let result = build_body(&op, &matches);
524        assert!(result.is_err());
525        let err_msg = result.unwrap_err().to_string();
526        assert!(
527            err_msg.contains("invalid --field format"),
528            "error should mention invalid format, got: {err_msg}"
529        );
530    }
531
532    #[test]
533    fn build_body_returns_error_for_invalid_json() {
534        let op = make_op_with_body(Some(json!({"type": "object"})));
535        let matches = build_matches_with_args(&["test", "--json", "{invalid json}"], true);
536
537        let result = build_body(&op, &matches);
538        assert!(result.is_err());
539        let err_msg = result.unwrap_err().to_string();
540        assert!(
541            err_msg.contains("invalid JSON"),
542            "error should mention invalid JSON, got: {err_msg}"
543        );
544    }
545
546    #[test]
547    fn build_body_returns_none_when_schema_present_but_no_flags() {
548        let op = make_op_with_body(Some(json!({"type": "object"})));
549        let matches = build_matches_with_args(&["test"], true);
550
551        let result = build_body(&op, &matches).unwrap();
552        assert!(result.is_none());
553    }
554
555    #[test]
556    fn build_body_json_takes_precedence_over_field() {
557        let op = make_op_with_body(Some(json!({"type": "object"})));
558        let matches = build_matches_with_args(
559            &[
560                "test",
561                "--json",
562                r#"{"from":"json"}"#,
563                "--field",
564                "from=field",
565            ],
566            true,
567        );
568
569        let result = build_body(&op, &matches).unwrap();
570        let body = result.unwrap();
571        // --json should win over --field
572        assert_eq!(body["from"], "json");
573    }
574
575    #[test]
576    fn build_body_returns_error_when_body_required_but_not_provided() {
577        let mut op = make_op_with_body(Some(json!({"type": "object"})));
578        op.body_required = true;
579        let matches = build_matches_with_args(&["test"], true);
580
581        let result = build_body(&op, &matches);
582        assert!(result.is_err());
583        assert!(result
584            .unwrap_err()
585            .to_string()
586            .contains("request body is required"));
587    }
588
589    // -- dispatch integration tests --
590
591    fn make_full_op(
592        method: &str,
593        path: &str,
594        path_params: Vec<Param>,
595        query_params: Vec<Param>,
596        header_params: Vec<Param>,
597        body_schema: Option<serde_json::Value>,
598    ) -> ApiOperation {
599        ApiOperation {
600            operation_id: "TestOp".to_string(),
601            method: method.to_string(),
602            path: path.to_string(),
603            group: "Test".to_string(),
604            summary: String::new(),
605            path_params,
606            query_params,
607            header_params,
608            body_schema,
609            body_required: false,
610        }
611    }
612
613    #[test]
614    fn dispatch_sends_get_with_path_and_query_params() {
615        let mut server = mockito::Server::new();
616        let mock = server
617            .mock("GET", "/pods/123")
618            .match_query(mockito::Matcher::UrlEncoded(
619                "verbose".into(),
620                "true".into(),
621            ))
622            .match_header("authorization", "Bearer test-key")
623            .with_status(200)
624            .with_header("content-type", "application/json")
625            .with_body(r#"{"id":"123"}"#)
626            .create();
627
628        let op = make_full_op(
629            "GET",
630            "/pods/{podId}",
631            vec![Param {
632                name: "podId".into(),
633                description: String::new(),
634                required: true,
635                schema: json!({"type": "string"}),
636            }],
637            vec![Param {
638                name: "verbose".into(),
639                description: String::new(),
640                required: false,
641                schema: json!({"type": "boolean"}),
642            }],
643            Vec::new(),
644            None,
645        );
646
647        let cmd = Command::new("test")
648            .arg(Arg::new("podId").required(true))
649            .arg(
650                Arg::new("verbose")
651                    .long("verbose")
652                    .action(ArgAction::SetTrue),
653            );
654        let matches = cmd
655            .try_get_matches_from(["test", "123", "--verbose"])
656            .unwrap();
657
658        let client = Client::new();
659        let result = dispatch(
660            &client,
661            &server.url(),
662            &Auth::Bearer("test-key"),
663            &op,
664            &matches,
665        );
666        assert!(result.is_ok());
667        assert_eq!(result.unwrap()["id"], "123");
668        mock.assert();
669    }
670
671    #[test]
672    fn dispatch_sends_post_with_json_body() {
673        let mut server = mockito::Server::new();
674        let mock = server
675            .mock("POST", "/pods")
676            .match_header("content-type", "application/json")
677            .match_body(mockito::Matcher::Json(json!({"name": "pod1"})))
678            .with_status(200)
679            .with_header("content-type", "application/json")
680            .with_body(r#"{"id":"new"}"#)
681            .create();
682
683        let op = make_full_op(
684            "POST",
685            "/pods",
686            Vec::new(),
687            Vec::new(),
688            Vec::new(),
689            Some(json!({"type": "object"})),
690        );
691
692        let cmd = Command::new("test").arg(
693            Arg::new("json-body")
694                .long("json")
695                .short('j')
696                .action(ArgAction::Set),
697        );
698        let matches = cmd
699            .try_get_matches_from(["test", "--json", r#"{"name":"pod1"}"#])
700            .unwrap();
701
702        let client = Client::new();
703        let result = dispatch(&client, &server.url(), &Auth::Bearer("key"), &op, &matches);
704        assert!(result.is_ok());
705        assert_eq!(result.unwrap()["id"], "new");
706        mock.assert();
707    }
708
709    #[test]
710    fn dispatch_sends_header_params() {
711        let mut server = mockito::Server::new();
712        let mock = server
713            .mock("GET", "/test")
714            .match_header("X-Request-Id", "abc123")
715            .with_status(200)
716            .with_header("content-type", "application/json")
717            .with_body(r#"{"ok":true}"#)
718            .create();
719
720        let op = make_full_op(
721            "GET",
722            "/test",
723            Vec::new(),
724            Vec::new(),
725            vec![Param {
726                name: "X-Request-Id".into(),
727                description: String::new(),
728                required: false,
729                schema: json!({"type": "string"}),
730            }],
731            None,
732        );
733
734        let cmd = Command::new("test").arg(
735            Arg::new("X-Request-Id")
736                .long("X-Request-Id")
737                .action(ArgAction::Set),
738        );
739        let matches = cmd
740            .try_get_matches_from(["test", "--X-Request-Id", "abc123"])
741            .unwrap();
742
743        let client = Client::new();
744        let result = dispatch(&client, &server.url(), &Auth::Bearer("key"), &op, &matches);
745        assert!(result.is_ok());
746        mock.assert();
747    }
748
749    #[test]
750    fn dispatch_url_encodes_path_params() {
751        let mut server = mockito::Server::new();
752        let mock = server
753            .mock("GET", "/items/hello%20world")
754            .with_status(200)
755            .with_header("content-type", "application/json")
756            .with_body(r#"{"ok":true}"#)
757            .create();
758
759        let op = make_full_op(
760            "GET",
761            "/items/{itemId}",
762            vec![Param {
763                name: "itemId".into(),
764                description: String::new(),
765                required: true,
766                schema: json!({"type": "string"}),
767            }],
768            Vec::new(),
769            Vec::new(),
770            None,
771        );
772
773        let cmd = Command::new("test").arg(Arg::new("itemId").required(true));
774        let matches = cmd.try_get_matches_from(["test", "hello world"]).unwrap();
775
776        let client = Client::new();
777        let result = dispatch(&client, &server.url(), &Auth::Bearer("key"), &op, &matches);
778        assert!(result.is_ok());
779        mock.assert();
780    }
781
782    #[test]
783    fn dispatch_returns_error_on_non_success_status() {
784        let mut server = mockito::Server::new();
785        let _mock = server
786            .mock("GET", "/fail")
787            .with_status(404)
788            .with_body("not found")
789            .create();
790
791        let op = make_full_op("GET", "/fail", Vec::new(), Vec::new(), Vec::new(), None);
792
793        let cmd = Command::new("test");
794        let matches = cmd.try_get_matches_from(["test"]).unwrap();
795
796        let client = Client::new();
797        let result = dispatch(&client, &server.url(), &Auth::Bearer("key"), &op, &matches);
798        assert!(result.is_err());
799        let err_msg = result.unwrap_err().to_string();
800        assert!(
801            err_msg.contains("404"),
802            "error should contain status code, got: {err_msg}"
803        );
804    }
805
806    #[test]
807    fn dispatch_omits_auth_header_when_auth_none() {
808        let mut server = mockito::Server::new();
809        let mock = server
810            .mock("GET", "/test")
811            .match_header("authorization", mockito::Matcher::Missing)
812            .with_status(200)
813            .with_header("content-type", "application/json")
814            .with_body(r#"{"ok":true}"#)
815            .create();
816
817        let op = make_full_op("GET", "/test", Vec::new(), Vec::new(), Vec::new(), None);
818
819        let cmd = Command::new("test");
820        let matches = cmd.try_get_matches_from(["test"]).unwrap();
821
822        let client = Client::new();
823        let result = dispatch(&client, &server.url(), &Auth::None, &op, &matches);
824        assert!(result.is_ok());
825        mock.assert();
826    }
827
828    #[test]
829    fn dispatch_sends_custom_header_auth() {
830        let mut server = mockito::Server::new();
831        let mock = server
832            .mock("GET", "/test")
833            .match_header("X-API-Key", "my-secret")
834            .with_status(200)
835            .with_header("content-type", "application/json")
836            .with_body(r#"{"ok":true}"#)
837            .create();
838
839        let op = make_full_op("GET", "/test", Vec::new(), Vec::new(), Vec::new(), None);
840
841        let cmd = Command::new("test");
842        let matches = cmd.try_get_matches_from(["test"]).unwrap();
843
844        let client = Client::new();
845        let auth = Auth::Header {
846            name: "X-API-Key",
847            value: "my-secret",
848        };
849        let result = dispatch(&client, &server.url(), &auth, &op, &matches);
850        assert!(result.is_ok());
851        mock.assert();
852    }
853
854    #[test]
855    fn dispatch_sends_basic_auth() {
856        let mut server = mockito::Server::new();
857        // Basic auth header: base64("user:pass") = "dXNlcjpwYXNz"
858        let mock = server
859            .mock("GET", "/test")
860            .match_header("authorization", "Basic dXNlcjpwYXNz")
861            .with_status(200)
862            .with_header("content-type", "application/json")
863            .with_body(r#"{"ok":true}"#)
864            .create();
865
866        let op = make_full_op("GET", "/test", Vec::new(), Vec::new(), Vec::new(), None);
867
868        let cmd = Command::new("test");
869        let matches = cmd.try_get_matches_from(["test"]).unwrap();
870
871        let client = Client::new();
872        let auth = Auth::Basic {
873            username: "user",
874            password: Some("pass"),
875        };
876        let result = dispatch(&client, &server.url(), &auth, &op, &matches);
877        assert!(result.is_ok());
878        mock.assert();
879    }
880
881    #[test]
882    fn dispatch_sends_query_auth() {
883        let mut server = mockito::Server::new();
884        let mock = server
885            .mock("GET", "/test")
886            .match_query(mockito::Matcher::UrlEncoded(
887                "api_key".into(),
888                "my-secret".into(),
889            ))
890            .match_header("authorization", mockito::Matcher::Missing)
891            .with_status(200)
892            .with_header("content-type", "application/json")
893            .with_body(r#"{"ok":true}"#)
894            .create();
895
896        let op = make_full_op("GET", "/test", Vec::new(), Vec::new(), Vec::new(), None);
897
898        let cmd = Command::new("test");
899        let matches = cmd.try_get_matches_from(["test"]).unwrap();
900
901        let client = Client::new();
902        let auth = Auth::Query {
903            name: "api_key",
904            value: "my-secret",
905        };
906        let result = dispatch(&client, &server.url(), &auth, &op, &matches);
907        assert!(result.is_ok());
908        mock.assert();
909    }
910
911    #[test]
912    fn dispatch_query_auth_coexists_with_operation_query_params() {
913        let mut server = mockito::Server::new();
914        let mock = server
915            .mock("GET", "/test")
916            .match_query(mockito::Matcher::AllOf(vec![
917                mockito::Matcher::UrlEncoded("verbose".into(), "true".into()),
918                mockito::Matcher::UrlEncoded("api_key".into(), "secret".into()),
919            ]))
920            .match_header("authorization", mockito::Matcher::Missing)
921            .with_status(200)
922            .with_header("content-type", "application/json")
923            .with_body(r#"{"ok":true}"#)
924            .create();
925
926        let op = make_full_op(
927            "GET",
928            "/test",
929            Vec::new(),
930            vec![Param {
931                name: "verbose".into(),
932                description: String::new(),
933                required: false,
934                schema: json!({"type": "boolean"}),
935            }],
936            Vec::new(),
937            None,
938        );
939
940        let cmd = Command::new("test").arg(
941            Arg::new("verbose")
942                .long("verbose")
943                .action(ArgAction::SetTrue),
944        );
945        let matches = cmd.try_get_matches_from(["test", "--verbose"]).unwrap();
946
947        let client = Client::new();
948        let auth = Auth::Query {
949            name: "api_key",
950            value: "secret",
951        };
952        let result = dispatch(&client, &server.url(), &auth, &op, &matches);
953        assert!(result.is_ok());
954        mock.assert();
955    }
956
957    #[test]
958    fn dispatch_returns_string_value_for_non_json_response() {
959        let mut server = mockito::Server::new();
960        let _mock = server
961            .mock("GET", "/plain")
962            .with_status(200)
963            .with_header("content-type", "text/plain")
964            .with_body("plain text response")
965            .create();
966
967        let op = make_full_op("GET", "/plain", Vec::new(), Vec::new(), Vec::new(), None);
968
969        let cmd = Command::new("test");
970        let matches = cmd.try_get_matches_from(["test"]).unwrap();
971
972        let client = Client::new();
973        let result = dispatch(&client, &server.url(), &Auth::Bearer("key"), &op, &matches);
974        assert!(result.is_ok());
975        assert_eq!(result.unwrap(), Value::String("plain text response".into()));
976    }
977
978    // -- PreparedRequest unit tests --
979
980    #[test]
981    fn prepared_request_resolves_url_and_method() {
982        let op = make_full_op(
983            "GET",
984            "/pods/{podId}",
985            vec![Param {
986                name: "podId".into(),
987                description: String::new(),
988                required: true,
989                schema: json!({"type": "string"}),
990            }],
991            Vec::new(),
992            Vec::new(),
993            None,
994        );
995        let cmd = Command::new("test").arg(Arg::new("podId").required(true));
996        let matches = cmd.try_get_matches_from(["test", "abc"]).unwrap();
997
998        let prepared =
999            PreparedRequest::from_operation("https://api.example.com", &Auth::None, &op, &matches)
1000                .unwrap();
1001
1002        assert_eq!(prepared.method, Method::GET);
1003        assert_eq!(prepared.url, "https://api.example.com/pods/abc");
1004        assert!(prepared.query_pairs.is_empty());
1005        assert!(prepared.headers.is_empty());
1006        assert!(prepared.body.is_none());
1007        assert_eq!(prepared.auth, ResolvedAuth::None);
1008    }
1009
1010    #[test]
1011    fn prepared_request_collects_query_pairs() {
1012        let op = make_full_op(
1013            "GET",
1014            "/test",
1015            Vec::new(),
1016            vec![
1017                Param {
1018                    name: "limit".into(),
1019                    description: String::new(),
1020                    required: false,
1021                    schema: json!({"type": "integer"}),
1022                },
1023                Param {
1024                    name: "verbose".into(),
1025                    description: String::new(),
1026                    required: false,
1027                    schema: json!({"type": "boolean"}),
1028                },
1029            ],
1030            Vec::new(),
1031            None,
1032        );
1033        let cmd = Command::new("test")
1034            .arg(Arg::new("limit").long("limit").action(ArgAction::Set))
1035            .arg(
1036                Arg::new("verbose")
1037                    .long("verbose")
1038                    .action(ArgAction::SetTrue),
1039            );
1040        let matches = cmd
1041            .try_get_matches_from(["test", "--limit", "10", "--verbose"])
1042            .unwrap();
1043
1044        let prepared =
1045            PreparedRequest::from_operation("https://api.example.com", &Auth::None, &op, &matches)
1046                .unwrap();
1047
1048        assert_eq!(
1049            prepared.query_pairs,
1050            vec![
1051                ("limit".to_string(), "10".to_string()),
1052                ("verbose".to_string(), "true".to_string()),
1053            ]
1054        );
1055    }
1056
1057    #[test]
1058    fn prepared_request_collects_headers() {
1059        let op = make_full_op(
1060            "GET",
1061            "/test",
1062            Vec::new(),
1063            Vec::new(),
1064            vec![Param {
1065                name: "X-Request-Id".into(),
1066                description: String::new(),
1067                required: false,
1068                schema: json!({"type": "string"}),
1069            }],
1070            None,
1071        );
1072        let cmd = Command::new("test").arg(
1073            Arg::new("X-Request-Id")
1074                .long("X-Request-Id")
1075                .action(ArgAction::Set),
1076        );
1077        let matches = cmd
1078            .try_get_matches_from(["test", "--X-Request-Id", "req-42"])
1079            .unwrap();
1080
1081        let prepared =
1082            PreparedRequest::from_operation("https://api.example.com", &Auth::None, &op, &matches)
1083                .unwrap();
1084
1085        assert_eq!(
1086            prepared.headers,
1087            vec![("X-Request-Id".to_string(), "req-42".to_string())]
1088        );
1089    }
1090
1091    #[test]
1092    fn from_operation_does_not_set_body() {
1093        let op = make_full_op(
1094            "POST",
1095            "/test",
1096            Vec::new(),
1097            Vec::new(),
1098            Vec::new(),
1099            Some(json!({"type": "object"})),
1100        );
1101        let cmd = Command::new("test").arg(
1102            Arg::new("json-body")
1103                .long("json")
1104                .short('j')
1105                .action(ArgAction::Set),
1106        );
1107        let matches = cmd
1108            .try_get_matches_from(["test", "--json", r#"{"key":"val"}"#])
1109            .unwrap();
1110
1111        let prepared =
1112            PreparedRequest::from_operation("https://api.example.com", &Auth::None, &op, &matches)
1113                .unwrap();
1114
1115        // from_operation no longer resolves body
1116        assert_eq!(prepared.body, None);
1117
1118        // build_body resolves it separately
1119        let body = build_body(&op, &matches).unwrap();
1120        assert_eq!(body, Some(json!({"key": "val"})));
1121    }
1122
1123    #[test]
1124    fn prepared_request_resolves_bearer_auth() {
1125        let op = make_full_op("GET", "/test", Vec::new(), Vec::new(), Vec::new(), None);
1126        let cmd = Command::new("test");
1127        let matches = cmd.try_get_matches_from(["test"]).unwrap();
1128
1129        let prepared = PreparedRequest::from_operation(
1130            "https://api.example.com",
1131            &Auth::Bearer("my-token"),
1132            &op,
1133            &matches,
1134        )
1135        .unwrap();
1136
1137        assert_eq!(prepared.auth, ResolvedAuth::Bearer("my-token".to_string()));
1138    }
1139
1140    #[test]
1141    fn prepared_request_resolves_basic_auth() {
1142        let op = make_full_op("GET", "/test", Vec::new(), Vec::new(), Vec::new(), None);
1143        let cmd = Command::new("test");
1144        let matches = cmd.try_get_matches_from(["test"]).unwrap();
1145
1146        let prepared = PreparedRequest::from_operation(
1147            "https://api.example.com",
1148            &Auth::Basic {
1149                username: "user",
1150                password: Some("pass"),
1151            },
1152            &op,
1153            &matches,
1154        )
1155        .unwrap();
1156
1157        assert_eq!(
1158            prepared.auth,
1159            ResolvedAuth::Basic {
1160                username: "user".to_string(),
1161                password: Some("pass".to_string()),
1162            }
1163        );
1164    }
1165
1166    #[test]
1167    fn prepared_request_resolves_header_auth() {
1168        let op = make_full_op("GET", "/test", Vec::new(), Vec::new(), Vec::new(), None);
1169        let cmd = Command::new("test");
1170        let matches = cmd.try_get_matches_from(["test"]).unwrap();
1171
1172        let prepared = PreparedRequest::from_operation(
1173            "https://api.example.com",
1174            &Auth::Header {
1175                name: "X-API-Key",
1176                value: "secret",
1177            },
1178            &op,
1179            &matches,
1180        )
1181        .unwrap();
1182
1183        assert_eq!(
1184            prepared.auth,
1185            ResolvedAuth::Header {
1186                name: "X-API-Key".to_string(),
1187                value: "secret".to_string(),
1188            }
1189        );
1190    }
1191
1192    #[test]
1193    fn prepared_request_resolves_query_auth_separate_from_query_pairs() {
1194        let op = make_full_op(
1195            "GET",
1196            "/test",
1197            Vec::new(),
1198            vec![Param {
1199                name: "verbose".into(),
1200                description: String::new(),
1201                required: false,
1202                schema: json!({"type": "boolean"}),
1203            }],
1204            Vec::new(),
1205            None,
1206        );
1207        let cmd = Command::new("test").arg(
1208            Arg::new("verbose")
1209                .long("verbose")
1210                .action(ArgAction::SetTrue),
1211        );
1212        let matches = cmd.try_get_matches_from(["test", "--verbose"]).unwrap();
1213
1214        let prepared = PreparedRequest::from_operation(
1215            "https://api.example.com",
1216            &Auth::Query {
1217                name: "api_key",
1218                value: "secret",
1219            },
1220            &op,
1221            &matches,
1222        )
1223        .unwrap();
1224
1225        // Auth query param is NOT in query_pairs — it's in auth
1226        assert_eq!(
1227            prepared.query_pairs,
1228            vec![("verbose".to_string(), "true".to_string())]
1229        );
1230        assert_eq!(
1231            prepared.auth,
1232            ResolvedAuth::Query {
1233                name: "api_key".to_string(),
1234                value: "secret".to_string(),
1235            }
1236        );
1237    }
1238
1239    #[test]
1240    fn prepared_request_url_encodes_path_params() {
1241        let op = make_full_op(
1242            "GET",
1243            "/items/{name}",
1244            vec![Param {
1245                name: "name".into(),
1246                description: String::new(),
1247                required: true,
1248                schema: json!({"type": "string"}),
1249            }],
1250            Vec::new(),
1251            Vec::new(),
1252            None,
1253        );
1254        let cmd = Command::new("test").arg(Arg::new("name").required(true));
1255        let matches = cmd.try_get_matches_from(["test", "hello world"]).unwrap();
1256
1257        let prepared =
1258            PreparedRequest::from_operation("https://api.example.com", &Auth::None, &op, &matches)
1259                .unwrap();
1260
1261        assert_eq!(prepared.url, "https://api.example.com/items/hello%20world");
1262    }
1263
1264    #[test]
1265    fn prepared_request_returns_error_for_unsupported_method() {
1266        // Method must contain invalid HTTP token characters to fail parsing
1267        let op = make_full_op(
1268            "NOT VALID",
1269            "/test",
1270            Vec::new(),
1271            Vec::new(),
1272            Vec::new(),
1273            None,
1274        );
1275        let cmd = Command::new("test");
1276        let matches = cmd.try_get_matches_from(["test"]).unwrap();
1277
1278        let result =
1279            PreparedRequest::from_operation("https://api.example.com", &Auth::None, &op, &matches);
1280        assert!(result.is_err());
1281        assert!(result
1282            .unwrap_err()
1283            .to_string()
1284            .contains("unsupported HTTP method"));
1285    }
1286
1287    // -- PreparedRequest builder tests --
1288
1289    #[test]
1290    fn builder_new_sets_method_and_url() {
1291        let req = PreparedRequest::new(Method::GET, "https://api.example.com/test");
1292
1293        assert_eq!(req.method, Method::GET);
1294        assert_eq!(req.url, "https://api.example.com/test");
1295        assert!(req.query_pairs.is_empty());
1296        assert!(req.headers.is_empty());
1297        assert!(req.body.is_none());
1298        assert_eq!(req.auth, ResolvedAuth::None);
1299    }
1300
1301    #[test]
1302    fn builder_query_appends_pairs() {
1303        let req = PreparedRequest::new(Method::GET, "https://example.com")
1304            .query("limit", "10")
1305            .query("offset", "0");
1306
1307        assert_eq!(
1308            req.query_pairs,
1309            vec![
1310                ("limit".to_string(), "10".to_string()),
1311                ("offset".to_string(), "0".to_string()),
1312            ]
1313        );
1314    }
1315
1316    #[test]
1317    fn builder_header_appends_headers() {
1318        let req = PreparedRequest::new(Method::GET, "https://example.com")
1319            .header("X-Request-Id", "abc123")
1320            .header("Accept", "application/json");
1321
1322        assert_eq!(
1323            req.headers,
1324            vec![
1325                ("X-Request-Id".to_string(), "abc123".to_string()),
1326                ("Accept".to_string(), "application/json".to_string()),
1327            ]
1328        );
1329    }
1330
1331    #[test]
1332    fn builder_clear_query_removes_all_pairs() {
1333        let req = PreparedRequest::new(Method::GET, "https://example.com")
1334            .query("a", "1")
1335            .query("b", "2")
1336            .clear_query();
1337
1338        assert!(req.query_pairs.is_empty());
1339    }
1340
1341    #[test]
1342    fn builder_clear_headers_removes_all_headers() {
1343        let req = PreparedRequest::new(Method::GET, "https://example.com")
1344            .header("X-Foo", "bar")
1345            .header("X-Baz", "qux")
1346            .clear_headers();
1347
1348        assert!(req.headers.is_empty());
1349    }
1350
1351    #[test]
1352    fn builder_body_sets_json() {
1353        let req = PreparedRequest::new(Method::POST, "https://example.com")
1354            .body(json!({"input": {"prompt": "hello"}}));
1355
1356        assert_eq!(req.body, Some(json!({"input": {"prompt": "hello"}})));
1357    }
1358
1359    #[test]
1360    fn builder_auth_sets_resolved_auth() {
1361        let req = PreparedRequest::new(Method::POST, "https://example.com")
1362            .auth(ResolvedAuth::Bearer("my-token".into()));
1363
1364        assert_eq!(req.auth, ResolvedAuth::Bearer("my-token".to_string()));
1365    }
1366
1367    #[test]
1368    fn builder_chaining_all_fields() {
1369        let req = PreparedRequest::new(Method::POST, "https://api.runpod.ai/v2/abc/run")
1370            .auth(ResolvedAuth::Bearer("key".into()))
1371            .body(json!({"input": {}}))
1372            .query("wait", "90000")
1373            .header("X-Custom", "value");
1374
1375        assert_eq!(req.method, Method::POST);
1376        assert_eq!(req.url, "https://api.runpod.ai/v2/abc/run");
1377        assert_eq!(req.auth, ResolvedAuth::Bearer("key".to_string()));
1378        assert_eq!(req.body, Some(json!({"input": {}})));
1379        assert_eq!(
1380            req.query_pairs,
1381            vec![("wait".to_string(), "90000".to_string())]
1382        );
1383        assert_eq!(
1384            req.headers,
1385            vec![("X-Custom".to_string(), "value".to_string())]
1386        );
1387    }
1388
1389    #[test]
1390    fn builder_send_executes_request() {
1391        let mut server = mockito::Server::new();
1392        let mock = server
1393            .mock("POST", "/v2/abc/run")
1394            .match_header("authorization", "Bearer test-key")
1395            .match_header("content-type", "application/json")
1396            .match_body(mockito::Matcher::Json(json!({"input": {"prompt": "hi"}})))
1397            .with_status(200)
1398            .with_header("content-type", "application/json")
1399            .with_body(r#"{"id":"job-123","status":"IN_QUEUE"}"#)
1400            .create();
1401
1402        let req = PreparedRequest::new(Method::POST, format!("{}/v2/abc/run", server.url()))
1403            .auth(ResolvedAuth::Bearer("test-key".into()))
1404            .body(json!({"input": {"prompt": "hi"}}));
1405
1406        let client = Client::new();
1407        let resp = req.send(&client).expect("send should succeed");
1408        assert!(resp.status.is_success());
1409        let val = resp.into_json().expect("should parse JSON");
1410        assert_eq!(val["id"], "job-123");
1411        assert_eq!(val["status"], "IN_QUEUE");
1412        mock.assert();
1413    }
1414
1415    #[test]
1416    fn builder_send_with_query_auth() {
1417        let mut server = mockito::Server::new();
1418        let mock = server
1419            .mock("GET", "/health")
1420            .match_query(mockito::Matcher::UrlEncoded(
1421                "api_key".into(),
1422                "secret".into(),
1423            ))
1424            .with_status(200)
1425            .with_header("content-type", "application/json")
1426            .with_body(r#"{"ok":true}"#)
1427            .create();
1428
1429        let req = PreparedRequest::new(Method::GET, format!("{}/health", server.url())).auth(
1430            ResolvedAuth::Query {
1431                name: "api_key".into(),
1432                value: "secret".into(),
1433            },
1434        );
1435
1436        let client = Client::new();
1437        let resp = req.send(&client).expect("send should succeed");
1438        let val = resp.into_json().expect("should parse JSON");
1439        assert_eq!(val["ok"], true);
1440        mock.assert();
1441    }
1442}