Skip to main content

feagi_api/common/
error.rs

1// Copyright 2025 Neuraville Inc.
2// SPDX-License-Identifier: Apache-2.0
3
4// API error types and conversions
5
6#[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/// HTTP status codes for API errors
17#[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/// API error response
30#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
31pub struct ApiError {
32    /// HTTP status code
33    pub code: u16,
34
35    /// Error message
36    pub message: String,
37
38    /// Optional error details
39    #[serde(skip_serializing_if = "Option::is_none")]
40    pub details: Option<String>,
41}
42
43impl ApiError {
44    /// Create a new API error
45    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    /// Set error code
54    pub fn with_code(mut self, code: ApiErrorCode) -> Self {
55        self.code = code as u16;
56        self
57    }
58
59    /// Set error details
60    pub fn with_details(mut self, details: impl Into<String>) -> Self {
61        self.details = Some(details.into());
62        self
63    }
64
65    /// Create a "not found" error
66    pub fn not_found(resource: &str, id: &str) -> Self {
67        Self::new(format!("{} '{}' not found", resource, id)).with_code(ApiErrorCode::NotFound)
68    }
69
70    /// Create an "invalid input" error
71    pub fn invalid_input(message: impl Into<String>) -> Self {
72        Self::new(message).with_code(ApiErrorCode::BadRequest)
73    }
74
75    /// Create a "conflict" error
76    pub fn conflict(message: impl Into<String>) -> Self {
77        Self::new(message).with_code(ApiErrorCode::Conflict)
78    }
79
80    /// Create an "internal error"
81    pub fn internal(message: impl Into<String>) -> Self {
82        Self::new(message).with_code(ApiErrorCode::Internal)
83    }
84
85    /// Create a "forbidden" error
86    pub fn forbidden(message: impl Into<String>) -> Self {
87        Self::new(message).with_code(ApiErrorCode::Forbidden)
88    }
89
90    /// Create a "not implemented" error
91    pub fn not_implemented(message: impl Into<String>) -> Self {
92        Self::new(message).with_code(ApiErrorCode::NotImplemented)
93    }
94}
95
96/// Convert from service layer errors
97impl 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::Conflict(msg) => ApiError::conflict(msg),
112            ServiceError::Internal(msg) => ApiError::new(msg).with_code(ApiErrorCode::Internal),
113            ServiceError::Forbidden(msg) => ApiError::new(msg).with_code(ApiErrorCode::Forbidden),
114            ServiceError::Backend(msg) => ApiError::new(msg).with_code(ApiErrorCode::Internal),
115            ServiceError::StateError(msg) => ApiError::new(msg).with_code(ApiErrorCode::Internal),
116            ServiceError::InvalidState(msg) => ApiError::new(msg).with_code(ApiErrorCode::Conflict),
117            ServiceError::NotImplemented(msg) => {
118                ApiError::new(msg).with_code(ApiErrorCode::NotImplemented)
119            }
120        }
121    }
122}
123
124/// Implement Axum's IntoResponse for ApiError (only when http feature is enabled)
125#[cfg(feature = "http")]
126impl IntoResponse for ApiError {
127    fn into_response(self) -> Response {
128        let status_code =
129            StatusCode::from_u16(self.code).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
130
131        (status_code, Json(self)).into_response()
132    }
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138
139    #[test]
140    fn test_api_error_creation() {
141        let error = ApiError::not_found("User", "123");
142        assert_eq!(error.code, 404);
143        assert!(error.message.contains("User"));
144        assert!(error.message.contains("123"));
145    }
146
147    #[test]
148    fn test_service_error_conversion() {
149        let service_error = ServiceError::NotFound {
150            resource: "Cortical Area".to_string(),
151            id: "v1".to_string(),
152        };
153
154        let api_error: ApiError = service_error.into();
155        assert_eq!(api_error.code, 404);
156        assert!(api_error.message.contains("Cortical Area"));
157    }
158}