rskit-httpclient 0.2.0-alpha.1

Async HTTP client with auth and injected resilience policies for rskit
Documentation
//! HTTP response wrapper.

use bytes::Bytes;
use http::StatusCode;
use rskit_errors::{AppError, ErrorCode};
use serde::de::DeserializeOwned;
use std::collections::HashMap;

/// Non-success HTTP response details for service-specific error mapping.
#[derive(Debug, Clone)]
pub struct ErrorResponse {
    /// Response status code.
    pub status: StatusCode,
    /// Raw response headers.
    ///
    /// Header names are case-insensitive; use [`ErrorResponse::header`] for lookups.
    pub headers: HashMap<String, String>,
    /// Response body as UTF-8 text, or a diagnostic marker for non-UTF-8 bodies.
    pub body: String,
}

impl ErrorResponse {
    /// Gets a response header value by name (case-insensitive).
    #[must_use]
    pub fn header(&self, name: &str) -> Option<&String> {
        header_value(&self.headers, name)
    }
}

/// Wrapped HTTP response with convenience methods.
pub struct Response {
    /// Response status code
    pub status: StatusCode,

    /// Response headers
    pub headers: HashMap<String, String>,

    /// Response body bytes
    body: Bytes,
}

impl Response {
    /// Creates a new response.
    pub(crate) fn new(status: StatusCode, headers: HashMap<String, String>, body: Bytes) -> Self {
        Self {
            status,
            headers,
            body,
        }
    }

    /// Gets the response status code.
    #[must_use]
    pub fn status(&self) -> StatusCode {
        self.status
    }

    /// Gets the numeric response status code.
    #[must_use]
    pub fn status_u16(&self) -> u16 {
        self.status.as_u16()
    }

    /// Checks if the response status is successful (2xx).
    #[must_use]
    pub fn is_success(&self) -> bool {
        self.status.is_success()
    }

    /// Gets the response headers.
    #[must_use]
    pub fn headers(&self) -> &HashMap<String, String> {
        &self.headers
    }

    /// Gets a header value by name (case-insensitive).
    #[must_use]
    pub fn header(&self, name: &str) -> Option<&String> {
        header_value(&self.headers, name)
    }

    /// Gets the response body as a byte slice.
    #[must_use]
    pub fn body_bytes(&self) -> &Bytes {
        &self.body
    }

    /// Consumes the response and returns the body as bytes.
    pub fn into_bytes(self) -> Bytes {
        self.body
    }

    /// Converts the response body to a string.
    pub fn text(self) -> rskit_errors::AppResult<String> {
        String::from_utf8(self.body.to_vec())
            .map_err(|e| AppError::new(ErrorCode::InvalidInput, format!("invalid utf8: {}", e)))
    }

    /// Converts the response body to a string, returning a diagnostic marker for non-UTF-8 bodies.
    #[must_use]
    pub fn text_or_diagnostic(self) -> String {
        String::from_utf8(self.body.to_vec()).unwrap_or_else(|_| "<non-utf8 body>".to_string())
    }

    /// Parses the response body as JSON.
    pub fn json<T: DeserializeOwned>(self) -> rskit_errors::AppResult<T> {
        serde_json::from_slice(&self.body).map_err(|e| {
            AppError::new(
                ErrorCode::InvalidInput,
                format!("failed to parse json response: {}", e),
            )
        })
    }

    /// Returns the body text after converting non-2xx responses into an [`AppError`].
    pub fn checked_text(self) -> rskit_errors::AppResult<String> {
        self.error_for_status()?.text()
    }

    /// Returns the body text after applying custom non-2xx response mapping.
    pub fn checked_text_with<F>(self, mapper: F) -> rskit_errors::AppResult<String>
    where
        F: FnOnce(ErrorResponse) -> AppError,
    {
        self.error_for_status_with(mapper)?.text()
    }

