rustial-engine 0.0.1

Framework-agnostic 2.5D map engine for rustial
Documentation
//! Abstract HTTP client trait for runtime-agnostic networking.
//!
//! # Design
//!
//! The engine crate must remain framework-agnostic: no tokio, no async
//! runtime, no platform-specific networking code.  Instead, the host
//! application provides an implementation of [`HttpClient`] that bridges
//! to whatever networking stack is available:
//!
//! | Platform | Typical implementation |
//! |----------|-----------------------|
//! | Desktop (reqwest) | Channel-based: `send` pushes to a background thread, `poll` drains completed responses. |
//! | Bevy | Bevy's `AsyncComputeTaskPool` (see `rustial-renderer-bevy`). |
//! | Browser / WASM | `web_sys::fetch` with a `JsValue` callback. |
//!
//! # Request lifecycle
//!
//! ```text
//! Engine                      Host HttpClient
//!   |                              |
//!   |--- send(HttpRequest) ------->|   (non-blocking, enqueues work)
//!   |                              |
//!   |--- poll() ------------------>|   (returns completed responses)
//!   |<-- Vec<(url, Result)> -------|
//! ```
//!
//! The engine calls [`send`](HttpClient::send) once per request and
//! [`poll`](HttpClient::poll) once per frame.  Implementations must be
//! `Send + Sync` because the engine may live on a different thread from
//! the renderer.
//!
//! # Error convention
//!
//! Transport-level errors (DNS failure, connection refused, timeout) are
//! returned as `Err(String)` in the poll results.  HTTP-level errors
//! (4xx, 5xx) are returned as `Ok(HttpResponse)` with the corresponding
//! [`status`](HttpResponse::status) code -- the engine decides how to
//! handle them.
//!
//! # Cancellation
//!
//! The base trait does not require cancellation support.  Implementations
//! that support it can expose a `cancel(url)` method outside the trait.
//! The [`FetchPool`](crate::FetchPool) wrapper handles queue-level
//! cancellation independently.

/// Maximum number of headers a request may carry.
///
/// Kept small to discourage misuse -- tile requests typically need
/// only `User-Agent` and occasionally `Authorization`.
const MAX_HEADERS: usize = 16;

// ---------------------------------------------------------------------------
// HttpRequest
// ---------------------------------------------------------------------------

/// Description of an outgoing HTTP request.
///
/// Deliberately minimal: the engine only needs GET requests for tile
/// fetching.  Headers are optional and cover authentication or
/// User-Agent overrides.
///
/// # Example
///
/// ```
/// use rustial_engine::HttpRequest;
///
/// let req = HttpRequest::get("https://tile.openstreetmap.org/10/512/340.png");
/// assert_eq!(req.url, "https://tile.openstreetmap.org/10/512/340.png");
/// assert_eq!(req.method, "GET");
/// ```
#[derive(Debug, Clone)]
pub struct HttpRequest {
    /// The URL to fetch.
    pub url: String,

    /// HTTP method.  Defaults to `"GET"` via [`HttpRequest::get`].
    ///
    /// The engine only uses GET, but the field is public so host
    /// applications can reuse this type for other verbs if needed.
    pub method: String,

    /// Optional request headers as `(name, value)` pairs.
    ///
    /// Common uses: `User-Agent`, `Authorization`, `Accept`.
    pub headers: Vec<(String, String)>,
}

impl HttpRequest {
    /// Create a GET request for the given URL with no extra headers.
    pub fn get(url: impl Into<String>) -> Self {
        Self {
            url: url.into(),
            method: "GET".into(),
            headers: Vec::new(),
        }
    }

    /// Add a header to this request.  Returns `self` for chaining.
    ///
    /// Silently ignores headers beyond [`MAX_HEADERS`] to prevent
    /// accidental unbounded growth.
    pub fn with_header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
        if self.headers.len() < MAX_HEADERS {
            self.headers.push((name.into(), value.into()));
        }
        self
    }
}

// ---------------------------------------------------------------------------
// HttpResponse
// ---------------------------------------------------------------------------

/// A completed HTTP response.
///
/// The body is the raw response bytes.  Image decoding is handled
/// separately by [`TileDecoder`](crate::TileDecoder).
#[derive(Debug, Clone)]
pub struct HttpResponse {
    /// HTTP status code (e.g. 200, 404, 500).
    pub status: u16,

    /// Response body bytes.
    pub body: Vec<u8>,

    /// Response headers as `(name, value)` pairs.
    ///
    /// Implementations may leave this empty if the engine does not
    /// need response headers (which is the common case for tile
    /// fetching).  The field exists for cache-control, ETag, and
    /// content-type inspection.
    pub headers: Vec<(String, String)>,
}

impl HttpResponse {
    /// Whether the status code indicates success (2xx).
    #[inline]
    pub fn is_success(&self) -> bool {
        (200..300).contains(&self.status)
    }

    /// Whether the status code indicates a client error (4xx).
    #[inline]
    pub fn is_client_error(&self) -> bool {
        (400..500).contains(&self.status)
    }

