Skip to main content

headless_lms_models/
error.rs

1/*!
2Contains error and result types for all the model functions.
3*/
4
5use std::{fmt::Display, num::TryFromIntError};
6
7use backtrace::Backtrace;
8use headless_lms_base::error::backend_error::BackendError;
9use headless_lms_utils::error::util_error::UtilError;
10use tracing_error::SpanTrace;
11use uuid::Uuid;
12
13/**
14Used as the result types for all models.
15
16See also [ModelError] for documentation on how to return errors from models.
17*/
18pub type ModelResult<T> = Result<T, ModelError>;
19
20pub trait TryToOptional<T, E> {
21    fn optional(self) -> Result<Option<T>, E>
22    where
23        Self: Sized;
24}
25
26impl<T> TryToOptional<T, ModelError> for ModelResult<T> {
27    fn optional(self) -> Result<Option<T>, ModelError> {
28        match self {
29            Ok(val) => Ok(Some(val)),
30            Err(err) => {
31                if err.error_type == ModelErrorType::RecordNotFound {
32                    Ok(None)
33                } else {
34                    Err(err)
35                }
36            }
37        }
38    }
39}
40
41/**
42Error type used by all models. Used as the error type in [ModelError], which is used by all the controllers in the application.
43
44All 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.
45
46## Examples
47
48### Usage without source error
49
50```no_run
51# use headless_lms_models::prelude::*;
52# fn random_function() -> ModelResult<()> {
53#    let erroneous_condition = 1 == 1;
54if erroneous_condition {
55    return Err(ModelError::new(
56        ModelErrorType::PreconditionFailed,
57        "The user has not enrolled to this course".to_string(),
58        None,
59    ));
60}
61# Ok(())
62# }
63```
64
65### Usage with a source error
66
67Used when calling a function that returns an error that cannot be automatically converted to an ModelError. (See `impl From<X>` implementations on this struct.)
68
69```no_run
70# use headless_lms_models::prelude::*;
71# fn some_function_returning_an_error() -> ModelResult<()> {
72#    return Err(ModelError::new(
73#        ModelErrorType::PreconditionFailed,
74#        "The user has not enrolled to this course".to_string(),
75#        None,
76#    ));
77# }
78#
79# fn random_function() -> ModelResult<()> {
80#    let erroneous_condition = 1 == 1;
81some_function_returning_an_error().map_err(|original_error| {
82    ModelError::new(
83        ModelErrorType::Generic,
84        "Everything went wrong".to_string(),
85        Some(original_error.into()),
86    )
87})?;
88# Ok(())
89# }
90```
91*/
92#[derive(Debug)]
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}
103
104impl std::error::Error for ModelError {
105    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
106        self.source.as_ref().and_then(|o| o.source())
107    }
108
109    fn cause(&self) -> Option<&dyn std::error::Error> {
110        self.source()
111    }
112}
113
114impl Display for ModelError {
115    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
116        write!(f, "ModelError {:?} {:?}", self.error_type, self.message)
117    }
118}
119
120impl BackendError for ModelError {
121    type ErrorType = ModelErrorType;
122
123    fn new<M: Into<String>, S: Into<Option<anyhow::Error>>>(
124        error_type: Self::ErrorType,
125        message: M,
126        source_error: S,
127    ) -> Self {
128        Self::new_with_traces(
129            error_type,
130            message,
131            source_error,
132            Backtrace::new(),
133            SpanTrace::capture(),
134        )
135    }
136
137    fn backtrace(&self) -> Option<&Backtrace> {
138        Some(&self.backtrace)
139    }
140
141    fn error_type(&self) -> &Self::ErrorType {
142        &self.error_type
143    }
144
145    fn message(&self) -> &str {
146        &self.message
147    }
148
149    fn span_trace(&self) -> &SpanTrace {
150        &self.span_trace
151    }
152
153    fn new_with_traces<M: Into<String>, S: Into<Option<anyhow::Error>>>(
154        error_type: Self::ErrorType,
155        message: M,
156        source_error: S,
157        backtrace: Backtrace,
158        span_trace: SpanTrace,
159    ) -> Self {
160        Self {
161            error_type,
162            message: message.into(),
163            source: source_error.into(),
164            span_trace: Box::new(span_trace),
165            backtrace: Box::new(backtrace),
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    PreconditionFailedWithCMSAnchorBlockId {
182        id: Uuid,
183        description: &'static str,
184    },
185    InvalidRequest,
186    Conversion,
187    Database,
188    Json,
189    Util,
190    Generic,
191    HttpRequest {
192        status_code: u16,
193        response_body: String,
194    },
195    /// HTTP request failed with specific error details
196    HttpError {
197        error_type: HttpErrorType,
198        reason: String,
199        status_code: Option<u16>,
200        response_body: Option<String>,
201    },
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 let Some(constraint) = db_err.constraint() {
235                    match constraint {
236                        "email_templates_subject_check" => ModelError::new(
237                            ModelErrorType::DatabaseConstraint {
238                                constraint: constraint.to_string(),
239                                description: "Subject must not be null",
240                            },
241                            err.to_string(),
242                            Some(err.into()),
243                        ),
244                        "user_details_email_check" => ModelError::new(
245                            ModelErrorType::DatabaseConstraint {
246                                constraint: constraint.to_string(),
247                                description: "Email must contain an '@' symbol.",
248                            },
249                            err.to_string(),
250                            Some(err.into()),
251                        ),
252                        "users_email" => ModelError::new(
253                            ModelErrorType::DatabaseConstraint {
254                                constraint: constraint.to_string(),
255                                description: "Email is already in use.",
256                            },
257                            err.to_string(),
258                            Some(err.into()),
259                        ),
260                        "unique_chatbot_names_within_course" => ModelError::new(
261                            ModelErrorType::DatabaseConstraint {
262                                constraint: constraint.to_string(),
263                                description: "The chatbot name is already taken by another chatbot on this course",
264                            },
265                            err.to_string(),
266                            Some(err.into()),
267                        ),
268                        _ => ModelError::new(
269                            ModelErrorType::Database,
270                            err.to_string(),
271                            Some(err.into()),
272                        ),
273                    }
274                } else {
275                    ModelError::new(ModelErrorType::Database, err.to_string(), Some(err.into()))
276                }
277            }
278            _ => ModelError::new(ModelErrorType::Database, err.to_string(), Some(err.into())),
279        }
280    }
281}
282
283impl std::convert::From<TryFromIntError> for ModelError {
284    fn from(source: TryFromIntError) -> Self {
285        ModelError::new(
286            ModelErrorType::Conversion,
287            source.to_string(),
288            Some(source.into()),
289        )
290    }
291}
292
293impl std::convert::From<serde_json::Error> for ModelError {
294    fn from(source: serde_json::Error) -> Self {
295        ModelError::new(
296            ModelErrorType::Json,
297            source.to_string(),
298            Some(source.into()),
299        )
300    }
301}
302
303impl std::convert::From<UtilError> for ModelError {
304    fn from(source: UtilError) -> Self {
305        ModelError::new(
306            ModelErrorType::Util,
307            source.to_string(),
308            Some(source.into()),
309        )
310    }
311}
312
313impl From<anyhow::Error> for ModelError {
314    fn from(err: anyhow::Error) -> ModelError {
315        Self::new(ModelErrorType::Conversion, err.to_string(), Some(err))
316    }
317}
318
319impl From<url::ParseError> for ModelError {
320    fn from(err: url::ParseError) -> ModelError {
321        Self::new(ModelErrorType::Generic, err.to_string(), Some(err.into()))
322    }
323}
324
325impl From<reqwest::Error> for ModelError {
326    fn from(err: reqwest::Error) -> Self {
327        let error_type = if err.is_decode() {
328            HttpErrorType::ResponseDecodeFailed
329        } else if err.is_timeout() {
330            HttpErrorType::Timeout
331        } else if err.is_connect() {
332            HttpErrorType::ConnectionFailed
333        } else if err.is_redirect() {
334            HttpErrorType::RedirectFailed
335        } else if err.is_builder() {
336            HttpErrorType::RequestBuildFailed
337        } else if err.is_body() {
338            HttpErrorType::BodyFailed
339        } else if err.is_status() {
340            HttpErrorType::StatusError
341        } else {
342            HttpErrorType::Unknown
343        };
344
345        let status_code = err.status().map(|s| s.as_u16());
346        let response_body = if err.is_decode() {
347            Some("Failed to decode JSON response".to_string())
348        } else {
349            None
350        };
351
352        ModelError::new(
353            ModelErrorType::HttpError {
354                error_type,
355                reason: err.to_string(),
356                status_code,
357                response_body,
358            },
359            format!("HTTP request failed: {}", err),
360            Some(err.into()),
361        )
362    }
363}
364
365// Generate error creation macros for ModelError
366headless_lms_utils::define_err_macro!(
367    model_err,
368    ModelError,
369    ModelErrorType,
370    ModelErrorType,
371    "Create a ModelError with less boilerplate."
372);
373
374/// Helper function for `.map_err()` chains to wrap any error as ModelError.
375///
376/// This function creates a closure that converts any error into a `ModelError`
377/// with the specified error type and message, including the original error as the source.
378///
379/// # Examples
380///
381/// ```ignore
382/// // Instead of:
383/// .map_err(|e| ModelError::new(ModelErrorType::Generic, e.to_string(), Some(e.into())))?
384///
385/// // You can write:
386/// .map_err(as_model_error(ModelErrorType::Generic, "Failed to process".to_string()))?
387/// ```
388pub fn as_model_error<E>(
389    error_type: ModelErrorType,
390    message: impl Into<String>,
391) -> impl FnOnce(E) -> ModelError
392where
393    E: Into<anyhow::Error>,
394{
395    let msg = message.into();
396    move |e| ModelError::new(error_type, msg, Some(e.into()))
397}
398
399/// Helper function for `.ok_or_else()` to create ModelError on None.
400///
401/// This function creates a closure that generates a `ModelError` with the
402/// specified error type and message when called.
403///
404/// # Examples
405///
406/// ```ignore
407/// // Instead of:
408/// .ok_or_else(|| ModelError::new(ModelErrorType::NotFound, "Item not found".to_string(), None))
409///
410/// // You can write:
411/// .ok_or_else(missing_model_error(ModelErrorType::NotFound, "Item not found".to_string()))
412/// ```
413pub fn missing_model_error(
414    error_type: ModelErrorType,
415    message: impl Into<String>,
416) -> impl FnOnce() -> ModelError {
417    let msg = message.into();
418    move || ModelError::new(error_type, msg, None)
419}
420
421#[cfg(test)]
422mod test {
423    use uuid::Uuid;
424
425    use super::*;
426    use crate::{
427        PKeyPolicy,
428        email_templates::{EmailTemplateNew, EmailTemplateType},
429        test_helper::*,
430    };
431
432    #[test]
433    fn test_model_err_macro_without_source() {
434        let err = model_err!(Generic, "Test error message".to_string());
435        assert_eq!(err.message(), "Test error message");
436        assert!(matches!(err.error_type(), ModelErrorType::Generic));
437    }
438
439    #[test]
440    fn test_model_err_macro_with_source() {
441        let source_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
442        let err = model_err!(Generic, "Wrapped error".to_string(), source_err);
443        assert_eq!(err.message(), "Wrapped error");
444        assert!(err.source.is_some());
445    }
446
447    #[test]
448    fn test_as_model_error_helper() {
449        let result: Result<(), std::io::Error> = Err(std::io::Error::new(
450            std::io::ErrorKind::NotFound,
451            "test error",
452        ));
453        let model_result = result.map_err(as_model_error(
454            ModelErrorType::Generic,
455            "Failed to read file".to_string(),
456        ));
457
458        assert!(model_result.is_err());
459        let err = model_result.unwrap_err();
460        assert_eq!(err.message(), "Failed to read file");
461        assert!(matches!(err.error_type(), ModelErrorType::Generic));
462    }
463
464    #[test]
465    fn test_missing_model_error_helper() {
466        let option: Option<String> = None;
467        let result = option.ok_or_else(missing_model_error(
468            ModelErrorType::NotFound,
469            "Item not found".to_string(),
470        ));
471
472        assert!(result.is_err());
473        let err = result.unwrap_err();
474        assert_eq!(err.message(), "Item not found");
475        assert!(matches!(err.error_type(), ModelErrorType::NotFound));
476    }
477
478    #[test]
479    fn test_model_err_with_format() {
480        let id = 123;
481        let err = model_err!(NotFound, format!("Item with id {} not found", id));
482        assert_eq!(err.message(), "Item with id 123 not found");
483    }
484
485    #[test]
486    fn test_model_err_macro_struct_variant_without_source() {
487        let err = model_err!(
488            PreconditionFailedWithCMSAnchorBlockId {
489                id: Uuid::nil(),
490                description: "Anchor missing",
491            },
492            "Invalid anchor".to_string()
493        );
494        assert_eq!(err.message(), "Invalid anchor");
495        assert!(matches!(
496            err.error_type(),
497            ModelErrorType::PreconditionFailedWithCMSAnchorBlockId { .. }
498        ));
499    }
500
501    #[test]
502    fn test_model_err_macro_struct_variant_with_source() {
503        let source_err = std::io::Error::other("source");
504        let err = model_err!(
505            PreconditionFailedWithCMSAnchorBlockId {
506                id: Uuid::nil(),
507                description: "Anchor missing",
508            },
509            "Invalid anchor".to_string(),
510            source_err
511        );
512        assert!(matches!(
513            err.error_type(),
514            ModelErrorType::PreconditionFailedWithCMSAnchorBlockId { .. }
515        ));
516        assert!(err.source.is_some());
517    }
518
519    #[tokio::test]
520    async fn email_templates_check() {
521        insert_data!(:tx, :user, :org, :course);
522
523        let err = crate::email_templates::insert_email_template(
524            tx.as_mut(),
525            Some(course),
526            EmailTemplateNew {
527                template_type: EmailTemplateType::Generic,
528                language: None,
529                content: None,
530                subject: None,
531            },
532            Some(""),
533        )
534        .await
535        .unwrap_err();
536        match err.error_type {
537            ModelErrorType::DatabaseConstraint { constraint, .. } => {
538                assert_eq!(constraint, "email_templates_subject_check");
539            }
540            _ => {
541                panic!("wrong error variant")
542            }
543        }
544    }
545
546    #[tokio::test]
547    async fn user_details_email_check() {
548        let mut conn = Conn::init().await;
549        let mut tx = conn.begin().await;
550        let err = crate::users::insert(
551            tx.as_mut(),
552            PKeyPolicy::Fixed(Uuid::parse_str("92c2d6d6-e1b8-4064-8c60-3ae52266c62c").unwrap()),
553            "invalid email",
554            None,
555            None,
556        )
557        .await
558        .unwrap_err();
559        match err.error_type {
560            ModelErrorType::DatabaseConstraint { constraint, .. } => {
561                assert_eq!(constraint, "user_details_email_check");
562            }
563            _ => {
564                panic!("wrong error variant")
565            }
566        }
567    }
568}