How to use Symfony Validator with Overblog/GraphQLBundle

We have been working for the last few months at AssoConnect.com to set up a GraphQL API with our Symfony 4 backend.

We use the bundle made by the Overblog team: https://github.com/overblog/GraphQLBundle.

This bundle is built upon the PHP port of Graph made by Webonyx: https://github.com/webonyx/graphql-php

The bundle comes with a lot of nice features but we were missing some tools to combine Symfony validation with GraphQL mutations.

Symfony validation component is a very good system to check that values coming from a user are correct and can be processed by your business logic.

For instance, if the username is a required field of your subscription form, then something must trigger an error and rejects a mutation with an empty username.

A common use case with Symfony and Doctrine is to use annotations on an entity properties like as described in Symfony documentation:

<?php
namespace App\Entity;
use Symfony\Component\Validator\Constraints as Assert;
Class User
{
  /**
* @Assert\NotBlank()
*/
protected $username;

}

Another common use case is to use the entity within a form but this is not recommended. Martin Hujer describes in this article [https://blog.martinhujer.cz/symfony-forms-with-request-objects] what he calls « request object » as a placeholder for user values.

The GraphQL specification does not give guidelines nor best practices on how to make mutation so we have decided to use these request objects as input for our mutations.

For instance a user registration mutation could be done like this:

mutation {
createUser(input: $input){
id
}
}
$input: {
username: “sylfabre”
firstname: “Sylvain”
lastname: “Fabre”
}

So we will have a request object to match this input with some Symfony constraints:

<?php
# src/GraphQL/Input/UserInput.php
namespace App\GraphQL\Input;
use Symfony\Component\Validator\Constraints as Assert;
Class UserInput
{

/**
* @Assert\NotBlank()
*/
public $username;

public $firstname;

public $lastname;
}

A GraphQL mutation can be set up like this:

#config/graphql/types/Mutation.types.yaml
Mutation:
type: object
config:
fields:
createUser:
type: User!
resolve: ‘@=mutation(“App\\GraphQL\\Mutation\\UserMutation::createUser”, [args])’
args:
input:
type: UserInput!

The mutation uses a UserInput type so let’s define it:

# config/graphql/types/UserInput.types.yaml
type: input-object
config:
fields:
username:
type: String
      firstname:
type: String
      lastname:
type: String

And the PHP mutation file:

<?php
# src/GraphQL/Mutation/UserMutation.php
namespace App\GraphQL\Mutation;
use App\Entity\User;
use App\GraphQL\Input\UserInput;
use Doctrine\ORM\EntityManager;
use Overblog\GraphQLBundle\Definition\Argument;
use Overblog\GraphQLBundle\Definition\Resolver\MutationInterface;
use Symfony\Component\Validator\Validator\ValidatorInterface;
Class CommunityMutation implements MutationInterface
{
  /**
* @EntityManager
*/
protected $entityManager;
  /**
* @ValidatorInterface
*/
protected $validator;
  public function __construct(EntityManager $entityManager, ValidatorInterface $validator)
{
$this->entityManager;
$this->validator = $validator;
}
  public function createUser(Argument $args) :User
{
    $rawArgs = $args->getRawArguments();

$input = new UserInput();
foreach($rawArgs[‘input’] as $key => $value){
$input->$key = $value;
}

// Validation
$errors = $this->validator->validate($input);

if(count($errors) !== 0){
// How to reject the request and display a nice message to the user?
}

// Insert your business logic
$user = new User();
$user->setUsername($input->username);
$user->setFirstname($input->firstname);
$user->setLastname($input->lastname);

// Persist in database
$this->entityManager->persist($user);
$this->entityManager->flush();

// Return
return $user;
  }
}

Creating mutations this way leads to 2 main problems:

1. Using validation constraints in both the request object UserInput and the entity User is duplicate code

2. How to turn Symfony validation violations into a nice message for the user? And how to always use the same format for this message so that a developer using our API will always find violation errors at the same place in the request response body?

For problem #2: Overblog GraphQL bundle provides a GraphQL\Error\ClientAware interface that an exception must implement so its original message will be displayed to the user instead of a generic “Internal server error”. This will be part of the solution but is documented in the GraphQL bundle.

Here comes our graphql-mutation-validation-bundle to solve these issues.

This bundle provides the following components:

  • A basic PHP object as a placeholder for user values that can be easily hydrated from the Overblog\GraphQLBundle\Definition\Argument $args argument of every GraphQL mutation
  • A Symfony service to validate a request object. The service will throw a GraphQL\Error\ClientAware exception to halt the execution of the mutation resolver method that will be caught and processed by GraphQL
  • A formatter to display the exception payload as a standard message
  • A custom constraint to use @see PHPDoc annotations to avoid duplicate code between request objects and entities

Let’s see now how to use this bundle.

It available on Packagist at https://packagist.org/packages/assoconnect/graphql-mutation-validator-bundle so we can use Composer to install it:

composer require assoconnect/graphql-mutation-validator-bundle

Then we update our UserInput with the following changes:

  • UserInputnow inherits from AssoConnect\GraphQLMutationValidatorBunde\RequestObject
  • We use @see annotations instead of duplicating constraints
<?php
namespace App\GraphQL\Input;
use App\Entity\User;
use AssoConnect\GraphQLMutationValidatorBundle\RequestObject;
use AssoConnect\GraphQLMutationValidatorBundle\Validator\Constraints as AssoConnectAssert;
/**
* @AssoConnectAssert\GraphQLRequestObject()
*/
Class UserInput extends RequestObject
{

/**
* @see User::$username
*/
public $username;

public $firstname;

public $lastname;
}

As a bonus, your IDE should add a link on the @see annotation to quickly find the source of User::$username (PHPStorm does and you can follow the link holding the Ctrl key while clicking).

And we update the mutation to use the bundle validator service. We will use Symfony autowiring feature to inject the service in our mutation. If you’re not familiar with this Dependency Injection feature, have a look at the Symfony documentation.

<?php
# src/GraphQL/Mutation/UserMutation.php
namespace App\GraphQL\Mutation;
use App\Entity\User;
use App\GraphQL\Input\UserInput;
use AssoConnect\GraphQLMutationValidatorBundle\Validator\MutationValidator
use Doctrine\ORM\EntityManager;
use Overblog\GraphQLBundle\Definition\Argument;
use Overblog\GraphQLBundle\Definition\Resolver\MutationInterface;
Class CommunityMutation implements MutationInterface
{
  /**
* @EntityManager
*/
protected $entityManager;
  /**
* @MutationValidator
*/
protected $validator;
  public function __construct(EntityManager $entityManager, MutationValidator $validator)
{
$this->entityManager;
$this->validator = $validator;
}
  public function createUser(Argument $args) :User
{
$input = new UserInput($args);

// Validation
$this->validator->validate($input);

// Insert your business logic
$user = new User();
$user->setUsername($input->username);
$user->setFirstname($input->firstname);
$user->setLastname($input->lastname);

// Persist in database
$this->entityManager->persist($user);
$this->entityManager->flush();

// Return
return $user;
  }
}

Let’s run our mutation:

mutation {
createUser(input: $input){
id
}
}
$input: {
username: “”
firstname: “Sylvain”
lastname: “Fabre”
}

We now get this response:

{
“errors”: [
{
“message”: “Invalid dataset”,
“path”: [“createUser “],
“state”: [
“username”: [“This value should not be blank.”]
      ],
“code”: [
“username”: [“e70f90dd-8d45–404f-81df-804612841e7c”]
]
}
]
}

The response contains both constraint message and error UUID code so depending on your needs and internal process, you can:
- Display the message to your end user after localization if you’re using Symfony translation / localization
- Keep the message as a developer hint
- Use the code as a key for translation