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}