Skip to main content

hojicha_core/async_helpers/
http.rs

1//! HTTP request helper commands
2
3use super::AsyncConfig;
4use crate::commands;
5use crate::core::{Cmd, Message};
6use std::collections::HashMap;
7
8/// HTTP methods
9#[derive(Debug, Clone, Copy)]
10pub enum HttpMethod {
11    /// GET request
12    Get,
13    /// POST request
14    Post,
15    /// PUT request
16    Put,
17    /// DELETE request
18    Delete,
19    /// PATCH request
20    Patch,
21    /// HEAD request
22    Head,
23}
24
25/// HTTP request error
26#[derive(Debug, Clone)]
27pub enum HttpError {
28    /// Network error
29    NetworkError(String),
30    /// Timeout
31    Timeout,
32    /// Invalid URL
33    InvalidUrl(String),
34    /// Server error (status code)
35    ServerError(u16, String),
36    /// Parse error
37    ParseError(String),
38}
39
40impl std::fmt::Display for HttpError {
41    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
42        match self {
43            Self::NetworkError(e) => write!(f, "Network error: {e}"),
44            Self::Timeout => write!(f, "Request timed out"),
45            Self::InvalidUrl(url) => write!(f, "Invalid URL: {url}"),
46            Self::ServerError(code, msg) => write!(f, "Server error {code}: {msg}"),
47            Self::ParseError(e) => write!(f, "Parse error: {e}"),
48        }
49    }
50}
51
52impl std::error::Error for HttpError {}
53
54/// HTTP response
55#[derive(Debug, Clone)]
56pub struct HttpResponse {
57    /// Status code
58    pub status: u16,
59    /// Response headers
60    pub headers: HashMap<String, String>,
61    /// Response body as string
62    pub body: String,
63    /// Response body as bytes
64    pub bytes: Vec<u8>,
65}
66
67/// Create a GET request command
68///
69/// # Example
70/// ```no_run
71/// # use hojicha_core::async_helpers::http_get;
72/// # #[derive(Clone)]
73/// # enum Msg {
74/// #     DataLoaded(String),
75/// #     Error(String),
76/// # }
77///
78/// http_get("https://api.example.com/data", |result| {
79///     match result {
80///         Ok(response) => Msg::DataLoaded(response.body),
81///         Err(e) => Msg::Error(e.to_string()),
82///     }
83/// })
84/// # ;
85/// ```
86pub fn http_get<M, F>(url: impl Into<String>, handler: F) -> Cmd<M>
87where
88    M: Message,
89    F: FnOnce(Result<HttpResponse, HttpError>) -> M + Send + 'static,
90{
91    http_request(HttpMethod::Get, url, None::<String>, None, handler)
92}
93
94/// Create a POST request command with JSON body
95///
96/// # Example
97/// ```no_run
98/// # use hojicha_core::async_helpers::http_post;
99/// # #[derive(Clone)]
100/// # enum Msg {
101/// #     Posted,
102/// #     Error(String),
103/// # }
104///
105/// let json_body = r#"{"name": "test"}"#;
106/// http_post("https://api.example.com/data", json_body, |result| {
107///     match result {
108///         Ok(_) => Msg::Posted,
109///         Err(e) => Msg::Error(e.to_string()),
110///     }
111/// })
112/// # ;
113/// ```
114pub fn http_post<M, F, B>(url: impl Into<String>, body: B, handler: F) -> Cmd<M>
115where
116    M: Message,
117    F: FnOnce(Result<HttpResponse, HttpError>) -> M + Send + 'static,
118    B: Into<String>,
119{
120    let mut headers = HashMap::new();
121    headers.insert("Content-Type".to_string(), "application/json".to_string());
122    http_request(HttpMethod::Post, url, Some(body), Some(headers), handler)
123}
124
125/// Create a custom HTTP request command
126///
127/// This is the most flexible HTTP helper, allowing you to specify method,
128/// headers, and body.
129pub fn http_request<M, F, B>(
130    method: HttpMethod,
131    url: impl Into<String>,
132    body: Option<B>,
133    headers: Option<HashMap<String, String>>,
134    handler: F,
135) -> Cmd<M>
136where
137    M: Message,
138    F: FnOnce(Result<HttpResponse, HttpError>) -> M + Send + 'static,
139    B: Into<String>,
140{
141    let url = url.into();
142    let body = body.map(std::convert::Into::into);
143
144    commands::spawn(async move {
145        // In a real implementation, we would use reqwest or similar
146        // For now, we'll create a mock implementation
147        let result = perform_http_request(method, url, body, headers).await;
148        Some(handler(result))
149    })
150}
151
152/// Internal function to perform HTTP request
153/// In a real implementation, this would use reqwest or similar
154async fn perform_http_request(
155    method: HttpMethod,
156    url: String,
157    body: Option<String>,
158    headers: Option<HashMap<String, String>>,
159) -> Result<HttpResponse, HttpError> {
160    // This is a placeholder implementation
161    // In production, you would use reqwest or another HTTP client
162
163    // Simulate network delay
164    tokio::time::sleep(std::time::Duration::from_millis(100)).await;
165
166    // For demonstration, return a mock response
167    Ok(HttpResponse {
168        status: 200,
169        headers: headers.unwrap_or_default(),
170        body: body.unwrap_or_else(|| {
171            format!(
172                "Mock response for {} {}",
173                match method {
174                    HttpMethod::Get => "GET",
175                    HttpMethod::Post => "POST",
176                    HttpMethod::Put => "PUT",
177                    HttpMethod::Delete => "DELETE",
178                    HttpMethod::Patch => "PATCH",
179                    HttpMethod::Head => "HEAD",
180                },
181                url
182            )
183        }),
184        bytes: Vec::new(),
185    })
186}
187
188/// Create an HTTP request with retry logic
189pub fn http_with_retry<M, F>(
190    method: HttpMethod,
191    url: impl Into<String>,
192    config: AsyncConfig,
193    handler: F,
194) -> Cmd<M>
195where
196    M: Message,
197    F: FnOnce(Result<HttpResponse, HttpError>) -> M + Send + 'static,
198{
199    let url = url.into();
200
201    commands::spawn(async move {
202        let mut attempts = 0;
203        loop {
204            let result = perform_http_request(method, url.clone(), None, None).await;
205
206            if result.is_ok() || attempts >= config.retries {
207                return Some(handler(result));
208            }
209
210            attempts += 1;
211
212            // Apply backoff strategy
213            match config.backoff {
214                super::BackoffStrategy::None => {}
215                super::BackoffStrategy::Linear(duration) => {
216                    tokio::time::sleep(duration * attempts).await;
217                }
218                super::BackoffStrategy::Exponential(duration) => {
219                    tokio::time::sleep(duration * 2u32.pow(attempts)).await;
220                }
221            }
222        }
223    })
224}
225
226#[cfg(test)]
227mod tests {
228    use super::*;
229    use proptest::prelude::*;
230
231    proptest! {
232        #[test]
233        fn prop_http_error_display_basic(error_msg in "[a-zA-Z0-9 ]{1,50}") {
234            // Test each error type individually with appropriate content
235            let network_error = HttpError::NetworkError(error_msg.clone());
236            let network_display = network_error.to_string();
237            prop_assert!(!network_display.is_empty());
238            prop_assert!(network_display.contains(&error_msg));
239
240            let parse_error = HttpError::ParseError(error_msg.clone());
241            let parse_display = parse_error.to_string();
242            prop_assert!(!parse_display.is_empty());
243            prop_assert!(parse_display.contains(&error_msg));
244
245            // Test timeout error (no message required)
246            let timeout_error = HttpError::Timeout;
247            let timeout_display = timeout_error.to_string();
248            prop_assert!(!timeout_display.is_empty());
249            prop_assert!(timeout_display.to_lowercase().contains("timed out"));
250        }
251    }
252
253    proptest! {
254        #[test]
255        fn prop_server_error_display(status_code in 400u16..600u16, message in "[a-zA-Z0-9 ]{1,30}") {
256            let error = HttpError::ServerError(status_code, message.clone());
257            let display = error.to_string();
258
259            prop_assert!(!display.is_empty());
260            prop_assert!(display.contains(&status_code.to_string()));
261            prop_assert!(display.contains(&message));
262        }
263    }
264}