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 chrono::{DateTime, Utc};
10use headless_lms_base::error::backend_error::BackendError;
11use headless_lms_utils::error::util_error::UtilError;
12use tracing_error::SpanTrace;
13use uuid::Uuid;
14
15/**
16Used as the result types for all models.
17
18See also [ModelError] for documentation on how to return errors from models.
19*/
20pub type ModelResult<T> = Result<T, ModelError>;
21
22pub trait TryToOptional<T, E> {
23    fn optional(self) -> Result<Option<T>, E>
24    where
25        Self: Sized;
26}
27
28impl<T> TryToOptional<T, ModelError> for ModelResult<T> {
29    fn optional(self) -> Result<Option<T>, ModelError> {
30        match self {
31            Ok(val) => Ok(Some(val)),
32            Err(err) => {
33                if err.error_type == ModelErrorType::RecordNotFound {
34                    Ok(None)
35                } else {
36                    Err(err)
37                }
38            }
39        }
40    }
41}
42
43/**
44Error type used by all models. Used as the error type in [ModelError], which is used by all the controllers in the application.
45
46All 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.
47
48## Examples
49
50### Usage without source error
51
52```no_run
53# use headless_lms_models::prelude::*;
54# fn random_function() -> ModelResult<()> {
55#    let erroneous_condition = 1 == 1;
56if erroneous_condition {
57    return Err(ModelError::new(
58        ModelErrorType::PreconditionFailed,
59        "The user has not enrolled to this course".to_string(),
60        None,
61    ));
62}
63# Ok(())
64# }
65```
66
67### Usage with a source error
68
69Used when calling a function that returns an error that cannot be automatically converted to an ModelError. (See `impl From<X>` implementations on this struct.)
70
71```no_run
72# use headless_lms_models::prelude::*;
73# fn some_function_returning_an_error() -> ModelResult<()> {
74#    return Err(ModelError::new(
75#        ModelErrorType::PreconditionFailed,
76#        "The user has not enrolled to this course".to_string(),
77#        None,
78#    ));
79# }
80#
81# fn random_function() -> ModelResult<()> {
82#    let erroneous_condition = 1 == 1;
83some_function_returning_an_error().map_err(|original_error| {
84    ModelError::new(
85        ModelErrorType::Generic,
86        "Everything went wrong".to_string(),
87        Some(original_error.into()),
88    )
89})?;
90# Ok(())
91# }
92```
93*/
94pub struct ModelError {
95    error_type: ModelErrorType,
96    message: String,
97    /// Original error that caused this error.
98    source: Option<anyhow::Error>,
99    /// A trace of tokio tracing spans, generated automatically when the error is generated.
100    span_trace: Box<SpanTrace>,
101    /// Stack trace, generated automatically when the error is created.
102    backtrace: Box<Backtrace>,
103    /// Source location where the error was raised.
104    location: Option<&'static Location<'static>>,
105}
106
107impl std::error::Error for ModelError {
108    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
109        self.source
110            .as_deref()
111            .map(|e| e as &(dyn std::error::Error + 'static))
112    }
113
114    fn cause(&self) -> Option<&dyn std::error::Error> {
115        self.source()
116    }
117}
118
119// Generate the clean developer `Debug`/`clean_string` and a cause resolver.
120headless_lms_base::impl_clean_debug!(ModelError, [ModelError, UtilError]);
121
122impl Display for ModelError {
123    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
124        write!(f, "ModelError {:?} {:?}", self.error_type, self.message)
125    }
126}
127
128impl BackendError for ModelError {
129    type ErrorType = ModelErrorType;
130
131    fn backtrace(&self) -> Option<&Backtrace> {
132        Some(&self.backtrace)
133    }
134
135    fn error_type(&self) -> &Self::ErrorType {
136        &self.error_type
137    }
138
139    fn message(&self) -> &str {
140        &self.message
141    }
142
143    fn span_trace(&self) -> &SpanTrace {
144        &self.span_trace
145    }
146
147    fn location(&self) -> Option<&'static Location<'static>> {
148        self.location
149    }
150
151    fn new_with_traces_and_location<M: Into<String>, S: Into<Option<anyhow::Error>>>(
152        error_type: Self::ErrorType,
153        message: M,
154        source_error: S,
155        backtrace: Backtrace,
156        span_trace: SpanTrace,
157        location: Option<&'static Location<'static>>,
158    ) -> Self {
159        Self {
160            error_type,
161            message: message.into(),
162            source: source_error.into(),
163            span_trace: Box::new(span_trace),
164            backtrace: Box::new(backtrace),
165            location,
166        }
167    }
168}
169
170/// The type of [ModelError] that occured.
171#[derive(Debug, PartialEq, Eq)]
172pub enum ModelErrorType {
173    RecordNotFound,
174    NotFound,
175    /// matched in From<sqlx::Error> for ModelError to get the constraint that was violated
176    DatabaseConstraint {
177        constraint: String,
178        description: &'static str,
179    },
180    PreconditionFailed,
181    /// A save was based on a version that has since been replaced by another save.
182    StaleVersion,
183    PreconditionFailedWithCMSAnchorBlockId {
184        id: Uuid,
185        description: &'static str,
186    },
187    InvalidRequest,
188    Conversion,
189    Database,
190    Json,
191    Util,
192    Generic,
193    HttpRequest {
194        status_code: u16,
195        response_body: String,
196    },
197    /// HTTP request failed with specific error details
198    HttpError {
199        error_type: HttpErrorType,
200        reason: String,
201        status_code: Option<u16>,
202        response_body: Option<String>,
203    },
204    ForeignKeyViolation {
205        constraint: Option<String>,
206        table: Option<String>,
207    },
208}
209
210/// Types of HTTP errors that can occur
211#[derive(Debug, PartialEq, Eq)]
212pub enum HttpErrorType {
213    /// HTTP request failed due to network connection issues
214    ConnectionFailed,
215    /// HTTP request failed due to timeout
216    Timeout,
217    /// HTTP request failed due to redirect issues
218    RedirectFailed,
219    /// HTTP request failed due to request building issues
220    RequestBuildFailed,
221    /// HTTP request failed due to response body issues
222    BodyFailed,
223    /// HTTP request succeeded but response body could not be decoded as JSON
224    ResponseDecodeFailed,
225    /// HTTP request failed with non-success status code
226    StatusError,
227    /// Unknown HTTP error type
228    Unknown,
229}
230
231impl From<sqlx::Error> for ModelError {
232    fn from(err: sqlx::Error) -> Self {
233        match &err {
234            sqlx::Error::RowNotFound => ModelError::new(
235                ModelErrorType::RecordNotFound,
236                err.to_string(),
237                Some(err.into()),
238            ),
239            sqlx::Error::Database(db_err) => {
240                if db_err.is_foreign_key_violation() {
241                    model_err!(
242                        ForeignKeyViolation {
243                            constraint: db_err.constraint().map(str::to_string),
244                            table: db_err.table().map(str::to_string)
245                        },
246                        err.to_string(),
247                        err
248                    )
249                } else if let Some(constraint) = db_err.constraint() {
250                    match constraint {
251                        "email_templates_subject_check" => ModelError::new(
252                            ModelErrorType::DatabaseConstraint {
253                                constraint: constraint.to_string(),
254                                description: "Subject must not be null",
255                            },
256                            err.to_string(),
257                            Some(err.into()),
258                        ),
259                        "user_details_email_check" => ModelError::new(
260                            ModelErrorType::DatabaseConstraint {
261                                constraint: constraint.to_string(),
262                                description: "Email must contain an '@' symbol.",
263                            },
264                            err.to_string(),
265                            Some(err.into()),
266                        ),
267                        "users_email" => ModelError::new(
268                            ModelErrorType::DatabaseConstraint {
269                                constraint: constraint.to_string(),
270                                description: "Email is already in use.",
271                            },
272                            err.to_string(),
273                            Some(err.into()),
274                        ),
275                        "users_upstream_id_active_uniq_idx" => ModelError::new(
276                            ModelErrorType::DatabaseConstraint {
277                                constraint: constraint.to_string(),
278                                description: "A user with this upstream id already exists.",
279                            },
280                            err.to_string(),
281                            Some(err.into()),
282                        ),
283                        "courses_slug_key_when_not_deleted"
284                        | "course_language_groups_slug_unique_non_deleted" => model_err!(
285                            DatabaseConstraint {
286                                constraint: constraint.to_string(),
287                                description: "A course with this slug already exists.",
288                            },
289                            err.to_string(),
290                            err
291                        ),
292                        "uq_oauth_device_codes_user_code_pending" => model_err!(
293                            DatabaseConstraint {
294                                constraint: constraint.to_string(),
295                                description: "A pending device authorization already uses this user_code.",
296                            },
297                            err.to_string(),
298                            err
299                        ),
300                        "unique_chatbot_names_within_course" => ModelError::new(
301                            ModelErrorType::DatabaseConstraint {
302                                constraint: constraint.to_string(),
303                                description: "The chatbot name is already taken by another chatbot on this course",
304                            },
305                            err.to_string(),
306                            Some(err.into()),
307                        ),
308                        "uq_credit_registrations_sisu_attainment" => model_err!(
309                            DatabaseConstraint {
310                                constraint: constraint.to_string(),
311                                description: "This Sisu attainment is already claimed by another credit registration.",
312                            },
313                            err.to_string(),
314                            err
315                        ),
316                        _ => ModelError::new(
317                            ModelErrorType::Database,
318                            err.to_string(),
319                            Some(err.into()),
320                        ),
321                    }
322                } else {
323                    ModelError::new(ModelErrorType::Database, err.to_string(), Some(err.into()))
324                }
325            }
326            _ => ModelError::new(ModelErrorType::Database, err.to_string(), Some(err.into())),
327        }
328    }
329}
330
331impl std::convert::From<TryFromIntError> for ModelError {
332    fn from(source: TryFromIntError) -> Self {
333        ModelError::new(
334            ModelErrorType::Conversion,
335            source.to_string(),
336            Some(source.into()),
337        )
338    }
339}
340
341impl std::convert::From<serde_json::Error> for ModelError {
342    fn from(source: serde_json::Error) -> Self {
343        ModelError::new(
344            ModelErrorType::Json,
345            source.to_string(),
346            Some(source.into()),
347        )
348    }
349}
350
351impl std::convert::From<UtilError> for ModelError {
352    fn from(source: UtilError) -> Self {
353        ModelError::new(
354            ModelErrorType::Util,
355            source.to_string(),
356            Some(source.into()),
357        )
358    }
359}
360
361impl From<anyhow::Error> for ModelError {
362    fn from(err: anyhow::Error) -> ModelError {
363        Self::new(ModelErrorType::Conversion, err.to_string(), Some(err))
364    }
365}
366
367impl From<url::ParseError> for ModelError {
368    fn from(err: url::ParseError) -> ModelError {
369        Self::new(ModelErrorType::Generic, err.to_string(), Some(err.into()))
370    }
371}
372
373impl From<reqwest::Error> for ModelError {
374    fn from(err: reqwest::Error) -> Self {
375        let error_type = if err.is_decode() {
376            HttpErrorType::ResponseDecodeFailed
377        } else if err.is_timeout() {
378            HttpErrorType::Timeout
379        } else if err.is_connect() {
380            HttpErrorType::ConnectionFailed
381        } else if err.is_redirect() {
382            HttpErrorType::RedirectFailed
383        } else if err.is_builder() {
384            HttpErrorType::RequestBuildFailed
385        } else if err.is_body() {
386            HttpErrorType::BodyFailed
387        } else if err.is_status() {
388            HttpErrorType::StatusError
389        } else {
390            HttpErrorType::Unknown
391        };
392
393        let status_code = err.status().map(|s| s.as_u16());
394        let response_body = if err.is_decode() {
395            Some("Failed to decode JSON response".to_string())
396        } else {
397            None
398        };
399
400        ModelError::new(
401            ModelErrorType::HttpError {
402                error_type,
403                reason: err.to_string(),
404                status_code,
405                response_body,
406            },
407            format!("HTTP request failed: {}", err),
408            Some(err.into()),
409        )
410    }
411}
412
413// Generate error creation macros for ModelError
414headless_lms_utils::define_err_macro!(
415    model_err,
416    ModelError,
417    ModelErrorType,
418    ModelErrorType,
419    "Create a ModelError with less boilerplate."
420);
421
422/// Helper function for `.map_err()` chains to wrap any error as ModelError.
423///
424/// This function creates a closure that converts any error into a `ModelError`
425/// with the specified error type and message, including the original error as the source.
426///
427/// # Examples
428///
429/// ```ignore
430/// // Instead of:
431/// .map_err(|e| ModelError::new(ModelErrorType::Generic, e.to_string(), Some(e.into())))?
432///
433/// // You can write:
434/// .map_err(as_model_error(ModelErrorType::Generic, "Failed to process".to_string()))?
435/// ```
436pub fn as_model_error<E>(
437    error_type: ModelErrorType,
438    message: impl Into<String>,
439) -> impl FnOnce(E) -> ModelError
440where
441    E: Into<anyhow::Error>,
442{
443    let msg = message.into();
444    move |e| ModelError::new(error_type, msg, Some(e.into()))
445}
446
447/// Helper function for `.ok_or_else()` to create ModelError on None.
448///
449/// This function creates a closure that generates a `ModelError` with the
450/// specified error type and message when called.
451///
452/// # Examples
453///
454/// ```ignore
455/// // Instead of:
456/// .ok_or_else(|| ModelError::new(ModelErrorType::NotFound, "Item not found".to_string(), None))
457///
458/// // You can write:
459/// .ok_or_else(missing_model_error(ModelErrorType::NotFound, "Item not found".to_string()))
460/// ```
461pub fn missing_model_error(
462    error_type: ModelErrorType,
463    message: impl Into<String>,
464) -> impl FnOnce() -> ModelError {
465    let msg = message.into();
466    move || ModelError::new(error_type, msg, None)
467}
468
469/// Refuses with [ModelErrorType::StaleVersion] and `message` unless `current`, a row's locked
470/// `updated_at` (`None` for a missing row), is `expected`. Passes whenever `expected` is `None`.
471#[track_caller]
472pub(crate) fn ensure_version(
473    current: Option<DateTime<Utc>>,
474    expected: Option<DateTime<Utc>>,
475    message: &str,
476) -> ModelResult<()> {
477    match expected {
478        Some(expected) if current != Some(expected) => {
479            Err(model_err!(StaleVersion, message.to_string()))
480        }
481        _ => Ok(()),
482    }
483}
484
485#[cfg(test)]
486mod test {
487    use uuid::Uuid;
488
489    use super::*;
490    use crate::{
491        PKeyPolicy,
492        email_templates::{EmailTemplateNew, EmailTemplateType},
493        test_helper::*,
494    };
495
496    #[test]
497    fn test_model_err_macro_without_source() {
498        let err = model_err!(Generic, "Test error message".to_string());
499        assert_eq!(err.message(), "Test error message");
500        assert!(matches!(err.error_type(), ModelErrorType::Generic));
501    }
502
503    #[test]
504    fn test_model_err_macro_with_source() {
505        let source_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
506        let err = model_err!(Generic, "Wrapped error".to_string(), source_err);
507        assert_eq!(err.message(), "Wrapped error");
508        assert!(err.source.is_some());
509    }
510
511    #[test]
512    fn test_as_model_error_helper() {
513        let result: Result<(), std::io::Error> = Err(std::io::Error::new(
514            std::io::ErrorKind::NotFound,
515            "test error",
516        ));
517        let model_result = result.map_err(as_model_error(
518            ModelErrorType::Generic,
519            "Failed to read file".to_string(),
520        ));
521
522        assert!(model_result.is_err());
523        let err = model_result.unwrap_err();
524        assert_eq!(err.message(), "Failed to read file");
525        assert!(matches!(err.error_type(), ModelErrorType::Generic));
526    }
527
528    #[test]
529    fn test_missing_model_error_helper() {
530        let option: Option<String> = None;
531        let result = option.ok_or_else(missing_model_error(
532            ModelErrorType::NotFound,
533            "Item not found".to_string(),
534        ));
535
536        assert!(result.is_err());
537        let err = result.unwrap_err();
538        assert_eq!(err.message(), "Item not found");
539        assert!(matches!(err.error_type(), ModelErrorType::NotFound));
540    }
541
542    #[test]
543    fn test_model_err_with_format() {
544        let id = 123;
545        let err = model_err!(NotFound, format!("Item with id {} not found", id));
546        assert_eq!(err.message(), "Item with id 123 not found");
547    }
548
549    /// A wrapped `BackendError` cause renders as its own node, not an `(external)` leaf,
550    /// checking the cross-crate downcast resolver.
551    #[test]
552    fn debug_renders_wrapped_backend_error_as_a_cause_node() {
553        use headless_lms_utils::error::util_error::{UtilError, UtilErrorType};
554        let util_error = UtilError::new(UtilErrorType::Other, "disk on fire".to_string(), None);
555        let model_error = ModelError::from(util_error);
556
557        let debug = format!("{model_error:?}");
558        assert!(debug.contains("ModelError · Util"), "got: {debug}");
559        assert!(debug.contains("caused by:"), "got: {debug}");
560        assert!(
561            debug.contains("1. UtilError · Other: disk on fire"),
562            "wrapped BackendError should render as a node, got: {debug}"
563        );
564        assert!(!debug.contains("(external)"), "got: {debug}");
565    }
566
567    #[test]
568    fn test_model_err_macro_struct_variant_without_source() {
569        let err = model_err!(
570            PreconditionFailedWithCMSAnchorBlockId {
571                id: Uuid::nil(),
572                description: "Anchor missing",
573            },
574            "Invalid anchor".to_string()
575        );
576        assert_eq!(err.message(), "Invalid anchor");
577        assert!(matches!(
578            err.error_type(),
579            ModelErrorType::PreconditionFailedWithCMSAnchorBlockId { .. }
580        ));
581    }
582
583    #[test]
584    fn test_model_err_macro_struct_variant_with_source() {
585        let source_err = std::io::Error::other("source");
586        let err = model_err!(
587            PreconditionFailedWithCMSAnchorBlockId {
588                id: Uuid::nil(),
589                description: "Anchor missing",
590            },
591            "Invalid anchor".to_string(),
592            source_err
593        );
594        assert!(matches!(
595            err.error_type(),
596            ModelErrorType::PreconditionFailedWithCMSAnchorBlockId { .. }
597        ));
598        assert!(err.source.is_some());
599    }
600
601    #[tokio::test]
602    async fn email_templates_check() {
603        insert_data!(:tx, :user, :org, :course);
604
605        let err = crate::email_templates::insert_email_template(
606            tx.as_mut(),
607            Some(course),
608            EmailTemplateNew {
609                template_type: EmailTemplateType::Generic,
610                language: None,
611                content: None,
612                subject: None,
613            },
614            Some(""),
615        )
616        .await
617        .unwrap_err();
618        match err.error_type {
619            ModelErrorType::DatabaseConstraint { constraint, .. } => {
620                assert_eq!(constraint, "email_templates_subject_check");
621            }
622            _ => {
623                panic!("wrong error variant")
624            }
625        }
626    }
627
628    #[tokio::test]
629    async fn course_language_groups_slug_uniqueness() {
630        let mut conn = Conn::init().await;
631        let mut tx = conn.begin().await;
632        crate::course_language_groups::insert(tx.as_mut(), PKeyPolicy::Generate, "taken-slug")
633            .await
634            .unwrap();
635        let err =
636            crate::course_language_groups::insert(tx.as_mut(), PKeyPolicy::Generate, "taken-slug")
637                .await
638                .unwrap_err();
639        match err.error_type {
640            ModelErrorType::DatabaseConstraint { constraint, .. } => {
641                assert_eq!(constraint, "course_language_groups_slug_unique_non_deleted");
642            }
643            _ => {
644                panic!("wrong error variant")
645            }
646        }
647    }
648
649    #[tokio::test]
650    async fn user_details_email_check() {
651        let mut conn = Conn::init().await;
652        let mut tx = conn.begin().await;
653        let err = crate::users::insert(
654            tx.as_mut(),
655            PKeyPolicy::Fixed(Uuid::parse_str("92c2d6d6-e1b8-4064-8c60-3ae52266c62c").unwrap()),
656            "invalid email",
657            None,
658            None,
659        )
660        .await
661        .unwrap_err();
662        match err.error_type {
663            ModelErrorType::DatabaseConstraint { constraint, .. } => {
664                assert_eq!(constraint, "user_details_email_check");
665            }
666            _ => {
667                panic!("wrong error variant")
668            }
669        }
670    }
671}