    /// Returns the parsed JSON body after converting non-2xx responses into an [`AppError`].
    pub fn checked_json<T: DeserializeOwned>(self) -> rskit_errors::AppResult<T> {
        self.error_for_status()?.json()
    }

    /// Returns the parsed JSON body after applying custom non-2xx response mapping.
    pub fn checked_json_with<T, F>(self, mapper: F) -> rskit_errors::AppResult<T>
    where
        T: DeserializeOwned,
        F: FnOnce(ErrorResponse) -> AppError,
    {
        self.error_for_status_with(mapper)?.json()
    }

    /// Returns an error if the status is not 2xx.
    pub fn error_for_status(self) -> rskit_errors::AppResult<Self> {
        self.error_for_status_with(default_error_mapper)
    }

    /// Returns an error if the status is not 2xx, using the supplied response mapper.
    pub fn error_for_status_with<F>(self, mapper: F) -> rskit_errors::AppResult<Self>
    where
        F: FnOnce(ErrorResponse) -> AppError,
    {
        if self.status.is_success() {
            Ok(self)
        } else {
            let status = self.status;
            let headers = self.headers;
            let body = String::from_utf8(self.body.to_vec())
                .unwrap_or_else(|_| "<non-utf8 body>".to_string());

            Err(mapper(ErrorResponse {
                status,
                headers,
                body,
            }))
        }
    }
}

fn header_value<'a>(headers: &'a HashMap<String, String>, name: &str) -> Option<&'a String> {
    headers
        .iter()
        .find(|(key, _)| key.eq_ignore_ascii_case(name))
        .map(|(_, value)| value)
}

fn default_error_mapper(response: ErrorResponse) -> AppError {
    let status = response.status;
    let code = match status.as_u16() {
        400 => ErrorCode::InvalidInput,
        401 => ErrorCode::Unauthorized,
        403 => ErrorCode::Forbidden,
        404 => ErrorCode::NotFound,
        409 => ErrorCode::Conflict,
        429 => ErrorCode::RateLimited,
        500 | 502 | 503 | 504 => ErrorCode::Internal,
        _ => ErrorCode::ExternalService,
    };

    AppError::new(
        code,
        format!(
            "http error: {} {}",
            status.as_u16(),
            status.canonical_reason().unwrap_or("Unknown")
        ),
    )
    .with_detail("status", status.as_u16().to_string())
    .with_detail("body", response.body)
}

impl std::fmt::Debug for Response {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("Response")
            .field("status", &self.status)
            .field("headers", &self.headers)
            .field("body_len", &self.body.len())
            .finish()
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_response_status_check() {
        let resp = Response::new(StatusCode::OK, HashMap::new(), Bytes::from("ok"));
        assert!(resp.is_success());

        let resp = Response::new(
            StatusCode::NOT_FOUND,
            HashMap::new(),
            Bytes::from("not found"),
        );
        assert!(!resp.is_success());
    }

    #[test]
    fn test_response_header_case_insensitive() {
        let mut headers = HashMap::new();
        headers.insert("Content-Type".to_string(), "application/json".to_string());

        let resp = Response::new(StatusCode::OK, headers, Bytes::new());
        assert_eq!(
            resp.header("content-type"),
            Some(&"application/json".to_string())
        );
    }

    #[test]
    fn body_accessors_and_debug_do_not_expose_body_contents() {
        let resp = Response::new(
            StatusCode::CREATED,
            HashMap::from([("x-id".to_string(), "123".to_string())]),
            Bytes::from_static(b"created"),
        );

        assert_eq!(resp.body_bytes(), &Bytes::from_static(b"created"));
        let debug = format!("{resp:?}");
        assert!(debug.contains("body_len"));
        assert!(!debug.contains("created"));
        assert_eq!(resp.into_bytes(), Bytes::from_static(b"created"));
    }

