lmrc-http-common 0.3.16

Common HTTP utilities and patterns for LMRC Stack applications
Documentation
//! Common error types for HTTP services

use axum::{
    http::StatusCode,
    response::{IntoResponse, Response},
    Json,
};
use serde::{Deserialize, Serialize};
use thiserror::Error;

/// Standard error response format
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ErrorResponse {
    /// Error code/type
    pub error: String,
    /// Human-readable error message
    pub message: String,
    /// Optional additional details
    #[serde(skip_serializing_if = "Option::is_none")]
    pub details: Option<serde_json::Value>,
}

impl ErrorResponse {
    pub fn new(error: impl Into<String>, message: impl Into<String>) -> Self {
        Self {
            error: error.into(),
            message: message.into(),
            details: None,
        }
    }

    pub fn with_details(mut self, details: serde_json::Value) -> Self {
        self.details = Some(details);
        self
    }
}

/// Common HTTP application errors
#[derive(Debug, Error)]
pub enum HttpError {
    /// Bad request (400)
    #[error("Bad request: {0}")]
    BadRequest(String),

    /// Unauthorized (401)
    #[error("Unauthorized: {0}")]
    Unauthorized(String),

    /// Forbidden (403)
    #[error("Forbidden: {0}")]
    Forbidden(String),

    /// Not found (404)
    #[error("Not found: {0}")]
    NotFound(String),

    /// Conflict (409)
    #[error("Conflict: {0}")]
    Conflict(String),

    /// Unprocessable entity (422)
    #[error("Validation failed: {0}")]
    ValidationError(String),

    /// Internal server error (500)
    #[error("Internal server error: {0}")]
    InternalServer(String),

    /// Service unavailable (503)
    #[error("Service unavailable: {0}")]
    ServiceUnavailable(String),

    /// Database error
    #[error("Database error: {0}")]
    Database(String),

    /// External service error
    #[error("External service error: {0}")]
    ExternalService(String),

    /// Custom error with status code
    #[error("{message}")]
    Custom {
        status: StatusCode,
        error_code: String,
        message: String,
    },
}

impl HttpError {
    /// Get the HTTP status code for this error
    pub fn status_code(&self) -> StatusCode {
        match self {
            HttpError::BadRequest(_) => StatusCode::BAD_REQUEST,
            HttpError::Unauthorized(_) => StatusCode::UNAUTHORIZED,
            HttpError::Forbidden(_) => StatusCode::FORBIDDEN,
            HttpError::NotFound(_) => StatusCode::NOT_FOUND,
            HttpError::Conflict(_) => StatusCode::CONFLICT,
            HttpError::ValidationError(_) => StatusCode::UNPROCESSABLE_ENTITY,
            HttpError::InternalServer(_) => StatusCode::INTERNAL_SERVER_ERROR,
            HttpError::ServiceUnavailable(_) => StatusCode::SERVICE_UNAVAILABLE,
            HttpError::Database(_) => StatusCode::INTERNAL_SERVER_ERROR,
            HttpError::ExternalService(_) => StatusCode::BAD_GATEWAY,
            HttpError::Custom { status, .. } => *status,
        }
    }

    /// Get the error code string
    pub fn error_code(&self) -> String {
        match self {
            HttpError::BadRequest(_) => "BAD_REQUEST".to_string(),
            HttpError::Unauthorized(_) => "UNAUTHORIZED".to_string(),
            HttpError::Forbidden(_) => "FORBIDDEN".to_string(),
            HttpError::NotFound(_) => "NOT_FOUND".to_string(),
            HttpError::Conflict(_) => "CONFLICT".to_string(),
            HttpError::ValidationError(_) => "VALIDATION_ERROR".to_string(),
            HttpError::InternalServer(_) => "INTERNAL_SERVER_ERROR".to_string(),
            HttpError::ServiceUnavailable(_) => "SERVICE_UNAVAILABLE".to_string(),
            HttpError::Database(_) => "DATABASE_ERROR".to_string(),
            HttpError::ExternalService(_) => "EXTERNAL_SERVICE_ERROR".to_string(),
            HttpError::Custom { error_code, .. } => error_code.clone(),
        }
    }

    /// Create a custom error with specific status code
    pub fn custom(
        status: StatusCode,
        error_code: impl Into<String>,
        message: impl Into<String>,
    ) -> Self {
        HttpError::Custom {
            status,
            error_code: error_code.into(),
            message: message.into(),
        }
    }
}

impl IntoResponse for HttpError {
    fn into_response(self) -> Response {
        let status = self.status_code();
        let error_code = self.error_code();
        let message = self.to_string();

        let body = ErrorResponse::new(error_code, message);

        (status, Json(body)).into_response()
    }
}

/// Convenience type for Result with HttpError
pub type HttpResult<T> = Result<T, HttpError>;

// Helpful From implementations for common error types

#[cfg(feature = "validation")]
impl From<validator::ValidationErrors> for HttpError {
    fn from(err: validator::ValidationErrors) -> Self {
        use std::fmt::Write;

        let mut message = String::new();
        let mut first = true;

        for (field, field_errors) in err.field_errors() {
            for error in field_errors {
                if !first {
                    write!(&mut message, "; ").unwrap();
                }
                first = false;

                write!(&mut message, "{}: ", field).unwrap();

                if let Some(msg) = &error.message {
                    write!(&mut message, "{}", msg).unwrap();
                } else {
                    write!(&mut message, "validation failed ({})", error.code).unwrap();
                }
            }
        }

        if message.is_empty() {
            HttpError::ValidationError("Validation failed".to_string())
        } else {
            HttpError::ValidationError(message)
        }
    }
}

/// Macro for creating app-specific error types that wrap HttpError
///
/// This makes it easy to create custom error types for your application
/// while leveraging the existing HttpError infrastructure.
///
/// ## Example
///
/// ```rust
/// use lmrc_http_common::app_error;
///
/// app_error! {
///     MyAppError {
///         BusinessLogic(String),
///         ExternalApi(String),
///     }
/// }
///
/// // Now you can use MyAppError in your handlers:
/// // fn my_handler() -> Result<(), MyAppError> { ... }
/// ```
#[macro_export]
macro_rules! app_error {
    ($name:ident { $($variant:ident($ty:ty)),* $(,)? }) => {
        #[derive(Debug, thiserror::Error)]
        pub enum $name {
            /// HTTP error
            #[error(transparent)]
            Http(#[from] $crate::error::HttpError),

            $(
                #[error("{0}")]
                $variant($ty),
            )*
        }

        impl axum::response::IntoResponse for $name {
            fn into_response(self) -> axum::response::Response {
                match self {
                    Self::Http(e) => e.into_response(),
                    _ => {
                        $crate::error::HttpError::InternalServer(self.to_string()).into_response()
                    }
                }
            }
        }
    };
}

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

    #[test]
    fn test_error_status_codes() {
        assert_eq!(
            HttpError::BadRequest("test".into()).status_code(),
            StatusCode::BAD_REQUEST
        );
        assert_eq!(
            HttpError::Unauthorized("test".into()).status_code(),
            StatusCode::UNAUTHORIZED
        );
        assert_eq!(
            HttpError::NotFound("test".into()).status_code(),
            StatusCode::NOT_FOUND
        );
    }

    #[test]
    fn test_custom_error() {
        let err = HttpError::custom(StatusCode::IM_A_TEAPOT, "TEAPOT", "I'm a teapot");
        assert_eq!(err.status_code(), StatusCode::IM_A_TEAPOT);
        assert_eq!(err.error_code(), "TEAPOT");
    }
}