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