feagi_api/common/
error.rs1#[cfg(feature = "http")]
7use axum::{
8 http::StatusCode,
9 response::{IntoResponse, Json, Response},
10};
11use serde::{Deserialize, Serialize};
12use utoipa::ToSchema;
13
14use feagi_services::ServiceError;
15
16#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
18pub enum ApiErrorCode {
19 BadRequest = 400,
20 Unauthorized = 401,
21 Forbidden = 403,
22 NotFound = 404,
23 Conflict = 409,
24 UnprocessableEntity = 422,
25 Internal = 500,
26 NotImplemented = 501,
27}
28
29#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
31pub struct ApiError {
32 pub code: u16,
34
35 pub message: String,
37
38 #[serde(skip_serializing_if = "Option::is_none")]
40 pub details: Option<String>,
41}
42
43impl ApiError {
44 pub fn new(message: impl Into<String>) -> Self {
46 Self {
47 code: ApiErrorCode::Internal as u16,
48 message: message.into(),
49 details: None,
50 }
51 }
52
53 pub fn with_code(mut self, code: ApiErrorCode) -> Self {
55 self.code = code as u16;
56 self
57 }
58
59 pub fn with_details(mut self, details: impl Into<String>) -> Self {
61 self.details = Some(details.into());
62 self
63 }
64
65 pub fn not_found(resource: &str, id: &str) -> Self {
67 Self::new(format!("{} '{}' not found", resource, id)).with_code(ApiErrorCode::NotFound)
68 }
69
70 pub fn invalid_input(message: impl Into<String>) -> Self {
72 Self::new(message).with_code(ApiErrorCode::BadRequest)
73 }
74
75 pub fn conflict(message: impl Into<String>) -> Self {
77 Self::new(message).with_code(ApiErrorCode::Conflict)
78 }
79
80 pub fn internal(message: impl Into<String>) -> Self {
82 Self::new(message).with_code(ApiErrorCode::Internal)
83 }
84
85 pub fn forbidden(message: impl Into<String>) -> Self {
87 Self::new(message).with_code(ApiErrorCode::Forbidden)
88 }
89
90 pub fn not_implemented(message: impl Into<String>) -> Self {
92 Self::new(message).with_code(ApiErrorCode::NotImplemented)
93 }
94}
95
96impl From<ServiceError> for ApiError {
98 fn from(error: ServiceError) -> Self {
99 match error {
100 ServiceError::NotFound { resource, id } => {
101 ApiError::new(format!("{} '{}' not found", resource, id))
102 .with_code(ApiErrorCode::NotFound)
103 }
104 ServiceError::InvalidInput(msg) => {
105 ApiError::new(msg).with_code(ApiErrorCode::BadRequest)
106 }
107 ServiceError::AlreadyExists { resource, id } => {
108 ApiError::new(format!("{} '{}' already exists", resource, id))
109 .with_code(ApiErrorCode::Conflict)
110 }
111 ServiceError::Internal(msg) => ApiError::new(msg).with_code(ApiErrorCode::Internal),
112 ServiceError::Forbidden(msg) => ApiError::new(msg).with_code(ApiErrorCode::Forbidden),
113 ServiceError::Backend(msg) => ApiError::new(msg).with_code(ApiErrorCode::Internal),
114 ServiceError::StateError(msg) => ApiError::new(msg).with_code(ApiErrorCode::Internal),
115 ServiceError::InvalidState(msg) => ApiError::new(msg).with_code(ApiErrorCode::Conflict),
116 ServiceError::NotImplemented(msg) => {
117 ApiError::new(msg).with_code(ApiErrorCode::NotImplemented)
118 }
119 }
120 }
121}
122
123#[cfg(feature = "http")]
125impl IntoResponse for ApiError {
126 fn into_response(self) -> Response {
127 let status_code =
128 StatusCode::from_u16(self.code).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
129
130 (status_code, Json(self)).into_response()
131 }
132}
133
134#[cfg(test)]
135mod tests {
136 use super::*;
137
138 #[test]
139 fn test_api_error_creation() {
140 let error = ApiError::not_found("User", "123");
141 assert_eq!(error.code, 404);
142 assert!(error.message.contains("User"));
143 assert!(error.message.contains("123"));
144 }
145
146 #[test]
147 fn test_service_error_conversion() {
148 let service_error = ServiceError::NotFound {
149 resource: "Cortical Area".to_string(),
150 id: "v1".to_string(),
151 };
152
153 let api_error: ApiError = service_error.into();
154 assert_eq!(api_error.code, 404);
155 assert!(api_error.message.contains("Cortical Area"));
156 }
157}