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::{GraphQLObject, GraphQLUnion, graphql_object};
#[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::{GraphQLObject, GraphQLUnion, graphql_object};
#[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
ValidationResultwhich 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::{FieldError, GraphQLObject, GraphQLUnion, ScalarValue, graphql_object, graphql_value};
#[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::{GraphQLObject, graphql_object};
#[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
}
}
}
}