Override FormErrorHandler for custom form validation errors
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?
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
I havent tried it. Will try it today.
@Mrkisha did you manage to get this working?
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 I havent had time yet.
@Mrkisha se my post above :)
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."
]
}
}
}
Possibly related: #1992
@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.
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...
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