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_base::error::{backend_error::BackendError, clean_format::ColorChoice};
16use headless_lms_chatbot::prelude::ChatbotError;
17use headless_lms_models::{ModelError, ModelErrorType, prelude::UtilErrorType};
18use headless_lms_utils::error::util_error::{SisuErrorVariant, UtilError};
19use serde::{Deserialize, Serialize};
20use tracing_error::SpanTrace;
21
22use uuid::Uuid;
23
24const MISSING_EXERCISE_TYPE_DESCRIPTION: &str = "Missing exercise type for exercise task.";
25
26pub type ControllerResult<T, E = ControllerError> = std::result::Result<AuthorizedResponse<T>, E>;
33
34#[derive(Debug, Display, Serialize, Deserialize)]
36pub enum ControllerErrorType {
37 #[display("Internal server error")]
39 InternalServerError,
40
41 #[display("Bad request")]
43 BadRequest,
44
45 #[display("Bad request")]
47 BadRequestWithData(ErrorMetadata),
48
49 #[display("Bad request")]
51 BadRequestWithReason(BadRequestReason),
52
53 #[display("Not found")]
55 NotFound,
56
57 #[display("Unauthorized")]
59 Unauthorized,
60
61 #[display("Unauthorized")]
63 UnauthorizedWithReason(UnauthorizedReason),
64
65 #[display("Forbidden")]
67 Forbidden,
68
69 #[display("OAuthError")]
71 OAuthError(Box<OAuthErrorData>),
72
73 #[display("SisuError")]
75 SisuError(SisuErrorType),
76}
77
78#[derive(Debug, Display, Serialize, Deserialize, Clone, Copy, PartialEq, Eq)]
79#[serde(rename_all = "snake_case")]
80pub enum UnauthorizedReason {
81 #[display("Chapter not open yet")]
82 ChapterNotOpenYet,
83 #[display("Authentication required for exam exercise")]
84 AuthenticationRequiredForExamExercise,
85}
86
87impl UnauthorizedReason {
88 fn message_key(self) -> &'static str {
90 match self {
91 Self::ChapterNotOpenYet => "chapter_not_open_yet",
92 Self::AuthenticationRequiredForExamExercise => {
93 "authentication_required_for_exam_exercise"
94 }
95 }
96 }
97}
98
99#[derive(Debug, Display, Serialize, Deserialize, Clone, Copy, PartialEq, Eq)]
101#[serde(rename_all = "snake_case")]
102pub enum BadRequestReason {
103 #[display("Course slug already taken")]
104 CourseSlugAlreadyTaken,
105}
106
107impl BadRequestReason {
108 fn message_key(self) -> &'static str {
110 match self {
111 Self::CourseSlugAlreadyTaken => "course_slug_already_taken",
112 }
113 }
114
115 fn from_database_constraint(constraint: &str) -> Option<Self> {
117 match constraint {
118 "courses_slug_key_when_not_deleted"
119 | "course_language_groups_slug_unique_non_deleted" => {
120 Some(Self::CourseSlugAlreadyTaken)
121 }
122 _ => None,
123 }
124 }
125}
126
127#[derive(Debug, Display, Serialize, Deserialize, Clone, Copy, PartialEq, Eq)]
128#[serde(rename_all = "snake_case")]
129pub enum SisuErrorType {
130 #[display("invalid course code")]
131 InvalidCourseCode,
132 #[display("generic sisu error")]
133 GenericSisuError,
134 #[display("sisu resource not found")]
135 SisuResourceNotFound,
136}
137
138impl SisuErrorType {
139 fn message_key(self) -> &'static str {
141 match self {
142 Self::InvalidCourseCode => "invalid_course_code",
143 Self::GenericSisuError => "generic_sisu_error",
144 Self::SisuResourceNotFound => "sisu_resource_not_found",
145 }
146 }
147}
148
149pub struct ControllerError {
213 error_type: <ControllerError as BackendError>::ErrorType,
214 message: String,
215 source: Option<anyhow::Error>,
217 span_trace: Box<SpanTrace>,
219 backtrace: Box<Backtrace>,
221 location: Option<&'static Location<'static>>,
223}
224
225headless_lms_base::impl_clean_debug!(
227 ControllerError,
228 [ControllerError, ChatbotError, ModelError, UtilError]
229);
230
231impl std::error::Error for ControllerError {
232 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
233 self.source
234 .as_deref()
235 .map(|e| e as &(dyn std::error::Error + 'static))
236 }
237
238 fn cause(&self) -> Option<&dyn std::error::Error> {
239 self.source()
240 }
241}
242
243impl std::fmt::Display for ControllerError {
244 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
245 write!(
246 f,
247 "ControllerError {:?} {:?}",
248 self.error_type, self.message
249 )
250 }
251}
252
253impl BackendError for ControllerError {
254 type ErrorType = ControllerErrorType;
255
256 fn backtrace(&self) -> Option<&Backtrace> {
257 Some(&self.backtrace)
258 }
259
260 fn error_type(&self) -> &Self::ErrorType {
261 &self.error_type
262 }
263
264 fn message(&self) -> &str {
265 &self.message
266 }
267
268 fn span_trace(&self) -> &SpanTrace {
269 &self.span_trace
270 }
271
272 fn location(&self) -> Option<&'static Location<'static>> {
273 self.location
274 }
275
276 fn new_with_traces_and_location<M: Into<String>, S: Into<Option<anyhow::Error>>>(
277 error_type: Self::ErrorType,
278 message: M,
279 source_error: S,
280 backtrace: Backtrace,
281 span_trace: SpanTrace,
282 location: Option<&'static Location<'static>>,
283 ) -> Self {
284 Self {
285 error_type,
286 message: message.into(),
287 source: source_error.into(),
288 span_trace: Box::new(span_trace),
289 backtrace: Box::new(backtrace),
290 location,
291 }
292 }
293}
294
295#[derive(Debug, Serialize, Deserialize, Clone)]
296#[serde(rename_all = "snake_case")]
297pub enum ErrorMetadata {
298 BlockId(Uuid),
299}
300
301#[derive(Debug, Serialize, Deserialize)]
302pub struct ApiErrorIssue {
303 pub path: Option<String>,
304 pub code: Option<String>,
305 pub message: String,
306}
307
308#[derive(Debug, Serialize, Deserialize, Clone, Copy)]
309#[serde(rename_all = "snake_case")]
310pub enum ValidationIssueCode {
311 MissingExerciseType,
312}
313
314impl ValidationIssueCode {
315 fn as_api_code(self) -> String {
317 serde_json::to_value(self)
318 .ok()
319 .and_then(|v| v.as_str().map(|code| code.to_string()))
320 .unwrap_or_else(|| "unknown_validation_issue".to_string())
321 }
322}
323
324#[derive(Debug, Serialize, Deserialize)]
326pub struct ApiErrorResponse {
327 #[serde(rename = "type")]
328 #[serde(skip_serializing_if = "Option::is_none")]
329 pub error_type: Option<String>,
330 #[serde(skip_serializing_if = "Option::is_none")]
331 pub message_key: Option<String>,
332 #[serde(skip_serializing_if = "Option::is_none")]
333 pub message: Option<String>,
334 #[serde(default, skip_serializing_if = "Vec::is_empty")]
335 pub errors: Vec<ApiErrorIssue>,
336 #[serde(skip_serializing_if = "Option::is_none")]
337 pub metadata: Option<serde_json::Value>,
338}
339
340impl error::ResponseError for ControllerError {
341 fn error_response(&self) -> HttpResponse {
342 if let ControllerErrorType::InternalServerError = &self.error_type {
343 error!(
345 "Internal server error:\n{}",
346 self.clean_string(ColorChoice::Auto)
347 );
348
349 if let Some(pool) = crate::domain::internal_error_reporting::error_reporting_pool() {
350 let pool = pool.clone();
351 let message = self.message.clone();
352 let stack_trace = format!("{:?}", self);
353 let details = serde_json::json!({
354 "kind": "controller_error",
355 "controller_error_type": self.error_type.to_string(),
356 });
357
358 actix_web::rt::spawn(async move {
360 let mut conn = match tokio::time::timeout(
361 std::time::Duration::from_millis(250),
362 pool.acquire(),
363 )
364 .await
365 {
366 Ok(Ok(conn)) => conn,
367 Ok(Err(err)) => {
368 warn!(
369 "internal error reporting skipped: failed to acquire pool connection: {err}"
370 );
371 return;
372 }
373 Err(_) => {
374 warn!(
375 "internal error reporting skipped: timed out acquiring pool connection"
376 );
377 return;
378 }
379 };
380 let report = headless_lms_models::errors::NewErrorReport {
381 service: "headless-lms".to_string(),
382 error_source: Some(headless_lms_models::errors::ErrorSource::Backend),
383 message,
384 stack_trace: Some(stack_trace),
385 path: None,
386 app_version: None,
387 details: Some(details),
388 };
389 if let Err(err) =
390 headless_lms_models::errors::insert(&mut conn, None, &report).await
391 {
392 debug!("internal error reporting insert failed: {err}");
393 }
394 });
395 }
396 }
397 if let ControllerErrorType::OAuthError(data) = &self.error_type {
398 if let Some(uri) = &data.redirect_uri
399 && let Ok(mut url) = url::Url::parse(uri)
400 {
401 {
402 let mut qp = url.query_pairs_mut();
403 qp.append_pair("error", &data.error);
404 qp.append_pair("error_description", &data.error_description);
405 if let Some(state) = &data.state {
406 qp.append_pair("state", state);
407 }
408 }
409 let loc = url.to_string();
410 return HttpResponse::Found()
411 .append_header(("Location", loc))
412 .finish();
413 }
414
415 let status = match data.error.as_str() {
416 "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,
422 };
423
424 let mut res = HttpResponse::build(status);
425 fn escape_auth_param(s: &str) -> String {
427 s.replace('\\', "\\\\").replace('"', "\\\"")
428 }
429
430 match data.error.as_str() {
431 "invalid_client" | "invalid_token" | "insufficient_scope" | "invalid_request" => {
433 let err = escape_auth_param(&data.error);
434 let desc = escape_auth_param(&data.error_description);
435 let hdr = format!(r#"Bearer error="{}", error_description="{}""#, err, desc);
436 res.append_header(("WWW-Authenticate", hdr));
437 }
438
439 "invalid_dpop_proof" => {
441 let err = escape_auth_param(&data.error);
442 let desc = escape_auth_param(&data.error_description);
443 let hdr = format!(r#"DPoP error="{}", error_description="{}""#, err, desc);
444 res.append_header(("WWW-Authenticate", hdr));
445 }
446
447 "use_dpop_nonce" => {
448 let err = escape_auth_param(&data.error);
449 let desc = escape_auth_param(&data.error_description);
450 let hdr = format!(r#"DPoP error="{}", error_description="{}""#, err, desc);
451 res.append_header(("WWW-Authenticate", hdr));
452
453 if let Some(nonce) = &data.nonce {
455 res.append_header(("DPoP-Nonce", nonce.clone()));
456 }
457 }
458
459 _ => {}
460 }
461
462 res.append_header(("Cache-Control", "no-store"))
464 .append_header(("Pragma", "no-cache"));
465
466 return res.json(serde_json::json!({
469 "error": data.error,
470 "error_description": data.error_description
471 }));
472 }
473
474 let status = self.status_code();
475
476 let metadata = match &self.error_type {
477 ControllerErrorType::BadRequestWithData(data) => Some(data.clone()),
478 _ => None,
479 };
480
481 let metadata_json =
482 metadata.map(|ErrorMetadata::BlockId(id)| serde_json::json!({ "block_id": id }));
483 let (error_type, message_key) = self.error_type_and_message_key();
484 let errors = self.validation_issues();
485 let message = Some(self.message.clone());
486
487 let error_response = ApiErrorResponse {
488 error_type: Some(error_type.to_string()),
489 message_key: Some(message_key.to_string()),
490 message,
491 errors,
492 metadata: metadata_json,
493 };
494
495 HttpResponseBuilder::new(status)
496 .append_header(ContentType::json())
497 .body(serde_json::to_string(&error_response).unwrap_or_else(|e| {
498 error!("Error while serialising error response: {e}");
499 r#"{"type":"internal_error","message_key":"internal_error"}"#.to_string()
500 }))
501 }
502
503 fn status_code(&self) -> StatusCode {
504 match self.error_type {
505 ControllerErrorType::InternalServerError => StatusCode::INTERNAL_SERVER_ERROR,
506 ControllerErrorType::BadRequest => StatusCode::UNPROCESSABLE_ENTITY,
507 ControllerErrorType::BadRequestWithData(_) => StatusCode::UNPROCESSABLE_ENTITY,
508 ControllerErrorType::BadRequestWithReason(_) => StatusCode::UNPROCESSABLE_ENTITY,
509 ControllerErrorType::NotFound => StatusCode::NOT_FOUND,
510 ControllerErrorType::Unauthorized => StatusCode::UNAUTHORIZED,
511 ControllerErrorType::UnauthorizedWithReason(_) => StatusCode::UNAUTHORIZED,
512 ControllerErrorType::Forbidden => StatusCode::FORBIDDEN,
513 ControllerErrorType::OAuthError(_) => StatusCode::OK,
514 ControllerErrorType::SisuError(SisuErrorType::InvalidCourseCode) => {
515 StatusCode::BAD_REQUEST
516 }
517 ControllerErrorType::SisuError(SisuErrorType::GenericSisuError) => {
518 StatusCode::BAD_GATEWAY
519 }
520 ControllerErrorType::SisuError(SisuErrorType::SisuResourceNotFound) => {
521 StatusCode::NOT_FOUND
522 }
523 }
524 }
525}
526
527impl ControllerError {
528 fn error_type_and_message_key(&self) -> (&'static str, &'static str) {
529 match self.error_type {
530 ControllerErrorType::InternalServerError => ("internal_error", "internal_error"),
531 ControllerErrorType::BadRequest => ("validation_error", "validation_error"),
532 ControllerErrorType::BadRequestWithData(_) => {
533 ("validation_error", "validation_error_with_metadata")
534 }
535 ControllerErrorType::BadRequestWithReason(reason) => {
536 ("validation_error", reason.message_key())
537 }
538 ControllerErrorType::NotFound => ("not_found", "not_found"),
539 ControllerErrorType::Unauthorized => ("unauthorized", "unauthorized"),
540 ControllerErrorType::UnauthorizedWithReason(reason) => {
541 ("unauthorized", reason.message_key())
542 }
543 ControllerErrorType::Forbidden => ("forbidden", "forbidden"),
544 ControllerErrorType::OAuthError(_) => ("oauth_error", "oauth_error"),
545 ControllerErrorType::SisuError(error_type) => ("sisu_error", error_type.message_key()),
546 }
547 }
548
549 fn validation_issues(&self) -> Vec<ApiErrorIssue> {
551 match &self.error_type {
552 ControllerErrorType::BadRequestWithData(_)
553 if self.message == MISSING_EXERCISE_TYPE_DESCRIPTION =>
554 {
555 vec![ApiErrorIssue {
556 path: Some("exercise_type".to_string()),
557 code: Some(ValidationIssueCode::MissingExerciseType.as_api_code()),
558 message: self.message.clone(),
559 }]
560 }
561 _ => Vec::new(),
562 }
563 }
564}
565
566#[derive(Debug, Serialize, Deserialize, Clone)]
567#[serde(rename_all = "snake_case")]
568pub struct OAuthErrorData {
569 pub error: String,
570 pub error_description: String,
571 pub redirect_uri: Option<String>,
572 pub state: Option<String>,
573 pub nonce: Option<String>,
574}
575
576pub enum OAuthErrorCode {
577 InvalidGrant,
578 InvalidRequest,
579 InvalidClient,
580 InvalidToken,
581 InsufficientScope,
582 UnsupportedGrantType,
583 UnsupportedResponseType,
584 ServerError,
585 InvalidDpopProof,
586 UseDpopNonce,
587}
588
589impl OAuthErrorCode {
590 pub fn as_str(&self) -> &'static str {
591 match self {
592 Self::InvalidGrant => "invalid_grant",
593 Self::InvalidRequest => "invalid_request",
594 Self::InvalidClient => "invalid_client",
595 Self::InvalidToken => "invalid_token",
596 Self::InsufficientScope => "insufficient_scope",
597 Self::UnsupportedGrantType => "unsupported_grant_type",
598 Self::UnsupportedResponseType => "unsupported_response_type",
599 Self::ServerError => "server_error",
600 Self::InvalidDpopProof => "invalid_dpop_proof",
601 Self::UseDpopNonce => "use_dpop_nonce",
602 }
603 }
604}
605
606impl From<anyhow::Error> for ControllerError {
607 fn from(err: anyhow::Error) -> ControllerError {
608 if let Some(sqlx::Error::RowNotFound) = err.downcast_ref::<sqlx::Error>() {
609 return Self::new(ControllerErrorType::NotFound, err.to_string(), Some(err));
610 }
611
612 Self::new(
613 ControllerErrorType::InternalServerError,
614 err.to_string(),
615 Some(err),
616 )
617 }
618}
619
620impl From<uuid::Error> for ControllerError {
621 fn from(err: uuid::Error) -> ControllerError {
622 Self::new(
623 ControllerErrorType::BadRequest,
624 err.to_string(),
625 Some(err.into()),
626 )
627 }
628}
629
630impl From<sqlx::Error> for ControllerError {
631 fn from(err: sqlx::Error) -> ControllerError {
632 Self::new(
633 ControllerErrorType::InternalServerError,
634 err.to_string(),
635 Some(err.into()),
636 )
637 }
638}
639
640impl From<git2::Error> for ControllerError {
641 fn from(err: git2::Error) -> ControllerError {
642 Self::new(
643 ControllerErrorType::InternalServerError,
644 err.to_string(),
645 Some(err.into()),
646 )
647 }
648}
649
650impl From<actix_web::Error> for ControllerError {
651 fn from(err: actix_web::Error) -> Self {
652 Self::new(
653 ControllerErrorType::InternalServerError,
654 err.to_string(),
655 None,
656 )
657 }
658}
659
660impl From<actix_multipart::MultipartError> for ControllerError {
661 fn from(err: actix_multipart::MultipartError) -> Self {
662 Self::new(
663 ControllerErrorType::InternalServerError,
664 err.to_string(),
665 None,
666 )
667 }
668}
669
670impl From<jsonwebtoken::errors::Error> for ControllerError {
671 fn from(err: jsonwebtoken::errors::Error) -> Self {
672 Self::new(
673 ControllerErrorType::InternalServerError,
674 err.to_string(),
675 None,
676 )
677 }
678}
679
680impl From<ModelError> for ControllerError {
681 fn from(err: ModelError) -> Self {
682 let backtrace: Backtrace =
683 match headless_lms_base::error::backend_error::BackendError::backtrace(&err) {
684 Some(backtrace) => backtrace.clone(),
685 _ => Backtrace::new(),
686 };
687 let span_trace = err.span_trace().clone();
688 match err.error_type() {
689 ModelErrorType::RecordNotFound => Self::new_with_traces(
690 ControllerErrorType::NotFound,
691 err.to_string(),
692 Some(err.into()),
693 backtrace,
694 span_trace,
695 ),
696 ModelErrorType::NotFound => Self::new_with_traces(
697 ControllerErrorType::NotFound,
698 err.to_string(),
699 Some(err.into()),
700 backtrace,
701 span_trace,
702 ),
703 ModelErrorType::PreconditionFailed => Self::new_with_traces(
704 ControllerErrorType::BadRequest,
705 err.message().to_string(),
706 Some(err.into()),
707 backtrace,
708 span_trace,
709 ),
710 ModelErrorType::PreconditionFailedWithCMSAnchorBlockId { description, id } => {
711 Self::new_with_traces(
712 ControllerErrorType::BadRequestWithData(ErrorMetadata::BlockId(*id)),
713 description.to_string(),
714 Some(err.into()),
715 backtrace,
716 span_trace,
717 )
718 }
719 ModelErrorType::DatabaseConstraint {
720 constraint,
721 description,
722 } => Self::new_with_traces(
723 BadRequestReason::from_database_constraint(constraint)
724 .map_or(ControllerErrorType::BadRequest, |reason| {
725 ControllerErrorType::BadRequestWithReason(reason)
726 }),
727 description.to_string(),
728 Some(err.into()),
729 backtrace,
730 span_trace,
731 ),
732 ModelErrorType::InvalidRequest => Self::new_with_traces(
733 ControllerErrorType::BadRequest,
734 err.message().to_string(),
735 Some(err.into()),
736 backtrace,
737 span_trace,
738 ),
739 _ => Self::new_with_traces(
740 ControllerErrorType::InternalServerError,
741 err.to_string(),
742 Some(err.into()),
743 backtrace,
744 span_trace,
745 ),
746 }
747 }
748}
749
750impl From<UtilError> for ControllerError {
751 fn from(err: UtilError) -> Self {
752 let backtrace: Backtrace =
753 match headless_lms_base::error::backend_error::BackendError::backtrace(&err) {
754 Some(backtrace) => backtrace.clone(),
755 _ => Backtrace::new(),
756 };
757 let span_trace = err.span_trace().clone();
758
759 match err.error_type() {
760 UtilErrorType::SisuClientError(SisuErrorVariant::GenericSisuError) => {
761 Self::new_with_traces(
762 ControllerErrorType::SisuError(SisuErrorType::GenericSisuError),
763 err.to_string(),
764 Some(err.into()),
765 backtrace,
766 span_trace,
767 )
768 }
769 UtilErrorType::SisuClientError(SisuErrorVariant::InvalidCourseCode) => {
770 Self::new_with_traces(
771 ControllerErrorType::SisuError(SisuErrorType::InvalidCourseCode),
772 err.to_string(),
773 Some(err.into()),
774 backtrace,
775 span_trace,
776 )
777 }
778 UtilErrorType::SisuClientError(SisuErrorVariant::SisuResourceNotFound) => {
779 Self::new_with_traces(
780 ControllerErrorType::SisuError(SisuErrorType::SisuResourceNotFound),
781 err.to_string(),
782 Some(err.into()),
783 backtrace,
784 span_trace,
785 )
786 }
787 _ => Self::new_with_traces(
788 ControllerErrorType::InternalServerError,
789 err.to_string(),
790 Some(err.into()),
791 backtrace,
792 span_trace,
793 ),
794 }
795 }
796}
797
798impl From<serde_json::Error> for ControllerError {
799 fn from(err: serde_json::Error) -> Self {
800 Self::new(
801 ControllerErrorType::InternalServerError,
802 err.to_string(),
803 Some(err.into()),
804 )
805 }
806}
807
808impl From<base64::DecodeError> for ControllerError {
809 fn from(err: base64::DecodeError) -> Self {
810 Self::new(
811 ControllerErrorType::InternalServerError,
812 err.to_string(),
813 Some(err.into()),
814 )
815 }
816}
817
818impl From<std::string::FromUtf8Error> for ControllerError {
819 fn from(err: std::string::FromUtf8Error) -> Self {
820 Self::new(
821 ControllerErrorType::InternalServerError,
822 err.to_string(),
823 Some(err.into()),
824 )
825 }
826}
827
828impl From<pkcs8::spki::Error> for ControllerError {
829 fn from(err: pkcs8::spki::Error) -> Self {
830 Self::new(
831 ControllerErrorType::InternalServerError,
832 err.to_string(),
833 Some(err.into()),
834 )
835 }
836}
837
838impl From<dpop_verifier::error::DpopError> for ControllerError {
839 fn from(err: DpopError) -> Self {
840 let oauth_error = match &err {
841 DpopError::MultipleDpopHeaders
842 | DpopError::InvalidDpopHeader
843 | DpopError::MissingDpopHeader
844 | DpopError::MalformedJws
845 | DpopError::InvalidAlg(_)
846 | DpopError::UnsupportedAlg(_)
847 | DpopError::InvalidSignature
848 | DpopError::BadJwk(_)
849 | DpopError::MissingClaim(_)
850 | DpopError::InvalidMethod
851 | DpopError::HtmMismatch
852 | DpopError::MalformedHtu
853 | DpopError::HtuMismatch
854 | DpopError::AthMalformed
855 | DpopError::MissingAth
856 | DpopError::AthMismatch
857 | DpopError::FutureSkew
858 | DpopError::Stale
859 | DpopError::Replay
860 | DpopError::JtiTooLong
861 | DpopError::NonceMismatch
862 | DpopError::NonceStale
863 | DpopError::InvalidHmacConfig
864 | DpopError::MissingNonce => OAuthErrorData {
865 error: OAuthErrorCode::InvalidDpopProof.as_str().into(),
866 error_description: err.to_string(),
867 redirect_uri: None,
868 state: None,
869 nonce: None,
870 },
871
872 DpopError::Store(e) => OAuthErrorData {
873 error: OAuthErrorCode::ServerError.as_str().into(),
874 error_description: format!("DPoP storage error: {e}"),
875 redirect_uri: None,
876 state: None,
877 nonce: None,
878 },
879
880 DpopError::UseDpopNonce { nonce } => OAuthErrorData {
881 error: OAuthErrorCode::UseDpopNonce.as_str().into(), error_description: "Server requires DPoP nonce".into(),
883 redirect_uri: None,
884 state: None,
885 nonce: Some(nonce.clone()),
886 },
887 };
888
889 ControllerError::new(
890 ControllerErrorType::OAuthError(Box::new(oauth_error)),
891 err.to_string(),
892 Some(err.into()),
893 )
894 }
895}
896
897#[derive(Debug, thiserror::Error)]
898pub enum PkceFlowError {
899 #[error("{0}")]
901 InvalidRequest(&'static str),
902
903 #[error("{0}")]
905 InvalidGrant(&'static str),
906
907 #[error("{0}")]
909 ServerError(&'static str),
910}
911
912impl From<PkceFlowError> for ControllerError {
913 fn from(err: PkceFlowError) -> Self {
914 let data = match &err {
915 PkceFlowError::InvalidRequest(msg) => OAuthErrorData {
916 error: OAuthErrorCode::InvalidRequest.as_str().into(),
917 error_description: (*msg).into(),
918 redirect_uri: None,
919 state: None,
920 nonce: None,
921 },
922 PkceFlowError::InvalidGrant(msg) => OAuthErrorData {
923 error: OAuthErrorCode::InvalidGrant.as_str().into(),
924 error_description: (*msg).into(),
925 redirect_uri: None,
926 state: None,
927 nonce: None,
928 },
929 PkceFlowError::ServerError(msg) => OAuthErrorData {
930 error: OAuthErrorCode::ServerError.as_str().into(),
931 error_description: (*msg).into(),
932 redirect_uri: None,
933 state: None,
934 nonce: None,
935 },
936 };
937
938 ControllerError::new(
939 ControllerErrorType::OAuthError(Box::new(data)),
940 err.to_string(),
941 Some(anyhow::anyhow!(err)),
942 )
943 }
944}
945
946impl From<crate::domain::oauth::pkce::PkceError> for PkceFlowError {
947 fn from(_err: crate::domain::oauth::pkce::PkceError) -> Self {
948 PkceFlowError::InvalidRequest("invalid code_verifier")
950 }
951}
952
953impl From<crate::domain::oauth::pkce::PkceError> for ControllerError {
954 fn from(err: crate::domain::oauth::pkce::PkceError) -> Self {
955 PkceFlowError::from(err).into()
956 }
957}
958
959impl From<ChatbotError> for ControllerError {
960 fn from(err: ChatbotError) -> Self {
961 ControllerError::new(
962 ControllerErrorType::InternalServerError,
963 err.message().to_string(),
964 Some(err.into()),
965 )
966 }
967}
968
969headless_lms_utils::define_err_macro!(
971 controller_err,
972 ControllerError,
973 ControllerErrorType,
974 ControllerErrorType,
975 "Create a ControllerError with less boilerplate."
976);
977
978pub fn as_controller_error<E>(
993 error_type: ControllerErrorType,
994 message: impl Into<String>,
995) -> impl FnOnce(E) -> ControllerError
996where
997 E: Into<anyhow::Error>,
998{
999 let msg = message.into();
1000 move |e| ControllerError::new(error_type, msg, Some(e.into()))
1001}
1002
1003pub fn missing_controller_error(
1018 error_type: ControllerErrorType,
1019 message: impl Into<String>,
1020) -> impl FnOnce() -> ControllerError {
1021 let msg = message.into();
1022 move || ControllerError::new(error_type, msg, None)
1023}
1024
1025#[cfg(test)]
1026mod tests {
1027 use super::*;
1028 use actix_web::ResponseError;
1029 use futures_util::FutureExt;
1030
1031 #[test]
1032 fn test_controller_err_macro_without_source() {
1033 let err = controller_err!(BadRequest, "Test error message".to_string());
1034 assert_eq!(err.message(), "Test error message");
1035 assert!(matches!(err.error_type(), ControllerErrorType::BadRequest));
1036 }
1037
1038 #[test]
1039 fn test_controller_err_macro_with_source() {
1040 let source_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
1041 let err = controller_err!(InternalServerError, "Wrapped error".to_string(), source_err);
1042 assert_eq!(err.message(), "Wrapped error");
1043 }
1044
1045 #[test]
1046 fn test_controller_err_macro_tuple_variant_with_source() {
1047 let source_err = std::io::Error::other("source");
1048 let err = controller_err!(
1049 UnauthorizedWithReason(UnauthorizedReason::ChapterNotOpenYet),
1050 "Wrapped error".to_string(),
1051 source_err
1052 );
1053 assert!(matches!(
1054 err.error_type(),
1055 ControllerErrorType::UnauthorizedWithReason(_)
1056 ));
1057 }
1058
1059 #[test]
1060 fn test_as_controller_error_helper() {
1061 let result: Result<(), std::io::Error> = Err(std::io::Error::new(
1062 std::io::ErrorKind::NotFound,
1063 "test error",
1064 ));
1065 let controller_result = result.map_err(as_controller_error(
1066 ControllerErrorType::BadRequest,
1067 "Invalid input".to_string(),
1068 ));
1069
1070 assert!(controller_result.is_err());
1071 let err = controller_result.unwrap_err();
1072 assert_eq!(err.message(), "Invalid input");
1073 assert!(matches!(err.error_type(), ControllerErrorType::BadRequest));
1074 }
1075
1076 #[test]
1077 fn test_missing_controller_error_helper() {
1078 let option: Option<String> = None;
1079 let result = option.ok_or_else(missing_controller_error(
1080 ControllerErrorType::NotFound,
1081 "Resource not found".to_string(),
1082 ));
1083
1084 assert!(result.is_err());
1085 let err = result.unwrap_err();
1086 assert_eq!(err.message(), "Resource not found");
1087 assert!(matches!(err.error_type(), ControllerErrorType::NotFound));
1088 }
1089
1090 #[test]
1091 fn test_controller_err_with_format() {
1092 let user_id = 42;
1093 let err = controller_err!(Unauthorized, format!("User {} is not authorized", user_id));
1094 assert_eq!(err.message(), "User 42 is not authorized");
1095 }
1096
1097 #[test]
1098 fn test_controller_err_all_variants() {
1099 let _ = controller_err!(InternalServerError, "test".to_string());
1101 let _ = controller_err!(BadRequest, "test".to_string());
1102 let _ = controller_err!(NotFound, "test".to_string());
1103 let _ = controller_err!(Unauthorized, "test".to_string());
1104 let _ = controller_err!(
1105 UnauthorizedWithReason(UnauthorizedReason::ChapterNotOpenYet),
1106 "test".to_string()
1107 );
1108 let _ = controller_err!(
1109 BadRequestWithData(ErrorMetadata::BlockId(Uuid::nil())),
1110 "test".to_string()
1111 );
1112 let _ = controller_err!(Forbidden, "test".to_string());
1113 }
1114
1115 #[test]
1116 fn test_canonical_error_envelope_shape() {
1117 let err = controller_err!(BadRequest, "Validation failed".to_string());
1118 let response = err.error_response();
1119 assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
1120
1121 let bytes = actix_web::body::to_bytes(response.into_body())
1122 .now_or_never()
1123 .expect("response should resolve immediately")
1124 .expect("body bytes");
1125 let value: serde_json::Value = serde_json::from_slice(&bytes).expect("json");
1126 assert_eq!(value["type"], "validation_error");
1127 assert_eq!(value["message_key"], "validation_error");
1128 assert_eq!(value["message"], "Validation failed");
1129 assert!(value.get("status").is_none());
1130 assert!(value.get("request_id").is_none());
1131 }
1132
1133 #[test]
1134 fn test_validation_issue_code_is_serialized_for_missing_exercise_type() {
1135 let err = ControllerError::new(
1136 ControllerErrorType::BadRequestWithData(ErrorMetadata::BlockId(Uuid::nil())),
1137 MISSING_EXERCISE_TYPE_DESCRIPTION.to_string(),
1138 None,
1139 );
1140 let response = err.error_response();
1141 let bytes = actix_web::body::to_bytes(response.into_body())
1142 .now_or_never()
1143 .expect("response should resolve immediately")
1144 .expect("body bytes");
1145 let value: serde_json::Value = serde_json::from_slice(&bytes).expect("json");
1146
1147 assert_eq!(value["type"], "validation_error");
1148 assert_eq!(value["message_key"], "validation_error_with_metadata");
1149 assert_eq!(value["errors"][0]["code"], "missing_exercise_type");
1150 assert_eq!(value["errors"][0]["path"], "exercise_type");
1151 }
1152
1153 #[test]
1154 fn test_chapter_not_open_uses_dedicated_message_key() {
1155 let err = ControllerError::new(
1156 ControllerErrorType::UnauthorizedWithReason(UnauthorizedReason::ChapterNotOpenYet),
1157 "Chapter is not open yet.".to_string(),
1158 None,
1159 );
1160 let response = err.error_response();
1161 let bytes = actix_web::body::to_bytes(response.into_body())
1162 .now_or_never()
1163 .expect("response should resolve immediately")
1164 .expect("body bytes");
1165 let value: serde_json::Value = serde_json::from_slice(&bytes).expect("json");
1166
1167 assert_eq!(value["type"], "unauthorized");
1168 assert_eq!(value["message_key"], "chapter_not_open_yet");
1169 assert_eq!(value["message"], "Chapter is not open yet.");
1170 }
1171
1172 #[test]
1173 fn test_exam_exercise_auth_requirement_uses_dedicated_message_key() {
1174 let err = ControllerError::new(
1175 ControllerErrorType::UnauthorizedWithReason(
1176 UnauthorizedReason::AuthenticationRequiredForExamExercise,
1177 ),
1178 "User must be authenticated to view exam exercises".to_string(),
1179 None,
1180 );
1181 let response = err.error_response();
1182 let bytes = actix_web::body::to_bytes(response.into_body())
1183 .now_or_never()
1184 .expect("response should resolve immediately")
1185 .expect("body bytes");
1186 let value: serde_json::Value = serde_json::from_slice(&bytes).expect("json");
1187
1188 assert_eq!(value["type"], "unauthorized");
1189 assert_eq!(
1190 value["message_key"],
1191 "authentication_required_for_exam_exercise"
1192 );
1193 assert_eq!(
1194 value["message"],
1195 "User must be authenticated to view exam exercises"
1196 );
1197 }
1198
1199 #[test]
1200 fn test_generic_unauthorized_uses_unauthorized_message_key() {
1201 let err = ControllerError::new(
1202 ControllerErrorType::Unauthorized,
1203 "Unauthorized".to_string(),
1204 None,
1205 );
1206 let response = err.error_response();
1207 let bytes = actix_web::body::to_bytes(response.into_body())
1208 .now_or_never()
1209 .expect("response should resolve immediately")
1210 .expect("body bytes");
1211 let value: serde_json::Value = serde_json::from_slice(&bytes).expect("json");
1212
1213 assert_eq!(value["type"], "unauthorized");
1214 assert_eq!(value["message_key"], "unauthorized");
1215 assert_eq!(value["message"], "Unauthorized");
1216 }
1217
1218 #[test]
1219 fn debug_renders_clean_format() {
1220 let err = controller_err!(InternalServerError, "database exploded".to_string());
1221 let debug = format!("{err:?}");
1222 assert!(
1223 debug.contains("ControllerError · InternalServerError: database exploded"),
1224 "got: {debug}"
1225 );
1226 assert!(!debug.contains("backend_error.rs"), "got: {debug}");
1228 assert!(!debug.contains("macros.rs"), "got: {debug}");
1229 }
1230
1231 #[test]
1232 fn debug_renders_wrapped_model_error_as_cause_node() {
1233 let model_error = ModelError::new(ModelErrorType::Generic, "row missing".to_string(), None);
1234 let err = ControllerError::from(model_error);
1235
1236 let debug = format!("{err:?}");
1237 assert!(debug.contains("ControllerError ·"), "got: {debug}");
1238 assert!(debug.contains("caused by:"), "got: {debug}");
1239 assert!(
1240 debug.contains("1. ModelError · Generic: row missing"),
1241 "got: {debug}"
1242 );
1243 assert!(!debug.contains("(external)"), "got: {debug}");
1244 }
1245}