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::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/// Implement Axum's IntoResponse for ApiError (only when http feature is enabled)
124#[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}