diff --git a/src/Doctrine/Odm/Metadata/Resource/DoctrineMongoDbOdmResourceCollectionMetadataFactory.php b/src/Doctrine/Odm/Metadata/Resource/DoctrineMongoDbOdmResourceCollectionMetadataFactory.php index 796f0089fd8..4894061be44 100644 --- a/src/Doctrine/Odm/Metadata/Resource/DoctrineMongoDbOdmResourceCollectionMetadataFactory.php +++ b/src/Doctrine/Odm/Metadata/Resource/DoctrineMongoDbOdmResourceCollectionMetadataFactory.php @@ -18,11 +18,14 @@ use ApiPlatform\Doctrine\Odm\State\Options; use ApiPlatform\Metadata\CollectionOperationInterface; use ApiPlatform\Metadata\DeleteOperationInterface; +use ApiPlatform\Metadata\HttpOperation; +use ApiPlatform\Metadata\Link; use ApiPlatform\Metadata\Operation; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; use ApiPlatform\State\Util\StateOptionsTrait; use Doctrine\ODM\MongoDB\DocumentManager; +use Doctrine\ODM\MongoDB\Mapping\ClassMetadata; use Doctrine\Persistence\ManagerRegistry; final class DoctrineMongoDbOdmResourceCollectionMetadataFactory implements ResourceMetadataCollectionFactoryInterface @@ -86,6 +89,10 @@ private function addDefaults(Operation $operation): Operation if (null === $operation->getProvider()) { $operation = $operation->withProvider($this->getProvider($operation)); + + if ($operation instanceof HttpOperation) { + $operation = $operation->withRequirements($this->getRequirements($operation)); + } } if (null === $operation->getProcessor()) { @@ -95,6 +102,54 @@ private function addDefaults(Operation $operation): Operation return $operation; } + /** + * @return array + */ + private function getRequirements(HttpOperation $operation): array + { + $requirements = $operation->getRequirements() ?? []; + $uriVariables = (array) ($operation->getUriVariables() ?? []); + + foreach ($uriVariables as $paramName => $uriVariable) { + if (isset($requirements[$paramName])) { + continue; + } + + if (!$uriVariable instanceof Link) { + continue; + } + + $identifiers = $uriVariable->getIdentifiers(); + if (1 !== \count($identifiers)) { + continue; + } + $fieldName = $identifiers[0]; + + $fromClass = $uriVariable->getFromClass(); + if (null === $fromClass) { + continue; + } + $classMetadata = $this->managerRegistry->getManagerForClass($fromClass)?->getClassMetadata($fromClass); + + $requirement = null; + if ($classMetadata instanceof ClassMetadata && $classMetadata->hasField($fieldName)) { + $fieldMapping = $classMetadata->getFieldMapping($fieldName); + $requirement = match ($fieldMapping['type']) { + 'uuid', 'guid' => '^[0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}$', + 'ulid' => '^[0-7][0-9a-hjkmnp-tv-zA-HJKMNP-TV-Z]{25}$', + 'smallint', 'integer', 'bigint' => '^-?[0-9]+$', + default => null, + }; + } + + if (null !== $requirement) { + $requirements[$paramName] = $requirement; + } + } + + return $requirements; + } + private function getProvider(Operation $operation): string { if ($operation instanceof CollectionOperationInterface) { diff --git a/src/Doctrine/Orm/Metadata/Resource/DoctrineOrmResourceCollectionMetadataFactory.php b/src/Doctrine/Orm/Metadata/Resource/DoctrineOrmResourceCollectionMetadataFactory.php index 77155723a89..7d82bc4824e 100644 --- a/src/Doctrine/Orm/Metadata/Resource/DoctrineOrmResourceCollectionMetadataFactory.php +++ b/src/Doctrine/Orm/Metadata/Resource/DoctrineOrmResourceCollectionMetadataFactory.php @@ -18,11 +18,15 @@ use ApiPlatform\Doctrine\Orm\State\Options; use ApiPlatform\Metadata\CollectionOperationInterface; use ApiPlatform\Metadata\DeleteOperationInterface; +use ApiPlatform\Metadata\HttpOperation; +use ApiPlatform\Metadata\Link; use ApiPlatform\Metadata\Operation; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; use ApiPlatform\State\Util\StateOptionsTrait; use Doctrine\ORM\EntityManagerInterface; +use Doctrine\ORM\Mapping\ClassMetadata; +use Doctrine\ORM\Mapping\FieldMapping; use Doctrine\Persistence\ManagerRegistry; final class DoctrineOrmResourceCollectionMetadataFactory implements ResourceMetadataCollectionFactoryInterface @@ -83,6 +87,10 @@ private function addDefaults(Operation $operation): Operation { if (null === $operation->getProvider()) { $operation = $operation->withProvider($this->getProvider($operation)); + + if ($operation instanceof HttpOperation) { + $operation = $operation->withRequirements($this->getRequirements($operation)); + } } $options = $operation->getStateOptions() ?: new Options(); @@ -98,6 +106,59 @@ private function addDefaults(Operation $operation): Operation return $operation; } + /** + * @return array + */ + private function getRequirements(HttpOperation $operation): array + { + $requirements = $operation->getRequirements() ?? []; + $uriVariables = (array) ($operation->getUriVariables() ?? []); + + foreach ($uriVariables as $paramName => $uriVariable) { + if (isset($requirements[$paramName])) { + continue; + } + + if (!$uriVariable instanceof Link) { + continue; + } + $identifiers = $uriVariable->getIdentifiers(); + if (1 !== \count($identifiers)) { + continue; + } + $fieldName = $identifiers[0]; + + $fromClass = $uriVariable->getFromClass(); + if (null === $fromClass) { + continue; + } + $classMetadata = $this->managerRegistry->getManagerForClass($fromClass)?->getClassMetadata($fromClass); + + $requirement = null; + if ($classMetadata instanceof ClassMetadata && $classMetadata->hasField($fieldName)) { + $fieldMapping = $classMetadata->getFieldMapping($fieldName); + if (class_exists(FieldMapping::class)) { + $type = $fieldMapping->type; + } else { + $type = $fieldMapping['type']; + } + + $requirement = match ($type) { + 'uuid', 'guid' => '^[0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}$', + 'ulid' => '^[0-7][0-9a-hjkmnp-tv-zA-HJKMNP-TV-Z]{25}$', + 'smallint', 'integer', 'bigint' => '^-?[0-9]+$', + default => null, + }; + } + + if (null !== $requirement) { + $requirements[$paramName] = $requirement; + } + } + + return $requirements; + } + private function getProvider(Operation $operation): string { if ($operation instanceof CollectionOperationInterface) { diff --git a/src/Metadata/Link.php b/src/Metadata/Link.php index 478c1327b9b..c26ea25fcb4 100644 --- a/src/Metadata/Link.php +++ b/src/Metadata/Link.php @@ -19,6 +19,10 @@ #[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::TARGET_METHOD | \Attribute::TARGET_PARAMETER)] final class Link extends Parameter { + /** + * @param class-string|null $fromClass + * @param class-string|null $toClass + */ public function __construct( private ?string $parameterName = null, private ?string $fromProperty = null, @@ -87,11 +91,17 @@ public function withParameterName(string $parameterName): self return $self; } + /** + * @return class-string|null + */ public function getFromClass(): ?string { return $this->fromClass; } + /** + * @param class-string $fromClass + */ public function withFromClass(string $fromClass): self { $self = clone $this; @@ -100,11 +110,17 @@ public function withFromClass(string $fromClass): self return $self; } + /** + * @return class-string|null + */ public function getToClass(): ?string { return $this->toClass; } + /** + * @param class-string $toClass + */ public function withToClass(string $toClass): self { $self = clone $this; diff --git a/src/State/Tests/Provider/SecurityParameterProviderTest.php b/src/State/Tests/Provider/SecurityParameterProviderTest.php index 30e59b1b995..c9df21591e3 100644 --- a/src/State/Tests/Provider/SecurityParameterProviderTest.php +++ b/src/State/Tests/Provider/SecurityParameterProviderTest.php @@ -30,7 +30,7 @@ public function testIsGrantedLink(): void $obj = new \stdClass(); $barObj = new \stdClass(); $operation = new GetCollection(uriVariables: [ - 'barId' => new Link(toProperty: 'bar', fromClass: 'Bar', security: 'is_granted("some_voter", "bar")'), + 'barId' => new Link(toProperty: 'bar', fromClass: $barObj::class, security: 'is_granted("some_voter", "bar")'), ], class: \stdClass::class); $decorated = $this->createMock(ProviderInterface::class); $decorated->method('provide')->willReturn($obj); @@ -39,7 +39,7 @@ public function testIsGrantedLink(): void $request->attributes = $parameterBag; $request->attributes->set('bar', $barObj); $resourceAccessChecker = $this->createMock(ResourceAccessCheckerInterface::class); - $resourceAccessChecker->expects($this->once())->method('isGranted')->with('Bar', 'is_granted("some_voter", "bar")', ['object' => $obj, 'previous_object' => null, 'request' => $request, 'bar' => $barObj, 'barId' => 1, 'operation' => $operation])->willReturn(true); + $resourceAccessChecker->expects($this->once())->method('isGranted')->with($barObj::class, 'is_granted("some_voter", "bar")', ['object' => $obj, 'previous_object' => null, 'request' => $request, 'bar' => $barObj, 'barId' => 1, 'operation' => $operation])->willReturn(true); $accessChecker = new SecurityParameterProvider($decorated, $resourceAccessChecker); $accessChecker->provide($operation, ['barId' => 1], ['request' => $request]); } @@ -51,7 +51,7 @@ public function testIsNotGrantedLink(): void $obj = new \stdClass(); $barObj = new \stdClass(); $operation = new GetCollection(uriVariables: [ - 'barId' => new Link(toProperty: 'bar', fromClass: 'Bar', security: 'is_granted("some_voter", "bar")'), + 'barId' => new Link(toProperty: 'bar', fromClass: $barObj::class, security: 'is_granted("some_voter", "bar")'), ], class: \stdClass::class); $decorated = $this->createMock(ProviderInterface::class); $decorated->method('provide')->willReturn($obj); @@ -60,7 +60,7 @@ public function testIsNotGrantedLink(): void $request->attributes = $parameterBag; $request->attributes->set('bar', $barObj); $resourceAccessChecker = $this->createMock(ResourceAccessCheckerInterface::class); - $resourceAccessChecker->expects($this->once())->method('isGranted')->with('Bar', 'is_granted("some_voter", "bar")', ['object' => $obj, 'previous_object' => null, 'request' => $request, 'bar' => $barObj, 'barId' => 1, 'operation' => $operation])->willReturn(false); + $resourceAccessChecker->expects($this->once())->method('isGranted')->with($barObj::class, 'is_granted("some_voter", "bar")', ['object' => $obj, 'previous_object' => null, 'request' => $request, 'bar' => $barObj, 'barId' => 1, 'operation' => $operation])->willReturn(false); $accessChecker = new SecurityParameterProvider($decorated, $resourceAccessChecker); $accessChecker->provide($operation, ['barId' => 1], ['request' => $request]); } @@ -73,7 +73,7 @@ public function testSecurityMessageLink(): void $obj = new \stdClass(); $barObj = new \stdClass(); $operation = new GetCollection(uriVariables: [ - 'barId' => new Link(toProperty: 'bar', fromClass: 'Bar', security: 'is_granted("some_voter", "bar")', securityMessage: 'You are not admin.'), + 'barId' => new Link(toProperty: 'bar', fromClass: $barObj::class, security: 'is_granted("some_voter", "bar")', securityMessage: 'You are not admin.'), ], class: \stdClass::class); $decorated = $this->createMock(ProviderInterface::class); $decorated->method('provide')->willReturn($obj); @@ -82,7 +82,7 @@ public function testSecurityMessageLink(): void $request->attributes = $parameterBag; $request->attributes->set('bar', $barObj); $resourceAccessChecker = $this->createMock(ResourceAccessCheckerInterface::class); - $resourceAccessChecker->expects($this->once())->method('isGranted')->with('Bar', 'is_granted("some_voter", "bar")', ['object' => $obj, 'previous_object' => null, 'request' => $request, 'bar' => $barObj, 'barId' => 1, 'operation' => $operation])->willReturn(false); + $resourceAccessChecker->expects($this->once())->method('isGranted')->with($barObj::class, 'is_granted("some_voter", "bar")', ['object' => $obj, 'previous_object' => null, 'request' => $request, 'bar' => $barObj, 'barId' => 1, 'operation' => $operation])->willReturn(false); $accessChecker = new SecurityParameterProvider($decorated, $resourceAccessChecker); $accessChecker->provide($operation, ['barId' => 1], ['request' => $request]); } diff --git a/tests/Fixtures/TestBundle/Entity/ContainNonResource.php b/tests/Fixtures/TestBundle/Entity/ContainNonResource.php index b1d0236609f..e72833a796b 100644 --- a/tests/Fixtures/TestBundle/Entity/ContainNonResource.php +++ b/tests/Fixtures/TestBundle/Entity/ContainNonResource.php @@ -27,9 +27,11 @@ #[ORM\Entity] class ContainNonResource { - #[ORM\Column(type: 'integer')] + /** + * @var string + */ + #[ORM\Column(type: 'string')] #[ORM\Id] - #[ORM\GeneratedValue(strategy: 'AUTO')] #[Groups('contain_non_resource')] public $id; /** diff --git a/tests/Fixtures/TestBundle/State/ContainNonResourceProvider.php b/tests/Fixtures/TestBundle/State/ContainNonResourceProvider.php index cb5990ce659..c9ff4bc492e 100644 --- a/tests/Fixtures/TestBundle/State/ContainNonResourceProvider.php +++ b/tests/Fixtures/TestBundle/State/ContainNonResourceProvider.php @@ -35,7 +35,7 @@ public function provide(Operation $operation, array $uriVariables = [], array $c $resourceClass = $operation->getClass(); // Retrieve the blog post item from somewhere $cnr = new $resourceClass(); - $cnr->id = $id; + $cnr->id = (string) $id; $cnr->notAResource = new NotAResource('f1', 'b1'); $cnr->nested = new $resourceClass(); $cnr->nested->id = "$id-nested";