How can I process JSON with nested objects with Symfony-Form-Component?

  fosrestbundle, php, symfony, symfony-forms

I’m working on a Server that is providing a REST-API. I’ve chosen to use Symfony with FOS-Rest-Bundle.
Using it with simple objects works great. But POSTing a object with nested objects inside is troubling me.

I use the symfony form component for reading/validating the data.
It mostly works, I think. But fetching the (already existing) Ingredient and associate it with my Component seems to make trouble.

For that I tried to do as the documenation told me.
I created the IngredientSelectorType that is adding a model transformer to the form. This should handle the transformation.

But somewhere a error is happening.
I get the message "The selected Ingredient does not exist", that is due to a exception that is thrown in the form component: "Submitted data was expected to be text or number, array given."

What am I doing wrong? I’m working on this for many evenings and I think I’m stuck.

Here are the relevant files, JSON-request and error-response. If you need some of my configuration I could add it to this question

Request

{
  "steps" : [
    {
      "components": [
        {
          "ingredient": {
            "id": 123,
            "name": "some ingredient name"
          }
        }
      ]
    }
  ]
}

this is decoded to

Array
(
    [steps] => Array
        (
            [0] => Array
                (
                    [components] => Array
                        (
                            [0] => Array
                                (
                                    [ingredient] => Array
                                        (
                                            [id] => 123
                                            [name] => some ingredient name
                                        )

                                )

                        )

                )

        )

)

Entities

class Recipe
{
    // ...
    
    /**
     * @ORMOneToMany(targetEntity=Step::class, mappedBy="recipe", cascade={"persist", "remove"}, orphanRemoval=true)
     */
    private $steps;

    // ...

}
class Step
{
    // ...

    /**
     * @ORMOneToMany(targetEntity=Component::class, mappedBy="step", cascade={"persist", "remove"}, orphanRemoval=true)
     */
    private $components;

    // ...
}
class Component
{
    // ...

    /**
     * @ORMManyToOne(targetEntity=Ingredient::class)
     */
    private $ingredient;

    // ...
}
class Ingredient
{
    /**
     * @ORMId
     * @ORMGeneratedValue
     * @ORMColumn(type="integer")
     */
    private $id;

    /**
     * @ORMColumn(type="string", length=45)
     */
    private $name;

    // ...
}

Controller

use AppEntityRecipe;
use AppFormTypeRecipeType;
use FOSRestBundleControllerAbstractFOSRestController;
use FOSRestBundleControllerAnnotations as Rest;
use SymfonyComponentHttpFoundationRequest;
use SymfonyComponentHttpFoundationResponse;

class RecipeController extends AbstractFOSRestController
{
    /**
     * @RestPost("/recipes")
     */
    public function postAction(Request $request): Response
    {
        return $this->save(new Recipe(), $request);
    }

    private function save(Recipe $recipe, Request $request): Response
    {
        $form = $this->createForm(
            RecipeType::class,
            $recipe
        );

        // returns a array representation of the json
        $data = $request->request->all();
        $form->submit($data);

        if (!$form->isValid()) {
            return $this->handleView(
                $this->view($form->getErrors(true), Response::HTTP_BAD_REQUEST)
            );
        }

        $em = $this->getDoctrine()->getManager();

        $newRecipe = $form->getData();
        $em->persist($newRecipe);
        $em->flush();

        return $this->handleView(
            $this->view($newRecipe, Response::HTTP_CREATED)
        );
    }
}

Form

class RecipeType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder
            // ...
            ->add('steps', CollectionType::class, [
                'entry_type' => StepType::class,
                'allow_add' => true,
                'by_reference' => false,
                'allow_delete' => true,
            ])
            // ...
        ;
    }

    public function configureOptions(OptionsResolver $resolver)
    {
        $resolver->setDefaults([
            'data_class' => Recipe::class,
        ]);
    }
}
class StepType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder
            // ...
            ->add('components', CollectionType::class, [
                'entry_type' => ComponentType::class,
                'allow_add' => true,
                'by_reference' => false,
                'allow_delete' => true,
            ])
            // ...
        ;
    }


    public function configureOptions(OptionsResolver $resolver)
    {
        $resolver->setDefaults([
            'data_class' => Step::class,
        ]);
    }
}

class ComponentType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder
            // ...
            ->add('ingredient', IngredientSelectorType::class)
            // ...
        ;
    }

    public function configureOptions(OptionsResolver $resolver)
    {
        $resolver->setDefaults([
            'data_class' => Component::class,
        ]);
    }
}

