cranpose_services/
http.rs1use cranpose_core::{compositionLocalOf, CompositionLocal};
2use std::future::Future;
3use std::pin::Pin;
4use std::sync::Arc;
5
6#[derive(thiserror::Error, Debug, Clone)]
7pub enum HttpError {
8 #[error("Failed to build HTTP client: {0}")]
9 ClientInit(String),
10 #[error("Request failed for {url}: {message}")]
11 RequestFailed { url: String, message: String },
12 #[error("Request failed with status {status} for {url}")]
13 HttpStatus { url: String, status: u16 },
14 #[error("Failed to read response body for {url}: {message}")]
15 BodyReadFailed { url: String, message: String },
16 #[error("Invalid response for {url}: {message}")]
17 InvalidResponse { url: String, message: String },
18 #[error("No window object available")]
19 NoWindow,
20}
21
22#[cfg(not(target_arch = "wasm32"))]
23pub type HttpFuture<'a, T> = Pin<Box<dyn Future<Output = Result<T, HttpError>> + Send + 'a>>;
24
25#[cfg(target_arch = "wasm32")]
26pub type HttpFuture<'a, T> = Pin<Box<dyn Future<Output = Result<T, HttpError>> + 'a>>;
27
28pub trait HttpClient: Send + Sync {
29 fn get_text<'a>(&'a self, url: &'a str) -> HttpFuture<'a, String>;
30}
31
32pub type HttpClientRef = Arc<dyn HttpClient>;
33
34struct DefaultHttpClient;
35
36impl HttpClient for DefaultHttpClient {
37 fn get_text<'a>(&'a self, url: &'a str) -> HttpFuture<'a, String> {
38 Box::pin(async move {
39 #[cfg(not(target_arch = "wasm32"))]
40 {
41 fetch_text_native(url)
42 }
43
44 #[cfg(target_arch = "wasm32")]
45 {
46 fetch_text_web(url).await
47 }
48 })
49 }
50}
51
52#[cfg(not(target_arch = "wasm32"))]
53fn fetch_text_native(url: &str) -> Result<String, HttpError> {
54 use std::sync::OnceLock;
55 use std::time::Duration;
56
57 static CLIENT: OnceLock<Result<reqwest::blocking::Client, HttpError>> = OnceLock::new();
58 let client = CLIENT
59 .get_or_init(|| {
60 reqwest::blocking::Client::builder()
61 .timeout(Duration::from_secs(10))
62 .user_agent("cranpose/0.1")
63 .build()
64 .map_err(|err| HttpError::ClientInit(err.to_string()))
65 })
66 .as_ref()
67 .map_err(|err| err.clone())?;
68
69 let response = client
70 .get(url)
71 .send()
72 .map_err(|err| HttpError::RequestFailed {
73 url: url.to_string(),
74 message: err.to_string(),
75 })?;
76
77 let status = response.status();
78 if !status.is_success() {
79 return Err(HttpError::HttpStatus {
80 url: url.to_string(),
81 status: status.as_u16(),
82 });
83 }
84
85 response.text().map_err(|err| HttpError::BodyReadFailed {
86 url: url.to_string(),
87 message: err.to_string(),
88 })
89}
90
91#[cfg(target_arch = "wasm32")]
92async fn fetch_text_web(url: &str) -> Result<String, HttpError> {
93 use wasm_bindgen::JsCast;
94 use wasm_bindgen_futures::JsFuture;
95 use web_sys::{Request, RequestInit, RequestMode, Response};
96
97 let opts = RequestInit::new();
98 opts.set_method("GET");
99 opts.set_mode(RequestMode::Cors);
100
101 let request =
102 Request::new_with_str_and_init(url, &opts).map_err(|err| HttpError::RequestFailed {
103 url: url.to_string(),
104 message: format!("{:?}", err),
105 })?;
106
107 let window = web_sys::window().ok_or(HttpError::NoWindow)?;
108 let resp_value = JsFuture::from(window.fetch_with_request(&request))
109 .await
110 .map_err(|err| HttpError::RequestFailed {
111 url: url.to_string(),
112 message: format!("{:?}", err),
113 })?;
114
115 let resp: Response = resp_value
116 .dyn_into()
117 .map_err(|_| HttpError::InvalidResponse {
118 url: url.to_string(),
119 message: "Response is not a Response object".to_string(),
120 })?;
121
122 if !resp.ok() {
123 return Err(HttpError::HttpStatus {
124 url: url.to_string(),
125 status: resp.status(),
126 });
127 }
128
129 let text_promise = resp.text().map_err(|err| HttpError::BodyReadFailed {
130 url: url.to_string(),
131 message: format!("{:?}", err),
132 })?;
133 let text_value =
134 JsFuture::from(text_promise)
135 .await
136 .map_err(|err| HttpError::BodyReadFailed {
137 url: url.to_string(),
138 message: format!("{:?}", err),
139 })?;
140
141 text_value
142 .as_string()
143 .ok_or_else(|| HttpError::InvalidResponse {
144 url: url.to_string(),
145 message: "Response body is not a string".to_string(),
146 })
147}
148
149pub fn default_http_client() -> HttpClientRef {
150 Arc::new(DefaultHttpClient)
151}
152
153pub fn local_http_client() -> CompositionLocal<HttpClientRef> {
154 thread_local! {
155 static LOCAL_HTTP_CLIENT: std::cell::RefCell<Option<CompositionLocal<HttpClientRef>>> = const { std::cell::RefCell::new(None) };
156 }
157
158 LOCAL_HTTP_CLIENT.with(|cell| {
159 let mut local = cell.borrow_mut();
160 if local.is_none() {
161 *local = Some(compositionLocalOf(default_http_client));
162 }
163 local
164 .as_ref()
165 .expect("HTTP client composition local must be initialized")
166 .clone()
167 })
168}
169
170#[cfg(test)]
171mod tests {
172 use super::*;
173 use crate::run_test_composition;
174 use cranpose_core::CompositionLocalProvider;
175 use std::cell::RefCell;
176 use std::rc::Rc;
177
178 struct TestHttpClient;
179
180 impl HttpClient for TestHttpClient {
181 fn get_text<'a>(&'a self, _url: &'a str) -> HttpFuture<'a, String> {
182 Box::pin(async { Ok("ok".to_string()) })
183 }
184 }
185
186 #[test]
187 fn default_http_client_is_available() {
188 let client = default_http_client();
189 let cloned = client.clone();
190 assert_eq!(Arc::strong_count(&client), 2);
191 drop(cloned);
192 assert_eq!(Arc::strong_count(&client), 1);
193 }
194
195 #[test]
196 fn local_http_client_can_be_overridden() {
197 let local = local_http_client();
198 let default_client = default_http_client();
199 let custom_client: HttpClientRef = Arc::new(TestHttpClient);
200 let captured = Rc::new(RefCell::new(None));
201
202 {
203 let captured_for_closure = Rc::clone(&captured);
204 let custom_client = custom_client.clone();
205 let local_for_provider = local.clone();
206 let local_for_read = local.clone();
207 run_test_composition(move || {
208 let captured = Rc::clone(&captured_for_closure);
209 let local_for_read = local_for_read.clone();
210 CompositionLocalProvider(
211 vec![local_for_provider.provides(custom_client.clone())],
212 move || {
213 let current = local_for_read.current();
214 *captured.borrow_mut() = Some(current);
215 },
216 );
217 });
218 }
219
220 let current = captured.borrow().as_ref().expect("client captured").clone();
221 assert!(Arc::ptr_eq(¤t, &custom_client));
222 assert!(!Arc::ptr_eq(¤t, &default_client));
223 }
224}