use std::collections::BTreeMap;
use async_graphql::{ErrorExtensions, ServerError, Value};
use autumn_web::AutumnError;
use autumn_web::middleware::AutumnErrorInfo;
use axum::response::IntoResponse;
use http::StatusCode;
pub mod codes {
pub const BAD_USER_INPUT: &str = "BAD_USER_INPUT";
pub const UNAUTHENTICATED: &str = "UNAUTHENTICATED";
pub const FORBIDDEN: &str = "FORBIDDEN";
pub const NOT_FOUND: &str = "NOT_FOUND";
pub const CONFLICT: &str = "CONFLICT";
pub const VALIDATION_FAILED: &str = "VALIDATION_FAILED";
pub const RATE_LIMITED: &str = "RATE_LIMITED";
pub const CLIENT_ERROR: &str = "CLIENT_ERROR";
pub const SERVICE_UNAVAILABLE: &str = "SERVICE_UNAVAILABLE";
pub const INTERNAL_SERVER_ERROR: &str = "INTERNAL_SERVER_ERROR";
pub const GRAPHQL_PARSE_FAILED: &str = "GRAPHQL_PARSE_FAILED";
pub const GRAPHQL_VALIDATION_FAILED: &str = "GRAPHQL_VALIDATION_FAILED";
pub const BAD_REQUEST: &str = "BAD_REQUEST";
pub const QUERY_LIMIT_EXCEEDED: &str = "QUERY_LIMIT_EXCEEDED";
pub const INTROSPECTION_DISABLED: &str = "INTROSPECTION_DISABLED";
pub const OPERATION_TIMEOUT: &str = "OPERATION_TIMEOUT";
pub const PERSISTED_QUERY_NOT_FOUND: &str = "PERSISTED_QUERY_NOT_FOUND";
pub const PERSISTED_QUERY_NOT_SUPPORTED: &str = "PERSISTED_QUERY_NOT_SUPPORTED";
pub const PERSISTED_QUERY_HASH_MISMATCH: &str = "PERSISTED_QUERY_HASH_MISMATCH";
pub const OPERATION_NOT_ALLOWLISTED: &str = "OPERATION_NOT_ALLOWLISTED";
pub const METHOD_NOT_ALLOWED: &str = "METHOD_NOT_ALLOWED";
pub const BATCH_REJECTED: &str = "BATCH_REJECTED";
pub const PAYLOAD_TOO_LARGE: &str = "PAYLOAD_TOO_LARGE";
pub const UNSUPPORTED_MEDIA_TYPE: &str = "UNSUPPORTED_MEDIA_TYPE";
pub const NOT_ACCEPTABLE: &str = "NOT_ACCEPTABLE";
pub const CSRF_PREVENTION: &str = "CSRF_PREVENTION";
pub const TOO_MANY_CONNECTIONS: &str = "TOO_MANY_CONNECTIONS";
}
pub const INTERNAL_MESSAGE: &str = "Internal server error";
#[must_use]
pub fn code_for_status(status: StatusCode) -> &'static str {
match status.as_u16() {
400 => codes::BAD_USER_INPUT,
401 => codes::UNAUTHENTICATED,
403 => codes::FORBIDDEN,
404 => codes::NOT_FOUND,
409 => codes::CONFLICT,
422 => codes::VALIDATION_FAILED,
429 => codes::RATE_LIMITED,
503 => codes::SERVICE_UNAVAILABLE,
s if (400..500).contains(&s) => codes::CLIENT_ERROR,
_ => codes::INTERNAL_SERVER_ERROR,
}
}
#[must_use]
pub fn graphql_error(
code: &str,
message: impl Into<String>,
status: Option<u16>,
) -> async_graphql::Error {
let code = code.to_owned();
async_graphql::Error::new(message).extend_with(move |_, e| {
e.set("code", code.clone());
if let Some(status) = status {
e.set("status", status);
}
})
}
pub trait IntoGraphqlError {
fn into_graphql_error(self) -> async_graphql::Error;
}
impl IntoGraphqlError for AutumnError {
fn into_graphql_error(self) -> async_graphql::Error {
let response = self.into_response();
let info = response.extensions().get::<AutumnErrorInfo>().cloned();
let (status, message, details) = info.map_or_else(
|| (response.status(), String::new(), None),
|info| (info.status, info.message, info.details),
);
coded_error(status, &message, details.as_ref())
}
}
fn coded_error(
status: StatusCode,
message: &str,
details: Option<&std::collections::HashMap<String, Vec<String>>>,
) -> async_graphql::Error {
let code = code_for_status(status);
if status.is_server_error() || !status.is_client_error() {
tracing::error!(status = %status, error = %message, "GraphQL resolver failed");
return graphql_error(code, INTERNAL_MESSAGE, Some(status.as_u16()));
}
let fields: Option<BTreeMap<&String, &Vec<String>>> = details
.filter(|d| !d.is_empty())
.map(|d| d.iter().collect());
let text = fields.as_ref().map_or_else(
|| message.to_owned(),
|fields| {
fields
.iter()
.flat_map(|(field, reasons)| reasons.iter().map(move |r| format!("{field}: {r}")))
.collect::<Vec<_>>()
.join("; ")
},
);
let field_map = fields.map(|fields| {
Value::Object(
fields
.into_iter()
.map(|(field, reasons)| {
(
async_graphql::Name::new(field),
Value::List(reasons.iter().map(|r| Value::from(r.as_str())).collect()),
)
})
.collect(),
)
});
graphql_error(code, text, Some(status.as_u16())).extend_with(move |_, e| {
if let Some(fields) = &field_map {
e.set("fields", fields.clone());
}
})
}
pub trait GraphqlResultExt<T> {
fn gql(self) -> async_graphql::Result<T>;
}
impl<T, E: IntoGraphqlError> GraphqlResultExt<T> for Result<T, E> {
fn gql(self) -> async_graphql::Result<T> {
self.map_err(IntoGraphqlError::into_graphql_error)
}
}
#[must_use]
pub fn error_code(error: &ServerError) -> Option<&str> {
match error.extensions.as_ref()?.get("code")? {
Value::String(code) => Some(code.as_str()),
_ => None,
}
}
#[must_use]
pub const fn is_execution_error(error: &ServerError) -> bool {
!error.path.is_empty()
}
#[must_use]
pub fn request_error(
code: &str,
message: impl Into<String>,
status: StatusCode,
extra: &[(&str, Value)],
) -> ServerError {
let mut error = ServerError::new(message, None);
let mut extensions = async_graphql::ErrorExtensionValues::default();
extensions.set("code", code);
extensions.set("status", status.as_u16());
for (key, value) in extra {
extensions.set(key, value.clone());
}
error.extensions = Some(extensions);
error
}
pub fn finalize_errors(errors: &mut [ServerError], mask: bool, parsed: bool) -> Vec<String> {
let mut seen = Vec::with_capacity(errors.len());
for error in errors.iter_mut() {
if let Some(code) = error_code(error) {
seen.push(code.to_owned());
continue;
}
let code = if is_execution_error(error) {
if mask {
tracing::error!(
error = %error.message,
path = ?error.path,
"masked an unexpected GraphQL resolver error (give it an extensions.code to \
expose it)"
);
INTERNAL_MESSAGE.clone_into(&mut error.message);
}
codes::INTERNAL_SERVER_ERROR
} else if parsed {
codes::GRAPHQL_VALIDATION_FAILED
} else {
codes::GRAPHQL_PARSE_FAILED
};
error
.extensions
.get_or_insert_with(Default::default)
.set("code", code);
seen.push(code.to_owned());
}
seen
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use std::collections::HashMap;
use async_graphql::PathSegment;
use super::*;
fn ext(err: &async_graphql::Error, key: &str) -> Option<Value> {
err.extensions.as_ref()?.get(key).cloned()
}
#[test]
fn client_errors_keep_their_message_and_carry_code_and_status() {
let err = AutumnError::not_found_msg("note 9 not found").into_graphql_error();
assert_eq!(err.message, "note 9 not found");
assert_eq!(ext(&err, "code"), Some(Value::from("NOT_FOUND")));
assert_eq!(ext(&err, "status"), Some(Value::from(404)));
let err = AutumnError::unauthorized_msg("sign in").into_graphql_error();
assert_eq!(ext(&err, "code"), Some(Value::from("UNAUTHENTICATED")));
let err = AutumnError::forbidden_msg("no").into_graphql_error();
assert_eq!(ext(&err, "code"), Some(Value::from("FORBIDDEN")));
let err = AutumnError::bad_request_msg("bad id").into_graphql_error();
assert_eq!(ext(&err, "code"), Some(Value::from("BAD_USER_INPUT")));
}
#[test]
fn server_errors_are_redacted() {
let err = AutumnError::internal_server_error_msg("connection refused: 10.0.0.3:5432")
.into_graphql_error();
assert_eq!(err.message, INTERNAL_MESSAGE);
assert_eq!(
ext(&err, "code"),
Some(Value::from("INTERNAL_SERVER_ERROR"))
);
assert_eq!(ext(&err, "status"), Some(Value::from(500)));
let err = AutumnError::service_unavailable_msg("no pool").into_graphql_error();
assert_eq!(err.message, INTERNAL_MESSAGE);
assert_eq!(ext(&err, "code"), Some(Value::from("SERVICE_UNAVAILABLE")));
assert_eq!(ext(&err, "status"), Some(Value::from(503)));
}
#[test]
fn validation_details_become_message_and_fields() {
let mut details = HashMap::new();
details.insert("title".to_owned(), vec!["too short".to_owned()]);
details.insert(
"body".to_owned(),
vec!["required".to_owned(), "x".to_owned()],
);
let err = AutumnError::validation(details).into_graphql_error();
assert_eq!(err.message, "body: required; body: x; title: too short");
assert_eq!(ext(&err, "code"), Some(Value::from("VALIDATION_FAILED")));
assert_eq!(ext(&err, "status"), Some(Value::from(422)));
let Some(Value::Object(fields)) = ext(&err, "fields") else {
panic!("fields missing");
};
assert_eq!(
fields.get("title"),
Some(&Value::List(vec![Value::from("too short")]))
);
}
#[test]
fn gql_maps_results() {
let ok: Result<u8, AutumnError> = Ok(1);
assert_eq!(ok.gql().unwrap(), 1);
let err: Result<u8, AutumnError> = Err(AutumnError::conflict_msg("stale"));
let err = err.gql().unwrap_err();
assert_eq!(ext(&err, "code"), Some(Value::from("CONFLICT")));
}
#[test]
fn status_codes_map_to_stable_codes() {
assert_eq!(
code_for_status(StatusCode::TOO_MANY_REQUESTS),
codes::RATE_LIMITED
);
assert_eq!(code_for_status(StatusCode::GONE), codes::CLIENT_ERROR);
assert_eq!(
code_for_status(StatusCode::BAD_GATEWAY),
codes::INTERNAL_SERVER_ERROR
);
assert_eq!(
code_for_status(StatusCode::OK),
codes::INTERNAL_SERVER_ERROR
);
}
#[test]
fn finalize_masks_only_uncoded_execution_errors() {
let mut unexpected = ServerError::new("Data `Pool` does not exist.", None);
unexpected.path = vec![PathSegment::Field("notes".into())];
let mut coded = ServerError::new("note 2 is pinned", None);
coded.path = vec![PathSegment::Field("deleteNote".into())];
let mut ext = async_graphql::ErrorExtensionValues::default();
ext.set("code", "VALIDATION_FAILED");
coded.extensions = Some(ext);
let validation = ServerError::new("Unknown field \"nope\"", None);
let mut errors = vec![unexpected, coded, validation];
let seen = finalize_errors(&mut errors, true, true);
assert_eq!(errors[0].message, INTERNAL_MESSAGE);
assert_eq!(errors[1].message, "note 2 is pinned");
assert_eq!(errors[2].message, "Unknown field \"nope\"");
assert_eq!(
seen,
[
"INTERNAL_SERVER_ERROR",
"VALIDATION_FAILED",
"GRAPHQL_VALIDATION_FAILED"
]
);
let mut unmasked = vec![ServerError::new("boom", None)];
unmasked[0].path = vec![PathSegment::Index(0)];
finalize_errors(&mut unmasked, false, true);
assert_eq!(unmasked[0].message, "boom");
assert_eq!(error_code(&unmasked[0]), Some("INTERNAL_SERVER_ERROR"));
let mut parse = vec![ServerError::new(" --> 1:1 expected selection", None)];
finalize_errors(&mut parse, true, false);
assert_eq!(error_code(&parse[0]), Some("GRAPHQL_PARSE_FAILED"));
}
#[test]
fn request_errors_carry_extras() {
let err = request_error(
codes::QUERY_LIMIT_EXCEEDED,
"too deep",
StatusCode::BAD_REQUEST,
&[("limit", Value::from("depth"))],
);
assert_eq!(error_code(&err), Some("QUERY_LIMIT_EXCEEDED"));
assert_eq!(
err.extensions.as_ref().unwrap().get("limit"),
Some(&Value::from("depth"))
);
assert!(!is_execution_error(&err));
}
}