class IngredientSelectorType extends AbstractType
{
    private EntityManagerInterface $entityManager;

    public function __construct(EntityManagerInterface $entityManager)
    {
        $this->entityManager = $entityManager;
    }

    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $transformer = new EntityToIdTransformer(Ingredient::class, $this->entityManager);
        $builder->addModelTransformer($transformer);
    }

    public function configureOptions(OptionsResolver $resolver)
    {
        $resolver->setDefaults([
            'invalid_message' => 'The selected Ingredient does not exist',

        ]);
    }

    public function getParent(): string
    {
        return TextType::class;
    }
}

class EntityToIdTransformer implements DataTransformerInterface
{
    private EntityManagerInterface $em;

    private string $entityClass;

    public function __construct(string $entityClass, EntityManagerInterface $em)
    {
        $this->em = $em;
        $this->entityClass = $entityClass;
    }

    public function transform($value): string
    {
        if (null === $value) {
            return '';
        }

        return $value->getId();
    }

    public function reverseTransform($value): ?object
    {
        if (!is_array($value)) {
            return null;
        }

        $entity = $this->em
            ->getRepository($this->entityClass)
            ->findOneBy(['id' => $value['id']]);
        ;

        if (null === $entity) {
            throw new TransformationFailedException(sprintf('An Entity with id "%s" does not exist!', $value['id']));
        }

        return $entity;
    }
}

The error Response

please ignore the other properties of my classes