    /// Whether the status code indicates a server error (5xx).
    #[inline]
    pub fn is_server_error(&self) -> bool {
        (500..600).contains(&self.status)
    }

    /// Look up a response header value by name (case-insensitive).
    ///
    /// Returns the first matching header, or `None`.
    pub fn header(&self, name: &str) -> Option<&str> {
        let lower = name.to_ascii_lowercase();
        self.headers
            .iter()
            .find(|(k, _)| k.to_ascii_lowercase() == lower)
            .map(|(_, v)| v.as_str())
    }
}

// ---------------------------------------------------------------------------
// HttpClient trait
// ---------------------------------------------------------------------------

/// Abstract HTTP client.
///
/// The engine calls [`send`](Self::send) to initiate a request and
/// [`poll`](Self::poll) each frame to collect completed responses.
/// Implementations **must not block** in either method.
///
/// # Object safety
///
/// The trait is object-safe so it can be stored as `Box<dyn HttpClient>`.
///
/// # Thread safety
///
/// Required to be `Send + Sync` because the engine state may be shared
/// across threads (e.g. between a main thread and a render thread).
pub trait HttpClient: Send + Sync {
    /// Initiate an HTTP request.  Must return immediately.
    ///
    /// The implementation should enqueue the request for background
    /// execution and make the result available via [`poll`](Self::poll).
    fn send(&self, request: HttpRequest);

    /// Collect completed HTTP responses.
    ///
    /// Returns `(original_url, Result<HttpResponse, error_message>)`
    /// for every request that has finished since the last call.
    ///
    /// Returns an empty `Vec` when no requests have completed.
    fn poll(&self) -> Vec<(String, Result<HttpResponse, String>)>;
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

#[cfg(test)]
mod tests {
    use super::*;

    // -- HttpRequest ------------------------------------------------------

    #[test]
    fn get_request_defaults() {
        let req = HttpRequest::get("https://example.com/tile.png");
        assert_eq!(req.url, "https://example.com/tile.png");
        assert_eq!(req.method, "GET");
        assert!(req.headers.is_empty());
    }

    #[test]
    fn request_with_headers() {
        let req = HttpRequest::get("https://example.com")
            .with_header("User-Agent", "rustial/0.1")
            .with_header("Authorization", "Bearer token");
        assert_eq!(req.headers.len(), 2);
        assert_eq!(req.headers[0].0, "User-Agent");
        assert_eq!(req.headers[1].0, "Authorization");
    }

    #[test]
    fn request_header_limit() {
        let mut req = HttpRequest::get("https://example.com");
        for i in 0..20 {
            req = req.with_header(format!("X-Header-{i}"), "value");
        }
        assert_eq!(req.headers.len(), MAX_HEADERS);
    }

    // -- HttpResponse -----------------------------------------------------

    #[test]
    fn response_status_helpers() {
        assert!(HttpResponse {
            status: 200,
            body: vec![],
            headers: vec![]
        }
        .is_success());
        assert!(HttpResponse {
            status: 204,
            body: vec![],
            headers: vec![]
        }
        .is_success());
        assert!(!HttpResponse {
            status: 301,
            body: vec![],
            headers: vec![]
        }
        .is_success());

        assert!(HttpResponse {
            status: 404,
            body: vec![],
            headers: vec![]
        }
        .is_client_error());
        assert!(!HttpResponse {
            status: 200,
            body: vec![],
            headers: vec![]
        }
        .is_client_error());

        assert!(HttpResponse {
            status: 500,
            body: vec![],
            headers: vec![]
        }
        .is_server_error());
        assert!(HttpResponse {
            status: 503,
            body: vec![],
            headers: vec![]
        }
        .is_server_error());
        assert!(!HttpResponse {
            status: 200,
            body: vec![],
            headers: vec![]
        }
        .is_server_error());
    }

    #[test]
    fn response_header_lookup() {
        let resp = HttpResponse {
            status: 200,
            body: vec![],
            headers: vec![
                ("Content-Type".into(), "image/png".into()),
                ("Cache-Control".into(), "max-age=3600".into()),
            ],
        };
        assert_eq!(resp.header("content-type"), Some("image/png"));
        assert_eq!(resp.header("CACHE-CONTROL"), Some("max-age=3600"));
        assert_eq!(resp.header("X-Missing"), None);
    }

    // -- HttpClient trait object safety -----------------------------------

    #[test]
    fn trait_is_object_safe() {
        struct Dummy;
        impl HttpClient for Dummy {
            fn send(&self, _request: HttpRequest) {}
            fn poll(&self) -> Vec<(String, Result<HttpResponse, String>)> {
                vec![]
            }
        }
        let _boxed: Box<dyn HttpClient> = Box::new(Dummy);
    }

    #[test]
    fn trait_is_send_sync() {
        fn assert_send_sync<T: Send + Sync>() {}
        struct Dummy;
        impl HttpClient for Dummy {
            fn send(&self, _request: HttpRequest) {}
            fn poll(&self) -> Vec<(String, Result<HttpResponse, String>)> {
                vec![]
            }
        }
        assert_send_sync::<Dummy>();
    }
}