    #[test]
    fn test_response_json() {
        let json_data = r#"{"key":"value"}"#;
        let resp = Response::new(StatusCode::OK, HashMap::new(), Bytes::from(json_data));

        let parsed: serde_json::Value = resp.json().unwrap();
        assert_eq!(parsed["key"], "value");
    }

    #[test]
    fn checked_text_rejects_error_status_with_body_detail() {
        let resp = Response::new(
            StatusCode::TOO_MANY_REQUESTS,
            HashMap::new(),
            Bytes::from("slow down"),
        );

        let error = resp.checked_text().unwrap_err();
        assert!(error.to_string().contains("http error: 429"));
        assert_eq!(
            error
                .details()
                .get("body")
                .and_then(serde_json::Value::as_str),
            Some("slow down")
        );
    }

    #[test]
    fn text_or_diagnostic_handles_non_utf8_body() {
        let resp = Response::new(
            StatusCode::BAD_GATEWAY,
            HashMap::new(),
            Bytes::from(vec![0xff]),
        );

        assert_eq!(resp.text_or_diagnostic(), "<non-utf8 body>");
    }

    #[test]
    fn custom_status_mapper_receives_status_headers_and_body() {
        let mut headers = HashMap::new();
        headers.insert("x-request-id".to_string(), "abc".to_string());
        let resp = Response::new(
            StatusCode::BAD_GATEWAY,
            headers,
            Bytes::from("upstream unavailable"),
        );

        let error = resp
            .error_for_status_with(|response| {
                AppError::new(ErrorCode::ExternalService, "mapped")
                    .with_detail("status", response.status.as_u16().to_string())
                    .with_detail(
                        "request_id",
                        response.header("X-Request-Id").cloned().unwrap_or_default(),
                    )
                    .with_detail("body", response.body)
            })
            .unwrap_err();

        assert_eq!(
            error
                .details()
                .get("request_id")
                .and_then(serde_json::Value::as_str),
            Some("abc")
        );
        assert_eq!(
            error
                .details()
                .get("body")
                .and_then(serde_json::Value::as_str),
            Some("upstream unavailable")
        );
    }

    #[test]
    fn checked_json_with_uses_custom_error_mapper() {
        let resp = Response::new(
            StatusCode::IM_A_TEAPOT,
            HashMap::new(),
            Bytes::from("short and stout"),
        );

        let error = resp
            .checked_json_with::<serde_json::Value, _>(|response| {
                AppError::new(
                    ErrorCode::ExternalService,
                    format!("mapped {}", response.status.as_u16()),
                )
                .with_detail("body", response.body)
            })
            .expect_err("teapot response should be mapped");

        assert!(error.message().contains("mapped 418"));
        assert_eq!(
            error
                .details()
                .get("body")
                .and_then(serde_json::Value::as_str),
            Some("short and stout")
        );
    }

    #[test]
    fn default_error_mapper_covers_client_auth_and_server_statuses() {
        let cases = [
            (StatusCode::BAD_REQUEST, ErrorCode::InvalidInput),
            (StatusCode::UNAUTHORIZED, ErrorCode::Unauthorized),
            (StatusCode::FORBIDDEN, ErrorCode::Forbidden),
            (StatusCode::INTERNAL_SERVER_ERROR, ErrorCode::Internal),
            (StatusCode::BAD_GATEWAY, ErrorCode::Internal),
            (StatusCode::SERVICE_UNAVAILABLE, ErrorCode::Internal),
            (StatusCode::GATEWAY_TIMEOUT, ErrorCode::Internal),
            (StatusCode::IM_A_TEAPOT, ErrorCode::ExternalService),
        ];

        for (status, code) in cases {
            let error = Response::new(status, HashMap::new(), Bytes::from("body"))
                .error_for_status()
                .expect_err("non-success status should fail");
            assert_eq!(error.code(), code, "{status}");
            assert_eq!(
                error
                    .details()
                    .get("status")
                    .and_then(serde_json::Value::as_str),
                Some(status.as_u16().to_string().as_str())
            );
        }
    }
}