FOSRestBundle icon indicating copy to clipboard operation
FOSRestBundle copied to clipboard

Override FormErrorHandler for custom form validation errors

Open Mrkisha opened this issue 6 years ago • 11 comments

How can I override FormErrorHandler so that I can provide custom validation errors for API response.

I've tried literally everything. Including overriding the service and only default FormErrorHandler from fosrest bundle is triggered. Including this https://www.goetas.com/blog/how-to-add-custom-error-codes-to-your-symfony-api-responses/ and this https://codereviewvideos.com/course/beginners-guide-back-end-json-api-front-end-2018/video/handling-errors-symfony-4-fosrestbundle. None of them work.

Any nice way to handle this?

Mrkisha avatar Jun 02 '19 21:06 Mrkisha

Have you tried decorating it ? (see https://symfony.com/doc/current/service_container/service_decoration.html) Its tags would be inherited and that should solve your issue

GuilhemN avatar Jun 20 '19 09:06 GuilhemN

I havent tried it. Will try it today.

Mrkisha avatar Jun 30 '19 12:06 Mrkisha

@Mrkisha did you manage to get this working?

tarjei avatar Nov 13 '19 12:11 tarjei

Hi, you will need to do a compiler pass to decorate the service:

Here's the relevant code I used.

(this code is from a 3.4 app, YMMV)

class AppBundle extends Bundle
{
    /**
     * std build method
     *
     * @param ContainerBuilder $container the container
     *
     * @return see parent
    
     **/
    public function build(ContainerBuilder $container)
    {
        parent::build($container);
        $container->addCompilerPass(new JMSFormErrorHandlerPass());

    }

    public function boot()
    {
        parent::boot();
        Logger::init($this->container->get('logger'));
    }
}

namespace AppBundle\DependencyInjection;


use AppBundle\Serializer\JMSFormErrorHandlerOverride;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Reference;

/**
 * Remove the FOSRestbundle errorHandler in favour of ours
 * User: tarjei
 * Date: 13.11.2019 / 13:44
 */
class JMSFormErrorHandlerPass implements CompilerPassInterface
{
    public function process(ContainerBuilder $container)
    {
        if (!$container->has('jms_serializer.form_error_handler')) {
            return;
        }

        $container->register('fpg.fos_rest.serializer.form_error_handler', JMSFormErrorHandlerOverride::class)
            ->setDecoratedService('jms_serializer.form_error_handler')
            ->addArgument(new Reference('fpg.fos_rest.serializer.form_error_handler.inner'))
            ->addArgument(new Reference('logger'))
        ;
    }


}

<?php

namespace AppBundle\Serializer;


use JMS\Serializer\Context;
use JMS\Serializer\GraphNavigatorInterface;
use JMS\Serializer\Handler\FormErrorHandler as JMSFormErrorHandler;
use FOS\RestBundle\Serializer\Normalizer\FormErrorHandler as FosFormErrorHandler;
use JMS\Serializer\Handler\SubscribingHandlerInterface;
use JMS\Serializer\JsonSerializationVisitor;
use JMS\Serializer\Visitor\SerializationVisitorInterface;
use JMS\Serializer\XmlSerializationVisitor;
use Psr\Log\LoggerInterface;
use Symfony\Component\Form\Form;
use Symfony\Component\Form\FormError;
use Symfony\Component\Translation\TranslatorInterface;


/**
 */
class JMSFormErrorHandlerOverride extends FosFormErrorHandler
{
    private $formErrorHandler;
    /**
     * @var LoggerInterface
     */
    private $logger;

    public function __construct($formErrorHandler, LoggerInterface $logger)
    {
        $this->formErrorHandler = $formErrorHandler;
        $this->logger = $logger;
    }

    public static function getSubscribingMethods()
    {
        return JMSFormErrorHandler::getSubscribingMethods();
    }

    public function serializeFormToJson(JsonSerializationVisitor $visitor, Form $form, array $type, Context $context = null)
    {
        $isRoot = !interface_exists(SerializationVisitorInterface::class) && null === $visitor->getRoot();
        $result = $this->adaptFormArray($this->formErrorHandler->serializeFormToJson($visitor, $form, $type), $context);

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

        return $result;
    }

    public function __call($name, $arguments)
    {
        return call_user_func_array([$this->formErrorHandler, $name], $arguments);
    }

    private function adaptFormArray(\ArrayObject $serializedForm, Context $context = null)
    {
        $statusCode = $this->getStatusCode($context);
        if (isset($serializedForm[''])) {
            $serializedForm['errors'] = $serializedForm[''];
            unset($serializedForm['']);
        }

        foreach($serializedForm as $key => $sub) {

        }


        if (null !== $statusCode) {
            return [
                'code' => $statusCode,
                'messages' => isset($serializedForm['errors']) ? $serializedForm['errors'] : ['Validation failed'],
                'formErrors' => $serializedForm,
            ];
        }

        return $serializedForm;
    }

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

        if ($context->hasAttribute('status_code')) {
            return $context->getAttribute('status_code');
        }
    }
}

