Symfony 3 API REST - Try Catch Exceptions to JSON response format

csu picture csu · May 8, 2017 · Viewed 9.3k times · Source

I create the api rest server with Symfony and these bundles FosRestBundle, jms/serializer-bundle, lexik/jwt-authentication-bundle.

How can I send a clean json response format like this :

Missing field "NotNullConstraintViolationException"
    {'status':'error','message':"Column 'name' cannot be null"}
or
    {'status':'error','message':"Column 'email' cannot be null"}
Or Duplicate entry "UniqueConstraintViolationException" :
    {'status':'error','message':"The email [email protected] exists in database."}

Instead of system message:

UniqueConstraintViolationException in AbstractMySQLDriver.php line 66: An exception occurred while executing 'INSERT INTO user (email, name, role, password, is_active) VALUES (?, ?, ?, ?, ?)' with params ["[email protected]", "etienne", "ROLE_USER", "$2y$13$tYW8AKQeDYYWvhmsQyfeme5VJqPsll\/7kck6EfI5v.wYmkaq1xynS", 1]: SQLSTATE[23000]: Integrity constraint violation: 1062 Duplicate entry '[email protected]' for key 'UNIQ_8D93D649E7927C74'

Return a clean json reponse with the name mandatory or missed field.

Here my controller:

    <?php

namespace AppBundle\Controller;

use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Method;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use AppBundle\Entity\User;
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
use Symfony\Component\Debug\ExceptionHandler;
use Symfony\Component\Debug\ErrorHandler;
use Symfony\Component\HttpFoundation\JsonResponse;

use FOS\RestBundle\Controller\Annotations as Rest; // alias pour toutes les annotations

class DefaultController extends Controller
{


 /**
 * @Rest\View()
 * @Rest\Post("/register")
 */
public function registerAction(Request $request)
{
    //catch all errors and convert them to exceptions
    ErrorHandler::register();

    $em = $this->get('doctrine')->getManager();
    $encoder = $this->container->get('security.password_encoder');

    $username = $request->request->get('email'); 
    $password = $request->request->get('password');
    $name = $request->request->get('name');

    $user = new User($username);

    $user->setPassword($encoder->encodePassword($user, $password));
    $user->setName($name);
    $user->setRole('ROLE_USER');
    try {
        $em->persist($user);
        $em->flush($user);
    }
    catch (NotNullConstraintViolationException $e) {
        // Found the name of missed field
            return new JsonResponse();
    } 
    catch (UniqueConstraintViolationException $e) {
        // Found the name of duplicate field
            return new JsonResponse();
    } 
    catch ( \Exception $e ) {

        //for debugging you can do like this
        $handler = new ExceptionHandler();
        $handler->handle( $e );
        return new JsonResponse(
            array(
                'status' => 'errorException', 
                'message' => $e->getMessage()
            )
        );
    }

    return new Response(sprintf('User %s successfully created', $user->getUsername()));
}
}

thanks

Answer

rafrsr picture rafrsr · May 8, 2017

We use the following approach:

Generic class for API exceptions:

class ApiException extends \Exception
{  
    public function getErrorDetails()
    {
        return [
            'code' => $this->getCode() ?: 999,
            'message' => $this->getMessage()?:'API Exception',
        ];
    }
}

Create a validation exception extending ApiException

class ValidationException extends ApiException
{
    private $form;

    public function __construct(FormInterface $form)
    {
        $this->form = $form;
    }

    public function getErrorDetails()
    {
        return [
            'code' => 1,
            'message' => 'Validation Error',
            'validation_errors' => $this->getFormErrors($this->form),
        ];
    }

    private function getFormErrors(FormInterface $form)
    {
        $errors = [];
        foreach ($form->getErrors() as $error) {
            $errors[] = $error->getMessage();
        }
        foreach ($form->all() as $childForm) {
            if ($childForm instanceof FormInterface) {
                if ($childErrors = $this->getFormErrors($childForm)) {
                    $errors[$childForm->getName()] = $childErrors;
                }
            }
        }

        return $errors;
    }
}

Use the exception in your controller when the form has errors

if ($form->getErrors(true)->count()) {
    throw new ValidationException($form);
}

Create and configure your ExceptionController

class ExceptionController extends FOSRestController
{

    public function showAction($exception)
    {
        $originException = $exception;

        if (!$exception instanceof ApiException && !$exception instanceof HttpException) {
            $exception = new HttpException($this->getStatusCode($exception), $this->getStatusText($exception));
        }

        if ($exception instanceof HttpException) {
            $exception = new ApiException($this->getStatusText($exception), $this->getStatusCode($exception));
        }

        $error = $exception->getErrorDetails();

        if ($this->isDebugMode()) {
            $error['exception'] = FlattenException::create($originException);
        }

        $code = $this->getStatusCode($originException);

        return $this->view(['error' => $error], $code, ['X-Status-Code' => $code]);
    }

    protected function getStatusCode(\Exception $exception)
    {
        // If matched
        if ($statusCode = $this->get('fos_rest.exception.codes_map')->resolveException($exception)) {
            return $statusCode;
        }

        // Otherwise, default
        if ($exception instanceof HttpExceptionInterface) {
            return $exception->getStatusCode();
        }

        return 500;
    }

    protected function getStatusText(\Exception $exception, $default = 'Internal Server Error')
    {
        $code = $this->getStatusCode($exception);

        return array_key_exists($code, Response::$statusTexts) ? Response::$statusTexts[$code] : $default;
    }

    public function isDebugMode()
    {
        return $this->getParameter('kernel.debug');
    }
}

config.yml

fos_rest:
    #...
    exception:
        enabled: true
        exception_controller: 'SomeBundle\Controller\ExceptionController::showAction'

see: http://symfony.com/doc/current/bundles/FOSRestBundle/4-exception-controller-support.html

With this approach can create custom exceptions with custom messages and codes for each type of error (helpful for API documentation) in the other hand hide other internal exceptions showing to the API consumer only "Internal Server Error" when the exception thrown does not extended from APIException.