1use alloc::string::String;
6use alloc::vec::Vec;
7use alloc::format;
8use core::fmt;
9
10use azul_css::{AzString, U8Vec, impl_vec, impl_vec_clone, impl_vec_debug, impl_vec_partialeq, impl_vec_mut, impl_option, impl_option_inner};
11
12#[derive(Debug, Clone, PartialEq, Eq)]
18#[repr(C)]
19pub struct HttpStatusError {
20 pub status_code: u16,
22 pub message: AzString,
24}
25
26#[derive(Copy, Debug, Clone, PartialEq, Eq)]
28#[repr(C)]
29pub struct HttpResponseTooLargeError {
30 pub max_size: u64,
32 pub actual_size: u64,
34}
35
36#[derive(Debug, Clone, PartialEq, Eq)]
38#[repr(C, u8)]
39pub enum HttpError {
40 InvalidUrl(AzString),
42 ConnectionFailed(AzString),
44 Timeout,
46 TlsError(AzString),
48 HttpStatus(HttpStatusError),
50 IoError(AzString),
52 ResponseTooLarge(HttpResponseTooLargeError),
54 Other(AzString),
56}
57
58impl HttpError {
59 #[must_use] pub const fn invalid_url(url: AzString) -> Self {
60 Self::InvalidUrl(url)
61 }
62
63 #[must_use] pub const fn connection_failed(msg: AzString) -> Self {
64 Self::ConnectionFailed(msg)
65 }
66
67 #[must_use] pub const fn tls_error(msg: AzString) -> Self {
68 Self::TlsError(msg)
69 }
70
71 #[must_use] pub const fn http_status(status_code: u16, message: AzString) -> Self {
72 Self::HttpStatus(HttpStatusError {
73 status_code,
74 message,
75 })
76 }
77
78 #[must_use] pub const fn io_error(msg: AzString) -> Self {
79 Self::IoError(msg)
80 }
81
82 #[must_use] pub const fn response_too_large(max_size: u64, actual_size: u64) -> Self {
83 Self::ResponseTooLarge(HttpResponseTooLargeError {
84 max_size,
85 actual_size,
86 })
87 }
88
89 #[must_use] pub const fn other(msg: AzString) -> Self {
90 Self::Other(msg)
91 }
92}
93
94impl fmt::Display for HttpError {
95 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
96 match self {
97 Self::InvalidUrl(url) => write!(f, "Invalid URL: {}", url.as_str()),
98 Self::ConnectionFailed(msg) => write!(f, "Connection failed: {}", msg.as_str()),
99 Self::Timeout => write!(f, "Request timed out"),
100 Self::TlsError(msg) => write!(f, "TLS error: {}", msg.as_str()),
101 Self::HttpStatus(e) => write!(f, "HTTP {} - {}", e.status_code, e.message.as_str()),
102 Self::IoError(msg) => write!(f, "I/O error: {}", msg.as_str()),
103 Self::ResponseTooLarge(e) => {
104 write!(f, "Response too large: {} bytes (max: {})", e.actual_size, e.max_size)
105 }
106 Self::Other(msg) => write!(f, "HTTP error: {}", msg.as_str()),
107 }
108 }
109}
110
111#[cfg(feature = "std")]
112impl std::error::Error for HttpError {}
113
114pub type HttpResult<T> = Result<T, HttpError>;
116
117use azul_css::{impl_result, impl_result_inner};
119
120#[derive(Debug, Clone, PartialEq, Eq)]
128#[repr(C)]
129pub struct HttpHeader {
130 pub name: AzString,
132 pub value: AzString,
134}
135
136impl HttpHeader {
137 pub fn new(name: impl Into<String>, value: impl Into<String>) -> Self {
138 Self {
139 name: AzString::from(name.into()),
140 value: AzString::from(value.into()),
141 }
142 }
143}
144
145impl_option!(HttpHeader, OptionHttpHeader, copy = false, [Debug, Clone, PartialEq, Eq]);
146impl_vec!(HttpHeader, HttpHeaderVec, HttpHeaderVecDestructor, HttpHeaderVecDestructorType, HttpHeaderVecSlice, OptionHttpHeader);
147impl_vec_clone!(HttpHeader, HttpHeaderVec, HttpHeaderVecDestructor);
148impl_vec_debug!(HttpHeader, HttpHeaderVec);
149impl_vec_partialeq!(HttpHeader, HttpHeaderVec);
150impl_vec_mut!(HttpHeader, HttpHeaderVec);
151
152#[derive(Debug, Clone)]
154#[repr(C)]
155pub struct HttpRequestConfig {
156 pub timeout_secs: u64,
158 pub max_response_size: u64,
160 pub user_agent: AzString,
162 pub headers: HttpHeaderVec,
164 pub disable_tls_cert_verification: bool,
169}
170
171impl Default for HttpRequestConfig {
172 fn default() -> Self {
173 Self {
174 timeout_secs: 30,
175 max_response_size: 100 * 1024 * 1024, user_agent: AzString::from("azul-http/1.0".to_string()),
177 headers: HttpHeaderVec::from_const_slice(&[]),
178 disable_tls_cert_verification: false,
179 }
180 }
181}
182
183impl HttpRequestConfig {
184 #[must_use] pub fn new() -> Self {
186 Self::default()
187 }
188
189 #[must_use] pub const fn with_timeout(mut self, secs: u64) -> Self {
191 self.timeout_secs = secs;
192 self
193 }
194
195 #[must_use] pub const fn with_max_size(mut self, max_bytes: u64) -> Self {
197 self.max_response_size = max_bytes;
198 self
199 }
200
201 #[must_use]
203 pub fn with_user_agent(mut self, ua: impl Into<String>) -> Self {
204 self.user_agent = AzString::from(ua.into());
205 self
206 }
207
208 #[must_use]
210 pub fn with_header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
211 self.headers.push(HttpHeader::new(name, value));
212 self
213 }
214
215 #[cfg(feature = "http")]
223 pub fn http_get_default(url: AzString) -> ResultHttpResponseHttpError {
224 let config = HttpRequestConfig::default();
225 http_get_with_config(url.as_str(), &config).into()
226 }
227
228 #[cfg(not(feature = "http"))]
230 #[must_use] pub fn http_get_default(_url: AzString) -> ResultHttpResponseHttpError {
231 ResultHttpResponseHttpError::Err(HttpError::other("http feature not enabled".into()))
232 }
233
234 #[cfg(feature = "http")]
242 pub fn http_get(&self, url: AzString) -> ResultHttpResponseHttpError {
243 http_get_with_config(url.as_str(), self).into()
244 }
245
246 #[cfg(not(feature = "http"))]
248 #[must_use] pub fn http_get(&self, _url: AzString) -> ResultHttpResponseHttpError {
249 ResultHttpResponseHttpError::Err(HttpError::other("http feature not enabled".into()))
250 }
251
252 #[cfg(feature = "http")]
260 pub fn download_bytes_default(url: AzString) -> ResultU8VecHttpError {
261 download_bytes(url.as_str()).into()
262 }
263
264 #[cfg(not(feature = "http"))]
266 #[must_use] pub fn download_bytes_default(_url: AzString) -> ResultU8VecHttpError {
267 ResultU8VecHttpError::Err(HttpError::other("http feature not enabled".into()))
268 }
269
270 #[cfg(feature = "http")]
278 pub fn download_bytes(&self, url: AzString) -> ResultU8VecHttpError {
279 download_bytes_with_config(url.as_str(), self).into()
280 }
281
282 #[cfg(not(feature = "http"))]
284 #[must_use] pub fn download_bytes(&self, _url: AzString) -> ResultU8VecHttpError {
285 ResultU8VecHttpError::Err(HttpError::other("http feature not enabled".into()))
286 }
287
288 #[cfg(feature = "http")]
296 pub fn is_url_reachable(url: AzString) -> bool {
297 is_url_reachable(url.as_str())
298 }
299
300 #[cfg(not(feature = "http"))]
302 #[must_use] pub fn is_url_reachable(_url: AzString) -> bool {
303 false
304 }
305}
306
307#[derive(Debug, Clone, PartialEq)]
313#[repr(C)]
314pub struct HttpResponse {
315 pub status_code: u16,
317 pub body: U8Vec,
319 pub content_type: AzString,
321 pub content_length: u64,
323 pub headers: HttpHeaderVec,
325}
326
327impl HttpResponse {
328 #[must_use] pub const fn is_success(&self) -> bool {
330 self.status_code >= 200 && self.status_code < 300
331 }
332
333 #[must_use] pub const fn is_redirect(&self) -> bool {
335 self.status_code >= 300 && self.status_code < 400
336 }
337
338 #[must_use] pub const fn is_client_error(&self) -> bool {
340 self.status_code >= 400 && self.status_code < 500
341 }
342
343 #[must_use] pub const fn is_server_error(&self) -> bool {
345 self.status_code >= 500 && self.status_code < 600
346 }
347
348 #[must_use] pub fn body_as_string(&self) -> Option<AzString> {
350 core::str::from_utf8(self.body.as_slice())
351 .ok()
352 .map(|s| AzString::from(s.to_string()))
353 }
354}
355
356impl_result!(
358 HttpResponse,
359 HttpError,
360 ResultHttpResponseHttpError,
361 copy = false,
362 clone = false,
363 [Debug, Clone, PartialEq]
364);
365
366impl_result!(
367 U8Vec,
368 HttpError,
369 ResultU8VecHttpError,
370 copy = false,
371 clone = false,
372 [Debug, Clone, PartialEq, Eq]
373);
374
375#[cfg(feature = "http")]
383pub fn http_get(url: &str) -> HttpResult<HttpResponse> {
384 http_get_with_config(url, &HttpRequestConfig::default())
385}
386
387#[cfg(not(feature = "http"))]
389pub fn http_get(_url: &str) -> HttpResult<HttpResponse> {
393 Err(HttpError::other("http feature not enabled".into()))
394}
395
396#[cfg(feature = "http")]
405fn make_agent(timeout_secs: u64, disable_tls_cert_verification: bool) -> ureq::Agent {
406 use std::time::Duration;
407
408 let mut tls_builder = ureq::tls::TlsConfig::builder()
409 .provider(ureq::tls::TlsProvider::Rustls)
410 .unversioned_rustls_crypto_provider(
411 std::sync::Arc::new(rustls_rustcrypto::provider())
412 );
413
414 if disable_tls_cert_verification {
415 tls_builder = tls_builder.disable_verification(true);
416 } else {
417 tls_builder = tls_builder.root_certs(ureq::tls::RootCerts::WebPki);
418 }
419
420 let tls_config = tls_builder.build();
421
422 ureq::Agent::config_builder()
423 .tls_config(tls_config)
424 .timeout_global(Some(Duration::from_secs(timeout_secs)))
425 .http_status_as_error(false)
426 .build()
427 .new_agent()
428}
429
430#[cfg(feature = "http")]
431pub fn http_get_with_config(url: &str, config: &HttpRequestConfig) -> HttpResult<HttpResponse> {
432 use std::io::Read;
433
434 let agent = make_agent(config.timeout_secs, config.disable_tls_cert_verification);
435
436 let mut request = agent.get(url);
438
439 if !config.user_agent.as_str().is_empty() {
441 request = request.header("User-Agent", config.user_agent.as_str());
442 }
443
444 for header in config.headers.as_slice() {
446 request = request.header(header.name.as_str(), header.value.as_str());
447 }
448
449 let response = request.call().map_err(|e| {
451 match &e {
452 ureq::Error::Timeout(_) => HttpError::Timeout,
453 ureq::Error::HostNotFound => HttpError::connection_failed(
454 format!("DNS resolution failed for {}", url).into(),
455 ),
456 ureq::Error::ConnectionFailed => HttpError::connection_failed(
457 format!("Connection failed: {}", url).into(),
458 ),
459 ureq::Error::Io(io_err) => HttpError::io_error(
460 format!("{}", io_err).into(),
461 ),
462 ureq::Error::BadUri(msg) => HttpError::invalid_url(
463 format!("{}: {}", url, msg).into(),
464 ),
465 ureq::Error::Tls(msg) => HttpError::tls_error(
466 format!("TLS error: {}", msg).into(),
467 ),
468 _ => {
470 let msg = e.to_string();
471 if msg.starts_with("rustls:") || msg.contains("TLS") || msg.contains("certificate") {
472 HttpError::tls_error(msg.into())
473 } else {
474 HttpError::other(msg.into())
475 }
476 }
477 }
478 })?;
479
480 let status_code = response.status().as_u16();
481 let content_type = AzString::from(
482 response.headers().get("Content-Type")
483 .and_then(|v| v.to_str().ok())
484 .unwrap_or("application/octet-stream")
485 .to_string()
486 );
487 let content_length = response.headers().get("Content-Length")
488 .and_then(|v| v.to_str().ok())
489 .and_then(|s| s.parse::<u64>().ok())
490 .unwrap_or(0);
491
492 let mut headers = Vec::new();
494 for (name, value) in response.headers().iter() {
495 if let Ok(v) = value.to_str() {
496 headers.push(HttpHeader::new(name.to_string(), v.to_string()));
497 }
498 }
499
500 if config.max_response_size > 0 && content_length > config.max_response_size {
502 return Err(HttpError::response_too_large(
503 config.max_response_size,
504 content_length,
505 ));
506 }
507
508 let mut body = Vec::new();
510 let limit = if config.max_response_size > 0 {
511 config.max_response_size as usize
512 } else {
513 usize::MAX
514 };
515 let mut body_reader = response.into_body();
516 let mut reader = body_reader.as_reader().take(limit as u64);
517 reader.read_to_end(&mut body).map_err(|e| HttpError::io_error(e.to_string().into()))?;
518
519 Ok(HttpResponse {
520 status_code,
521 body: U8Vec::from(body),
522 content_type,
523 content_length,
524 headers: HttpHeaderVec::from_vec(headers),
525 })
526}
527
528#[cfg(not(feature = "http"))]
530pub fn http_get_with_config(_url: &str, _config: &HttpRequestConfig) -> HttpResult<HttpResponse> {
534 Err(HttpError::other("http feature not enabled".into()))
535}
536
537#[cfg(feature = "http")]
545pub fn download_bytes(url: &str) -> HttpResult<U8Vec> {
546 download_bytes_with_config(url, &HttpRequestConfig::default())
547}
548
549#[cfg(not(feature = "http"))]
551pub fn download_bytes(_url: &str) -> HttpResult<U8Vec> {
555 Err(HttpError::other("http feature not enabled".into()))
556}
557
558#[cfg(feature = "http")]
567pub fn download_bytes_with_config(url: &str, config: &HttpRequestConfig) -> HttpResult<U8Vec> {
568 let response = http_get_with_config(url, config)?;
569
570 if response.status_code >= 400 {
572 return Err(HttpError::http_status(
573 response.status_code,
574 format!("HTTP error {}", response.status_code).into(),
575 ));
576 }
577
578 Ok(response.body)
579}
580
581#[cfg(not(feature = "http"))]
583pub fn download_bytes_with_config(_url: &str, _config: &HttpRequestConfig) -> HttpResult<U8Vec> {
587 Err(HttpError::other("http feature not enabled".into()))
588}
589
590#[cfg(feature = "http")]
598pub fn is_url_reachable(url: &str) -> bool {
599 const REACHABILITY_TIMEOUT_SECS: u64 = 10;
600 let agent = make_agent(REACHABILITY_TIMEOUT_SECS, false);
601 match agent.head(url).call() {
602 Ok(resp) => {
603 let code = resp.status().as_u16();
604 code >= 200 && code < 300
605 }
606 Err(_) => false,
607 }
608}
609
610#[cfg(not(feature = "http"))]
612#[must_use] pub const fn is_url_reachable(_url: &str) -> bool {
613 false
614}
615
616#[cfg(test)]
617mod tests {
618 use super::*;
619
620 #[test]
621 fn test_http_request_config_default() {
622 let config = HttpRequestConfig::default();
623 assert_eq!(config.timeout_secs, 30);
624 assert_eq!(config.max_response_size, 100 * 1024 * 1024);
625 assert!(!config.user_agent.as_str().is_empty());
626 }
627
628 #[test]
629 fn test_http_response_status_checks() {
630 let response = HttpResponse {
631 status_code: 200,
632 body: U8Vec::from(Vec::new()),
633 content_type: AzString::from(String::new()),
634 content_length: 0,
635 headers: HttpHeaderVec::from_const_slice(&[]),
636 };
637 assert!(response.is_success());
638 assert!(!response.is_redirect());
639 assert!(!response.is_client_error());
640 assert!(!response.is_server_error());
641 }
642
643 #[test]
644 fn test_http_error_constructors() {
645 let err = HttpError::http_status(404, "Not Found".into());
646 assert!(err.to_string().contains("404"));
647
648 let err2 = HttpError::response_too_large(100, 200);
649 assert!(err2.to_string().contains("200"));
650 }
651}
652
653#[cfg(test)]
654mod autotest_generated {
655 use super::*;
656
657 fn huge_ascii() -> String {
667 "A".repeat(256 * 1024)
668 }
669
670 const NASTY: &str = "\u{0}\r\n\t\"{}{{}}%s%n\u{7f}héllo·🦀·\u{202e}\u{feff}";
672
673 fn response_with_status(status_code: u16) -> HttpResponse {
674 HttpResponse {
675 status_code,
676 body: U8Vec::from(Vec::new()),
677 content_type: AzString::from("application/octet-stream"),
678 content_length: 0,
679 headers: HttpHeaderVec::from_const_slice(&[]),
680 }
681 }
682
683 fn response_with_body(body: Vec<u8>) -> HttpResponse {
684 HttpResponse {
685 status_code: 200,
686 body: U8Vec::from(body),
687 content_type: AzString::from("text/plain"),
688 content_length: 0,
689 headers: HttpHeaderVec::from_const_slice(&[]),
690 }
691 }
692
693 #[test]
698 fn http_error_string_constructors_store_payload_verbatim() {
699 for payload in ["", "http://example.com", NASTY, huge_ascii().as_str()] {
700 let s = AzString::from(payload);
701
702 assert_eq!(
703 HttpError::invalid_url(s.clone()),
704 HttpError::InvalidUrl(s.clone())
705 );
706 assert_eq!(
707 HttpError::connection_failed(s.clone()),
708 HttpError::ConnectionFailed(s.clone())
709 );
710 assert_eq!(
711 HttpError::tls_error(s.clone()),
712 HttpError::TlsError(s.clone())
713 );
714 assert_eq!(
715 HttpError::io_error(s.clone()),
716 HttpError::IoError(s.clone())
717 );
718 assert_eq!(HttpError::other(s.clone()), HttpError::Other(s.clone()));
719
720 match HttpError::invalid_url(s.clone()) {
723 HttpError::InvalidUrl(inner) => assert_eq!(inner.as_str(), payload),
724 other => panic!("wrong variant: {other:?}"),
725 }
726 }
727 }
728
729 #[test]
730 fn http_error_variants_are_not_conflated() {
731 let s = AzString::from("x");
732 assert_ne!(HttpError::invalid_url(s.clone()), HttpError::other(s.clone()));
733 assert_ne!(HttpError::tls_error(s.clone()), HttpError::io_error(s.clone()));
734 assert_ne!(HttpError::connection_failed(s.clone()), HttpError::Timeout);
735 }
736
737 #[test]
742 fn http_status_accepts_full_u16_range_without_clamping() {
743 for code in [0_u16, 1, 99, 100, 200, 299, 400, 599, 600, 999, u16::MAX] {
746 let err = HttpError::http_status(code, AzString::from("msg"));
747 match err {
748 HttpError::HttpStatus(ref e) => {
749 assert_eq!(e.status_code, code);
750 assert_eq!(e.message.as_str(), "msg");
751 }
752 ref other => panic!("wrong variant: {other:?}"),
753 }
754 assert!(err.to_string().contains(&code.to_string()));
756 }
757 }
758
759 #[test]
760 fn http_status_with_empty_and_huge_message() {
761 let empty = HttpError::http_status(u16::MAX, AzString::from(""));
762 assert_eq!(empty.to_string(), "HTTP 65535 - ");
763
764 let big = huge_ascii();
765 let huge = HttpError::http_status(0, AzString::from(big.as_str()));
766 assert_eq!(huge.to_string().len(), "HTTP 0 - ".len() + big.len());
767 }
768
769 #[test]
770 fn response_too_large_stores_both_sizes_at_u64_limits() {
771 for (max, actual) in [
774 (0_u64, 0_u64),
775 (0, u64::MAX),
776 (u64::MAX, 0),
777 (u64::MAX, u64::MAX),
778 (1, 1),
779 (100, 200),
780 (u64::MAX, u64::MAX - 1),
781 ] {
782 let err = HttpError::response_too_large(max, actual);
783 match err {
784 HttpError::ResponseTooLarge(ref e) => {
785 assert_eq!(e.max_size, max);
786 assert_eq!(e.actual_size, actual);
787 }
788 ref other => panic!("wrong variant: {other:?}"),
789 }
790 let msg = err.to_string();
791 assert!(msg.contains(&actual.to_string()));
792 assert!(msg.contains(&max.to_string()));
793 }
794 }
795
796 #[test]
801 fn display_is_non_empty_for_every_variant() {
802 let variants = [
803 HttpError::invalid_url(AzString::from("u")),
804 HttpError::connection_failed(AzString::from("c")),
805 HttpError::Timeout,
806 HttpError::tls_error(AzString::from("t")),
807 HttpError::http_status(500, AzString::from("s")),
808 HttpError::io_error(AzString::from("i")),
809 HttpError::response_too_large(1, 2),
810 HttpError::other(AzString::from("o")),
811 ];
812 for v in &variants {
813 let s = v.to_string();
814 assert!(!s.is_empty(), "empty Display for {v:?}");
815 }
816 assert_eq!(HttpError::Timeout.to_string(), "Request timed out");
817 }
818
819 #[test]
820 fn display_does_not_interpret_the_payload_as_a_format_string() {
821 let err = HttpError::other(AzString::from("{} {0} {{}} %s %n"));
824 assert_eq!(err.to_string(), "HTTP error: {} {0} {{}} %s %n");
825 }
826
827 #[test]
828 fn display_preserves_nul_newlines_and_unicode() {
829 let err = HttpError::invalid_url(AzString::from(NASTY));
830 let s = err.to_string();
831 assert!(s.starts_with("Invalid URL: "));
832 assert!(s.ends_with(NASTY));
833 assert!(s.contains('\u{0}'));
834 assert!(s.contains('🦀'));
835 }
836
837 #[test]
838 fn display_of_edge_numeric_values_does_not_panic() {
839 assert_eq!(
840 HttpError::http_status(u16::MAX, AzString::from("x")).to_string(),
841 "HTTP 65535 - x"
842 );
843 assert_eq!(
844 HttpError::response_too_large(u64::MAX, u64::MAX).to_string(),
845 format!(
846 "Response too large: {} bytes (max: {})",
847 u64::MAX,
848 u64::MAX
849 )
850 );
851 assert_eq!(
852 HttpError::response_too_large(0, 0).to_string(),
853 "Response too large: 0 bytes (max: 0)"
854 );
855 }
856
857 #[test]
862 fn http_header_new_keeps_fields_exactly_as_given() {
863 for (name, value) in [
864 ("", ""),
865 ("Content-Type", "text/html; charset=utf-8"),
866 (NASTY, NASTY),
867 (huge_ascii().as_str(), ""),
868 ("", huge_ascii().as_str()),
869 ] {
870 let h = HttpHeader::new(name, value);
871 assert_eq!(h.name.as_str(), name);
872 assert_eq!(h.value.as_str(), value);
873 }
874 }
875
876 #[test]
877 fn http_header_new_does_not_sanitize_crlf() {
878 let h = HttpHeader::new("X-Evil\r\nInjected: 1", "v\r\nSet-Cookie: pwned=1");
883 assert_eq!(h.name.as_str(), "X-Evil\r\nInjected: 1");
884 assert_eq!(h.value.as_str(), "v\r\nSet-Cookie: pwned=1");
885 }
886
887 #[test]
888 fn http_header_new_accepts_string_and_str() {
889 let from_str = HttpHeader::new("a", "b");
890 let from_string = HttpHeader::new(String::from("a"), String::from("b"));
891 assert_eq!(from_str, from_string);
892 }
893
894 #[test]
899 fn config_new_matches_default_and_documented_values() {
900 let a = HttpRequestConfig::new();
901 let b = HttpRequestConfig::default();
902 assert_eq!(a.timeout_secs, b.timeout_secs);
903 assert_eq!(a.max_response_size, b.max_response_size);
904 assert_eq!(a.user_agent.as_str(), b.user_agent.as_str());
905 assert_eq!(a.headers.len(), b.headers.len());
906 assert_eq!(
907 a.disable_tls_cert_verification,
908 b.disable_tls_cert_verification
909 );
910
911 assert_eq!(a.timeout_secs, 30);
912 assert_eq!(a.max_response_size, 100 * 1024 * 1024);
913 assert!(a.headers.is_empty());
914 assert!(!a.disable_tls_cert_verification);
916 }
917
918 #[test]
919 fn with_timeout_stores_extremes_verbatim() {
920 for secs in [0_u64, 1, 30, u64::MAX / 2, u64::MAX - 1, u64::MAX] {
921 let cfg = HttpRequestConfig::new().with_timeout(secs);
922 assert_eq!(cfg.timeout_secs, secs);
923 assert_eq!(cfg.max_response_size, 100 * 1024 * 1024);
925 assert!(cfg.headers.is_empty());
926 }
927 }
928
929 #[test]
930 fn with_max_size_stores_extremes_verbatim() {
931 for max in [0_u64, 1, u64::MAX] {
932 let cfg = HttpRequestConfig::new().with_max_size(max);
933 assert_eq!(cfg.max_response_size, max);
934 assert_eq!(cfg.timeout_secs, 30);
935 }
936 assert_eq!(HttpRequestConfig::new().with_max_size(0).max_response_size, 0);
938 }
939
940 #[test]
941 fn builder_setters_are_last_write_wins_and_independent() {
942 let cfg = HttpRequestConfig::new()
943 .with_timeout(1)
944 .with_timeout(u64::MAX)
945 .with_max_size(5)
946 .with_max_size(0)
947 .with_user_agent("first")
948 .with_user_agent("second");
949
950 assert_eq!(cfg.timeout_secs, u64::MAX);
951 assert_eq!(cfg.max_response_size, 0);
952 assert_eq!(cfg.user_agent.as_str(), "second");
953 }
954
955 #[test]
956 fn with_user_agent_accepts_empty_and_extreme_values() {
957 let empty = HttpRequestConfig::new().with_user_agent("");
958 assert!(empty.user_agent.as_str().is_empty());
960
961 let unicode = HttpRequestConfig::new().with_user_agent(NASTY);
962 assert_eq!(unicode.user_agent.as_str(), NASTY);
963
964 let big = huge_ascii();
965 let huge = HttpRequestConfig::new().with_user_agent(big.clone());
966 assert_eq!(huge.user_agent.as_str().len(), big.len());
967 }
968
969 #[test]
970 fn with_header_appends_in_order_and_keeps_duplicates() {
971 let mut cfg = HttpRequestConfig::new();
972 assert!(cfg.headers.is_empty());
973
974 for i in 0..100_usize {
975 cfg = cfg.with_header(format!("H{i}"), format!("v{i}"));
976 }
977 cfg = cfg.with_header("H0", "second-value");
979
980 assert_eq!(cfg.headers.len(), 101);
981 let slice = cfg.headers.as_slice();
982 for (i, h) in slice.iter().take(100).enumerate() {
983 assert_eq!(h.name.as_str(), format!("H{i}"));
984 assert_eq!(h.value.as_str(), format!("v{i}"));
985 }
986 assert_eq!(slice[100].name.as_str(), "H0");
987 assert_eq!(slice[100].value.as_str(), "second-value");
988 }
989
990 #[test]
991 fn with_header_accepts_empty_name_and_value() {
992 let cfg = HttpRequestConfig::new().with_header("", "");
993 assert_eq!(cfg.headers.len(), 1);
994 assert!(cfg.headers.as_slice()[0].name.as_str().is_empty());
995 assert!(cfg.headers.as_slice()[0].value.as_str().is_empty());
996 }
997
998 #[test]
999 fn cloning_a_config_gives_an_independent_header_vec() {
1000 let base = HttpRequestConfig::new().with_header("A", "1");
1003 let cloned = base.clone().with_header("B", "2");
1004
1005 assert_eq!(base.headers.len(), 1);
1006 assert_eq!(cloned.headers.len(), 2);
1007 assert_eq!(base.headers.as_slice()[0].name.as_str(), "A");
1008 assert_eq!(cloned.headers.as_slice()[0].name.as_str(), "A");
1009 assert_eq!(cloned.headers.as_slice()[1].name.as_str(), "B");
1010
1011 drop(cloned);
1012 assert_eq!(base.headers.as_slice()[0].value.as_str(), "1");
1014 }
1015
1016 #[test]
1021 fn status_predicates_at_class_boundaries() {
1022 let cases: &[(u16, bool, bool, bool, bool)] = &[
1023 (0, false, false, false, false),
1025 (100, false, false, false, false),
1026 (199, false, false, false, false),
1027 (200, true, false, false, false),
1028 (204, true, false, false, false),
1029 (299, true, false, false, false),
1030 (300, false, true, false, false),
1031 (399, false, true, false, false),
1032 (400, false, false, true, false),
1033 (499, false, false, true, false),
1034 (500, false, false, false, true),
1035 (599, false, false, false, true),
1036 (600, false, false, false, false),
1037 (999, false, false, false, false),
1038 (u16::MAX, false, false, false, false),
1039 ];
1040
1041 for &(status, success, redirect, client, server) in cases {
1042 let r = response_with_status(status);
1043 assert_eq!(r.is_success(), success, "is_success({status})");
1044 assert_eq!(r.is_redirect(), redirect, "is_redirect({status})");
1045 assert_eq!(r.is_client_error(), client, "is_client_error({status})");
1046 assert_eq!(r.is_server_error(), server, "is_server_error({status})");
1047 }
1048 }
1049
1050 #[test]
1051 fn status_predicates_are_mutually_exclusive_over_the_whole_u16_range() {
1052 let mut r = response_with_status(0);
1053 for status in 0..=u16::MAX {
1054 r.status_code = status;
1055 let hits = u8::from(r.is_success())
1056 + u8::from(r.is_redirect())
1057 + u8::from(r.is_client_error())
1058 + u8::from(r.is_server_error());
1059 assert!(hits <= 1, "status {status} matched {hits} classes");
1060 let expected = u8::from((200_u16..600_u16).contains(&status));
1062 assert_eq!(hits, expected, "status {status}");
1063 }
1064 }
1065
1066 #[test]
1067 fn status_predicates_ignore_body_and_headers() {
1068 let mut r = response_with_body(vec![0xFF; 1024]);
1069 r.status_code = 503;
1070 r.content_length = u64::MAX;
1071 r.headers = HttpHeaderVec::from_vec(vec![HttpHeader::new("X", "Y")]);
1072 assert!(r.is_server_error());
1073 assert!(!r.is_success());
1074 }
1075
1076 #[test]
1081 fn body_as_string_on_empty_body_is_some_empty_string() {
1082 let r = response_with_body(Vec::new());
1083 let s = r.body_as_string().expect("empty body is valid UTF-8");
1084 assert_eq!(s.as_str(), "");
1085 }
1086
1087 #[test]
1088 fn body_as_string_round_trips_valid_utf8() {
1089 for text in ["hello", NASTY, "🦀🦀🦀", "a\u{0}b"] {
1090 let r = response_with_body(text.as_bytes().to_vec());
1091 let s = r.body_as_string().expect("valid UTF-8 must decode");
1092 assert_eq!(s.as_str(), text);
1093 assert_eq!(s.as_str().len(), text.len());
1094 }
1095 }
1096
1097 #[test]
1098 fn body_as_string_returns_none_for_invalid_utf8() {
1099 let invalid: &[&[u8]] = &[
1100 &[0xFF], &[0x80], &[0xC3], &[0xE2, 0x82], &[0xED, 0xA0, 0x80], &[0xF4, 0x90, 0x80, 0x80], &[0xC0, 0x80], &[b'o', b'k', 0xFE, b'!'], ];
1109 for bytes in invalid {
1110 let r = response_with_body(bytes.to_vec());
1111 assert!(
1112 r.body_as_string().is_none(),
1113 "expected None for {bytes:02X?}"
1114 );
1115 }
1116 }
1117
1118 #[test]
1119 fn body_as_string_is_pure_and_repeatable() {
1120 let r = response_with_body(b"payload".to_vec());
1121 let first = r.body_as_string();
1122 let second = r.body_as_string();
1123 assert_eq!(first, second);
1124 assert_eq!(r.body.as_slice(), &b"payload"[..]);
1126 }
1127
1128 #[test]
1129 fn body_as_string_ignores_a_lying_content_length() {
1130 let mut r = response_with_body(b"1234".to_vec());
1133 r.content_length = u64::MAX;
1134 assert_eq!(r.body_as_string().expect("valid").as_str(), "1234");
1135
1136 r.content_length = 0;
1137 assert_eq!(r.body_as_string().expect("valid").as_str(), "1234");
1138 }
1139
1140 #[test]
1141 fn body_as_string_handles_a_large_body() {
1142 let big = huge_ascii();
1143 let r = response_with_body(big.clone().into_bytes());
1144 let s = r.body_as_string().expect("ASCII is valid UTF-8");
1145 assert_eq!(s.as_str().len(), big.len());
1146 }
1147
1148 #[test]
1153 fn result_http_response_round_trips_through_the_ffi_enum() {
1154 let ok: Result<HttpResponse, HttpError> = Ok(response_with_status(200));
1155 let ffi: ResultHttpResponseHttpError = ok.clone().into();
1156 assert!(ffi.is_ok());
1157 assert!(!ffi.is_err());
1158 assert_eq!(ffi.into_result(), ok);
1159
1160 let err: Result<HttpResponse, HttpError> =
1161 Err(HttpError::http_status(u16::MAX, AzString::from(NASTY)));
1162 let ffi: ResultHttpResponseHttpError = err.clone().into();
1163 assert!(ffi.is_err());
1164 assert!(!ffi.is_ok());
1165 assert_eq!(ffi.into_result(), err);
1166 }
1167
1168 #[test]
1169 fn result_u8vec_round_trips_through_the_ffi_enum() {
1170 let ok: Result<U8Vec, HttpError> = Ok(U8Vec::from(vec![0u8, 0xFF, 0x7F]));
1171 let ffi: ResultU8VecHttpError = ok.clone().into();
1172 assert!(ffi.is_ok());
1173 assert_eq!(ffi.into_result(), ok);
1174
1175 let err: Result<U8Vec, HttpError> = Err(HttpError::response_too_large(0, u64::MAX));
1176 let ffi: ResultU8VecHttpError = err.clone().into();
1177 assert!(ffi.is_err());
1178 assert_eq!(ffi.into_result(), err);
1179
1180 let empty: ResultU8VecHttpError = Ok(U8Vec::from(Vec::new())).into();
1182 assert!(empty.is_ok());
1183 assert_eq!(empty.as_result().map(|v| v.len()), Ok(0));
1184 }
1185
1186 #[test]
1187 fn ffi_result_as_result_agrees_with_is_ok() {
1188 let ffi: ResultHttpResponseHttpError = Ok(response_with_status(404)).into();
1189 assert_eq!(ffi.is_ok(), ffi.as_result().is_ok());
1190 assert_eq!(
1191 ffi.as_result().map(HttpResponse::is_client_error),
1192 Ok(true)
1193 );
1194 }
1195
1196 #[cfg(not(feature = "http"))]
1201 #[test]
1202 fn stub_free_functions_return_err_for_any_url() {
1203 for url in ["", "https://example.com", NASTY, huge_ascii().as_str()] {
1204 let cfg = HttpRequestConfig::new();
1205 assert!(matches!(http_get(url), Err(HttpError::Other(_))));
1206 assert!(matches!(
1207 http_get_with_config(url, &cfg),
1208 Err(HttpError::Other(_))
1209 ));
1210 assert!(matches!(download_bytes(url), Err(HttpError::Other(_))));
1211 assert!(matches!(
1212 download_bytes_with_config(url, &cfg),
1213 Err(HttpError::Other(_))
1214 ));
1215 }
1216 }
1217
1218 #[cfg(not(feature = "http"))]
1219 #[test]
1220 fn stub_is_url_reachable_is_always_false() {
1221 for url in ["", "https://example.com", NASTY, huge_ascii().as_str()] {
1223 assert!(!is_url_reachable(url));
1224 assert!(!HttpRequestConfig::is_url_reachable(AzString::from(url)));
1225 }
1226 }
1227
1228 #[cfg(not(feature = "http"))]
1229 #[test]
1230 fn stub_config_methods_return_err_results() {
1231 let cfg = HttpRequestConfig::new().with_timeout(u64::MAX).with_max_size(0);
1232 for url in ["", "https://example.com", NASTY] {
1233 let u = AzString::from(url);
1234 assert!(HttpRequestConfig::http_get_default(u.clone()).is_err());
1235 assert!(cfg.http_get(u.clone()).is_err());
1236 assert!(HttpRequestConfig::download_bytes_default(u.clone()).is_err());
1237 assert!(cfg.download_bytes(u.clone()).is_err());
1238 }
1239 }
1240
1241 #[cfg(feature = "http")]
1246 #[test]
1247 fn make_agent_builds_at_timeout_extremes() {
1248 for secs in [0_u64, 1, 30, u64::MAX] {
1251 for disable_tls in [false, true] {
1252 let _agent = make_agent(secs, disable_tls);
1253 }
1254 }
1255 }
1256
1257 #[cfg(feature = "http")]
1258 #[test]
1259 fn malformed_urls_are_rejected_without_touching_the_network() {
1260 let cfg = HttpRequestConfig::new().with_timeout(1);
1262 for url in ["", "not a url", "://no-scheme", "ht tp://spaces"] {
1263 assert!(http_get(url).is_err(), "expected Err for {url:?}");
1264 assert!(
1265 http_get_with_config(url, &cfg).is_err(),
1266 "expected Err for {url:?}"
1267 );
1268 assert!(download_bytes(url).is_err(), "expected Err for {url:?}");
1269 assert!(!is_url_reachable(url), "expected false for {url:?}");
1270 }
1271 }
1272
1273 #[cfg(feature = "http")]
1274 #[test]
1275 fn malformed_urls_are_rejected_through_the_ffi_wrappers() {
1276 let cfg = HttpRequestConfig::new().with_timeout(1);
1277 for url in ["", "not a url"] {
1278 let u = AzString::from(url);
1279 assert!(HttpRequestConfig::http_get_default(u.clone()).is_err());
1280 assert!(cfg.http_get(u.clone()).is_err());
1281 assert!(HttpRequestConfig::download_bytes_default(u.clone()).is_err());
1282 assert!(cfg.download_bytes(u.clone()).is_err());
1283 assert!(!HttpRequestConfig::is_url_reachable(u.clone()));
1284 }
1285 }
1286}