Skip to main content

rustial_engine/io/
http_client.rs

1//! Abstract HTTP client trait for runtime-agnostic networking.
2//!
3//! # Design
4//!
5//! The engine crate must remain framework-agnostic: no tokio, no async
6//! runtime, no platform-specific networking code.  Instead, the host
7//! application provides an implementation of [`HttpClient`] that bridges
8//! to whatever networking stack is available:
9//!
10//! | Platform | Typical implementation |
11//! |----------|-----------------------|
12//! | Desktop (reqwest) | Channel-based: `send` pushes to a background thread, `poll` drains completed responses. |
13//! | Bevy | Bevy's `AsyncComputeTaskPool` (see `rustial-renderer-bevy`). |
14//! | Browser / WASM | `web_sys::fetch` with a `JsValue` callback. |
15//!
16//! # Request lifecycle
17//!
18//! ```text
19//! Engine                      Host HttpClient
20//!   |                              |
21//!   |--- send(HttpRequest) ------->|   (non-blocking, enqueues work)
22//!   |                              |
23//!   |--- poll() ------------------>|   (returns completed responses)
24//!   |<-- Vec<(url, Result)> -------|
25//! ```
26//!
27//! The engine calls [`send`](HttpClient::send) once per request and
28//! [`poll`](HttpClient::poll) once per frame.  Implementations must be
29//! `Send + Sync` because the engine may live on a different thread from
30//! the renderer.
31//!
32//! # Error convention
33//!
34//! Transport-level errors (DNS failure, connection refused, timeout) are
35//! returned as `Err(String)` in the poll results.  HTTP-level errors
36//! (4xx, 5xx) are returned as `Ok(HttpResponse)` with the corresponding
37//! [`status`](HttpResponse::status) code -- the engine decides how to
38//! handle them.
39//!
40//! # Cancellation
41//!
42//! The base trait does not require cancellation support.  Implementations
43//! that support it can expose a `cancel(url)` method outside the trait.
44//! The [`FetchPool`](crate::FetchPool) wrapper handles queue-level
45//! cancellation independently.
46
47/// Maximum number of headers a request may carry.
48///
49/// Kept small to discourage misuse -- tile requests typically need
50/// only `User-Agent` and occasionally `Authorization`.
51const MAX_HEADERS: usize = 16;
52
53// ---------------------------------------------------------------------------
54// HttpRequest
55// ---------------------------------------------------------------------------
56
57/// Description of an outgoing HTTP request.
58///
59/// Deliberately minimal: the engine only needs GET requests for tile
60/// fetching.  Headers are optional and cover authentication or
61/// User-Agent overrides.
62///
63/// # Example
64///
65/// ```
66/// use rustial_engine::HttpRequest;
67///
68/// let req = HttpRequest::get("https://tile.openstreetmap.org/10/512/340.png");
69/// assert_eq!(req.url, "https://tile.openstreetmap.org/10/512/340.png");
70/// assert_eq!(req.method, "GET");
71/// ```
72#[derive(Debug, Clone)]
73pub struct HttpRequest {
74    /// The URL to fetch.
75    pub url: String,
76
77    /// HTTP method.  Defaults to `"GET"` via [`HttpRequest::get`].
78    ///
79    /// The engine only uses GET, but the field is public so host
80    /// applications can reuse this type for other verbs if needed.
81    pub method: String,
82
83    /// Optional request headers as `(name, value)` pairs.
84    ///
85    /// Common uses: `User-Agent`, `Authorization`, `Accept`.
86    pub headers: Vec<(String, String)>,
87}
88
89impl HttpRequest {
90    /// Create a GET request for the given URL with no extra headers.
91    pub fn get(url: impl Into<String>) -> Self {
92        Self {
93            url: url.into(),
94            method: "GET".into(),
95            headers: Vec::new(),
96        }
97    }
98
99    /// Add a header to this request.  Returns `self` for chaining.
100    ///
101    /// Silently ignores headers beyond [`MAX_HEADERS`] to prevent
102    /// accidental unbounded growth.
103    pub fn with_header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
104        if self.headers.len() < MAX_HEADERS {
105            self.headers.push((name.into(), value.into()));
106        }
107        self
108    }
109}
110
111// ---------------------------------------------------------------------------
112// HttpResponse
113// ---------------------------------------------------------------------------
114
115/// A completed HTTP response.
116///
117/// The body is the raw response bytes.  Image decoding is handled
118/// separately by [`TileDecoder`](crate::TileDecoder).
119#[derive(Debug, Clone)]
120pub struct HttpResponse {
121    /// HTTP status code (e.g. 200, 404, 500).
122    pub status: u16,
123
124    /// Response body bytes.
125    pub body: Vec<u8>,
126
127    /// Response headers as `(name, value)` pairs.
128    ///
129    /// Implementations may leave this empty if the engine does not
130    /// need response headers (which is the common case for tile
131    /// fetching).  The field exists for cache-control, ETag, and
132    /// content-type inspection.
133    pub headers: Vec<(String, String)>,
134}
135
136impl HttpResponse {
137    /// Whether the status code indicates success (2xx).
138    #[inline]
139    pub fn is_success(&self) -> bool {
140        (200..300).contains(&self.status)
141    }
142
143    /// Whether the status code indicates a client error (4xx).
144    #[inline]
145    pub fn is_client_error(&self) -> bool {
146        (400..500).contains(&self.status)
147    }
148
149    /// Whether the status code indicates a server error (5xx).
150    #[inline]
151    pub fn is_server_error(&self) -> bool {
152        (500..600).contains(&self.status)
153    }
154
155    /// Look up a response header value by name (case-insensitive).
156    ///
157    /// Returns the first matching header, or `None`.
158    pub fn header(&self, name: &str) -> Option<&str> {
159        let lower = name.to_ascii_lowercase();
160        self.headers
161            .iter()
162            .find(|(k, _)| k.to_ascii_lowercase() == lower)
163            .map(|(_, v)| v.as_str())
164    }
165}
166
167// ---------------------------------------------------------------------------
168// HttpClient trait
169// ---------------------------------------------------------------------------
170
171/// Abstract HTTP client.
172///
173/// The engine calls [`send`](Self::send) to initiate a request and
174/// [`poll`](Self::poll) each frame to collect completed responses.
175/// Implementations **must not block** in either method.
176///
177/// # Object safety
178///
179/// The trait is object-safe so it can be stored as `Box<dyn HttpClient>`.
180///
181/// # Thread safety
182///
183/// Required to be `Send + Sync` because the engine state may be shared
184/// across threads (e.g. between a main thread and a render thread).
185pub trait HttpClient: Send + Sync {
186    /// Initiate an HTTP request.  Must return immediately.
187    ///
188    /// The implementation should enqueue the request for background
189    /// execution and make the result available via [`poll`](Self::poll).
190    fn send(&self, request: HttpRequest);
191
192    /// Collect completed HTTP responses.
193    ///
194    /// Returns `(original_url, Result<HttpResponse, error_message>)`
195    /// for every request that has finished since the last call.
196    ///
197    /// Returns an empty `Vec` when no requests have completed.
198    fn poll(&self) -> Vec<(String, Result<HttpResponse, String>)>;
199}
200
201// ---------------------------------------------------------------------------
202// Tests
203// ---------------------------------------------------------------------------
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208
209    // -- HttpRequest ------------------------------------------------------
210
211    #[test]
212    fn get_request_defaults() {
213        let req = HttpRequest::get("https://example.com/tile.png");
214        assert_eq!(req.url, "https://example.com/tile.png");
215        assert_eq!(req.method, "GET");
216        assert!(req.headers.is_empty());
217    }
218
219    #[test]
220    fn request_with_headers() {
221        let req = HttpRequest::get("https://example.com")
222            .with_header("User-Agent", "rustial/0.1")
223            .with_header("Authorization", "Bearer token");
224        assert_eq!(req.headers.len(), 2);
225        assert_eq!(req.headers[0].0, "User-Agent");
226        assert_eq!(req.headers[1].0, "Authorization");
227    }
228
229    #[test]
230    fn request_header_limit() {
231        let mut req = HttpRequest::get("https://example.com");
232        for i in 0..20 {
233            req = req.with_header(format!("X-Header-{i}"), "value");
234        }
235        assert_eq!(req.headers.len(), MAX_HEADERS);
236    }
237
238    // -- HttpResponse -----------------------------------------------------
239
240    #[test]
241    fn response_status_helpers() {
242        assert!(HttpResponse { status: 200, body: vec![], headers: vec![] }.is_success());
243        assert!(HttpResponse { status: 204, body: vec![], headers: vec![] }.is_success());
244        assert!(!HttpResponse { status: 301, body: vec![], headers: vec![] }.is_success());
245
246        assert!(HttpResponse { status: 404, body: vec![], headers: vec![] }.is_client_error());
247        assert!(!HttpResponse { status: 200, body: vec![], headers: vec![] }.is_client_error());
248
249        assert!(HttpResponse { status: 500, body: vec![], headers: vec![] }.is_server_error());
250        assert!(HttpResponse { status: 503, body: vec![], headers: vec![] }.is_server_error());
251        assert!(!HttpResponse { status: 200, body: vec![], headers: vec![] }.is_server_error());
252    }
253
254    #[test]
255    fn response_header_lookup() {
256        let resp = HttpResponse {
257            status: 200,
258            body: vec![],
259            headers: vec![
260                ("Content-Type".into(), "image/png".into()),
261                ("Cache-Control".into(), "max-age=3600".into()),
262            ],
263        };
264        assert_eq!(resp.header("content-type"), Some("image/png"));
265        assert_eq!(resp.header("CACHE-CONTROL"), Some("max-age=3600"));
266        assert_eq!(resp.header("X-Missing"), None);
267    }
268
269    // -- HttpClient trait object safety -----------------------------------
270
271    #[test]
272    fn trait_is_object_safe() {
273        struct Dummy;
274        impl HttpClient for Dummy {
275            fn send(&self, _request: HttpRequest) {}
276            fn poll(&self) -> Vec<(String, Result<HttpResponse, String>)> {
277                vec![]
278            }
279        }
280        let _boxed: Box<dyn HttpClient> = Box::new(Dummy);
281    }
282
283    #[test]
284    fn trait_is_send_sync() {
285        fn assert_send_sync<T: Send + Sync>() {}
286        struct Dummy;
287        impl HttpClient for Dummy {
288            fn send(&self, _request: HttpRequest) {}
289            fn poll(&self) -> Vec<(String, Result<HttpResponse, String>)> {
290                vec![]
291            }
292        }
293        assert_send_sync::<Dummy>();
294    }
295}