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