1use curl::easy::{Easy2, Handler, List, WriteError};
47use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode};
48use std::{
49 borrow::Cow,
50 fmt::Display,
51 io::{Cursor, Read, Write},
52};
53use thiserror::Error;
54use url::Url;
55
56#[derive(Debug, Clone, Default)]
58pub struct Response {
59 pub status: StatusCode,
61 pub headers: Vec<ResponseHeader>,
63 pub body: Vec<u8>,
65}
66
67#[derive(Debug, Clone, PartialEq, Eq)]
69pub struct ResponseHeader {
70 pub name: String,
72 pub value: String,
74}
75
76macro_rules! status_codes {
77 ($(
78 $variant:ident => ($code:literal, $reason:literal, $const_name:ident)
79 ),+ $(,)?) => {
80 #[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
82 #[repr(u16)]
83 pub enum StatusCode {
84 $(
85 #[doc = $reason]
86 $variant = $code,
87 )+
88 }
89
90 impl StatusCode {
91 pub const fn as_u16(self) -> u16 {
93 self as u16
94 }
95
96 pub const fn canonical_reason(self) -> &'static str {
98 match self {
99 $(StatusCode::$variant => $reason,)+
100 }
101 }
102
103 pub const fn from_u16(code: u16) -> Option<Self> {
105 match code {
106 $($code => Some(StatusCode::$variant),)+
107 _ => None,
108 }
109 }
110
111 $(
112 pub const $const_name: StatusCode = StatusCode::$variant;
114 )+
115 }
116
117 impl Display for StatusCode {
118 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
119 write!(f, "{} {}", self.as_u16(), self.canonical_reason())
120 }
121 }
122
123 impl Default for StatusCode {
124 fn default() -> Self {
125 StatusCode::Ok
126 }
127 }
128 };
129}
130
131status_codes! {
132 Continue => (100, "Continue", CONTINUE),
133 SwitchingProtocols => (101, "Switching Protocols", SWITCHING_PROTOCOLS),
134 Processing => (102, "Processing", PROCESSING),
135 EarlyHints => (103, "Early Hints", EARLY_HINTS),
136 Ok => (200, "OK", OK),
137 Created => (201, "Created", CREATED),
138 Accepted => (202, "Accepted", ACCEPTED),
139 NonAuthoritativeInformation => (203, "Non-Authoritative Information", NON_AUTHORITATIVE_INFORMATION),
140 NoContent => (204, "No Content", NO_CONTENT),
141 ResetContent => (205, "Reset Content", RESET_CONTENT),
142 PartialContent => (206, "Partial Content", PARTIAL_CONTENT),
143 MultiStatus => (207, "Multi-Status", MULTI_STATUS),
144 AlreadyReported => (208, "Already Reported", ALREADY_REPORTED),
145 ImUsed => (226, "IM Used", IM_USED),
146 MultipleChoices => (300, "Multiple Choices", MULTIPLE_CHOICES),
147 MovedPermanently => (301, "Moved Permanently", MOVED_PERMANENTLY),
148 Found => (302, "Found", FOUND),
149 SeeOther => (303, "See Other", SEE_OTHER),
150 NotModified => (304, "Not Modified", NOT_MODIFIED),
151 UseProxy => (305, "Use Proxy", USE_PROXY),
152 TemporaryRedirect => (307, "Temporary Redirect", TEMPORARY_REDIRECT),
153 PermanentRedirect => (308, "Permanent Redirect", PERMANENT_REDIRECT),
154 BadRequest => (400, "Bad Request", BAD_REQUEST),
155 Unauthorized => (401, "Unauthorized", UNAUTHORIZED),
156 PaymentRequired => (402, "Payment Required", PAYMENT_REQUIRED),
157 Forbidden => (403, "Forbidden", FORBIDDEN),
158 NotFound => (404, "Not Found", NOT_FOUND),
159 MethodNotAllowed => (405, "Method Not Allowed", METHOD_NOT_ALLOWED),
160 NotAcceptable => (406, "Not Acceptable", NOT_ACCEPTABLE),
161 ProxyAuthenticationRequired => (407, "Proxy Authentication Required", PROXY_AUTHENTICATION_REQUIRED),
162 RequestTimeout => (408, "Request Timeout", REQUEST_TIMEOUT),
163 Conflict => (409, "Conflict", CONFLICT),
164 Gone => (410, "Gone", GONE),
165 LengthRequired => (411, "Length Required", LENGTH_REQUIRED),
166 PreconditionFailed => (412, "Precondition Failed", PRECONDITION_FAILED),
167 PayloadTooLarge => (413, "Content Too Large", PAYLOAD_TOO_LARGE),
168 UriTooLong => (414, "URI Too Long", URI_TOO_LONG),
169 UnsupportedMediaType => (415, "Unsupported Media Type", UNSUPPORTED_MEDIA_TYPE),
170 RangeNotSatisfiable => (416, "Range Not Satisfiable", RANGE_NOT_SATISFIABLE),
171 ExpectationFailed => (417, "Expectation Failed", EXPECTATION_FAILED),
172 ImATeapot => (418, "I'm a teapot", IM_A_TEAPOT),
173 MisdirectedRequest => (421, "Misdirected Request", MISDIRECTED_REQUEST),
174 UnprocessableEntity => (422, "Unprocessable Content", UNPROCESSABLE_ENTITY),
175 Locked => (423, "Locked", LOCKED),
176 FailedDependency => (424, "Failed Dependency", FAILED_DEPENDENCY),
177 TooEarly => (425, "Too Early", TOO_EARLY),
178 UpgradeRequired => (426, "Upgrade Required", UPGRADE_REQUIRED),
179 PreconditionRequired => (428, "Precondition Required", PRECONDITION_REQUIRED),
180 TooManyRequests => (429, "Too Many Requests", TOO_MANY_REQUESTS),
181 RequestHeaderFieldsTooLarge => (431, "Request Header Fields Too Large", REQUEST_HEADER_FIELDS_TOO_LARGE),
182 UnavailableForLegalReasons => (451, "Unavailable For Legal Reasons", UNAVAILABLE_FOR_LEGAL_REASONS),
183 InternalServerError => (500, "Internal Server Error", INTERNAL_SERVER_ERROR),
184 NotImplemented => (501, "Not Implemented", NOT_IMPLEMENTED),
185 BadGateway => (502, "Bad Gateway", BAD_GATEWAY),
186 ServiceUnavailable => (503, "Service Unavailable", SERVICE_UNAVAILABLE),
187 GatewayTimeout => (504, "Gateway Timeout", GATEWAY_TIMEOUT),
188 HttpVersionNotSupported => (505, "HTTP Version Not Supported", HTTP_VERSION_NOT_SUPPORTED),
189 VariantAlsoNegotiates => (506, "Variant Also Negotiates", VARIANT_ALSO_NEGOTIATES),
190 InsufficientStorage => (507, "Insufficient Storage", INSUFFICIENT_STORAGE),
191 LoopDetected => (508, "Loop Detected", LOOP_DETECTED),
192 NotExtended => (510, "Not Extended", NOT_EXTENDED),
193 NetworkAuthenticationRequired => (511, "Network Authentication Required", NETWORK_AUTHENTICATION_REQUIRED),
194}
195
196#[derive(Debug, Error)]
198pub enum Error {
199 #[error("curl error: {0}")]
201 Client(#[from] curl::Error),
202 #[error("invalid url: {0}")]
204 InvalidUrl(String),
205 #[error("invalid header value for {0}")]
207 InvalidHeaderValue(String),
208 #[error("invalid header name: {0}")]
210 InvalidHeaderName(String),
211 #[error("invalid HTTP status code: {0}")]
213 InvalidStatusCode(u32),
214 #[error("brotli decompression failed: {0}")]
216 BrotliDecompression(#[from] std::io::Error),
217}
218
219#[derive(Debug, Clone, PartialEq)]
221pub enum Header<'a> {
222 Authorization(Cow<'a, str>),
224 Accept(Cow<'a, str>),
226 ContentType(Cow<'a, str>),
228 UserAgent(Cow<'a, str>),
230 AcceptEncoding(Cow<'a, str>),
234 AcceptLanguage(Cow<'a, str>),
236 CacheControl(Cow<'a, str>),
238 Referer(Cow<'a, str>),
240 Origin(Cow<'a, str>),
242 Host(Cow<'a, str>),
244 Custom(Cow<'a, str>, Cow<'a, str>),
248}
249
250#[derive(Clone)]
252pub struct QueryParam<'a> {
253 key: Cow<'a, str>,
254 value: Cow<'a, str>,
255}
256
257#[derive(Debug, Default, Clone)]
259pub enum Method {
260 #[default]
262 Get,
263 Post,
265 Put,
267 Delete,
269 Head,
271 Options,
273 Patch,
275 Connect,
277 Trace,
279}
280
281struct Collector {
282 body: Vec<u8>,
283 headers: Vec<ResponseHeader>,
284 position: usize,
285}
286
287impl Read for Collector {
288 fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
289 if self.position > self.body.len() {
290 return Ok(0);
291 }
292
293 let remaining = &self.body[self.position..];
294 let to_read = remaining.len().min(buf.len());
295
296 buf[..to_read].copy_from_slice(&remaining[..to_read]);
297 self.position += to_read;
298
299 Ok(to_read)
300 }
301}
302
303impl Collector {
304 fn new() -> Self {
305 Self {
306 body: Vec::new(),
307 headers: Vec::new(),
308 position: Default::default(),
309 }
310 }
311}
312
313impl Handler for Collector {
314 fn write(&mut self, data: &[u8]) -> Result<usize, WriteError> {
315 self.body.extend_from_slice(data);
316 Ok(data.len())
317 }
318
319 fn header(&mut self, data: &[u8]) -> bool {
320 if data.is_empty() {
321 return true;
322 }
323 let Ok(line) = std::str::from_utf8(data) else {
324 return true;
325 };
326 let line = line.trim_end_matches(['\r', '\n']);
327 if line.is_empty() {
328 return true;
329 }
330 if line.starts_with("HTTP/") {
331 return true;
332 }
333 if line.starts_with(' ') || line.starts_with('\t') {
334 if let Some(last) = self.headers.last_mut() {
335 let trimmed = line.trim();
336 if !trimmed.is_empty() {
337 if !last.value.is_empty() {
338 last.value.push(' ');
339 }
340 last.value.push_str(trimmed);
341 }
342 }
343 return true;
344 }
345 if let Some((name, value)) = line.split_once(':') {
346 let name = name.trim();
347 let value = value.trim();
348 if !name.is_empty() {
349 self.headers.push(ResponseHeader {
350 name: name.to_string(),
351 value: value.to_string(),
352 });
353 }
354 }
355 true
356 }
357}
358
359pub struct Client<'a> {
363 method: Method,
364 headers: Vec<Header<'a>>,
365 query: Vec<QueryParam<'a>>,
366 body: Option<Body<'a>>,
367 default_user_agent: Option<Cow<'a, str>>,
368 max_redirects: i8,
369 brotli: bool,
370}
371
372#[deprecated(note = "Renamed to Client; use Client instead.")]
373pub type Curl<'a> = Client<'a>;
374
375impl<'a> Default for Client<'a> {
376 fn default() -> Self {
377 Self {
378 method: Method::Get,
379 headers: Vec::new(),
380 query: Vec::new(),
381 body: None,
382 default_user_agent: None,
383 max_redirects: 1,
384 brotli: false,
385 }
386 }
387}
388
389impl<'a> Client<'a> {
390 pub fn new() -> Self {
392 Self::default()
393 }
394
395 pub fn with_user_agent(agent: impl Into<Cow<'a, str>>) -> Self {
399 Self {
400 default_user_agent: Some(agent.into()),
401 ..Self::default()
402 }
403 }
404
405 pub fn max_redirects(mut self, max: i8) -> Self {
421 self.max_redirects = max;
422 self
423 }
424
425 pub fn brotli(mut self, is_enabled: bool) -> Self {
432 self.brotli = is_enabled;
433
434 self
435 }
436
437 pub fn method(mut self, method: Method) -> Self {
439 self.method = method;
440 self
441 }
442
443 pub fn get(self) -> Self {
445 self.method(Method::Get)
446 }
447
448 pub fn post(self) -> Self {
450 self.method(Method::Post)
451 }
452
453 pub fn put(self) -> Self {
455 self.method(Method::Put)
456 }
457
458 pub fn delete(self) -> Self {
460 self.method(Method::Delete)
461 }
462
463 pub fn head(self) -> Self {
465 self.method(Method::Head)
466 }
467
468 pub fn options(self) -> Self {
470 self.method(Method::Options)
471 }
472
473 pub fn patch(self) -> Self {
475 self.method(Method::Patch)
476 }
477
478 pub fn connect(self) -> Self {
480 self.method(Method::Connect)
481 }
482
483 pub fn trace(self) -> Self {
485 self.method(Method::Trace)
486 }
487
488 pub fn header(mut self, header: Header<'a>) -> Self {
502 self.headers.push(header);
503 self
504 }
505
506 pub fn headers<I>(mut self, headers: I) -> Self
523 where
524 I: IntoIterator<Item = Header<'a>>,
525 {
526 self.headers.extend(headers);
527 self
528 }
529
530 pub fn query_param(mut self, param: QueryParam<'a>) -> Self {
544 self.query.push(param);
545 self
546 }
547
548 pub fn query_param_kv(
562 self,
563 key: impl Into<Cow<'a, str>>,
564 value: impl Into<Cow<'a, str>>,
565 ) -> Self {
566 self.query_param(QueryParam::new(key, value))
567 }
568
569 pub fn query_params<I>(mut self, params: I) -> Self
586 where
587 I: IntoIterator<Item = QueryParam<'a>>,
588 {
589 self.query.extend(params);
590 self
591 }
592
593 pub fn body(mut self, body: Body<'a>) -> Self {
607 self.body = Some(body);
608 self
609 }
610
611 pub fn body_bytes(self, bytes: impl Into<Cow<'a, [u8]>>) -> Self {
625 self.body(Body::Bytes(bytes.into()))
626 }
627
628 pub fn body_text(self, text: impl Into<Cow<'a, str>>) -> Self {
642 self.body(Body::Text(text.into()))
643 }
644
645 pub fn body_json(self, json: impl Into<Cow<'a, str>>) -> Self {
659 self.body(Body::Json(json.into()))
660 }
661
662 pub fn send(self, url: &str) -> Result<Response, Error> {
668 let mut easy = Easy2::new(Collector::new());
669 self.method.apply(&mut easy)?;
670 if self.max_redirects >= 0 {
671 easy.follow_location(true)?;
672 easy.max_redirections(self.max_redirects as u32)?;
673 }
674
675 if !self.brotli {
676 easy.accept_encoding("gzip")?;
677 }
678
679 let mut list = List::new();
680 let mut has_headers = false;
681
682 if self.brotli && !self.has_accept_encoding_header() {
683 list.append("Accept-Encoding: br")?;
684 has_headers = true;
685 }
686
687 for header in &self.headers {
688 list.append(&header.to_line()?)?;
689 has_headers = true;
690 }
691
692 if let Some(default_user_agent) = &self.default_user_agent {
693 if !self.has_user_agent_header() {
694 list.append(&format!("User-Agent: {default_user_agent}"))?;
695 has_headers = true;
696 }
697 }
698
699 if let Some(content_type) = self.body_content_type() {
700 if !self.has_content_type_header() {
701 list.append(&format!("Content-Type: {content_type}"))?;
702 has_headers = true;
703 }
704 }
705
706 if has_headers {
707 easy.http_headers(list)?;
708 }
709
710 if let Some(body) = &self.body {
711 easy.post_fields_copy(body.bytes())?;
712 }
713
714 let url = add_query_params(url, &self.query);
715 validate_url(url.as_ref())?;
716 easy.url(url.as_ref())?;
717 easy.perform()?;
718
719 let status_code = easy.response_code()?;
720 let status_u16 =
721 u16::try_from(status_code).map_err(|_| Error::InvalidStatusCode(status_code))?;
722 let status =
723 StatusCode::from_u16(status_u16).ok_or(Error::InvalidStatusCode(status_code))?;
724 let response_body = easy.get_ref().body.clone();
725 let headers = easy.get_ref().headers.clone();
726
727 if headers.iter().any(|header| {
728 header.name.eq_ignore_ascii_case("Content-Encoding")
729 && header.value.eq_ignore_ascii_case("br")
730 }) {
731 let mut writable_body = Cursor::new(response_body.to_vec());
732 let mut decompressed = Vec::new();
733
734 brotli_decompressor::BrotliDecompress(&mut writable_body, &mut decompressed)
735 .map_err(Error::BrotliDecompression)?;
736 let _ = writable_body.write(&decompressed);
737
738 return Ok(Response {
739 status,
740 headers,
741 body: decompressed,
742 });
743 }
744
745 Ok(Response {
746 status,
747 headers,
748 body: response_body,
749 })
750 }
751
752 fn has_accept_encoding_header(&self) -> bool {
753 self.headers.iter().any(|header| match header {
754 Header::AcceptEncoding(_) => true,
755 Header::Custom(name, _) => name.eq_ignore_ascii_case("Accept-Encoding"),
756 _ => false,
757 })
758 }
759
760 fn has_content_type_header(&self) -> bool {
761 self.headers.iter().any(|header| match header {
762 Header::ContentType(_) => true,
763 Header::Custom(name, _) => name.eq_ignore_ascii_case("Content-Type"),
764 _ => false,
765 })
766 }
767
768 fn has_user_agent_header(&self) -> bool {
769 self.headers.iter().any(|header| match header {
770 Header::UserAgent(_) => true,
771 Header::Custom(name, _) => name.eq_ignore_ascii_case("User-Agent"),
772 _ => false,
773 })
774 }
775
776 fn body_content_type(&self) -> Option<&'static str> {
777 match &self.body {
778 Some(Body::Json(_)) => Some("application/json"),
779 Some(Body::Text(_)) => Some("text/plain; charset=utf-8"),
780 Some(Body::Bytes(_)) => None,
781 None => None,
782 }
783 }
784}
785
786impl Method {
787 fn apply(&self, easy: &mut Easy2<Collector>) -> Result<(), Error> {
788 match self {
789 Method::Get => easy.get(true)?,
790 Method::Post => easy.post(true)?,
791 Method::Put => easy.custom_request("PUT")?,
792 Method::Delete => easy.custom_request("DELETE")?,
793 Method::Head => easy.nobody(true)?,
794 Method::Options => easy.custom_request("OPTIONS")?,
795 Method::Patch => easy.custom_request("PATCH")?,
796 Method::Connect => easy.custom_request("CONNECT")?,
797 Method::Trace => easy.custom_request("TRACE")?,
798 }
799 Ok(())
800 }
801}
802
803impl Header<'_> {
804 fn to_line(&self) -> Result<String, Error> {
805 let name = self.name();
806 let value = self.value();
807 if value.contains('\n') || value.contains('\r') {
808 return Err(Error::InvalidHeaderValue(name.to_string()));
809 }
810 if matches!(self, Header::Custom(_, _)) {
811 validate_header_name(name)?;
812 }
813 match self {
814 Header::Authorization(value) => Ok(format!("Authorization: {value}")),
815 Header::Accept(value) => Ok(format!("Accept: {value}")),
816 Header::ContentType(value) => Ok(format!("Content-Type: {value}")),
817 Header::UserAgent(value) => Ok(format!("User-Agent: {value}")),
818 Header::AcceptEncoding(value) => Ok(format!("Accept-Encoding: {value}")),
819 Header::AcceptLanguage(value) => Ok(format!("Accept-Language: {value}")),
820 Header::CacheControl(value) => Ok(format!("Cache-Control: {value}")),
821 Header::Referer(value) => Ok(format!("Referer: {value}")),
822 Header::Origin(value) => Ok(format!("Origin: {value}")),
823 Header::Host(value) => Ok(format!("Host: {value}")),
824 Header::Custom(name, value) => Ok(format!("{}: {}", name, value)),
825 }
826 }
827
828 fn name(&self) -> &str {
829 match self {
830 Header::Authorization(_) => "Authorization",
831 Header::Accept(_) => "Accept",
832 Header::ContentType(_) => "Content-Type",
833 Header::UserAgent(_) => "User-Agent",
834 Header::AcceptEncoding(_) => "Accept-Encoding",
835 Header::AcceptLanguage(_) => "Accept-Language",
836 Header::CacheControl(_) => "Cache-Control",
837 Header::Referer(_) => "Referer",
838 Header::Origin(_) => "Origin",
839 Header::Host(_) => "Host",
840 Header::Custom(name, _) => name.as_ref(),
841 }
842 }
843
844 fn value(&self) -> &str {
845 match self {
846 Header::Authorization(value) => value.as_ref(),
847 Header::Accept(value) => value.as_ref(),
848 Header::ContentType(value) => value.as_ref(),
849 Header::UserAgent(value) => value.as_ref(),
850 Header::AcceptEncoding(value) => value.as_ref(),
851 Header::AcceptLanguage(value) => value.as_ref(),
852 Header::CacheControl(value) => value.as_ref(),
853 Header::Referer(value) => value.as_ref(),
854 Header::Origin(value) => value.as_ref(),
855 Header::Host(value) => value.as_ref(),
856 Header::Custom(_, value) => value.as_ref(),
857 }
858 }
859}
860
861pub enum Body<'a> {
862 Json(Cow<'a, str>),
864 Text(Cow<'a, str>),
866 Bytes(Cow<'a, [u8]>),
868}
869
870impl Body<'_> {
871 fn bytes(&self) -> &[u8] {
872 match self {
873 Body::Json(value) => value.as_bytes(),
874 Body::Text(value) => value.as_bytes(),
875 Body::Bytes(value) => value.as_ref(),
876 }
877 }
878}
879
880impl<'a> QueryParam<'a> {
881 pub fn new(key: impl Into<Cow<'a, str>>, value: impl Into<Cow<'a, str>>) -> Self {
892 Self {
893 key: key.into(),
894 value: value.into(),
895 }
896 }
897}
898
899fn add_query_params<'a>(url: &'a str, params: &[QueryParam<'_>]) -> Cow<'a, str> {
900 if params.is_empty() {
901 return Cow::Borrowed(url);
902 }
903
904 let (base, fragment) = match url.split_once('#') {
905 Some((base, fragment)) => (base, Some(fragment)),
906 None => (url, None),
907 };
908
909 let mut out = String::with_capacity(base.len() + 1);
910 out.push_str(base);
911
912 if base.contains('?') {
913 if !base.ends_with('?') && !base.ends_with('&') {
914 out.push('&');
915 }
916 } else {
917 out.push('?');
918 }
919
920 for (idx, param) in params.iter().enumerate() {
921 if idx > 0 {
922 out.push('&');
923 }
924 out.push_str(&encode_query_component(param.key.as_ref()));
925 out.push('=');
926 out.push_str(&encode_query_component(param.value.as_ref()));
927 }
928
929 if let Some(fragment) = fragment {
930 out.push('#');
931 out.push_str(fragment);
932 }
933
934 Cow::Owned(out)
935}
936
937fn encode_query_component(value: &str) -> String {
938 utf8_percent_encode(value, NON_ALPHANUMERIC).to_string()
939}
940
941fn validate_url(url: &str) -> Result<(), Error> {
942 Url::parse(url)
943 .map(|_| ())
944 .map_err(|_| Error::InvalidUrl(url.to_string()))
945}
946
947fn validate_header_name(name: &str) -> Result<(), Error> {
948 if name.is_empty() {
949 return Err(Error::InvalidHeaderName(name.to_string()));
950 }
951 for b in name.bytes() {
952 if !is_tchar(b) {
953 return Err(Error::InvalidHeaderName(name.to_string()));
954 }
955 }
956 Ok(())
957}
958
959fn is_tchar(b: u8) -> bool {
960 matches!(
961 b,
962 b'!' | b'#' | b'$' | b'%' | b'&' | b'\'' | b'*' | b'+' | b'-' | b'.' | b'^' | b'_' | b'`'
963 | b'|' | b'~' | b'0'..=b'9' | b'a'..=b'z' | b'A'..=b'Z'
964 )
965}
966
967#[cfg(test)]
968mod tests {
969 use super::*;
970
971 #[test]
972 fn query_params_are_encoded_and_appended() {
973 let params = [
974 QueryParam::new("q", "rust curl"),
975 QueryParam::new("page", "1"),
976 ];
977 let url = add_query_params("https://example.com/search", ¶ms);
978 assert_eq!(
979 url.as_ref(),
980 "https://example.com/search?q=rust%20curl&page=1"
981 );
982 }
983
984 #[test]
985 fn query_params_preserve_fragments() {
986 let params = [QueryParam::new("a", "b")];
987 let url = add_query_params("https://example.com/path#frag", ¶ms);
988 assert_eq!(url.as_ref(), "https://example.com/path?a=b#frag");
989 }
990
991 #[test]
992 fn query_params_noop_is_borrowed() {
993 let url = add_query_params("https://example.com", &[]);
994 assert!(matches!(url, Cow::Borrowed(_)));
995 }
996
997 #[test]
998 fn header_rejects_newlines() {
999 let header = Header::UserAgent("bad\r\nvalue".into());
1000 let err = header.to_line().expect_err("expected invalid header");
1001 assert!(matches!(err, Error::InvalidHeaderValue(name) if name == "User-Agent"));
1002 }
1003
1004 #[test]
1005 fn custom_header_rejects_invalid_name() {
1006 let header = Header::Custom("X Bad".into(), "ok".into());
1007 let err = header.to_line().expect_err("expected invalid header name");
1008 assert!(matches!(err, Error::InvalidHeaderName(name) if name == "X Bad"));
1009 }
1010
1011 #[test]
1012 fn custom_header_allows_standard_token_chars() {
1013 let header = Header::Custom("X-Request-Id".into(), "abc123".into());
1014 let line = header.to_line().expect("expected valid header");
1015 assert_eq!(line, "X-Request-Id: abc123");
1016 }
1017
1018 #[test]
1019 fn body_content_type_defaults() {
1020 let curl = Client::default().body_json(r#"{"ok":true}"#);
1021 assert_eq!(curl.body_content_type(), Some("application/json"));
1022
1023 let curl = Client::default().body_text("hi");
1024 assert_eq!(curl.body_content_type(), Some("text/plain; charset=utf-8"));
1025 }
1026
1027 #[test]
1028 fn content_type_header_overrides_body_default() {
1029 let curl = Client::default()
1030 .body_json(r#"{"ok":true}"#)
1031 .header(Header::ContentType("application/custom+json".into()));
1032 assert!(curl.has_content_type_header());
1033 assert_eq!(curl.body_content_type(), Some("application/json"));
1034 }
1035
1036 #[test]
1037 fn with_user_agent_sets_default() {
1038 let curl = Client::with_user_agent("my-agent/1.0");
1039 assert_eq!(curl.default_user_agent.as_deref(), Some("my-agent/1.0"));
1040 }
1041
1042 #[test]
1043 fn user_agent_detection_handles_custom_header() {
1044 let curl = Client::default().header(Header::Custom("User-Agent".into(), "custom".into()));
1045 assert!(curl.has_user_agent_header());
1046 }
1047
1048 #[test]
1049 fn url_validation_rejects_invalid_urls() {
1050 let err = validate_url("http://[::1").expect_err("expected invalid url");
1051 assert!(matches!(err, Error::InvalidUrl(_)));
1052 }
1053
1054 #[test]
1055 fn query_params_append_to_existing_query() {
1056 let params = [QueryParam::new("b", "2")];
1057 let url = add_query_params("https://example.com/path?a=1", ¶ms);
1058 assert_eq!(url.as_ref(), "https://example.com/path?a=1&b=2");
1059 }
1060
1061 #[test]
1062 fn query_params_encode_unicode() {
1063 let params = [QueryParam::new("q", "café")];
1064 let url = add_query_params("https://example.com/search", ¶ms);
1065 assert_eq!(url.as_ref(), "https://example.com/search?q=caf%C3%A9");
1066 }
1067
1068 #[test]
1069 fn header_name_and_value_match() {
1070 let header = Header::Accept("application/json".into());
1071 assert_eq!(header.name(), "Accept");
1072 assert_eq!(header.value(), "application/json");
1073 }
1074
1075 #[test]
1076 fn status_code_default_is_ok() {
1077 assert_eq!(StatusCode::default(), StatusCode::Ok);
1078 }
1079
1080 #[test]
1081 fn headers_comparison() {
1082 let mut headers: Vec<Header> = Vec::new();
1083 headers.push(Header::AcceptEncoding(Cow::Borrowed("br")));
1084
1085 assert!(
1086 headers
1087 .iter()
1088 .any(|header| header == &Header::AcceptEncoding(Cow::Borrowed("br")))
1089 )
1090 }
1091}