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    /// Insufficient scope (403).
89    #[error("insufficient scope")]
90    InsufficientScope,
91
92    /// Store operation failed (500).
93    #[error("database error")]
94    Store(#[from] StoreError),
95
96    /// Internal server error (500).
97    #[error("internal server error")]
98    Internal(String),
99}
100
101impl ApiError {
102    /// Return the error code for JSON serialization.
103    fn code(&self) -> &str {
104        match self {
105            ApiError::RunNotFound(_) => "RUN_NOT_FOUND",
106            ApiError::StepNotFound(_) => "STEP_NOT_FOUND",
107            ApiError::WorkflowNotFound(_) => "WORKFLOW_NOT_FOUND",
108            ApiError::BadRequest(_) => "BAD_REQUEST",
109            ApiError::Unauthorized => "UNAUTHORIZED",
110            ApiError::InvalidCredentials => "INVALID_CREDENTIALS",
111            ApiError::DuplicateEmail => "DUPLICATE_EMAIL",
112            ApiError::DuplicateUsername => "DUPLICATE_USERNAME",
113            ApiError::ApiKeyNotFound(_) => "API_KEY_NOT_FOUND",
114            ApiError::UserNotFound(_) => "USER_NOT_FOUND",
115            ApiError::Forbidden => "FORBIDDEN",
116            ApiError::InsufficientScope => "INSUFFICIENT_SCOPE",
117            ApiError::Store(_) => "DATABASE_ERROR",
118            ApiError::Internal(_) => "INTERNAL_ERROR",
119        }
120    }
121
122    /// Return the HTTP status code for this error.
123    fn status(&self) -> StatusCode {
124        match self {
125            ApiError::RunNotFound(_) => StatusCode::NOT_FOUND,
126            ApiError::StepNotFound(_) => StatusCode::NOT_FOUND,
127            ApiError::WorkflowNotFound(_) => StatusCode::NOT_FOUND,
128            ApiError::BadRequest(_) => StatusCode::BAD_REQUEST,
129            ApiError::Unauthorized => StatusCode::UNAUTHORIZED,
130            ApiError::InvalidCredentials => StatusCode::UNAUTHORIZED,
131            ApiError::DuplicateEmail => StatusCode::CONFLICT,
132            ApiError::DuplicateUsername => StatusCode::CONFLICT,
133            ApiError::ApiKeyNotFound(_) => StatusCode::NOT_FOUND,
134            ApiError::UserNotFound(_) => StatusCode::NOT_FOUND,
135            ApiError::Forbidden => StatusCode::FORBIDDEN,
136            ApiError::InsufficientScope => StatusCode::FORBIDDEN,
137            ApiError::Store(_) => StatusCode::INTERNAL_SERVER_ERROR,
138            ApiError::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
139        }
140    }
141}
142
143impl IntoResponse for ApiError {
144    fn into_response(self) -> Response {
145        let status = self.status();
146        let code = self.code().to_string();
147        let message = self.to_string();
148
149        // Log internal details server-side before returning opaque message to client
150        match &self {
151            ApiError::Store(e) => error!(error = %e, code = %code, "store error"),
152            ApiError::Internal(detail) => {
153                error!(detail = %detail, code = %code, "internal error")
154            }
155            _ => {}
156        }
157
158        let envelope = ErrorEnvelope { code, message };
159
160        (status, Json(json!({ "error": envelope }))).into_response()
161    }
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167
168    #[test]
169    fn run_not_found_code() {
170        let err = ApiError::RunNotFound(Uuid::nil());
171        assert_eq!(err.code(), "RUN_NOT_FOUND");
172    }
173
174    #[test]
175    fn run_not_found_status() {
176        let err = ApiError::RunNotFound(Uuid::nil());
177        assert_eq!(err.status(), StatusCode::NOT_FOUND);
178    }
179
180    #[test]
181    fn bad_request_status() {
182        let err = ApiError::BadRequest("invalid field".to_string());
183        assert_eq!(err.status(), StatusCode::BAD_REQUEST);
184        assert_eq!(err.code(), "BAD_REQUEST");
185    }
186
187    #[test]
188    fn internal_error_status() {
189        let err = ApiError::Internal("something went wrong".to_string());
190        assert_eq!(err.status(), StatusCode::INTERNAL_SERVER_ERROR);
191        assert_eq!(err.code(), "INTERNAL_ERROR");
192    }
193
194    #[test]
195    fn error_to_response() {
196        let err = ApiError::BadRequest("invalid input".to_string());
197        let response = err.into_response();
198        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
199    }
200
201    #[test]
202    fn unauthorized_status() {
203        let err = ApiError::Unauthorized;
204        assert_eq!(err.status(), StatusCode::UNAUTHORIZED);
205        assert_eq!(err.code(), "UNAUTHORIZED");
206    }
207
208    #[test]
209    fn invalid_credentials_status() {
210        let err = ApiError::InvalidCredentials;
211        assert_eq!(err.status(), StatusCode::UNAUTHORIZED);
212        assert_eq!(err.code(), "INVALID_CREDENTIALS");
213    }
214
215    #[test]
216    fn duplicate_email_status() {
217        let err = ApiError::DuplicateEmail;
218        assert_eq!(err.status(), StatusCode::CONFLICT);
219        assert_eq!(err.code(), "DUPLICATE_EMAIL");
220    }
221
222    #[test]
223    fn duplicate_username_status() {
224        let err = ApiError::DuplicateUsername;
225        assert_eq!(err.status(), StatusCode::CONFLICT);
226        assert_eq!(err.code(), "DUPLICATE_USERNAME");
227    }
228
229    #[test]
230    fn workflow_not_found_status() {
231        let err = ApiError::WorkflowNotFound("test".to_string());
232        assert_eq!(err.status(), StatusCode::NOT_FOUND);
233        assert_eq!(err.code(), "WORKFLOW_NOT_FOUND");
234    }
235
236    #[test]
237    fn step_not_found_status() {
238        let err = ApiError::StepNotFound(Uuid::nil());
239        assert_eq!(err.status(), StatusCode::NOT_FOUND);
240        assert_eq!(err.code(), "STEP_NOT_FOUND");
241    }
242
243    #[test]
244    fn user_not_found_status() {
245        let err = ApiError::UserNotFound(Uuid::nil());
246        assert_eq!(err.status(), StatusCode::NOT_FOUND);
247        assert_eq!(err.code(), "USER_NOT_FOUND");
248    }
249
250    #[test]
251    fn forbidden_status() {
252        let err = ApiError::Forbidden;
253        assert_eq!(err.status(), StatusCode::FORBIDDEN);
254        assert_eq!(err.code(), "FORBIDDEN");
255    }
256}