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 = match self.error_type {
448            ControllerErrorType::InternalServerError => None,
449            _ => Some(self.message.clone()),
450        };
451
452        let error_response = ApiErrorResponse {
453            error_type: Some(error_type.to_string()),
454            message_key: Some(message_key.to_string()),
455            message,
456            errors,
457            metadata: metadata_json,
458        };
459
460        HttpResponseBuilder::new(status)
461            .append_header(ContentType::json())
462            .body(serde_json::to_string(&error_response).unwrap_or_else(|_| {
463                r#"{"type":"internal_error","message_key":"internal_error"}"#.to_string()
464            }))
465    }
466
467    fn status_code(&self) -> StatusCode {
468        match self.error_type {
469            ControllerErrorType::InternalServerError => StatusCode::INTERNAL_SERVER_ERROR,
470            ControllerErrorType::BadRequest => StatusCode::UNPROCESSABLE_ENTITY,
471            ControllerErrorType::BadRequestWithData(_) => StatusCode::UNPROCESSABLE_ENTITY,
472            ControllerErrorType::NotFound => StatusCode::NOT_FOUND,
473            ControllerErrorType::Unauthorized => StatusCode::UNAUTHORIZED,
474            ControllerErrorType::UnauthorizedWithReason(_) => StatusCode::UNAUTHORIZED,
475            ControllerErrorType::Forbidden => StatusCode::FORBIDDEN,
476            ControllerErrorType::OAuthError(_) => StatusCode::OK,
477        }
478    }
479}
480
481impl ControllerError {
482    fn error_type_and_message_key(&self) -> (&'static str, &'static str) {
483        match self.error_type {
484            ControllerErrorType::InternalServerError => ("internal_error", "internal_error"),
485            ControllerErrorType::BadRequest => ("validation_error", "validation_error"),
486            ControllerErrorType::BadRequestWithData(_) => {
487                ("validation_error", "validation_error_with_metadata")
488            }
489            ControllerErrorType::NotFound => ("not_found", "not_found"),
490            ControllerErrorType::Unauthorized => ("unauthorized", "unauthorized"),
491            ControllerErrorType::UnauthorizedWithReason(reason) => {
492                ("unauthorized", reason.message_key())
493            }
494            ControllerErrorType::Forbidden => ("forbidden", "forbidden"),
495            ControllerErrorType::OAuthError(_) => ("oauth_error", "oauth_error"),
496        }
497    }
498
499    /// Derives issue-level validation details from known backend validation cases.
500    fn validation_issues(&self) -> Vec<ApiErrorIssue> {
501        match &self.error_type {
502            ControllerErrorType::BadRequestWithData(_)
503                if self.message == MISSING_EXERCISE_TYPE_DESCRIPTION =>
504            {
505                vec![ApiErrorIssue {
506                    path: Some("exercise_type".to_string()),
507                    code: Some(ValidationIssueCode::MissingExerciseType.as_api_code()),
508                    message: self.message.clone(),
509                }]
510            }
511            _ => Vec::new(),
512        }
513    }
514}
515
516#[derive(Debug, Serialize, Deserialize, Clone)]
517#[serde(rename_all = "snake_case")]
518pub struct OAuthErrorData {
519    pub error: String,
520    pub error_description: String,
521    pub redirect_uri: Option<String>,
522    pub state: Option<String>,
523    pub nonce: Option<String>,
524}
525
526pub enum OAuthErrorCode {
527    InvalidGrant,
528    InvalidRequest,
529    InvalidClient,
530    InvalidToken,
531    InsufficientScope,
532    UnsupportedGrantType,
533    UnsupportedResponseType,
534    ServerError,
535    InvalidDpopProof,
536    UseDpopNonce,
537}
538
539impl OAuthErrorCode {
540    pub fn as_str(&self) -> &'static str {
541        match self {
542            Self::InvalidGrant => "invalid_grant",
543            Self::InvalidRequest => "invalid_request",
544            Self::InvalidClient => "invalid_client",
545            Self::InvalidToken => "invalid_token",
546            Self::InsufficientScope => "insufficient_scope",
547            Self::UnsupportedGrantType => "unsupported_grant_type",
548            Self::UnsupportedResponseType => "unsupported_response_type",
549            Self::ServerError => "server_error",
550            Self::InvalidDpopProof => "invalid_dpop_proof",
551            Self::UseDpopNonce => "use_dpop_nonce",
552        }
553    }
554}
555
556impl From<anyhow::Error> for ControllerError {
557    fn from(err: anyhow::Error) -> ControllerError {
558        if let Some(sqlx::Error::RowNotFound) = err.downcast_ref::<sqlx::Error>() {
559            return Self::new(ControllerErrorType::NotFound, err.to_string(), Some(err));
560        }
561
562        Self::new(
563            ControllerErrorType::InternalServerError,
564            err.to_string(),
565            Some(err),
566        )
567    }
568}
569
570impl From<uuid::Error> for ControllerError {
571    fn from(err: uuid::Error) -> ControllerError {
572        Self::new(
573            ControllerErrorType::BadRequest,
574            err.to_string(),
575            Some(err.into()),
576        )
577    }
578}
579
580impl From<sqlx::Error> for ControllerError {
581    fn from(err: sqlx::Error) -> ControllerError {
582        Self::new(
583            ControllerErrorType::InternalServerError,
584            err.to_string(),
585            Some(err.into()),
586        )
587    }
588}
589
590impl From<git2::Error> for ControllerError {
591    fn from(err: git2::Error) -> ControllerError {
592        Self::new(
593            ControllerErrorType::InternalServerError,
594            err.to_string(),
595            Some(err.into()),
596        )
597    }
598}
599
600impl From<actix_web::Error> for ControllerError {
601    fn from(err: actix_web::Error) -> Self {
602        Self::new(
603            ControllerErrorType::InternalServerError,
604            err.to_string(),
605            None,
606        )
607    }
608}
609
610impl From<actix_multipart::MultipartError> for ControllerError {
611    fn from(err: actix_multipart::MultipartError) -> Self {
612        Self::new(
613            ControllerErrorType::InternalServerError,
614            err.to_string(),
615            None,
616        )
617    }
618}
619
620impl From<jsonwebtoken::errors::Error> for ControllerError {
621    fn from(err: jsonwebtoken::errors::Error) -> Self {
622        Self::new(
623            ControllerErrorType::InternalServerError,
624            err.to_string(),
625            None,
626        )
627    }
628}
629
630impl From<ModelError> for ControllerError {
631    fn from(err: ModelError) -> Self {
632        let backtrace: Backtrace =
633            match headless_lms_base::error::backend_error::BackendError::backtrace(&err) {
634                Some(backtrace) => backtrace.clone(),
635                _ => Backtrace::new(),
636            };
637        let span_trace = err.span_trace().clone();
638        match err.error_type() {
639            ModelErrorType::RecordNotFound => Self::new_with_traces(
640                ControllerErrorType::NotFound,
641                err.to_string(),
642                Some(err.into()),
643                backtrace,
644                span_trace,
645            ),
646            ModelErrorType::NotFound => Self::new_with_traces(
647                ControllerErrorType::NotFound,
648                err.to_string(),
649                Some(err.into()),
650                backtrace,
651                span_trace,
652            ),
653            ModelErrorType::PreconditionFailed => Self::new_with_traces(
654                ControllerErrorType::BadRequest,
655                err.message().to_string(),
656                Some(err.into()),
657                backtrace,
658                span_trace,
659            ),
660            ModelErrorType::PreconditionFailedWithCMSAnchorBlockId { description, id } => {
661                Self::new_with_traces(
662                    ControllerErrorType::BadRequestWithData(ErrorMetadata::BlockId(*id)),
663                    description.to_string(),
664                    Some(err.into()),
665                    backtrace,
666                    span_trace,
667                )
668            }
669            ModelErrorType::DatabaseConstraint { description, .. } => Self::new_with_traces(
670                ControllerErrorType::BadRequest,
671                description.to_string(),
672                Some(err.into()),
673                backtrace,
674                span_trace,
675            ),
676            ModelErrorType::InvalidRequest => Self::new_with_traces(
677                ControllerErrorType::BadRequest,
678                err.message().to_string(),
679                Some(err.into()),
680                backtrace,
681                span_trace,
682            ),
683            _ => Self::new_with_traces(
684                ControllerErrorType::InternalServerError,
685                err.to_string(),
686                Some(err.into()),
687                backtrace,
688                span_trace,
689            ),
690        }
691    }
692}
693
694impl From<UtilError> for ControllerError {
695    fn from(err: UtilError) -> Self {
696        let backtrace: Backtrace =
697            match headless_lms_base::error::backend_error::BackendError::backtrace(&err) {
698                Some(backtrace) => backtrace.clone(),
699                _ => Backtrace::new(),
700            };
701        let span_trace = err.span_trace().clone();
702        Self::new_with_traces(
703            ControllerErrorType::InternalServerError,
704            err.to_string(),
705            Some(err.into()),
706            backtrace,
707            span_trace,
708        )
709    }
710}
711
712impl From<serde_json::Error> for ControllerError {
713    fn from(err: serde_json::Error) -> Self {
714        Self::new(
715            ControllerErrorType::InternalServerError,
716            err.to_string(),
717            Some(err.into()),
718        )
719    }
720}
721
722impl From<base64::DecodeError> for ControllerError {
723    fn from(err: base64::DecodeError) -> Self {
724        Self::new(
725            ControllerErrorType::InternalServerError,
726            err.to_string(),
727            Some(err.into()),
728        )
729    }
730}
731
732impl From<std::string::FromUtf8Error> for ControllerError {
733    fn from(err: std::string::FromUtf8Error) -> Self {
734        Self::new(
735            ControllerErrorType::InternalServerError,
736            err.to_string(),
737            Some(err.into()),
738        )
739    }
740}
741
742impl From<pkcs8::spki::Error> for ControllerError {
743    fn from(err: pkcs8::spki::Error) -> Self {
744        Self::new(
745            ControllerErrorType::InternalServerError,
746            err.to_string(),
747            Some(err.into()),
748        )
749    }
750}
751
752impl From<dpop_verifier::error::DpopError> for ControllerError {
753    fn from(err: DpopError) -> Self {
754        let oauth_error = match &err {
755            DpopError::MultipleDpopHeaders
756            | DpopError::InvalidDpopHeader
757            | DpopError::MissingDpopHeader
758            | DpopError::MalformedJws
759            | DpopError::InvalidAlg(_)
760            | DpopError::UnsupportedAlg(_)
761            | DpopError::InvalidSignature
762            | DpopError::BadJwk(_)
763            | DpopError::MissingClaim(_)
764            | DpopError::InvalidMethod
765            | DpopError::HtmMismatch
766            | DpopError::MalformedHtu
767            | DpopError::HtuMismatch
768            | DpopError::AthMalformed
769            | DpopError::MissingAth
770            | DpopError::AthMismatch
771            | DpopError::FutureSkew
772            | DpopError::Stale
773            | DpopError::Replay
774            | DpopError::JtiTooLong
775            | DpopError::NonceMismatch
776            | DpopError::NonceStale
777            | DpopError::InvalidHmacConfig
778            | DpopError::MissingNonce => OAuthErrorData {
779                error: OAuthErrorCode::InvalidDpopProof.as_str().into(),
780                error_description: err.to_string(),
781                redirect_uri: None,
782                state: None,
783                nonce: None,
784            },
785
786            DpopError::Store(e) => OAuthErrorData {
787                error: OAuthErrorCode::ServerError.as_str().into(),
788                error_description: format!("DPoP storage error: {e}"),
789                redirect_uri: None,
790                state: None,
791                nonce: None,
792            },
793
794            DpopError::UseDpopNonce { nonce } => OAuthErrorData {
795                error: OAuthErrorCode::UseDpopNonce.as_str().into(), // per RFC 9449 §12.2
796                error_description: "Server requires DPoP nonce".into(),
797                redirect_uri: None,
798                state: None,
799                nonce: Some(nonce.clone()),
800            },
801        };
802
803        ControllerError::new(
804            ControllerErrorType::OAuthError(Box::new(oauth_error)),
805            err.to_string(),
806            Some(err.into()),
807        )
808    }
809}
810
811#[derive(Debug, thiserror::Error)]
812pub enum PkceFlowError {
813    /// Request is malformed or missing a required PKCE parameter
814    #[error("{0}")]
815    InvalidRequest(&'static str),
816
817    /// PKCE check failed (e.g., code_verifier doesn't match stored challenge)
818    #[error("{0}")]
819    InvalidGrant(&'static str),
820
821    /// Server-side (DB/state) problem
822    #[error("{0}")]
823    ServerError(&'static str),
824}
825
826impl From<PkceFlowError> for ControllerError {
827    fn from(err: PkceFlowError) -> Self {
828        let data = match &err {
829            PkceFlowError::InvalidRequest(msg) => OAuthErrorData {
830                error: OAuthErrorCode::InvalidRequest.as_str().into(),
831                error_description: (*msg).into(),
832                redirect_uri: None,
833                state: None,
834                nonce: None,
835            },
836            PkceFlowError::InvalidGrant(msg) => OAuthErrorData {
837                error: OAuthErrorCode::InvalidGrant.as_str().into(),
838                error_description: (*msg).into(),
839                redirect_uri: None,
840                state: None,
841                nonce: None,
842            },
843            PkceFlowError::ServerError(msg) => OAuthErrorData {
844                error: OAuthErrorCode::ServerError.as_str().into(),
845                error_description: (*msg).into(),
846                redirect_uri: None,
847                state: None,
848                nonce: None,
849            },
850        };
851
852        ControllerError::new(
853            ControllerErrorType::OAuthError(Box::new(data)),
854            err.to_string(),
855            Some(anyhow::anyhow!(err)),
856        )
857    }
858}
859
860impl From<crate::domain::oauth::pkce::PkceError> for PkceFlowError {
861    fn from(_err: crate::domain::oauth::pkce::PkceError) -> Self {
862        // Both BadLength and BadCharset are "invalid_request" per OAuth spec
863        PkceFlowError::InvalidRequest("invalid code_verifier")
864    }
865}
866
867impl From<crate::domain::oauth::pkce::PkceError> for ControllerError {
868    fn from(err: crate::domain::oauth::pkce::PkceError) -> Self {
869        PkceFlowError::from(err).into()
870    }
871}
872
873impl From<ChatbotError> for ControllerError {
874    fn from(err: ChatbotError) -> Self {
875        ControllerError::new(
876            ControllerErrorType::InternalServerError,
877            err.message().to_string(),
878            Some(err.into()),
879        )
880    }
881}
882
883// Generate error creation macros for ControllerError
884headless_lms_utils::define_err_macro!(
885    controller_err,
886    ControllerError,
887    ControllerErrorType,
888    ControllerErrorType,
889    "Create a ControllerError with less boilerplate."
890);
891
892/// Helper function for `.map_err()` chains to wrap any error as ControllerError.
893///
894/// This function creates a closure that converts any error into a `ControllerError`
895/// with the specified error type and message, including the original error as the source.
896///
897/// # Examples
898///
899/// ```ignore
900/// // Instead of:
901/// .map_err(|e| ControllerError::new(ControllerErrorType::BadRequest, e.to_string(), Some(e.into())))?
902///
903/// // You can write:
904/// .map_err(as_controller_error(ControllerErrorType::BadRequest, "Failed to process".to_string()))?
905/// ```
906pub fn as_controller_error<E>(
907    error_type: ControllerErrorType,
908    message: impl Into<String>,
909) -> impl FnOnce(E) -> ControllerError
910where
911    E: Into<anyhow::Error>,
912{
913    let msg = message.into();
914    move |e| ControllerError::new(error_type, msg, Some(e.into()))
915}
916
917/// Helper function for `.ok_or_else()` to create ControllerError on None.
918///
919/// This function creates a closure that generates a `ControllerError` with the
920/// specified error type and message when called.
921///
922/// # Examples
923///
924/// ```ignore
925/// // Instead of:
926/// .ok_or_else(|| ControllerError::new(ControllerErrorType::NotFound, "Item not found".to_string(), None))
927///
928/// // You can write:
929/// .ok_or_else(missing_controller_error(ControllerErrorType::NotFound, "Item not found".to_string()))
930/// ```
931pub fn missing_controller_error(
932    error_type: ControllerErrorType,
933    message: impl Into<String>,
934) -> impl FnOnce() -> ControllerError {
935    let msg = message.into();
936    move || ControllerError::new(error_type, msg, None)
937}
938
939#[cfg(test)]
940mod tests {
941    use super::*;
942    use actix_web::ResponseError;
943    use futures_util::FutureExt;
944
945    #[test]
946    fn test_controller_err_macro_without_source() {
947        let err = controller_err!(BadRequest, "Test error message".to_string());
948        assert_eq!(err.message(), "Test error message");
949        assert!(matches!(err.error_type(), ControllerErrorType::BadRequest));
950    }
951
952    #[test]
953    fn test_controller_err_macro_with_source() {
954        let source_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
955        let err = controller_err!(InternalServerError, "Wrapped error".to_string(), source_err);
956        assert_eq!(err.message(), "Wrapped error");
957    }
958
959    #[test]
960    fn test_controller_err_macro_tuple_variant_with_source() {
961        let source_err = std::io::Error::other("source");
962        let err = controller_err!(
963            UnauthorizedWithReason(UnauthorizedReason::ChapterNotOpenYet),
964            "Wrapped error".to_string(),
965            source_err
966        );
967        assert!(matches!(
968            err.error_type(),
969            ControllerErrorType::UnauthorizedWithReason(_)
970        ));
971    }
972
973    #[test]
974    fn test_as_controller_error_helper() {
975        let result: Result<(), std::io::Error> = Err(std::io::Error::new(
976            std::io::ErrorKind::NotFound,
977            "test error",
978        ));
979        let controller_result = result.map_err(as_controller_error(
980            ControllerErrorType::BadRequest,
981            "Invalid input".to_string(),
982        ));
983
984        assert!(controller_result.is_err());
985        let err = controller_result.unwrap_err();
986        assert_eq!(err.message(), "Invalid input");
987        assert!(matches!(err.error_type(), ControllerErrorType::BadRequest));
988    }
989
990    #[test]
991    fn test_missing_controller_error_helper() {
992        let option: Option<String> = None;
993        let result = option.ok_or_else(missing_controller_error(
994            ControllerErrorType::NotFound,
995            "Resource not found".to_string(),
996        ));
997
998        assert!(result.is_err());
999        let err = result.unwrap_err();
1000        assert_eq!(err.message(), "Resource not found");
1001        assert!(matches!(err.error_type(), ControllerErrorType::NotFound));
1002    }
1003
1004    #[test]
1005    fn test_controller_err_with_format() {
1006        let user_id = 42;
1007        let err = controller_err!(Unauthorized, format!("User {} is not authorized", user_id));
1008        assert_eq!(err.message(), "User 42 is not authorized");
1009    }
1010
1011    #[test]
1012    fn test_controller_err_all_variants() {
1013        // Test that macros work with all standard error type variants
1014        let _ = controller_err!(InternalServerError, "test".to_string());
1015        let _ = controller_err!(BadRequest, "test".to_string());
1016        let _ = controller_err!(NotFound, "test".to_string());
1017        let _ = controller_err!(Unauthorized, "test".to_string());
1018        let _ = controller_err!(
1019            UnauthorizedWithReason(UnauthorizedReason::ChapterNotOpenYet),
1020            "test".to_string()
1021        );
1022        let _ = controller_err!(
1023            BadRequestWithData(ErrorMetadata::BlockId(Uuid::nil())),
1024            "test".to_string()
1025        );
1026        let _ = controller_err!(Forbidden, "test".to_string());
1027    }
1028
1029    #[test]
1030    fn test_canonical_error_envelope_shape() {
1031        let err = controller_err!(BadRequest, "Validation failed".to_string());
1032        let response = err.error_response();
1033        assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
1034
1035        let bytes = actix_web::body::to_bytes(response.into_body())
1036            .now_or_never()
1037            .expect("response should resolve immediately")
1038            .expect("body bytes");
1039        let value: serde_json::Value = serde_json::from_slice(&bytes).expect("json");
1040        assert_eq!(value["type"], "validation_error");
1041        assert_eq!(value["message_key"], "validation_error");
1042        assert_eq!(value["message"], "Validation failed");
1043        assert!(value.get("status").is_none());
1044        assert!(value.get("request_id").is_none());
1045    }
1046
1047    #[test]
1048    fn test_validation_issue_code_is_serialized_for_missing_exercise_type() {
1049        let err = ControllerError::new(
1050            ControllerErrorType::BadRequestWithData(ErrorMetadata::BlockId(Uuid::nil())),
1051            MISSING_EXERCISE_TYPE_DESCRIPTION.to_string(),
1052            None,
1053        );
1054        let response = err.error_response();
1055        let bytes = actix_web::body::to_bytes(response.into_body())
1056            .now_or_never()
1057            .expect("response should resolve immediately")
1058            .expect("body bytes");
1059        let value: serde_json::Value = serde_json::from_slice(&bytes).expect("json");
1060
1061        assert_eq!(value["type"], "validation_error");
1062        assert_eq!(value["message_key"], "validation_error_with_metadata");
1063        assert_eq!(value["errors"][0]["code"], "missing_exercise_type");
1064        assert_eq!(value["errors"][0]["path"], "exercise_type");
1065    }
1066
1067    #[test]
1068    fn test_chapter_not_open_uses_dedicated_message_key() {
1069        let err = ControllerError::new(
1070            ControllerErrorType::UnauthorizedWithReason(UnauthorizedReason::ChapterNotOpenYet),
1071            "Chapter is not open yet.".to_string(),
1072            None,
1073        );
1074        let response = err.error_response();
1075        let bytes = actix_web::body::to_bytes(response.into_body())
1076            .now_or_never()
1077            .expect("response should resolve immediately")
1078            .expect("body bytes");
1079        let value: serde_json::Value = serde_json::from_slice(&bytes).expect("json");
1080
1081        assert_eq!(value["type"], "unauthorized");
1082        assert_eq!(value["message_key"], "chapter_not_open_yet");
1083        assert_eq!(value["message"], "Chapter is not open yet.");
1084    }
1085
1086    #[test]
1087    fn test_exam_exercise_auth_requirement_uses_dedicated_message_key() {
1088        let err = ControllerError::new(
1089            ControllerErrorType::UnauthorizedWithReason(
1090                UnauthorizedReason::AuthenticationRequiredForExamExercise,
1091            ),
1092            "User must be authenticated to view exam exercises".to_string(),
1093            None,
1094        );
1095        let response = err.error_response();
1096        let bytes = actix_web::body::to_bytes(response.into_body())
1097            .now_or_never()
1098            .expect("response should resolve immediately")
1099            .expect("body bytes");
1100        let value: serde_json::Value = serde_json::from_slice(&bytes).expect("json");
1101
1102        assert_eq!(value["type"], "unauthorized");
1103        assert_eq!(
1104            value["message_key"],
1105            "authentication_required_for_exam_exercise"
1106        );
1107        assert_eq!(
1108            value["message"],
1109            "User must be authenticated to view exam exercises"
1110        );
1111    }
1112
1113    #[test]
1114    fn test_generic_unauthorized_uses_unauthorized_message_key() {
1115        let err = ControllerError::new(
1116            ControllerErrorType::Unauthorized,
1117            "Unauthorized".to_string(),
1118            None,
1119        );
1120        let response = err.error_response();
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
1127        assert_eq!(value["type"], "unauthorized");
1128        assert_eq!(value["message_key"], "unauthorized");
1129        assert_eq!(value["message"], "Unauthorized");
1130    }
1131}