use axum::http::StatusCode; use axum::response::{IntoResponse, Response}; use serde_json::json; #[derive(Debug, thiserror::Error)] pub enum AppError { #[error("Authentication required")] Unauthorized, #[error("Forbidden: {0}")] Forbidden(String), #[error("Not found: {0}")] NotFound(String), #[error("Bad request: {0}")] BadRequest(String), #[error("gRPC error: {0}")] GrpcError(#[from] tonic::Status), #[error("Internal error: {0}")] Internal(#[from] anyhow::Error), } impl IntoResponse for AppError { fn into_response(self) -> Response { let (status, message) = match &self { AppError::Unauthorized => (StatusCode::UNAUTHORIZED, self.to_string()), AppError::Forbidden(msg) => (StatusCode::FORBIDDEN, msg.clone()), AppError::NotFound(msg) => (StatusCode::NOT_FOUND, msg.clone()), AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg.clone()), AppError::GrpcError(s) => ( #[allow(clippy::wildcard_enum_match_arm)] match s.code() { tonic::Code::NotFound => StatusCode::NOT_FOUND, tonic::Code::PermissionDenied => StatusCode::FORBIDDEN, tonic::Code::InvalidArgument => StatusCode::BAD_REQUEST, tonic::Code::Unauthenticated => StatusCode::UNAUTHORIZED, tonic::Code::DeadlineExceeded => StatusCode::REQUEST_TIMEOUT, tonic::Code::Cancelled => StatusCode::from_u16(499) .unwrap_or(StatusCode::INTERNAL_SERVER_ERROR), tonic::Code::AlreadyExists => StatusCode::CONFLICT, tonic::Code::ResourceExhausted => StatusCode::TOO_MANY_REQUESTS, tonic::Code::Unavailable => StatusCode::SERVICE_UNAVAILABLE, _ => StatusCode::INTERNAL_SERVER_ERROR, }, s.message().to_owned(), ), AppError::Internal(_) => ( StatusCode::INTERNAL_SERVER_ERROR, "Internal server error".to_owned(), ), }; let body = json!({ "error": message }); (status, axum::Json(body)).into_response() } } #[cfg(test)] #[allow(clippy::unwrap_used)] mod tests { use super::*; use axum::response::IntoResponse; #[test] fn test_unauthorized_status() { let resp = AppError::Unauthorized.into_response(); assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); } #[test] fn test_forbidden_status() { let resp = AppError::Forbidden("denied".into()).into_response(); assert_eq!(resp.status(), StatusCode::FORBIDDEN); } #[test] fn test_not_found_status() { let resp = AppError::NotFound("missing".into()).into_response(); assert_eq!(resp.status(), StatusCode::NOT_FOUND); } #[test] fn test_bad_request_status() { let resp = AppError::BadRequest("invalid".into()).into_response(); assert_eq!(resp.status(), StatusCode::BAD_REQUEST); } #[test] fn test_internal_error_status() { let err = anyhow::anyhow!("something broke"); let resp = AppError::Internal(err).into_response(); assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR); } #[test] fn test_grpc_not_found_maps_to_404() { let status = tonic::Status::not_found("no such resource"); let resp = AppError::GrpcError(status).into_response(); assert_eq!(resp.status(), StatusCode::NOT_FOUND); } #[test] fn test_grpc_permission_denied_maps_to_403() { let status = tonic::Status::permission_denied("forbidden"); let resp = AppError::GrpcError(status).into_response(); assert_eq!(resp.status(), StatusCode::FORBIDDEN); } #[test] fn test_grpc_invalid_argument_maps_to_400() { let status = tonic::Status::invalid_argument("bad field"); let resp = AppError::GrpcError(status).into_response(); assert_eq!(resp.status(), StatusCode::BAD_REQUEST); } #[test] fn test_grpc_unauthenticated_maps_to_401() { let status = tonic::Status::unauthenticated("no token"); let resp = AppError::GrpcError(status).into_response(); assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); } #[test] fn test_grpc_unknown_maps_to_500() { let status = tonic::Status::internal("crash"); let resp = AppError::GrpcError(status).into_response(); assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR); } #[test] fn test_grpc_deadline_exceeded_maps_to_408() { let status = tonic::Status::deadline_exceeded("timed out"); let resp = AppError::GrpcError(status).into_response(); assert_eq!(resp.status(), StatusCode::REQUEST_TIMEOUT); } #[test] fn test_grpc_cancelled_maps_to_499() { let status = tonic::Status::cancelled("client cancelled"); let resp = AppError::GrpcError(status).into_response(); assert_eq!(resp.status().as_u16(), 499); } #[test] fn test_grpc_already_exists_maps_to_409() { let status = tonic::Status::already_exists("duplicate"); let resp = AppError::GrpcError(status).into_response(); assert_eq!(resp.status(), StatusCode::CONFLICT); } #[test] fn test_grpc_resource_exhausted_maps_to_429() { let status = tonic::Status::resource_exhausted("rate limited"); let resp = AppError::GrpcError(status).into_response(); assert_eq!(resp.status(), StatusCode::TOO_MANY_REQUESTS); } #[test] fn test_grpc_unavailable_maps_to_503() { let status = tonic::Status::unavailable("service down"); let resp = AppError::GrpcError(status).into_response(); assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE); } }