tarjei avatar Nov 13 '19 13:11 tarjei

@tarjei I havent had time yet.

Mrkisha avatar Nov 14 '19 10:11 Mrkisha

@Mrkisha se my post above :)

tarjei avatar Nov 14 '19 10:11 tarjei

This is how I managed to extend the FOS\RestBundle\Serializer\Normalizer\FormErrorHandler in Symfony 4.4 for an API app.

// config/services.yml

    App\Serializer\Normalizer\FormErrorHandler:
        decorates: 'fos_rest.serializer.form_error_handler'
        decoration_priority: -20
// App\Serializer\Normalizer\FormErrorHandler.php

namespace App\Serializer\Normalizer;

use FOS\RestBundle\Serializer\Normalizer\FormErrorHandler as FOSJMSFormErrorHandler;
use JMS\Serializer\Context;
use JMS\Serializer\JsonSerializationVisitor;
use JMS\Serializer\Visitor\SerializationVisitorInterface;
use Symfony\Component\Form\Form;
use Symfony\Component\Form\FormInterface;

/**
 * Extend the JMS FormErrorHandler to include more informations when using the ViewHandler.
 */
class FormErrorHandler extends FOSJMSFormErrorHandler
{
    /** @var FOSJMSFormErrorHandler */
    private $formErrorHandler;

    /**
     * FormErrorHandler constructor.
     *
     * @param FOSJMSFormErrorHandler $formErrorHandler
     */
    public function __construct(FOSJMSFormErrorHandler $formErrorHandler)
    {
        $this->formErrorHandler = $formErrorHandler;
    }

    /**
     * @param JsonSerializationVisitor $visitor
     * @param Form                     $form
     * @param array                    $type
     * @param Context|null             $context
     *
     * @return array|\ArrayObject
     */
    public function serializeFormToJson(
        JsonSerializationVisitor $visitor,
        Form $form,
        array $type,
        Context $context = null
    )
    {
        $isRoot = !interface_exists(SerializationVisitorInterface::class) && null === $visitor->getRoot();
        $result = $this->adaptFormArray($form, $this->formErrorHandler->serializeFormToJson($visitor, $form, $type), $context);

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

        return $result;
    }

    /**
     * @param FormInterface $form
     * @param \ArrayObject  $serializedForm
     * @param Context|null  $context
     *
     * @return array|\ArrayObject
     */
    private function adaptFormArray(FormInterface $form, \ArrayObject $serializedForm, Context $context = null)
    {
        $statusCode = $this->getStatusCode($context);

        if (null !== $statusCode) {
            return [
                'code' => $statusCode,
                'message' => 'Validation Failed',
                'formErrors' => $this->getErrorsFromForm($form),
            ];
        }

        return $serializedForm;
    }

    /**
     * @param Context|null $context
     *
     * @return mixed|void
     */
    private function getStatusCode(Context $context = null)
    {
        if (null === $context) {
            return;
        }

        if ($context->hasAttribute('status_code')) {
            return $context->getAttribute('status_code');
        }
    }

    /**
     * @param FormInterface $form
     *
     * @return array
     */
    private function getErrorsFromForm(FormInterface $form)
    {
        $errors = [];

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

        return $errors;
    }
}

This will output the validation messages like this:

{
    "code": 400,
    "message": "Validation Failed",
    "formErrors": {
        "fields": {
            "key": [
                "`key` MUST NOT be empty."
            ]
        }
    }
}

codr86 avatar Jan 21 '20 09:01 codr86

