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