Skip to main content

curl_rest/
lib.rs

1//! A small, blocking REST client built on libcurl.
2//!
3//! The API is a builder centered around `Client`, with GET as the default method.
4//! Use `send` as the terminal operation.
5//!
6//! # libcurl dependency
7//! `curl-rest` links to libcurl, so your build needs a libcurl development
8//! package available on the system (for example, installed via your OS package
9//! manager). If you prefer a vendored build or static linking, enable the
10//! appropriate `curl`/`curl-sys` features in your application so Cargo
11//! propagates them to this crate.
12//!
13//! This crate exposes a few convenience features (default is `ssl`):
14//! - `ssl`: enable OpenSSL-backed TLS (libcurl's default).
15//! - `rustls`: enable Rustls-backed TLS (disable default features in your
16//!   dependency to avoid OpenSSL).
17//! - `static-curl`: build and link against a bundled libcurl.
18//! - `static-ssl`: build and link against a bundled OpenSSL.
19//! - `vendored`: enables both `static-curl` and `static-ssl`.
20//!
21//! # Quickstart
22//! ```no_run
23//! let resp = curl_rest::Client::default()
24//!     .post()
25//!     .body_json(r#"{"name":"stanley"}"#)
26//!     .send("https://example.com/users")?;
27//! println!("{}", String::from_utf8_lossy(&resp.body));
28//! # Ok::<(), curl_rest::Error>(())
29//! ```
30//!
31//! # Examples
32//! ```no_run
33//! let resp = curl_rest::Client::default()
34//!     .get()
35//!     .header(curl_rest::Header::Accept("application/json".into()))
36//!     .header(curl_rest::Header::Custom("X-Request-Id".into(), "req-123".into()))
37//!     .query_param_kv("page", "1")
38//!     .send("https://example.com/api/users")
39//!     .expect("request failed");
40//! println!("Status: {}", resp.status);
41//! for header in &resp.headers {
42//!     println!("{}: {}", header.name, header.value);
43//! }
44//! ```
45
46use curl::easy::{Easy2, Handler, List, WriteError};
47use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode};
48use std::{
49    borrow::Cow,
50    fmt::Display,
51    io::{Cursor, Read, Write},
52};
53use thiserror::Error;
54use url::Url;
55
56/// HTTP response container returned by `send`.
57#[derive(Debug, Clone, Default)]
58pub struct Response {
59    /// Status code returned by the server.
60    pub status: StatusCode,
61    /// Response headers in received order (including duplicates).
62    pub headers: Vec<ResponseHeader>,
63    /// Raw response body bytes.
64    pub body: Vec<u8>,
65}
66
67/// A single HTTP response header entry.
68#[derive(Debug, Clone, PartialEq, Eq)]
69pub struct ResponseHeader {
70    /// Header name as received.
71    pub name: String,
72    /// Header value as received (trimmed).
73    pub value: String,
74}
75
76macro_rules! status_codes {
77    ($(
78        $variant:ident => ($code:literal, $reason:literal, $const_name:ident)
79    ),+ $(,)?) => {
80        /// HTTP status codes defined by RFC 9110 and related specifications.
81        #[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
82        #[repr(u16)]
83        pub enum StatusCode {
84            $(
85                #[doc = $reason]
86                $variant = $code,
87            )+
88        }
89
90        impl StatusCode {
91            /// Returns the numeric status code.
92            pub const fn as_u16(self) -> u16 {
93                self as u16
94            }
95
96            /// Returns the canonical reason phrase for this status code.
97            pub const fn canonical_reason(self) -> &'static str {
98                match self {
99                    $(StatusCode::$variant => $reason,)+
100                }
101            }
102
103            /// Converts a numeric status code into a `StatusCode` if known.
104            pub const fn from_u16(code: u16) -> Option<Self> {
105                match code {
106                    $($code => Some(StatusCode::$variant),)+
107                    _ => None,
108                }
109            }
110
111            $(
112                /// Alias matching reqwest's naming style.
113                pub const $const_name: StatusCode = StatusCode::$variant;
114            )+
115        }
116
117        impl Display for StatusCode {
118            fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
119                write!(f, "{} {}", self.as_u16(), self.canonical_reason())
120            }
121        }
122
123        impl Default for StatusCode {
124            fn default() -> Self {
125                StatusCode::Ok
126            }
127        }
128    };
129}
130
131status_codes! {
132    Continue => (100, "Continue", CONTINUE),
133    SwitchingProtocols => (101, "Switching Protocols", SWITCHING_PROTOCOLS),
134    Processing => (102, "Processing", PROCESSING),
135    EarlyHints => (103, "Early Hints", EARLY_HINTS),
136    Ok => (200, "OK", OK),
137    Created => (201, "Created", CREATED),
138    Accepted => (202, "Accepted", ACCEPTED),
139    NonAuthoritativeInformation => (203, "Non-Authoritative Information", NON_AUTHORITATIVE_INFORMATION),
140    NoContent => (204, "No Content", NO_CONTENT),
141    ResetContent => (205, "Reset Content", RESET_CONTENT),
142    PartialContent => (206, "Partial Content", PARTIAL_CONTENT),
143    MultiStatus => (207, "Multi-Status", MULTI_STATUS),
144    AlreadyReported => (208, "Already Reported", ALREADY_REPORTED),
145    ImUsed => (226, "IM Used", IM_USED),
146    MultipleChoices => (300, "Multiple Choices", MULTIPLE_CHOICES),
147    MovedPermanently => (301, "Moved Permanently", MOVED_PERMANENTLY),
148    Found => (302, "Found", FOUND),
149    SeeOther => (303, "See Other", SEE_OTHER),
150    NotModified => (304, "Not Modified", NOT_MODIFIED),
151    UseProxy => (305, "Use Proxy", USE_PROXY),
152    TemporaryRedirect => (307, "Temporary Redirect", TEMPORARY_REDIRECT),
153    PermanentRedirect => (308, "Permanent Redirect", PERMANENT_REDIRECT),
154    BadRequest => (400, "Bad Request", BAD_REQUEST),
155    Unauthorized => (401, "Unauthorized", UNAUTHORIZED),
156    PaymentRequired => (402, "Payment Required", PAYMENT_REQUIRED),
157    Forbidden => (403, "Forbidden", FORBIDDEN),
158    NotFound => (404, "Not Found", NOT_FOUND),
159    MethodNotAllowed => (405, "Method Not Allowed", METHOD_NOT_ALLOWED),
160    NotAcceptable => (406, "Not Acceptable", NOT_ACCEPTABLE),
161    ProxyAuthenticationRequired => (407, "Proxy Authentication Required", PROXY_AUTHENTICATION_REQUIRED),
162    RequestTimeout => (408, "Request Timeout", REQUEST_TIMEOUT),
163    Conflict => (409, "Conflict", CONFLICT),
164    Gone => (410, "Gone", GONE),
165    LengthRequired => (411, "Length Required", LENGTH_REQUIRED),
166    PreconditionFailed => (412, "Precondition Failed", PRECONDITION_FAILED),
167    PayloadTooLarge => (413, "Content Too Large", PAYLOAD_TOO_LARGE),
168    UriTooLong => (414, "URI Too Long", URI_TOO_LONG),
169    UnsupportedMediaType => (415, "Unsupported Media Type", UNSUPPORTED_MEDIA_TYPE),
170    RangeNotSatisfiable => (416, "Range Not Satisfiable", RANGE_NOT_SATISFIABLE),
171    ExpectationFailed => (417, "Expectation Failed", EXPECTATION_FAILED),
172    ImATeapot => (418, "I'm a teapot", IM_A_TEAPOT),
173    MisdirectedRequest => (421, "Misdirected Request", MISDIRECTED_REQUEST),
174    UnprocessableEntity => (422, "Unprocessable Content", UNPROCESSABLE_ENTITY),
175    Locked => (423, "Locked", LOCKED),
176    FailedDependency => (424, "Failed Dependency", FAILED_DEPENDENCY),
177    TooEarly => (425, "Too Early", TOO_EARLY),
178    UpgradeRequired => (426, "Upgrade Required", UPGRADE_REQUIRED),
179    PreconditionRequired => (428, "Precondition Required", PRECONDITION_REQUIRED),
180    TooManyRequests => (429, "Too Many Requests", TOO_MANY_REQUESTS),
181    RequestHeaderFieldsTooLarge => (431, "Request Header Fields Too Large", REQUEST_HEADER_FIELDS_TOO_LARGE),
182    UnavailableForLegalReasons => (451, "Unavailable For Legal Reasons", UNAVAILABLE_FOR_LEGAL_REASONS),
183    InternalServerError => (500, "Internal Server Error", INTERNAL_SERVER_ERROR),
184    NotImplemented => (501, "Not Implemented", NOT_IMPLEMENTED),
185    BadGateway => (502, "Bad Gateway", BAD_GATEWAY),
186    ServiceUnavailable => (503, "Service Unavailable", SERVICE_UNAVAILABLE),
187    GatewayTimeout => (504, "Gateway Timeout", GATEWAY_TIMEOUT),
188    HttpVersionNotSupported => (505, "HTTP Version Not Supported", HTTP_VERSION_NOT_SUPPORTED),
189    VariantAlsoNegotiates => (506, "Variant Also Negotiates", VARIANT_ALSO_NEGOTIATES),
190    InsufficientStorage => (507, "Insufficient Storage", INSUFFICIENT_STORAGE),
191    LoopDetected => (508, "Loop Detected", LOOP_DETECTED),
192    NotExtended => (510, "Not Extended", NOT_EXTENDED),
193    NetworkAuthenticationRequired => (511, "Network Authentication Required", NETWORK_AUTHENTICATION_REQUIRED),
194}
195
196/// Error type returned by the curl-rest client.
197#[derive(Debug, Error)]
198pub enum Error {
199    /// Error reported by libcurl.
200    #[error("curl error: {0}")]
201    Client(#[from] curl::Error),
202    /// The provided URL could not be parsed.
203    #[error("invalid url: {0}")]
204    InvalidUrl(String),
205    /// The provided header value contained invalid characters.
206    #[error("invalid header value for {0}")]
207    InvalidHeaderValue(String),
208    /// The provided header name contained invalid characters.
209    #[error("invalid header name: {0}")]
210    InvalidHeaderName(String),
211    /// The server returned an unrecognized HTTP status code.
212    #[error("invalid HTTP status code: {0}")]
213    InvalidStatusCode(u32),
214    /// There was an error during brotli decompression
215    #[error("brotli decompression failed: {0}")]
216    BrotliDecompression(#[from] std::io::Error),
217}
218
219/// Common HTTP headers supported by the client, plus `Custom` for non-standard names.
220#[derive(Debug, Clone, PartialEq)]
221pub enum Header<'a> {
222    /// Authorization header, e.g. "Bearer &lt;token&gt;".
223    Authorization(Cow<'a, str>),
224    /// Accept header describing accepted response types.
225    Accept(Cow<'a, str>),
226    /// Content-Type header describing request body type.
227    ContentType(Cow<'a, str>),
228    /// User-Agent header string.
229    UserAgent(Cow<'a, str>),
230    /// Accept-Encoding header for compression preferences.
231    ///
232    /// Common values include `gzip`, `br`, or `deflate`.
233    AcceptEncoding(Cow<'a, str>),
234    /// Accept-Language header for locale preferences.
235    AcceptLanguage(Cow<'a, str>),
236    /// Cache-Control header directives.
237    CacheControl(Cow<'a, str>),
238    /// Referer header.
239    Referer(Cow<'a, str>),
240    /// Origin header.
241    Origin(Cow<'a, str>),
242    /// Host header.
243    Host(Cow<'a, str>),
244    /// Custom header for non-standard names like "X-Request-Id".
245    ///
246    /// Header names must be valid RFC 9110 `token` values (tchar only).
247    Custom(Cow<'a, str>, Cow<'a, str>),
248}
249
250/// Query parameter represented as a key-value pair.
251#[derive(Clone)]
252pub struct QueryParam<'a> {
253    key: Cow<'a, str>,
254    value: Cow<'a, str>,
255}
256
257/// Supported HTTP methods.
258#[derive(Debug, Default, Clone)]
259pub enum Method {
260    /// HTTP GET.
261    #[default]
262    Get,
263    /// HTTP POST.
264    Post,
265    /// HTTP PUT.
266    Put,
267    /// HTTP DELETE.
268    Delete,
269    /// HTTP HEAD.
270    Head,
271    /// HTTP OPTIONS.
272    Options,
273    /// HTTP PATCH.
274    Patch,
275    /// HTTP CONNECT.
276    Connect,
277    /// HTTP TRACE.
278    Trace,
279}
280
281struct Collector {
282    body: Vec<u8>,
283    headers: Vec<ResponseHeader>,
284    position: usize,
285}
286
287impl Read for Collector {
288    fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
289        if self.position > self.body.len() {
290            return Ok(0);
291        }
292
293        let remaining = &self.body[self.position..];
294        let to_read = remaining.len().min(buf.len());
295
296        buf[..to_read].copy_from_slice(&remaining[..to_read]);
297        self.position += to_read;
298
299        Ok(to_read)
300    }
301}
302
303impl Collector {
304    fn new() -> Self {
305        Self {
306            body: Vec::new(),
307            headers: Vec::new(),
308            position: Default::default(),
309        }
310    }
311}
312
313impl Handler for Collector {
314    fn write(&mut self, data: &[u8]) -> Result<usize, WriteError> {
315        self.body.extend_from_slice(data);
316        Ok(data.len())
317    }
318
319    fn header(&mut self, data: &[u8]) -> bool {
320        if data.is_empty() {
321            return true;
322        }
323        let Ok(line) = std::str::from_utf8(data) else {
324            return true;
325        };
326        let line = line.trim_end_matches(['\r', '\n']);
327        if line.is_empty() {
328            return true;
329        }
330        if line.starts_with("HTTP/") {
331            return true;
332        }
333        if line.starts_with(' ') || line.starts_with('\t') {
334            if let Some(last) = self.headers.last_mut() {
335                let trimmed = line.trim();
336                if !trimmed.is_empty() {
337                    if !last.value.is_empty() {
338                        last.value.push(' ');
339                    }
340                    last.value.push_str(trimmed);
341                }
342            }
343            return true;
344        }
345        if let Some((name, value)) = line.split_once(':') {
346            let name = name.trim();
347            let value = value.trim();
348            if !name.is_empty() {
349                self.headers.push(ResponseHeader {
350                    name: name.to_string(),
351                    value: value.to_string(),
352                });
353            }
354        }
355        true
356    }
357}
358
359/// Builder for constructing and sending a blocking HTTP request.
360///
361/// Defaults to GET when created via `Default`.
362pub struct Client<'a> {
363    method: Method,
364    headers: Vec<Header<'a>>,
365    query: Vec<QueryParam<'a>>,
366    body: Option<Body<'a>>,
367    default_user_agent: Option<Cow<'a, str>>,
368    max_redirects: i8,
369    brotli: bool,
370}
371
372#[deprecated(note = "Renamed to Client; use Client instead.")]
373pub type Curl<'a> = Client<'a>;
374
375impl<'a> Default for Client<'a> {
376    fn default() -> Self {
377        Self {
378            method: Method::Get,
379            headers: Vec::new(),
380            query: Vec::new(),
381            body: None,
382            default_user_agent: None,
383            max_redirects: 1,
384            brotli: false,
385        }
386    }
387}
388
389impl<'a> Client<'a> {
390    /// Creates a new builder with default settings (GET, no headers, no query).
391    pub fn new() -> Self {
392        Self::default()
393    }
394
395    /// Creates a new builder with a default User-Agent header.
396    ///
397    /// The User-Agent is only applied if the request does not already set one.
398    pub fn with_user_agent(agent: impl Into<Cow<'a, str>>) -> Self {
399        Self {
400            default_user_agent: Some(agent.into()),
401            ..Self::default()
402        }
403    }
404
405    /// Sets the number of redirects to follow.
406    ///
407    /// Setting -1 means unlimited responses.
408    ///
409    /// # Examples
410    /// ```no_run
411    /// let resp = curl_rest::Client::default()
412    ///     .get()
413    ///     .max_redirects(-1)
414    ///     .send("https://example.com/private")?;
415    /// # Ok::<(), curl_rest::Error>(())
416    /// ```
417    ///
418    /// # Errors
419    /// This method does not return errors. Header validation happens in `send`.
420    pub fn max_redirects(mut self, max: i8) -> Self {
421        self.max_redirects = max;
422        self
423    }
424
425    /// Sets brotli on or off.
426    /// This setting interferes with other compression algorithms like `gzip`.
427    /// To use those, leave this as false.
428    ///
429    /// This has to be set to true to disable automatic decompression because libcurl
430    /// does not support brotli.
431    pub fn brotli(mut self, is_enabled: bool) -> Self {
432        self.brotli = is_enabled;
433
434        self
435    }
436
437    /// Sets the HTTP method explicitly.
438    pub fn method(mut self, method: Method) -> Self {
439        self.method = method;
440        self
441    }
442
443    /// Sets the request method to GET.
444    pub fn get(self) -> Self {
445        self.method(Method::Get)
446    }
447
448    /// Sets the request method to POST.
449    pub fn post(self) -> Self {
450        self.method(Method::Post)
451    }
452
453    /// Sets the request method to PUT.
454    pub fn put(self) -> Self {
455        self.method(Method::Put)
456    }
457
458    /// Sets the request method to DELETE.
459    pub fn delete(self) -> Self {
460        self.method(Method::Delete)
461    }
462
463    /// Sets the request method to HEAD.
464    pub fn head(self) -> Self {
465        self.method(Method::Head)
466    }
467
468    /// Sets the request method to OPTIONS.
469    pub fn options(self) -> Self {
470        self.method(Method::Options)
471    }
472
473    /// Sets the request method to PATCH.
474    pub fn patch(self) -> Self {
475        self.method(Method::Patch)
476    }
477
478    /// Sets the request method to CONNECT.
479    pub fn connect(self) -> Self {
480        self.method(Method::Connect)
481    }
482
483    /// Sets the request method to TRACE.
484    pub fn trace(self) -> Self {
485        self.method(Method::Trace)
486    }
487
488    /// Adds a single header.
489    ///
490    /// # Examples
491    /// ```no_run
492    /// let resp = curl_rest::Client::default()
493    ///     .get()
494    ///     .header(curl_rest::Header::Authorization("Bearer token".into()))
495    ///     .send("https://example.com/private")?;
496    /// # Ok::<(), curl_rest::Error>(())
497    /// ```
498    ///
499    /// # Errors
500    /// This method does not return errors. Header validation happens in `send`.
501    pub fn header(mut self, header: Header<'a>) -> Self {
502        self.headers.push(header);
503        self
504    }
505
506    /// Adds multiple headers.
507    ///
508    /// # Examples
509    /// ```no_run
510    /// let resp = curl_rest::Client::default()
511    ///     .get()
512    ///     .headers([
513    ///         curl_rest::Header::Accept("application/json".into()),
514    ///         curl_rest::Header::UserAgent("curl-rest/0.1".into()),
515    ///     ])
516    ///     .send("https://example.com/users")?;
517    /// # Ok::<(), curl_rest::Error>(())
518    /// ```
519    ///
520    /// # Errors
521    /// This method does not return errors. Header validation happens in `send`.
522    pub fn headers<I>(mut self, headers: I) -> Self
523    where
524        I: IntoIterator<Item = Header<'a>>,
525    {
526        self.headers.extend(headers);
527        self
528    }
529
530    /// Adds a single query parameter.
531    ///
532    /// # Examples
533    /// ```no_run
534    /// let resp = curl_rest::Client::default()
535    ///     .get()
536    ///     .query_param(curl_rest::QueryParam::new("q", "rust"))
537    ///     .send("https://example.com/search")?;
538    /// # Ok::<(), curl_rest::Error>(())
539    /// ```
540    ///
541    /// # Errors
542    /// This method does not return errors. URL validation happens in `send`.
543    pub fn query_param(mut self, param: QueryParam<'a>) -> Self {
544        self.query.push(param);
545        self
546    }
547
548    /// Adds a single query parameter by key/value.
549    ///
550    /// # Examples
551    /// ```no_run
552    /// let resp = curl_rest::Client::default()
553    ///     .get()
554    ///     .query_param_kv("page", "1")
555    ///     .send("https://example.com/search")?;
556    /// # Ok::<(), curl_rest::Error>(())
557    /// ```
558    ///
559    /// # Errors
560    /// This method does not return errors. URL validation happens in `send`.
561    pub fn query_param_kv(
562        self,
563        key: impl Into<Cow<'a, str>>,
564        value: impl Into<Cow<'a, str>>,
565    ) -> Self {
566        self.query_param(QueryParam::new(key, value))
567    }
568
569    /// Adds multiple query parameters.
570    ///
571    /// # Examples
572    /// ```no_run
573    /// let resp = curl_rest::Client::default()
574    ///     .get()
575    ///     .query_params([
576    ///         curl_rest::QueryParam::new("sort", "desc"),
577    ///         curl_rest::QueryParam::new("limit", "50"),
578    ///     ])
579    ///     .send("https://example.com/items")?;
580    /// # Ok::<(), curl_rest::Error>(())
581    /// ```
582    ///
583    /// # Errors
584    /// This method does not return errors. URL validation happens in `send`.
585    pub fn query_params<I>(mut self, params: I) -> Self
586    where
587        I: IntoIterator<Item = QueryParam<'a>>,
588    {
589        self.query.extend(params);
590        self
591    }
592
593    /// Sets a request body explicitly.
594    ///
595    /// # Examples
596    /// ```no_run
597    /// let resp = curl_rest::Client::default()
598    ///     .post()
599    ///     .body(curl_rest::Body::Text("hello".into()))
600    ///     .send("https://example.com/echo")?;
601    /// # Ok::<(), curl_rest::Error>(())
602    /// ```
603    ///
604    /// # Errors
605    /// This method does not return errors. Failures are reported by `send`.
606    pub fn body(mut self, body: Body<'a>) -> Self {
607        self.body = Some(body);
608        self
609    }
610
611    /// Sets a raw byte body.
612    ///
613    /// # Examples
614    /// ```no_run
615    /// let resp = curl_rest::Client::default()
616    ///     .post()
617    ///     .body_bytes(vec![1, 2, 3])
618    ///     .send("https://example.com/bytes")?;
619    /// # Ok::<(), curl_rest::Error>(())
620    /// ```
621    ///
622    /// # Errors
623    /// This method does not return errors. Failures are reported by `send`.
624    pub fn body_bytes(self, bytes: impl Into<Cow<'a, [u8]>>) -> Self {
625        self.body(Body::Bytes(bytes.into()))
626    }
627
628    /// Sets a text body with a `text/plain; charset=utf-8` default content type.
629    ///
630    /// # Examples
631    /// ```no_run
632    /// let resp = curl_rest::Client::default()
633    ///     .post()
634    ///     .body_text("hello")
635    ///     .send("https://example.com/echo")?;
636    /// # Ok::<(), curl_rest::Error>(())
637    /// ```
638    ///
639    /// # Errors
640    /// This method does not return errors. Failures are reported by `send`.
641    pub fn body_text(self, text: impl Into<Cow<'a, str>>) -> Self {
642        self.body(Body::Text(text.into()))
643    }
644
645    /// Sets a JSON body with an `application/json` default content type.
646    ///
647    /// # Examples
648    /// ```no_run
649    /// let resp = curl_rest::Client::default()
650    ///     .post()
651    ///     .body_json(r#"{"name":"stanley"}"#)
652    ///     .send("https://example.com/users")?;
653    /// # Ok::<(), curl_rest::Error>(())
654    /// ```
655    ///
656    /// # Errors
657    /// This method does not return errors. Failures are reported by `send`.
658    pub fn body_json(self, json: impl Into<Cow<'a, str>>) -> Self {
659        self.body(Body::Json(json.into()))
660    }
661
662    /// Sends the request to the provided URL.
663    ///
664    /// # Errors
665    /// Returns an error if the URL is invalid, a header name or value is malformed, the
666    /// status code is unrecognized, or libcurl reports a failure.
667    pub fn send(self, url: &str) -> Result<Response, Error> {
668        let mut easy = Easy2::new(Collector::new());
669        self.method.apply(&mut easy)?;
670        if self.max_redirects >= 0 {
671            easy.follow_location(true)?;
672            easy.max_redirections(self.max_redirects as u32)?;
673        }
674
675        if !self.brotli {
676            easy.accept_encoding("gzip")?;
677        }
678
679        let mut list = List::new();
680        let mut has_headers = false;
681
682        if self.brotli && !self.has_accept_encoding_header() {
683            list.append("Accept-Encoding: br")?;
684            has_headers = true;
685        }
686
687        for header in &self.headers {
688            list.append(&header.to_line()?)?;
689            has_headers = true;
690        }
691
692        if let Some(default_user_agent) = &self.default_user_agent {
693            if !self.has_user_agent_header() {
694                list.append(&format!("User-Agent: {default_user_agent}"))?;
695                has_headers = true;
696            }
697        }
698
699        if let Some(content_type) = self.body_content_type() {
700            if !self.has_content_type_header() {
701                list.append(&format!("Content-Type: {content_type}"))?;
702                has_headers = true;
703            }
704        }
705
706        if has_headers {
707            easy.http_headers(list)?;
708        }
709
710        if let Some(body) = &self.body {
711            easy.post_fields_copy(body.bytes())?;
712        }
713
714        let url = add_query_params(url, &self.query);
715        validate_url(url.as_ref())?;
716        easy.url(url.as_ref())?;
717        easy.perform()?;
718
719        let status_code = easy.response_code()?;
720        let status_u16 =
721            u16::try_from(status_code).map_err(|_| Error::InvalidStatusCode(status_code))?;
722        let status =
723            StatusCode::from_u16(status_u16).ok_or(Error::InvalidStatusCode(status_code))?;
724        let response_body = easy.get_ref().body.clone();
725        let headers = easy.get_ref().headers.clone();
726
727        if headers.iter().any(|header| {
728            header.name.eq_ignore_ascii_case("Content-Encoding")
729                && header.value.eq_ignore_ascii_case("br")
730        }) {
731            let mut writable_body = Cursor::new(response_body.to_vec());
732            let mut decompressed = Vec::new();
733
734            brotli_decompressor::BrotliDecompress(&mut writable_body, &mut decompressed)
735                .map_err(Error::BrotliDecompression)?;
736            let _ = writable_body.write(&decompressed);
737
738            return Ok(Response {
739                status,
740                headers,
741                body: decompressed,
742            });
743        }
744
745        Ok(Response {
746            status,
747            headers,
748            body: response_body,
749        })
750    }
751
752    fn has_accept_encoding_header(&self) -> bool {
753        self.headers.iter().any(|header| match header {
754            Header::AcceptEncoding(_) => true,
755            Header::Custom(name, _) => name.eq_ignore_ascii_case("Accept-Encoding"),
756            _ => false,
757        })
758    }
759
760    fn has_content_type_header(&self) -> bool {
761        self.headers.iter().any(|header| match header {
762            Header::ContentType(_) => true,
763            Header::Custom(name, _) => name.eq_ignore_ascii_case("Content-Type"),
764            _ => false,
765        })
766    }
767
768    fn has_user_agent_header(&self) -> bool {
769        self.headers.iter().any(|header| match header {
770            Header::UserAgent(_) => true,
771            Header::Custom(name, _) => name.eq_ignore_ascii_case("User-Agent"),
772            _ => false,
773        })
774    }
775
776    fn body_content_type(&self) -> Option<&'static str> {
777        match &self.body {
778            Some(Body::Json(_)) => Some("application/json"),
779            Some(Body::Text(_)) => Some("text/plain; charset=utf-8"),
780            Some(Body::Bytes(_)) => None,
781            None => None,
782        }
783    }
784}
785
786impl Method {
787    fn apply(&self, easy: &mut Easy2<Collector>) -> Result<(), Error> {
788        match self {
789            Method::Get => easy.get(true)?,
790            Method::Post => easy.post(true)?,
791            Method::Put => easy.custom_request("PUT")?,
792            Method::Delete => easy.custom_request("DELETE")?,
793            Method::Head => easy.nobody(true)?,
794            Method::Options => easy.custom_request("OPTIONS")?,
795            Method::Patch => easy.custom_request("PATCH")?,
796            Method::Connect => easy.custom_request("CONNECT")?,
797            Method::Trace => easy.custom_request("TRACE")?,
798        }
799        Ok(())
800    }
801}
802
803impl Header<'_> {
804    fn to_line(&self) -> Result<String, Error> {
805        let name = self.name();
806        let value = self.value();
807        if value.contains('\n') || value.contains('\r') {
808            return Err(Error::InvalidHeaderValue(name.to_string()));
809        }
810        if matches!(self, Header::Custom(_, _)) {
811            validate_header_name(name)?;
812        }
813        match self {
814            Header::Authorization(value) => Ok(format!("Authorization: {value}")),
815            Header::Accept(value) => Ok(format!("Accept: {value}")),
816            Header::ContentType(value) => Ok(format!("Content-Type: {value}")),
817            Header::UserAgent(value) => Ok(format!("User-Agent: {value}")),
818            Header::AcceptEncoding(value) => Ok(format!("Accept-Encoding: {value}")),
819            Header::AcceptLanguage(value) => Ok(format!("Accept-Language: {value}")),
820            Header::CacheControl(value) => Ok(format!("Cache-Control: {value}")),
821            Header::Referer(value) => Ok(format!("Referer: {value}")),
822            Header::Origin(value) => Ok(format!("Origin: {value}")),
823            Header::Host(value) => Ok(format!("Host: {value}")),
824            Header::Custom(name, value) => Ok(format!("{}: {}", name, value)),
825        }
826    }
827
828    fn name(&self) -> &str {
829        match self {
830            Header::Authorization(_) => "Authorization",
831            Header::Accept(_) => "Accept",
832            Header::ContentType(_) => "Content-Type",
833            Header::UserAgent(_) => "User-Agent",
834            Header::AcceptEncoding(_) => "Accept-Encoding",
835            Header::AcceptLanguage(_) => "Accept-Language",
836            Header::CacheControl(_) => "Cache-Control",
837            Header::Referer(_) => "Referer",
838            Header::Origin(_) => "Origin",
839            Header::Host(_) => "Host",
840            Header::Custom(name, _) => name.as_ref(),
841        }
842    }
843
844    fn value(&self) -> &str {
845        match self {
846            Header::Authorization(value) => value.as_ref(),
847            Header::Accept(value) => value.as_ref(),
848            Header::ContentType(value) => value.as_ref(),
849            Header::UserAgent(value) => value.as_ref(),
850            Header::AcceptEncoding(value) => value.as_ref(),
851            Header::AcceptLanguage(value) => value.as_ref(),
852            Header::CacheControl(value) => value.as_ref(),
853            Header::Referer(value) => value.as_ref(),
854            Header::Origin(value) => value.as_ref(),
855            Header::Host(value) => value.as_ref(),
856            Header::Custom(_, value) => value.as_ref(),
857        }
858    }
859}
860
861pub enum Body<'a> {
862    /// JSON text body.
863    Json(Cow<'a, str>),
864    /// UTF-8 text body.
865    Text(Cow<'a, str>),
866    /// Raw bytes body.
867    Bytes(Cow<'a, [u8]>),
868}
869
870impl Body<'_> {
871    fn bytes(&self) -> &[u8] {
872        match self {
873            Body::Json(value) => value.as_bytes(),
874            Body::Text(value) => value.as_bytes(),
875            Body::Bytes(value) => value.as_ref(),
876        }
877    }
878}
879
880impl<'a> QueryParam<'a> {
881    /// Creates a new query parameter.
882    ///
883    /// # Examples
884    /// ```no_run
885    /// let resp = curl_rest::Client::default()
886    ///     .get()
887    ///     .query_param(curl_rest::QueryParam::new("page", "2"))
888    ///     .send("https://example.com/search")?;
889    /// # Ok::<(), curl_rest::Error>(())
890    /// ```
891    pub fn new(key: impl Into<Cow<'a, str>>, value: impl Into<Cow<'a, str>>) -> Self {
892        Self {
893            key: key.into(),
894            value: value.into(),
895        }
896    }
897}
898
899fn add_query_params<'a>(url: &'a str, params: &[QueryParam<'_>]) -> Cow<'a, str> {
900    if params.is_empty() {
901        return Cow::Borrowed(url);
902    }
903
904    let (base, fragment) = match url.split_once('#') {
905        Some((base, fragment)) => (base, Some(fragment)),
906        None => (url, None),
907    };
908
909    let mut out = String::with_capacity(base.len() + 1);
910    out.push_str(base);
911
912    if base.contains('?') {
913        if !base.ends_with('?') && !base.ends_with('&') {
914            out.push('&');
915        }
916    } else {
917        out.push('?');
918    }
919
920    for (idx, param) in params.iter().enumerate() {
921        if idx > 0 {
922            out.push('&');
923        }
924        out.push_str(&encode_query_component(param.key.as_ref()));
925        out.push('=');
926        out.push_str(&encode_query_component(param.value.as_ref()));
927    }
928
929    if let Some(fragment) = fragment {
930        out.push('#');
931        out.push_str(fragment);
932    }
933
934    Cow::Owned(out)
935}
936
937fn encode_query_component(value: &str) -> String {
938    utf8_percent_encode(value, NON_ALPHANUMERIC).to_string()
939}
940
941fn validate_url(url: &str) -> Result<(), Error> {
942    Url::parse(url)
943        .map(|_| ())
944        .map_err(|_| Error::InvalidUrl(url.to_string()))
945}
946
947fn validate_header_name(name: &str) -> Result<(), Error> {
948    if name.is_empty() {
949        return Err(Error::InvalidHeaderName(name.to_string()));
950    }
951    for b in name.bytes() {
952        if !is_tchar(b) {
953            return Err(Error::InvalidHeaderName(name.to_string()));
954        }
955    }
956    Ok(())
957}
958
959fn is_tchar(b: u8) -> bool {
960    matches!(
961        b,
962        b'!' | b'#' | b'$' | b'%' | b'&' | b'\'' | b'*' | b'+' | b'-' | b'.' | b'^' | b'_' | b'`'
963            | b'|' | b'~' | b'0'..=b'9' | b'a'..=b'z' | b'A'..=b'Z'
964    )
965}
966
967#[cfg(test)]
968mod tests {
969    use super::*;
970
971    #[test]
972    fn query_params_are_encoded_and_appended() {
973        let params = [
974            QueryParam::new("q", "rust curl"),
975            QueryParam::new("page", "1"),
976        ];
977        let url = add_query_params("https://example.com/search", &params);
978        assert_eq!(
979            url.as_ref(),
980            "https://example.com/search?q=rust%20curl&page=1"
981        );
982    }
983
984    #[test]
985    fn query_params_preserve_fragments() {
986        let params = [QueryParam::new("a", "b")];
987        let url = add_query_params("https://example.com/path#frag", &params);
988        assert_eq!(url.as_ref(), "https://example.com/path?a=b#frag");
989    }
990
991    #[test]
992    fn query_params_noop_is_borrowed() {
993        let url = add_query_params("https://example.com", &[]);
994        assert!(matches!(url, Cow::Borrowed(_)));
995    }
996
997    #[test]
998    fn header_rejects_newlines() {
999        let header = Header::UserAgent("bad\r\nvalue".into());
1000        let err = header.to_line().expect_err("expected invalid header");
1001        assert!(matches!(err, Error::InvalidHeaderValue(name) if name == "User-Agent"));
1002    }
1003
1004    #[test]
1005    fn custom_header_rejects_invalid_name() {
1006        let header = Header::Custom("X Bad".into(), "ok".into());
1007        let err = header.to_line().expect_err("expected invalid header name");
1008        assert!(matches!(err, Error::InvalidHeaderName(name) if name == "X Bad"));
1009    }
1010
1011    #[test]
1012    fn custom_header_allows_standard_token_chars() {
1013        let header = Header::Custom("X-Request-Id".into(), "abc123".into());
1014        let line = header.to_line().expect("expected valid header");
1015        assert_eq!(line, "X-Request-Id: abc123");
1016    }
1017
1018    #[test]
1019    fn body_content_type_defaults() {
1020        let curl = Client::default().body_json(r#"{"ok":true}"#);
1021        assert_eq!(curl.body_content_type(), Some("application/json"));
1022
1023        let curl = Client::default().body_text("hi");
1024        assert_eq!(curl.body_content_type(), Some("text/plain; charset=utf-8"));
1025    }
1026
1027    #[test]
1028    fn content_type_header_overrides_body_default() {
1029        let curl = Client::default()
1030            .body_json(r#"{"ok":true}"#)
1031            .header(Header::ContentType("application/custom+json".into()));
1032        assert!(curl.has_content_type_header());
1033        assert_eq!(curl.body_content_type(), Some("application/json"));
1034    }
1035
1036    #[test]
1037    fn with_user_agent_sets_default() {
1038        let curl = Client::with_user_agent("my-agent/1.0");
1039        assert_eq!(curl.default_user_agent.as_deref(), Some("my-agent/1.0"));
1040    }
1041
1042    #[test]
1043    fn user_agent_detection_handles_custom_header() {
1044        let curl = Client::default().header(Header::Custom("User-Agent".into(), "custom".into()));
1045        assert!(curl.has_user_agent_header());
1046    }
1047
1048    #[test]
1049    fn url_validation_rejects_invalid_urls() {
1050        let err = validate_url("http://[::1").expect_err("expected invalid url");
1051        assert!(matches!(err, Error::InvalidUrl(_)));
1052    }
1053
1054    #[test]
1055    fn query_params_append_to_existing_query() {
1056        let params = [QueryParam::new("b", "2")];
1057        let url = add_query_params("https://example.com/path?a=1", &params);
1058        assert_eq!(url.as_ref(), "https://example.com/path?a=1&b=2");
1059    }
1060
1061    #[test]
1062    fn query_params_encode_unicode() {
1063        let params = [QueryParam::new("q", "café")];
1064        let url = add_query_params("https://example.com/search", &params);
1065        assert_eq!(url.as_ref(), "https://example.com/search?q=caf%C3%A9");
1066    }
1067
1068    #[test]
1069    fn header_name_and_value_match() {
1070        let header = Header::Accept("application/json".into());
1071        assert_eq!(header.name(), "Accept");
1072        assert_eq!(header.value(), "application/json");
1073    }
1074
1075    #[test]
1076    fn status_code_default_is_ok() {
1077        assert_eq!(StatusCode::default(), StatusCode::Ok);
1078    }
1079
1080    #[test]
1081    fn headers_comparison() {
1082        let mut headers: Vec<Header> = Vec::new();
1083        headers.push(Header::AcceptEncoding(Cow::Borrowed("br")));
1084
1085        assert!(
1086            headers
1087                .iter()
1088                .any(|header| header == &Header::AcceptEncoding(Cow::Borrowed("br")))
1089        )
1090    }
1091}