fierros_core/
http_client.rs1use 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}