1use std::collections::BTreeMap;
23
24use async_graphql::{ErrorExtensions, ServerError, Value};
25use autumn_web::AutumnError;
26use autumn_web::middleware::AutumnErrorInfo;
27use axum::response::IntoResponse;
28use http::StatusCode;
29
30pub mod codes {
35 pub const BAD_USER_INPUT: &str = "BAD_USER_INPUT";
37 pub const UNAUTHENTICATED: &str = "UNAUTHENTICATED";
39 pub const FORBIDDEN: &str = "FORBIDDEN";
41 pub const NOT_FOUND: &str = "NOT_FOUND";
43 pub const CONFLICT: &str = "CONFLICT";
45 pub const VALIDATION_FAILED: &str = "VALIDATION_FAILED";
47 pub const RATE_LIMITED: &str = "RATE_LIMITED";
49 pub const CLIENT_ERROR: &str = "CLIENT_ERROR";
51 pub const SERVICE_UNAVAILABLE: &str = "SERVICE_UNAVAILABLE";
53 pub const INTERNAL_SERVER_ERROR: &str = "INTERNAL_SERVER_ERROR";
55 pub const GRAPHQL_PARSE_FAILED: &str = "GRAPHQL_PARSE_FAILED";
57 pub const GRAPHQL_VALIDATION_FAILED: &str = "GRAPHQL_VALIDATION_FAILED";
59 pub const BAD_REQUEST: &str = "BAD_REQUEST";
61 pub const QUERY_LIMIT_EXCEEDED: &str = "QUERY_LIMIT_EXCEEDED";
64 pub const INTROSPECTION_DISABLED: &str = "INTROSPECTION_DISABLED";
66 pub const OPERATION_TIMEOUT: &str = "OPERATION_TIMEOUT";
68 pub const PERSISTED_QUERY_NOT_FOUND: &str = "PERSISTED_QUERY_NOT_FOUND";
70 pub const PERSISTED_QUERY_NOT_SUPPORTED: &str = "PERSISTED_QUERY_NOT_SUPPORTED";
72 pub const PERSISTED_QUERY_HASH_MISMATCH: &str = "PERSISTED_QUERY_HASH_MISMATCH";
74 pub const OPERATION_NOT_ALLOWLISTED: &str = "OPERATION_NOT_ALLOWLISTED";
76 pub const METHOD_NOT_ALLOWED: &str = "METHOD_NOT_ALLOWED";
78 pub const BATCH_REJECTED: &str = "BATCH_REJECTED";
80 pub const PAYLOAD_TOO_LARGE: &str = "PAYLOAD_TOO_LARGE";
82 pub const UNSUPPORTED_MEDIA_TYPE: &str = "UNSUPPORTED_MEDIA_TYPE";
84 pub const NOT_ACCEPTABLE: &str = "NOT_ACCEPTABLE";
86 pub const CSRF_PREVENTION: &str = "CSRF_PREVENTION";
88 pub const TOO_MANY_CONNECTIONS: &str = "TOO_MANY_CONNECTIONS";
90}
91
92pub const INTERNAL_MESSAGE: &str = "Internal server error";
94
95#[must_use]
97pub fn code_for_status(status: StatusCode) -> &'static str {
98 match status.as_u16() {
99 400 => codes::BAD_USER_INPUT,
100 401 => codes::UNAUTHENTICATED,
101 403 => codes::FORBIDDEN,
102 404 => codes::NOT_FOUND,
103 409 => codes::CONFLICT,
104 422 => codes::VALIDATION_FAILED,
105 429 => codes::RATE_LIMITED,
106 503 => codes::SERVICE_UNAVAILABLE,
107 s if (400..500).contains(&s) => codes::CLIENT_ERROR,
108 _ => codes::INTERNAL_SERVER_ERROR,
109 }
110}
111
112#[must_use]
122pub fn graphql_error(
123 code: &str,
124 message: impl Into<String>,
125 status: Option<u16>,
126) -> async_graphql::Error {
127 let code = code.to_owned();
128 async_graphql::Error::new(message).extend_with(move |_, e| {
129 e.set("code", code.clone());
130 if let Some(status) = status {
131 e.set("status", status);
132 }
133 })
134}
135
136pub trait IntoGraphqlError {
138 fn into_graphql_error(self) -> async_graphql::Error;
140}
141
142impl IntoGraphqlError for AutumnError {
143 fn into_graphql_error(self) -> async_graphql::Error {
144 let response = self.into_response();
149 let info = response.extensions().get::<AutumnErrorInfo>().cloned();
150 let (status, message, details) = info.map_or_else(
151 || (response.status(), String::new(), None),
152 |info| (info.status, info.message, info.details),
153 );
154 coded_error(status, &message, details.as_ref())
155 }
156}
157
158fn coded_error(
159 status: StatusCode,
160 message: &str,
161 details: Option<&std::collections::HashMap<String, Vec<String>>>,
162) -> async_graphql::Error {
163 let code = code_for_status(status);
164 if status.is_server_error() || !status.is_client_error() {
165 tracing::error!(status = %status, error = %message, "GraphQL resolver failed");
166 return graphql_error(code, INTERNAL_MESSAGE, Some(status.as_u16()));
167 }
168 let fields: Option<BTreeMap<&String, &Vec<String>>> = details
169 .filter(|d| !d.is_empty())
170 .map(|d| d.iter().collect());
171 let text = fields.as_ref().map_or_else(
172 || message.to_owned(),
173 |fields| {
174 fields
179 .iter()
180 .flat_map(|(field, reasons)| reasons.iter().map(move |r| format!("{field}: {r}")))
181 .collect::<Vec<_>>()
182 .join("; ")
183 },
184 );
185 let field_map = fields.map(|fields| {
186 Value::Object(
187 fields
188 .into_iter()
189 .map(|(field, reasons)| {
190 (
191 async_graphql::Name::new(field),
192 Value::List(reasons.iter().map(|r| Value::from(r.as_str())).collect()),
193 )
194 })
195 .collect(),
196 )
197 });
198 graphql_error(code, text, Some(status.as_u16())).extend_with(move |_, e| {
199 if let Some(fields) = &field_map {
200 e.set("fields", fields.clone());
201 }
202 })
203}
204
205pub trait GraphqlResultExt<T> {
213 fn gql(self) -> async_graphql::Result<T>;
219}
220
221impl<T, E: IntoGraphqlError> GraphqlResultExt<T> for Result<T, E> {
222 fn gql(self) -> async_graphql::Result<T> {
223 self.map_err(IntoGraphqlError::into_graphql_error)
224 }
225}
226
227#[must_use]
229pub fn error_code(error: &ServerError) -> Option<&str> {
230 match error.extensions.as_ref()?.get("code")? {
231 Value::String(code) => Some(code.as_str()),
232 _ => None,
233 }
234}
235
236#[must_use]
239pub const fn is_execution_error(error: &ServerError) -> bool {
240 !error.path.is_empty()
241}
242
243#[must_use]
246pub fn request_error(
247 code: &str,
248 message: impl Into<String>,
249 status: StatusCode,
250 extra: &[(&str, Value)],
251) -> ServerError {
252 let mut error = ServerError::new(message, None);
253 let mut extensions = async_graphql::ErrorExtensionValues::default();
254 extensions.set("code", code);
255 extensions.set("status", status.as_u16());
256 for (key, value) in extra {
257 extensions.set(key, value.clone());
258 }
259 error.extensions = Some(extensions);
260 error
261}
262
263pub fn finalize_errors(errors: &mut [ServerError], mask: bool, parsed: bool) -> Vec<String> {
270 let mut seen = Vec::with_capacity(errors.len());
271 for error in errors.iter_mut() {
272 if let Some(code) = error_code(error) {
273 seen.push(code.to_owned());
274 continue;
275 }
276 let code = if is_execution_error(error) {
277 if mask {
278 tracing::error!(
279 error = %error.message,
280 path = ?error.path,
281 "masked an unexpected GraphQL resolver error (give it an extensions.code to \
282 expose it)"
283 );
284 INTERNAL_MESSAGE.clone_into(&mut error.message);
285 }
286 codes::INTERNAL_SERVER_ERROR
287 } else if parsed {
288 codes::GRAPHQL_VALIDATION_FAILED
289 } else {
290 codes::GRAPHQL_PARSE_FAILED
291 };
292 error
293 .extensions
294 .get_or_insert_with(Default::default)
295 .set("code", code);
296 seen.push(code.to_owned());
297 }
298 seen
299}
300
301#[cfg(test)]
302#[allow(clippy::unwrap_used)]
303mod tests {
304 use std::collections::HashMap;
305
306 use async_graphql::PathSegment;
307
308 use super::*;
309
310 fn ext(err: &async_graphql::Error, key: &str) -> Option<Value> {
311 err.extensions.as_ref()?.get(key).cloned()
312 }
313
314 #[test]
315 fn client_errors_keep_their_message_and_carry_code_and_status() {
316 let err = AutumnError::not_found_msg("note 9 not found").into_graphql_error();
317 assert_eq!(err.message, "note 9 not found");
318 assert_eq!(ext(&err, "code"), Some(Value::from("NOT_FOUND")));
319 assert_eq!(ext(&err, "status"), Some(Value::from(404)));
320
321 let err = AutumnError::unauthorized_msg("sign in").into_graphql_error();
322 assert_eq!(ext(&err, "code"), Some(Value::from("UNAUTHENTICATED")));
323 let err = AutumnError::forbidden_msg("no").into_graphql_error();
324 assert_eq!(ext(&err, "code"), Some(Value::from("FORBIDDEN")));
325 let err = AutumnError::bad_request_msg("bad id").into_graphql_error();
326 assert_eq!(ext(&err, "code"), Some(Value::from("BAD_USER_INPUT")));
327 }
328
329 #[test]
330 fn server_errors_are_redacted() {
331 let err = AutumnError::internal_server_error_msg("connection refused: 10.0.0.3:5432")
332 .into_graphql_error();
333 assert_eq!(err.message, INTERNAL_MESSAGE);
334 assert_eq!(
335 ext(&err, "code"),
336 Some(Value::from("INTERNAL_SERVER_ERROR"))
337 );
338 assert_eq!(ext(&err, "status"), Some(Value::from(500)));
339
340 let err = AutumnError::service_unavailable_msg("no pool").into_graphql_error();
341 assert_eq!(err.message, INTERNAL_MESSAGE);
342 assert_eq!(ext(&err, "code"), Some(Value::from("SERVICE_UNAVAILABLE")));
343 assert_eq!(ext(&err, "status"), Some(Value::from(503)));
344 }
345
346 #[test]
347 fn validation_details_become_message_and_fields() {
348 let mut details = HashMap::new();
349 details.insert("title".to_owned(), vec!["too short".to_owned()]);
350 details.insert(
351 "body".to_owned(),
352 vec!["required".to_owned(), "x".to_owned()],
353 );
354 let err = AutumnError::validation(details).into_graphql_error();
355 assert_eq!(err.message, "body: required; body: x; title: too short");
356 assert_eq!(ext(&err, "code"), Some(Value::from("VALIDATION_FAILED")));
357 assert_eq!(ext(&err, "status"), Some(Value::from(422)));
358 let Some(Value::Object(fields)) = ext(&err, "fields") else {
359 panic!("fields missing");
360 };
361 assert_eq!(
362 fields.get("title"),
363 Some(&Value::List(vec![Value::from("too short")]))
364 );
365 }
366
367 #[test]
368 fn gql_maps_results() {
369 let ok: Result<u8, AutumnError> = Ok(1);
370 assert_eq!(ok.gql().unwrap(), 1);
371 let err: Result<u8, AutumnError> = Err(AutumnError::conflict_msg("stale"));
372 let err = err.gql().unwrap_err();
373 assert_eq!(ext(&err, "code"), Some(Value::from("CONFLICT")));
374 }
375
376 #[test]
377 fn status_codes_map_to_stable_codes() {
378 assert_eq!(
379 code_for_status(StatusCode::TOO_MANY_REQUESTS),
380 codes::RATE_LIMITED
381 );
382 assert_eq!(code_for_status(StatusCode::GONE), codes::CLIENT_ERROR);
383 assert_eq!(
384 code_for_status(StatusCode::BAD_GATEWAY),
385 codes::INTERNAL_SERVER_ERROR
386 );
387 assert_eq!(
388 code_for_status(StatusCode::OK),
389 codes::INTERNAL_SERVER_ERROR
390 );
391 }
392
393 #[test]
394 fn finalize_masks_only_uncoded_execution_errors() {
395 let mut unexpected = ServerError::new("Data `Pool` does not exist.", None);
396 unexpected.path = vec![PathSegment::Field("notes".into())];
397 let mut coded = ServerError::new("note 2 is pinned", None);
398 coded.path = vec![PathSegment::Field("deleteNote".into())];
399 let mut ext = async_graphql::ErrorExtensionValues::default();
400 ext.set("code", "VALIDATION_FAILED");
401 coded.extensions = Some(ext);
402 let validation = ServerError::new("Unknown field \"nope\"", None);
403
404 let mut errors = vec![unexpected, coded, validation];
405 let seen = finalize_errors(&mut errors, true, true);
406 assert_eq!(errors[0].message, INTERNAL_MESSAGE);
407 assert_eq!(errors[1].message, "note 2 is pinned");
408 assert_eq!(errors[2].message, "Unknown field \"nope\"");
409 assert_eq!(
410 seen,
411 [
412 "INTERNAL_SERVER_ERROR",
413 "VALIDATION_FAILED",
414 "GRAPHQL_VALIDATION_FAILED"
415 ]
416 );
417
418 let mut unmasked = vec![ServerError::new("boom", None)];
419 unmasked[0].path = vec![PathSegment::Index(0)];
420 finalize_errors(&mut unmasked, false, true);
421 assert_eq!(unmasked[0].message, "boom");
422 assert_eq!(error_code(&unmasked[0]), Some("INTERNAL_SERVER_ERROR"));
423
424 let mut parse = vec![ServerError::new(" --> 1:1 expected selection", None)];
425 finalize_errors(&mut parse, true, false);
426 assert_eq!(error_code(&parse[0]), Some("GRAPHQL_PARSE_FAILED"));
427 }
428
429 #[test]
430 fn request_errors_carry_extras() {
431 let err = request_error(
432 codes::QUERY_LIMIT_EXCEEDED,
433 "too deep",
434 StatusCode::BAD_REQUEST,
435 &[("limit", Value::from("depth"))],
436 );
437 assert_eq!(error_code(&err), Some("QUERY_LIMIT_EXCEEDED"));
438 assert_eq!(
439 err.extensions.as_ref().unwrap().get("limit"),
440 Some(&Value::from("depth"))
441 );
442 assert!(!is_execution_error(&err));
443 }
444}