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