Search code examples
embedded-resourceapi-platform.comdenormalization

How not to allow an IRI when denormalizing embedded relations?


I have a Customer entity that is linked to a Contact entity, in a nullable OneToOne relationship.

When I create a new Customer, the creation of the linked Contact is optional, but it must not be possible to fill in the IRI of an existing Contact. In other words, it must be a new Contact or nothing.

class Customer
{

    #[ORM\OneToOne(targetEntity: Contact::class, cascade: ["persist"])]
    #[Groups([
        'write:Customer:collection', '...'
    ])]
    private $contact;
}

The 'write:Customer:collection' denormalization group is also present on the Contact properties.

With a good request as follow, I can create my Customer and my Contact, no problem with it.

{
    "name": "test company",
    "contact": [
        "firstname" => 'hello',
        "lastname" => 'world'
    ]
}

Problem:

But, and I don't want it, I also can create the new Customer with an existing Contact, like this:

{
    "name": "test company",
    "contact": "/api/contacts/{id}"
}

As stated in the serialization documentation:

The following rules apply when denormalizing embedded relations:

  • If an @id key is present in the embedded resource, then the object corresponding to the given URI will be retrieved through the data provider. Any changes in the embedded relation will also be applied to that object.
  • If no @id key exists, a new object will be created containing data provided in the embedded JSON document.

However, I would like to disable the rule if an @id key is present, for specific validation group.

I thought of creating a custom constraint that would check that the resource does not exist in the database, but I am surprised that no constraint allows to check this.

Am I missing something? Do you have a solution for me? Thanks in advance.


Solution

  • I finally created a custom constraint that checks if the embed resource sent in request is already managed by Doctrine.

    The constraint itself:

    namespace App\Validator\Constraints;
    
    use Symfony\Component\Validator\Constraint;
    
    /**
     * @Annotation
     * @Target({"PROPERTY", "METHOD", "ANNOTATION"})
     */
    #[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)]
    class AcceptPersisted extends Constraint
    {
        public bool $expected = false;
        public string $mustBePersistMessage = 'Set a new {{ entity }} is invalid. Must be an existing one.';
        public string $mustBeNotPersistMessage = 'Set an existing {{ entity }} is invalid. Must be a new one.';
    
        public function __construct(bool $expected = false, $options = null, array $groups = null, $payload = null)
        {
            parent::__construct($options, $groups, $payload);
            $this->expected = $expected;
        }
    }
    

    And it validator:

    namespace App\Validator\Constraints;
    
    use Doctrine\ORM\EntityManagerInterface;
    use Symfony\Component\Validator\Constraint;
    use Symfony\Component\Validator\ConstraintValidator;
    use Symfony\Component\Validator\Exception\UnexpectedTypeException;
    
    class AcceptPersistedValidator extends ConstraintValidator
    {
        public function __construct(private EntityManagerInterface $entityManager) {}
    
        public function validate($value, Constraint $constraint)
        {
            if (!$constraint instanceof AcceptPersisted) {
                throw new UnexpectedTypeException($constraint, AcceptPersisted::class);
            }
    
            if ($value === null) {
                return;
            }
    
            //if current value is/is not manage by doctrine
            if ($this->entityManager->contains($value) !== $constraint->expected) {
                $entity = (new \ReflectionClass($value))->getShortName();
                $message = $constraint->expected ? $constraint->mustBePersistMessage : $constraint->mustBeNotPersistMessage;
    
                $this->context->buildViolation($message)->setParameter("{{ entity }}", $entity)->addViolation();
            }
        }
    }
    

    So, I just had to add the custom constraint on my property:

    use App\Validator\Constraints as CustomAssert;
    
    class Customer
    {
    
        #[ORM\OneToOne(targetEntity: Contact::class, cascade: ["persist"])]
        #[CustomAssert\AcceptPersisted(expected: false)]
        //...
        private $contact;
    }