How to add custom error codes to your Symfony API responses

When writing APIs, a proper error handling is fundamental.

HTTP status codes are a great start, but often when we deal with user inputs is not enough. If out model has complex validation rules, understanding the reason behind an 400 Bad Request error can be not trivial.

Fortunately when for symfony developers there are many libraries to deal with it. Symfony Validator, Symfony Form, FOS REST Bundle and JMS Serializer combined allows you to have nice error messages to be shown to your users.

The entity

This is out model and in this case is petty trivial with only one property called "name". By using the symfony validator annotations we define that the name can not be blank.

namespace AppBundle\Entity;

use Symfony\Component\Validator\Constraints as Assert;

class Author
{
    /**
     * @Assert\NotBlank()
     */
    public $name;
}

The form

This is the symfony form type to handle the author createing.

namespace AppBundle\Form;

use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;

class AuthorType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder->add('name');
    }

    public function configureOptions(OptionsResolver $resolver)
    {
        $resolver->setDefault('data_class', Author::class);
    }
}

The controller

This is the controller that acts as a glue of our components.

namespace AppBundle\Controller;

use AppBundle\Entity\Author;
use AppBundle\From\AuthorType;
use FOS\RestBundle\View\View;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;

class AuthorController
{
    public function create(Request $request)
    {
        $author = new Author(); 
        $form = $this->createForm(AuthorType::class, $author);
        $form->handleRequest($request);

        if ($form->isValid()) {

            // do something here

            return new View($author, Response::HTTP_CREATED);
        } else {
            return new View($form, Response::HTTP_BAD_REQUEST);
        }
    }
}

The result

The result of a PUT call to the create action, if the data are not correct will be:

HTTP/1.1 400 Bad Request
Content-Type: application/json

{
  "code": 400,
  "message": "Validation Failed",
  "errors": {
    "children": {
      "name": {
          "errors": [
            "This value shoul not be blank."
          ],
      }
    }
  }
}

Here the error message can be displayed to users, but if the API has to be consumed by some other software (as example a mobile app), the error message can be not sufficient anymore. Having some "error code" that can be hardcoded in some reliable way in the app code, will allow us to provide a better user experience (offering some nice popup as example).

To do so is pretty easy, we will have to add just a custom error handler into the JMS serializer serialization process. The custom error handler will use the the symfony validator payload feature to add machine readable error codes.

Custom error codes

The entity

In the original entity we just specify the payload property with our error code.

(The provided error handler accepts a string or an array of strings as error code)

namespace AppBundle\Entity;

use Symfony\Component\Validator\Constraints as Assert;

class Author
{
    /**
     * @Assert\NotBlank(payload={"error_code"="INVALID_NAME"})
     */
    public $name;
}

The error handler

This is the longest class in this tutorial but its logic is pretty simple and is mainly a copy/paste from the original error handler.

namespace AppBundle\Serializer;

use JMS\Serializer\Context;
use JMS\Serializer\GraphNavigator;
use JMS\Serializer\Handler\SubscribingHandlerInterface;
use JMS\Serializer\JsonSerializationVisitor;
use JMS\Serializer\VisitorInterface;
use Symfony\Component\Form\Form;
use Symfony\Component\Form\FormError;
use Symfony\Component\Translation\TranslatorInterface;
use Symfony\Component\Validator\ConstraintViolation;

class FormErrorHandler implements SubscribingHandlerInterface
{
    private $translator;

    public static function getSubscribingMethods()
    {
        $methods = array();
        $methods[] = array(
            'direction' => GraphNavigator::DIRECTION_SERIALIZATION,
            'type' => Form::class,
            'format' => 'json',
        );

        return $methods;
    }

    public function __construct(TranslatorInterface $translator)
    {
        $this->translator = $translator;
    }

    public function serializeFormToJson(JsonSerializationVisitor $visitor, Form $form, array $type, Context $context = null)
    {
        $isRoot = null === $visitor->getRoot();
        $result = $this->adaptFormArray($this->convertFormToArray($visitor, $form), $context);

        if ($isRoot) {
            $visitor->setRoot($result);
        }

        return $result;

    }

    private function getErrorMessage(FormError $error)
    {
        if (null !== $error->getMessagePluralization()) {
            return $this->translator->transChoice($error->getMessageTemplate(), $error->getMessagePluralization(), $error->getMessageParameters(), 'validators');
        }

        return $this->translator->trans($error->getMessageTemplate(), $error->getMessageParameters(), 'validators');
    }

    private function getErrorPayloads(FormError $error)
    {
        /**
         * @var $cause ConstraintViolation
         */
        $cause = $error->getCause();

        if (!($cause instanceof ConstraintViolation) || !$cause->getConstraint() || empty($cause->getConstraint()->payload['error_code'])) {
            return null;
        }

        return $cause->getConstraint()->payload['error_code'];
    }

    private function convertFormToArray(VisitorInterface $visitor, Form $data)
    {
        $isRoot = null === $visitor->getRoot();

        $form = new \ArrayObject();
        $errorCodes = array();
        $errors = array();

        foreach ($data->getErrors() as $error) {
            $errors[] = $this->getErrorMessage($error);
        }

        foreach ($data->getErrors() as $error) {
            $errorCode = $this->getErrorPayloads($error);
            if (is_array($errorCode)) {
                $errorCodes = array_merge($errorCodes, array_values($errorCode));
            } elseif ($errorCode !== null) {
                $errorCodes[] = $errorCode;
            }
        }

        if ($errors) {
            $form['errors'] = $errors;
            if ($errorCodes) {
                $form['error_codes'] = array_unique($errorCodes);
            }
        }

        $children = array();
        foreach ($data->all() as $child) {
            if ($child instanceof Form) {
                $children[$child->getName()] = $this->convertFormToArray($visitor, $child);
            }
        }

        if ($children) {
            $form['children'] = $children;
        }

        if ($isRoot) {
            $visitor->setRoot($form);
        }

        return $form;
    }


    private function adaptFormArray(\ArrayObject $serializedForm, Context $context = null)
    {
        $statusCode = $this->getStatusCode($context);
        if (null !== $statusCode) {
            return [
                'code' => $statusCode,
                'message' => 'Validation Failed',
                'errors' => $serializedForm,
            ];
        }

        return $serializedForm;
    }

    private function getStatusCode(Context $context = null)
    {
        if (null === $context) {
            return;
        }

        $statusCode = $context->attributes->get('status_code');
        if ($statusCode->isDefined()) {
            return $statusCode->get();
        }
    }
}

Registering the error handler

Add this snippet to your services.xml to register the custom error handler to your application.

<service class="AppBundle\Serializer\FormErrorHandler" id="AppBundle\Serializer\FormErrorHandler">
    <argument id="translator" type="service"/>
    <tag name="jms_serializer.subscribing_handler"/>
</service>

The result

That's all, ehe result of a PUT call to the create action now will contain our custom error codes.

HTTP/1.1 400 Bad Request
Content-Type: application/json

{
  "code": 400,
  "message": "Validation Failed",
  "errors": {
    "children": {
      "name": {
          "errors": [
            "This value should not be blank."
          ],
          "error_codes": [
            "INVALID_NAME"
          ],
      }
    }
  }
}

Conclusion

In this short tutorial we saw how is easy to add custom error codes to your API.

Of course, using the same strategy is possible to add many more information as "error_severity" or whatever you prefer.

php, jms-serializer, json, serialization, symfony, symfony-form, error-codes, api, custom-error

Do you need something similar for your company?