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