Skip to main content

azul_layout/
http.rs

1//! Simple HTTP client module for downloading resources (language packs, etc.)
2//!
3//! Uses ureq for simple, blocking HTTP requests. Designed to be exposed via C API.
4
5use 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// ============================================================================
13// Error types (C-compatible, single field per variant)
14// ============================================================================
15
16/// HTTP status error (4xx, 5xx responses)
17#[derive(Debug, Clone, PartialEq, Eq)]
18#[repr(C)]
19pub struct HttpStatusError {
20    /// HTTP status code
21    pub status_code: u16,
22    /// Status message
23    pub message: AzString,
24}
25
26/// Response too large error
27#[derive(Copy, Debug, Clone, PartialEq, Eq)]
28#[repr(C)]
29pub struct HttpResponseTooLargeError {
30    /// Maximum allowed size in bytes
31    pub max_size: u64,
32    /// Actual size in bytes
33    pub actual_size: u64,
34}
35
36/// HTTP error types (C-compatible)
37#[derive(Debug, Clone, PartialEq, Eq)]
38#[repr(C, u8)]
39pub enum HttpError {
40    /// Invalid URL format
41    InvalidUrl(AzString),
42    /// Connection failed
43    ConnectionFailed(AzString),
44    /// Request timed out
45    Timeout,
46    /// TLS/SSL error
47    TlsError(AzString),
48    /// HTTP error response (4xx, 5xx)
49    HttpStatus(HttpStatusError),
50    /// I/O error during request
51    IoError(AzString),
52    /// Response body too large
53    ResponseTooLarge(HttpResponseTooLargeError),
54    /// Other error
55    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
114/// Result type for HTTP operations
115pub type HttpResult<T> = Result<T, HttpError>;
116
117// FFI-safe Result types for HTTP operations
118use azul_css::{impl_result, impl_result_inner};
119
120// Forward declaration - actual impl_result! calls are after HttpResponse definition
121
122// ============================================================================
123// Request configuration (C-compatible)
124// ============================================================================
125
126/// HTTP header key-value pair
127#[derive(Debug, Clone, PartialEq, Eq)]
128#[repr(C)]
129pub struct HttpHeader {
130    /// Header name
131    pub name: AzString,
132    /// Header value
133    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/// HTTP request configuration (C-compatible)
153#[derive(Debug, Clone)]
154#[repr(C)]
155pub struct HttpRequestConfig {
156    /// Request timeout in seconds (default: 30)
157    pub timeout_secs: u64,
158    /// Maximum response size in bytes (default: 100MB, 0 = unlimited)
159    pub max_response_size: u64,
160    /// User-Agent header value
161    pub user_agent: AzString,
162    /// Additional headers
163    pub headers: HttpHeaderVec,
164    /// Disable TLS certificate verification (default: false).
165    /// WARNING: This makes HTTPS connections vulnerable to MITM attacks.
166    /// Use only for testing or when connecting to servers with self-signed
167    /// or cross-signed certificates not in the Mozilla root store.
168    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, // 100 MB
176            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    /// Create a new config with default values
185    #[must_use] pub fn new() -> Self {
186        Self::default()
187    }
188    
189    /// Set timeout in seconds
190    #[must_use] pub const fn with_timeout(mut self, secs: u64) -> Self {
191        self.timeout_secs = secs;
192        self
193    }
194    
195    /// Set maximum response size (0 = unlimited)
196    #[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    /// Set User-Agent header
202    #[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    /// Add a header
209    #[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    /// Simple HTTP GET request with default configuration
216    ///
217    /// # Arguments
218    /// * `url` - The URL to request
219    ///
220    /// # Returns
221    /// * `ResultHttpResponseHttpError` - The response or an error
222    #[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    /// Stub: `http` feature disabled.
229    #[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    /// HTTP GET request using this configuration
235    /// 
236    /// # Arguments
237    /// * `url` - The URL to request
238    /// 
239    /// # Returns
240    /// * `ResultHttpResponseHttpError` - The response or an error
241    #[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    /// Stub: `http` feature disabled.
247    #[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    /// Download URL to bytes with default configuration
253    /// 
254    /// # Arguments
255    /// * `url` - The URL to download
256    /// 
257    /// # Returns
258    /// * `ResultU8VecHttpError` - The response body or an error
259    #[cfg(feature = "http")]
260    pub fn download_bytes_default(url: AzString) -> ResultU8VecHttpError {
261        download_bytes(url.as_str()).into()
262    }
263
264    /// Stub: `http` feature disabled.
265    #[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    /// Download URL to bytes using this configuration
271    /// 
272    /// # Arguments
273    /// * `url` - The URL to download
274    /// 
275    /// # Returns
276    /// * `ResultU8VecHttpError` - The response body or an error
277    #[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    /// Stub: `http` feature disabled.
283    #[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    /// Check if a URL is reachable (HEAD request)
289    /// 
290    /// # Arguments
291    /// * `url` - The URL to check
292    /// 
293    /// # Returns
294    /// * `bool` - True if reachable (2xx status)
295    #[cfg(feature = "http")]
296    pub fn is_url_reachable(url: AzString) -> bool {
297        is_url_reachable(url.as_str())
298    }
299
300    /// Stub: `http` feature disabled.
301    #[cfg(not(feature = "http"))]
302    #[must_use] pub fn is_url_reachable(_url: AzString) -> bool {
303        false
304    }
305}
306
307// ============================================================================
308// Response (C-compatible)
309// ============================================================================
310
311/// HTTP response with status code, headers, and body
312#[derive(Debug, Clone, PartialEq)]
313#[repr(C)]
314pub struct HttpResponse {
315    /// HTTP status code (200, 404, etc.)
316    pub status_code: u16,
317    /// Response body as bytes
318    pub body: U8Vec,
319    /// Content-Type header value
320    pub content_type: AzString,
321    /// Content-Length header value (0 if unknown)
322    pub content_length: u64,
323    /// Response headers
324    pub headers: HttpHeaderVec,
325}
326
327impl HttpResponse {
328    /// Check if the response was successful (2xx status)
329    #[must_use] pub const fn is_success(&self) -> bool {
330        self.status_code >= 200 && self.status_code < 300
331    }
332    
333    /// Check if the response is a redirect (3xx status)
334    #[must_use] pub const fn is_redirect(&self) -> bool {
335        self.status_code >= 300 && self.status_code < 400
336    }
337    
338    /// Check if the response is a client error (4xx status)
339    #[must_use] pub const fn is_client_error(&self) -> bool {
340        self.status_code >= 400 && self.status_code < 500
341    }
342    
343    /// Check if the response is a server error (5xx status)
344    #[must_use] pub const fn is_server_error(&self) -> bool {
345        self.status_code >= 500 && self.status_code < 600
346    }
347    
348    /// Try to convert the body to a UTF-8 string
349    #[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
356// FFI-safe Result types for HTTP operations (must be after HttpResponse definition)
357impl_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/// Simple HTTP GET request
376///
377/// # Arguments
378/// * `url` - The URL to request
379///
380/// # Returns
381/// * `HttpResult<HttpResponse>` - The response or an error
382#[cfg(feature = "http")]
383pub fn http_get(url: &str) -> HttpResult<HttpResponse> {
384    http_get_with_config(url, &HttpRequestConfig::default())
385}
386
387/// Stub: `http` feature disabled.
388#[cfg(not(feature = "http"))]
389/// # Errors
390///
391/// Returns an `HttpError` if the request fails (network/status error, or the networking feature is disabled).
392pub fn http_get(_url: &str) -> HttpResult<HttpResponse> {
393    Err(HttpError::other("http feature not enabled".into()))
394}
395
396/// HTTP GET request with custom configuration
397/// 
398/// # Arguments
399/// * `url` - The URL to request
400/// * `config` - Request configuration
401/// 
402/// # Returns
403/// * `HttpResult<HttpResponse>` - The response or an error
404#[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    // Build the request
437    let mut request = agent.get(url);
438
439    // Add user agent
440    if !config.user_agent.as_str().is_empty() {
441        request = request.header("User-Agent", config.user_agent.as_str());
442    }
443
444    // Add custom headers
445    for header in config.headers.as_slice() {
446        request = request.header(header.name.as_str(), header.value.as_str());
447    }
448
449    // Execute request — map transport errors to specific HttpError variants
450    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            // Catch-all for feature-gated variants (Rustls, Pem, etc.)
469            _ => {
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    // Collect response headers
493    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    // Check response size limit
501    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    // Read body with size limit
509    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/// Stub: `http` feature disabled.
529#[cfg(not(feature = "http"))]
530/// # Errors
531///
532/// Returns an `HttpError` if the request fails (network/status error, or the networking feature is disabled).
533pub fn http_get_with_config(_url: &str, _config: &HttpRequestConfig) -> HttpResult<HttpResponse> {
534    Err(HttpError::other("http feature not enabled".into()))
535}
536
537/// Download a URL to bytes (convenience wrapper with default config)
538/// 
539/// # Arguments
540/// * `url` - The URL to download
541/// 
542/// # Returns
543/// * `HttpResult<U8Vec>` - The response body or an error
544#[cfg(feature = "http")]
545pub fn download_bytes(url: &str) -> HttpResult<U8Vec> {
546    download_bytes_with_config(url, &HttpRequestConfig::default())
547}
548
549/// Stub: `http` feature disabled.
550#[cfg(not(feature = "http"))]
551/// # Errors
552///
553/// Returns an `HttpError` if the request fails (network/status error, or the networking feature is disabled).
554pub fn download_bytes(_url: &str) -> HttpResult<U8Vec> {
555    Err(HttpError::other("http feature not enabled".into()))
556}
557
558/// Download a URL to bytes with custom configuration
559/// 
560/// # Arguments
561/// * `url` - The URL to download
562/// * `config` - Request configuration (timeout, max size, etc.)
563/// 
564/// # Returns
565/// * `HttpResult<U8Vec>` - The response body or an error
566#[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    // Check for successful status
571    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/// Stub: `http` feature disabled.
582#[cfg(not(feature = "http"))]
583/// # Errors
584///
585/// Returns an `HttpError` if the request fails (network/status error, or the networking feature is disabled).
586pub fn download_bytes_with_config(_url: &str, _config: &HttpRequestConfig) -> HttpResult<U8Vec> {
587    Err(HttpError::other("http feature not enabled".into()))
588}
589
590/// Check if a URL is reachable (HEAD request)
591/// 
592/// # Arguments
593/// * `url` - The URL to check
594/// 
595/// # Returns
596/// * `bool` - True if reachable (2xx status)
597#[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/// Stub: `http` feature disabled.
611#[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}