Skip to main content

ironflow_api/
error.rs

1//! REST API error types and responses.
2//!
3//! [`ApiError`] is the primary error type for all API handlers. It implements
4//! [`IntoResponse`] to serialize errors to JSON
5//! with proper HTTP status codes.
6
7use axum::Json;
8use axum::http::StatusCode;
9use axum::response::{IntoResponse, Response};
10use ironflow_store::error::StoreError;
11use serde::Serialize;
12use serde_json::json;
13use thiserror::Error;
14use tracing::error;
15use uuid::Uuid;
16
17/// API error response envelope.
18///
19/// Serialized to JSON as: `{ "error": { "code": "...", "message": "..." } }`
20#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
21#[derive(Debug, Serialize)]
22pub struct ErrorEnvelope {
23    /// Machine-readable error code (e.g., "RUN_NOT_FOUND").
24    pub code: String,
25    /// Human-readable error message.
26    pub message: String,
27}
28
29/// Error type for REST API operations.
30///
31/// Maps to appropriate HTTP status codes and error codes in the JSON response.
32///
33/// # Examples
34///
35/// ```
36/// use ironflow_api::error::ApiError;
37/// use uuid::Uuid;
38///
39/// let err = ApiError::RunNotFound(Uuid::nil());
40/// assert_eq!(err.to_string(), "run not found");
41/// ```
42#[derive(Debug, Error)]
43pub enum ApiError {
44    /// The requested run does not exist (404).
45    #[error("run not found")]
46    RunNotFound(Uuid),
47
48    /// The requested step does not exist (404).
49    #[error("step not found")]
50    StepNotFound(Uuid),
51
52    /// Workflow not found (404).
53    #[error("workflow not found")]
54    WorkflowNotFound(String),
55
56    /// Bad request: invalid input (400).
57    #[error("{0}")]
58    BadRequest(String),
59
60    /// Authentication required (401).
61    #[error("authentication required")]
62    Unauthorized,
63
64    /// Invalid credentials (401).
65    #[error("invalid credentials")]
66    InvalidCredentials,
67
68    /// Email already taken (409).
69    #[error("email already exists")]
70    DuplicateEmail,
71
72    /// Username already taken (409).
73    #[error("username already exists")]
74    DuplicateUsername,
75
76    /// API key not found (404).
77    #[error("API key not found")]
78    ApiKeyNotFound(Uuid),
79
80    /// User not found (404).
81    #[error("user not found")]
82    UserNotFound(Uuid),
83
84    /// Insufficient permissions for this action (403).
85    #[error("insufficient permissions")]
86    Forbidden,
87
88    /// Secret not found (404).
89    #[error("secret not found")]
90    SecretNotFound(String),
91
92    /// Insufficient scope (403).
93    #[error("insufficient scope")]
94    InsufficientScope,
95
96    /// Store operation failed (500).
97    #[error("database error")]
98    Store(#[from] StoreError),
99
100    /// Internal server error (500).
101    #[error("internal server error")]
102    Internal(String),
103}
104
105impl ApiError {
106    /// Return the error code for JSON serialization.
107    fn code(&self) -> &str {
108        match self {
109            ApiError::RunNotFound(_) => "RUN_NOT_FOUND",
110            ApiError::StepNotFound(_) => "STEP_NOT_FOUND",
111            ApiError::WorkflowNotFound(_) => "WORKFLOW_NOT_FOUND",
112            ApiError::BadRequest(_) => "BAD_REQUEST",
113            ApiError::Unauthorized => "UNAUTHORIZED",
114            ApiError::InvalidCredentials => "INVALID_CREDENTIALS",
115            ApiError::DuplicateEmail => "DUPLICATE_EMAIL",
116            ApiError::DuplicateUsername => "DUPLICATE_USERNAME",
117            ApiError::ApiKeyNotFound(_) => "API_KEY_NOT_FOUND",
118            ApiError::UserNotFound(_) => "USER_NOT_FOUND",
119            ApiError::SecretNotFound(_) => "SECRET_NOT_FOUND",
120            ApiError::Forbidden => "FORBIDDEN",
121            ApiError::InsufficientScope => "INSUFFICIENT_SCOPE",
122            ApiError::Store(StoreError::Crypto(_)) => "SECRET_STORE_UNAVAILABLE",
123            ApiError::Store(_) => "DATABASE_ERROR",
124            ApiError::Internal(_) => "INTERNAL_ERROR",
125        }
126    }
127
128    /// Return the HTTP status code for this error.
129    fn status(&self) -> StatusCode {
130        match self {
131            ApiError::RunNotFound(_) => StatusCode::NOT_FOUND,
132            ApiError::StepNotFound(_) => StatusCode::NOT_FOUND,
133            ApiError::WorkflowNotFound(_) => StatusCode::NOT_FOUND,
134            ApiError::SecretNotFound(_) => StatusCode::NOT_FOUND,
135            ApiError::BadRequest(_) => StatusCode::BAD_REQUEST,
136            ApiError::Unauthorized => StatusCode::UNAUTHORIZED,
137            ApiError::InvalidCredentials => StatusCode::UNAUTHORIZED,
138            ApiError::DuplicateEmail => StatusCode::CONFLICT,
139            ApiError::DuplicateUsername => StatusCode::CONFLICT,
140            ApiError::ApiKeyNotFound(_) => StatusCode::NOT_FOUND,
141            ApiError::UserNotFound(_) => StatusCode::NOT_FOUND,
142            ApiError::Forbidden => StatusCode::FORBIDDEN,
143            ApiError::InsufficientScope => StatusCode::FORBIDDEN,
144            ApiError::Store(StoreError::Crypto(_)) => StatusCode::NOT_IMPLEMENTED,
145            ApiError::Store(_) => StatusCode::INTERNAL_SERVER_ERROR,
146            ApiError::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
147        }
148    }
149}
150
151impl IntoResponse for ApiError {
152    fn into_response(self) -> Response {
153        let status = self.status();
154        let code = self.code().to_string();
155
156        let message = match &self {
157            ApiError::Store(StoreError::Crypto(_)) => {
158                "secret store not configured (set IRONFLOW_SECRET_KEY)".to_string()
159            }
160            _ => self.to_string(),
161        };
162
163        match &self {
164            ApiError::Store(e) => error!(error = %e, code = %code, "store error"),
165            ApiError::Internal(detail) => {
166                error!(detail = %detail, code = %code, "internal error")
167            }
168            _ => {}
169        }
170
171        let envelope = ErrorEnvelope { code, message };
172
173        (status, Json(json!({ "error": envelope }))).into_response()
174    }
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180
181    #[test]
182    fn run_not_found_code() {
183        let err = ApiError::RunNotFound(Uuid::nil());
184        assert_eq!(err.code(), "RUN_NOT_FOUND");
185    }
186
187    #[test]
188    fn run_not_found_status() {
189        let err = ApiError::RunNotFound(Uuid::nil());
190        assert_eq!(err.status(), StatusCode::NOT_FOUND);
191    }
192
193    #[test]
194    fn bad_request_status() {
195        let err = ApiError::BadRequest("invalid field".to_string());
196        assert_eq!(err.status(), StatusCode::BAD_REQUEST);
197        assert_eq!(err.code(), "BAD_REQUEST");
198    }
199
200    #[test]
201    fn internal_error_status() {
202        let err = ApiError::Internal("something went wrong".to_string());
203        assert_eq!(err.status(), StatusCode::INTERNAL_SERVER_ERROR);
204        assert_eq!(err.code(), "INTERNAL_ERROR");
205    }
206
207    #[test]
208    fn error_to_response() {
209        let err = ApiError::BadRequest("invalid input".to_string());
210        let response = err.into_response();
211        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
212    }
213
214    #[test]
215    fn unauthorized_status() {
216        let err = ApiError::Unauthorized;
217        assert_eq!(err.status(), StatusCode::UNAUTHORIZED);
218        assert_eq!(err.code(), "UNAUTHORIZED");
219    }
220
221    #[test]
222    fn invalid_credentials_status() {
223        let err = ApiError::InvalidCredentials;
224        assert_eq!(err.status(), StatusCode::UNAUTHORIZED);
225        assert_eq!(err.code(), "INVALID_CREDENTIALS");
226    }
227
228    #[test]
229    fn duplicate_email_status() {
230        let err = ApiError::DuplicateEmail;
231        assert_eq!(err.status(), StatusCode::CONFLICT);
232        assert_eq!(err.code(), "DUPLICATE_EMAIL");
233    }
234
235    #[test]
236    fn duplicate_username_status() {
237        let err = ApiError::DuplicateUsername;
238        assert_eq!(err.status(), StatusCode::CONFLICT);
239        assert_eq!(err.code(), "DUPLICATE_USERNAME");
240    }
241
242    #[test]
243    fn workflow_not_found_status() {
244        let err = ApiError::WorkflowNotFound("test".to_string());
245        assert_eq!(err.status(), StatusCode::NOT_FOUND);
246        assert_eq!(err.code(), "WORKFLOW_NOT_FOUND");
247    }
248
249    #[test]
250    fn step_not_found_status() {
251        let err = ApiError::StepNotFound(Uuid::nil());
252        assert_eq!(err.status(), StatusCode::NOT_FOUND);
253        assert_eq!(err.code(), "STEP_NOT_FOUND");
254    }
255
256    #[test]
257    fn user_not_found_status() {
258        let err = ApiError::UserNotFound(Uuid::nil());
259        assert_eq!(err.status(), StatusCode::NOT_FOUND);
260        assert_eq!(err.code(), "USER_NOT_FOUND");
261    }
262
263    #[test]
264    fn forbidden_status() {
265        let err = ApiError::Forbidden;
266        assert_eq!(err.status(), StatusCode::FORBIDDEN);
267        assert_eq!(err.code(), "FORBIDDEN");
268    }
269
270    #[test]
271    fn secret_not_found_status() {
272        let err = ApiError::SecretNotFound("demo/api-key".to_string());
273        assert_eq!(err.status(), StatusCode::NOT_FOUND);
274        assert_eq!(err.code(), "SECRET_NOT_FOUND");
275    }
276}