Skip to main content

platform_http/
errors.rs

1use axum::Json;
2use axum::http::{HeaderValue, StatusCode, header};
3use axum::response::{IntoResponse, Response};
4use platform_core::{AppError, ErrorCode, RequestContext};
5use serde::Serialize;
6use utoipa::ToSchema;
7
8#[derive(Debug, Serialize, ToSchema)]
9#[schema(as = ErrorResponse)]
10pub struct ProblemDetails {
11    #[serde(rename = "type")]
12    pub problem_type: String,
13    pub title: String,
14    #[schema(minimum = 100, maximum = 599)]
15    pub status: u16,
16    pub detail: String,
17    pub code: String,
18    pub request_id: Option<String>,
19    pub correlation_id: Option<String>,
20    pub errors: Vec<ProblemErrorDetail>,
21}
22
23#[derive(Debug, Serialize, ToSchema)]
24pub struct ProblemErrorDetail {
25    pub field: Option<String>,
26    pub reason: String,
27}
28
29pub trait IntoApiError {
30    fn status_code(&self) -> StatusCode;
31}
32
33impl IntoApiError for AppError {
34    fn status_code(&self) -> StatusCode {
35        match self.code {
36            ErrorCode::Validation => StatusCode::BAD_REQUEST,
37            ErrorCode::Unauthorized => StatusCode::UNAUTHORIZED,
38            ErrorCode::Forbidden => StatusCode::FORBIDDEN,
39            ErrorCode::NotFound => StatusCode::NOT_FOUND,
40            ErrorCode::Conflict => StatusCode::CONFLICT,
41            ErrorCode::RateLimited => StatusCode::TOO_MANY_REQUESTS,
42            ErrorCode::ExternalDependency => StatusCode::BAD_GATEWAY,
43            ErrorCode::Internal => StatusCode::INTERNAL_SERVER_ERROR,
44        }
45    }
46}
47
48#[derive(Debug)]
49pub struct ApiErrorResponse {
50    pub error: AppError,
51    pub context: Option<RequestContext>,
52}
53
54impl From<AppError> for ApiErrorResponse {
55    fn from(error: AppError) -> Self {
56        Self {
57            error,
58            context: None,
59        }
60    }
61}
62
63impl ApiErrorResponse {
64    pub fn with_context(error: AppError, context: &RequestContext) -> Self {
65        Self {
66            error,
67            context: Some(context.clone()),
68        }
69    }
70}
71
72impl IntoResponse for ApiErrorResponse {
73    fn into_response(self) -> Response {
74        let error = self.error;
75        let status = error.status_code();
76        let request_id = self.context.as_ref().map(|ctx| ctx.request_id.0.clone());
77        let correlation_id = self
78            .context
79            .as_ref()
80            .map(|ctx| ctx.correlation_id.0.clone());
81        tracing::warn!(
82            error_code = error.code.as_str(),
83            status = status.as_u16(),
84            request_id = request_id.as_deref().unwrap_or(""),
85            correlation_id = correlation_id.as_deref().unwrap_or(""),
86            "HTTP request failed"
87        );
88        let code = error.code;
89        let body = ProblemDetails {
90            problem_type: problem_type(code),
91            title: problem_title(code).to_owned(),
92            status: status.as_u16(),
93            detail: error.public_message,
94            code: code.as_str().to_owned(),
95            request_id,
96            correlation_id,
97            errors: error
98                .details
99                .into_iter()
100                .map(|detail| ProblemErrorDetail {
101                    field: detail.field,
102                    reason: detail.reason,
103                })
104                .collect(),
105        };
106        let mut response = (status, Json(body)).into_response();
107        response.headers_mut().insert(
108            header::CONTENT_TYPE,
109            HeaderValue::from_static("application/problem+json"),
110        );
111        if let Ok(value) = HeaderValue::from_str(code.as_str()) {
112            response.headers_mut().insert("x-lenso-error-code", value);
113        }
114        response
115    }
116}
117
118fn problem_title(code: ErrorCode) -> &'static str {
119    match code {
120        ErrorCode::Validation => "Validation failed",
121        ErrorCode::Unauthorized => "Unauthorized",
122        ErrorCode::Forbidden => "Forbidden",
123        ErrorCode::NotFound => "Not found",
124        ErrorCode::Conflict => "Conflict",
125        ErrorCode::RateLimited => "Rate limited",
126        ErrorCode::ExternalDependency => "External dependency failure",
127        ErrorCode::Internal => "Internal error",
128    }
129}
130
131fn problem_type(code: ErrorCode) -> String {
132    format!("https://lenso.dev/problems/{}", code.as_str())
133}