use crate::{FierrosError, FierrosResult};
use async_trait::async_trait;
use reqwest::header::{HeaderName, HeaderValue};
use serde_json::Value;
#[derive(Debug, Clone, PartialEq)]
pub struct JsonHttpRequest {
pub url: String,
pub headers: Vec<(String, String)>,
pub body: Value,
}
#[async_trait]
pub trait JsonHttpClient: Send + Sync {
async fn post_json(&self, request: JsonHttpRequest) -> FierrosResult<Value>;
}
#[derive(Debug, Clone, Default)]
pub struct ReqwestJsonHttpClient {
client: reqwest::Client,
}
impl ReqwestJsonHttpClient {
pub fn new(client: reqwest::Client) -> Self {
Self { client }
}
}
#[async_trait]
impl JsonHttpClient for ReqwestJsonHttpClient {
async fn post_json(&self, request: JsonHttpRequest) -> FierrosResult<Value> {
let mut builder = self.client.post(&request.url);
for (name, value) in &request.headers {
let header_name = HeaderName::from_bytes(name.as_bytes()).map_err(|error| {
FierrosError::Configuration(format!("invalid HTTP header name '{name}': {error}"))
})?;
let header_value = HeaderValue::from_str(value).map_err(|error| {
FierrosError::Configuration(format!(
"invalid HTTP header value for '{name}': {error}"
))
})?;
builder = builder.header(header_name, header_value);
}
let response = builder
.json(&request.body)
.send()
.await
.map_err(|error| FierrosError::Provider(format!("request failed: {error}")))?;
let status = response.status();
let body_text = response.text().await.map_err(|error| {
FierrosError::Provider(format!("failed to read response body: {error}"))
})?;
if !status.is_success() {
return Err(FierrosError::Provider(format!(
"HTTP {} from '{}': {body_text}",
status.as_u16(),
request.url
)));
}
serde_json::from_str(&body_text).map_err(|error| {
FierrosError::Provider(format!("response body was not valid JSON: {error}"))
})
}
}
#[cfg(test)]
mod tests {
use super::{JsonHttpClient, JsonHttpRequest, ReqwestJsonHttpClient};
use crate::FierrosError;
use serde_json::json;
#[tokio::test]
async fn reqwest_client_constructor_accepts_custom_client() {
let client = ReqwestJsonHttpClient::new(reqwest::Client::new());
let error = client
.post_json(JsonHttpRequest {
url: "http://127.0.0.1:1".into(),
headers: vec![("invalid header".into(), "token".into())],
body: json!({}),
})
.await
.unwrap_err();
assert!(matches!(error, FierrosError::Configuration(_)));
}
#[tokio::test]
async fn post_json_surfaces_request_errors_for_invalid_url() {
let client = ReqwestJsonHttpClient::default();
let error = client
.post_json(JsonHttpRequest {
url: "::://not-a-valid-url".into(),
headers: vec![],
body: json!({ "query": "status" }),
})
.await
.unwrap_err();
assert!(matches!(error, FierrosError::Provider(_)));
assert!(error.to_string().contains("request failed"));
}
#[tokio::test]
async fn post_json_rejects_invalid_header_name() {
let client = ReqwestJsonHttpClient::default();
let error = client
.post_json(JsonHttpRequest {
url: "http://127.0.0.1:1".into(),
headers: vec![("invalid header".into(), "token".into())],
body: json!({}),
})
.await
.unwrap_err();
assert!(matches!(error, FierrosError::Configuration(_)));
assert!(error.to_string().contains("invalid HTTP header name"));
}
#[tokio::test]
async fn post_json_rejects_invalid_header_value() {
let client = ReqwestJsonHttpClient::default();
let error = client
.post_json(JsonHttpRequest {
url: "http://127.0.0.1:1".into(),
headers: vec![("x-auth-token".into(), "bad\nvalue".into())],
body: json!({ "query": "status" }),
})
.await
.unwrap_err();
assert!(matches!(error, FierrosError::Configuration(_)));
assert!(error
.to_string()
.contains("invalid HTTP header value for 'x-auth-token'"));
}
}