This is my first time posting, my apologies if I don't follow some rules.
I'm using API Platform in my Symfony 5.3 project. I'm trying to make a field in one of my entities writable with some rules. The entity is called StripeAccount and must be linked to a $company object (see mapping below). Here are the rules
- If the user is NOT granted ROLE_ADMIN, then the $company is not mandatory as it will be automatically filled
- If the user is NOT granted ROLE_ADMIN and provide the $company, it MUST match the user's one (or else a violation is added)
- If the user IS granted ROLE_ADMIN, then the $company IS mandatory but it can be any company
This is my StripeAccount entity :
<?php
namespace App\Entity;
use ApiPlatform\Core\Annotation\ApiProperty;
use ApiPlatform\Core\Annotation\ApiResource;
use App\Repository\StripeAccountRepository;
use App\Validator\IsValidCompany;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\HttpFoundation\File\File;
use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Serializer\Annotation\MaxDepth;
use Symfony\Component\Validator\Constraints as Assert;
use Vich\UploaderBundle\Mapping\Annotation as Vich;
/**
* @Vich\Uploadable
* @ApiResource(
* iri="http://schema.org/StripeAccount",
* normalizationContext={"groups"={"read:StripeAccount"}, "enable_max_depth"=true},
* denormalizationContext={"groups"={"write:StripeAccount"}},
* collectionOperations={
* "post"={
* "input_formats"={
* "multipart"={"multipart/form-data"}
* },
* },
* },
* itemOperations={
* "get"
* }
* )
* @ORM\Entity(repositoryClass=StripeAccountRepository::class)
*/
class StripeAccount
{
public const ACCOUNT_TYPE = 'custom';
/**
* @ORM\Id
* @ORM\GeneratedValue
* @ORM\Column(type="integer")
* @Groups({"read:StripeAccount", "write:StripeAccount"})
*/
private $id;
/**
* @ORM\ManyToOne(targetEntity=Company::class, inversedBy="stripeAccounts")
* @ORM\JoinColumn(nullable=false)
* @Groups({"read:StripeAccount", "admin:write"})
* @Assert\NotBlank(groups={"admin:write"})
* @IsValidCompany
*/
private $company;
/**
* @ORM\OneToMany(targetEntity=Brand::class, mappedBy="stripeAccount")
* @Groups({"read:StripeAccount", "write:StripeAccount"})
*/
private $brands;
// other fields
public function __construct()
{
$this->brands = new ArrayCollection();
}
public static function getType(): string
{
return self::ACCOUNT_TYPE;
}
public function getId(): ?int
{
return $this->id;
}
public function getCompany(): ?Company
{
return $this->company;
}
public function setCompany(?Company $company): self
{
$this->company = $company;
return $this;
}
// other methods
}
I followed this tutorial : https://symfonycasts.com/screencast/api-platform-security/context-builder#play (chapters 25, and 33 to 36), so I have this validator :
<?php
namespace App\Validator;
use App\Entity\{Company, User};
use Symfony\Component\Security\Core\Security;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
class IsValidCompanyValidator extends ConstraintValidator
{
private $security;
public function __construct(Security $security)
{
$this->security = $security;
}
public function validate($value, Constraint $constraint)
{
/* @var $constraint \App\Validator\IsValidCompany */
if (null === $value || '' === $value) {
return;
}
$user = $this->security->getUser();
if (!$user instanceof User) {
$this->context->buildViolation($constraint->anonymousMessage)->addViolation();
return;
}
if ($this->security->isGranted('ROLE_ADMIN')) {
return;
}
if (!$value instanceof Company) {
throw new \InvalidArgumentException(
'@IsValidCompany constraint must be put on a property containing a Company object'
);
}
if ($value->getId() !== $user->getId()) {
$this->context->buildViolation($constraint->message)
->setParameter('%value%', $value)
->addViolation();
}
}
}
and this ContextBuilder :
<?php
namespace App\Serializer;
use ApiPlatform\Core\Serializer\SerializerContextBuilderInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
final class AdminGroupsContextBuilder implements SerializerContextBuilderInterface
{
private $decorated;
private $authorizationChecker;
public function __construct(
SerializerContextBuilderInterface $decorated,
AuthorizationCheckerInterface $authorizationChecker
) {
$this->decorated = $decorated;
$this->authorizationChecker = $authorizationChecker;
}
public function createFromRequest(Request $request, bool $normalization, ?array $extractedAttributes = null): array
{
$context = $this->decorated->createFromRequest($request, $normalization, $extractedAttributes);
$isAdmin = $this->authorizationChecker->isGranted('ROLE_ADMIN');
if (isset($context['groups']) && $isAdmin) {
$context['groups'][] = $normalization ? 'admin:read' : 'admin:write';
}
return $context;
}
}
Everything works fine, the group 'admin:write' is added if the user making the request is an admin, and the $company is set if the user is not an admin.
My problem is : My @Assert\NotBlank(groups={"admin:write"}) is completly ignored. I tried a few adjustments with the @Groups annotation and even the denormalizationContext, but no, it's not applied at any moment. What am I missing here?
Btw, I'm using Postman to test my API
Thanks a lot for your help
[EDIT] Based on @TekPike's answer, here is my working code:
StripeAccount.php
<?php
namespace App\Entity;
use ApiPlatform\Core\Annotation\ApiProperty;
use ApiPlatform\Core\Annotation\ApiResource;
use App\Repository\StripeAccountRepository;
use App\Validation\AdminValidationGroupsGenerator;
use App\Validator\IsValidCompany;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Serializer\Annotation\MaxDepth;
use Symfony\Component\Validator\Constraints as Assert;
/**
* @ApiResource(
* iri="http://schema.org/StripeAccount",
* attributes={
* "validation_groups"=AdminValidationGroupsGenerator::class,
* },
* normalizationContext={"groups"={"read:StripeAccount"}, "enable_max_depth"=true},
* denormalizationContext={"groups"={"write:StripeAccount"}},
* collectionOperations={
* "post"={
* "input_formats"={
* "multipart"={"multipart/form-data"}
* },
* },
* },
* itemOperations={
* "get",
* "delete",
* }
* )
* @ORM\Entity(repositoryClass=StripeAccountRepository::class)
*/
class StripeAccount
{
/**
* @ORM\Id
* @ORM\GeneratedValue
* @ORM\Column(type="integer")
* @Groups({"read:StripeAccount", "write:StripeAccount"})
*/
private $id;
/**
* @ORM\ManyToOne(targetEntity=Company::class, inversedBy="stripeAccounts")
* @ORM\JoinColumn(nullable=false)
* @Groups({"read:StripeAccount", "admin:write"})
* @Assert\NotBlank(groups={"admin:write"})
* @IsValidCompany
*/
private $company;
/**
* @ORM\Column(type="string", length=255)
* @Groups({"read:StripeAccount", "write:StripeAccount"})
* @Assert\NotBlank
*/
private $name;
// ...
}
And my AdminValidationGroupsGenerator.php :
<?php
namespace App\Validation;
use ApiPlatform\Core\Bridge\Symfony\Validator\ValidationGroupsGeneratorInterface;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
final class AdminValidationGroupsGenerator implements ValidationGroupsGeneratorInterface
{
private $authorizationChecker;
public function __construct(AuthorizationCheckerInterface $authorizationChecker)
{
$this->authorizationChecker = $authorizationChecker;
}
/**
* {@inheritdoc}
*/
public function __invoke($entity): array
{
$reflect = new \ReflectionClass($entity);
$name = "write:" . $reflect->getShortName();
return $this->authorizationChecker->isGranted('ROLE_ADMIN', $entity) ? [$name, 'admin:write'] : [$name];
}
}
CodePudding user response:
You confuse serialization groups with validation groups.
Currently you define serialization groups with the annotation denormalizationContext={"groups"={"write:StripeAccount"}}
and the class App\SerializerAdminGroupsContextBuilder
.
However, the "admin:write" group defined in the constraint @Assert\NotBlank(groups={"admin:write"})
is a validation group.
In your case, since the validation group changes depending on the user, you have to use dynamic validation groups.