1use 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
14pub 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
42pub struct ModelError {
94 error_type: ModelErrorType,
95 message: String,
96 source: Option<anyhow::Error>,
98 span_trace: Box<SpanTrace>,
100 backtrace: Box<Backtrace>,
102 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
118headless_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#[derive(Debug, PartialEq, Eq)]
171pub enum ModelErrorType {
172 RecordNotFound,
173 NotFound,
174 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 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#[derive(Debug, PartialEq, Eq)]
209pub enum HttpErrorType {
210 ConnectionFailed,
212 Timeout,
214 RedirectFailed,
216 RequestBuildFailed,
218 BodyFailed,
220 ResponseDecodeFailed,
222 StatusError,
224 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
410headless_lms_utils::define_err_macro!(
412 model_err,
413 ModelError,
414 ModelErrorType,
415 ModelErrorType,
416 "Create a ModelError with less boilerplate."
417);
418
419pub 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
444pub 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 #[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}