Skip to main content

max / balanced_breakfast

5.3 KB · 177 lines History Blame Raw
1 //! API error types for Tauri commands
2 use serde::Serialize;
3
4 /// Typed error codes for API responses.
5 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
6 pub enum ApiErrorCode {
7 #[serde(rename = "BAD_REQUEST")]
8 BadRequest,
9 #[serde(rename = "NOT_FOUND")]
10 NotFound,
11 #[serde(rename = "DATABASE")]
12 Database,
13 #[serde(rename = "INTERNAL")]
14 Internal,
15 #[serde(rename = "PLUGIN")]
16 Plugin,
17 }
18
19 impl std::fmt::Display for ApiErrorCode {
20 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
21 match self {
22 Self::BadRequest => write!(f, "BAD_REQUEST"),
23 Self::NotFound => write!(f, "NOT_FOUND"),
24 Self::Database => write!(f, "DATABASE"),
25 Self::Internal => write!(f, "INTERNAL"),
26 Self::Plugin => write!(f, "PLUGIN"),
27 }
28 }
29 }
30
31 /// API error type for Tauri commands
32 #[derive(Debug, Clone, Serialize)]
33 pub struct ApiError {
34 pub code: ApiErrorCode,
35 pub message: String,
36 }
37
38 impl ApiError {
39 /// Create a `BAD_REQUEST` error for invalid input from the frontend.
40 pub fn bad_request(message: impl Into<String>) -> Self {
41 Self {
42 code: ApiErrorCode::BadRequest,
43 message: message.into(),
44 }
45 }
46
47 /// Create a `NOT_FOUND` error when a resource doesn't exist.
48 pub fn not_found(message: impl Into<String>) -> Self {
49 Self {
50 code: ApiErrorCode::NotFound,
51 message: message.into(),
52 }
53 }
54
55 /// Create a `DATABASE` error for SQL/connection failures.
56 pub fn database(message: impl Into<String>) -> Self {
57 Self {
58 code: ApiErrorCode::Database,
59 message: message.into(),
60 }
61 }
62
63 /// Create an `INTERNAL` error for unexpected server-side failures.
64 pub fn internal(message: impl Into<String>) -> Self {
65 Self {
66 code: ApiErrorCode::Internal,
67 message: message.into(),
68 }
69 }
70
71 /// Create a `PLUGIN` error for busser/plugin failures.
72 pub fn plugin(message: impl Into<String>) -> Self {
73 Self {
74 code: ApiErrorCode::Plugin,
75 message: message.into(),
76 }
77 }
78 }
79
80 impl From<sqlx::Error> for ApiError {
81 fn from(e: sqlx::Error) -> Self {
82 // Log the full error for debugging but send a generic message to the
83 // frontend to avoid leaking file paths, SQL text, or schema details.
84 tracing::error!(error = %e, "Database error");
85 Self::database("A database error occurred")
86 }
87 }
88
89 impl From<bb_feed::FeedError> for ApiError {
90 fn from(e: bb_feed::FeedError) -> Self {
91 tracing::error!(error = %e, "Feed error");
92 Self::database("A feed query error occurred")
93 }
94 }
95
96 impl From<bb_core::orchestrator::OrchestratorError> for ApiError {
97 fn from(e: bb_core::orchestrator::OrchestratorError) -> Self {
98 use bb_core::orchestrator::OrchestratorError;
99 match e {
100 OrchestratorError::Database(e) => {
101 tracing::error!(error = %e, "Database error");
102 Self::database("A database error occurred")
103 }
104 OrchestratorError::Plugin(e) => {
105 tracing::error!(error = %e, "Plugin error");
106 Self::plugin("A plugin error occurred")
107 }
108 OrchestratorError::Feed(e) => {
109 tracing::error!(error = %e, "Feed error");
110 Self::database("A feed query error occurred")
111 }
112 OrchestratorError::Config(msg) => Self::internal(msg),
113 }
114 }
115 }
116
117 impl std::fmt::Display for ApiError {
118 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
119 write!(f, "{}: {}", self.code, self.message)
120 }
121 }
122
123 #[cfg(test)]
124 mod tests {
125 use super::*;
126
127 #[test]
128 fn bad_request_has_correct_code() {
129 let e = ApiError::bad_request("invalid");
130 assert_eq!(e.code, ApiErrorCode::BadRequest);
131 assert_eq!(e.message, "invalid");
132 }
133
134 #[test]
135 fn not_found_has_correct_code() {
136 let e = ApiError::not_found("missing");
137 assert_eq!(e.code, ApiErrorCode::NotFound);
138 }
139
140 #[test]
141 fn constructor_codes() {
142 assert_eq!(ApiError::database("err").code, ApiErrorCode::Database);
143 assert_eq!(ApiError::internal("err").code, ApiErrorCode::Internal);
144 assert_eq!(ApiError::plugin("err").code, ApiErrorCode::Plugin);
145 }
146
147 #[test]
148 fn display_format() {
149 let e = ApiError::bad_request("test msg");
150 assert_eq!(e.to_string(), "BAD_REQUEST: test msg");
151 }
152
153 #[test]
154 fn from_sqlx_error() {
155 let sqlx_err = sqlx::Error::RowNotFound;
156 let api_err = ApiError::from(sqlx_err);
157 assert_eq!(api_err.code, ApiErrorCode::Database);
158 assert!(!api_err.message.is_empty());
159 }
160
161 #[test]
162 fn from_orchestrator_config_error() {
163 use bb_core::orchestrator::OrchestratorError;
164 let err = OrchestratorError::Config("bad config".to_string());
165 let api_err = ApiError::from(err);
166 assert_eq!(api_err.code, ApiErrorCode::Internal);
167 assert_eq!(api_err.message, "bad config");
168 }
169
170 #[test]
171 fn serde_codes_match_wire_format() {
172 let e = ApiError::bad_request("test");
173 let json = serde_json::to_string(&e).unwrap();
174 assert!(json.contains("\"BAD_REQUEST\""));
175 }
176 }
177