pforge_runtime/handlers/
http.rs

1use crate::{Error, Result};
2use reqwest::{Client, Method};
3use rustc_hash::FxHashMap;
4use schemars::JsonSchema;
5use serde::{Deserialize, Serialize};
6
7#[derive(Debug, Clone)]
8pub struct HttpHandler {
9    pub endpoint: String,
10    pub method: HttpMethod,
11    pub headers: FxHashMap<String, String>,
12    pub auth: Option<AuthConfig>,
13    pub timeout_ms: Option<u64>,
14    client: Client,
15}
16
17#[derive(Debug, Clone)]
18pub enum HttpMethod {
19    Get,
20    Post,
21    Put,
22    Delete,
23    Patch,
24}
25
26#[derive(Debug, Clone)]
27pub enum AuthConfig {
28    Bearer { token: String },
29    Basic { username: String, password: String },
30    ApiKey { key: String, header: String },
31}
32
33#[derive(Debug, Deserialize, JsonSchema)]
34pub struct HttpInput {
35    #[serde(default)]
36    pub body: Option<serde_json::Value>,
37    #[serde(default)]
38    pub query: FxHashMap<String, String>,
39}
40
41#[derive(Debug, Serialize, JsonSchema)]
42pub struct HttpOutput {
43    pub status: u16,
44    pub body: serde_json::Value,
45    pub headers: FxHashMap<String, String>,
46}
47
48impl HttpHandler {
49    pub fn new(
50        endpoint: String,
51        method: HttpMethod,
52        headers: FxHashMap<String, String>,
53        auth: Option<AuthConfig>,
54        timeout_ms: Option<u64>,
55    ) -> Self {
56        // Build client with timeout if specified
57        let client = if let Some(timeout) = timeout_ms {
58            Client::builder()
59                .timeout(std::time::Duration::from_millis(timeout))
60                .build()
61                .unwrap_or_else(|_| Client::new())
62        } else {
63            Client::new()
64        };
65
66        Self {
67            endpoint,
68            method,
69            headers,
70            auth,
71            timeout_ms,
72            client,
73        }
74    }
75
76    pub async fn execute(&self, input: HttpInput) -> Result<HttpOutput> {
77        let method = match self.method {
78            HttpMethod::Get => Method::GET,
79            HttpMethod::Post => Method::POST,
80            HttpMethod::Put => Method::PUT,
81            HttpMethod::Delete => Method::DELETE,
82            HttpMethod::Patch => Method::PATCH,
83        };
84
85        let mut request = self.client.request(method, &self.endpoint);
86
87        // Add headers
88        for (k, v) in &self.headers {
89            request = request.header(k, v);
90        }
91
92        // Add authentication
93        if let Some(auth) = &self.auth {
94            request = match auth {
95                AuthConfig::Bearer { token } => request.bearer_auth(token),
96                AuthConfig::Basic { username, password } => {
97                    request.basic_auth(username, Some(password))
98                }
99                AuthConfig::ApiKey { key, header } => request.header(header, key),
100            };
101        }
102
103        // Add query parameters
104        if !input.query.is_empty() {
105            request = request.query(&input.query);
106        }
107
108        // Add body for non-GET requests
109        if let Some(body) = input.body {
110            request = request.json(&body);
111        }
112
113        // Execute request
114        let response = request
115            .send()
116            .await
117            .map_err(|e| Error::Http(format!("Request failed: {}", e)))?;
118
119        let status = response.status().as_u16();
120
121        // Extract headers
122        let mut headers = FxHashMap::default();
123        for (k, v) in response.headers() {
124            if let Ok(v_str) = v.to_str() {
125                headers.insert(k.to_string(), v_str.to_string());
126            }
127        }
128
129        // Parse body as JSON (or empty object if fails)
130        let body = response
131            .json::<serde_json::Value>()
132            .await
133            .unwrap_or(serde_json::json!({}));
134
135        Ok(HttpOutput {
136            status,
137            body,
138            headers,
139        })
140    }
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146
147    #[test]
148    fn test_http_handler_new() {
149        let handler = HttpHandler::new(
150            "https://api.example.com".to_string(),
151            HttpMethod::Get,
152            FxHashMap::default(),
153            None,
154            None,
155        );
156
157        assert_eq!(handler.endpoint, "https://api.example.com");
158        assert!(handler.headers.is_empty());
159        assert!(handler.auth.is_none());
160        assert!(handler.timeout_ms.is_none());
161    }
162
163    #[test]
164    fn test_http_handler_new_with_auth() {
165        let mut headers = FxHashMap::default();
166        headers.insert("Content-Type".to_string(), "application/json".to_string());
167
168        let auth = Some(AuthConfig::Bearer {
169            token: "test_token".to_string(),
170        });
171
172        let handler = HttpHandler::new(
173            "https://api.example.com".to_string(),
174            HttpMethod::Post,
175            headers.clone(),
176            auth,
177            Some(30000), // 30 second timeout
178        );
179
180        assert_eq!(handler.endpoint, "https://api.example.com");
181        assert_eq!(handler.headers.len(), 1);
182        assert!(handler.auth.is_some());
183        assert_eq!(handler.timeout_ms, Some(30000));
184    }
185
186    #[test]
187    fn test_http_input_with_body() {
188        let json = r#"{"body": {"key": "value"}, "query": {}}"#;
189        let input: HttpInput = serde_json::from_str(json).unwrap();
190
191        assert!(input.body.is_some());
192        assert_eq!(input.body.unwrap()["key"], "value");
193    }
194
195    #[test]
196    fn test_http_input_with_query() {
197        let json = r#"{"body": null, "query": {"param": "value"}}"#;
198        let input: HttpInput = serde_json::from_str(json).unwrap();
199
200        assert!(input.body.is_none());
201        assert_eq!(input.query.get("param"), Some(&"value".to_string()));
202    }
203
204    #[test]
205    fn test_http_output_serialization() {
206        let mut headers = FxHashMap::default();
207        headers.insert("content-type".to_string(), "application/json".to_string());
208
209        let output = HttpOutput {
210            status: 200,
211            body: serde_json::json!({"result": "success"}),
212            headers,
213        };
214
215        let json = serde_json::to_string(&output).unwrap();
216        assert!(json.contains("\"status\":200"));
217        assert!(json.contains("\"result\":\"success\""));
218    }
219
220    #[tokio::test]
221    async fn test_execute_get_request() {
222        let mut server = mockito::Server::new_async().await;
223        let mock = server
224            .mock("GET", "/test")
225            .with_status(200)
226            .with_header("content-type", "application/json")
227            .with_body(r#"{"message": "success"}"#)
228            .create_async()
229            .await;
230
231        let handler = HttpHandler::new(
232            format!("{}/test", server.url()),
233            HttpMethod::Get,
234            FxHashMap::default(),
235            None,
236            None,
237        );
238
239        let input = HttpInput {
240            body: None,
241            query: FxHashMap::default(),
242        };
243
244        let output = handler.execute(input).await.unwrap();
245
246        assert_eq!(output.status, 200);
247        assert_eq!(output.body["message"], "success");
248        mock.assert_async().await;
249    }
250
251    #[tokio::test]
252    async fn test_execute_post_request_with_body() {
253        let mut server = mockito::Server::new_async().await;
254        let mock = server
255            .mock("POST", "/api/data")
256            .match_header("content-type", "application/json")
257            .match_body(mockito::Matcher::JsonString(
258                r#"{"key":"value"}"#.to_string(),
259            ))
260            .with_status(201)
261            .with_body(r#"{"id": "123"}"#)
262            .create_async()
263            .await;
264
265        let handler = HttpHandler::new(
266            format!("{}/api/data", server.url()),
267            HttpMethod::Post,
268            FxHashMap::default(),
269            None,
270            None,
271        );
272
273        let input = HttpInput {
274            body: Some(serde_json::json!({"key": "value"})),
275            query: FxHashMap::default(),
276        };
277
278        let output = handler.execute(input).await.unwrap();
279
280        assert_eq!(output.status, 201);
281        assert_eq!(output.body["id"], "123");
282        mock.assert_async().await;
283    }
284
285    #[tokio::test]
286    async fn test_execute_with_query_params() {
287        let mut server = mockito::Server::new_async().await;
288        let mock = server
289            .mock("GET", "/search")
290            .match_query(mockito::Matcher::AllOf(vec![
291                mockito::Matcher::UrlEncoded("q".to_string(), "rust".to_string()),
292                mockito::Matcher::UrlEncoded("limit".to_string(), "10".to_string()),
293            ]))
294            .with_status(200)
295            .with_body(r#"{"results": []}"#)
296            .create_async()
297            .await;
298
299        let handler = HttpHandler::new(
300            format!("{}/search", server.url()),
301            HttpMethod::Get,
302            FxHashMap::default(),
303            None,
304            None,
305        );
306
307        let mut query = FxHashMap::default();
308        query.insert("q".to_string(), "rust".to_string());
309        query.insert("limit".to_string(), "10".to_string());
310
311        let input = HttpInput { body: None, query };
312
313        let output = handler.execute(input).await.unwrap();
314
315        assert_eq!(output.status, 200);
316        mock.assert_async().await;
317    }
318
319    #[tokio::test]
320    async fn test_execute_with_bearer_auth() {
321        let mut server = mockito::Server::new_async().await;
322        let mock = server
323            .mock("GET", "/protected")
324            .match_header("authorization", "Bearer secret_token")
325            .with_status(200)
326            .with_body(r#"{"authorized": true}"#)
327            .create_async()
328            .await;
329
330        let handler = HttpHandler::new(
331            format!("{}/protected", server.url()),
332            HttpMethod::Get,
333            FxHashMap::default(),
334            Some(AuthConfig::Bearer {
335                token: "secret_token".to_string(),
336            }),
337            None,
338        );
339
340        let input = HttpInput {
341            body: None,
342            query: FxHashMap::default(),
343        };
344
345        let output = handler.execute(input).await.unwrap();
346
347        assert_eq!(output.status, 200);
348        assert_eq!(output.body["authorized"], true);
349        mock.assert_async().await;
350    }
351
352    #[tokio::test]
353    async fn test_execute_with_basic_auth() {
354        let mut server = mockito::Server::new_async().await;
355        let mock = server
356            .mock("GET", "/admin")
357            .match_header("authorization", "Basic dXNlcjpwYXNz")
358            .with_status(200)
359            .with_body(r#"{"admin": true}"#)
360            .create_async()
361            .await;
362
363        let handler = HttpHandler::new(
364            format!("{}/admin", server.url()),
365            HttpMethod::Get,
366            FxHashMap::default(),
367            Some(AuthConfig::Basic {
368                username: "user".to_string(),
369                password: "pass".to_string(),
370            }),
371            None,
372        );
373
374        let input = HttpInput {
375            body: None,
376            query: FxHashMap::default(),
377        };
378
379        let output = handler.execute(input).await.unwrap();
380
381        assert_eq!(output.status, 200);
382        assert_eq!(output.body["admin"], true);
383        mock.assert_async().await;
384    }
385
386    #[tokio::test]
387    async fn test_execute_with_api_key() {
388        let mut server = mockito::Server::new_async().await;
389        let mock = server
390            .mock("GET", "/api")
391            .match_header("x-api-key", "my_api_key")
392            .with_status(200)
393            .with_body(r#"{"valid": true}"#)
394            .create_async()
395            .await;
396
397        let handler = HttpHandler::new(
398            format!("{}/api", server.url()),
399            HttpMethod::Get,
400            FxHashMap::default(),
401            Some(AuthConfig::ApiKey {
402                key: "my_api_key".to_string(),
403                header: "x-api-key".to_string(),
404            }),
405            None,
406        );
407
408        let input = HttpInput {
409            body: None,
410            query: FxHashMap::default(),
411        };
412
413        let output = handler.execute(input).await.unwrap();
414
415        assert_eq!(output.status, 200);
416        assert_eq!(output.body["valid"], true);
417        mock.assert_async().await;
418    }
419
420    #[tokio::test]
421    async fn test_execute_with_custom_headers() {
422        let mut server = mockito::Server::new_async().await;
423        let mock = server
424            .mock("GET", "/headers")
425            .match_header("x-custom", "custom_value")
426            .match_header("x-request-id", "123")
427            .with_status(200)
428            .with_body(r#"{"ok": true}"#)
429            .create_async()
430            .await;
431
432        let mut headers = FxHashMap::default();
433        headers.insert("x-custom".to_string(), "custom_value".to_string());
434        headers.insert("x-request-id".to_string(), "123".to_string());
435
436        let handler = HttpHandler::new(
437            format!("{}/headers", server.url()),
438            HttpMethod::Get,
439            headers,
440            None,
441            None,
442        );
443
444        let input = HttpInput {
445            body: None,
446            query: FxHashMap::default(),
447        };
448
449        let output = handler.execute(input).await.unwrap();
450
451        assert_eq!(output.status, 200);
452        mock.assert_async().await;
453    }
454
455    #[tokio::test]
456    async fn test_execute_put_request() {
457        let mut server = mockito::Server::new_async().await;
458        let mock = server
459            .mock("PUT", "/update")
460            .with_status(200)
461            .with_body(r#"{"updated": true}"#)
462            .create_async()
463            .await;
464
465        let handler = HttpHandler::new(
466            format!("{}/update", server.url()),
467            HttpMethod::Put,
468            FxHashMap::default(),
469            None,
470            None,
471        );
472
473        let input = HttpInput {
474            body: Some(serde_json::json!({"data": "new_value"})),
475            query: FxHashMap::default(),
476        };
477
478        let output = handler.execute(input).await.unwrap();
479
480        assert_eq!(output.status, 200);
481        assert_eq!(output.body["updated"], true);
482        mock.assert_async().await;
483    }
484
485    #[tokio::test]
486    async fn test_execute_delete_request() {
487        let mut server = mockito::Server::new_async().await;
488        let mock = server
489            .mock("DELETE", "/resource/123")
490            .with_status(204)
491            .with_body("")
492            .create_async()
493            .await;
494
495        let handler = HttpHandler::new(
496            format!("{}/resource/123", server.url()),
497            HttpMethod::Delete,
498            FxHashMap::default(),
499            None,
500            None,
501        );
502
503        let input = HttpInput {
504            body: None,
505            query: FxHashMap::default(),
506        };
507
508        let output = handler.execute(input).await.unwrap();
509
510        assert_eq!(output.status, 204);
511        mock.assert_async().await;
512    }
513
514    #[tokio::test]
515    async fn test_execute_patch_request() {
516        let mut server = mockito::Server::new_async().await;
517        let mock = server
518            .mock("PATCH", "/partial")
519            .with_status(200)
520            .with_body(r#"{"patched": true}"#)
521            .create_async()
522            .await;
523
524        let handler = HttpHandler::new(
525            format!("{}/partial", server.url()),
526            HttpMethod::Patch,
527            FxHashMap::default(),
528            None,
529            None,
530        );
531
532        let input = HttpInput {
533            body: Some(serde_json::json!({"field": "value"})),
534            query: FxHashMap::default(),
535        };
536
537        let output = handler.execute(input).await.unwrap();
538
539        assert_eq!(output.status, 200);
540        assert_eq!(output.body["patched"], true);
541        mock.assert_async().await;
542    }
543
544    #[tokio::test]
545    async fn test_execute_error_handling() {
546        let handler = HttpHandler::new(
547            "http://localhost:1/nonexistent".to_string(),
548            HttpMethod::Get,
549            FxHashMap::default(),
550            None,
551            None,
552        );
553
554        let input = HttpInput {
555            body: None,
556            query: FxHashMap::default(),
557        };
558
559        let result = handler.execute(input).await;
560        assert!(result.is_err());
561    }
562}