crux_http 0.19.0

HTTP capability for use with crux_core
Documentation
use facet::Facet;
use serde::{Deserialize, Serialize};
use thiserror::Error as ThisError;

/// An error produced when an HTTP request fails.
///
/// Variants fall into two groups:
///
/// **Transport errors** — generated by the shell when it cannot complete the HTTP
/// exchange. These cross the FFI boundary and are serialized in the protocol:
/// [`Url`](HttpError::Url), [`Io`](HttpError::Io), [`Timeout`](HttpError::Timeout).
///
/// **Processing errors** — generated on the Rust side after a response arrives.
/// These are never serialized or visible to shells:
///
/// - [`Http`](HttpError::Http) — produced by `Response::new()` when the server returns
///   a 4xx or 5xx status. At the *protocol* level these arrive as
///   [`HttpResult::Ok`](crate::protocol::HttpResult::Ok); `Response::new()` converts
///   them here, so app code using `crux_http::Result<Response<T>>` will see them as
///   `Err(HttpError::Http { code, .. })`.
/// - [`Json`](HttpError::Json) — produced when response body deserialisation fails.
#[derive(Facet, Serialize, Deserialize, PartialEq, Eq, Clone, ThisError, Debug)]
#[repr(C)]
pub enum HttpError {
    // Note: Url, Io, Timeout must come first to preserve discriminant order across the FFI.
    /// The request URL could not be parsed.
    #[error("URL parse error: {0}")]
    Url(String),
    /// An IO error prevented the request from completing.
    #[error("IO error: {0}")]
    Io(String),
    /// The request timed out before a response was received.
    #[error("Timeout")]
    Timeout,

    // Internal only — not serialized, never sent over the FFI boundary.
    #[error("HTTP error {code}: {message}")]
    #[serde(skip)]
    #[facet(skip)]
    Http {
        code: u16,
        message: String,
        body: Option<Vec<u8>>,
    },
    #[error("JSON serialization error: {0}")]
    #[serde(skip)]
    #[facet(skip)]
    Json(String),
}

#[cfg(feature = "http-types")]
impl From<http_types::Error> for HttpError {
    fn from(e: http_types::Error) -> Self {
        Self::Http {
            code: e.status().into(),
            message: e.to_string(),
            body: None,
        }
    }
}

impl From<std::io::Error> for HttpError {
    fn from(e: std::io::Error) -> Self {
        Self::Io(e.to_string())
    }
}

impl From<serde_json::Error> for HttpError {
    fn from(e: serde_json::Error) -> Self {
        Self::Json(e.to_string())
    }
}

impl From<url::ParseError> for HttpError {
    fn from(e: url::ParseError) -> Self {
        Self::Url(e.to_string())
    }
}

impl From<serde_qs::Error> for HttpError {
    fn from(e: serde_qs::Error) -> Self {
        Self::Json(e.to_string())
    }
}

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

    #[test]
    fn test_error_display() {
        let error = HttpError::Http {
            code: 400,
            message: "Bad Request".to_string(),
            body: None,
        };
        assert_eq!(error.to_string(), "HTTP error 400: Bad Request");
    }

    #[test]
    fn http_code_is_plain_u16() {
        // The code field is a u16, so any valid status code literal works.
        let error = HttpError::Http {
            code: 404u16,
            message: "Not Found".to_string(),
            body: None,
        };
        assert_eq!(error.to_string(), "HTTP error 404: Not Found");
    }

    #[test]
    fn io_error_converts_to_io_variant() {
        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
        let http_err = HttpError::from(io_err);
        assert!(matches!(http_err, HttpError::Io(_)));
        assert_eq!(http_err.to_string(), "IO error: file not found");
    }

    #[test]
    fn serde_json_error_converts_to_json_variant() {
        let json_err: serde_json::Error =
            serde_json::from_str::<serde_json::Value>("{bad}").unwrap_err();
        let http_err = HttpError::from(json_err);
        assert!(matches!(http_err, HttpError::Json(_)));
    }

    #[test]
    fn url_parse_error_converts_to_url_variant() {
        let url_err = url::Url::parse("not a url").unwrap_err();
        let http_err = HttpError::from(url_err);
        assert!(matches!(http_err, HttpError::Url(_)));
    }

    #[test]
    fn serde_qs_error_converts_to_json_variant() {
        let qs_err: serde_qs::Error =
            serde_qs::from_str::<std::collections::HashMap<String, String>>("%bad%").unwrap_err();
        let http_err = HttpError::from(qs_err);
        assert!(matches!(http_err, HttpError::Json(_)));
    }
}