Skip to main content

headless_lms_server/domain/
error.rs

1/*!
2Contains error and result types for all the controllers.
3*/
4
5use crate::domain::authorization::AuthorizedResponse;
6use actix_web::{
7    HttpResponse, HttpResponseBuilder, error,
8    http::{StatusCode, header::ContentType},
9};
10use backtrace::Backtrace;
11use derive_more::Display;
12use dpop_verifier::error::DpopError;
13use headless_lms_base::error::{
14    backend_error::BackendError, backtrace_formatter::format_backtrace,
15};
16use headless_lms_chatbot::prelude::ChatbotError;
17use headless_lms_models::{ModelError, ModelErrorType};
18use headless_lms_utils::error::util_error::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 404.
50    #[display("Not found")]
51    NotFound,
52
53    /// HTTP status code 401. Needs to log in.
54    #[display("Unauthorized")]
55    Unauthorized,
56
57    /// HTTP status code 401 with a specific domain reason.
58    #[display("Unauthorized")]
59    UnauthorizedWithReason(UnauthorizedReason),
60
61    /// HTTP status code 403. Is logged in but is not allowed to access the resource.
62    #[display("Forbidden")]
63    Forbidden,
64
65    /// Varied response based on error
66    #[display("OAuthError")]
67    OAuthError(Box<OAuthErrorData>),
68}
69
70#[derive(Debug, Display, Serialize, Deserialize, Clone, Copy, PartialEq, Eq)]
71#[serde(rename_all = "snake_case")]
72pub enum UnauthorizedReason {
73    #[display("Chapter not open yet")]
74    ChapterNotOpenYet,
75    #[display("Authentication required for exam exercise")]
76    AuthenticationRequiredForExamExercise,
77}
78
79impl UnauthorizedReason {
80    /// Returns the stable message key for this unauthorized reason.
81    fn message_key(self) -> &'static str {
82        match self {
83            Self::ChapterNotOpenYet => "chapter_not_open_yet",
84            Self::AuthenticationRequiredForExamExercise => {
85                "authentication_required_for_exam_exercise"
86            }
87        }
88    }
89}
90
91/**
92Represents error messages that are sent in responses. Used as the error type in [ControllerError], which is used by all the controllers in the application.
93
94All 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.
95
96## Examples
97
98### Usage without source error
99
100```no_run
101# use headless_lms_server::prelude::*;
102# fn random_function() -> ControllerResult<web::Json<()>> {
103#    let token = skip_authorize();
104#    let erroneous_condition = 1 == 1;
105if erroneous_condition {
106    return Err(ControllerError::new(
107        ControllerErrorType::BadRequest,
108        "Cannot create a new account when signed in.".to_string(),
109        None,
110    ));
111}
112# token.authorized_ok(web::Json(()))
113# }
114```
115
116### Usage with a source error
117
118Used when calling a function that returns an error that cannot be automatically converted to an ControllerError. (See `impl From<X>` implementations on this struct.)
119
120```no_run
121# use headless_lms_server::prelude::*;
122# fn some_function_returning_an_error() -> ControllerResult<web::Json<()>> {
123#    return Err(ControllerError::new(
124#         ControllerErrorType::BadRequest,
125#         "Cannot create a new account when signed in.".to_string(),
126#         None,
127#     ));
128# }
129#
130# fn random_function() -> ControllerResult<web::Json<()>> {
131#    let token = skip_authorize();
132#    let erroneous_condition = 1 == 1;
133some_function_returning_an_error().map_err(|original_error| {
134    ControllerError::new(
135        ControllerErrorType::InternalServerError,
136        "Could not read file".to_string(),
137        Some(original_error.into()),
138    )
139})?;
140# token.authorized_ok(web::Json(()))
141# }
142```
143
144### Example HTTP response from an error
145
146```json
147{
148    "title": "Internal Server Error",
149    "message": "pool timed out while waiting for an open connection",
150    "source": "source of error"
151}
152```
153*/
154pub struct ControllerError {
155    error_type: <ControllerError as BackendError>::ErrorType,
156    message: String,
157    /// Original error that caused this error.
158    source: Option<anyhow::Error>,
159    /// A trace of tokio tracing spans, generated automatically when the error is generated.
160    span_trace: Box<SpanTrace>,
161    /// Stack trace, generated automatically when the error is created.
162    backtrace: Box<Backtrace>,
163}
164
165/// Custom formatter so that errors that get printed to the console are easy-to-read with proper context where the error is coming from.
166impl std::fmt::Debug for ControllerError {
167    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
168        f.debug_struct("ControllerError")
169            .field("error_type", &self.error_type)
170            .field("message", &self.message)
171            .field("source", &self.source)
172            .finish()?;
173
174        f.write_str("\n\nOperating system thread stack backtrace:\n")?;
175        format_backtrace(&self.backtrace, f)?;
176
177        f.write_str("\n\nTokio tracing span trace:\n")?;
178        f.write_fmt(format_args!("{}\n", &self.span_trace))?;
179
180        Ok(())
181    }
182}
183
184impl std::error::Error for ControllerError {
185    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
186        self.source.as_ref().and_then(|o| o.source())
187    }
188
189    fn cause(&self) -> Option<&dyn std::error::Error> {
190        self.source()
191    }
192}
193
194impl std::fmt::Display for ControllerError {
195    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
196        write!(
197            f,
198            "ControllerError {:?} {:?}",
199            self.error_type, self.message
200        )
201    }
202}
203
204impl BackendError for ControllerError {
205    type ErrorType = ControllerErrorType;
206
207    fn new<M: Into<String>, S: Into<Option<anyhow::Error>>>(
208        error_type: Self::ErrorType,
209        message: M,
210        source_error: S,
211    ) -> Self {
212        Self::new_with_traces(
213            error_type,
214            message,
215            source_error,
216            Backtrace::new(),
217            SpanTrace::capture(),
218        )
219    }
220
221    fn backtrace(&self) -> Option<&Backtrace> {
222        Some(&self.backtrace)
223    }
224
225    fn error_type(&self) -> &Self::ErrorType {
226        &self.error_type
227    }
228
229    fn message(&self) -> &str {
230        &self.message
231    }
232
233    fn span_trace(&self) -> &SpanTrace {
234        &self.span_trace
235    }
236
237    fn new_with_traces<M: Into<String>, S: Into<Option<anyhow::Error>>>(
238        error_type: Self::ErrorType,
239        message: M,
240        source_error: S,
241        backtrace: Backtrace,
242        span_trace: SpanTrace,
243    ) -> Self {
244        Self {
245            error_type,
246            message: message.into(),
247            source: source_error.into(),
248            span_trace: Box::new(span_trace),
249            backtrace: Box::new(backtrace),
250        }
251    }
252}
253
254#[derive(Debug, Serialize, Deserialize, Clone)]
255#[serde(rename_all = "snake_case")]
256pub enum ErrorMetadata {
257    BlockId(Uuid),
258}
259
260#[derive(Debug, Serialize, Deserialize)]
261pub struct ApiErrorIssue {
262    pub path: Option<String>,
263    pub code: Option<String>,
264    pub message: String,
265}
266
267#[derive(Debug, Serialize, Deserialize, Clone, Copy)]
268#[serde(rename_all = "snake_case")]
269pub enum ValidationIssueCode {
270    MissingExerciseType,
271}
272
273impl ValidationIssueCode {
274    /// Returns stable API-facing code value for validation issues.
275    fn as_api_code(self) -> String {
276        serde_json::to_value(self)
277            .ok()
278            .and_then(|v| v.as_str().map(|code| code.to_string()))
279            .unwrap_or_else(|| "unknown_validation_issue".to_string())
280    }
281}
282
283/// Canonical API error envelope returned for controlled application errors.
284#[derive(Debug, Serialize, Deserialize)]
285pub struct ApiErrorResponse {
286    #[serde(rename = "type")]
287    #[serde(skip_serializing_if = "Option::is_none")]
288    pub error_type: Option<String>,
289    #[serde(skip_serializing_if = "Option::is_none")]
290    pub message_key: Option<String>,
291    #[serde(skip_serializing_if = "Option::is_none")]
292    pub message: Option<String>,
293    #[serde(default, skip_serializing_if = "Vec::is_empty")]
294    pub errors: Vec<ApiErrorIssue>,
295    #[serde(skip_serializing_if = "Option::is_none")]
296    pub metadata: Option<serde_json::Value>,
297}
298
299impl error::ResponseError for ControllerError {
300    fn error_response(&self) -> HttpResponse {
301        if let ControllerErrorType::InternalServerError = &self.error_type {
302            use std::fmt::Write as _;
303            let mut err_string = String::new();
304            let mut source = Some(self as &dyn std::error::Error);
305            while let Some(err) = source {
306                let _ = write!(err_string, "{}\n    ", err);
307                source = err.source();
308            }
309            error!("Internal server error: {}", err_string);
310
311            if let Some(pool) = crate::domain::internal_error_reporting::error_reporting_pool() {
312                let pool = pool.clone();
313                let message = self.message.clone();
314                let stack_trace = format!("{:?}", self);
315                let details = serde_json::json!({
316                    "kind": "controller_error",
317                    "controller_error_type": self.error_type.to_string(),
318                });
319
320                // Uses the main DB pool intentionally for best-effort reporting; this can amplify outage pressure.
321                actix_web::rt::spawn(async move {
322                    let mut conn = match tokio::time::timeout(
323                        std::time::Duration::from_millis(250),
324                        pool.acquire(),
325                    )
326                    .await
327                    {
328                        Ok(Ok(conn)) => conn,
329                        Ok(Err(err)) => {
330                            warn!(
331                                "internal error reporting skipped: failed to acquire pool connection: {err}"
332                            );
333                            return;
334                        }
335                        Err(_) => {
336                            warn!(
337                                "internal error reporting skipped: timed out acquiring pool connection"
338                            );
339                            return;
340                        }
341                    };
342                    let report = headless_lms_models::errors::NewErrorReport {
343                        service: "headless-lms".to_string(),
344                        error_source: Some(headless_lms_models::errors::ErrorSource::Backend),
345                        message,
346                        stack_trace: Some(stack_trace),
347                        path: None,
348                        app_version: None,
349                        details: Some(details),
350                    };
351                    if let Err(err) =
352                        headless_lms_models::errors::insert(&mut conn, None, &report).await
353                    {
354                        debug!("internal error reporting insert failed: {err}");
355                    }
356                });
357            }
358        }
359        if let ControllerErrorType::OAuthError(data) = &self.error_type {
360            if let Some(uri) = &data.redirect_uri
361                && let Ok(mut url) = url::Url::parse(uri)
362            {
363                {
364                    let mut qp = url.query_pairs_mut();
365                    qp.append_pair("error", &data.error);
366                    qp.append_pair("error_description", &data.error_description);
367                    if let Some(state) = &data.state {
368                        qp.append_pair("state", state);
369                    }
370                }
371                let loc = url.to_string();
372                return HttpResponse::Found()
373                    .append_header(("Location", loc))
374                    .finish();
375            }
376
377            let status = match data.error.as_str() {
378                "invalid_client" => StatusCode::UNAUTHORIZED,     // 401
379                "invalid_token" => StatusCode::UNAUTHORIZED,      // 401 (bearer)
380                "invalid_dpop_proof" => StatusCode::UNAUTHORIZED, // 401 (dpop)
381                "use_dpop_nonce" => StatusCode::UNAUTHORIZED,     // 401 (dpop)
382                "insufficient_scope" => StatusCode::FORBIDDEN,    // 403
383                _ => StatusCode::BAD_REQUEST,
384            };
385
386            let mut res = HttpResponse::build(status);
387            // Small helper to safely embed values in WWW-Authenticate auth-param strings.
388            fn escape_auth_param(s: &str) -> String {
389                s.replace('\\', "\\\\").replace('"', "\\\"")
390            }
391
392            match data.error.as_str() {
393                // OAuth2 Bearer challenges (RFC 6750 §3)
394                "invalid_client" | "invalid_token" | "insufficient_scope" | "invalid_request" => {
395                    let err = escape_auth_param(&data.error);
396                    let desc = escape_auth_param(&data.error_description);
397                    let hdr = format!(r#"Bearer error="{}", error_description="{}""#, err, desc);
398                    res.append_header(("WWW-Authenticate", hdr));
399                }
400
401                // DPoP auth challenges (RFC 9449 §12.2)
402                "invalid_dpop_proof" => {
403                    let err = escape_auth_param(&data.error);
404                    let desc = escape_auth_param(&data.error_description);
405                    let hdr = format!(r#"DPoP error="{}", error_description="{}""#, err, desc);
406                    res.append_header(("WWW-Authenticate", hdr));
407                }
408
409                "use_dpop_nonce" => {
410                    let err = escape_auth_param(&data.error);
411                    let desc = escape_auth_param(&data.error_description);
412                    let hdr = format!(r#"DPoP error="{}", error_description="{}""#, err, desc);
413                    res.append_header(("WWW-Authenticate", hdr));
414
415                    // Provide the server-generated nonce (clients must echo it in the next proof)
416                    if let Some(nonce) = &data.nonce {
417                        res.append_header(("DPoP-Nonce", nonce.clone()));
418                    }
419                }
420
421                _ => {}
422            }
423
424            // Prevent caching per RFC 6749 §5.1 (common practice for error responses too)
425            res.append_header(("Cache-Control", "no-store"))
426                .append_header(("Pragma", "no-cache"));
427
428            // OAuth token/introspection semantics are standardized around `error` and
429            // `error_description`; keep compatibility for protocol clients.
430            return res.json(serde_json::json!({
431                "error": data.error,
432                "error_description": data.error_description
433            }));
434        }
435
436        let status = self.status_code();
437
438        let metadata = match &self.error_type {
439            ControllerErrorType::BadRequestWithData(data) => Some(data.clone()),
440            _ => None,
441        };
442
443        let metadata_json =
444            metadata.map(|ErrorMetadata::BlockId(id)| serde_json::json!({ "block_id": id }));
445        let (error_type, message_key) = self.error_type_and_message_key();
446        let errors = self.validation_issues();
447        let message = Some(self.message.clone());
448
449        let error_response = ApiErrorResponse {
450            error_type: Some(error_type.to_string()),
451            message_key: Some(message_key.to_string()),
452            message,
453            errors,
454            metadata: metadata_json,
455        };
456
457        HttpResponseBuilder::new(status)
458            .append_header(ContentType::json())
459            .body(serde_json::to_string(&error_response).unwrap_or_else(|e| {
460                error!("Error while serialising error response: {e}");
461                r#"{"type":"internal_error","message_key":"internal_error"}"#.to_string()
462            }))
463    }
464
465    fn status_code(&self) -> StatusCode {
466        match self.error_type {
467            ControllerErrorType::InternalServerError => StatusCode::INTERNAL_SERVER_ERROR,
468            ControllerErrorType::BadRequest => StatusCode::UNPROCESSABLE_ENTITY,
469            ControllerErrorType::BadRequestWithData(_) => StatusCode::UNPROCESSABLE_ENTITY,
470            ControllerErrorType::NotFound => StatusCode::NOT_FOUND,
471            ControllerErrorType::Unauthorized => StatusCode::UNAUTHORIZED,
472            ControllerErrorType::UnauthorizedWithReason(_) => StatusCode::UNAUTHORIZED,
473            ControllerErrorType::Forbidden => StatusCode::FORBIDDEN,
474            ControllerErrorType::OAuthError(_) => StatusCode::OK,
475        }
476    }
477}
478
479impl ControllerError {
480    fn error_type_and_message_key(&self) -> (&'static str, &'static str) {
481        match self.error_type {
482            ControllerErrorType::InternalServerError => ("internal_error", "internal_error"),
483            ControllerErrorType::BadRequest => ("validation_error", "validation_error"),
484            ControllerErrorType::BadRequestWithData(_) => {
485                ("validation_error", "validation_error_with_metadata")
486            }
487            ControllerErrorType::NotFound => ("not_found", "not_found"),
488            ControllerErrorType::Unauthorized => ("unauthorized", "unauthorized"),
489            ControllerErrorType::UnauthorizedWithReason(reason) => {
490                ("unauthorized", reason.message_key())
491            }
492            ControllerErrorType::Forbidden => ("forbidden", "forbidden"),
493            ControllerErrorType::OAuthError(_) => ("oauth_error", "oauth_error"),
494        }
495    }
496
497    /// Derives issue-level validation details from known backend validation cases.
498    fn validation_issues(&self) -> Vec<ApiErrorIssue> {
499        match &self.error_type {
500            ControllerErrorType::BadRequestWithData(_)
501                if self.message == MISSING_EXERCISE_TYPE_DESCRIPTION =>
502            {
503                vec![ApiErrorIssue {
504                    path: Some("exercise_type".to_string()),
505                    code: Some(ValidationIssueCode::MissingExerciseType.as_api_code()),
506                    message: self.message.clone(),
507                }]
508            }
509            _ => Vec::new(),
510        }
511    }
512}
513
514#[derive(Debug, Serialize, Deserialize, Clone)]
515#[serde(rename_all = "snake_case")]
516pub struct OAuthErrorData {
517    pub error: String,
518    pub error_description: String,
519    pub redirect_uri: Option<String>,
520    pub state: Option<String>,
521    pub nonce: Option<String>,
522}
523
524pub enum OAuthErrorCode {
525    InvalidGrant,
526    InvalidRequest,
527    InvalidClient,
528    InvalidToken,
529    InsufficientScope,
530    UnsupportedGrantType,
531    UnsupportedResponseType,
532    ServerError,
533    InvalidDpopProof,
534    UseDpopNonce,
535}
536
537impl OAuthErrorCode {
538    pub fn as_str(&self) -> &'static str {
539        match self {
540            Self::InvalidGrant => "invalid_grant",
541            Self::InvalidRequest => "invalid_request",
542            Self::InvalidClient => "invalid_client",
543            Self::InvalidToken => "invalid_token",
544            Self::InsufficientScope => "insufficient_scope",
545            Self::UnsupportedGrantType => "unsupported_grant_type",
546            Self::UnsupportedResponseType => "unsupported_response_type",
547            Self::ServerError => "server_error",
548            Self::InvalidDpopProof => "invalid_dpop_proof",
549            Self::UseDpopNonce => "use_dpop_nonce",
550        }
551    }
552}
553
554impl From<anyhow::Error> for ControllerError {
555    fn from(err: anyhow::Error) -> ControllerError {
556        if let Some(sqlx::Error::RowNotFound) = err.downcast_ref::<sqlx::Error>() {
557            return Self::new(ControllerErrorType::NotFound, err.to_string(), Some(err));
558        }
559
560        Self::new(
561            ControllerErrorType::InternalServerError,
562            err.to_string(),
563            Some(err),
564        )
565    }
566}
567
568impl From<uuid::Error> for ControllerError {
569    fn from(err: uuid::Error) -> ControllerError {
570        Self::new(
571            ControllerErrorType::BadRequest,
572            err.to_string(),
573            Some(err.into()),
574        )
575    }
576}
577
578impl From<sqlx::Error> for ControllerError {
579    fn from(err: sqlx::Error) -> ControllerError {
580        Self::new(
581            ControllerErrorType::InternalServerError,
582            err.to_string(),
583            Some(err.into()),
584        )
585    }
586}
587
588impl From<git2::Error> for ControllerError {
589    fn from(err: git2::Error) -> ControllerError {
590        Self::new(
591            ControllerErrorType::InternalServerError,
592            err.to_string(),
593            Some(err.into()),
594        )
595    }
596}
597
598impl From<actix_web::Error> for ControllerError {
599    fn from(err: actix_web::Error) -> Self {
600        Self::new(
601            ControllerErrorType::InternalServerError,
602            err.to_string(),
603            None,
604        )
605    }
606}
607
608impl From<actix_multipart::MultipartError> for ControllerError {
609    fn from(err: actix_multipart::MultipartError) -> Self {
610        Self::new(
611            ControllerErrorType::InternalServerError,
612            err.to_string(),
613            None,
614        )
615    }
616}
617
618impl From<jsonwebtoken::errors::Error> for ControllerError {
619    fn from(err: jsonwebtoken::errors::Error) -> Self {
620        Self::new(
621            ControllerErrorType::InternalServerError,
622            err.to_string(),
623            None,
624        )
625    }
626}
627
628impl From<ModelError> for ControllerError {
629    fn from(err: ModelError) -> Self {
630        let backtrace: Backtrace =
631            match headless_lms_base::error::backend_error::BackendError::backtrace(&err) {
632                Some(backtrace) => backtrace.clone(),
633                _ => Backtrace::new(),
634            };
635        let span_trace = err.span_trace().clone();
636        match err.error_type() {
637            ModelErrorType::RecordNotFound => Self::new_with_traces(
638                ControllerErrorType::NotFound,
639                err.to_string(),
640                Some(err.into()),
641                backtrace,
642                span_trace,
643            ),
644            ModelErrorType::NotFound => Self::new_with_traces(
645                ControllerErrorType::NotFound,
646                err.to_string(),
647                Some(err.into()),
648                backtrace,
649                span_trace,
650            ),
651            ModelErrorType::PreconditionFailed => Self::new_with_traces(
652                ControllerErrorType::BadRequest,
653                err.message().to_string(),
654                Some(err.into()),
655                backtrace,
656                span_trace,
657            ),
658            ModelErrorType::PreconditionFailedWithCMSAnchorBlockId { description, id } => {
659                Self::new_with_traces(
660                    ControllerErrorType::BadRequestWithData(ErrorMetadata::BlockId(*id)),
661                    description.to_string(),
662                    Some(err.into()),
663                    backtrace,
664                    span_trace,
665                )
666            }
667            ModelErrorType::DatabaseConstraint { description, .. } => Self::new_with_traces(
668                ControllerErrorType::BadRequest,
669                description.to_string(),
670                Some(err.into()),
671                backtrace,
672                span_trace,
673            ),
674            ModelErrorType::InvalidRequest => Self::new_with_traces(
675                ControllerErrorType::BadRequest,
676                err.message().to_string(),
677                Some(err.into()),
678                backtrace,
679                span_trace,
680            ),
681            _ => Self::new_with_traces(
682                ControllerErrorType::InternalServerError,
683                err.to_string(),
684                Some(err.into()),
685                backtrace,
686                span_trace,
687            ),
688        }
689    }
690}
691
692impl From<UtilError> for ControllerError {
693    fn from(err: UtilError) -> Self {
694        let backtrace: Backtrace =
695            match headless_lms_base::error::backend_error::BackendError::backtrace(&err) {
696                Some(backtrace) => backtrace.clone(),
697                _ => Backtrace::new(),
698            };
699        let span_trace = err.span_trace().clone();
700        Self::new_with_traces(
701            ControllerErrorType::InternalServerError,
702            err.to_string(),
703            Some(err.into()),
704            backtrace,
705            span_trace,
706        )
707    }
708}
709
710impl From<serde_json::Error> for ControllerError {
711    fn from(err: serde_json::Error) -> Self {
712        Self::new(
713            ControllerErrorType::InternalServerError,
714            err.to_string(),
715            Some(err.into()),
716        )
717    }
718}
719
720impl From<base64::DecodeError> for ControllerError {
721    fn from(err: base64::DecodeError) -> Self {
722        Self::new(
723            ControllerErrorType::InternalServerError,
724            err.to_string(),
725            Some(err.into()),
726        )
727    }
728}
729
730impl From<std::string::FromUtf8Error> for ControllerError {
731    fn from(err: std::string::FromUtf8Error) -> Self {
732        Self::new(
733            ControllerErrorType::InternalServerError,
734            err.to_string(),
735            Some(err.into()),
736        )
737    }
738}
739
740impl From<pkcs8::spki::Error> for ControllerError {
741    fn from(err: pkcs8::spki::Error) -> Self {
742        Self::new(
743            ControllerErrorType::InternalServerError,
744            err.to_string(),
745            Some(err.into()),
746        )
747    }
748}
749
750impl From<dpop_verifier::error::DpopError> for ControllerError {
751    fn from(err: DpopError) -> Self {
752        let oauth_error = match &err {
753            DpopError::MultipleDpopHeaders
754            | DpopError::InvalidDpopHeader
755            | DpopError::MissingDpopHeader
756            | DpopError::MalformedJws
757            | DpopError::InvalidAlg(_)
758            | DpopError::UnsupportedAlg(_)
759            | DpopError::InvalidSignature
760            | DpopError::BadJwk(_)
761            | DpopError::MissingClaim(_)
762            | DpopError::InvalidMethod
763            | DpopError::HtmMismatch
764            | DpopError::MalformedHtu
765            | DpopError::HtuMismatch
766            | DpopError::AthMalformed
767            | DpopError::MissingAth
768            | DpopError::AthMismatch
769            | DpopError::FutureSkew
770            | DpopError::Stale
771            | DpopError::Replay
772            | DpopError::JtiTooLong
773            | DpopError::NonceMismatch
774            | DpopError::NonceStale
775            | DpopError::InvalidHmacConfig
776            | DpopError::MissingNonce => OAuthErrorData {
777                error: OAuthErrorCode::InvalidDpopProof.as_str().into(),
778                error_description: err.to_string(),
779                redirect_uri: None,
780                state: None,
781                nonce: None,
782            },
783
784            DpopError::Store(e) => OAuthErrorData {
785                error: OAuthErrorCode::ServerError.as_str().into(),
786                error_description: format!("DPoP storage error: {e}"),
787                redirect_uri: None,
788                state: None,
789                nonce: None,
790            },
791
792            DpopError::UseDpopNonce { nonce } => OAuthErrorData {
793                error: OAuthErrorCode::UseDpopNonce.as_str().into(), // per RFC 9449 §12.2
794                error_description: "Server requires DPoP nonce".into(),
795                redirect_uri: None,
796                state: None,
797                nonce: Some(nonce.clone()),
798            },
799        };
800
801        ControllerError::new(
802            ControllerErrorType::OAuthError(Box::new(oauth_error)),
803            err.to_string(),
804            Some(err.into()),
805        )
806    }
807}
808
809#[derive(Debug, thiserror::Error)]
810pub enum PkceFlowError {
811    /// Request is malformed or missing a required PKCE parameter
812    #[error("{0}")]
813    InvalidRequest(&'static str),
814
815    /// PKCE check failed (e.g., code_verifier doesn't match stored challenge)
816    #[error("{0}")]
817    InvalidGrant(&'static str),
818
819    /// Server-side (DB/state) problem
820    #[error("{0}")]
821    ServerError(&'static str),
822}
823
824impl From<PkceFlowError> for ControllerError {
825    fn from(err: PkceFlowError) -> Self {
826        let data = match &err {
827            PkceFlowError::InvalidRequest(msg) => OAuthErrorData {
828                error: OAuthErrorCode::InvalidRequest.as_str().into(),
829                error_description: (*msg).into(),
830                redirect_uri: None,
831                state: None,
832                nonce: None,
833            },
834            PkceFlowError::InvalidGrant(msg) => OAuthErrorData {
835                error: OAuthErrorCode::InvalidGrant.as_str().into(),
836                error_description: (*msg).into(),
837                redirect_uri: None,
838                state: None,
839                nonce: None,
840            },
841            PkceFlowError::ServerError(msg) => OAuthErrorData {
842                error: OAuthErrorCode::ServerError.as_str().into(),
843                error_description: (*msg).into(),
844                redirect_uri: None,
845                state: None,
846                nonce: None,
847            },
848        };
849
850        ControllerError::new(
851            ControllerErrorType::OAuthError(Box::new(data)),
852            err.to_string(),
853            Some(anyhow::anyhow!(err)),
854        )
855    }
856}
857
858impl From<crate::domain::oauth::pkce::PkceError> for PkceFlowError {
859    fn from(_err: crate::domain::oauth::pkce::PkceError) -> Self {
860        // Both BadLength and BadCharset are "invalid_request" per OAuth spec
861        PkceFlowError::InvalidRequest("invalid code_verifier")
862    }
863}
864
865impl From<crate::domain::oauth::pkce::PkceError> for ControllerError {
866    fn from(err: crate::domain::oauth::pkce::PkceError) -> Self {
867        PkceFlowError::from(err).into()
868    }
869}
870
871impl From<ChatbotError> for ControllerError {
872    fn from(err: ChatbotError) -> Self {
873        ControllerError::new(
874            ControllerErrorType::InternalServerError,
875            err.message().to_string(),
876            Some(err.into()),
877        )
878    }
879}
880
881// Generate error creation macros for ControllerError
882headless_lms_utils::define_err_macro!(
883    controller_err,
884    ControllerError,
885    ControllerErrorType,
886    ControllerErrorType,
887    "Create a ControllerError with less boilerplate."
888);
889
890/// Helper function for `.map_err()` chains to wrap any error as ControllerError.
891///
892/// This function creates a closure that converts any error into a `ControllerError`
893/// with the specified error type and message, including the original error as the source.
894///
895/// # Examples
896///
897/// ```ignore
898/// // Instead of:
899/// .map_err(|e| ControllerError::new(ControllerErrorType::BadRequest, e.to_string(), Some(e.into())))?
900///
901/// // You can write:
902/// .map_err(as_controller_error(ControllerErrorType::BadRequest, "Failed to process".to_string()))?
903/// ```
904pub fn as_controller_error<E>(
905    error_type: ControllerErrorType,
906    message: impl Into<String>,
907) -> impl FnOnce(E) -> ControllerError
908where
909    E: Into<anyhow::Error>,
910{
911    let msg = message.into();
912    move |e| ControllerError::new(error_type, msg, Some(e.into()))
913}
914
915/// Helper function for `.ok_or_else()` to create ControllerError on None.
916///
917/// This function creates a closure that generates a `ControllerError` with the
918/// specified error type and message when called.
919///
920/// # Examples
921///
922/// ```ignore
923/// // Instead of:
924/// .ok_or_else(|| ControllerError::new(ControllerErrorType::NotFound, "Item not found".to_string(), None))
925///
926/// // You can write:
927/// .ok_or_else(missing_controller_error(ControllerErrorType::NotFound, "Item not found".to_string()))
928/// ```
929pub fn missing_controller_error(
930    error_type: ControllerErrorType,
931    message: impl Into<String>,
932) -> impl FnOnce() -> ControllerError {
933    let msg = message.into();
934    move || ControllerError::new(error_type, msg, None)
935}
936
937#[cfg(test)]
938mod tests {
939    use super::*;
940    use actix_web::ResponseError;
941    use futures_util::FutureExt;
942
943    #[test]
944    fn test_controller_err_macro_without_source() {
945        let err = controller_err!(BadRequest, "Test error message".to_string());
946        assert_eq!(err.message(), "Test error message");
947        assert!(matches!(err.error_type(), ControllerErrorType::BadRequest));
948    }
949
950    #[test]
951    fn test_controller_err_macro_with_source() {
952        let source_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
953        let err = controller_err!(InternalServerError, "Wrapped error".to_string(), source_err);
954        assert_eq!(err.message(), "Wrapped error");
955    }
956
957    #[test]
958    fn test_controller_err_macro_tuple_variant_with_source() {
959        let source_err = std::io::Error::other("source");
960        let err = controller_err!(
961            UnauthorizedWithReason(UnauthorizedReason::ChapterNotOpenYet),
962            "Wrapped error".to_string(),
963            source_err
964        );
965        assert!(matches!(
966            err.error_type(),
967            ControllerErrorType::UnauthorizedWithReason(_)
968        ));
969    }
970
971    #[test]
972    fn test_as_controller_error_helper() {
973        let result: Result<(), std::io::Error> = Err(std::io::Error::new(
974            std::io::ErrorKind::NotFound,
975            "test error",
976        ));
977        let controller_result = result.map_err(as_controller_error(
978            ControllerErrorType::BadRequest,
979            "Invalid input".to_string(),
980        ));
981
982        assert!(controller_result.is_err());
983        let err = controller_result.unwrap_err();
984        assert_eq!(err.message(), "Invalid input");
985        assert!(matches!(err.error_type(), ControllerErrorType::BadRequest));
986    }
987
988    #[test]
989    fn test_missing_controller_error_helper() {
990        let option: Option<String> = None;
991        let result = option.ok_or_else(missing_controller_error(
992            ControllerErrorType::NotFound,
993            "Resource not found".to_string(),
994        ));
995
996        assert!(result.is_err());
997        let err = result.unwrap_err();
998        assert_eq!(err.message(), "Resource not found");
999        assert!(matches!(err.error_type(), ControllerErrorType::NotFound));
1000    }
1001
1002    #[test]
1003    fn test_controller_err_with_format() {
1004        let user_id = 42;
1005        let err = controller_err!(Unauthorized, format!("User {} is not authorized", user_id));
1006        assert_eq!(err.message(), "User 42 is not authorized");
1007    }
1008
1009    #[test]
1010    fn test_controller_err_all_variants() {
1011        // Test that macros work with all standard error type variants
1012        let _ = controller_err!(InternalServerError, "test".to_string());
1013        let _ = controller_err!(BadRequest, "test".to_string());
1014        let _ = controller_err!(NotFound, "test".to_string());
1015        let _ = controller_err!(Unauthorized, "test".to_string());
1016        let _ = controller_err!(
1017            UnauthorizedWithReason(UnauthorizedReason::ChapterNotOpenYet),
1018            "test".to_string()
1019        );
1020        let _ = controller_err!(
1021            BadRequestWithData(ErrorMetadata::BlockId(Uuid::nil())),
1022            "test".to_string()
1023        );
1024        let _ = controller_err!(Forbidden, "test".to_string());
1025    }
1026
1027    #[test]
1028    fn test_canonical_error_envelope_shape() {
1029        let err = controller_err!(BadRequest, "Validation failed".to_string());
1030        let response = err.error_response();
1031        assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
1032
1033        let bytes = actix_web::body::to_bytes(response.into_body())
1034            .now_or_never()
1035            .expect("response should resolve immediately")
1036            .expect("body bytes");
1037        let value: serde_json::Value = serde_json::from_slice(&bytes).expect("json");
1038        assert_eq!(value["type"], "validation_error");
1039        assert_eq!(value["message_key"], "validation_error");
1040        assert_eq!(value["message"], "Validation failed");
1041        assert!(value.get("status").is_none());
1042        assert!(value.get("request_id").is_none());
1043    }
1044
1045    #[test]
1046    fn test_validation_issue_code_is_serialized_for_missing_exercise_type() {
1047        let err = ControllerError::new(
1048            ControllerErrorType::BadRequestWithData(ErrorMetadata::BlockId(Uuid::nil())),
1049            MISSING_EXERCISE_TYPE_DESCRIPTION.to_string(),
1050            None,
1051        );
1052        let response = err.error_response();
1053        let bytes = actix_web::body::to_bytes(response.into_body())
1054            .now_or_never()
1055            .expect("response should resolve immediately")
1056            .expect("body bytes");
1057        let value: serde_json::Value = serde_json::from_slice(&bytes).expect("json");
1058
1059        assert_eq!(value["type"], "validation_error");
1060        assert_eq!(value["message_key"], "validation_error_with_metadata");
1061        assert_eq!(value["errors"][0]["code"], "missing_exercise_type");
1062        assert_eq!(value["errors"][0]["path"], "exercise_type");
1063    }
1064
1065    #[test]
1066    fn test_chapter_not_open_uses_dedicated_message_key() {
1067        let err = ControllerError::new(
1068            ControllerErrorType::UnauthorizedWithReason(UnauthorizedReason::ChapterNotOpenYet),
1069            "Chapter is not open yet.".to_string(),
1070            None,
1071        );
1072        let response = err.error_response();
1073        let bytes = actix_web::body::to_bytes(response.into_body())
1074            .now_or_never()
1075            .expect("response should resolve immediately")
1076            .expect("body bytes");
1077        let value: serde_json::Value = serde_json::from_slice(&bytes).expect("json");
1078
1079        assert_eq!(value["type"], "unauthorized");
1080        assert_eq!(value["message_key"], "chapter_not_open_yet");
1081        assert_eq!(value["message"], "Chapter is not open yet.");
1082    }
1083
1084    #[test]
1085    fn test_exam_exercise_auth_requirement_uses_dedicated_message_key() {
1086        let err = ControllerError::new(
1087            ControllerErrorType::UnauthorizedWithReason(
1088                UnauthorizedReason::AuthenticationRequiredForExamExercise,
1089            ),
1090            "User must be authenticated to view exam exercises".to_string(),
1091            None,
1092        );
1093        let response = err.error_response();
1094        let bytes = actix_web::body::to_bytes(response.into_body())
1095            .now_or_never()
1096            .expect("response should resolve immediately")
1097            .expect("body bytes");
1098        let value: serde_json::Value = serde_json::from_slice(&bytes).expect("json");
1099
1100        assert_eq!(value["type"], "unauthorized");
1101        assert_eq!(
1102            value["message_key"],
1103            "authentication_required_for_exam_exercise"
1104        );
1105        assert_eq!(
1106            value["message"],
1107            "User must be authenticated to view exam exercises"
1108        );
1109    }
1110
1111    #[test]
1112    fn test_generic_unauthorized_uses_unauthorized_message_key() {
1113        let err = ControllerError::new(
1114            ControllerErrorType::Unauthorized,
1115            "Unauthorized".to_string(),
1116            None,
1117        );
1118        let response = err.error_response();
1119        let bytes = actix_web::body::to_bytes(response.into_body())
1120            .now_or_never()
1121            .expect("response should resolve immediately")
1122            .expect("body bytes");
1123        let value: serde_json::Value = serde_json::from_slice(&bytes).expect("json");
1124
1125        assert_eq!(value["type"], "unauthorized");
1126        assert_eq!(value["message_key"], "unauthorized");
1127        assert_eq!(value["message"], "Unauthorized");
1128    }
1129}