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