1use std::{fmt::Display, num::TryFromIntError};
6
7use backtrace::Backtrace;
8use headless_lms_base::error::backend_error::BackendError;
9use headless_lms_utils::error::util_error::UtilError;
10use tracing_error::SpanTrace;
11use uuid::Uuid;
12
13pub type ModelResult<T> = Result<T, ModelError>;
19
20pub trait TryToOptional<T, E> {
21 fn optional(self) -> Result<Option<T>, E>
22 where
23 Self: Sized;
24}
25
26impl<T> TryToOptional<T, ModelError> for ModelResult<T> {
27 fn optional(self) -> Result<Option<T>, ModelError> {
28 match self {
29 Ok(val) => Ok(Some(val)),
30 Err(err) => {
31 if err.error_type == ModelErrorType::RecordNotFound {
32 Ok(None)
33 } else {
34 Err(err)
35 }
36 }
37 }
38 }
39}
40
41#[derive(Debug)]
93pub struct ModelError {
94 error_type: ModelErrorType,
95 message: String,
96 source: Option<anyhow::Error>,
98 span_trace: Box<SpanTrace>,
100 backtrace: Box<Backtrace>,
102}
103
104impl std::error::Error for ModelError {
105 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
106 self.source.as_ref().and_then(|o| o.source())
107 }
108
109 fn cause(&self) -> Option<&dyn std::error::Error> {
110 self.source()
111 }
112}
113
114impl Display for ModelError {
115 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
116 write!(f, "ModelError {:?} {:?}", self.error_type, self.message)
117 }
118}
119
120impl BackendError for ModelError {
121 type ErrorType = ModelErrorType;
122
123 fn new<M: Into<String>, S: Into<Option<anyhow::Error>>>(
124 error_type: Self::ErrorType,
125 message: M,
126 source_error: S,
127 ) -> Self {
128 Self::new_with_traces(
129 error_type,
130 message,
131 source_error,
132 Backtrace::new(),
133 SpanTrace::capture(),
134 )
135 }
136
137 fn backtrace(&self) -> Option<&Backtrace> {
138 Some(&self.backtrace)
139 }
140
141 fn error_type(&self) -> &Self::ErrorType {
142 &self.error_type
143 }
144
145 fn message(&self) -> &str {
146 &self.message
147 }
148
149 fn span_trace(&self) -> &SpanTrace {
150 &self.span_trace
151 }
152
153 fn new_with_traces<M: Into<String>, S: Into<Option<anyhow::Error>>>(
154 error_type: Self::ErrorType,
155 message: M,
156 source_error: S,
157 backtrace: Backtrace,
158 span_trace: SpanTrace,
159 ) -> Self {
160 Self {
161 error_type,
162 message: message.into(),
163 source: source_error.into(),
164 span_trace: Box::new(span_trace),
165 backtrace: Box::new(backtrace),
166 }
167 }
168}
169
170#[derive(Debug, PartialEq, Eq)]
172pub enum ModelErrorType {
173 RecordNotFound,
174 NotFound,
175 DatabaseConstraint {
177 constraint: String,
178 description: &'static str,
179 },
180 PreconditionFailed,
181 PreconditionFailedWithCMSAnchorBlockId {
182 id: Uuid,
183 description: &'static str,
184 },
185 InvalidRequest,
186 Conversion,
187 Database,
188 Json,
189 Util,
190 Generic,
191 HttpRequest {
192 status_code: u16,
193 response_body: String,
194 },
195 HttpError {
197 error_type: HttpErrorType,
198 reason: String,
199 status_code: Option<u16>,
200 response_body: Option<String>,
201 },
202}
203
204#[derive(Debug, PartialEq, Eq)]
206pub enum HttpErrorType {
207 ConnectionFailed,
209 Timeout,
211 RedirectFailed,
213 RequestBuildFailed,
215 BodyFailed,
217 ResponseDecodeFailed,
219 StatusError,
221 Unknown,
223}
224
225impl From<sqlx::Error> for ModelError {
226 fn from(err: sqlx::Error) -> Self {
227 match &err {
228 sqlx::Error::RowNotFound => ModelError::new(
229 ModelErrorType::RecordNotFound,
230 err.to_string(),
231 Some(err.into()),
232 ),
233 sqlx::Error::Database(db_err) => {
234 if let Some(constraint) = db_err.constraint() {
235 match constraint {
236 "email_templates_subject_check" => ModelError::new(
237 ModelErrorType::DatabaseConstraint {
238 constraint: constraint.to_string(),
239 description: "Subject must not be null",
240 },
241 err.to_string(),
242 Some(err.into()),
243 ),
244 "user_details_email_check" => ModelError::new(
245 ModelErrorType::DatabaseConstraint {
246 constraint: constraint.to_string(),
247 description: "Email must contain an '@' symbol.",
248 },
249 err.to_string(),
250 Some(err.into()),
251 ),
252 "users_email" => ModelError::new(
253 ModelErrorType::DatabaseConstraint {
254 constraint: constraint.to_string(),
255 description: "Email is already in use.",
256 },
257 err.to_string(),
258 Some(err.into()),
259 ),
260 "unique_chatbot_names_within_course" => ModelError::new(
261 ModelErrorType::DatabaseConstraint {
262 constraint: constraint.to_string(),
263 description: "The chatbot name is already taken by another chatbot on this course",
264 },
265 err.to_string(),
266 Some(err.into()),
267 ),
268 _ => ModelError::new(
269 ModelErrorType::Database,
270 err.to_string(),
271 Some(err.into()),
272 ),
273 }
274 } else {
275 ModelError::new(ModelErrorType::Database, err.to_string(), Some(err.into()))
276 }
277 }
278 _ => ModelError::new(ModelErrorType::Database, err.to_string(), Some(err.into())),
279 }
280 }
281}
282
283impl std::convert::From<TryFromIntError> for ModelError {
284 fn from(source: TryFromIntError) -> Self {
285 ModelError::new(
286 ModelErrorType::Conversion,
287 source.to_string(),
288 Some(source.into()),
289 )
290 }
291}
292
293impl std::convert::From<serde_json::Error> for ModelError {
294 fn from(source: serde_json::Error) -> Self {
295 ModelError::new(
296 ModelErrorType::Json,
297 source.to_string(),
298 Some(source.into()),
299 )
300 }
301}
302
303impl std::convert::From<UtilError> for ModelError {
304 fn from(source: UtilError) -> Self {
305 ModelError::new(
306 ModelErrorType::Util,
307 source.to_string(),
308 Some(source.into()),
309 )
310 }
311}
312
313impl From<anyhow::Error> for ModelError {
314 fn from(err: anyhow::Error) -> ModelError {
315 Self::new(ModelErrorType::Conversion, err.to_string(), Some(err))
316 }
317}
318
319impl From<url::ParseError> for ModelError {
320 fn from(err: url::ParseError) -> ModelError {
321 Self::new(ModelErrorType::Generic, err.to_string(), Some(err.into()))
322 }
323}
324
325impl From<reqwest::Error> for ModelError {
326 fn from(err: reqwest::Error) -> Self {
327 let error_type = if err.is_decode() {
328 HttpErrorType::ResponseDecodeFailed
329 } else if err.is_timeout() {
330 HttpErrorType::Timeout
331 } else if err.is_connect() {
332 HttpErrorType::ConnectionFailed
333 } else if err.is_redirect() {
334 HttpErrorType::RedirectFailed
335 } else if err.is_builder() {
336 HttpErrorType::RequestBuildFailed
337 } else if err.is_body() {
338 HttpErrorType::BodyFailed
339 } else if err.is_status() {
340 HttpErrorType::StatusError
341 } else {
342 HttpErrorType::Unknown
343 };
344
345 let status_code = err.status().map(|s| s.as_u16());
346 let response_body = if err.is_decode() {
347 Some("Failed to decode JSON response".to_string())
348 } else {
349 None
350 };
351
352 ModelError::new(
353 ModelErrorType::HttpError {
354 error_type,
355 reason: err.to_string(),
356 status_code,
357 response_body,
358 },
359 format!("HTTP request failed: {}", err),
360 Some(err.into()),
361 )
362 }
363}
364
365headless_lms_utils::define_err_macro!(
367 model_err,
368 ModelError,
369 ModelErrorType,
370 ModelErrorType,
371 "Create a ModelError with less boilerplate."
372);
373
374pub fn as_model_error<E>(
389 error_type: ModelErrorType,
390 message: impl Into<String>,
391) -> impl FnOnce(E) -> ModelError
392where
393 E: Into<anyhow::Error>,
394{
395 let msg = message.into();
396 move |e| ModelError::new(error_type, msg, Some(e.into()))
397}
398
399pub fn missing_model_error(
414 error_type: ModelErrorType,
415 message: impl Into<String>,
416) -> impl FnOnce() -> ModelError {
417 let msg = message.into();
418 move || ModelError::new(error_type, msg, None)
419}
420
421#[cfg(test)]
422mod test {
423 use uuid::Uuid;
424
425 use super::*;
426 use crate::{
427 PKeyPolicy,
428 email_templates::{EmailTemplateNew, EmailTemplateType},
429 test_helper::*,
430 };
431
432 #[test]
433 fn test_model_err_macro_without_source() {
434 let err = model_err!(Generic, "Test error message".to_string());
435 assert_eq!(err.message(), "Test error message");
436 assert!(matches!(err.error_type(), ModelErrorType::Generic));
437 }
438
439 #[test]
440 fn test_model_err_macro_with_source() {
441 let source_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
442 let err = model_err!(Generic, "Wrapped error".to_string(), source_err);
443 assert_eq!(err.message(), "Wrapped error");
444 assert!(err.source.is_some());
445 }
446
447 #[test]
448 fn test_as_model_error_helper() {
449 let result: Result<(), std::io::Error> = Err(std::io::Error::new(
450 std::io::ErrorKind::NotFound,
451 "test error",
452 ));
453 let model_result = result.map_err(as_model_error(
454 ModelErrorType::Generic,
455 "Failed to read file".to_string(),
456 ));
457
458 assert!(model_result.is_err());
459 let err = model_result.unwrap_err();
460 assert_eq!(err.message(), "Failed to read file");
461 assert!(matches!(err.error_type(), ModelErrorType::Generic));
462 }
463
464 #[test]
465 fn test_missing_model_error_helper() {
466 let option: Option<String> = None;
467 let result = option.ok_or_else(missing_model_error(
468 ModelErrorType::NotFound,
469 "Item not found".to_string(),
470 ));
471
472 assert!(result.is_err());
473 let err = result.unwrap_err();
474 assert_eq!(err.message(), "Item not found");
475 assert!(matches!(err.error_type(), ModelErrorType::NotFound));
476 }
477
478 #[test]
479 fn test_model_err_with_format() {
480 let id = 123;
481 let err = model_err!(NotFound, format!("Item with id {} not found", id));
482 assert_eq!(err.message(), "Item with id 123 not found");
483 }
484
485 #[test]
486 fn test_model_err_macro_struct_variant_without_source() {
487 let err = model_err!(
488 PreconditionFailedWithCMSAnchorBlockId {
489 id: Uuid::nil(),
490 description: "Anchor missing",
491 },
492 "Invalid anchor".to_string()
493 );
494 assert_eq!(err.message(), "Invalid anchor");
495 assert!(matches!(
496 err.error_type(),
497 ModelErrorType::PreconditionFailedWithCMSAnchorBlockId { .. }
498 ));
499 }
500
501 #[test]
502 fn test_model_err_macro_struct_variant_with_source() {
503 let source_err = std::io::Error::other("source");
504 let err = model_err!(
505 PreconditionFailedWithCMSAnchorBlockId {
506 id: Uuid::nil(),
507 description: "Anchor missing",
508 },
509 "Invalid anchor".to_string(),
510 source_err
511 );
512 assert!(matches!(
513 err.error_type(),
514 ModelErrorType::PreconditionFailedWithCMSAnchorBlockId { .. }
515 ));
516 assert!(err.source.is_some());
517 }
518
519 #[tokio::test]
520 async fn email_templates_check() {
521 insert_data!(:tx, :user, :org, :course);
522
523 let err = crate::email_templates::insert_email_template(
524 tx.as_mut(),
525 Some(course),
526 EmailTemplateNew {
527 template_type: EmailTemplateType::Generic,
528 language: None,
529 content: None,
530 subject: None,
531 },
532 Some(""),
533 )
534 .await
535 .unwrap_err();
536 match err.error_type {
537 ModelErrorType::DatabaseConstraint { constraint, .. } => {
538 assert_eq!(constraint, "email_templates_subject_check");
539 }
540 _ => {
541 panic!("wrong error variant")
542 }
543 }
544 }
545
546 #[tokio::test]
547 async fn user_details_email_check() {
548 let mut conn = Conn::init().await;
549 let mut tx = conn.begin().await;
550 let err = crate::users::insert(
551 tx.as_mut(),
552 PKeyPolicy::Fixed(Uuid::parse_str("92c2d6d6-e1b8-4064-8c60-3ae52266c62c").unwrap()),
553 "invalid email",
554 None,
555 None,
556 )
557 .await
558 .unwrap_err();
559 match err.error_type {
560 ModelErrorType::DatabaseConstraint { constraint, .. } => {
561 assert_eq!(constraint, "user_details_email_check");
562 }
563 _ => {
564 panic!("wrong error variant")
565 }
566 }
567 }
568}