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