Possibly related: #1992

W0rma avatar Jan 21 '20 15:01 W0rma

@codr86 @tarjei you guys actually doomed yourself: bundle developers say you can never rely on (you can not extend) FOS\RestBundle\Serializer\Normalizer\FormErrorHandler

https://github.com/FriendsOfSymfony/FOSRestBundle/issues/2240#issuecomment-640274425

Additionally, these classes are tagged as internal so you need to be extra careful as we may change and/or remove them at any time without further notice/deprecation.

So, one day you update your dependencies and your whole API does not work anymore. I would really recommend you guys to do a refactoring now and find a different way as long as the class is still there and so you have time.

I'm really thinking of entirely dropping this bundle and doing the things myself, because instead of solving developer's pain the bundle just adds even more pain, problems, confusion and frustration. I'm not even talking about the bundle's documentation.

yyaremenko avatar Jun 12 '20 09:06 yyaremenko

Please, could you explain me the right way to customise form errors serialization? FOS\RestBundle\Serializer\Normalizer\FormErrorHandler should not be extended because it is internal. FOS\RestBundle\Serializer\Normalizer\FormErrorNormalizer does not being called when using JMS Serializer. Own handlers for JMS Serializer are not being used for forms because of FOS FormErrorHandler...

bzis avatar Dec 22 '20 11:12 bzis

For those who may come across this post with the same problem

1. Create a decorator over JMS\Serializer\Handler\FormErrorHandler example:

namespace App\Decorator;

use JMS\Serializer\Handler\FormErrorHandler as JMSFormErrorHandler;
use JMS\Serializer\Handler\SubscribingHandlerInterface;
use JMS\Serializer\JsonSerializationVisitor;
use ReflectionMethod;
use Symfony\Component\Form\Form;

class FormErrorHandler implements SubscribingHandlerInterface
{
    private const KEY_CHILDREN = 'children';
    private const KEY_ERRORS = 'errors';
    private const KEY_FORM_ERROR_GLOBAL = 'global_form_error';

    /**
     * @var JMSFormErrorHandler
     */
    private $jmsFormErrorHandler;

    public function __construct(JMSFormErrorHandler $jmsFormErrorHandler)
    {
        $this->jmsFormErrorHandler = $jmsFormErrorHandler;
    }

    // any invocations of methods our decorator does not have
    // are passed to the underlying JMSFormErrorHandler
    public function __call($methodName, $methodArguments)
    {
        $reflectedMethod = new ReflectionMethod($this->jmsFormErrorHandler, $methodName);
        // disallow invocation of a private method
        if ($reflectedMethod->isPrivate()) {
            throw new \Exception('Can not access a private method.');
        }

        return $reflectedMethod->invokeArgs($this->jmsFormErrorHandler, $methodArguments);
    }

    // DO YOUR STUFF HERE
    public function serializeFormToJson(JsonSerializationVisitor $visitor, Form $form, array $type)
    {
        // pre-serialize form errors with JMSFormErrorHandler 
        $result = $this->jmsFormErrorHandler->serializeFormToJson($visitor, $form, $type);

        // now, do your stuff, e.g.:
        $processed = $this->getErrorsProcessed($result);
        if (isset($result[self::KEY_ERRORS])) {
            $processed[self::KEY_FORM_ERROR_GLOBAL] = $result[self::KEY_ERRORS];
        }

        return $processed;
    }

    // EXAMPLE
    private function getErrorsProcessed($errors): array
    {
        if (isset($errors[self::KEY_CHILDREN])) {
            return $this->getErrorsProcessed($errors[self::KEY_CHILDREN]);
        }

        $normalized = [];

        foreach ($errors as $fieldName => $fieldData) {
            if (0 === count($fieldData)) {
                continue;
            }

            if (!isset($fieldData[self::KEY_ERRORS])) {
                continue;
            }

            $actualErrors = $fieldData[self::KEY_ERRORS];
            if (0 !== count($actualErrors)) {
                $normalized[$fieldName] = $actualErrors;
            }
        }

        return $normalized;
    }
}

2. Register the decorator in your services.yaml, add

    # decorators

    App\Decorator\FormErrorHandler:
        decorates: jms_serializer.form_error_handler

yyaremenko avatar Mar 25 '21 17:03 yyaremenko