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

            
5
use alloc::string::String;
6
use alloc::vec::Vec;
7
use alloc::format;
8
use core::fmt;
9

            
10
use 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)]
18
#[repr(C)]
19
pub 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(Debug, Clone, PartialEq)]
28
#[repr(C)]
29
pub 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)]
38
#[repr(C, u8)]
39
pub 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

            
58
impl HttpError {
59
    pub fn invalid_url(url: AzString) -> Self {
60
        Self::InvalidUrl(url)
61
    }
62
    
63
    pub fn connection_failed(msg: AzString) -> Self {
64
        Self::ConnectionFailed(msg)
65
    }
66
    
67
    pub fn tls_error(msg: AzString) -> Self {
68
        Self::TlsError(msg)
69
    }
70
    
71
1
    pub fn http_status(status_code: u16, message: AzString) -> Self {
72
1
        Self::HttpStatus(HttpStatusError {
73
1
            status_code,
74
1
            message,
75
1
        })
76
1
    }
77
    
78
    pub fn io_error(msg: AzString) -> Self {
79
        Self::IoError(msg)
80
    }
81
    
82
1
    pub fn response_too_large(max_size: u64, actual_size: u64) -> Self {
83
1
        Self::ResponseTooLarge(HttpResponseTooLargeError {
84
1
            max_size,
85
1
            actual_size,
86
1
        })
87
1
    }
88
    
89
    pub fn other(msg: AzString) -> Self {
90
        Self::Other(msg)
91
    }
92
}
93

            
94
impl fmt::Display for HttpError {
95
2
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
96
2
        match self {
97
            HttpError::InvalidUrl(url) => write!(f, "Invalid URL: {}", url.as_str()),
98
            HttpError::ConnectionFailed(msg) => write!(f, "Connection failed: {}", msg.as_str()),
99
            HttpError::Timeout => write!(f, "Request timed out"),
100
            HttpError::TlsError(msg) => write!(f, "TLS error: {}", msg.as_str()),
101
1
            HttpError::HttpStatus(e) => write!(f, "HTTP {} - {}", e.status_code, e.message.as_str()),
102
            HttpError::IoError(msg) => write!(f, "I/O error: {}", msg.as_str()),
103
1
            HttpError::ResponseTooLarge(e) => {
104
1
                write!(f, "Response too large: {} bytes (max: {})", e.actual_size, e.max_size)
105
            }
106
            HttpError::Other(msg) => write!(f, "HTTP error: {}", msg.as_str()),
107
        }
108
2
    }
109
}
110

            
111
#[cfg(feature = "std")]
112
impl std::error::Error for HttpError {}
113

            
114
/// Result type for HTTP operations
115
pub type HttpResult<T> = Result<T, HttpError>;
116

            
117
// FFI-safe Result types for HTTP operations
118
use 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)]
128
#[repr(C)]
129
pub struct HttpHeader {
130
    /// Header name
131
    pub name: AzString,
132
    /// Header value
133
    pub value: AzString,
134
}
135

            
136
impl 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

            
145
impl_option!(HttpHeader, OptionHttpHeader, copy = false, [Debug, Clone, PartialEq]);
146
impl_vec!(HttpHeader, HttpHeaderVec, HttpHeaderVecDestructor, HttpHeaderVecDestructorType, HttpHeaderVecSlice, OptionHttpHeader);
147
impl_vec_clone!(HttpHeader, HttpHeaderVec, HttpHeaderVecDestructor);
148
impl_vec_debug!(HttpHeader, HttpHeaderVec);
149
impl_vec_partialeq!(HttpHeader, HttpHeaderVec);
150
impl_vec_mut!(HttpHeader, HttpHeaderVec);
151

            
152
/// HTTP request configuration (C-compatible)
153
#[derive(Debug, Clone)]
154
#[repr(C)]
155
pub 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

            
171
impl Default for HttpRequestConfig {
172
1
    fn default() -> Self {
173
1
        Self {
174
1
            timeout_secs: 30,
175
1
            max_response_size: 100 * 1024 * 1024, // 100 MB
176
1
            user_agent: AzString::from("azul-http/1.0".to_string()),
177
1
            headers: HttpHeaderVec::from_const_slice(&[]),
178
1
            disable_tls_cert_verification: false,
179
1
        }
180
1
    }
181
}
182

            
183
impl HttpRequestConfig {
184
    /// Create a new config with default values
185
    pub fn new() -> Self {
186
        Self::default()
187
    }
188
    
189
    /// Set timeout in seconds
190
    pub 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
    pub 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
    pub fn with_user_agent(mut self, ua: impl Into<String>) -> Self {
203
        self.user_agent = AzString::from(ua.into());
204
        self
205
    }
206
    
207
    /// Add a header
208
    pub fn with_header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
209
        self.headers.push(HttpHeader::new(name, value));
210
        self
211
    }
