road-runner-common 0.9.0

Shared Rust utilities for exchange ecosystem backend services.
Documentation
use crate::response::{ApiErrorDetail, ApiResponse};
use thiserror::Error;

#[derive(Debug, Clone, Copy)]
pub enum ErrorCode {
    Validation,
    Unauthorized,
    Forbidden,
    NotFound,
    Conflict,
    RateLimited,
    Internal,
    External,
    ServiceUnavailable,
}

impl ErrorCode {
    pub fn as_str(self) -> &'static str {
        match self {
            ErrorCode::Validation => "VALIDATION_ERROR",
            ErrorCode::Unauthorized => "UNAUTHORIZED",
            ErrorCode::Forbidden => "FORBIDDEN",
            ErrorCode::NotFound => "NOT_FOUND",
            ErrorCode::Conflict => "CONFLICT",
            ErrorCode::RateLimited => "RATE_LIMITED",
            ErrorCode::Internal => "INTERNAL_ERROR",
            ErrorCode::External => "EXTERNAL_ERROR",
            ErrorCode::ServiceUnavailable => "SERVICE_UNAVAILABLE",
        }
    }
}

#[derive(Error, Debug)]
pub enum AppError {
    #[error("Validation failed")]
    Validation {
        message: String,
        #[source]
        source: Option<anyhow::Error>,
        details: Vec<ApiErrorDetail>,
    },

    #[error("Unauthorized: {message}")]
    Unauthorized { message: String },

    #[error("Forbidden: {message}")]
    Forbidden { message: String },

    #[error("Not found: {message}")]
    NotFound { message: String },

    #[error("Conflict: {message}")]
    Conflict { message: String },

    #[error("Rate limited: {message}")]
    RateLimited { message: String },

    #[error("Internal error")]
    Internal {
        message: String,
        #[source]
        source: Option<anyhow::Error>,
    },

    #[error("External dependency error")]
    External {
        message: String,
        #[source]
        source: Option<anyhow::Error>,
    },

    #[error("Service unavailable: {message}")]
    ServiceUnavailable { message: String },
}

impl AppError {
    pub fn code(&self) -> ErrorCode {
        match self {
            AppError::Validation { .. } => ErrorCode::Validation,
            AppError::Unauthorized { .. } => ErrorCode::Unauthorized,
            AppError::Forbidden { .. } => ErrorCode::Forbidden,
            AppError::NotFound { .. } => ErrorCode::NotFound,
            AppError::Conflict { .. } => ErrorCode::Conflict,
            AppError::RateLimited { .. } => ErrorCode::RateLimited,
            AppError::Internal { .. } => ErrorCode::Internal,
            AppError::External { .. } => ErrorCode::External,
            AppError::ServiceUnavailable { .. } => ErrorCode::ServiceUnavailable,
        }
    }

    pub fn public_message(&self) -> String {
        match self {
            AppError::Validation { message, .. } => message.clone(),
            AppError::Unauthorized { message } => message.clone(),
            AppError::Forbidden { message } => message.clone(),
            AppError::NotFound { message } => message.clone(),
            AppError::Conflict { message } => message.clone(),
            AppError::RateLimited { message } => message.clone(),
            AppError::Internal { .. } => "Internal server error".to_string(),
            AppError::External { .. } => "Upstream service error".to_string(),
            AppError::ServiceUnavailable { message } => message.clone(),
        }
    }

    pub fn validation(message: impl Into<String>, details: Vec<ApiErrorDetail>) -> Self {
        Self::Validation {
            message: message.into(),
            source: None,
            details,
        }
    }

    pub fn internal(message: impl Into<String>, source: Option<anyhow::Error>) -> Self {
        Self::Internal {
            message: message.into(),
            source,
        }
    }

    pub fn external(message: impl Into<String>, source: Option<anyhow::Error>) -> Self {
        Self::External {
            message: message.into(),
            source,
        }
    }

    pub fn to_api_response(&self, request_id: impl Into<String>) -> ApiResponse<()> {
        let rid = request_id.into();
        match self {
            AppError::Validation { details, .. } => {
                let mut errs = details.clone();
                if errs.is_empty() {
                    errs.push(ApiErrorDetail::new(self.code().as_str(), self.public_message()));
                }
                ApiResponse::err(errs, rid)
            }
            _ => ApiResponse::err(
                vec![ApiErrorDetail::new(self.code().as_str(), self.public_message())],
                rid,
            ),
        }
    }
}

#[cfg(feature = "web")]
mod web_impl {
    use super::*;
    use actix_web::{http::StatusCode, HttpResponse, ResponseError};

    impl AppError {
        pub fn http_status(&self) -> StatusCode {
            match self {
                AppError::Validation { .. } => StatusCode::BAD_REQUEST,
                AppError::Unauthorized { .. } => StatusCode::UNAUTHORIZED,
                AppError::Forbidden { .. } => StatusCode::FORBIDDEN,
                AppError::NotFound { .. } => StatusCode::NOT_FOUND,
                AppError::Conflict { .. } => StatusCode::CONFLICT,
                AppError::RateLimited { .. } => StatusCode::TOO_MANY_REQUESTS,
                AppError::Internal { .. } => StatusCode::INTERNAL_SERVER_ERROR,
                AppError::External { .. } => StatusCode::BAD_GATEWAY,
                AppError::ServiceUnavailable { .. } => StatusCode::SERVICE_UNAVAILABLE,
            }
        }
    }

    /// Actix: `Result<T, AppError>` döndüğünde otomatik olarak buradan HTTP response üretir.
    impl ResponseError for AppError {
        fn status_code(&self) -> StatusCode {
            self.http_status()
        }

        fn error_response(&self) -> HttpResponse {
            // request_id propagation: idealde middleware header/extension ile koyar.
            // Burada fallback üretiyoruz.
            let request_id = uuid::Uuid::new_v4().to_string();

            #[cfg(feature = "observability")]
            tracing::error!(error_code = %self.code().as_str(), error = ?self, "request failed");

            let body = self.to_api_response(request_id);
            HttpResponse::build(self.status_code()).json(body)
        }
    }
}