1use 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#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
21#[derive(Debug, Serialize)]
22pub struct ErrorEnvelope {
23 pub code: String,
25 pub message: String,
27}
28
29#[derive(Debug, Error)]
43pub enum ApiError {
44 #[error("run not found")]
46 RunNotFound(Uuid),
47
48 #[error("step not found")]
50 StepNotFound(Uuid),
51
52 #[error("workflow not found")]
54 WorkflowNotFound(String),
55
56 #[error("{0}")]
58 BadRequest(String),
59
60 #[error("authentication required")]
62 Unauthorized,
63
64 #[error("invalid credentials")]
66 InvalidCredentials,
67
68 #[error("email already exists")]
70 DuplicateEmail,
71
72 #[error("username already exists")]
74 DuplicateUsername,
75
76 #[error("API key not found")]
78 ApiKeyNotFound(Uuid),
79
80 #[error("user not found")]
82 UserNotFound(Uuid),
83
84 #[error("insufficient permissions")]
86 Forbidden,
87
88 #[error("secret not found")]
90 SecretNotFound(String),
91
92 #[error("insufficient scope")]
94 InsufficientScope,
95
96 #[error("database error")]
98 Store(#[from] StoreError),
99
100 #[error("internal server error")]
102 Internal(String),
103}
104
105impl ApiError {
106 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 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}