hojicha_core/async_helpers/
http.rs1use super::AsyncConfig;
4use crate::commands;
5use crate::core::{Cmd, Message};
6use std::collections::HashMap;
7
8#[derive(Debug, Clone, Copy)]
10pub enum HttpMethod {
11 Get,
13 Post,
15 Put,
17 Delete,
19 Patch,
21 Head,
23}
24
25#[derive(Debug, Clone)]
27pub enum HttpError {
28 NetworkError(String),
30 Timeout,
32 InvalidUrl(String),
34 ServerError(u16, String),
36 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#[derive(Debug, Clone)]
56pub struct HttpResponse {
57 pub status: u16,
59 pub headers: HashMap<String, String>,
61 pub body: String,
63 pub bytes: Vec<u8>,
65}
66
67pub 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
94pub 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
125pub 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 let result = perform_http_request(method, url, body, headers).await;
148 Some(handler(result))
149 })
150}
151
152async 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 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
165
166 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
188pub 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 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 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 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}