cronback_api_srv/
errors.rs

1use 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    // 400
27    #[error("Malformed request: {0}")]
28    BadRequest(String),
29
30    // 404
31    #[error("Resource requested was not found: {0}")]
32    NotFound(String),
33
34    // 401 Unauthorized response status code indicates that the client request
35    // has not been completed because it lacks valid authentication
36    // credentials for the requested resource.
37    #[error("Authentication required to access this resource")]
38    Unauthorized,
39
40    // 403 Forbidden response status code indicates that the server
41    // understands the request but refuses to authorize it.
42    // ***
43    // NOTE: DO NOT USE THIS IF A RESOURCE EXISTS BUT IS OWNED BY A DIFFERENT
44    // PROJECT, USE NotFound INSTEAD.
45    // ***
46    #[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    // 415 Unsupported Media Type
56    #[error("Expected request with `Content-Type: application/json`")]
57    UnsupportedContentType,
58
59    // 422 Unprocessable Entity/Content
60    #[error("Request has failed validation")]
61    UnprocessableContent {
62        message: String,
63        params: HashMap<String, Vec<String>>,
64    },
65
66    // 500 Internal Server Error
67    #[error(
68        "Internal server error, the error has been logged and will be \
69         investigated."
70    )]
71    InternalServerError,
72    // 503
73    #[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    // This is always 503 Service Unavailable!
84    #[error(transparent)]
85    AppStateError(#[from] AppStateError),
86    // This is always 503 Service Unavailable!
87    #[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            // Indicates a non-retryable logical error in the system.
162            // An operation cannot be performed.
163            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                // We should not expect to have Status::Ok as an error!
171                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            // GRPC service is not available, log this and report to the user.
180            // Timeout! It's a sad day for humanity :sadface:
181            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            // We should not expect to see those errors. If we do, we should
192            // just tell the user and generate a debug key
193            | tonic::Code::Internal
194            // All validation should happen on API side, if we should not expect
195            // an `InvalidArgument` to be triggered from user input, therefore,
196            // we treat this as an internal error and we log the details for
197            //
198            // Change this to report BadRequest or UnprocessableContent if you want to use it to
199            // report non-validation input errors.
200            | 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            // Request body is syntactically valid but couldn't be deserialised
232            // into the target type.
233            | 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            // Json syntax error.
243            | JsonRejection::JsonSyntaxError(e) => {
244                ApiError::BadRequest(format!(
245                    "Invalid JSON syntax, reason: {}",
246                    e.source().unwrap()
247                ))
248            }
249            // Content-Type header is missing or not `application/json`.
250            | JsonRejection::MissingJsonContentType(..) => {
251                ApiError::UnsupportedContentType
252            }
253            // Used when the request body is too large, buffering error, invalid
254            // UTF-8.
255            | JsonRejection::BytesRejection(e) => ApiError::BytesRejection(e),
256            // JsonRejection is non-exhaustive, we must cover _.
257            | _ => {
258                error!("Unexpected JsonRejection: {:?}", value);
259                ApiError::InternalServerError
260            }
261        }
262    }
263}
264
265// attempt to extract the inner `serde::de::value::Error`, if that succeeds we
266// can provide a more specific error
267fn 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}
281// attempt to downcast `err` into a `T` and if that fails recursively try and
282// downcast `err`'s source
283fn 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        // Various errors on a single field, we collect.
304        | 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        // Nested errors in a struct, we flatten.
310        | ValidationErrorsKind::Struct(errs) => {
311            failures.extend(format_struct(errs, path));
312        }
313        // Errors in a list, we add the index to the path to flatten.
314        | 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}