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#[derive(Debug, Serialize)]
21pub struct ErrorEnvelope {
22    /// Machine-readable error code (e.g., "RUN_NOT_FOUND").
23    pub code: String,
24    /// Human-readable error message.
25    pub message: String,
26}
27
28/// Error type for REST API operations.
29///
30/// Maps to appropriate HTTP status codes and error codes in the JSON response.
31///
32/// # Examples
33///
34/// ```
35/// use ironflow_api::error::ApiError;
36/// use uuid::Uuid;
37///
38/// let err = ApiError::RunNotFound(Uuid::nil());
39/// assert_eq!(err.to_string(), "run not found");
40/// ```
41#[derive(Debug, Error)]
42pub enum ApiError {
43    /// The requested run does not exist (404).
44    #[error("run not found")]
45    RunNotFound(Uuid),
46
47    /// The requested step does not exist (404).
48    #[error("step not found")]
49    StepNotFound(Uuid),
50
51    /// Workflow not found (404).
52    #[error("workflow not found")]
53    WorkflowNotFound(String),
54
55    /// Bad request: invalid input (400).
56    #[error("{0}")]
57    BadRequest(String),
58
59    /// Authentication required (401).
60    #[error("authentication required")]
61    Unauthorized,
62
63    /// Invalid credentials (401).
64    #[error("invalid credentials")]
65    InvalidCredentials,
66
67    /// Email already taken (409).
68    #[error("email already exists")]
69    DuplicateEmail,
70
71    /// Username already taken (409).
72    #[error("username already exists")]
73    DuplicateUsername,
74
75    /// Store operation failed (500).
76    #[error("database error")]
77    Store(#[from] StoreError),
78
79    /// Internal server error (500).
80    #[error("internal server error")]
81    Internal(String),
82}
83
84impl ApiError {
85    /// Return the error code for JSON serialization.
86    fn code(&self) -> &str {
87        match self {
88            ApiError::RunNotFound(_) => "RUN_NOT_FOUND",
89            ApiError::StepNotFound(_) => "STEP_NOT_FOUND",
90            ApiError::WorkflowNotFound(_) => "WORKFLOW_NOT_FOUND",
91            ApiError::BadRequest(_) => "BAD_REQUEST",
92            ApiError::Unauthorized => "UNAUTHORIZED",
93            ApiError::InvalidCredentials => "INVALID_CREDENTIALS",
94            ApiError::DuplicateEmail => "DUPLICATE_EMAIL",
95            ApiError::DuplicateUsername => "DUPLICATE_USERNAME",
96            ApiError::Store(_) => "DATABASE_ERROR",
97            ApiError::Internal(_) => "INTERNAL_ERROR",
98        }
99    }
100
101    /// Return the HTTP status code for this error.
102    fn status(&self) -> StatusCode {
103        match self {
104            ApiError::RunNotFound(_) => StatusCode::NOT_FOUND,
105            ApiError::StepNotFound(_) => StatusCode::NOT_FOUND,
106            ApiError::WorkflowNotFound(_) => StatusCode::NOT_FOUND,
107            ApiError::BadRequest(_) => StatusCode::BAD_REQUEST,
108            ApiError::Unauthorized => StatusCode::UNAUTHORIZED,
109            ApiError::InvalidCredentials => StatusCode::UNAUTHORIZED,
110            ApiError::DuplicateEmail => StatusCode::CONFLICT,
111            ApiError::DuplicateUsername => StatusCode::CONFLICT,
112            ApiError::Store(_) => StatusCode::INTERNAL_SERVER_ERROR,
113            ApiError::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
114        }
115    }
116}
117
118impl IntoResponse for ApiError {
119    fn into_response(self) -> Response {
120        let status = self.status();
121        let code = self.code().to_string();
122        let message = self.to_string();
123
124        // Log internal details server-side before returning opaque message to client
125        match &self {
126            ApiError::Store(e) => error!(error = %e, code = %code, "store error"),
127            ApiError::Internal(detail) => {
128                error!(detail = %detail, code = %code, "internal error")
129            }
130            _ => {}
131        }
132
133        let envelope = ErrorEnvelope { code, message };
134
135        (status, Json(json!({ "error": envelope }))).into_response()
136    }
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142
143    #[test]
144    fn run_not_found_code() {
145        let err = ApiError::RunNotFound(Uuid::nil());
146        assert_eq!(err.code(), "RUN_NOT_FOUND");
147    }
148
149    #[test]
150    fn run_not_found_status() {
151        let err = ApiError::RunNotFound(Uuid::nil());
152        assert_eq!(err.status(), StatusCode::NOT_FOUND);
153    }
154
155    #[test]
156    fn bad_request_status() {
157        let err = ApiError::BadRequest("invalid field".to_string());
158        assert_eq!(err.status(), StatusCode::BAD_REQUEST);
159        assert_eq!(err.code(), "BAD_REQUEST");
160    }
161
162    #[test]
163    fn internal_error_status() {
164        let err = ApiError::Internal("something went wrong".to_string());
165        assert_eq!(err.status(), StatusCode::INTERNAL_SERVER_ERROR);
166        assert_eq!(err.code(), "INTERNAL_ERROR");
167    }
168
169    #[test]
170    fn error_to_response() {
171        let err = ApiError::BadRequest("invalid input".to_string());
172        let response = err.into_response();
173        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
174    }
175
176    #[test]
177    fn unauthorized_status() {
178        let err = ApiError::Unauthorized;
179        assert_eq!(err.status(), StatusCode::UNAUTHORIZED);
180        assert_eq!(err.code(), "UNAUTHORIZED");
181    }
182
183    #[test]
184    fn invalid_credentials_status() {
185        let err = ApiError::InvalidCredentials;
186        assert_eq!(err.status(), StatusCode::UNAUTHORIZED);
187        assert_eq!(err.code(), "INVALID_CREDENTIALS");
188    }
189
190    #[test]
191    fn duplicate_email_status() {
192        let err = ApiError::DuplicateEmail;
193        assert_eq!(err.status(), StatusCode::CONFLICT);
194        assert_eq!(err.code(), "DUPLICATE_EMAIL");
195    }
196
197    #[test]
198    fn duplicate_username_status() {
199        let err = ApiError::DuplicateUsername;
200        assert_eq!(err.status(), StatusCode::CONFLICT);
201        assert_eq!(err.code(), "DUPLICATE_USERNAME");
202    }
203
204    #[test]
205    fn workflow_not_found_status() {
206        let err = ApiError::WorkflowNotFound("test".to_string());
207        assert_eq!(err.status(), StatusCode::NOT_FOUND);
208        assert_eq!(err.code(), "WORKFLOW_NOT_FOUND");
209    }
210
211    #[test]
212    fn step_not_found_status() {
213        let err = ApiError::StepNotFound(Uuid::nil());
214        assert_eq!(err.status(), StatusCode::NOT_FOUND);
215        assert_eq!(err.code(), "STEP_NOT_FOUND");
216    }
217}