Schema errors
Rust's model of errors can be adapted for GraphQL. Rust's panic is similar to a field error - the whole query is aborted and nothing can be extracted (except for error related information).
Not all errors require this strict handling. Recoverable or partial errors can be put into a GraphQL schema, so the client can intelligently handle them.
To implement this approach, all errors must be partitioned into two classes:
- Critical errors that cannot be fixed by clients (e.g. a database error).
- Recoverable errors that can be fixed by clients (e.g. invalid input data).
Critical errors are returned from resolvers as field errors (from the previous chapter). Recoverable errors are part of a GraphQL schema and can be handled gracefully by clients. Similar to Rust, GraphQL allows similar error models with unions (see "Unions" chapter).
Example: Simple
In this example, basic input validation is implemented with GraphQL types. Strings are used to identify the problematic field name. Errors for a particular field are also returned as a string.
extern crate juniper; use juniper::{graphql_object, GraphQLObject, GraphQLUnion}; #[derive(GraphQLObject)] pub struct Item { name: String, quantity: i32, } #[derive(GraphQLObject)] pub struct ValidationError { field: String, message: String, } #[derive(GraphQLObject)] pub struct ValidationErrors { errors: Vec<ValidationError>, } #[derive(GraphQLUnion)] pub enum GraphQLResult { Ok(Item), Err(ValidationErrors), } pub struct Mutation; #[graphql_object] impl Mutation { fn add_item(&self, name: String, quantity: i32) -> GraphQLResult { let mut errors = Vec::new(); if !(10 <= name.len() && name.len() <= 100) { errors.push(ValidationError { field: "name".into(), message: "between 10 and 100".into(), }); } if !(1 <= quantity && quantity <= 10) { errors.push(ValidationError { field: "quantity".into(), message: "between 1 and 10".into(), }); } if errors.is_empty() { GraphQLResult::Ok(Item { name, quantity }) } else { GraphQLResult::Err(ValidationErrors { errors }) } } } fn main() {}
Each function may have a different return type and depending on the input parameters a new result type may be required. For example, adding a User
would require a new result type containing the variant Ok(User)
instead of Ok(Item)
.
NOTE: In this example the returned string contains a server-side localized error message. However, it is also possible to return a unique string identifier and have the client present a localized string to its users.
The client can send a mutation request and handle the resulting errors in the following manner:
{
mutation {
addItem(name: "", quantity: 0) {
... on Item {
name
}
... on ValidationErrors {
errors {
field
message
}
}
}
}
}
NOTE: A useful side effect of this approach is to have partially successful queries or mutations. If one resolver fails, the results of the successful resolvers are not discarded.
Example: Complex
Instead of using strings to propagate errors, it is possible to use GraphQL type system to describe the errors more precisely.
For each fallible input argument we create a field in a GraphQL object. The field is set if the validation for that particular argument fails.
extern crate juniper; use juniper::{graphql_object, GraphQLObject, GraphQLUnion}; #[derive(GraphQLObject)] pub struct Item { name: String, quantity: i32, } #[derive(GraphQLObject)] pub struct ValidationError { name: Option<String>, quantity: Option<String>, } #[derive(GraphQLUnion)] pub enum GraphQLResult { Ok(Item), Err(ValidationError), } pub struct Mutation; #[graphql_object] impl Mutation { fn add_item(&self, name: String, quantity: i32) -> GraphQLResult { let mut error = ValidationError { name: None, quantity: None, }; if !(10 <= name.len() && name.len() <= 100) { error.name = Some("between 10 and 100".into()); } if !(1 <= quantity && quantity <= 10) { error.quantity = Some("between 1 and 10".into()); } if error.name.is_none() && error.quantity.is_none() { GraphQLResult::Ok(Item { name, quantity }) } else { GraphQLResult::Err(error) } } } fn main() {}
NOTE: We will likely want some kind of code generation to reduce repetition as the number of types required is significantly larger than before. Each resolver function has a custom
ValidationResult
which contains only fields provided by the function.
So, all the expected errors are handled directly inside the query. Additionally, all non-critical errors are known in advance by both the server and the client:
{
mutation {
addItem {
... on Item {
name
}
... on ValidationErrorsItem {
name
quantity
}
}
}
}
Example: Complex with critical errors
Our examples so far have only included non-critical errors. Providing errors inside a GraphQL schema still allows us to return unexpected critical errors when they occur.
In the following example, a theoretical database could fail and would generate errors. Since it is not common for a database to fail, the corresponding error is returned as a critical error:
extern crate juniper; use juniper::{graphql_object, graphql_value, FieldError, GraphQLObject, GraphQLUnion, ScalarValue}; #[derive(GraphQLObject)] pub struct Item { name: String, quantity: i32, } #[derive(GraphQLObject)] pub struct ValidationErrorItem { name: Option<String>, quantity: Option<String>, } #[derive(GraphQLUnion)] pub enum GraphQLResult { Ok(Item), Err(ValidationErrorItem), } pub enum ApiError { Database, } impl<S: ScalarValue> juniper::IntoFieldError<S> for ApiError { fn into_field_error(self) -> FieldError<S> { match self { Self::Database => FieldError::new( "Internal database error", graphql_value!({"type": "DATABASE"}), ), } } } pub struct Mutation; #[graphql_object] impl Mutation { fn add_item(&self, name: String, quantity: i32) -> Result<GraphQLResult, ApiError> { let mut error = ValidationErrorItem { name: None, quantity: None, }; if !(10 <= name.len() && name.len() <= 100) { error.name = Some("between 10 and 100".into()); } if !(1 <= quantity && quantity <= 10) { error.quantity = Some("between 1 and 10".into()); } if error.name.is_none() && error.quantity.is_none() { Ok(GraphQLResult::Ok(Item { name, quantity })) } else { Ok(GraphQLResult::Err(error)) } } } fn main() {}
Example: Shopify API
The Shopify API implements a similar approach. Their API is a good reference to explore this approach in a real world application.
Example: Non-struct objects
Up until now, we've only looked at mapping structs to GraphQL objects. However, any Rust type can be exposed a GraphQL object.
Using Result
-like enums can be a useful way of reporting validation errors from a mutation:
extern crate juniper; use juniper::{graphql_object, GraphQLObject}; #[derive(GraphQLObject)] struct User { name: String, } #[derive(GraphQLObject)] struct ValidationError { field: String, message: String, } enum SignUpResult { Ok(User), Error(Vec<ValidationError>), } #[graphql_object] impl SignUpResult { fn user(&self) -> Option<&User> { match self { Self::Ok(user) => Some(user), Self::Error(_) => None, } } fn error(&self) -> Option<&[ValidationError]> { match self { Self::Ok(_) => None, Self::Error(errs) => Some(errs.as_slice()) } } } fn main() {}
Here, we use an enum to decide whether a client's input data is valid or not, and it could be used as the result of e.g. a signUp
mutation:
{
mutation {
signUp(name: "wrong") {
user {
name
}
error {
field
message
}
}
}
}