graphql_starter/error/
api.rs

1use std::collections::HashMap;
2
3use axum::{
4    http::StatusCode,
5    response::{IntoResponse, Response},
6};
7use error_info::ErrorInfo;
8use http::{header::IntoHeaderName, HeaderMap, HeaderValue};
9use serde::Serialize;
10
11use super::{Error, GenericErrorCode};
12use crate::axum::extract::Json;
13
14pub type ApiResult<T, E = Box<ApiError>> = std::result::Result<T, E>;
15
16/// An RFC-7807 compatible error implementing axum's [IntoResponse]
17#[derive(Debug, Serialize)]
18pub struct ApiError {
19    /// A short, human-readable title for the general error type
20    title: String,
21    /// Conveying the HTTP status code
22    #[serde(serialize_with = "serialize_status_u16")]
23    status: StatusCode,
24    /// A human-readable description of the specific error
25    detail: String,
26    /// Additional information about the error
27    #[serde(skip_serializing_if = "HashMap::is_empty")]
28    info: HashMap<String, String>,
29    /// Additional details for each one of the errors encountered
30    #[serde(skip_serializing_if = "HashMap::is_empty")]
31    errors: HashMap<String, serde_json::Value>,
32    /// Additional headers to be sent with the response
33    #[serde(skip)]
34    headers: Option<HeaderMap>,
35}
36
37impl ApiError {
38    /// Builds a new error from the detail message
39    pub fn new(status: StatusCode, detail: impl Into<String>) -> Box<Self> {
40        Box::new(ApiError {
41            title: status
42                .canonical_reason()
43                .unwrap_or(GenericErrorCode::InternalServerError.raw_message())
44                .to_owned(),
45            status,
46            detail: detail.into(),
47            info: Default::default(),
48            errors: Default::default(),
49            headers: None,
50        })
51    }
52
53    /// Builds a new [ApiError] from the core [Error]
54    #[allow(clippy::boxed_local)]
55    pub fn from_err(err: Box<Error>) -> Box<Self> {
56        let err = *err;
57
58        // Trace error before losing context information, this should usually happen just before returning to clients
59        if err.unexpected {
60            tracing::error!("{err:#}");
61        } else if tracing::event_enabled!(tracing::Level::DEBUG) {
62            tracing::warn!("{err:#}")
63        } else {
64            tracing::warn!("{err}")
65        }
66
67        // Build the ApiError
68        let mut ret = ApiError::new(err.info.status(), err.info.message());
69
70        // Extend the error info to allow for i18n
71        ret = ret.with_info("errorCode", err.info.code());
72        ret = ret.with_info("rawMessage", err.info.raw_message());
73        for (key, value) in err.info.fields() {
74            if key == "errorCode" || key == "rawMessage" {
75                tracing::error!("Error '{}' contains a reserved property: {}", err.info.code(), key);
76                continue;
77            }
78            ret = ret.with_info(key, value);
79        }
80
81        // Extend with the error properties
82        if let Some(properties) = err.properties {
83            for (key, value) in properties {
84                ret = ret.with_error_info(key, value);
85            }
86        }
87
88        ret
89    }
90
91    /// Modify the title
92    pub fn with_title(mut self: Box<Self>, title: impl Into<String>) -> Box<Self> {
93        self.title = title.into();
94        self
95    }
96
97    /// Extend the error with additional information
98    pub fn with_info(mut self: Box<Self>, key: impl Into<String>, value: impl Into<String>) -> Box<Self> {
99        self.info.insert(key.into(), value.into());
100        self
101    }
102
103    /// Extend the error with additional information about errors
104    pub fn with_error_info(mut self: Box<Self>, field: impl Into<String>, info: serde_json::Value) -> Box<Self> {
105        self.errors.insert(field.into(), info);
106        self
107    }
108
109    /// Extend the error with an additional header
110    pub fn with_header(mut self: Box<Self>, key: impl IntoHeaderName, value: impl TryInto<HeaderValue>) -> Box<Self> {
111        if let Ok(value) = value.try_into() {
112            let headers = self.headers.get_or_insert_with(Default::default);
113            headers.append(key, value);
114        }
115        self
116    }
117
118    /// Retrieves the error title
119    pub fn title(&self) -> &str {
120        &self.title
121    }
122
123    /// Retrieves the status code
124    pub fn status(&self) -> StatusCode {
125        self.status
126    }
127
128    /// Retrieves the error detail
129    pub fn detail(&self) -> &str {
130        &self.detail
131    }
132
133    /// Retrieves the error info
134    pub fn info(&self) -> &HashMap<String, String> {
135        &self.info
136    }
137
138    /// Retrieves the internal errors
139    pub fn errors(&self) -> &HashMap<String, serde_json::Value> {
140        &self.errors
141    }
142
143    /// Retrieves the additional headers
144    pub fn headers(&self) -> &Option<HeaderMap> {
145        &self.headers
146    }
147}
148
149impl From<Box<Error>> for Box<ApiError> {
150    fn from(err: Box<Error>) -> Self {
151        ApiError::from_err(err)
152    }
153}
154
155impl IntoResponse for Box<ApiError> {
156    fn into_response(mut self) -> Response {
157        if let Some(headers) = self.headers.take() {
158            (self.status, headers, Json(self)).into_response()
159        } else {
160            (self.status, Json(self)).into_response()
161        }
162    }
163}
164
165fn serialize_status_u16<S>(status: &StatusCode, serializer: S) -> Result<S::Ok, S::Error>
166where
167    S: serde::Serializer,
168{
169    serializer.serialize_u16(status.as_u16())
170}