1use 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
15pub 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
43pub struct ModelError {
95 error_type: ModelErrorType,
96 message: String,
97 source: Option<anyhow::Error>,
99 span_trace: Box<SpanTrace>,
101 backtrace: Box<Backtrace>,
103 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
119headless_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#[derive(Debug, PartialEq, Eq)]
172pub enum ModelErrorType {
173 RecordNotFound,
174 NotFound,
175 DatabaseConstraint {
177 constraint: String,
178 description: &'static str,
179 },
180 PreconditionFailed,
181 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 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#[derive(Debug, PartialEq, Eq)]
212pub enum HttpErrorType {
213 ConnectionFailed,
215 Timeout,
217 RedirectFailed,
219 RequestBuildFailed,
221 BodyFailed,
223 ResponseDecodeFailed,
225 StatusError,
227 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
413headless_lms_utils::define_err_macro!(
415 model_err,
416 ModelError,
417 ModelErrorType,
418 ModelErrorType,
419 "Create a ModelError with less boilerplate."
420);
421
422pub 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
447pub 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#[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 #[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}