#[cfg(feature = "http")]
use axum::{
http::StatusCode,
response::{IntoResponse, Json, Response},
};
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use feagi_services::ServiceError;
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub enum ApiErrorCode {
BadRequest = 400,
Unauthorized = 401,
Forbidden = 403,
NotFound = 404,
Conflict = 409,
UnprocessableEntity = 422,
Internal = 500,
NotImplemented = 501,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct ApiError {
pub code: u16,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub details: Option<String>,
}
impl ApiError {
pub fn new(message: impl Into<String>) -> Self {
Self {
code: ApiErrorCode::Internal as u16,
message: message.into(),
details: None,
}
}
pub fn with_code(mut self, code: ApiErrorCode) -> Self {
self.code = code as u16;
self
}
pub fn with_details(mut self, details: impl Into<String>) -> Self {
self.details = Some(details.into());
self
}
pub fn not_found(resource: &str, id: &str) -> Self {
Self::new(format!("{} '{}' not found", resource, id)).with_code(ApiErrorCode::NotFound)
}
pub fn invalid_input(message: impl Into<String>) -> Self {
Self::new(message).with_code(ApiErrorCode::BadRequest)
}
pub fn conflict(message: impl Into<String>) -> Self {
Self::new(message).with_code(ApiErrorCode::Conflict)
}
pub fn internal(message: impl Into<String>) -> Self {
Self::new(message).with_code(ApiErrorCode::Internal)
}
pub fn forbidden(message: impl Into<String>) -> Self {
Self::new(message).with_code(ApiErrorCode::Forbidden)
}
pub fn not_implemented(message: impl Into<String>) -> Self {
Self::new(message).with_code(ApiErrorCode::NotImplemented)
}
}
impl From<ServiceError> for ApiError {
fn from(error: ServiceError) -> Self {
match error {
ServiceError::NotFound { resource, id } => {
ApiError::new(format!("{} '{}' not found", resource, id))
.with_code(ApiErrorCode::NotFound)
}
ServiceError::InvalidInput(msg) => {
ApiError::new(msg).with_code(ApiErrorCode::BadRequest)
}
ServiceError::AlreadyExists { resource, id } => {
ApiError::new(format!("{} '{}' already exists", resource, id))
.with_code(ApiErrorCode::Conflict)
}
ServiceError::Conflict(msg) => ApiError::conflict(msg),
ServiceError::Internal(msg) => ApiError::new(msg).with_code(ApiErrorCode::Internal),
ServiceError::Forbidden(msg) => ApiError::new(msg).with_code(ApiErrorCode::Forbidden),
ServiceError::Backend(msg) => ApiError::new(msg).with_code(ApiErrorCode::Internal),
ServiceError::StateError(msg) => ApiError::new(msg).with_code(ApiErrorCode::Internal),
ServiceError::InvalidState(msg) => ApiError::new(msg).with_code(ApiErrorCode::Conflict),
ServiceError::NotImplemented(msg) => {
ApiError::new(msg).with_code(ApiErrorCode::NotImplemented)
}
}
}
}
#[cfg(feature = "http")]
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
let status_code =
StatusCode::from_u16(self.code).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
(status_code, Json(self)).into_response()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_api_error_creation() {
let error = ApiError::not_found("User", "123");
assert_eq!(error.code, 404);
assert!(error.message.contains("User"));
assert!(error.message.contains("123"));
}
#[test]
fn test_service_error_conversion() {
let service_error = ServiceError::NotFound {
resource: "Cortical Area".to_string(),
id: "v1".to_string(),
};
let api_error: ApiError = service_error.into();
assert_eq!(api_error.code, 404);
assert!(api_error.message.contains("Cortical Area"));
}
}