[
   {
      "message":"The selected Ingredient does not exist",
      "message_template":"The selected Ingredient does not exist",
      "message_parameters":{
         "{{ value }}":"null"
      },
      "cause":{
         "message_template":"The selected Ingredient does not exist",
         "parameters":{
            "{{ value }}":"null"
         },
         "message":"The selected Ingredient does not exist",
         "root":{
            "code":400,
            "message":"Validation Failed",
            "errors":{
               "children":{
                  "title":[
                     
                  ],
                  "title_translation":[
                     
                  ],
                  "description":[
                     
                  ],
                  "source":[
                     
                  ],
                  "steps":{
                     "children":[
                        {
                           "children":{
                              "title":[
                                 
                              ],
                              "number":[
                                 
                              ],
                              "tasks":{
                                 "children":[
                                    {
                                       "children":{
                                          "description":[
                                             
                                          ],
                                          "number":[
                                             
                                          ]
                                       }
                                    }
                                 ]
                              },
                              "components":{
                                 "children":[
                                    {
                                       "children":{
                                          "quantity":[
                                             
                                          ],
                                          "preparation":[
                                             
                                          ],
                                          "ingredient":{
                                             "errors":[
                                                "The selected Ingredient does not exist"
                                             ]
                                          },
                                          "measuring_unit":{
                                             "errors":[
                                                "The selected MeasuringUnit does not exist"
                                             ]
                                          }
                                       }
                                    }
                                 ]
                              }
                           }
                        }
                     ]
                  },
                  "cooking_time":[
                     
                  ],
                  "preparation_time":[
                     
                  ],
                  "keywords":[
                     
                  ],
                  "matching_recipes":[
                     
                  ],
                  "created_at":[
                     
                  ],
                  "updated_at":[
                     
                  ]
               }
            }
         },
         "property_path":"children[steps].children[0].children[components].children[0].children[ingredient]",
         "constraint":{
            "targets":"class",
            "required_options":[
               
            ]
         },
         "cause":{
            "invalid_message_parameters":[
               
            ],
            "message":"Submitted data was expected to be text or number, array given.",
            "code":0,
            "file":"/dev/XYZ/server/vendor/symfony/form/Form.php",
            "line":538,
            "trace":[
               {
                  "file":"/dev/XYZ/server/vendor/symfony/form/Form.php",
                  "line":576,
                  "function":"submit",
                  "class":"SymfonyComponentFormForm",
                  "type":"->"
               },
               {
                  "file":"/dev/XYZ/server/vendor/symfony/form/Form.php",
                  "line":576,
                  "function":"submit",
                  "class":"SymfonyComponentFormForm",
                  "type":"->"
               },
               {
                  "file":"/dev/XYZ/server/vendor/symfony/form/Form.php",
                  "line":576,
                  "function":"submit",
                  "class":"SymfonyComponentFormForm",
                  "type":"->"
               },
               {
                  "file":"/dev/XYZ/server/vendor/symfony/form/Form.php",
                  "line":576,
                  "function":"submit",
                  "class":"SymfonyComponentFormForm",
                  "type":"->"
               },
               {
                  "file":"/dev/XYZ/server/vendor/symfony/form/Form.php",
                  "line":576,
                  "function":"submit",
                  "class":"SymfonyComponentFormForm",
                  "type":"->"
               },
               {
                  "file":"/dev/XYZ/server/src/Controller/RecipeController.php",
                  "line":114,
                  "function":"submit",
                  "class":"SymfonyComponentFormForm",
                  "type":"->"
               },
               {
                  "file":"/dev/XYZ/server/src/Controller/RecipeController.php",
                  "line":83,
                  "function":"save",
                  "class":"AppControllerRecipeController",
                  "type":"->"
               },
               {
                  "file":"/dev/XYZ/server/vendor/symfony/http-kernel/HttpKernel.php",
                  "line":157,
                  "function":"postAction",
                  "class":"AppControllerRecipeController",
                  "type":"->"
               },
               {
                  "file":"/dev/XYZ/server/vendor/symfony/http-kernel/HttpKernel.php",
                  "line":79,
                  "function":"handleRaw",
                  "class":"SymfonyComponentHttpKernelHttpKernel",
                  "type":"->"
               },
               {
                  "file":"/dev/XYZ/server/vendor/symfony/http-kernel/Kernel.php",
                  "line":195,
                  "function":"handle",
                  "class":"SymfonyComponentHttpKernelHttpKernel",
                  "type":"->"
               },
               {
                  "file":"/dev/XYZ/server/vendor/symfony/http-kernel/HttpKernelBrowser.php",
                  "line":63,
                  "function":"handle",
                  "class":"SymfonyComponentHttpKernelKernel",
                  "type":"->"
               },
               {
                  "file":"/dev/XYZ/server/vendor/symfony/framework-bundle/KernelBrowser.php",
                  "line":159,
                  "function":"doRequest",
                  "class":"SymfonyComponentHttpKernelHttpKernelBrowser",
                  "type":"->"
               },
               {
                  "file":"/dev/XYZ/server/vendor/symfony/browser-kit/AbstractBrowser.php",
                  "line":384,
                  "function":"doRequest",
                  "class":"SymfonyBundleFrameworkBundleKernelBrowser",
                  "type":"->"
               },
               {
                  "file":"/dev/XYZ/server/tests/Controller/RecipeControllerTest.php",
                  "line":313,
                  "function":"request",
                  "class":"SymfonyComponentBrowserKitAbstractBrowser",
                  "type":"->"
               },
               {
                  "file":"/dev/XYZ/server/vendor/phpunit/phpunit/src/Framework/TestCase.php",
                  "line":1526,
                  "function":"testJsonPostAction",
                  "class":"AppTestsControllerRecipeControllerTest",
                  "type":"->"
               },
               {
                  "file":"/dev/XYZ/server/vendor/phpunit/phpunit/src/Framework/TestCase.php",
                  "line":1132,
                  "function":"runTest",
                  "class":"PHPUnitFrameworkTestCase",
                  "type":"->"
               },
               {
                  "file":"/dev/XYZ/server/vendor/phpunit/phpunit/src/Framework/TestResult.php",
                  "line":722,
                  "function":"runBare",
                  "class":"PHPUnitFrameworkTestCase",
                  "type":"->"
               },
               {
                  "file":"/dev/XYZ/server/vendor/phpunit/phpunit/src/Framework/TestCase.php",
                  "line":884,
                  "function":"run",
                  "class":"PHPUnitFrameworkTestResult",
                  "type":"->"
               },
               {
                  "file":"/dev/XYZ/server/vendor/phpunit/phpunit/src/Framework/TestSuite.php",
                  "line":677,
                  "function":"run",
                  "class":"PHPUnitFrameworkTestCase",
                  "type":"->"
               },
               {
                  "file":"/dev/XYZ/server/vendor/phpunit/phpunit/src/Framework/TestSuite.php",
                  "line":677,
                  "function":"run",
                  "class":"PHPUnitFrameworkTestSuite",
                  "type":"->"
               },
               {
                  "file":"/dev/XYZ/server/vendor/phpunit/phpunit/src/TextUI/TestRunner.php",
                  "line":667,
                  "function":"run",
                  "class":"PHPUnitFrameworkTestSuite",
                  "type":"->"
               },
               {
                  "file":"/dev/XYZ/server/vendor/phpunit/phpunit/src/TextUI/Command.php",
                  "line":143,
                  "function":"run",
                  "class":"PHPUnitTextUITestRunner",
                  "type":"->"
               },
               {
                  "file":"/dev/XYZ/server/vendor/phpunit/phpunit/src/TextUI/Command.php",
                  "line":96,
                  "function":"run",
                  "class":"PHPUnitTextUICommand",
                  "type":"->"
               },
               {
                  "file":"/dev/XYZ/server/vendor/phpunit/phpunit/phpunit",
                  "line":61,
                  "function":"main",
                  "class":"PHPUnitTextUICommand",
                  "type":"::"
               }
            ],
            "trace_as_string":"#0 /dev/XYZ/server/vendor/symfony/form/Form.php(576): SymfonyComponentFormForm->submit()n#1 /dev/XYZ/server/vendor/symfony/form/Form.php(576): SymfonyComponentFormForm->submit()n#2 /dev/XYZ/server/vendor/symfony/form/Form.php(576): SymfonyComponentFormForm->submit()n#3 /dev/XYZ/server/vendor/symfony/form/Form.php(576): SymfonyComponentFormForm->submit()n#4 /dev/XYZ/server/vendor/symfony/form/Form.php(576): SymfonyComponentFormForm->submit()n#5 /dev/XYZ/server/src/Controller/RecipeController.php(114): SymfonyComponentFormForm->submit()n#6 /dev/XYZ/server/src/Controller/RecipeController.php(83): AppControllerRecipeController->save()n#7 /dev/XYZ/server/vendor/symfony/http-kernel/HttpKernel.php(157): AppControllerRecipeController->postAction()n#8 /dev/XYZ/server/vendor/symfony/http-kernel/HttpKernel.php(79): SymfonyComponentHttpKernelHttpKernel->handleRaw()n#9 /dev/XYZ/server/vendor/symfony/http-kernel/Kernel.php(195): SymfonyComponentHttpKernelHttpKernel->handle()n#10 /dev/XYZ/server/vendor/symfony/http-kernel/HttpKernelBrowser.php(63): SymfonyComponentHttpKernelKernel->handle()n#11 /dev/XYZ/server/vendor/symfony/framework-bundle/KernelBrowser.php(159): SymfonyComponentHttpKernelHttpKernelBrowser->doRequest()n#12 /dev/XYZ/server/vendor/symfony/browser-kit/AbstractBrowser.php(384): SymfonyBundleFrameworkBundleKernelBrowser->doRequest()n#13 /dev/XYZ/server/tests/Controller/RecipeControllerTest.php(313): SymfonyComponentBrowserKitAbstractBrowser->request()n#14 /dev/XYZ/server/vendor/phpunit/phpunit/src/Framework/TestCase.php(1526): AppTestsControllerRecipeControllerTest->testJsonPostAction()n#15 /dev/XYZ/server/vendor/phpunit/phpunit/src/Framework/TestCase.php(1132): PHPUnitFrameworkTestCase->runTest()n#16 /dev/XYZ/server/vendor/phpunit/phpunit/src/Framework/TestResult.php(722): PHPUnitFrameworkTestCase->runBare()n#17 /dev/XYZ/server/vendor/phpunit/phpunit/src/Framework/TestCase.php(884): PHPUnitFrameworkTestResult->run()n#18 /dev/XYZ/server/vendor/phpunit/phpunit/src/Framework/TestSuite.php(677): PHPUnitFrameworkTestCase->run()n#19 /dev/XYZ/server/vendor/phpunit/phpunit/src/Framework/TestSuite.php(677): PHPUnitFrameworkTestSuite->run()n#20 /dev/XYZ/server/vendor/phpunit/phpunit/src/TextUI/TestRunner.php(667): PHPUnitFrameworkTestSuite->run()n#21 /dev/XYZ/server/vendor/phpunit/phpunit/src/TextUI/Command.php(143): PHPUnitTextUITestRunner->run()n#22 /dev/XYZ/server/vendor/phpunit/phpunit/src/TextUI/Command.php(96): PHPUnitTextUICommand->run()n#23 /dev/XYZ/server/vendor/phpunit/phpunit/phpunit(61): PHPUnitTextUICommand::main()n#24 {main}"
         },
         "code":"1dafa156-89e1-4736-b832-419c2e501fca"
      },
      "origin":{
         "code":400,
         "message":"Validation Failed",
         "errors":{
            "errors":[
               "The selected Ingredient does not exist"
            ]
         }
      }
   }
]

Source: Ask PHP

LEAVE A COMMENT