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