Skip to main content

headless_lms_models/
error.rs

1/*!
2Contains error and result types for all the model functions.
3*/
4
5use 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
14/**
15Used as the result types for all models.
16
17See also [ModelError] for documentation on how to return errors from models.
18*/
19pub 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
42/**
43Error type used by all models. Used as the error type in [ModelError], which is used by all the controllers in the application.
44
45All the information in the error is meant to be seen by the user. The type of error is determined by the [ModelErrorType] enum, which is stored inside this struct.
46
47## Examples
48
49### Usage without source error
50
51```no_run
52# use headless_lms_models::prelude::*;
53# fn random_function() -> ModelResult<()> {
54#    let erroneous_condition = 1 == 1;
55if erroneous_condition {
56    return Err(ModelError::new(
57        ModelErrorType::PreconditionFailed,
58        "The user has not enrolled to this course".to_string(),
59        None,
60    ));
61}
62# Ok(())
63# }
64```
65
66### Usage with a source error
67
68Used when calling a function that returns an error that cannot be automatically converted to an ModelError. (See `impl From<X>` implementations on this struct.)
69
70```no_run
71# use headless_lms_models::prelude::*;
72# fn some_function_returning_an_error() -> ModelResult<()> {
73#    return Err(ModelError::new(
74#        ModelErrorType::PreconditionFailed,
75#        "The user has not enrolled to this course".to_string(),
76#        None,
77#    ));
78# }
79#
80# fn random_function() -> ModelResult<()> {
81#    let erroneous_condition = 1 == 1;
82some_function_returning_an_error().map_err(|original_error| {
83    ModelError::new(
84        ModelErrorType::Generic,
85        "Everything went wrong".to_string(),
86        Some(original_error.into()),
87    )
88})?;
89# Ok(())
90# }
91```
92*/
93pub struct ModelError {
94    error_type: ModelErrorType,
95    message: String,
96    /// Original error that caused this error.
97    source: Option<anyhow::Error>,
98    /// A trace of tokio tracing spans, generated automatically when the error is generated.
99    span_trace: Box<SpanTrace>,
100    /// Stack trace, generated automatically when the error is created.
101    backtrace: Box<Backtrace>,
102    /// Source location where the error was raised.
103    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
118// Generate the clean developer `Debug`/`clean_string` and a cause resolver.
119headless_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/// The type of [ModelError] that occured.
170#[derive(Debug, PartialEq, Eq)]
171pub enum ModelErrorType {
172    RecordNotFound,
173    NotFound,
174    /// matched in From<sqlx::Error> for ModelError to get the constraint that was violated
175    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    /// HTTP request failed with specific error details
195    HttpError {
196        error_type: HttpErrorType,
197        reason: String,
198        status_code: Option<u16>,
199        response_body: Option<String>,
200    },
201}
202
203/// Types of HTTP errors that can occur
204#[derive(Debug, PartialEq, Eq)]
205pub enum HttpErrorType {
206    /// HTTP request failed due to network connection issues
207    ConnectionFailed,
208    /// HTTP request failed due to timeout
209    Timeout,
210    /// HTTP request failed due to redirect issues
211    RedirectFailed,
212    /// HTTP request failed due to request building issues
213    RequestBuildFailed,
214    /// HTTP request failed due to response body issues
215    BodyFailed,
216    /// HTTP request succeeded but response body could not be decoded as JSON
217    ResponseDecodeFailed,
218    /// HTTP request failed with non-success status code
219    StatusError,
220    /// Unknown HTTP error type
221    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
381// Generate error creation macros for ModelError
382headless_lms_utils::define_err_macro!(
383    model_err,
384    ModelError,
385    ModelErrorType,
386    ModelErrorType,
387    "Create a ModelError with less boilerplate."
388);
389
390/// Helper function for `.map_err()` chains to wrap any error as ModelError.
391///
392/// This function creates a closure that converts any error into a `ModelError`
393/// with the specified error type and message, including the original error as the source.
394///
395/// # Examples
396///
397/// ```ignore
398/// // Instead of:
399/// .map_err(|e| ModelError::new(ModelErrorType::Generic, e.to_string(), Some(e.into())))?
400///
401/// // You can write:
402/// .map_err(as_model_error(ModelErrorType::Generic, "Failed to process".to_string()))?
403/// ```
404pub 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
415/// Helper function for `.ok_or_else()` to create ModelError on None.
416///
417/// This function creates a closure that generates a `ModelError` with the
418/// specified error type and message when called.
419///
420/// # Examples
421///
422/// ```ignore
423/// // Instead of:
424/// .ok_or_else(|| ModelError::new(ModelErrorType::NotFound, "Item not found".to_string(), None))
425///
426/// // You can write:
427/// .ok_or_else(missing_model_error(ModelErrorType::NotFound, "Item not found".to_string()))
428/// ```
429pub 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    /// A wrapped `BackendError` cause renders as its own node, not an `(external)` leaf,
502    /// checking the cross-crate downcast resolver.
503    #[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}