Skip to main content

xurl/api/
request.rs

1/// HTTP request building and execution for the X API.
2///
3/// Mirrors the Go `ApiClient` — builds requests with auth headers,
4/// handles regular/streaming/multipart responses.
5use std::collections::HashMap;
6use std::io::{BufRead, BufReader};
7use std::time::Duration;
8
9use percent_encoding::{AsciiSet, CONTROLS, utf8_percent_encode};
10use reqwest::blocking::{Client, multipart};
11
12use crate::auth::Auth;
13use crate::config::Config;
14use crate::error::{Result, XurlError};
15use crate::output::OutputConfig;
16
17/// Percent-encoding set for path-parameter values and query parameters.
18///
19/// Matches RFC 3986 §2.3 "unreserved" characters (alphanumeric, `-_.~`)
20/// — everything else is encoded. Mirrors `percent-encoding`'s
21/// `NON_ALPHANUMERIC` set widened to keep the URL-safe punctuation.
22const URL_VALUE_ENCODE_SET: &AsciiSet = &CONTROLS
23    .add(b' ')
24    .add(b'"')
25    .add(b'#')
26    .add(b'%')
27    .add(b'&')
28    .add(b'+')
29    .add(b',')
30    .add(b'/')
31    .add(b':')
32    .add(b';')
33    .add(b'<')
34    .add(b'=')
35    .add(b'>')
36    .add(b'?')
37    .add(b'@')
38    .add(b'[')
39    .add(b'\\')
40    .add(b']')
41    .add(b'^')
42    .add(b'`')
43    .add(b'{')
44    .add(b'|')
45    .add(b'}');
46
47/// Typed target for an HTTP request.
48///
49/// Either a template path with substitutable `{name}` segments plus an
50/// ordered query list (`Template`), or a fully-formed external URL that
51/// bypasses the auth matrix (`RawUrl`). The split is the v2.0.0 contract
52/// that lets the matrix validator reason about `(method, path)` without
53/// re-parsing already-rendered URLs.
54#[derive(Debug, Clone)]
55pub enum RequestTarget {
56    /// API path template — e.g. `"/2/users/{id}/likes"` — paired with
57    /// `path_params` to substitute and a `query` vec whose insertion
58    /// order is preserved on render.
59    Template {
60        /// Path template containing `{name}` segments to substitute. Must
61        /// match the spec verbatim (the auth matrix is keyed on this string).
62        path: String,
63        /// Map of `{name}` segments to caller-supplied values.
64        path_params: HashMap<String, String>,
65        /// Ordered query parameters. Each `(key, value)` pair is
66        /// percent-encoded and joined with `&`; empty `query` produces no
67        /// `?` on render.
68        query: Vec<(String, String)>,
69    },
70    /// Fully-formed URL — used by raw mode (`xr <URL>`) and by shortcuts
71    /// whose path is intentionally outside the spec. The matrix validator
72    /// short-circuits for `RawUrl` — the user accepted the contract by
73    /// reaching for raw mode.
74    RawUrl(String),
75}
76
77impl Default for RequestTarget {
78    fn default() -> Self {
79        Self::Template {
80            path: String::new(),
81            path_params: HashMap::new(),
82            query: Vec::new(),
83        }
84    }
85}
86
87/// Common options for API requests.
88///
89/// Threaded into [`ApiClient::send_request`], [`ApiClient::send_multipart_request`],
90/// and [`ApiClient::stream_request`]; carries everything those calls need
91/// beyond the client itself.
92#[derive(Debug, Clone, Default)]
93pub struct RequestOptions {
94    /// HTTP method (`"GET"`, `"POST"`, etc). Empty defaults to `"GET"`.
95    pub method: String,
96    /// Typed request target — either a path template or a raw URL.
97    pub target: RequestTarget,
98    /// Extra HTTP headers in `"Name: Value"` form.
99    pub headers: Vec<String>,
100    /// Request body. JSON-shaped strings are sent as `application/json`;
101    /// otherwise as `application/x-www-form-urlencoded`.
102    pub data: String,
103    /// Explicit auth scheme — `"oauth1"`, `"oauth2"`, `"app"`, or empty for
104    /// auto-detect against the endpoint's accepted set.
105    pub auth_type: String,
106    /// OAuth2 username for the active app. Empty selects the active app's
107    /// first stored OAuth2 token.
108    pub username: String,
109    /// Skip auth-header attachment entirely. Used for unauthenticated probes.
110    pub no_auth: bool,
111    /// Emit verbose request / response diagnostics through the client's
112    /// [`OutputConfig`].
113    pub verbose: bool,
114    /// Emit the `X-B3-Flags: 1` header to flag the request for upstream tracing.
115    pub trace: bool,
116    /// Cursor / `pagination_token` query parameter for list endpoints.
117    ///
118    /// Threaded in from the global `--cursor` / `--after` flag (or
119    /// `XURL_CURSOR` / `XURL_AFTER` env vars). List shortcuts append it to
120    /// the URL when non-empty; non-paginated endpoints ignore it.
121    pub pagination_token: String,
122}
123
124/// Default request timeout in seconds when none is supplied.
125pub const DEFAULT_TIMEOUT_SECS: u64 = 30;
126
127/// Consumer-facing options for shortcut methods.
128///
129/// Exposes only the fields relevant to crate consumers, hiding internal
130/// request construction details like `method`, `endpoint`, `headers`, and `data`.
131#[derive(Debug, Clone)]
132pub struct CallOptions {
133    /// Explicit auth scheme — `"oauth1"`, `"oauth2"`, `"app"`, or empty for
134    /// auto-detect.
135    pub auth_type: String,
136    /// OAuth2 username for the active app. Empty selects the active app's
137    /// first stored OAuth2 token.
138    pub username: String,
139    /// Skip auth-header attachment entirely.
140    pub no_auth: bool,
141    /// Emit verbose request / response diagnostics.
142    pub verbose: bool,
143    /// Emit the `X-B3-Flags: 1` header for upstream tracing.
144    pub trace: bool,
145    /// Per-call HTTP timeout in seconds. Mirrors the `--timeout` flag /
146    /// `XURL_TIMEOUT` env var. Used by the streaming and per-call refresh
147    /// paths; non-streaming requests inherit the timeout that was passed to
148    /// [`ApiClient::new`].
149    pub timeout_secs: u64,
150    /// Cursor / `pagination_token` query parameter for list endpoints.
151    ///
152    /// Threaded in from the global `--cursor` flag. List shortcuts append
153    /// it to the URL when non-empty; non-paginated endpoints ignore it.
154    pub pagination_token: String,
155}
156
157impl Default for CallOptions {
158    fn default() -> Self {
159        Self {
160            auth_type: String::new(),
161            username: String::new(),
162            no_auth: false,
163            verbose: false,
164            trace: false,
165            timeout_secs: DEFAULT_TIMEOUT_SECS,
166            pagination_token: String::new(),
167        }
168    }
169}
170
171impl CallOptions {
172    /// Converts to a [`RequestOptions`] with consumer fields populated
173    /// and request-specific fields (method, endpoint, data, headers) at defaults.
174    #[must_use]
175    pub(crate) fn to_request_options(&self) -> RequestOptions {
176        RequestOptions {
177            auth_type: self.auth_type.clone(),
178            username: self.username.clone(),
179            no_auth: self.no_auth,
180            verbose: self.verbose,
181            trace: self.trace,
182            pagination_token: self.pagination_token.clone(),
183            ..Default::default()
184        }
185    }
186}
187
188/// Options specific to multipart requests.
189///
190/// Used by the media upload path to ship a single file plus form fields
191/// in one `multipart/form-data` POST.
192#[derive(Debug, Clone)]
193pub struct MultipartOptions {
194    /// Base request configuration (target, headers, auth, verbose, trace).
195    pub request: RequestOptions,
196    /// Non-file form fields keyed by field name.
197    pub form_fields: std::collections::HashMap<String, String>,
198    /// Form field name to attach the file under (e.g. `"media"`).
199    pub file_field: String,
200    /// Path to the file to upload. Mutually exclusive with `file_data`.
201    pub file_path: String,
202    /// Filename surfaced to the server in the multipart part header. Only
203    /// consulted when `file_data` carries the bytes.
204    pub file_name: String,
205    /// In-memory file bytes. Mutually exclusive with `file_path`.
206    pub file_data: Vec<u8>,
207}
208
209/// Handles API requests with authentication.
210///
211/// # Example
212///
213/// ```rust,no_run
214/// use xurl::api::{ApiClient, RequestOptions, RequestTarget};
215/// use xurl::auth::Auth;
216/// use xurl::config::Config;
217/// use xurl::error::XurlError;
218/// use std::collections::HashMap;
219///
220/// let cfg = Config::new();
221/// let auth = Auth::new(&cfg);
222/// let mut client = ApiClient::new(&cfg, auth);
223///
224/// let mut opts = RequestOptions::default();
225/// opts.method = "GET".to_string();
226/// opts.target = RequestTarget::Template {
227///     path: "/2/users/me".to_string(),
228///     path_params: HashMap::new(),
229///     query: Vec::new(),
230/// };
231///
232/// match client.send_request(&opts) {
233///     Ok(json) => println!("{json}"),
234///     Err(XurlError::Api { status, body }) => eprintln!("API {status}: {body}"),
235///     Err(e) => eprintln!("error: {e}"),
236/// }
237/// ```
238pub struct ApiClient {
239    base_url: String,
240    client: Client,
241    auth: Auth,
242    timeout_secs: u64,
243    /// Output configuration used to route verbose request/response logs
244    /// through the single owner in `src/output.rs`. Library callers that
245    /// haven't supplied one get the [`OutputConfig::default`] (text, no
246    /// verbose) — `verbose=false` suppresses diagnostics.
247    out: OutputConfig,
248}
249
250impl ApiClient {
251    /// Creates a new `ApiClient` using the timeout configured on `config`.
252    ///
253    /// The CLI runner writes `--timeout` / `XURL_TIMEOUT` into
254    /// [`Config::http_timeout_secs`]; library consumers that want a different
255    /// timeout can use [`ApiClient::with_timeout`].
256    pub fn new(config: &Config, auth: Auth) -> Self {
257        Self::with_timeout(config, auth, config.http_timeout_secs)
258    }
259
260    /// Creates a new `ApiClient` with an explicit request timeout.
261    ///
262    /// The timeout bounds every non-streaming HTTP call dispatched by this
263    /// client. Streaming requests intentionally retain `.timeout(None)` — the
264    /// long-running shape is the point — and bound runtime via signal handlers
265    /// in the streaming handler.
266    pub fn with_timeout(config: &Config, auth: Auth, timeout_secs: u64) -> Self {
267        let client = Client::builder()
268            .timeout(Duration::from_secs(timeout_secs))
269            .build()
270            .unwrap_or_else(|_| Client::new());
271
272        Self {
273            base_url: config.api_base_url.clone(),
274            client,
275            auth,
276            timeout_secs,
277            out: OutputConfig::default(),
278        }
279    }
280
281    /// Returns the per-call timeout used by this client (seconds).
282    #[must_use]
283    pub fn timeout_secs(&self) -> u64 {
284        self.timeout_secs
285    }
286
287    /// Installs an `OutputConfig` for verbose request/response diagnostics.
288    ///
289    /// The CLI runner calls this after constructing the client so the
290    /// verbose logs route through the single output owner. Library callers
291    /// that skip this get a default config (text, verbose off).
292    pub fn set_output(&mut self, out: OutputConfig) {
293        self.out = out;
294    }
295
296    /// Creates an `ApiClient` from environment variables.
297    ///
298    /// Reads `CLIENT_ID`, `CLIENT_SECRET`, and other env vars via [`Config::new()`],
299    /// validates that `CLIENT_ID` is non-empty, and returns a ready-to-use client.
300    ///
301    /// For full control over configuration and auth, use [`ApiClient::new()`] instead.
302    ///
303    /// # Errors
304    ///
305    /// Returns `XurlError::Validation` if `CLIENT_ID` is not set or empty.
306    #[allow(dead_code)] // Public library API — used by consumers
307    pub fn from_env() -> Result<Self> {
308        let cfg = Config::new();
309        if cfg.client_id.is_empty() {
310            return Err(XurlError::validation(
311                "CLIENT_ID not set — set the environment variable or use ApiClient::new() for manual configuration",
312            ));
313        }
314        let auth = Auth::new(&cfg);
315        Ok(Self::new(&cfg, auth))
316    }
317
318    /// Builds the full URL from a target (public accessor for command layer).
319    ///
320    /// # Errors
321    ///
322    /// Returns [`XurlError::InvalidUrl`] when a `RawUrl` target's scheme
323    /// is not `http` or `https`, [`XurlError::InvalidPathParam`] when a
324    /// substituted value contains a URL-reserved character, or
325    /// [`XurlError::Internal`] when a path template references a `{name}`
326    /// segment missing from `path_params`.
327    pub fn build_url_public(&self, target: &RequestTarget) -> Result<String> {
328        self.build_url(target)
329    }
330
331    /// Returns the active app name carried by the underlying [`Auth`].
332    ///
333    /// Library-public so callers building requests outside `ApiClient` (e.g.
334    /// the CLI streaming wrapper) can thread the active app into
335    /// `auth_matrix::validate` for the user-facing message.
336    #[must_use]
337    pub fn auth_app_name(&self) -> &str {
338        self.auth.app_name()
339    }
340
341    /// Builds the full URL from a target.
342    fn build_url(&self, target: &RequestTarget) -> Result<String> {
343        build_url_for_target(&self.base_url, target)
344    }
345
346    /// Sends a regular API request and returns the JSON response.
347    ///
348    /// # Errors
349    ///
350    /// Returns an error if the HTTP method is invalid, the request fails,
351    /// or the API returns an error status (>= 400).
352    pub fn send_request(&mut self, options: &RequestOptions) -> Result<serde_json::Value> {
353        let method = options.method.to_uppercase();
354        let method = if method.is_empty() { "GET" } else { &method };
355        // Auth-matrix validation lives inside `get_auth_header` (called
356        // below) so each request performs one matrix lookup, not two. The
357        // explicit-auth branch there rejects with
358        // `XurlError::AuthMethodMismatch` before any header is produced;
359        // `no_auth: true` short-circuits past the call site entirely.
360        let url = self.build_url(&options.target)?;
361
362        // Build the request
363        let req_method = reqwest::Method::from_bytes(method.as_bytes())
364            .map_err(|_| XurlError::InvalidMethod(method.to_string()))?;
365
366        let mut builder = self.client.request(req_method.clone(), &url);
367
368        // Add body for POST/PUT/PATCH
369        if !options.data.is_empty() && (method == "POST" || method == "PUT" || method == "PATCH") {
370            // Detect content type
371            if serde_json::from_str::<serde_json::Value>(&options.data).is_ok() {
372                builder = builder
373                    .header("Content-Type", "application/json")
374                    .body(options.data.clone());
375            } else {
376                builder = builder
377                    .header("Content-Type", "application/x-www-form-urlencoded")
378                    .body(options.data.clone());
379            }
380        }
381
382        // Add custom headers
383        for header in &options.headers {
384            if let Some((key, value)) = header.split_once(':') {
385                builder = builder.header(key.trim(), value.trim());
386            }
387        }
388
389        // Add auth header (skip if no_auth is set). When auth resolution
390        // fails (e.g., TokenNotFound for the resolved app), propagate the
391        // error so the user sees the real problem instead of letting the
392        // request go out unauthenticated and surfacing as a confusing 401
393        // from upstream. The older "silently skip on Err" form let auth
394        // bugs masquerade as upstream auth rejections.
395        if !options.no_auth {
396            let auth_header = self.get_auth_header(options)?;
397            builder = builder.header("Authorization", auth_header);
398        }
399
400        // Add common headers
401        builder = builder.header("User-Agent", format!("xurl/{}", env!("CARGO_PKG_VERSION")));
402
403        if options.trace {
404            builder = builder.header("X-B3-Flags", "1");
405        }
406
407        if options.verbose {
408            let mut err = std::io::stderr().lock();
409            if self.out.use_color {
410                self.out
411                    .verbose(&mut err, &format!("\x1b[1;34m> {method}\x1b[0m {url}"));
412            } else {
413                self.out.verbose(&mut err, &format!("> {method} {url}"));
414            }
415        }
416
417        let resp = builder.send()?;
418
419        if options.verbose {
420            let mut err = std::io::stderr().lock();
421            log_response_headers(&self.out, &mut err, resp.status(), resp.headers());
422        }
423
424        let status = resp.status();
425        let body = resp.text().unwrap_or_default();
426
427        let json: serde_json::Value = if body.is_empty() {
428            serde_json::json!({})
429        } else if let Ok(v) = serde_json::from_str(&body) {
430            v
431        } else {
432            if status.as_u16() >= 400 {
433                return Err(XurlError::api(
434                    status.as_u16(),
435                    format!("HTTP error: {status}"),
436                ));
437            }
438            serde_json::json!({})
439        };
440
441        if status.as_u16() >= 400 {
442            return Err(XurlError::api(status.as_u16(), json.to_string()));
443        }
444
445        Ok(json)
446    }
447
448    /// Sends a multipart request (used for media upload chunks).
449    ///
450    /// # Errors
451    ///
452    /// Returns an error if the HTTP method is invalid, file I/O fails,
453    /// the request fails, or the API returns an error status (>= 400).
454    pub fn send_multipart_request(
455        &mut self,
456        options: &MultipartOptions,
457    ) -> Result<serde_json::Value> {
458        let method = options.request.method.to_uppercase();
459        let method = if method.is_empty() { "POST" } else { &method };
460        // Auth-matrix validation lives inside `get_auth_header` (called
461        // below). `no_auth: true` short-circuits past the call site.
462        let url = self.build_url(&options.request.target)?;
463
464        let req_method = reqwest::Method::from_bytes(method.as_bytes())
465            .map_err(|_| XurlError::InvalidMethod(method.to_string()))?;
466
467        let mut form = multipart::Form::new();
468
469        // Add file from path or data
470        if !options.file_field.is_empty() && !options.file_path.is_empty() {
471            let part = multipart::Part::file(&options.file_path)
472                .map_err(|e| XurlError::Io(format!("error opening file: {e}")))?;
473            form = form.part(options.file_field.clone(), part);
474        } else if !options.file_field.is_empty() && !options.file_data.is_empty() {
475            let part = multipart::Part::bytes(options.file_data.clone())
476                .file_name(options.file_name.clone());
477            form = form.part(options.file_field.clone(), part);
478        }
479
480        // Add form fields
481        for (key, value) in &options.form_fields {
482            form = form.text(key.clone(), value.clone());
483        }
484
485        let mut builder = self.client.request(req_method, &url).multipart(form);
486
487        // Add custom headers
488        for header in &options.request.headers {
489            if let Some((key, value)) = header.split_once(':') {
490                builder = builder.header(key.trim(), value.trim());
491            }
492        }
493
494        // Add auth header (skip if no_auth is set). Propagate auth errors
495        // rather than silently sending the request unauthenticated; see the
496        // matching propagation site in `send_request` for the rationale.
497        if !options.request.no_auth {
498            let auth_header = self.get_auth_header(&options.request)?;
499            builder = builder.header("Authorization", auth_header);
500        }
501
502        builder = builder.header("User-Agent", format!("xurl/{}", env!("CARGO_PKG_VERSION")));
503
504        if options.request.trace {
505            builder = builder.header("X-B3-Flags", "1");
506        }
507
508        if options.request.verbose {
509            let mut err = std::io::stderr().lock();
510            if self.out.use_color {
511                self.out
512                    .verbose(&mut err, &format!("\x1b[1;34m> {method}\x1b[0m {url}"));
513            } else {
514                self.out.verbose(&mut err, &format!("> {method} {url}"));
515            }
516        }
517
518        let resp = builder.send()?;
519        let status = resp.status();
520        let body = resp.text().unwrap_or_default();
521
522        let json: serde_json::Value = if body.is_empty() {
523            serde_json::json!({})
524        } else {
525            serde_json::from_str(&body).unwrap_or(serde_json::json!({}))
526        };
527
528        if status.as_u16() >= 400 {
529            return Err(XurlError::api(status.as_u16(), json.to_string()));
530        }
531
532        Ok(json)
533    }
534
535    /// Sends a streaming request — reads lines until EOF.
536    ///
537    /// All output flows through this client's configured `OutputConfig`
538    /// (set via [`ApiClient::set_output`]); the CLI binary calls the
539    /// `stream_request_with_output` helper in `cli::commands` which threads
540    /// the runner's `OutputConfig` and writers in directly. Library callers
541    /// pass their own `stdout`/`stderr` here so a streaming session can be
542    /// captured in tests or redirected to a custom sink.
543    ///
544    /// # Errors
545    ///
546    /// Returns an error if the HTTP method is invalid, the request fails,
547    /// the API returns an error status (>= 400), or a read error occurs.
548    #[allow(dead_code)] // Public library API — used by consumers and integration tests
549    pub fn stream_request(
550        &mut self,
551        options: &RequestOptions,
552        stdout: &mut dyn std::io::Write,
553        stderr: &mut dyn std::io::Write,
554    ) -> Result<()> {
555        let method = options.method.to_uppercase();
556        let method = if method.is_empty() { "GET" } else { &method };
557        // Auth-matrix validation lives inside `get_auth_header` (called
558        // below). Streaming honours the same fail-fast rule: an explicit
559        // `--auth X` against an endpoint that doesn't accept `X` rejects
560        // via `get_auth_header` before `builder.send()` opens any socket.
561        let url = self.build_url(&options.target)?;
562
563        let req_method = reqwest::Method::from_bytes(method.as_bytes())
564            .map_err(|_| XurlError::InvalidMethod(method.to_string()))?;
565
566        let mut builder = Client::builder()
567            .timeout(None)
568            .build()
569            .unwrap_or_else(|_| Client::new())
570            .request(req_method, &url);
571
572        if !options.data.is_empty() {
573            if serde_json::from_str::<serde_json::Value>(&options.data).is_ok() {
574                builder = builder
575                    .header("Content-Type", "application/json")
576                    .body(options.data.clone());
577            } else {
578                builder = builder
579                    .header("Content-Type", "application/x-www-form-urlencoded")
580                    .body(options.data.clone());
581            }
582        }
583
584        for header in &options.headers {
585            if let Some((key, value)) = header.split_once(':') {
586                builder = builder.header(key.trim(), value.trim());
587            }
588        }
589
590        if !options.no_auth {
591            let auth_header = self.get_auth_header(options)?;
592            builder = builder.header("Authorization", auth_header);
593        }
594
595        builder = builder.header("User-Agent", format!("xurl/{}", env!("CARGO_PKG_VERSION")));
596
597        if options.trace {
598            builder = builder.header("X-B3-Flags", "1");
599        }
600
601        if options.verbose {
602            if self.out.use_color {
603                self.out
604                    .verbose(stderr, &format!("\x1b[1;34m> {method}\x1b[0m {url}"));
605            } else {
606                self.out.verbose(stderr, &format!("> {method} {url}"));
607            }
608        }
609
610        self.out
611            .status(stderr, &format!("Connecting to streaming endpoint: {url}"));
612
613        let resp = builder.send()?;
614
615        if options.verbose {
616            log_response_headers(&self.out, stderr, resp.status(), resp.headers());
617        }
618
619        let resp_status = resp.status();
620        if resp_status.as_u16() >= 400 {
621            let body = resp.text().unwrap_or_default();
622            if let Ok(json) = serde_json::from_str::<serde_json::Value>(&body) {
623                return Err(XurlError::api(resp_status.as_u16(), json.to_string()));
624            }
625            return Err(XurlError::api(resp_status.as_u16(), body));
626        }
627
628        self.out
629            .status(stderr, "--- Streaming response started ---");
630        self.out.status(stderr, "--- Press Ctrl+C to stop ---");
631
632        let reader = BufReader::with_capacity(1024 * 1024, resp);
633        for line in reader.lines() {
634            match line {
635                Ok(line) => {
636                    if line.is_empty() {
637                        continue;
638                    }
639                    self.out.print_stream_line(stdout, &line);
640                }
641                Err(e) => {
642                    return Err(XurlError::Io(e.to_string()));
643                }
644            }
645        }
646
647        self.out.status(stderr, "--- End of stream ---");
648        Ok(())
649    }
650
651    /// Gets the authorization header for a request (public accessor for command layer).
652    ///
653    /// # Errors
654    ///
655    /// Returns an error if no valid auth method is found, or — when the
656    /// auto-detect path resolves an empty intersection between stored
657    /// credentials and the endpoint's accepted schemes — an
658    /// [`XurlError::AuthMethodMismatch`] in the empty-intersection shape.
659    pub fn get_auth_header_public(&mut self, options: &RequestOptions) -> Result<String> {
660        self.get_auth_header(options)
661    }
662
663    /// Gets the authorization header for a request.
664    ///
665    /// When `options.auth_type` is non-empty, dispatches directly to that
666    /// scheme. When empty, runs the endpoint-aware auto-detect: intersects
667    /// the active app's stored credentials with the endpoint's accepted
668    /// auth schemes per the matrix, picks the first match in OAuth2 →
669    /// OAuth1 → Bearer preference order, and falls back to that fixed
670    /// order for [`RequestTarget::RawUrl`] and matrix-miss
671    /// [`RequestTarget::Template`] targets (the permissive surface for
672    /// unknown endpoints).
673    fn get_auth_header(&mut self, options: &RequestOptions) -> Result<String> {
674        let auth_type = &options.auth_type;
675        let method_raw = options.method.to_uppercase();
676        let method = if method_raw.is_empty() {
677            "GET"
678        } else {
679            method_raw.as_str()
680        };
681
682        // One matrix lookup for the entire decision. Both the explicit-auth
683        // validation below and the auto-detect intersection further down
684        // consume this, eliminating the prior duplicate `supported_auth`
685        // call that fired from `auth_matrix::validate` plus a second pass
686        // here.
687        let endpoint_schemes = match &options.target {
688            RequestTarget::Template { path, .. } => {
689                crate::api::auth_matrix::supported_auth(method, path).map(|s| (path.clone(), s))
690            }
691            RequestTarget::RawUrl(_) => None,
692        };
693
694        if !auth_type.is_empty() {
695            // Validate the explicit-auth request against the matrix entry
696            // when present. Matrix-miss is permissive per the unknown-
697            // endpoint rule. Empty wire list (currently unreachable) would
698            // collapse to permissive too — the matrix only emits entries
699            // for endpoints that declare a `security:` list.
700            if let Some((path, schemes)) = &endpoint_schemes {
701                let supported_static = crate::api::auth_matrix::schemes_to_wire_list(schemes);
702                let requested_norm = auth_type.to_ascii_lowercase();
703                if !supported_static.contains(&requested_norm.as_str()) {
704                    let supported: Vec<String> =
705                        supported_static.iter().map(|s| (*s).to_string()).collect();
706                    let rendered_url = render_template_template(&options.target).ok();
707                    let raw_app = self.auth.app_name();
708                    let app_name = if raw_app.is_empty() {
709                        self.auth.token_store.default_app.clone()
710                    } else {
711                        raw_app.to_string()
712                    };
713                    return Err(XurlError::AuthMethodMismatch {
714                        endpoint: path.clone(),
715                        rendered_url,
716                        method: method.to_string(),
717                        requested: Some(requested_norm),
718                        supported,
719                        available_in_app: None,
720                        app: Some(app_name),
721                        other_apps_with_creds: None,
722                    });
723                }
724            }
725            let url = self.build_url(&options.target)?;
726            return match auth_type.to_lowercase().as_str() {
727                "oauth1" => self.auth.get_oauth1_header(method, &url, None),
728                "oauth2" => self.auth.get_oauth2_header(&options.username),
729                "app" => self.auth.get_bearer_token_header(),
730                _ => Err(XurlError::auth(format!("invalid auth type: {auth_type}"))),
731            };
732        }
733
734        // Auto-detect: scope every check to the active app (set by
735        // `--app NAME` via `Auth::with_app_name`) so a `--app NAME`
736        // invocation without `--auth` picks NAME's tokens, not the
737        // default app's. Per-app probes route through `_for_app(app_name)`
738        // accessors on the token store so the active-app contract holds
739        // even when the active app differs from the default.
740        let raw_app = self.auth.app_name();
741        // Empty active app name is the "use default app" convention. Resolve
742        // it to the store's actual default_app name so the envelope's `app`
743        // field carries something the user can act on (e.g. "default" rather
744        // than "").
745        let app_name = if raw_app.is_empty() {
746            self.auth.token_store.default_app.clone()
747        } else {
748            raw_app.to_string()
749        };
750        let available_in_app = self.available_auth_in_app(&app_name);
751
752        // Auto-detect filter: walk the preference order and keep every
753        // scheme the active app has, optionally intersected with the
754        // endpoint's accepted set. The closure expression collapses the
755        // two earlier branches that duplicated the availability filter.
756        let endpoint_supported_static: Option<Vec<&'static str>> = endpoint_schemes
757            .as_ref()
758            .map(|(_, schemes)| crate::api::auth_matrix::schemes_to_wire_list(schemes));
759        let candidate_order: Vec<crate::api::auth_matrix::WireScheme> =
760            crate::api::auth_matrix::WireScheme::ALL_BY_PREFERENCE
761                .into_iter()
762                .filter(|m| {
763                    let wire = m.as_wire();
764                    let in_app = available_in_app.contains(&wire);
765                    let in_endpoint = endpoint_supported_static
766                        .as_ref()
767                        .is_none_or(|sup| sup.contains(&wire));
768                    in_app && in_endpoint
769                })
770                .collect();
771
772        if candidate_order.is_empty() {
773            // Empty intersection (or empty active app entirely). The
774            // matrix-hit branches construct the typed envelope; the
775            // matrix-miss branch falls back to the generic auth error
776            // because no endpoint context is in scope.
777            if let Some((path, _)) = &endpoint_schemes {
778                let rendered_url = render_template_template(&options.target).ok();
779                let endpoint_supported = endpoint_supported_static
780                    .as_ref()
781                    .map(|sup| sup.iter().map(|s| (*s).to_string()).collect::<Vec<_>>())
782                    .unwrap_or_default();
783                if available_in_app.is_empty() {
784                    // Active app holds nothing. Check whether OTHER apps
785                    // in the store hold credentials. If so, surface a
786                    // wrong-app envelope (exit 2) instead of generic
787                    // auth-required (exit 77) — the user logged in, just
788                    // not against the app they invoked.
789                    let other_apps = self.other_apps_with_credentials(&app_name);
790                    if other_apps.is_empty() {
791                        return Err(XurlError::auth(
792                            "NoAuthMethod: no authentication method available",
793                        ));
794                    }
795                    return Err(XurlError::AuthMethodMismatch {
796                        endpoint: path.clone(),
797                        rendered_url,
798                        method: method.to_string(),
799                        requested: None,
800                        supported: endpoint_supported,
801                        available_in_app: Some(Vec::new()),
802                        app: Some(app_name.clone()),
803                        other_apps_with_creds: Some(other_apps),
804                    });
805                }
806                return Err(XurlError::AuthMethodMismatch {
807                    endpoint: path.clone(),
808                    rendered_url,
809                    method: method.to_string(),
810                    requested: None,
811                    supported: endpoint_supported,
812                    available_in_app: Some(
813                        available_in_app.iter().map(|s| (*s).to_string()).collect(),
814                    ),
815                    app: Some(app_name.clone()),
816                    other_apps_with_creds: None,
817                });
818            }
819            return Err(XurlError::auth(
820                "NoAuthMethod: no authentication method available",
821            ));
822        }
823
824        // Pick the first candidate in OAuth2 → OAuth1 → Bearer preference
825        // order. Dispatching on the typed [`WireScheme`] makes adding a
826        // new variant a compile error — the previous `&str`-keyed match
827        // could panic at runtime if the candidate list ever grew without
828        // a matching arm.
829        use crate::api::auth_matrix::WireScheme;
830        match candidate_order[0] {
831            WireScheme::OAuth2 => self.auth.get_oauth2_header(&options.username),
832            WireScheme::OAuth1 => {
833                let url = self.build_url(&options.target)?;
834                self.auth.get_oauth1_header(method, &url, None)
835            }
836            WireScheme::App => self.auth.get_bearer_token_header(),
837        }
838    }
839
840    /// Probes the active app for which auth schemes have stored credentials.
841    ///
842    /// Returned vector lists the wire strings (`"oauth2"`, `"oauth1"`,
843    /// `"app"`) in OAuth2 → OAuth1 → Bearer order. Used by the auto-detect
844    /// intersection in [`Self::get_auth_header`] and by the empty-intersection
845    /// error envelope to populate `available_in_app`.
846    fn available_auth_in_app(&self, app_name: &str) -> Vec<&'static str> {
847        let mut out: Vec<&'static str> = Vec::with_capacity(3);
848        if self
849            .auth
850            .token_store
851            .get_first_oauth2_token_for_app(app_name)
852            .is_some()
853        {
854            out.push("oauth2");
855        }
856        if self
857            .auth
858            .token_store
859            .get_oauth1_tokens_for_app(app_name)
860            .is_some()
861        {
862            out.push("oauth1");
863        }
864        if self
865            .auth
866            .token_store
867            .get_bearer_token_for_app(app_name)
868            .is_some()
869        {
870            out.push("app");
871        }
872        out
873    }
874
875    /// Names of apps OTHER than `active` that hold at least one stored
876    /// credential.
877    ///
878    /// Used by `get_auth_header` to surface a "wrong-app" envelope when the
879    /// active app is empty but the user has credentials elsewhere. Returns
880    /// the apps in stable BTreeMap iteration order so the resulting message
881    /// is deterministic.
882    fn other_apps_with_credentials(&self, active: &str) -> Vec<String> {
883        self.auth
884            .token_store
885            .apps_with_credentials()
886            .into_iter()
887            .filter(|name| name != active)
888            .collect()
889    }
890}
891
892/// Renders the path portion of a [`RequestTarget::Template`] without the
893/// `base_url` prefix or query string.
894///
895/// Used by error-envelope construction so user-facing messages show the
896/// substituted path (`/2/users/12345/likes`) instead of the spec template
897/// (`/2/users/{id}/likes`). Returns `Err` when the target is `RawUrl` (no
898/// substitution applies) or when substitution itself fails — both surface
899/// as `None` at the call site so the envelope falls back to `endpoint`.
900fn render_template_template(target: &RequestTarget) -> Result<String> {
901    match target {
902        RequestTarget::Template {
903            path, path_params, ..
904        } => render_template_path(path, path_params),
905        RequestTarget::RawUrl(_) => Err(XurlError::Internal(
906            "RawUrl target has no template to render".to_string(),
907        )),
908    }
909}
910
911/// Renders a [`RequestTarget`] against `base_url` into a full URL string.
912///
913/// Free function so unit tests can exercise the rendering without
914/// instantiating a full [`ApiClient`].
915fn build_url_for_target(base_url: &str, target: &RequestTarget) -> Result<String> {
916    match target {
917        RequestTarget::Template {
918            path,
919            path_params,
920            query,
921        } => {
922            let rendered_path = render_template_path(path, path_params)?;
923
924            let mut url = base_url.to_string();
925            if !url.ends_with('/') {
926                url.push('/');
927            }
928            if let Some(stripped) = rendered_path.strip_prefix('/') {
929                url.push_str(stripped);
930            } else {
931                url.push_str(&rendered_path);
932            }
933
934            if !query.is_empty() {
935                url.push('?');
936                for (i, (key, value)) in query.iter().enumerate() {
937                    if i > 0 {
938                        url.push('&');
939                    }
940                    write_encoded(&mut url, key);
941                    url.push('=');
942                    write_encoded(&mut url, value);
943                }
944            }
945            Ok(url)
946        }
947        RequestTarget::RawUrl(raw) => {
948            validate_raw_url_scheme(raw)?;
949            Ok(raw.clone())
950        }
951    }
952}
953
954/// Substitutes `{name}` segments in a path template using `path_params`.
955///
956/// Each substituted value is rejected up-front if it contains `/`, `?`,
957/// `#`, or `%` (would break URL semantics on encode/decode), then
958/// percent-encoded against [`URL_VALUE_ENCODE_SET`]. A `{name}` whose
959/// `name` is missing from `path_params` is a programmer error and
960/// surfaces as [`XurlError::Internal`].
961pub(crate) fn render_template_path(
962    template: &str,
963    path_params: &HashMap<String, String>,
964) -> Result<String> {
965    let mut out = String::with_capacity(template.len());
966    let bytes = template.as_bytes();
967    let mut i = 0;
968    while i < bytes.len() {
969        if bytes[i] == b'{' {
970            // Find matching closing brace
971            if let Some(end) = template[i + 1..].find('}') {
972                let name = &template[i + 1..i + 1 + end];
973                let value = path_params.get(name).ok_or_else(|| {
974                    XurlError::Internal(format!(
975                        "path template {template:?} references {{{name}}} but path_params has no such key"
976                    ))
977                })?;
978                if value.contains('/')
979                    || value.contains('?')
980                    || value.contains('#')
981                    || value.contains('%')
982                {
983                    return Err(XurlError::InvalidPathParam {
984                        name: name.to_string(),
985                        value: value.clone(),
986                    });
987                }
988                write_encoded(&mut out, value);
989                i += 1 + end + 1;
990                continue;
991            }
992        }
993        // Push raw byte (template literal char). Safe because template
994        // segments outside braces are ASCII per spec.
995        out.push(char::from(bytes[i]));
996        i += 1;
997    }
998    Ok(out)
999}
1000
1001/// Validates that a raw URL uses the `http` or `https` scheme.
1002///
1003/// Refuses `file://`, `ftp://`, `data:` etc. before any handle to the
1004/// filesystem or external service is created. Case-insensitive match
1005/// on the scheme prefix.
1006fn validate_raw_url_scheme(url: &str) -> Result<()> {
1007    let lower = url.trim_start().to_ascii_lowercase();
1008    if lower.starts_with("https://") || lower.starts_with("http://") {
1009        return Ok(());
1010    }
1011    Err(XurlError::InvalidUrl(format!(
1012        "URL must start with http:// or https://: {url}"
1013    )))
1014}
1015
1016/// Appends a percent-encoded value to `out` using [`URL_VALUE_ENCODE_SET`].
1017fn write_encoded(out: &mut String, value: &str) {
1018    for chunk in utf8_percent_encode(value, URL_VALUE_ENCODE_SET) {
1019        out.push_str(chunk);
1020    }
1021}
1022
1023/// Emits the verbose response-header dump (`< STATUS`, `< key: value`, blank
1024/// line) through the supplied `OutputConfig`. Lives at module scope so
1025/// `send_request`, `send_multipart_request`, and `stream_request` share one
1026/// implementation.
1027fn log_response_headers(
1028    out: &OutputConfig,
1029    err: &mut dyn std::io::Write,
1030    status: reqwest::StatusCode,
1031    headers: &reqwest::header::HeaderMap,
1032) {
1033    if out.use_color {
1034        out.verbose(err, &format!("\x1b[1;31m< {status}\x1b[0m"));
1035        for (key, value) in headers {
1036            out.verbose(
1037                err,
1038                &format!("\x1b[1;32m< {key}\x1b[0m: {}", value.to_str().unwrap_or("")),
1039            );
1040        }
1041    } else {
1042        out.verbose(err, &format!("< {status}"));
1043        for (key, value) in headers {
1044            out.verbose(err, &format!("< {key}: {}", value.to_str().unwrap_or("")));
1045        }
1046    }
1047    out.verbose(err, "");
1048}
1049
1050#[cfg(test)]
1051mod tests {
1052    use super::*;
1053
1054    #[test]
1055    fn call_options_to_request_options_maps_all_fields() {
1056        let opts = CallOptions {
1057            auth_type: "oauth2".to_string(),
1058            username: "testuser".to_string(),
1059            no_auth: true,
1060            verbose: true,
1061            trace: true,
1062            timeout_secs: 45,
1063            pagination_token: "abc123".to_string(),
1064        };
1065
1066        let req = opts.to_request_options();
1067
1068        assert_eq!(req.auth_type, "oauth2");
1069        assert_eq!(req.username, "testuser");
1070        assert!(req.no_auth);
1071        assert!(req.verbose);
1072        assert!(req.trace);
1073        assert_eq!(req.pagination_token, "abc123");
1074        // Request-specific fields should be at defaults
1075        assert!(req.method.is_empty());
1076        match &req.target {
1077            RequestTarget::Template {
1078                path,
1079                path_params,
1080                query,
1081            } => {
1082                assert!(path.is_empty());
1083                assert!(path_params.is_empty());
1084                assert!(query.is_empty());
1085            }
1086            RequestTarget::RawUrl(_) => panic!("default target must be Template"),
1087        }
1088        assert!(req.data.is_empty());
1089        assert!(req.headers.is_empty());
1090    }
1091
1092    #[test]
1093    fn call_options_default_has_safe_values() {
1094        let opts = CallOptions::default();
1095        let req = opts.to_request_options();
1096
1097        assert!(!req.no_auth, "no_auth should default to false");
1098        assert!(!req.verbose);
1099        assert!(!req.trace);
1100        assert!(req.auth_type.is_empty());
1101        assert!(req.username.is_empty());
1102        assert!(
1103            opts.pagination_token.is_empty(),
1104            "pagination_token should default to empty so non-paginated endpoints stay clean"
1105        );
1106        assert_eq!(
1107            opts.timeout_secs, DEFAULT_TIMEOUT_SECS,
1108            "timeout_secs should default to {DEFAULT_TIMEOUT_SECS}"
1109        );
1110    }
1111
1112    // ── build_url tests ────────────────────────────────────────────────
1113
1114    const TEST_BASE_URL: &str = "https://api.x.com";
1115
1116    fn tmpl(path: &str) -> RequestTarget {
1117        RequestTarget::Template {
1118            path: path.to_string(),
1119            path_params: HashMap::new(),
1120            query: Vec::new(),
1121        }
1122    }
1123
1124    #[test]
1125    fn build_url_template_empty_params_and_query() {
1126        let url = build_url_for_target(TEST_BASE_URL, &tmpl("/2/users/me")).unwrap();
1127        assert_eq!(url, "https://api.x.com/2/users/me");
1128    }
1129
1130    #[test]
1131    fn build_url_template_substitutes_path_param() {
1132        let mut params = HashMap::new();
1133        params.insert("id".to_string(), "12345".to_string());
1134        let target = RequestTarget::Template {
1135            path: "/2/users/{id}/likes".to_string(),
1136            path_params: params,
1137            query: Vec::new(),
1138        };
1139        let url = build_url_for_target(TEST_BASE_URL, &target).unwrap();
1140        assert_eq!(url, "https://api.x.com/2/users/12345/likes");
1141    }
1142
1143    #[test]
1144    fn build_url_template_query_preserves_insertion_order() {
1145        let target = RequestTarget::Template {
1146            path: "/2/tweets/search/recent".to_string(),
1147            path_params: HashMap::new(),
1148            query: vec![
1149                ("query".to_string(), "rustlang".to_string()),
1150                ("max_results".to_string(), "10".to_string()),
1151            ],
1152        };
1153        let url = build_url_for_target(TEST_BASE_URL, &target).unwrap();
1154        assert_eq!(
1155            url,
1156            "https://api.x.com/2/tweets/search/recent?query=rustlang&max_results=10"
1157        );
1158    }
1159
1160    #[test]
1161    fn build_url_template_percent_encodes_value_with_spaces() {
1162        let target = RequestTarget::Template {
1163            path: "/2/tweets/search/recent".to_string(),
1164            path_params: HashMap::new(),
1165            query: vec![("query".to_string(), "hello world".to_string())],
1166        };
1167        let url = build_url_for_target(TEST_BASE_URL, &target).unwrap();
1168        assert_eq!(
1169            url,
1170            "https://api.x.com/2/tweets/search/recent?query=hello%20world"
1171        );
1172    }
1173
1174    #[test]
1175    fn build_url_template_rejects_path_param_with_slash() {
1176        let mut params = HashMap::new();
1177        params.insert("id".to_string(), "abc/etc/passwd".to_string());
1178        let target = RequestTarget::Template {
1179            path: "/2/users/{id}/likes".to_string(),
1180            path_params: params,
1181            query: Vec::new(),
1182        };
1183        let err = build_url_for_target(TEST_BASE_URL, &target).unwrap_err();
1184        match err {
1185            XurlError::InvalidPathParam { name, value } => {
1186                assert_eq!(name, "id");
1187                assert_eq!(value, "abc/etc/passwd");
1188            }
1189            other => panic!("expected InvalidPathParam, got {other:?}"),
1190        }
1191    }
1192
1193    #[test]
1194    fn build_url_template_rejects_path_param_with_hash() {
1195        let mut params = HashMap::new();
1196        params.insert("id".to_string(), "abc#fragment".to_string());
1197        let target = RequestTarget::Template {
1198            path: "/2/users/{id}/likes".to_string(),
1199            path_params: params,
1200            query: Vec::new(),
1201        };
1202        let err = build_url_for_target(TEST_BASE_URL, &target).unwrap_err();
1203        match err {
1204            XurlError::InvalidPathParam { name, value } => {
1205                assert_eq!(name, "id");
1206                assert_eq!(value, "abc#fragment");
1207            }
1208            other => panic!("expected InvalidPathParam, got {other:?}"),
1209        }
1210    }
1211
1212    #[test]
1213    fn build_url_template_rejects_path_param_with_percent() {
1214        let mut params = HashMap::new();
1215        params.insert("id".to_string(), "already%20encoded".to_string());
1216        let target = RequestTarget::Template {
1217            path: "/2/users/{id}/likes".to_string(),
1218            path_params: params,
1219            query: Vec::new(),
1220        };
1221        let err = build_url_for_target(TEST_BASE_URL, &target).unwrap_err();
1222        match err {
1223            XurlError::InvalidPathParam { name, value } => {
1224                assert_eq!(name, "id");
1225                assert_eq!(value, "already%20encoded");
1226            }
1227            other => panic!("expected InvalidPathParam, got {other:?}"),
1228        }
1229    }
1230
1231    #[test]
1232    fn build_url_template_rejects_path_param_with_question_mark() {
1233        let mut params = HashMap::new();
1234        params.insert("id".to_string(), "abc?injected".to_string());
1235        let target = RequestTarget::Template {
1236            path: "/2/users/{id}".to_string(),
1237            path_params: params,
1238            query: Vec::new(),
1239        };
1240        let err = build_url_for_target(TEST_BASE_URL, &target).unwrap_err();
1241        assert!(matches!(err, XurlError::InvalidPathParam { .. }));
1242    }
1243
1244    #[test]
1245    fn build_url_template_missing_path_param_is_internal_error() {
1246        let target = RequestTarget::Template {
1247            path: "/2/users/{id}/likes".to_string(),
1248            path_params: HashMap::new(),
1249            query: Vec::new(),
1250        };
1251        let err = build_url_for_target(TEST_BASE_URL, &target).unwrap_err();
1252        assert!(matches!(err, XurlError::Internal(_)), "got {err:?}");
1253    }
1254
1255    #[test]
1256    fn build_url_raw_url_https_returns_clone() {
1257        let target = RequestTarget::RawUrl("https://api.x.com/2/raw".to_string());
1258        let url = build_url_for_target(TEST_BASE_URL, &target).unwrap();
1259        assert_eq!(url, "https://api.x.com/2/raw");
1260    }
1261
1262    #[test]
1263    fn build_url_raw_url_http_returns_clone() {
1264        let target = RequestTarget::RawUrl("http://localhost:8080/dev".to_string());
1265        let url = build_url_for_target(TEST_BASE_URL, &target).unwrap();
1266        assert_eq!(url, "http://localhost:8080/dev");
1267    }
1268
1269    #[test]
1270    fn build_url_raw_url_file_scheme_rejected() {
1271        let target = RequestTarget::RawUrl("file:///etc/passwd".to_string());
1272        let err = build_url_for_target(TEST_BASE_URL, &target).unwrap_err();
1273        assert!(matches!(err, XurlError::InvalidUrl(_)), "got {err:?}");
1274    }
1275
1276    #[test]
1277    fn build_url_raw_url_ftp_scheme_rejected() {
1278        let target = RequestTarget::RawUrl("ftp://attacker.com/payload".to_string());
1279        let err = build_url_for_target(TEST_BASE_URL, &target).unwrap_err();
1280        assert!(matches!(err, XurlError::InvalidUrl(_)), "got {err:?}");
1281    }
1282}