Skip to main content

fierros_core/
http_client.rs

1use crate::{FierrosError, FierrosResult};
2use async_trait::async_trait;
3use reqwest::header::{HeaderName, HeaderValue};
4use serde_json::Value;
5
6#[derive(Debug, Clone, PartialEq)]
7pub struct JsonHttpRequest {
8    pub url: String,
9    pub headers: Vec<(String, String)>,
10    pub body: Value,
11}
12
13#[async_trait]
14pub trait JsonHttpClient: Send + Sync {
15    async fn post_json(&self, request: JsonHttpRequest) -> FierrosResult<Value>;
16}
17
18#[derive(Debug, Clone, Default)]
19pub struct ReqwestJsonHttpClient {
20    client: reqwest::Client,
21}
22
23impl ReqwestJsonHttpClient {
24    pub fn new(client: reqwest::Client) -> Self {
25        Self { client }
26    }
27}
28
29#[async_trait]
30impl JsonHttpClient for ReqwestJsonHttpClient {
31    async fn post_json(&self, request: JsonHttpRequest) -> FierrosResult<Value> {
32        let mut builder = self.client.post(&request.url);
33        for (name, value) in &request.headers {
34            let header_name = HeaderName::from_bytes(name.as_bytes()).map_err(|error| {
35                FierrosError::Configuration(format!("invalid HTTP header name '{name}': {error}"))
36            })?;
37            let header_value = HeaderValue::from_str(value).map_err(|error| {
38                FierrosError::Configuration(format!(
39                    "invalid HTTP header value for '{name}': {error}"
40                ))
41            })?;
42            builder = builder.header(header_name, header_value);
43        }
44
45        let response = builder
46            .json(&request.body)
47            .send()
48            .await
49            .map_err(|error| FierrosError::Provider(format!("request failed: {error}")))?;
50
51        let status = response.status();
52        let body_text = response.text().await.map_err(|error| {
53            FierrosError::Provider(format!("failed to read response body: {error}"))
54        })?;
55        if !status.is_success() {
56            return Err(FierrosError::Provider(format!(
57                "HTTP {} from '{}': {body_text}",
58                status.as_u16(),
59                request.url
60            )));
61        }
62
63        serde_json::from_str(&body_text).map_err(|error| {
64            FierrosError::Provider(format!("response body was not valid JSON: {error}"))
65        })
66    }
67}
68
69#[cfg(test)]
70mod tests {
71    use super::{JsonHttpClient, JsonHttpRequest, ReqwestJsonHttpClient};
72    use crate::FierrosError;
73    use serde_json::json;
74
75    #[tokio::test]
76    async fn reqwest_client_constructor_accepts_custom_client() {
77        let client = ReqwestJsonHttpClient::new(reqwest::Client::new());
78        let error = client
79            .post_json(JsonHttpRequest {
80                url: "http://127.0.0.1:1".into(),
81                headers: vec![("invalid header".into(), "token".into())],
82                body: json!({}),
83            })
84            .await
85            .unwrap_err();
86
87        assert!(matches!(error, FierrosError::Configuration(_)));
88    }
89
90    #[tokio::test]
91    async fn post_json_surfaces_request_errors_for_invalid_url() {
92        let client = ReqwestJsonHttpClient::default();
93        let error = client
94            .post_json(JsonHttpRequest {
95                url: "::://not-a-valid-url".into(),
96                headers: vec![],
97                body: json!({ "query": "status" }),
98            })
99            .await
100            .unwrap_err();
101
102        assert!(matches!(error, FierrosError::Provider(_)));
103        assert!(error.to_string().contains("request failed"));
104    }
105
106    #[tokio::test]
107    async fn post_json_rejects_invalid_header_name() {
108        let client = ReqwestJsonHttpClient::default();
109        let error = client
110            .post_json(JsonHttpRequest {
111                url: "http://127.0.0.1:1".into(),
112                headers: vec![("invalid header".into(), "token".into())],
113                body: json!({}),
114            })
115            .await
116            .unwrap_err();
117
118        assert!(matches!(error, FierrosError::Configuration(_)));
119        assert!(error.to_string().contains("invalid HTTP header name"));
120    }
121
122    #[tokio::test]
123    async fn post_json_rejects_invalid_header_value() {
124        let client = ReqwestJsonHttpClient::default();
125        let error = client
126            .post_json(JsonHttpRequest {
127                url: "http://127.0.0.1:1".into(),
128                headers: vec![("x-auth-token".into(), "bad\nvalue".into())],
129                body: json!({ "query": "status" }),
130            })
131            .await
132            .unwrap_err();
133
134        assert!(matches!(error, FierrosError::Configuration(_)));
135        assert!(error
136            .to_string()
137            .contains("invalid HTTP header value for 'x-auth-token'"));
138    }
139}