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