212

            
213
    /// Simple HTTP GET request with default configuration
214
    ///
215
    /// # Arguments
216
    /// * `url` - The URL to request
217
    ///
218
    /// # Returns
219
    /// * `ResultHttpResponseHttpError` - The response or an error
220
    #[cfg(feature = "http")]
221
    pub fn http_get_default(url: AzString) -> ResultHttpResponseHttpError {
222
        let config = HttpRequestConfig::default();
223
        http_get_with_config(url.as_str(), &config).into()
224
    }
225

            
226
    /// Stub: `http` feature disabled.
227
    #[cfg(not(feature = "http"))]
228
    pub fn http_get_default(_url: AzString) -> ResultHttpResponseHttpError {
229
        ResultHttpResponseHttpError::Err(HttpError::other("http feature not enabled".into()))
230
    }
231

            
232
    /// HTTP GET request using this configuration
233
    /// 
234
    /// # Arguments
235
    /// * `url` - The URL to request
236
    /// 
237
    /// # Returns
238
    /// * `ResultHttpResponseHttpError` - The response or an error
239
    #[cfg(feature = "http")]
240
    pub fn http_get(&self, url: AzString) -> ResultHttpResponseHttpError {
241
        http_get_with_config(url.as_str(), self).into()
242
    }
243

            
244
    /// Stub: `http` feature disabled.
245
    #[cfg(not(feature = "http"))]
246
    pub fn http_get(&self, _url: AzString) -> ResultHttpResponseHttpError {
247
        ResultHttpResponseHttpError::Err(HttpError::other("http feature not enabled".into()))
248
    }
249

            
250
    /// Download URL to bytes with default configuration
251
    /// 
252
    /// # Arguments
253
    /// * `url` - The URL to download
254
    /// 
255
    /// # Returns
256
    /// * `ResultU8VecHttpError` - The response body or an error
257
    #[cfg(feature = "http")]
258
    pub fn download_bytes_default(url: AzString) -> ResultU8VecHttpError {
259
        download_bytes(url.as_str()).into()
260
    }
261

            
262
    /// Stub: `http` feature disabled.
263
    #[cfg(not(feature = "http"))]
264
    pub fn download_bytes_default(_url: AzString) -> ResultU8VecHttpError {
265
        ResultU8VecHttpError::Err(HttpError::other("http feature not enabled".into()))
266
    }
267

            
268
    /// Download URL to bytes using this configuration
269
    /// 
270
    /// # Arguments
271
    /// * `url` - The URL to download
272
    /// 
273
    /// # Returns
274
    /// * `ResultU8VecHttpError` - The response body or an error
275
    #[cfg(feature = "http")]
276
    pub fn download_bytes(&self, url: AzString) -> ResultU8VecHttpError {
277
        download_bytes_with_config(url.as_str(), self).into()
278
    }
279

            
280
    /// Stub: `http` feature disabled.
281
    #[cfg(not(feature = "http"))]
282
    pub fn download_bytes(&self, _url: AzString) -> ResultU8VecHttpError {
283
        ResultU8VecHttpError::Err(HttpError::other("http feature not enabled".into()))
284
    }
285

            
286
    /// Check if a URL is reachable (HEAD request)
287
    /// 
288
    /// # Arguments
289
    /// * `url` - The URL to check
290
    /// 
291
    /// # Returns
292
    /// * `bool` - True if reachable (2xx status)
293
    #[cfg(feature = "http")]
294
    pub fn is_url_reachable(url: AzString) -> bool {
295
        is_url_reachable(url.as_str())
296
    }
297

            
298
    /// Stub: `http` feature disabled.
299
    #[cfg(not(feature = "http"))]
300
    pub fn is_url_reachable(_url: AzString) -> bool {
301
        false
302
    }
