1use 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#[derive(Debug, Clone, Default)]
60pub struct Response {
61 pub status: StatusCode,
63 pub headers: Vec<ResponseHeader>,
65 pub body: Vec<u8>,
67}
68
69#[derive(Debug, Clone, PartialEq, Eq)]
71pub struct ResponseHeader {
72 pub name: String,
74 pub value: String,
76}
77
78macro_rules! status_codes {
79 ($(
80 $variant:ident => ($code:literal, $reason:literal, $const_name:ident)
81 ),+ $(,)?) => {
82 #[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 pub const fn as_u16(self) -> u16 {
95 self as u16
96 }
97
98 pub const fn canonical_reason(self) -> &'static str {
100 match self {
101 $(StatusCode::$variant => $reason,)+
102 }
103 }
104
105 pub const fn from_u16(code: u16) -> Option<Self> {
107 match code {
108 $($code => Some(StatusCode::$variant),)+
109 _ => None,
110 }
111 }
112
113 $(
114 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#[derive(Debug, Error)]
200pub enum Error {
201 #[error("curl error: {0}")]
203 Client(#[from] curl::Error),
204 #[error("invalid url: {0}")]
206 InvalidUrl(String),
207 #[error("invalid header value for {0}")]
209 InvalidHeaderValue(String),
210 #[error("invalid header name: {0}")]
212 InvalidHeaderName(String),
213 #[error("invalid HTTP status code: {0}")]
215 InvalidStatusCode(u32),
216}
217
218#[derive(Clone)]
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}
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
340pub 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 pub fn new() -> Self {
369 Self::default()
370 }
371
372 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 pub fn method(mut self, method: Method) -> Self {
384 self.method = method;
385 self
386 }
387
388 pub fn get(self) -> Self {
390 self.method(Method::Get)
391 }
392
393 pub fn post(self) -> Self {
395 self.method(Method::Post)
396 }
397
398 pub fn put(self) -> Self {
400 self.method(Method::Put)
401 }
402
403 pub fn delete(self) -> Self {
405 self.method(Method::Delete)
406 }
407
408 pub fn head(self) -> Self {
410 self.method(Method::Head)
411 }
412
413 pub fn options(self) -> Self {
415 self.method(Method::Options)
416 }
417
418 pub fn patch(self) -> Self {
420 self.method(Method::Patch)
421 }
422
423 pub fn connect(self) -> Self {
425 self.method(Method::Connect)
426 }
427
428 pub fn trace(self) -> Self {
430 self.method(Method::Trace)
431 }
432
433 pub fn header(mut self, header: Header<'a>) -> Self {
447 self.headers.push(header);
448 self
449 }
450
451 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 pub fn query_param(mut self, param: QueryParam<'a>) -> Self {
489 self.query.push(param);
490 self
491 }
492
493 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 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 pub fn body(mut self, body: Body<'a>) -> Self {
552 self.body = Some(body);
553 self
554 }
555
556 pub fn body_bytes(self, bytes: impl Into<Cow<'a, [u8]>>) -> Self {
570 self.body(Body::Bytes(bytes.into()))
571 }
572
573 pub fn body_text(self, text: impl Into<Cow<'a, str>>) -> Self {
587 self.body(Body::Text(text.into()))
588 }
589
590 pub fn body_json(self, json: impl Into<Cow<'a, str>>) -> Self {
604 self.body(Body::Json(json.into()))
605 }
606
607 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(Cow<'a, str>),
762 Text(Cow<'a, str>),
764 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 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
865pub fn request(method: Method, url: &str) -> Result<Response, Error> {
878 Client::default().method(method).send(url)
879}
880
881pub 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
908pub fn get(url: &str) -> Result<Response, Error> {
921 Client::default().get().send(url)
922}
923
924pub fn post(url: &str) -> Result<Response, Error> {
937 Client::default().post().send(url)
938}
939
940pub 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
962pub 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", ¶ms);
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", ¶ms);
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", ¶ms);
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", ¶ms);
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}