1use std::panic::Location;
6
7use crate::domain::authorization::AuthorizedResponse;
8use actix_web::{
9 HttpResponse, HttpResponseBuilder, error,
10 http::{StatusCode, header::ContentType},
11};
12use backtrace::Backtrace;
13use derive_more::Display;
14use dpop_verifier::error::DpopError;
15use headless_lms_authorization::error::{AuthorizationError, AuthorizationErrorType};
16use headless_lms_base::error::{backend_error::BackendError, clean_format::ColorChoice};
17use headless_lms_chatbot::prelude::{ChatbotError, ChatbotErrorType};
18use headless_lms_credit_registration::error::{
19 CreditRegistrationError, CreditRegistrationErrorType,
20};
21use headless_lms_models::{ModelError, ModelErrorType, prelude::UtilErrorType};
22use headless_lms_utils::error::util_error::{SisuErrorVariant, UtilError};
23use serde::{Deserialize, Serialize};
24use tracing_error::SpanTrace;
25
26use uuid::Uuid;
27
28const MISSING_EXERCISE_TYPE_DESCRIPTION: &str = "Missing exercise type for exercise task.";
29
30pub type ControllerResult<T, E = ControllerError> = std::result::Result<AuthorizedResponse<T>, E>;
37
38#[derive(Debug, Display, Serialize, Deserialize)]
40pub enum ControllerErrorType {
41 #[display("Internal server error")]
43 InternalServerError,
44
45 #[display("Bad request")]
47 BadRequest,
48
49 #[display("Bad request")]
51 BadRequestWithData(ErrorMetadata),
52
53 #[display("Bad request")]
56 BadRequestWithReason(BadRequestReason),
57
58 #[display("Upgrade required")]
60 UpgradeRequired,
61
62 #[display("Not found")]
64 NotFound,
65
66 #[display("Unauthorized")]
68 Unauthorized,
69
70 #[display("Unauthorized")]
72 UnauthorizedWithReason(UnauthorizedReason),
73
74 #[display("Forbidden")]
76 Forbidden,
77
78 #[display("OAuthError")]
80 OAuthError(Box<OAuthErrorData>),
81
82 #[display("SisuError")]
84 SisuError(SisuErrorType),
85}
86
87#[derive(Debug, Display, Serialize, Deserialize, Clone, Copy, PartialEq, Eq)]
88#[serde(rename_all = "snake_case")]
89pub enum UnauthorizedReason {
90 #[display("Chapter not open yet")]
91 ChapterNotOpenYet,
92 #[display("Authentication required for exam exercise")]
93 AuthenticationRequiredForExamExercise,
94}
95
96impl UnauthorizedReason {
97 fn message_key(self) -> &'static str {
99 match self {
100 Self::ChapterNotOpenYet => "chapter_not_open_yet",
101 Self::AuthenticationRequiredForExamExercise => {
102 "authentication_required_for_exam_exercise"
103 }
104 }
105 }
106}
107
108pub fn bad_request_with_reason(reason: BadRequestReason, message: String) -> ControllerError {
110 controller_err!(BadRequestWithReason(reason), message)
111}
112
113#[derive(Debug, Display, Serialize, Deserialize, Clone, Copy, PartialEq, Eq)]
116#[serde(rename_all = "snake_case")]
117pub enum BadRequestReason {
118 #[display("Course slug already taken")]
119 CourseSlugAlreadyTaken,
120 #[display("Foreign key violation")]
121 ForeignKeyViolation,
122 #[display("Not enrolled")]
124 NotEnrolled,
125 #[display("Upload expired")]
127 UploadExpired,
128 #[display("Unknown upload")]
130 UnknownUpload,
131 #[display("Duplicate upload")]
133 DuplicateUpload,
134}
135
136impl BadRequestReason {
137 fn message_key(self) -> &'static str {
139 match self {
140 Self::CourseSlugAlreadyTaken => "course_slug_already_taken",
141 Self::ForeignKeyViolation => "foreign_key_violation",
142 Self::NotEnrolled => "not_enrolled",
143 Self::UploadExpired => "upload_expired",
144 Self::UnknownUpload => "unknown_upload",
145 Self::DuplicateUpload => "duplicate_upload",
146 }
147 }
148
149 fn from_database_constraint(constraint: &str) -> Option<Self> {
151 match constraint {
152 "courses_slug_key_when_not_deleted"
153 | "course_language_groups_slug_unique_non_deleted" => {
154 Some(Self::CourseSlugAlreadyTaken)
155 }
156 _ => None,
157 }
158 }
159}
160
161#[derive(Debug, Display, Serialize, Deserialize, Clone, Copy, PartialEq, Eq)]
162#[serde(rename_all = "snake_case")]
163pub enum SisuErrorType {
164 #[display("invalid course code")]
165 InvalidCourseCode,
166 #[display("generic sisu error")]
167 GenericSisuError,
168 #[display("sisu resource not found")]
169 SisuResourceNotFound,
170}
171
172impl SisuErrorType {
173 fn message_key(self) -> &'static str {
175 match self {
176 Self::InvalidCourseCode => "invalid_course_code",
177 Self::GenericSisuError => "generic_sisu_error",
178 Self::SisuResourceNotFound => "sisu_resource_not_found",
179 }
180 }
181}
182
183pub struct ControllerError {
247 error_type: <ControllerError as BackendError>::ErrorType,
248 message: String,
249 source: Option<anyhow::Error>,
251 span_trace: Box<SpanTrace>,
253 backtrace: Box<Backtrace>,
255 location: Option<&'static Location<'static>>,
257}
258
259headless_lms_base::impl_clean_debug!(
261 ControllerError,
262 [
263 ControllerError,
264 AuthorizationError,
265 ChatbotError,
266 CreditRegistrationError,
267 ModelError,
268 UtilError
269 ]
270);
271
272impl std::error::Error for ControllerError {
273 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
274 self.source
275 .as_deref()
276 .map(|e| e as &(dyn std::error::Error + 'static))
277 }
278
279 fn cause(&self) -> Option<&dyn std::error::Error> {
280 self.source()
281 }
282}
283
284impl std::fmt::Display for ControllerError {
285 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
286 write!(
287 f,
288 "ControllerError {:?} {:?}",
289 self.error_type, self.message
290 )
291 }
292}
293
294impl BackendError for ControllerError {
295 type ErrorType = ControllerErrorType;
296
297 fn backtrace(&self) -> Option<&Backtrace> {
298 Some(&self.backtrace)
299 }
300
301 fn error_type(&self) -> &Self::ErrorType {
302 &self.error_type
303 }
304
305 fn message(&self) -> &str {
306 &self.message
307 }
308
309 fn span_trace(&self) -> &SpanTrace {
310 &self.span_trace
311 }
312
313 fn location(&self) -> Option<&'static Location<'static>> {
314 self.location
315 }
316
317 fn new_with_traces_and_location<M: Into<String>, S: Into<Option<anyhow::Error>>>(
318 error_type: Self::ErrorType,
319 message: M,
320 source_error: S,
321 backtrace: Backtrace,
322 span_trace: SpanTrace,
323 location: Option<&'static Location<'static>>,
324 ) -> Self {
325 Self {
326 error_type,
327 message: message.into(),
328 source: source_error.into(),
329 span_trace: Box::new(span_trace),
330 backtrace: Box::new(backtrace),
331 location,
332 }
333 }
334}
335
336#[derive(Debug, Serialize, Deserialize, Clone)]
337#[serde(rename_all = "snake_case")]
338pub enum ErrorMetadata {
339 BlockId(Uuid),
340 ForeignKeyViolationMetadata {
341 constraint: Option<String>,
342 table: Option<String>,
343 },
344}
345
346#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
347pub struct ApiErrorIssue {
348 pub path: Option<String>,
349 pub code: Option<String>,
350 pub message: String,
351}
352
353#[derive(Debug, Serialize, Deserialize, Clone, Copy)]
354#[serde(rename_all = "snake_case")]
355pub enum ValidationIssueCode {
356 MissingExerciseType,
357}
358
359impl ValidationIssueCode {
360 fn as_api_code(self) -> String {
362 serde_json::to_value(self)
363 .ok()
364 .and_then(|v| v.as_str().map(|code| code.to_string()))
365 .unwrap_or_else(|| "unknown_validation_issue".to_string())
366 }
367}
368
369#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
371pub struct ApiErrorResponse {
372 #[serde(rename = "type")]
373 pub error_type: String,
374 pub message_key: String,
375 pub message: String,
376 #[serde(default, skip_serializing_if = "Vec::is_empty")]
377 pub errors: Vec<ApiErrorIssue>,
378 #[serde(skip_serializing_if = "Option::is_none")]
379 pub metadata: Option<serde_json::Value>,
380}
381
382impl error::ResponseError for ControllerError {
383 fn error_response(&self) -> HttpResponse {
384 if let ControllerErrorType::InternalServerError = &self.error_type {
385 error!(
387 "Internal server error:\n{}",
388 self.clean_string(ColorChoice::Auto)
389 );
390
391 if let Some(pool) = crate::domain::internal_error_reporting::error_reporting_pool() {
392 let pool = pool.clone();
393 let message = self.message.clone();
394 let stack_trace = format!("{:?}", self);
395 let details = serde_json::json!({
396 "kind": "controller_error",
397 "controller_error_type": self.error_type.to_string(),
398 });
399
400 actix_web::rt::spawn(async move {
402 let report = headless_lms_models::errors::NewErrorReport {
403 service: "headless-lms".to_string(),
404 error_source: Some(headless_lms_models::errors::ErrorSource::Backend),
405 message,
406 stack_trace: Some(stack_trace),
407 path: None,
408 app_version: None,
409 details: Some(details),
410 };
411 headless_lms_models::errors::insert_best_effort(&pool, &report).await;
412 });
413 }
414 }
415 if let ControllerErrorType::OAuthError(data) = &self.error_type {
416 if let Some(uri) = &data.redirect_uri
417 && let Ok(mut url) = url::Url::parse(uri)
418 {
419 {
420 let mut qp = url.query_pairs_mut();
421 qp.append_pair("error", &data.error);
422 qp.append_pair("error_description", &data.error_description);
423 if let Some(state) = &data.state {
424 qp.append_pair("state", state);
425 }
426 }
427 let loc = url.to_string();
428 return HttpResponse::Found()
429 .append_header(("Location", loc))
430 .finish();
431 }
432
433 let status = match data.error.as_str() {
434 "invalid_client" => StatusCode::UNAUTHORIZED, "invalid_token" => StatusCode::UNAUTHORIZED, "invalid_dpop_proof" => StatusCode::UNAUTHORIZED, "use_dpop_nonce" => StatusCode::UNAUTHORIZED, "insufficient_scope" => StatusCode::FORBIDDEN, _ => StatusCode::BAD_REQUEST,
440 };
441
442 let mut res = HttpResponse::build(status);
443 fn escape_auth_param(s: &str) -> String {
445 s.replace('\\', "\\\\").replace('"', "\\\"")
446 }
447
448 match data.error.as_str() {
449 "invalid_client" | "invalid_token" | "insufficient_scope" | "invalid_request" => {
451 let err = escape_auth_param(&data.error);
452 let desc = escape_auth_param(&data.error_description);
453 let hdr = format!(r#"Bearer error="{}", error_description="{}""#, err, desc);
454 res.append_header(("WWW-Authenticate", hdr));
455 }
456
457 "invalid_dpop_proof" => {
459 let err = escape_auth_param(&data.error);
460 let desc = escape_auth_param(&data.error_description);
461 let hdr = format!(r#"DPoP error="{}", error_description="{}""#, err, desc);
462 res.append_header(("WWW-Authenticate", hdr));
463 }
464
465 "use_dpop_nonce" => {
466 let err = escape_auth_param(&data.error);
467 let desc = escape_auth_param(&data.error_description);
468 let hdr = format!(r#"DPoP error="{}", error_description="{}""#, err, desc);
469 res.append_header(("WWW-Authenticate", hdr));
470
471 if let Some(nonce) = &data.nonce {
473 res.append_header(("DPoP-Nonce", nonce.clone()));
474 }
475 }
476
477 _ => {}
478 }
479
480 res.append_header(("Cache-Control", "no-store"))
482 .append_header(("Pragma", "no-cache"));
483
484 return res.json(serde_json::json!({
487 "error": data.error,
488 "error_description": data.error_description
489 }));
490 }
491
492 let status = self.status_code();
493
494 let metadata = match &self.error_type {
495 ControllerErrorType::BadRequestWithData(data) => Some(data.clone()),
496 _ => None,
497 };
498
499 let metadata_json = metadata.map(|metadata| match metadata {
500 ErrorMetadata::BlockId(id) => serde_json::json!({ "block_id": id }),
501 ErrorMetadata::ForeignKeyViolationMetadata { constraint, table } => {
502 serde_json::json!({
503 "constraint": constraint,
504 "table": table })
505 }
506 });
507
508 let (error_type, message_key) = self.error_type_and_message_key();
509 let errors = self.validation_issues();
510 let error_response = ApiErrorResponse {
511 error_type: error_type.to_string(),
512 message_key: message_key.to_string(),
513 message: self.message.clone(),
514 errors,
515 metadata: metadata_json,
516 };
517
518 HttpResponseBuilder::new(status)
519 .append_header(ContentType::json())
520 .body(serde_json::to_string(&error_response).unwrap_or_else(|e| {
521 error!("Error while serialising error response: {e}");
522 r#"{"type":"internal_error","message_key":"internal_error","message":"Internal server error"}"#.to_string()
523 }))
524 }
525
526 fn status_code(&self) -> StatusCode {
527 match self.error_type {
528 ControllerErrorType::InternalServerError => StatusCode::INTERNAL_SERVER_ERROR,
529 ControllerErrorType::BadRequest => StatusCode::UNPROCESSABLE_ENTITY,
530 ControllerErrorType::BadRequestWithData(_) => StatusCode::UNPROCESSABLE_ENTITY,
531 ControllerErrorType::BadRequestWithReason(_) => StatusCode::UNPROCESSABLE_ENTITY,
532 ControllerErrorType::UpgradeRequired => StatusCode::UPGRADE_REQUIRED,
533 ControllerErrorType::NotFound => StatusCode::NOT_FOUND,
534 ControllerErrorType::Unauthorized => StatusCode::UNAUTHORIZED,
535 ControllerErrorType::UnauthorizedWithReason(_) => StatusCode::UNAUTHORIZED,
536 ControllerErrorType::Forbidden => StatusCode::FORBIDDEN,
537 ControllerErrorType::OAuthError(_) => StatusCode::OK,
538 ControllerErrorType::SisuError(SisuErrorType::InvalidCourseCode) => {
539 StatusCode::BAD_REQUEST
540 }
541 ControllerErrorType::SisuError(SisuErrorType::GenericSisuError) => {
542 StatusCode::BAD_GATEWAY
543 }
544 ControllerErrorType::SisuError(SisuErrorType::SisuResourceNotFound) => {
545 StatusCode::NOT_FOUND
546 }
547 }
548 }
549}
550
551impl ControllerError {
552 fn error_type_and_message_key(&self) -> (&'static str, &'static str) {
553 match self.error_type {
554 ControllerErrorType::InternalServerError => ("internal_error", "internal_error"),
555 ControllerErrorType::BadRequest => ("validation_error", "validation_error"),
556 ControllerErrorType::BadRequestWithData(ErrorMetadata::BlockId(_)) => {
557 ("validation_error", "validation_error_with_metadata")
558 }
559 ControllerErrorType::BadRequestWithData(
560 ErrorMetadata::ForeignKeyViolationMetadata { .. },
561 ) => ("validation_error", "foreign_key_violation"),
562 ControllerErrorType::BadRequestWithReason(reason) => {
563 ("validation_error", reason.message_key())
564 }
565 ControllerErrorType::UpgradeRequired => ("obsolete_client", "obsolete_client"),
566 ControllerErrorType::NotFound => ("not_found", "not_found"),
567 ControllerErrorType::Unauthorized => ("unauthorized", "unauthorized"),
568 ControllerErrorType::UnauthorizedWithReason(reason) => {
569 ("unauthorized", reason.message_key())
570 }
571 ControllerErrorType::Forbidden => ("forbidden", "forbidden"),
572 ControllerErrorType::OAuthError(_) => ("oauth_error", "oauth_error"),
573 ControllerErrorType::SisuError(error_type) => ("sisu_error", error_type.message_key()),
574 }
575 }
576
577 fn validation_issues(&self) -> Vec<ApiErrorIssue> {
579 match &self.error_type {
580 ControllerErrorType::BadRequestWithData(_)
581 if self.message == MISSING_EXERCISE_TYPE_DESCRIPTION =>
582 {
583 vec![ApiErrorIssue {
584 path: Some("exercise_type".to_string()),
585 code: Some(ValidationIssueCode::MissingExerciseType.as_api_code()),
586 message: self.message.clone(),
587 }]
588 }
589 _ => Vec::new(),
590 }
591 }
592}
593
594#[derive(Debug, Serialize, Deserialize, Clone)]
595#[serde(rename_all = "snake_case")]
596pub struct OAuthErrorData {
597 pub error: String,
598 pub error_description: String,
599 pub redirect_uri: Option<String>,
600 pub state: Option<String>,
601 pub nonce: Option<String>,
602}
603
604pub enum OAuthErrorCode {
605 InvalidGrant,
606 InvalidRequest,
607 InvalidClient,
608 InvalidToken,
609 InsufficientScope,
610 InvalidScope,
611 UnauthorizedClient,
612 UnsupportedGrantType,
613 UnsupportedResponseType,
614 ServerError,
615 InvalidDpopProof,
616 UseDpopNonce,
617 AuthorizationPending,
620 SlowDown,
621 ExpiredToken,
622 AccessDenied,
623}
624
625impl OAuthErrorCode {
626 pub fn as_str(&self) -> &'static str {
627 match self {
628 Self::InvalidGrant => "invalid_grant",
629 Self::InvalidRequest => "invalid_request",
630 Self::InvalidClient => "invalid_client",
631 Self::InvalidToken => "invalid_token",
632 Self::InsufficientScope => "insufficient_scope",
633 Self::InvalidScope => "invalid_scope",
634 Self::UnauthorizedClient => "unauthorized_client",
635 Self::UnsupportedGrantType => "unsupported_grant_type",
636 Self::UnsupportedResponseType => "unsupported_response_type",
637 Self::ServerError => "server_error",
638 Self::InvalidDpopProof => "invalid_dpop_proof",
639 Self::UseDpopNonce => "use_dpop_nonce",
640 Self::AuthorizationPending => "authorization_pending",
641 Self::SlowDown => "slow_down",
642 Self::ExpiredToken => "expired_token",
643 Self::AccessDenied => "access_denied",
644 }
645 }
646}
647
648impl From<anyhow::Error> for ControllerError {
649 fn from(err: anyhow::Error) -> ControllerError {
650 if let Some(sqlx::Error::RowNotFound) = err.downcast_ref::<sqlx::Error>() {
651 return Self::new(ControllerErrorType::NotFound, err.to_string(), Some(err));
652 }
653
654 Self::new(
655 ControllerErrorType::InternalServerError,
656 err.to_string(),
657 Some(err),
658 )
659 }
660}
661
662impl From<uuid::Error> for ControllerError {
663 fn from(err: uuid::Error) -> ControllerError {
664 Self::new(
665 ControllerErrorType::BadRequest,
666 err.to_string(),
667 Some(err.into()),
668 )
669 }
670}
671
672impl From<sqlx::Error> for ControllerError {
673 fn from(err: sqlx::Error) -> ControllerError {
674 Self::new(
675 ControllerErrorType::InternalServerError,
676 err.to_string(),
677 Some(err.into()),
678 )
679 }
680}
681
682impl From<git2::Error> for ControllerError {
683 fn from(err: git2::Error) -> ControllerError {
684 Self::new(
685 ControllerErrorType::InternalServerError,
686 err.to_string(),
687 Some(err.into()),
688 )
689 }
690}
691
692impl From<actix_web::Error> for ControllerError {
693 fn from(err: actix_web::Error) -> Self {
694 Self::new(
695 ControllerErrorType::InternalServerError,
696 err.to_string(),
697 None,
698 )
699 }
700}
701
702impl From<actix_multipart::MultipartError> for ControllerError {
703 fn from(err: actix_multipart::MultipartError) -> Self {
704 Self::new(
705 ControllerErrorType::InternalServerError,
706 err.to_string(),
707 None,
708 )
709 }
710}
711
712impl From<jsonwebtoken::errors::Error> for ControllerError {
713 fn from(err: jsonwebtoken::errors::Error) -> Self {
714 Self::new(
715 ControllerErrorType::InternalServerError,
716 err.to_string(),
717 None,
718 )
719 }
720}
721
722impl From<ModelError> for ControllerError {
723 fn from(err: ModelError) -> Self {
724 let backtrace: Backtrace =
725 match headless_lms_base::error::backend_error::BackendError::backtrace(&err) {
726 Some(backtrace) => backtrace.clone(),
727 _ => Backtrace::new(),
728 };
729 let span_trace = err.span_trace().clone();
730 match err.error_type() {
731 ModelErrorType::RecordNotFound => Self::new_with_traces(
732 ControllerErrorType::NotFound,
733 err.to_string(),
734 Some(err.into()),
735 backtrace,
736 span_trace,
737 ),
738 ModelErrorType::NotFound => Self::new_with_traces(
739 ControllerErrorType::NotFound,
740 err.to_string(),
741 Some(err.into()),
742 backtrace,
743 span_trace,
744 ),
745 ModelErrorType::PreconditionFailed => Self::new_with_traces(
746 ControllerErrorType::BadRequest,
747 err.message().to_string(),
748 Some(err.into()),
749 backtrace,
750 span_trace,
751 ),
752 ModelErrorType::PreconditionFailedWithCMSAnchorBlockId { description, id } => {
753 Self::new_with_traces(
754 ControllerErrorType::BadRequestWithData(ErrorMetadata::BlockId(*id)),
755 description.to_string(),
756 Some(err.into()),
757 backtrace,
758 span_trace,
759 )
760 }
761 ModelErrorType::DatabaseConstraint {
762 constraint,
763 description,
764 } => Self::new_with_traces(
765 BadRequestReason::from_database_constraint(constraint)
766 .map_or(ControllerErrorType::BadRequest, |reason| {
767 ControllerErrorType::BadRequestWithReason(reason)
768 }),
769 description.to_string(),
770 Some(err.into()),
771 backtrace,
772 span_trace,
773 ),
774 ModelErrorType::InvalidRequest => Self::new_with_traces(
775 ControllerErrorType::BadRequest,
776 err.message().to_string(),
777 Some(err.into()),
778 backtrace,
779 span_trace,
780 ),
781 ModelErrorType::ForeignKeyViolation { constraint, table } => Self::new_with_traces(
782 ControllerErrorType::BadRequestWithData(
783 ErrorMetadata::ForeignKeyViolationMetadata {
784 constraint: constraint.to_owned(),
785 table: table.to_owned(),
786 },
787 ),
788 err.message().to_string(),
789 Some(err.into()),
790 backtrace,
791 span_trace,
792 ),
793 _ => Self::new_with_traces(
794 ControllerErrorType::InternalServerError,
795 err.to_string(),
796 Some(err.into()),
797 backtrace,
798 span_trace,
799 ),
800 }
801 }
802}
803
804impl From<AuthorizationError> for ControllerError {
805 fn from(err: AuthorizationError) -> Self {
806 let err = match err.into_model_error() {
809 Ok(model_error) => return model_error.into(),
810 Err(err) => err,
811 };
812
813 let backtrace: Backtrace = match BackendError::backtrace(&err) {
814 Some(backtrace) => backtrace.clone(),
815 _ => Backtrace::new(),
816 };
817 let span_trace = err.span_trace().clone();
818 let error_type = match err.error_type() {
819 AuthorizationErrorType::Unauthorized => ControllerErrorType::Unauthorized,
820 AuthorizationErrorType::Forbidden => ControllerErrorType::Forbidden,
821 AuthorizationErrorType::InternalServerError | AuthorizationErrorType::Model => {
822 ControllerErrorType::InternalServerError
823 }
824 };
825 let message = err.message().to_string();
828
829 Self::new_with_traces(error_type, message, Some(err.into()), backtrace, span_trace)
830 }
831}
832
833impl From<UtilError> for ControllerError {
834 fn from(err: UtilError) -> Self {
835 let backtrace: Backtrace =
836 match headless_lms_base::error::backend_error::BackendError::backtrace(&err) {
837 Some(backtrace) => backtrace.clone(),
838 _ => Backtrace::new(),
839 };
840 let span_trace = err.span_trace().clone();
841
842 match err.error_type() {
843 UtilErrorType::SisuClientError(SisuErrorVariant::GenericSisuError) => {
844 Self::new_with_traces(
845 ControllerErrorType::SisuError(SisuErrorType::GenericSisuError),
846 err.to_string(),
847 Some(err.into()),
848 backtrace,
849 span_trace,
850 )
851 }
852 UtilErrorType::SisuClientError(SisuErrorVariant::InvalidCourseCode) => {
853 Self::new_with_traces(
854 ControllerErrorType::SisuError(SisuErrorType::InvalidCourseCode),
855 err.to_string(),
856 Some(err.into()),
857 backtrace,
858 span_trace,
859 )
860 }
861 UtilErrorType::SisuClientError(SisuErrorVariant::SisuResourceNotFound) => {
862 Self::new_with_traces(
863 ControllerErrorType::SisuError(SisuErrorType::SisuResourceNotFound),
864 err.to_string(),
865 Some(err.into()),
866 backtrace,
867 span_trace,
868 )
869 }
870 _ => Self::new_with_traces(
871 ControllerErrorType::InternalServerError,
872 err.to_string(),
873 Some(err.into()),
874 backtrace,
875 span_trace,
876 ),
877 }
878 }
879}
880
881impl From<serde_json::Error> for ControllerError {
882 fn from(err: serde_json::Error) -> Self {
883 Self::new(
884 ControllerErrorType::InternalServerError,
885 err.to_string(),
886 Some(err.into()),
887 )
888 }
889}
890
891impl From<base64::DecodeError> for ControllerError {
892 fn from(err: base64::DecodeError) -> Self {
893 Self::new(
894 ControllerErrorType::InternalServerError,
895 err.to_string(),
896 Some(err.into()),
897 )
898 }
899}
900
901impl From<std::string::FromUtf8Error> for ControllerError {
902 fn from(err: std::string::FromUtf8Error) -> Self {
903 Self::new(
904 ControllerErrorType::InternalServerError,
905 err.to_string(),
906 Some(err.into()),
907 )
908 }
909}
910
911impl From<pkcs8::spki::Error> for ControllerError {
912 fn from(err: pkcs8::spki::Error) -> Self {
913 Self::new(
914 ControllerErrorType::InternalServerError,
915 err.to_string(),
916 Some(err.into()),
917 )
918 }
919}
920
921impl From<dpop_verifier::error::DpopError> for ControllerError {
922 fn from(err: DpopError) -> Self {
923 let oauth_error = match &err {
924 DpopError::MultipleDpopHeaders
925 | DpopError::InvalidDpopHeader
926 | DpopError::MissingDpopHeader
927 | DpopError::MalformedJws
928 | DpopError::InvalidAlg(_)
929 | DpopError::UnsupportedAlg(_)
930 | DpopError::InvalidSignature
931 | DpopError::BadJwk(_)
932 | DpopError::MissingClaim(_)
933 | DpopError::InvalidMethod
934 | DpopError::HtmMismatch
935 | DpopError::MalformedHtu
936 | DpopError::HtuMismatch
937 | DpopError::AthMalformed
938 | DpopError::MissingAth
939 | DpopError::AthMismatch
940 | DpopError::FutureSkew
941 | DpopError::Stale
942 | DpopError::Replay
943 | DpopError::JtiTooLong
944 | DpopError::NonceMismatch
945 | DpopError::NonceStale
946 | DpopError::InvalidHmacConfig
947 | DpopError::MissingNonce => OAuthErrorData {
948 error: OAuthErrorCode::InvalidDpopProof.as_str().into(),
949 error_description: err.to_string(),
950 redirect_uri: None,
951 state: None,
952 nonce: None,
953 },
954
955 DpopError::Store(e) => OAuthErrorData {
956 error: OAuthErrorCode::ServerError.as_str().into(),
957 error_description: format!("DPoP storage error: {e}"),
958 redirect_uri: None,
959 state: None,
960 nonce: None,
961 },
962
963 DpopError::UseDpopNonce { nonce } => OAuthErrorData {
964 error: OAuthErrorCode::UseDpopNonce.as_str().into(), error_description: "Server requires DPoP nonce".into(),
966 redirect_uri: None,
967 state: None,
968 nonce: Some(nonce.clone()),
969 },
970 };
971
972 ControllerError::new(
973 ControllerErrorType::OAuthError(Box::new(oauth_error)),
974 err.to_string(),
975 Some(err.into()),
976 )
977 }
978}
979
980#[derive(Debug, thiserror::Error)]
981pub enum PkceFlowError {
982 #[error("{0}")]
984 InvalidRequest(&'static str),
985
986 #[error("{0}")]
988 InvalidGrant(&'static str),
989
990 #[error("{0}")]
992 ServerError(&'static str),
993}
994
995impl From<PkceFlowError> for ControllerError {
996 fn from(err: PkceFlowError) -> Self {
997 let data = match &err {
998 PkceFlowError::InvalidRequest(msg) => OAuthErrorData {
999 error: OAuthErrorCode::InvalidRequest.as_str().into(),
1000 error_description: (*msg).into(),
1001 redirect_uri: None,
1002 state: None,
1003 nonce: None,
1004 },
1005 PkceFlowError::InvalidGrant(msg) => OAuthErrorData {
1006 error: OAuthErrorCode::InvalidGrant.as_str().into(),
1007 error_description: (*msg).into(),
1008 redirect_uri: None,
1009 state: None,
1010 nonce: None,
1011 },
1012 PkceFlowError::ServerError(msg) => OAuthErrorData {
1013 error: OAuthErrorCode::ServerError.as_str().into(),
1014 error_description: (*msg).into(),
1015 redirect_uri: None,
1016 state: None,
1017 nonce: None,
1018 },
1019 };
1020
1021 ControllerError::new(
1022 ControllerErrorType::OAuthError(Box::new(data)),
1023 err.to_string(),
1024 Some(anyhow::anyhow!(err)),
1025 )
1026 }
1027}
1028
1029impl From<crate::domain::oauth::pkce::PkceError> for PkceFlowError {
1030 fn from(_err: crate::domain::oauth::pkce::PkceError) -> Self {
1031 PkceFlowError::InvalidRequest("invalid code_verifier")
1033 }
1034}
1035
1036impl From<crate::domain::oauth::pkce::PkceError> for ControllerError {
1037 fn from(err: crate::domain::oauth::pkce::PkceError) -> Self {
1038 PkceFlowError::from(err).into()
1039 }
1040}
1041
1042impl From<ChatbotError> for ControllerError {
1043 fn from(err: ChatbotError) -> Self {
1044 let err = match err.into_model_error() {
1047 Ok(model_error) => return model_error.into(),
1048 Err(err) => err,
1049 };
1050
1051 let backtrace: Backtrace = match BackendError::backtrace(&err) {
1052 Some(backtrace) => backtrace.clone(),
1053 _ => Backtrace::new(),
1054 };
1055 let span_trace = err.span_trace().clone();
1056 let error_type = match err.error_type() {
1057 ChatbotErrorType::InvalidToolAnswer => ControllerErrorType::BadRequest,
1059 ChatbotErrorType::InvalidMessageShape
1060 | ChatbotErrorType::InvalidToolName
1061 | ChatbotErrorType::InvalidToolArguments
1062 | ChatbotErrorType::ToolUseError
1063 | ChatbotErrorType::ChatbotModelError
1064 | ChatbotErrorType::ChatbotMessageSuggestError
1065 | ChatbotErrorType::UrlParse
1066 | ChatbotErrorType::TokioIo
1067 | ChatbotErrorType::SerdeJson
1068 | ChatbotErrorType::SqlxError
1069 | ChatbotErrorType::ReqwestError
1070 | ChatbotErrorType::Other
1071 | ChatbotErrorType::DeserializationError
1072 | ChatbotErrorType::AzureAISearchFilterError
1073 | ChatbotErrorType::UpstreamReportedError
1074 | ChatbotErrorType::ResponseIncomplete
1075 | ChatbotErrorType::StreamEndedEarly
1076 | ChatbotErrorType::UnexpectedProtocolShape
1077 | ChatbotErrorType::StreamInvariantViolation
1078 | ChatbotErrorType::ContentCleaning
1079 | ChatbotErrorType::AzureRequestBuildError
1080 | ChatbotErrorType::FailedAzureResponse
1081 | ChatbotErrorType::SisuDescriptionError
1082 | ChatbotErrorType::ChatbotUtilError => ControllerErrorType::InternalServerError,
1083 };
1084 let message = err.message().to_string();
1085
1086 Self::new_with_traces(error_type, message, Some(err.into()), backtrace, span_trace)
1087 }
1088}
1089
1090impl From<CreditRegistrationError> for ControllerError {
1091 fn from(err: CreditRegistrationError) -> Self {
1092 let err = match err.into_model_error() {
1095 Ok(model_error) => return model_error.into(),
1096 Err(err) => err,
1097 };
1098
1099 let backtrace: Backtrace = match BackendError::backtrace(&err) {
1100 Some(backtrace) => backtrace.clone(),
1101 _ => Backtrace::new(),
1102 };
1103 let span_trace = err.span_trace().clone();
1104 let error_type = match err.error_type() {
1105 CreditRegistrationErrorType::Model | CreditRegistrationErrorType::Database => {
1106 ControllerErrorType::InternalServerError
1107 }
1108 };
1109 let message = err.message().to_string();
1110
1111 Self::new_with_traces(error_type, message, Some(err.into()), backtrace, span_trace)
1112 }
1113}
1114
1115headless_lms_utils::define_err_macro!(
1117 controller_err,
1118 ControllerError,
1119 ControllerErrorType,
1120 ControllerErrorType,
1121 "Create a ControllerError with less boilerplate."
1122);
1123
1124pub fn as_controller_error<E>(
1139 error_type: ControllerErrorType,
1140 message: impl Into<String>,
1141) -> impl FnOnce(E) -> ControllerError
1142where
1143 E: Into<anyhow::Error>,
1144{
1145 let msg = message.into();
1146 move |e| ControllerError::new(error_type, msg, Some(e.into()))
1147}
1148
1149pub fn missing_controller_error(
1164 error_type: ControllerErrorType,
1165 message: impl Into<String>,
1166) -> impl FnOnce() -> ControllerError {
1167 let msg = message.into();
1168 move || ControllerError::new(error_type, msg, None)
1169}
1170
1171#[cfg(test)]
1172mod tests {
1173 use super::*;
1174 use actix_web::ResponseError;
1175 use futures_util::FutureExt;
1176
1177 #[test]
1178 fn test_controller_err_macro_without_source() {
1179 let err = controller_err!(BadRequest, "Test error message".to_string());
1180 assert_eq!(err.message(), "Test error message");
1181 assert!(matches!(err.error_type(), ControllerErrorType::BadRequest));
1182 }
1183
1184 #[test]
1185 fn test_controller_err_macro_with_source() {
1186 let source_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
1187 let err = controller_err!(InternalServerError, "Wrapped error".to_string(), source_err);
1188 assert_eq!(err.message(), "Wrapped error");
1189 }
1190
1191 #[test]
1192 fn test_controller_err_macro_tuple_variant_with_source() {
1193 let source_err = std::io::Error::other("source");
1194 let err = controller_err!(
1195 UnauthorizedWithReason(UnauthorizedReason::ChapterNotOpenYet),
1196 "Wrapped error".to_string(),
1197 source_err
1198 );
1199 assert!(matches!(
1200 err.error_type(),
1201 ControllerErrorType::UnauthorizedWithReason(_)
1202 ));
1203 }
1204
1205 #[test]
1206 fn test_as_controller_error_helper() {
1207 let result: Result<(), std::io::Error> = Err(std::io::Error::new(
1208 std::io::ErrorKind::NotFound,
1209 "test error",
1210 ));
1211 let controller_result = result.map_err(as_controller_error(
1212 ControllerErrorType::BadRequest,
1213 "Invalid input".to_string(),
1214 ));
1215
1216 assert!(controller_result.is_err());
1217 let err = controller_result.unwrap_err();
1218 assert_eq!(err.message(), "Invalid input");
1219 assert!(matches!(err.error_type(), ControllerErrorType::BadRequest));
1220 }
1221
1222 #[test]
1223 fn test_missing_controller_error_helper() {
1224 let option: Option<String> = None;
1225 let result = option.ok_or_else(missing_controller_error(
1226 ControllerErrorType::NotFound,
1227 "Resource not found".to_string(),
1228 ));
1229
1230 assert!(result.is_err());
1231 let err = result.unwrap_err();
1232 assert_eq!(err.message(), "Resource not found");
1233 assert!(matches!(err.error_type(), ControllerErrorType::NotFound));
1234 }
1235
1236 #[test]
1237 fn test_controller_err_with_format() {
1238 let user_id = 42;
1239 let err = controller_err!(Unauthorized, format!("User {} is not authorized", user_id));
1240 assert_eq!(err.message(), "User 42 is not authorized");
1241 }
1242
1243 #[test]
1244 fn test_controller_err_all_variants() {
1245 let _ = controller_err!(InternalServerError, "test".to_string());
1247 let _ = controller_err!(BadRequest, "test".to_string());
1248 let _ = controller_err!(NotFound, "test".to_string());
1249 let _ = controller_err!(Unauthorized, "test".to_string());
1250 let _ = controller_err!(
1251 UnauthorizedWithReason(UnauthorizedReason::ChapterNotOpenYet),
1252 "test".to_string()
1253 );
1254 let _ = controller_err!(
1255 BadRequestWithData(ErrorMetadata::BlockId(Uuid::nil())),
1256 "test".to_string()
1257 );
1258 let _ = controller_err!(Forbidden, "test".to_string());
1259 }
1260
1261 #[test]
1262 fn test_canonical_error_envelope_shape() {
1263 let err = controller_err!(BadRequest, "Validation failed".to_string());
1264 let response = err.error_response();
1265 assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
1266
1267 let bytes = actix_web::body::to_bytes(response.into_body())
1268 .now_or_never()
1269 .expect("response should resolve immediately")
1270 .expect("body bytes");
1271 let value: serde_json::Value = serde_json::from_slice(&bytes).expect("json");
1272 assert_eq!(value["type"], "validation_error");
1273 assert_eq!(value["message_key"], "validation_error");
1274 assert_eq!(value["message"], "Validation failed");
1275 assert!(value.get("status").is_none());
1276 assert!(value.get("request_id").is_none());
1277 }
1278
1279 #[test]
1280 fn test_error_envelope_schema_requires_type_message_key_and_message() {
1281 let schema = serde_json::to_value(<ApiErrorResponse as utoipa::PartialSchema>::schema())
1282 .expect("schema json");
1283 let mut required: Vec<&str> = schema["required"]
1284 .as_array()
1285 .expect("required list")
1286 .iter()
1287 .map(|v| v.as_str().expect("field name"))
1288 .collect();
1289 required.sort_unstable();
1290 assert_eq!(required, ["message", "message_key", "type"]);
1291 }
1292
1293 #[test]
1294 fn test_validation_issue_code_is_serialized_for_missing_exercise_type() {
1295 let err = ControllerError::new(
1296 ControllerErrorType::BadRequestWithData(ErrorMetadata::BlockId(Uuid::nil())),
1297 MISSING_EXERCISE_TYPE_DESCRIPTION.to_string(),
1298 None,
1299 );
1300 let response = err.error_response();
1301 let bytes = actix_web::body::to_bytes(response.into_body())
1302 .now_or_never()
1303 .expect("response should resolve immediately")
1304 .expect("body bytes");
1305 let value: serde_json::Value = serde_json::from_slice(&bytes).expect("json");
1306
1307 assert_eq!(value["type"], "validation_error");
1308 assert_eq!(value["message_key"], "validation_error_with_metadata");
1309 assert_eq!(value["errors"][0]["code"], "missing_exercise_type");
1310 assert_eq!(value["errors"][0]["path"], "exercise_type");
1311 }
1312
1313 #[test]
1314 fn test_chapter_not_open_uses_dedicated_message_key() {
1315 let err = ControllerError::new(
1316 ControllerErrorType::UnauthorizedWithReason(UnauthorizedReason::ChapterNotOpenYet),
1317 "Chapter is not open yet.".to_string(),
1318 None,
1319 );
1320 let response = err.error_response();
1321 let bytes = actix_web::body::to_bytes(response.into_body())
1322 .now_or_never()
1323 .expect("response should resolve immediately")
1324 .expect("body bytes");
1325 let value: serde_json::Value = serde_json::from_slice(&bytes).expect("json");
1326
1327 assert_eq!(value["type"], "unauthorized");
1328 assert_eq!(value["message_key"], "chapter_not_open_yet");
1329 assert_eq!(value["message"], "Chapter is not open yet.");
1330 }
1331
1332 #[test]
1333 fn test_exam_exercise_auth_requirement_uses_dedicated_message_key() {
1334 let err = ControllerError::new(
1335 ControllerErrorType::UnauthorizedWithReason(
1336 UnauthorizedReason::AuthenticationRequiredForExamExercise,
1337 ),
1338 "User must be authenticated to view exam exercises".to_string(),
1339 None,
1340 );
1341 let response = err.error_response();
1342 let bytes = actix_web::body::to_bytes(response.into_body())
1343 .now_or_never()
1344 .expect("response should resolve immediately")
1345 .expect("body bytes");
1346 let value: serde_json::Value = serde_json::from_slice(&bytes).expect("json");
1347
1348 assert_eq!(value["type"], "unauthorized");
1349 assert_eq!(
1350 value["message_key"],
1351 "authentication_required_for_exam_exercise"
1352 );
1353 assert_eq!(
1354 value["message"],
1355 "User must be authenticated to view exam exercises"
1356 );
1357 }
1358
1359 #[test]
1360 fn test_not_enrolled_uses_dedicated_message_key_and_422() {
1361 let err = ControllerError::new(
1362 ControllerErrorType::BadRequestWithReason(BadRequestReason::NotEnrolled),
1363 "User is not enrolled to this exercise's course".to_string(),
1364 None,
1365 );
1366 let response = err.error_response();
1367 assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
1368 let bytes = actix_web::body::to_bytes(response.into_body())
1369 .now_or_never()
1370 .expect("response should resolve immediately")
1371 .expect("body bytes");
1372 let value: serde_json::Value = serde_json::from_slice(&bytes).expect("json");
1373
1374 assert_eq!(value["type"], "validation_error");
1375 assert_eq!(value["message_key"], "not_enrolled");
1376 }
1377
1378 #[test]
1379 fn test_upgrade_required_uses_obsolete_client_key_and_426() {
1380 let err = ControllerError::new(
1381 ControllerErrorType::UpgradeRequired,
1382 "Client is too old".to_string(),
1383 None,
1384 );
1385 let response = err.error_response();
1386 assert_eq!(response.status(), StatusCode::UPGRADE_REQUIRED);
1387 let bytes = actix_web::body::to_bytes(response.into_body())
1388 .now_or_never()
1389 .expect("response should resolve immediately")
1390 .expect("body bytes");
1391 let value: serde_json::Value = serde_json::from_slice(&bytes).expect("json");
1392
1393 assert_eq!(value["type"], "obsolete_client");
1394 assert_eq!(value["message_key"], "obsolete_client");
1395 }
1396
1397 #[test]
1398 fn test_generic_unauthorized_uses_unauthorized_message_key() {
1399 let err = ControllerError::new(
1400 ControllerErrorType::Unauthorized,
1401 "Unauthorized".to_string(),
1402 None,
1403 );
1404 let response = err.error_response();
1405 let bytes = actix_web::body::to_bytes(response.into_body())
1406 .now_or_never()
1407 .expect("response should resolve immediately")
1408 .expect("body bytes");
1409 let value: serde_json::Value = serde_json::from_slice(&bytes).expect("json");
1410
1411 assert_eq!(value["type"], "unauthorized");
1412 assert_eq!(value["message_key"], "unauthorized");
1413 assert_eq!(value["message"], "Unauthorized");
1414 }
1415
1416 #[test]
1417 fn debug_renders_clean_format() {
1418 let err = controller_err!(InternalServerError, "database exploded".to_string());
1419 let debug = format!("{err:?}");
1420 assert!(
1421 debug.contains("ControllerError · InternalServerError: database exploded"),
1422 "got: {debug}"
1423 );
1424 assert!(!debug.contains("backend_error.rs"), "got: {debug}");
1426 assert!(!debug.contains("macros.rs"), "got: {debug}");
1427 }
1428
1429 #[test]
1430 fn debug_renders_wrapped_model_error_as_cause_node() {
1431 let model_error = ModelError::new(ModelErrorType::Generic, "row missing".to_string(), None);
1432 let err = ControllerError::from(model_error);
1433
1434 let debug = format!("{err:?}");
1435 assert!(debug.contains("ControllerError ·"), "got: {debug}");
1436 assert!(debug.contains("caused by:"), "got: {debug}");
1437 assert!(
1438 debug.contains("1. ModelError · Generic: row missing"),
1439 "got: {debug}"
1440 );
1441 assert!(!debug.contains("(external)"), "got: {debug}");
1442 }
1443}