cronback_api_srv/
errors.rs1use std::collections::HashMap;
2use std::error::Error;
3
4use axum::extract::rejection::JsonRejection;
5use axum::http::StatusCode;
6use axum::response::{IntoResponse, Response};
7use axum::Json;
8use lib::grpc_client_provider::GrpcClientError;
9use serde::Serialize;
10use serde_with::skip_serializing_none;
11use thiserror::Error;
12use tracing::{error, warn};
13use validator::{ValidationErrors, ValidationErrorsKind};
14
15use crate::AppStateError;
16
17#[skip_serializing_none]
18#[derive(Serialize, Debug)]
19struct ApiErrorBody {
20 message: String,
21 params: Option<HashMap<String, Vec<String>>>,
22}
23
24#[derive(Error, Debug)]
25pub enum ApiError {
26 #[error("Malformed request: {0}")]
28 BadRequest(String),
29
30 #[error("Resource requested was not found: {0}")]
32 NotFound(String),
33
34 #[error("Authentication required to access this resource")]
38 Unauthorized,
39
40 #[error(
47 "Authentication was successful but access to this resource is \
48 forbidden"
49 )]
50 Forbidden,
51
52 #[error("Resource conflict: {0}")]
53 Conflict(String),
54
55 #[error("Expected request with `Content-Type: application/json`")]
57 UnsupportedContentType,
58
59 #[error("Request has failed validation")]
61 UnprocessableContent {
62 message: String,
63 params: HashMap<String, Vec<String>>,
64 },
65
66 #[error(
68 "Internal server error, the error has been logged and will be \
69 investigated."
70 )]
71 InternalServerError,
72 #[error(
74 "Service is currently unavailable, please retry again in a few seconds"
75 )]
76 ServiceUnavailable,
77
78 #[error("This functionality is not implemented")]
79 NotImplemented,
80
81 #[error(transparent)]
82 BytesRejection(#[from] axum::extract::rejection::BytesRejection),
83 #[error(transparent)]
85 AppStateError(#[from] AppStateError),
86 #[error(transparent)]
88 GrpcClientError(#[from] GrpcClientError),
89}
90
91impl ApiError {
92 pub fn unprocessable_content_naked(message: &str) -> Self {
93 ApiError::UnprocessableContent {
94 message: message.to_owned(),
95 params: Default::default(),
96 }
97 }
98
99 pub fn status_code(&self) -> StatusCode {
100 match self {
101 | ApiError::BadRequest(..) => StatusCode::BAD_REQUEST,
102 | ApiError::Unauthorized => StatusCode::UNAUTHORIZED,
103 | ApiError::Forbidden => StatusCode::FORBIDDEN,
104 | ApiError::NotFound(..) => StatusCode::NOT_FOUND,
105 | ApiError::Conflict(..) => StatusCode::CONFLICT,
106 | ApiError::UnsupportedContentType => {
107 StatusCode::UNSUPPORTED_MEDIA_TYPE
108 }
109 | ApiError::InternalServerError => {
110 StatusCode::INTERNAL_SERVER_ERROR
111 }
112 | ApiError::ServiceUnavailable => StatusCode::SERVICE_UNAVAILABLE,
113 | ApiError::NotImplemented => StatusCode::NOT_IMPLEMENTED,
114 | ApiError::UnprocessableContent { .. } => {
115 StatusCode::UNPROCESSABLE_ENTITY
116 }
117 | ApiError::BytesRejection(e) => e.status(),
118 | ApiError::AppStateError(_) => StatusCode::SERVICE_UNAVAILABLE,
119 | ApiError::GrpcClientError(_) => StatusCode::SERVICE_UNAVAILABLE,
120 }
121 }
122}
123
124impl IntoResponse for ApiError {
125 #[tracing::instrument]
126 fn into_response(self) -> Response {
127 let status_code = self.status_code();
128 let body = match self {
129 | Self::UnprocessableContent { message, params } => {
130 ApiErrorBody {
131 message,
132 params: if params.is_empty() {
133 None
134 } else {
135 Some(params)
136 },
137 }
138 }
139 | Self::BytesRejection(e) => {
140 ApiErrorBody {
141 message: e.body_text(),
142 params: None,
143 }
144 }
145 | e => {
146 ApiErrorBody {
147 message: e.to_string(),
148 params: None,
149 }
150 }
151 };
152 (status_code, Json(body)).into_response()
153 }
154}
155
156#[allow(clippy::wildcard_in_or_patterns)]
157impl From<tonic::Status> for ApiError {
158 fn from(value: tonic::Status) -> Self {
159 match value.code() {
160 tonic::Code::NotFound => ApiError::NotFound(value.message().to_string()),
161 tonic::Code::FailedPrecondition => {
164 ApiError::unprocessable_content_naked(value.message())
165 },
166 tonic::Code::AlreadyExists => {
167 ApiError::Conflict(value.message().to_string())
168 },
169 tonic::Code::Ok => {
170 error!(
172 grpc_code = ?value.code(),
173 grpc_message = ?value.message(),
174 "How did we end up here? we should not see Status::Ok wrapped \
175 in an error!"
176 );
177 ApiError::InternalServerError
178 }
179 tonic::Code::DeadlineExceeded
182 | tonic::Code::Unavailable
183 | tonic::Code::ResourceExhausted => {
184 error!(
185 grpc_code = ?value.code(),
186 grpc_message = ?value.message(),
187 "ServiceUnavailable reported due to error reported from GRPC response"
188 );
189 ApiError::ServiceUnavailable
190 },
191 | tonic::Code::Internal
194 | tonic::Code::InvalidArgument
201 | _ => {
202 error!(
203 grpc_code = ?value.code(),
204 grpc_message = ?value.message(),
205 "InternalServerError reported due to error from GRPC response"
206 );
207 ApiError::InternalServerError
208 },
209 }
210 }
211}
212
213impl From<ValidationErrors> for ApiError {
214 fn from(value: ValidationErrors) -> Self {
215 let mut params = HashMap::new();
216 for (key, err) in value.errors() {
217 let errors = format_validation_errors(key, err);
218 params.extend(errors)
219 }
220
221 ApiError::UnprocessableContent {
222 message: "Request body has failed validation".to_owned(),
223 params,
224 }
225 }
226}
227
228impl From<JsonRejection> for ApiError {
229 fn from(value: JsonRejection) -> Self {
230 match value {
231 | JsonRejection::JsonDataError(e) => {
234 let params = get_serde_error_params(&e);
235 ApiError::UnprocessableContent {
236 message: "JSON input is valid but doesn't conform to the \
237 API shape"
238 .to_owned(),
239 params,
240 }
241 }
242 | JsonRejection::JsonSyntaxError(e) => {
244 ApiError::BadRequest(format!(
245 "Invalid JSON syntax, reason: {}",
246 e.source().unwrap()
247 ))
248 }
249 | JsonRejection::MissingJsonContentType(..) => {
251 ApiError::UnsupportedContentType
252 }
253 | JsonRejection::BytesRejection(e) => ApiError::BytesRejection(e),
256 | _ => {
258 error!("Unexpected JsonRejection: {:?}", value);
259 ApiError::InternalServerError
260 }
261 }
262 }
263}
264
265fn get_serde_error_params<'a>(
268 err: &'a (dyn Error + 'static),
269) -> HashMap<String, Vec<String>> {
270 let mut params = HashMap::new();
271 if let Some(serde_err) =
272 find_error_source::<serde_path_to_error::Error<serde_json::Error>>(err)
273 {
274 params.insert(
275 serde_err.path().to_string(),
276 vec![serde_err.inner().to_string()],
277 );
278 }
279 params
280}
281fn find_error_source<'a, T>(err: &'a (dyn Error + 'static)) -> Option<&'a T>
284where
285 T: Error + 'static,
286{
287 if let Some(err) = err.downcast_ref::<T>() {
288 Some(err)
289 } else if let Some(source) = err.source() {
290 find_error_source(source)
291 } else {
292 None
293 }
294}
295
296fn format_validation_errors(
297 path: &str,
298 errs: &ValidationErrorsKind,
299) -> HashMap<String, Vec<String>> {
300 let mut failures = HashMap::new();
301
302 match errs {
303 | ValidationErrorsKind::Field(errs) => {
305 let err_col: Vec<String> =
306 errs.iter().map(ToString::to_string).collect();
307 failures.insert(path.into(), err_col);
308 }
309 | ValidationErrorsKind::Struct(errs) => {
311 failures.extend(format_struct(errs, path));
312 }
313 | ValidationErrorsKind::List(errs) => {
315 for (idx, err) in errs.iter() {
316 let base_path = format!("{path}[{idx}]");
317 failures.extend(format_struct(err, &base_path));
318 }
319 }
320 };
321
322 failures
323}
324
325fn format_struct(
326 errs: &ValidationErrors,
327 path: &str,
328) -> HashMap<String, Vec<String>> {
329 let mut failures = HashMap::new();
330 for (key, err) in errs.errors() {
331 let base_path = format!("{path}.{key}");
332 failures.extend(format_validation_errors(&base_path, err));
333 }
334 failures
335}