Skip to main content

max / goingson

12.5 KB · 397 lines History Blame Raw
1 //! Typed API error system for Tauri commands.
2 //!
3 //! Provides structured error responses that replace string errors with typed errors
4 //! containing machine-readable codes and human-readable messages.
5 //!
6 //! # Example Usage
7 //!
8 //! ```ignore
9 //! use crate::commands::error::{ApiError, ErrorCode};
10 //!
11 //! #[tauri::command]
12 //! pub async fn get_task(id: Uuid) -> Result<Task, ApiError> {
13 //! // Validation error with details
14 //! if id.is_nil() {
15 //! return Err(ApiError::validation("id", "Task ID cannot be nil"));
16 //! }
17 //!
18 //! // Not found error
19 //! state.tasks.get_by_id(id)
20 //! .await?
21 //! .ok_or_else(|| ApiError::not_found("task", id))
22 //! }
23 //! ```
24
25 use goingson_core::CoreError;
26 use serde::Serialize;
27
28 /// Machine-readable error codes for API responses.
29 ///
30 /// These codes can be used by the frontend to provide specific error handling
31 /// or localized error messages.
32 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
33 #[serde(rename_all = "SCREAMING_SNAKE_CASE")]
34 pub enum ErrorCode {
35 /// Resource was not found (e.g., task, project, event).
36 NotFound,
37
38 /// Input validation failed (e.g., empty description, invalid date).
39 ValidationError,
40
41 /// Database operation failed.
42 DatabaseError,
43
44 /// Request was malformed or invalid.
45 BadRequest,
46
47 /// Authentication or authorization failed.
48 AuthError,
49
50 /// Parsing failed (e.g., invalid date format, malformed JSON).
51 ParseError,
52
53 /// An unexpected internal error occurred.
54 InternalError,
55
56 /// Operation conflict (e.g., duplicate entry, state conflict).
57 Conflict,
58
59 /// External service error (e.g., IMAP).
60 ExternalServiceError,
61 }
62
63 impl ErrorCode {
64 /// Returns the string representation of the error code.
65 pub fn as_str(&self) -> &'static str {
66 match self {
67 ErrorCode::NotFound => "NOT_FOUND",
68 ErrorCode::ValidationError => "VALIDATION_ERROR",
69 ErrorCode::DatabaseError => "DATABASE_ERROR",
70 ErrorCode::BadRequest => "BAD_REQUEST",
71 ErrorCode::AuthError => "AUTH_ERROR",
72 ErrorCode::ParseError => "PARSE_ERROR",
73 ErrorCode::InternalError => "INTERNAL_ERROR",
74 ErrorCode::Conflict => "CONFLICT",
75 ErrorCode::ExternalServiceError => "EXTERNAL_SERVICE_ERROR",
76 }
77 }
78 }
79
80 /// Structured API error for Tauri command responses.
81 ///
82 /// This provides a consistent error format across all commands with:
83 /// - A machine-readable `code` for programmatic error handling
84 /// - A human-readable `message` for display to users
85 /// - Optional `details` for additional context (field names, resource types, etc.)
86 #[derive(Debug, Clone, Serialize)]
87 #[serde(rename_all = "camelCase")]
88 pub struct ApiError {
89 /// Machine-readable error code.
90 pub code: ErrorCode,
91
92 /// Human-readable error message.
93 pub message: String,
94
95 /// Optional additional details (e.g., field name for validation errors).
96 #[serde(skip_serializing_if = "Option::is_none")]
97 pub details: Option<ErrorDetails>,
98 }
99
100 /// Additional error details for specific error types.
101 #[derive(Debug, Clone, Serialize)]
102 #[serde(rename_all = "camelCase")]
103 pub struct ErrorDetails {
104 /// The field that caused a validation error.
105 #[serde(skip_serializing_if = "Option::is_none")]
106 pub field: Option<String>,
107
108 /// The type of resource that was not found (e.g., "task", "project").
109 #[serde(skip_serializing_if = "Option::is_none")]
110 pub resource: Option<String>,
111
112 /// The ID of the resource that was not found.
113 #[serde(skip_serializing_if = "Option::is_none")]
114 pub resource_id: Option<String>,
115 }
116
117 impl ApiError {
118 /// Creates a new API error with the given code and message.
119 pub fn new(code: ErrorCode, message: impl Into<String>) -> Self {
120 Self {
121 code,
122 message: message.into(),
123 details: None,
124 }
125 }
126
127 /// Creates a new API error with details.
128 pub fn with_details(code: ErrorCode, message: impl Into<String>, details: ErrorDetails) -> Self {
129 Self {
130 code,
131 message: message.into(),
132 details: Some(details),
133 }
134 }
135
136 // ============ Convenience constructors ============
137
138 /// Creates a not-found error for a specific resource.
139 pub fn not_found(resource: &str, id: impl ToString) -> Self {
140 let id_str = id.to_string();
141 Self::with_details(
142 ErrorCode::NotFound,
143 format!("{} not found: {}", resource, id_str),
144 ErrorDetails {
145 field: None,
146 resource: Some(resource.to_string()),
147 resource_id: Some(id_str),
148 },
149 )
150 }
151
152 /// Creates a validation error for a specific field.
153 pub fn validation(field: &str, message: impl Into<String>) -> Self {
154 Self::with_details(
155 ErrorCode::ValidationError,
156 message.into(),
157 ErrorDetails {
158 field: Some(field.to_string()),
159 resource: None,
160 resource_id: None,
161 },
162 )
163 }
164
165 /// Creates a generic validation error without a specific field.
166 pub fn validation_msg(message: impl Into<String>) -> Self {
167 Self::new(ErrorCode::ValidationError, message)
168 }
169
170 /// Creates a database error.
171 pub fn database(message: impl Into<String>) -> Self {
172 Self::new(ErrorCode::DatabaseError, message)
173 }
174
175 /// Creates a bad request error.
176 pub fn bad_request(message: impl Into<String>) -> Self {
177 Self::new(ErrorCode::BadRequest, message)
178 }
179
180 /// Creates an authentication error.
181 pub fn auth(message: impl Into<String>) -> Self {
182 Self::new(ErrorCode::AuthError, message)
183 }
184
185 /// Creates a parse error.
186 pub fn parse(message: impl Into<String>) -> Self {
187 Self::new(ErrorCode::ParseError, message)
188 }
189
190 /// Creates an internal error.
191 pub fn internal(message: impl Into<String>) -> Self {
192 Self::new(ErrorCode::InternalError, message)
193 }
194
195 /// Creates a conflict error.
196 pub fn conflict(message: impl Into<String>) -> Self {
197 Self::new(ErrorCode::Conflict, message)
198 }
199
200 /// Creates an external service error.
201 pub fn external_service(message: impl Into<String>) -> Self {
202 Self::new(ErrorCode::ExternalServiceError, message)
203 }
204 }
205
206 // ============ Extension Traits ============
207
208 /// Extension trait on `Option<T>` for converting to `Result<T, ApiError>` with a not-found error.
209 ///
210 /// Replaces the verbose `.ok_or_else(|| ApiError::not_found("entity", id))?` pattern.
211 ///
212 /// # Example
213 ///
214 /// ```ignore
215 /// use crate::commands::error::OptionNotFound;
216 ///
217 /// let task = state.tasks.get_by_id(id, user_id)
218 /// .await?
219 /// .or_not_found("task", id)?;
220 /// ```
221 pub trait OptionNotFound<T> {
222 /// Converts `None` into `ApiError::not_found(entity, id)`.
223 fn or_not_found(self, entity: &str, id: impl ToString) -> Result<T, ApiError>;
224 }
225
226 impl<T> OptionNotFound<T> for Option<T> {
227 fn or_not_found(self, entity: &str, id: impl ToString) -> Result<T, ApiError> {
228 self.ok_or_else(|| ApiError::not_found(entity, id))
229 }
230 }
231
232 /// Extension trait on `Option<T>` for converting to `Result<T, ApiError>` with an arbitrary error.
233 ///
234 /// Replaces patterns like `.ok_or_else(|| ApiError::bad_request("..."))`.
235 ///
236 /// # Example
237 ///
238 /// ```ignore
239 /// use crate::commands::error::OptionApiError;
240 ///
241 /// let settings = state.backup_settings.get(user_id)
242 /// .await?
243 /// .or_api_err(|| ApiError::bad_request("Backup settings not configured"))?;
244 /// ```
245 pub trait OptionApiError<T> {
246 /// Converts `None` into the `ApiError` returned by `f`.
247 fn or_api_err(self, f: impl FnOnce() -> ApiError) -> Result<T, ApiError>;
248 }
249
250 impl<T> OptionApiError<T> for Option<T> {
251 fn or_api_err(self, f: impl FnOnce() -> ApiError) -> Result<T, ApiError> {
252 self.ok_or_else(f)
253 }
254 }
255
256 /// Extension trait on `Result<T, E>` for mapping errors into `ApiError` with a formatted message.
257 ///
258 /// Replaces the verbose `.map_err(|e| ApiError::internal(format!("Failed to do X: {}", e)))` pattern.
259 ///
260 /// # Example
261 ///
262 /// ```ignore
263 /// use crate::commands::error::ResultApiError;
264 ///
265 /// std::fs::write(&path, content)
266 /// .map_api_err("Failed to write file", ApiError::internal)?;
267 ///
268 /// client.send().await
269 /// .map_api_err("Failed to send email", ApiError::external_service)?;
270 /// ```
271 pub trait ResultApiError<T, E: std::fmt::Display> {
272 /// Maps the error into an `ApiError` using `constructor(format!("{prefix}: {err}"))`.
273 fn map_api_err(
274 self,
275 prefix: &str,
276 constructor: fn(String) -> ApiError,
277 ) -> Result<T, ApiError>;
278 }
279
280 impl<T, E: std::fmt::Display> ResultApiError<T, E> for Result<T, E> {
281 fn map_api_err(
282 self,
283 prefix: &str,
284 constructor: fn(String) -> ApiError,
285 ) -> Result<T, ApiError> {
286 self.map_err(|e| constructor(format!("{}: {}", prefix, e)))
287 }
288 }
289
290 impl std::fmt::Display for ApiError {
291 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
292 write!(f, "[{}] {}", self.code.as_str(), self.message)
293 }
294 }
295
296 impl std::error::Error for ApiError {}
297
298 /// Converts CoreError to ApiError, mapping error variants to appropriate codes.
299 impl From<CoreError> for ApiError {
300 fn from(err: CoreError) -> Self {
301 match err {
302 CoreError::NotFound { resource, id } => ApiError::with_details(
303 ErrorCode::NotFound,
304 format!("{} not found: {}", resource, id),
305 ErrorDetails {
306 field: None,
307 resource: Some(resource.to_string()),
308 resource_id: Some(id),
309 },
310 ),
311 CoreError::Validation { field, message } => ApiError::with_details(
312 ErrorCode::ValidationError,
313 message,
314 ErrorDetails {
315 field: Some(field.to_string()),
316 resource: None,
317 resource_id: None,
318 },
319 ),
320 CoreError::Database { message, .. } => ApiError::database(message),
321 CoreError::Parse(message) => ApiError::parse(message),
322 CoreError::BadRequest(message) => ApiError::bad_request(message),
323 CoreError::Internal(message) => ApiError::internal(message),
324 CoreError::Auth(message) => ApiError::auth(message),
325 CoreError::Sync(message) => ApiError::external_service(message),
326 }
327 }
328 }
329
330 #[cfg(test)]
331 mod tests {
332 use super::*;
333
334 #[test]
335 fn test_not_found_error() {
336 let err = ApiError::not_found("task", "123e4567-e89b-12d3-a456-426614174000");
337
338 assert_eq!(err.code, ErrorCode::NotFound);
339 assert!(err.message.contains("task"));
340 assert!(err.message.contains("123e4567-e89b-12d3-a456-426614174000"));
341
342 let details = err.details.unwrap();
343 assert_eq!(details.resource, Some("task".to_string()));
344 assert_eq!(
345 details.resource_id,
346 Some("123e4567-e89b-12d3-a456-426614174000".to_string())
347 );
348 }
349
350 #[test]
351 fn test_validation_error() {
352 let err = ApiError::validation("description", "Description is required");
353
354 assert_eq!(err.code, ErrorCode::ValidationError);
355 assert_eq!(err.message, "Description is required");
356
357 let details = err.details.unwrap();
358 assert_eq!(details.field, Some("description".to_string()));
359 }
360
361 #[test]
362 fn test_core_error_conversion() {
363 let core_err = CoreError::not_found("project", "abc123");
364 let api_err: ApiError = core_err.into();
365
366 assert_eq!(api_err.code, ErrorCode::NotFound);
367 assert!(api_err.message.contains("project"));
368 assert!(api_err.message.contains("abc123"));
369 }
370
371 #[test]
372 fn test_error_display() {
373 let err = ApiError::database("Connection failed");
374 assert_eq!(err.to_string(), "[DATABASE_ERROR] Connection failed");
375 }
376
377 #[test]
378 fn test_serialization() {
379 let err = ApiError::validation("email", "Invalid email format");
380 let json = serde_json::to_string(&err).unwrap();
381
382 // Verify camelCase serialization
383 assert!(json.contains("\"code\":\"VALIDATION_ERROR\""));
384 assert!(json.contains("\"message\":\"Invalid email format\""));
385 assert!(json.contains("\"field\":\"email\""));
386 }
387
388 #[test]
389 fn test_details_skip_none() {
390 let err = ApiError::internal("Something went wrong");
391 let json = serde_json::to_string(&err).unwrap();
392
393 // Details should not be present when None
394 assert!(!json.contains("details"));
395 }
396 }
397