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 {
243 status: 200,
244 body: vec![],
245 headers: vec![]
246 }
247 .is_success());
248 assert!(HttpResponse {
249 status: 204,
250 body: vec![],
251 headers: vec![]
252 }
253 .is_success());
254 assert!(!HttpResponse {
255 status: 301,
256 body: vec![],
257 headers: vec![]
258 }
259 .is_success());
260
261 assert!(HttpResponse {
262 status: 404,
263 body: vec![],
264 headers: vec![]
265 }
266 .is_client_error());
267 assert!(!HttpResponse {
268 status: 200,
269 body: vec![],
270 headers: vec![]
271 }
272 .is_client_error());
273
274 assert!(HttpResponse {
275 status: 500,
276 body: vec![],
277 headers: vec![]
278 }
279 .is_server_error());
280 assert!(HttpResponse {
281 status: 503,
282 body: vec![],
283 headers: vec![]
284 }
285 .is_server_error());
286 assert!(!HttpResponse {
287 status: 200,
288 body: vec![],
289 headers: vec![]
290 }
291 .is_server_error());
292 }
293
294 #[test]
295 fn response_header_lookup() {
296 let resp = HttpResponse {
297 status: 200,
298 body: vec![],
299 headers: vec![
300 ("Content-Type".into(), "image/png".into()),
301 ("Cache-Control".into(), "max-age=3600".into()),
302 ],
303 };
304 assert_eq!(resp.header("content-type"), Some("image/png"));
305 assert_eq!(resp.header("CACHE-CONTROL"), Some("max-age=3600"));
306 assert_eq!(resp.header("X-Missing"), None);
307 }
308
309 // -- HttpClient trait object safety -----------------------------------
310
311 #[test]
312 fn trait_is_object_safe() {
313 struct Dummy;
314 impl HttpClient for Dummy {
315 fn send(&self, _request: HttpRequest) {}
316 fn poll(&self) -> Vec<(String, Result<HttpResponse, String>)> {
317 vec![]
318 }
319 }
320 let _boxed: Box<dyn HttpClient> = Box::new(Dummy);
321 }
322
323 #[test]
324 fn trait_is_send_sync() {
325 fn assert_send_sync<T: Send + Sync>() {}
326 struct Dummy;
327 impl HttpClient for Dummy {
328 fn send(&self, _request: HttpRequest) {}
329 fn poll(&self) -> Vec<(String, Result<HttpResponse, String>)> {
330 vec![]
331 }
332 }
333 assert_send_sync::<Dummy>();
334 }
335}