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}