Search code examples
restsymfonyformbuildersymfony-3.3

automatching property_path in Symfony API


I'm having a REST-API built in Symfony3.

As an example here are the API-fields of Price in a form, made with the FormBuilderInterface. The code-example below is of ApiBundle/Form/PriceType.php

class PriceType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder
            ->add('name', TextType::class, array(
                  'description' => 'Name',

              ))
             ->add('price_category', EntityPublicKeyTextType::class, array(
                'class' => 'MyCustomBundle:PriceCategory',
                'property_path' => 'priceCategory',
            ))

The issue is about good response messages of fields which have e.g. a validation error. For default symfony-types (e.g. IntegerType, TextType) it can find the property_path automatically and hands me out an useful error message. Here is the API-response with two errors:

  • name can be resolved in a good way (because I see what field it is about,
    • for price_category it can't resolve it (second message).
    {
      "name": [
        "This value is too long. It should have 50 characters or less."
      ],
      "0": "This value should not be null."
    }

To resolve the issue. I add 'property_path' => 'priceCategory' for the field price_category. The value of property_path is matching with BaseBundle/Entity/Price.php where the var protected $priceCategory; is defined.

After adding property_path the error message looks fine.

{
  "name": [
    "This value is too long. It should have 50 characters or less."
  ],
  "price_category": [
    "This value should not be null."
  ]
}

The class of price_category is EntityPublicKeyTextType which is abstracted from TextType (which can do errors just fine).

Therefore I have the following question: What do i have to add to my inherited class EntityPublicKeyTextType to avoid adding the property_path for all fields by hand?

Any hint to fix this is highly welcome

Best endo

EDIT:

EntityPublicKeyTextType:

class EntityPublicKeyTextType extends AbstractType
{
    /**
     * @var ObjectManager
     */
    private $om;

    /**
     * @param ObjectManager $om
     */
    public function __construct(ObjectManager $om)
    {
        $this->om = $om;
    }

    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $transformer = new ObjectToPublicKeyTransformer(
            $this->om,
            $options['class'],
            $options['public_key'],
            $options['remove_whitespaces'],
            $options['multiple'],
            $options['string_separator'],
            $options['extra_find_by']
        );
        $builder->addModelTransformer($transformer);
    }

    public function configureOptions(OptionsResolver $resolver)
    {
        $resolver
            ->setRequired(array(
                'class',
                'public_key'
            ))
            ->setDefaults(array(
                'multiple' => false,
                'string_separator' => false,
                'extra_find_by' => array(),
                'remove_whitespaces' => true,
            ));
    }

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

    public function getBlockPrefix()
    {
        return 'entity_public_key_text';
    }
}

ObjectToPublicKeyTransformer:

class ObjectToPublicKeyTransformer implements DataTransformerInterface
{
    /**
     * @var PropertyAccessorInterface
     */
    private $propertyAccessor;

    /**
     * @var ObjectManager
     */
    private $om;

    /**
     * @var string
     */
    private $class;

    /**
     * @var string|string[]
     */
    private $publicKey;

    /**
     * @var bool
     */
    private $removeWhitespaces;

    /**
     * @var boolean
     */
    private $multiple;

    /**
     * @var boolean|string
     */
    private $stringSeparator;

    /**
     * @var array
     */
    private $extraFindBy;

    public function __construct(
        ObjectManager $om,
        string $class,
        $publicKey,
        bool $removeWhitespaces,
        bool $multiple = false,
        $stringSeparator = false,
        array $extraFindBy = array(),
        PropertyAccessorInterface $propertyAccessor = null
    ) {
        $this->propertyAccessor = $propertyAccessor ?: PropertyAccess::createPropertyAccessor();
        $this->om = $om;
        $classMetadata = $om->getClassMetadata($class);
        $this->class = $classMetadata->getName();
        $this->publicKey = $publicKey;
        $this->stringSeparator = $stringSeparator;
        $this->multiple = $multiple;
        $this->extraFindBy = $extraFindBy;
        $this->removeWhitespaces = $removeWhitespaces;
    }

    /**
     * Transforms an object / Collection of objects to a publicKey string / array of publicKey strings.
     *
     * @param   object|Collection $object
     * @return  string|array
     */
    public function transform($object)
    {
        if (null == $object) {
            return null;
        }

        if (is_array($this->publicKey)) {
            $publicKey = $this->publicKey[0];
        } else {
            $publicKey = $this->publicKey;
        }

        if ($this->multiple) {
            if ($object instanceof Collection) {
                $values = array();

                foreach ($object as $objectItem) {
                    $values[] = (string)$this->propertyAccessor->getValue($objectItem, $publicKey);
                }

                if ($this->stringSeparator) {
                    return implode($this->stringSeparator, $values);
                }

                return $values;
            }
        } else {
            return (string)$this->propertyAccessor->getValue($object, $publicKey);
        }
    }

    /**
     * Transforms an publicKey string / array of public key strings to an object / Collection of objects.
     *
     * @param   string|array $value
     * @return  object|Collection
     *
     * @throws TransformationFailedException if object is not found.
     */
    public function reverseTransform($value)
    {
        if (null === $value) {
            return $this->multiple ? new ArrayCollection() : null;
        }

        if (is_array($this->publicKey)) {
            $publicKeys = $this->publicKey;
        } else {
            $publicKeys = array($this->publicKey);
        }

        if ($this->multiple) {
            if ($this->stringSeparator) {
                $value = explode($this->stringSeparator, $value);
            }

            if (is_array($value)) {
                $objects = new ArrayCollection();

                foreach ($value as $valueItem) {
                    foreach ($publicKeys as $publicKey) {
                        $object = $this->findObject($valueItem, $publicKey);

                        if ($object instanceof $this->class) {
                            $objects->add($object);
                            break;
                        }
                    }
                }

                return $objects;
            }
        }

        foreach ($publicKeys as $publicKey) {
            $object = $this->findObject($value, $publicKey);

            if ($object instanceof $this->class) {
                return $object;
            }
        }

        return $this->multiple ? new ArrayCollection() : null;
    }

    private function findObject($value, $publicKey)
    {
        if ($this->removeWhitespaces) {
            $value = str_replace(' ', '', $value);
        }
        $findBy = array_merge([$publicKey => $value], $this->extraFindBy);
        $object = $this->om->getRepository($this->class)->findOneBy($findBy);

        return $object;
    }
}

Solution

  • It would be useful if you also provide your Price model/entity class. It seems that you are using camel case for the property name in your model (priceCategory) and then you use snake case in your form (price_category).

    If you use the same convention for the model and the form, the validation errors will automatically map to the correct property.

    The explanation is that Symfony's mappers can still map your fields by transforming snake to camel case and vice versa, that's why your form is still working and submitting values even without using the property_path option. But the problem is that the validator does not do this mapping and cannot match the correct property (price_category -> priceCategory).