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