303
}
304

            
305
// ============================================================================
306
// Response (C-compatible)
307
// ============================================================================
308

            
309
/// HTTP response with status code, headers, and body
310
#[derive(Debug, Clone, PartialEq)]
311
#[repr(C)]
312
pub struct HttpResponse {
313
    /// HTTP status code (200, 404, etc.)
314
    pub status_code: u16,
315
    /// Response body as bytes
316
    pub body: U8Vec,
317
    /// Content-Type header value
318
    pub content_type: AzString,
319
    /// Content-Length header value (0 if unknown)
320
    pub content_length: u64,
321
    /// Response headers
322
    pub headers: HttpHeaderVec,
323
}
324

            
325
impl HttpResponse {
326
    /// Check if the response was successful (2xx status)
327
1
    pub fn is_success(&self) -> bool {
328
1
        self.status_code >= 200 && self.status_code < 300
329
1
    }
330
    
331
    /// Check if the response is a redirect (3xx status)
332
1
    pub fn is_redirect(&self) -> bool {
333
1
        self.status_code >= 300 && self.status_code < 400
334
1
    }
335
    
336
    /// Check if the response is a client error (4xx status)
337
1
    pub fn is_client_error(&self) -> bool {
338
1
        self.status_code >= 400 && self.status_code < 500
339
1
    }
340
    
341
    /// Check if the response is a server error (5xx status)
342
1
    pub fn is_server_error(&self) -> bool {
343
1
        self.status_code >= 500 && self.status_code < 600
344
1
    }
345
    
346
    /// Try to convert the body to a UTF-8 string
347
    pub fn body_as_string(&self) -> Option<AzString> {
348
        core::str::from_utf8(self.body.as_slice())
349
            .ok()
350
            .map(|s| AzString::from(s.to_string()))
351
    }
352
}
353

            
354
// FFI-safe Result types for HTTP operations (must be after HttpResponse definition)
355
impl_result!(
356
    HttpResponse,
357
    HttpError,
358
    ResultHttpResponseHttpError,
359
    copy = false,
360
    clone = false,
361
    [Debug, Clone, PartialEq]
362
);
363

            
364
impl_result!(
365
    U8Vec,
366
    HttpError,
367
    ResultU8VecHttpError,
368
    copy = false,
369
    clone = false,
370
    [Debug, Clone, PartialEq]
371
);
372

            
373
/// Simple HTTP GET request
374
///
375
/// # Arguments
376
/// * `url` - The URL to request
377
///
378
/// # Returns
379
/// * `HttpResult<HttpResponse>` - The response or an error
380
#[cfg(feature = "http")]
381
pub fn http_get(url: &str) -> HttpResult<HttpResponse> {
382
    http_get_with_config(url, &HttpRequestConfig::default())
383
}
384

            
385
/// Stub: `http` feature disabled.
386
#[cfg(not(feature = "http"))]
387
pub fn http_get(_url: &str) -> HttpResult<HttpResponse> {
388
    Err(HttpError::other("http feature not enabled".into()))
389
}
390

            
391
/// HTTP GET request with TLS verification disabled
392
#[cfg(feature = "http")]
393
pub fn http_get_no_verify(url: &str) -> HttpResult<HttpResponse> {
394
    let mut config = HttpRequestConfig::default();
395
    config.disable_tls_cert_verification = true;
396
    http_get_with_config(url, &config)
397
}
398

            
399
/// Stub: `http` feature disabled.
400
#[cfg(not(feature = "http"))]
401
pub fn http_get_no_verify(_url: &str) -> HttpResult<HttpResponse> {
402
    Err(HttpError::other("http feature not enabled".into()))
403
}
404

            
405
/// HTTP GET request with custom configuration
406
/// 
407
/// # Arguments
408
/// * `url` - The URL to request
409
/// * `config` - Request configuration
410
/// 
411
/// # Returns
412
/// * `HttpResult<HttpResponse>` - The response or an error
413
#[cfg(feature = "http")]
414
fn make_agent(timeout_secs: u64, disable_tls_cert_verification: bool) -> ureq::Agent {
415
    use std::time::Duration;
416

            
417
    let mut tls_builder = ureq::tls::TlsConfig::builder()
418
        .provider(ureq::tls::TlsProvider::Rustls)
419
        .unversioned_rustls_crypto_provider(
420
            std::sync::Arc::new(rustls_rustcrypto::provider())
421
        );
422

            
423
    if disable_tls_cert_verification {
424
        tls_builder = tls_builder.disable_verification(true);
425
    } else {
426
        tls_builder = tls_builder.root_certs(ureq::tls::RootCerts::WebPki);
427
    }
428

            
429
    let tls_config = tls_builder.build();
430

            
431
    ureq::Agent::config_builder()
432
        .tls_config(tls_config)
433
        .timeout_global(Some(Duration::from_secs(timeout_secs)))
434
        .http_status_as_error(false)
435
        .build()
436
        .new_agent()
437
}
438

            
439
#[cfg(feature = "http")]
440
pub fn http_get_with_config(url: &str, config: &HttpRequestConfig) -> HttpResult<HttpResponse> {
441
    use std::io::Read;
442

            
443
    let agent = make_agent(config.timeout_secs, config.disable_tls_cert_verification);
444

            
445
    // Build the request
446
    let mut request = agent.get(url);
447

            
448
    // Add user agent
449
    if !config.user_agent.as_str().is_empty() {
450
        request = request.header("User-Agent", config.user_agent.as_str());
451
    }
452

            
453
    // Add custom headers
454
    for header in config.headers.as_slice() {
455
        request = request.header(header.name.as_str(), header.value.as_str());
456
    }
457

            
458
    // Execute request — map transport errors to specific HttpError variants
459
    let response = request.call().map_err(|e| {
460
        match &e {
461
            ureq::Error::Timeout(_) => HttpError::Timeout,
462
            ureq::Error::HostNotFound => HttpError::connection_failed(
463
                format!("DNS resolution failed for {}", url).into(),
464
            ),
465
            ureq::Error::ConnectionFailed => HttpError::connection_failed(
466
                format!("Connection failed: {}", url).into(),
467
            ),
468
            ureq::Error::Io(io_err) => HttpError::io_error(
469
                format!("{}", io_err).into(),
470
            ),
471
            ureq::Error::BadUri(msg) => HttpError::invalid_url(
472
                format!("{}: {}", url, msg).into(),
473
            ),
474
            ureq::Error::Tls(msg) => HttpError::tls_error(
475
                format!("TLS error: {}", msg).into(),
476
            ),
477
            ureq::Error::StatusCode(code) => HttpError::http_status(
478
                *code, format!("HTTP {}", code).into(),
479
            ),
480
            // Catch-all for feature-gated variants (Rustls, Pem, etc.)
481
            _ => {
482
                let msg = e.to_string();
483
                if msg.starts_with("rustls:") || msg.contains("TLS") || msg.contains("certificate") {
484
                    HttpError::tls_error(msg.into())
485
                } else {
486
                    HttpError::other(msg.into())
487
                }
488
            }
489
        }
490
    })?;
491

            
492
    let status_code = response.status().as_u16();
493
    let content_type = AzString::from(
494
        response.headers().get("Content-Type")
495
            .and_then(|v| v.to_str().ok())
496
            .unwrap_or("application/octet-stream")
497
            .to_string()
498
    );
499
    let content_length = response.headers().get("Content-Length")
500
        .and_then(|v| v.to_str().ok())
501
        .and_then(|s| s.parse::<u64>().ok())
502
        .unwrap_or(0);
503

            
504
    // Collect response headers
505
    let mut headers = Vec::new();
506
    for (name, value) in response.headers().iter() {
507
        if let Ok(v) = value.to_str() {
508
            headers.push(HttpHeader::new(name.to_string(), v.to_string()));
509
        }
510
    }
511

            
512
    // Check response size limit
513
    if config.max_response_size > 0 && content_length > config.max_response_size {
514
        return Err(HttpError::response_too_large(
515
            config.max_response_size,
516
            content_length,
517
        ));
518
    }
519

            
520
    // Read body with size limit
521
    let mut body = Vec::new();
522
    let limit = if config.max_response_size > 0 {
523
        config.max_response_size as usize
524
    } else {
525
        usize::MAX
526
    };
527
    let mut body_reader = response.into_body();
528
    let mut reader = body_reader.as_reader().take(limit as u64);
529
    reader.read_to_end(&mut body).map_err(|e| HttpError::io_error(e.to_string().into()))?;
530

            
531
    Ok(HttpResponse {
532
        status_code,
533
        body: U8Vec::from(body),
534
        content_type,
535
        content_length,
536
        headers: HttpHeaderVec::from_vec(headers),
537
    })
538
}
539

            
540
/// Stub: `http` feature disabled.
541
#[cfg(not(feature = "http"))]
542
pub fn http_get_with_config(_url: &str, _config: &HttpRequestConfig) -> HttpResult<HttpResponse> {
543
    Err(HttpError::other("http feature not enabled".into()))
544
}
545

            
546
/// Download a URL to bytes (convenience wrapper with default config)
547
/// 
548
/// # Arguments
549
/// * `url` - The URL to download
550
/// 
551
/// # Returns
552
/// * `HttpResult<U8Vec>` - The response body or an error
553
#[cfg(feature = "http")]
554
pub fn download_bytes(url: &str) -> HttpResult<U8Vec> {
555
    download_bytes_with_config(url, &HttpRequestConfig::default())
556
}
557

            
558
/// Stub: `http` feature disabled.
559
#[cfg(not(feature = "http"))]
560
pub fn download_bytes(_url: &str) -> HttpResult<U8Vec> {
561
    Err(HttpError::other("http feature not enabled".into()))
562
}
563

            
564
/// Download a URL to bytes with custom configuration
565
/// 
566
/// # Arguments
567
/// * `url` - The URL to download
568
/// * `config` - Request configuration (timeout, max size, etc.)
569
/// 
570
/// # Returns
571
/// * `HttpResult<U8Vec>` - The response body or an error
572
#[cfg(feature = "http")]
573
pub fn download_bytes_with_config(url: &str, config: &HttpRequestConfig) -> HttpResult<U8Vec> {
574
    let response = http_get_with_config(url, config)?;
575
    
576
    // Check for successful status
577
    if response.status_code >= 400 {
578
        return Err(HttpError::http_status(
579
            response.status_code,
580
            format!("HTTP error {}", response.status_code).into(),
581
        ));
582
    }
583
    
584
    Ok(response.body)
585
}
586

            
587
/// Stub: `http` feature disabled.
588
#[cfg(not(feature = "http"))]
589
pub fn download_bytes_with_config(_url: &str, _config: &HttpRequestConfig) -> HttpResult<U8Vec> {
590
    Err(HttpError::other("http feature not enabled".into()))
591
}
592

            
593
/// Check if a URL is reachable (HEAD request)
594
/// 
595
/// # Arguments
596
/// * `url` - The URL to check
597
/// 
598
/// # Returns
599
/// * `bool` - True if reachable (2xx status)
600
#[cfg(feature = "http")]
601
pub fn is_url_reachable(url: &str) -> bool {
602
    const REACHABILITY_TIMEOUT_SECS: u64 = 10;
603
    let agent = make_agent(REACHABILITY_TIMEOUT_SECS, false);
604
    match agent.head(url).call() {
605
        Ok(resp) => {
606
            let code = resp.status().as_u16();
607
            code >= 200 && code < 300
608
        }
609
        Err(_) => false,
610
    }
611
}
612

            
613
/// Stub: `http` feature disabled.
614
#[cfg(not(feature = "http"))]
615
pub fn is_url_reachable(_url: &str) -> bool {
616
    false
617
}
618

            
619
#[cfg(test)]
620
mod tests {
621
    use super::*;
622
    
623
    #[test]
624
1
    fn test_http_request_config_default() {
625
1
        let config = HttpRequestConfig::default();
626
1
        assert_eq!(config.timeout_secs, 30);
627
1
        assert_eq!(config.max_response_size, 100 * 1024 * 1024);
628
1
        assert!(!config.user_agent.as_str().is_empty());
629
1
    }
630
    
631
    #[test]
632
1
    fn test_http_response_status_checks() {
633
1
        let response = HttpResponse {
634
1
            status_code: 200,
635
1
            body: U8Vec::from(Vec::new()),
636
1
            content_type: AzString::from(String::new()),
637
1
            content_length: 0,
638
1
            headers: HttpHeaderVec::from_const_slice(&[]),
639
1
        };
640
1
        assert!(response.is_success());
641
1
        assert!(!response.is_redirect());
642
1
        assert!(!response.is_client_error());
643
1
        assert!(!response.is_server_error());
644
1
    }
645
    
646
    #[test]
647
1
    fn test_http_error_constructors() {
648
1
        let err = HttpError::http_status(404, "Not Found".into());
649
1
        assert!(err.to_string().contains("404"));
650
        
651
1
        let err2 = HttpError::response_too_large(100, 200);
652
1
        assert!(err2.to_string().contains("200"));
653
1
    }
654
}