-
Notifications
You must be signed in to change notification settings - Fork 78
/
error.rs
222 lines (203 loc) · 8.75 KB
/
error.rs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
use axum::body::{Bytes, Full, HttpBody};
use axum::http::header::WWW_AUTHENTICATE;
use axum::http::{HeaderMap, HeaderValue, Response, StatusCode};
use axum::response::IntoResponse;
use axum::Json;
use sqlx::error::DatabaseError;
use std::borrow::Cow;
use std::collections::HashMap;
/// A common error type that can be used throughout the API.
///
/// Can be returned in a `Result` from an API handler function.
///
/// For convenience, this represents both API errors as well as internal recoverable errors,
/// and maps them to appropriate status codes along with at least a minimally useful error
/// message in a plain text body, or a JSON body in the case of `UnprocessableEntity`.
#[derive(thiserror::Error, Debug)]
pub enum Error {
/// Return `401 Unauthorized`
#[error("authentication required")]
Unauthorized,
/// Return `403 Forbidden`
#[error("user may not perform that action")]
Forbidden,
/// Return `404 Not Found`
#[error("request path not found")]
NotFound,
/// Return `422 Unprocessable Entity`
///
/// This also serializes the `errors` map to JSON to satisfy the requirement for
/// `422 Unprocessable Entity` errors in the Realworld spec:
/// https://realworld-docs.netlify.app/docs/specs/backend-specs/error-handling
///
/// For a good API, the other status codes should also ideally map to some sort of JSON body
/// that the frontend can deal with, but I do admit sometimes I've just gotten lazy and
/// returned a plain error message if there were few enough error modes for a route
/// that the frontend could infer the error from the status code alone.
#[error("error in the request body")]
UnprocessableEntity {
errors: HashMap<Cow<'static, str>, Vec<Cow<'static, str>>>,
},
/// Automatically return `500 Internal Server Error` on a `sqlx::Error`.
///
/// Via the generated `From<sqlx::Error> for Error` impl,
/// this allows using `?` on database calls in handler functions without a manual mapping step.
///
/// I highly recommend creating an error type like this if only to make handler function code
/// nicer; code in Actix-web projects that we started before I settled on this pattern is
/// filled with `.map_err(ErrInternalServerError)?` which is a *ton* of unnecessary noise.
///
/// The actual error message isn't returned to the client for security reasons.
/// It should be logged instead.
///
/// Note that this could also contain database constraint errors, which should usually
/// be transformed into client errors (e.g. `422 Unprocessable Entity` or `409 Conflict`).
/// See `ResultExt` below for a convenient way to do this.
#[error("an error occurred with the database")]
Sqlx(#[from] sqlx::Error),
/// Return `500 Internal Server Error` on a `anyhow::Error`.
///
/// `anyhow::Error` is used in a few places to capture context and backtraces
/// on unrecoverable (but technically non-fatal) errors which could be highly useful for
/// debugging. We use it a lot in our code for background tasks or making API calls
/// to external services so we can use `.context()` to refine the logged error.
///
/// Via the generated `From<anyhow::Error> for Error` impl, this allows the
/// use of `?` in handler functions to automatically convert `anyhow::Error` into a response.
///
/// Like with `Error::Sqlx`, the actual error message is not returned to the client
/// for security reasons.
#[error("an internal server error occurred")]
Anyhow(#[from] anyhow::Error),
}
impl Error {
/// Convenient constructor for `Error::UnprocessableEntity`.
///
/// Multiple for the same key are collected into a list for that key.
///
/// Try "Go to Usage" in an IDE for examples.
pub fn unprocessable_entity<K, V>(errors: impl IntoIterator<Item = (K, V)>) -> Self
where
K: Into<Cow<'static, str>>,
V: Into<Cow<'static, str>>,
{
let mut error_map = HashMap::new();
for (key, val) in errors {
error_map
.entry(key.into())
.or_insert_with(Vec::new)
.push(val.into());
}
Self::UnprocessableEntity { errors: error_map }
}
fn status_code(&self) -> StatusCode {
match self {
Self::Unauthorized => StatusCode::UNAUTHORIZED,
Self::Forbidden => StatusCode::FORBIDDEN,
Self::NotFound => StatusCode::NOT_FOUND,
Self::UnprocessableEntity { .. } => StatusCode::UNPROCESSABLE_ENTITY,
Self::Sqlx(_) | Self::Anyhow(_) => StatusCode::INTERNAL_SERVER_ERROR,
}
}
}
/// Axum allows you to return `Result` from handler functions, but the error type
/// also must be some sort of response type.
///
/// By default, the generated `Display` impl is used to return a plaintext error message
/// to the client.
impl IntoResponse for Error {
type Body = Full<Bytes>;
type BodyError = <Full<Bytes> as HttpBody>::Error;
fn into_response(self) -> Response<Self::Body> {
match self {
Self::UnprocessableEntity { errors } => {
#[derive(serde::Serialize)]
struct Errors {
errors: HashMap<Cow<'static, str>, Vec<Cow<'static, str>>>,
}
return (StatusCode::UNPROCESSABLE_ENTITY, Json(Errors { errors })).into_response();
}
Self::Unauthorized => {
return (
self.status_code(),
// Include the `WWW-Authenticate` challenge required in the specification
// for the `401 Unauthorized` response code:
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/401
//
// The Realworld spec does not specify this:
// https://realworld-docs.netlify.app/docs/specs/backend-specs/error-handling
//
// However, at Launchbadge we try to adhere to web standards wherever possible,
// if nothing else than to try to act as a vanguard of sanity on the web.
[(WWW_AUTHENTICATE, HeaderValue::from_static("Token"))]
.into_iter()
.collect::<HeaderMap>(),
self.to_string(),
)
.into_response();
}
Self::Sqlx(ref e) => {
// TODO: we probably want to use `tracing` instead
// so that this gets linked to the HTTP request by `TraceLayer`.
log::error!("SQLx error: {:?}", e);
}
Self::Anyhow(ref e) => {
// TODO: we probably want to use `tracing` instead
// so that this gets linked to the HTTP request by `TraceLayer`.
log::error!("Generic error: {:?}", e);
}
// Other errors get mapped normally.
_ => (),
}
(self.status_code(), self.to_string()).into_response()
}
}
/// A little helper trait for more easily converting database constraint errors into API errors.
///
/// ```rust,ignore
/// let user_id = sqlx::query_scalar!(
/// r#"insert into "user" (username, email, password_hash) values ($1, $2, $3) returning user_id"#,
/// username,
/// email,
/// password_hash
/// )
/// .fetch_one(&ctxt.db)
/// .await
/// .on_constraint("user_username_key", |_| Error::unprocessable_entity([("username", "already taken")]))?;
/// ```
///
/// Something like this would ideally live in a `sqlx-axum` crate if it made sense to author one,
/// however its definition is tied pretty intimately to the `Error` type, which is itself
/// tied directly to application semantics.
///
/// To actually make this work in a generic context would make it quite a bit more complex,
/// as you'd need an intermediate error type to represent either a mapped or an unmapped error,
/// and even then it's not clear how to handle `?` in the unmapped case without more boilerplate.
pub trait ResultExt<T> {
/// If `self` contains a SQLx database constraint error with the given name,
/// transform the error.
///
/// Otherwise, the result is passed through unchanged.
fn on_constraint(
self,
name: &str,
f: impl FnOnce(Box<dyn DatabaseError>) -> Error,
) -> Result<T, Error>;
}
impl<T, E> ResultExt<T> for Result<T, E>
where
E: Into<Error>,
{
fn on_constraint(
self,
name: &str,
map_err: impl FnOnce(Box<dyn DatabaseError>) -> Error,
) -> Result<T, Error> {
self.map_err(|e| match e.into() {
Error::Sqlx(sqlx::Error::Database(dbe)) if dbe.constraint() == Some(name) => {
map_err(dbe)
}
e => e,
})
}
}