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