Skip to main content

headless_lms_server/domain/
error.rs

1/*!
2Contains error and result types for all the controllers.
3*/
4
5use 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
26/**
27Used as the result types for all controllers.
28Only put information here that you want to be visible to users.
29
30See also [ControllerError] for documentation on how to return errors from controllers.
31*/
32pub type ControllerResult<T, E = ControllerError> = std::result::Result<AuthorizedResponse<T>, E>;
33
34/// The type of [ControllerError] that occured.
35#[derive(Debug, Display, Serialize, Deserialize)]
36pub enum ControllerErrorType {
37    /// HTTP status code 500.
38    #[display("Internal server error")]
39    InternalServerError,
40
41    /// HTTP status code 400.
42    #[display("Bad request")]
43    BadRequest,
44
45    /// HTTP status code 400.
46    #[display("Bad request")]
47    BadRequestWithData(ErrorMetadata),
48
49    /// HTTP status code 422 with a specific domain reason.
50    #[display("Bad request")]
51    BadRequestWithReason(BadRequestReason),
52
53    /// HTTP status code 404.
54    #[display("Not found")]
55    NotFound,
56
57    /// HTTP status code 401. Needs to log in.
58    #[display("Unauthorized")]
59    Unauthorized,
60
61    /// HTTP status code 401 with a specific domain reason.
62    #[display("Unauthorized")]
63    UnauthorizedWithReason(UnauthorizedReason),
64
65    /// HTTP status code 403. Is logged in but is not allowed to access the resource.
66    #[display("Forbidden")]
67    Forbidden,
68
69    /// Varied response based on error
70    #[display("OAuthError")]
71    OAuthError(Box<OAuthErrorData>),
72
73    /// SISUERROR
74    #[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    /// Returns the stable message key for this unauthorized reason.
89    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/// Bad request reasons that the frontend has a translated message for.
100#[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    /// Returns the stable message key for this bad request reason.
109    fn message_key(self) -> &'static str {
110        match self {
111            Self::CourseSlugAlreadyTaken => "course_slug_already_taken",
112        }
113    }
114
115    /// Both slug indexes collapse into one reason: they guard the same user mistake.
116    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    /// Returns the stable message key for this unauthorized reason.
140    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
149/**
150Represents error messages that are sent in responses. Used as the error type in [ControllerError], which is used by all the controllers in the application.
151
152All the information in the error is meant to be seen by the user. The type of error is determined by the [ControllerErrorType] enum, which is stored inside this struct. The type of the error determines which HTTP status code will be sent to the user.
153
154## Examples
155
156### Usage without source error
157
158```no_run
159# use headless_lms_server::prelude::*;
160# fn random_function() -> ControllerResult<web::Json<()>> {
161#    let token = skip_authorize();
162#    let erroneous_condition = 1 == 1;
163if erroneous_condition {
164    return Err(ControllerError::new(
165        ControllerErrorType::BadRequest,
166        "Cannot create a new account when signed in.".to_string(),
167        None,
168    ));
169}
170# token.authorized_ok(web::Json(()))
171# }
172```
173
174### Usage with a source error
175
176Used when calling a function that returns an error that cannot be automatically converted to an ControllerError. (See `impl From<X>` implementations on this struct.)
177
178```no_run
179# use headless_lms_server::prelude::*;
180# fn some_function_returning_an_error() -> ControllerResult<web::Json<()>> {
181#    return Err(ControllerError::new(
182#         ControllerErrorType::BadRequest,
183#         "Cannot create a new account when signed in.".to_string(),
184#         None,
185#     ));
186# }
187#
188# fn random_function() -> ControllerResult<web::Json<()>> {
189#    let token = skip_authorize();
190#    let erroneous_condition = 1 == 1;
191some_function_returning_an_error().map_err(|original_error| {
192    ControllerError::new(
193        ControllerErrorType::InternalServerError,
194        "Could not read file".to_string(),
195        Some(original_error.into()),
196    )
197})?;
198# token.authorized_ok(web::Json(()))
199# }
200```
201
202### Example HTTP response from an error
203
204```json
205{
206    "title": "Internal Server Error",
207    "message": "pool timed out while waiting for an open connection",
208    "source": "source of error"
209}
210```
211*/
212pub struct ControllerError {
213    error_type: <ControllerError as BackendError>::ErrorType,
214    message: String,
215    /// Original error that caused this error.
216    source: Option<anyhow::Error>,
217    /// A trace of tokio tracing spans, generated automatically when the error is generated.
218    span_trace: Box<SpanTrace>,
219    /// Stack trace, generated automatically when the error is created.
220    backtrace: Box<Backtrace>,
221    /// Source location where the error was raised.
222    location: Option<&'static Location<'static>>,
223}
224
225// Generate the clean developer `Debug`/`clean_string` and a cause resolver.
226headless_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    /// Returns stable API-facing code value for validation issues.
316    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/// Canonical API error envelope returned for controlled application errors.
325#[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            // Clean format, colored only on a TTY (Auto); the DB report below stays plain.
344            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                // Uses the main DB pool intentionally for best-effort reporting; this can amplify outage pressure.
359                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,     // 401
417                "invalid_token" => StatusCode::UNAUTHORIZED,      // 401 (bearer)
418                "invalid_dpop_proof" => StatusCode::UNAUTHORIZED, // 401 (dpop)
419                "use_dpop_nonce" => StatusCode::UNAUTHORIZED,     // 401 (dpop)
420                "insufficient_scope" => StatusCode::FORBIDDEN,    // 403
421                _ => StatusCode::BAD_REQUEST,
422            };
423
424            let mut res = HttpResponse::build(status);
425            // Small helper to safely embed values in WWW-Authenticate auth-param strings.
426            fn escape_auth_param(s: &str) -> String {
427                s.replace('\\', "\\\\").replace('"', "\\\"")
428            }
429
430            match data.error.as_str() {
431                // OAuth2 Bearer challenges (RFC 6750 §3)
432                "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                // DPoP auth challenges (RFC 9449 §12.2)
440                "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                    // Provide the server-generated nonce (clients must echo it in the next proof)
454                    if let Some(nonce) = &data.nonce {
455                        res.append_header(("DPoP-Nonce", nonce.clone()));
456                    }
457                }
458
459                _ => {}
460            }
461
462            // Prevent caching per RFC 6749 §5.1 (common practice for error responses too)
463            res.append_header(("Cache-Control", "no-store"))
464                .append_header(("Pragma", "no-cache"));
465
466            // OAuth token/introspection semantics are standardized around `error` and
467            // `error_description`; keep compatibility for protocol clients.
468            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    /// Derives issue-level validation details from known backend validation cases.
550    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(), // per RFC 9449 §12.2
882                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    /// Request is malformed or missing a required PKCE parameter
900    #[error("{0}")]
901    InvalidRequest(&'static str),
902
903    /// PKCE check failed (e.g., code_verifier doesn't match stored challenge)
904    #[error("{0}")]
905    InvalidGrant(&'static str),
906
907    /// Server-side (DB/state) problem
908    #[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        // Both BadLength and BadCharset are "invalid_request" per OAuth spec
949        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
969// Generate error creation macros for ControllerError
970headless_lms_utils::define_err_macro!(
971    controller_err,
972    ControllerError,
973    ControllerErrorType,
974    ControllerErrorType,
975    "Create a ControllerError with less boilerplate."
976);
977
978/// Helper function for `.map_err()` chains to wrap any error as ControllerError.
979///
980/// This function creates a closure that converts any error into a `ControllerError`
981/// with the specified error type and message, including the original error as the source.
982///
983/// # Examples
984///
985/// ```ignore
986/// // Instead of:
987/// .map_err(|e| ControllerError::new(ControllerErrorType::BadRequest, e.to_string(), Some(e.into())))?
988///
989/// // You can write:
990/// .map_err(as_controller_error(ControllerErrorType::BadRequest, "Failed to process".to_string()))?
991/// ```
992pub 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
1003/// Helper function for `.ok_or_else()` to create ControllerError on None.
1004///
1005/// This function creates a closure that generates a `ControllerError` with the
1006/// specified error type and message when called.
1007///
1008/// # Examples
1009///
1010/// ```ignore
1011/// // Instead of:
1012/// .ok_or_else(|| ControllerError::new(ControllerErrorType::NotFound, "Item not found".to_string(), None))
1013///
1014/// // You can write:
1015/// .ok_or_else(missing_controller_error(ControllerErrorType::NotFound, "Item not found".to_string()))
1016/// ```
1017pub 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        // Test that macros work with all standard error type variants
1100        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        // No error-infrastructure leakage in the raise line.
1227        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}