Skip to main content

cranpose_services/
http.rs

1use 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(&current, &custom_client));
222        assert!(!Arc::ptr_eq(&current, &default_client));
223    }
224}