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    ForeignKeyViolation,
202}
203
204/// Types of HTTP errors that can occur
205#[derive(Debug, PartialEq, Eq)]
206pub enum HttpErrorType {
207    /// HTTP request failed due to network connection issues
208    ConnectionFailed,
209    /// HTTP request failed due to timeout
210    Timeout,
211    /// HTTP request failed due to redirect issues
212    RedirectFailed,
213    /// HTTP request failed due to request building issues
214    RequestBuildFailed,
215    /// HTTP request failed due to response body issues
216    BodyFailed,
217    /// HTTP request succeeded but response body could not be decoded as JSON
218    ResponseDecodeFailed,
219    /// HTTP request failed with non-success status code
220    StatusError,
221    /// Unknown HTTP error type
222    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 db_err.is_foreign_key_violation() {
235                    model_err!(ForeignKeyViolation, err.to_string(), err)
236                } else if let Some(constraint) = db_err.constraint() {
237                    match constraint {
238                        "email_templates_subject_check" => ModelError::new(
239                            ModelErrorType::DatabaseConstraint {
240                                constraint: constraint.to_string(),
241                                description: "Subject must not be null",
242                            },
243                            err.to_string(),
244                            Some(err.into()),
245                        ),
246                        "user_details_email_check" => ModelError::new(
247                            ModelErrorType::DatabaseConstraint {
248                                constraint: constraint.to_string(),
249                                description: "Email must contain an '@' symbol.",
250                            },
251                            err.to_string(),
252                            Some(err.into()),
253                        ),
254                        "users_email" => ModelError::new(
255                            ModelErrorType::DatabaseConstraint {
256                                constraint: constraint.to_string(),
257                                description: "Email is already in use.",
258                            },
259                            err.to_string(),
260                            Some(err.into()),
261                        ),
262                        "users_upstream_id_active_uniq_idx" => ModelError::new(
263                            ModelErrorType::DatabaseConstraint {
264                                constraint: constraint.to_string(),
265                                description: "A user with this upstream id already exists.",
266                            },
267                            err.to_string(),
268                            Some(err.into()),
269                        ),
270                        "courses_slug_key_when_not_deleted"
271                        | "course_language_groups_slug_unique_non_deleted" => model_err!(
272                            DatabaseConstraint {
273                                constraint: constraint.to_string(),
274                                description: "A course with this slug already exists.",
275                            },
276                            err.to_string(),
277                            err
278                        ),
279                        "uq_oauth_device_codes_user_code_pending" => model_err!(
280                            DatabaseConstraint {
281                                constraint: constraint.to_string(),
282                                description: "A pending device authorization already uses this user_code.",
283                            },
284                            err.to_string(),
285                            err
286                        ),
287                        "unique_chatbot_names_within_course" => ModelError::new(
288                            ModelErrorType::DatabaseConstraint {
289                                constraint: constraint.to_string(),
290                                description: "The chatbot name is already taken by another chatbot on this course",
291                            },
292                            err.to_string(),
293                            Some(err.into()),
294                        ),
295                        "uq_credit_registrations_sisu_attainment" => model_err!(
296                            DatabaseConstraint {
297                                constraint: constraint.to_string(),
298                                description: "This Sisu attainment is already claimed by another credit registration.",
299                            },
300                            err.to_string(),
301                            err
302                        ),
303                        _ => ModelError::new(
304                            ModelErrorType::Database,
305                            err.to_string(),
306                            Some(err.into()),
307                        ),
308                    }
309                } else {
310                    ModelError::new(ModelErrorType::Database, err.to_string(), Some(err.into()))
311                }
312            }
313            _ => ModelError::new(ModelErrorType::Database, err.to_string(), Some(err.into())),
314        }
315    }
316}
317
318impl std::convert::From<TryFromIntError> for ModelError {
319    fn from(source: TryFromIntError) -> Self {
320        ModelError::new(
321            ModelErrorType::Conversion,
322            source.to_string(),
323            Some(source.into()),
324        )
325    }
326}
327
328impl std::convert::From<serde_json::Error> for ModelError {
329    fn from(source: serde_json::Error) -> Self {
330        ModelError::new(
331            ModelErrorType::Json,
332            source.to_string(),
333            Some(source.into()),
334        )
335    }
336}
337
338impl std::convert::From<UtilError> for ModelError {
339    fn from(source: UtilError) -> Self {
340        ModelError::new(
341            ModelErrorType::Util,
342            source.to_string(),
343            Some(source.into()),
344        )
345    }
346}
347
348impl From<anyhow::Error> for ModelError {
349    fn from(err: anyhow::Error) -> ModelError {
350        Self::new(ModelErrorType::Conversion, err.to_string(), Some(err))
351    }
352}
353
354impl From<url::ParseError> for ModelError {
355    fn from(err: url::ParseError) -> ModelError {
356        Self::new(ModelErrorType::Generic, err.to_string(), Some(err.into()))
357    }
358}
359
360impl From<reqwest::Error> for ModelError {
361    fn from(err: reqwest::Error) -> Self {
362        let error_type = if err.is_decode() {
363            HttpErrorType::ResponseDecodeFailed
364        } else if err.is_timeout() {
365            HttpErrorType::Timeout
366        } else if err.is_connect() {
367            HttpErrorType::ConnectionFailed
368        } else if err.is_redirect() {
369            HttpErrorType::RedirectFailed
370        } else if err.is_builder() {
371            HttpErrorType::RequestBuildFailed
372        } else if err.is_body() {
373            HttpErrorType::BodyFailed
374        } else if err.is_status() {
375            HttpErrorType::StatusError
376        } else {
377            HttpErrorType::Unknown
378        };
379
380        let status_code = err.status().map(|s| s.as_u16());
381        let response_body = if err.is_decode() {
382            Some("Failed to decode JSON response".to_string())
383        } else {
384            None
385        };
386
387        ModelError::new(
388            ModelErrorType::HttpError {
389                error_type,
390                reason: err.to_string(),
391                status_code,
392                response_body,
393            },
394            format!("HTTP request failed: {}", err),
395            Some(err.into()),
396        )
397    }
398}
399
400// Generate error creation macros for ModelError
401headless_lms_utils::define_err_macro!(
402    model_err,
403    ModelError,
404    ModelErrorType,
405    ModelErrorType,
406    "Create a ModelError with less boilerplate."
407);
408
409/// Helper function for `.map_err()` chains to wrap any error as ModelError.
410///
411/// This function creates a closure that converts any error into a `ModelError`
412/// with the specified error type and message, including the original error as the source.
413///
414/// # Examples
415///
416/// ```ignore
417/// // Instead of:
418/// .map_err(|e| ModelError::new(ModelErrorType::Generic, e.to_string(), Some(e.into())))?
419///
420/// // You can write:
421/// .map_err(as_model_error(ModelErrorType::Generic, "Failed to process".to_string()))?
422/// ```
423pub fn as_model_error<E>(
424    error_type: ModelErrorType,
425    message: impl Into<String>,
426) -> impl FnOnce(E) -> ModelError
427where
428    E: Into<anyhow::Error>,
429{
430    let msg = message.into();
431    move |e| ModelError::new(error_type, msg, Some(e.into()))
432}
433
434/// Helper function for `.ok_or_else()` to create ModelError on None.
435///
436/// This function creates a closure that generates a `ModelError` with the
437/// specified error type and message when called.
438///
439/// # Examples
440///
441/// ```ignore
442/// // Instead of:
443/// .ok_or_else(|| ModelError::new(ModelErrorType::NotFound, "Item not found".to_string(), None))
444///
445/// // You can write:
446/// .ok_or_else(missing_model_error(ModelErrorType::NotFound, "Item not found".to_string()))
447/// ```
448pub fn missing_model_error(
449    error_type: ModelErrorType,
450    message: impl Into<String>,
451) -> impl FnOnce() -> ModelError {
452    let msg = message.into();
453    move || ModelError::new(error_type, msg, None)
454}
455
456#[cfg(test)]
457mod test {
458    use uuid::Uuid;
459
460    use super::*;
461    use crate::{
462        PKeyPolicy,
463        email_templates::{EmailTemplateNew, EmailTemplateType},
464        test_helper::*,
465    };
466
467    #[test]
468    fn test_model_err_macro_without_source() {
469        let err = model_err!(Generic, "Test error message".to_string());
470        assert_eq!(err.message(), "Test error message");
471        assert!(matches!(err.error_type(), ModelErrorType::Generic));
472    }
473
474    #[test]
475    fn test_model_err_macro_with_source() {
476        let source_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
477        let err = model_err!(Generic, "Wrapped error".to_string(), source_err);
478        assert_eq!(err.message(), "Wrapped error");
479        assert!(err.source.is_some());
480    }
481
482    #[test]
483    fn test_as_model_error_helper() {
484        let result: Result<(), std::io::Error> = Err(std::io::Error::new(
485            std::io::ErrorKind::NotFound,
486            "test error",
487        ));
488        let model_result = result.map_err(as_model_error(
489            ModelErrorType::Generic,
490            "Failed to read file".to_string(),
491        ));
492
493        assert!(model_result.is_err());
494        let err = model_result.unwrap_err();
495        assert_eq!(err.message(), "Failed to read file");
496        assert!(matches!(err.error_type(), ModelErrorType::Generic));
497    }
498
499    #[test]
500    fn test_missing_model_error_helper() {
501        let option: Option<String> = None;
502        let result = option.ok_or_else(missing_model_error(
503            ModelErrorType::NotFound,
504            "Item not found".to_string(),
505        ));
506
507        assert!(result.is_err());
508        let err = result.unwrap_err();
509        assert_eq!(err.message(), "Item not found");
510        assert!(matches!(err.error_type(), ModelErrorType::NotFound));
511    }
512
513    #[test]
514    fn test_model_err_with_format() {
515        let id = 123;
516        let err = model_err!(NotFound, format!("Item with id {} not found", id));
517        assert_eq!(err.message(), "Item with id 123 not found");
518    }
519
520    /// A wrapped `BackendError` cause renders as its own node, not an `(external)` leaf,
521    /// checking the cross-crate downcast resolver.
522    #[test]
523    fn debug_renders_wrapped_backend_error_as_a_cause_node() {
524        use headless_lms_utils::error::util_error::{UtilError, UtilErrorType};
525        let util_error = UtilError::new(UtilErrorType::Other, "disk on fire".to_string(), None);
526        let model_error = ModelError::from(util_error);
527
528        let debug = format!("{model_error:?}");
529        assert!(debug.contains("ModelError · Util"), "got: {debug}");
530        assert!(debug.contains("caused by:"), "got: {debug}");
531        assert!(
532            debug.contains("1. UtilError · Other: disk on fire"),
533            "wrapped BackendError should render as a node, got: {debug}"
534        );
535        assert!(!debug.contains("(external)"), "got: {debug}");
536    }
537
538    #[test]
539    fn test_model_err_macro_struct_variant_without_source() {
540        let err = model_err!(
541            PreconditionFailedWithCMSAnchorBlockId {
542                id: Uuid::nil(),
543                description: "Anchor missing",
544            },
545            "Invalid anchor".to_string()
546        );
547        assert_eq!(err.message(), "Invalid anchor");
548        assert!(matches!(
549            err.error_type(),
550            ModelErrorType::PreconditionFailedWithCMSAnchorBlockId { .. }
551        ));
552    }
553
554    #[test]
555    fn test_model_err_macro_struct_variant_with_source() {
556        let source_err = std::io::Error::other("source");
557        let err = model_err!(
558            PreconditionFailedWithCMSAnchorBlockId {
559                id: Uuid::nil(),
560                description: "Anchor missing",
561            },
562            "Invalid anchor".to_string(),
563            source_err
564        );
565        assert!(matches!(
566            err.error_type(),
567            ModelErrorType::PreconditionFailedWithCMSAnchorBlockId { .. }
568        ));
569        assert!(err.source.is_some());
570    }
571
572    #[tokio::test]
573    async fn email_templates_check() {
574        insert_data!(:tx, :user, :org, :course);
575
576        let err = crate::email_templates::insert_email_template(
577            tx.as_mut(),
578            Some(course),
579            EmailTemplateNew {
580                template_type: EmailTemplateType::Generic,
581                language: None,
582                content: None,
583                subject: None,
584            },
585            Some(""),
586        )
587        .await
588        .unwrap_err();
589        match err.error_type {
590            ModelErrorType::DatabaseConstraint { constraint, .. } => {
591                assert_eq!(constraint, "email_templates_subject_check");
592            }
593            _ => {
594                panic!("wrong error variant")
595            }
596        }
597    }
598
599    #[tokio::test]
600    async fn course_language_groups_slug_uniqueness() {
601        let mut conn = Conn::init().await;
602        let mut tx = conn.begin().await;
603        crate::course_language_groups::insert(tx.as_mut(), PKeyPolicy::Generate, "taken-slug")
604            .await
605            .unwrap();
606        let err =
607            crate::course_language_groups::insert(tx.as_mut(), PKeyPolicy::Generate, "taken-slug")
608                .await
609                .unwrap_err();
610        match err.error_type {
611            ModelErrorType::DatabaseConstraint { constraint, .. } => {
612                assert_eq!(constraint, "course_language_groups_slug_unique_non_deleted");
613            }
614            _ => {
615                panic!("wrong error variant")
616            }
617        }
618    }
619
620    #[tokio::test]
621    async fn user_details_email_check() {
622        let mut conn = Conn::init().await;
623        let mut tx = conn.begin().await;
624        let err = crate::users::insert(
625            tx.as_mut(),
626            PKeyPolicy::Fixed(Uuid::parse_str("92c2d6d6-e1b8-4064-8c60-3ae52266c62c").unwrap()),
627            "invalid email",
628            None,
629            None,
630        )
631        .await
632        .unwrap_err();
633        match err.error_type {
634            ModelErrorType::DatabaseConstraint { constraint, .. } => {
635                assert_eq!(constraint, "user_details_email_check");
636            }
637            _ => {
638                panic!("wrong error variant")
639            }
640        }
641    }
642}