fierros-core 1.0.0

Provider-neutral core primitives for Fierros
Documentation
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'"));
    }
}