Skip to content

Commit 6f5d414

Browse files
authored
feat(doctrine): remove PUT & PATCH for readonly entity (#7453)
1 parent 15ea6d8 commit 6f5d414

File tree

6 files changed

+257
-2
lines changed

6 files changed

+257
-2
lines changed

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
# Changelog
22

3+
## Unreleased
4+
5+
### Features
6+
7+
* [ba0c76c](https://github.com/api-platform/core/commit/ba0c76c6f5c8afa8622e87a155b8b99f453d6453) feat(doctrine): remove put & path for readonly entity (#7019)
8+
39
## v4.2.2
410

511
### Bug fixes

src/Doctrine/Orm/Metadata/Resource/DoctrineOrmResourceCollectionMetadataFactory.php

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@
1919
use ApiPlatform\Metadata\CollectionOperationInterface;
2020
use ApiPlatform\Metadata\DeleteOperationInterface;
2121
use ApiPlatform\Metadata\Operation;
22+
use ApiPlatform\Metadata\Patch;
23+
use ApiPlatform\Metadata\Put;
2224
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
2325
use ApiPlatform\Metadata\Resource\ResourceMetadataCollection;
2426
use ApiPlatform\State\Util\StateOptionsTrait;
@@ -44,10 +46,19 @@ public function create(string $resourceClass): ResourceMetadataCollection
4446
$operations = $resourceMetadata->getOperations();
4547

4648
if ($operations) {
47-
foreach ($resourceMetadata->getOperations() as $operationName => $operation) {
49+
foreach ($operations as $operationName => $operation) {
4850
$entityClass = $this->getStateOptionsClass($operation, $operation->getClass(), Options::class);
4951

50-
if (!$this->managerRegistry->getManagerForClass($entityClass) instanceof EntityManagerInterface) {
52+
$manager = $this->managerRegistry->getManagerForClass($entityClass);
53+
if (!$manager instanceof EntityManagerInterface) {
54+
continue;
55+
}
56+
57+
$classMetadata = $manager->getClassMetadata($entityClass);
58+
// @see https://www.doctrine-project.org/projects/doctrine-orm/en/3.5/reference/improving-performance.html#read-only-entities
59+
// Read-Only allows to persist new entities of a kind and remove existing ones, they are just not considered for updates.
60+
if ($classMetadata->isReadOnly && ($operation instanceof Put || $operation instanceof Patch)) {
61+
$operations->remove($operationName);
5162
continue;
5263
}
5364

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <dunglas@gmail.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\Doctrine\Orm\Tests\Fixtures\Entity;
15+
16+
use ApiPlatform\Metadata\ApiResource;
17+
use Doctrine\ORM\Mapping as ORM;
18+
19+
#[ApiResource]
20+
#[ORM\Entity(readOnly: true)]
21+
class DummyReadOnly
22+
{
23+
/**
24+
* @var int|null The id
25+
*/
26+
#[ORM\Column(type: 'integer', nullable: true)]
27+
#[ORM\Id]
28+
#[ORM\GeneratedValue(strategy: 'AUTO')]
29+
private $id;
30+
31+
#[ORM\Column(type: 'string')]
32+
private string $name;
33+
34+
public function getId()
35+
{
36+
return $this->id;
37+
}
38+
39+
public function setId($id): void
40+
{
41+
$this->id = $id;
42+
}
43+
44+
public function getName(): string
45+
{
46+
return $this->name;
47+
}
48+
49+
public function setName(string $name): void
50+
{
51+
$this->name = $name;
52+
}
53+
}

src/Doctrine/Orm/Tests/Metadata/Resource/DoctrineOrmResourceCollectionMetadataFactoryTest.php

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,15 +17,20 @@
1717
use ApiPlatform\Doctrine\Orm\State\CollectionProvider;
1818
use ApiPlatform\Doctrine\Orm\State\ItemProvider;
1919
use ApiPlatform\Doctrine\Orm\Tests\Fixtures\Entity\Dummy;
20+
use ApiPlatform\Doctrine\Orm\Tests\Fixtures\Entity\DummyReadOnly;
2021
use ApiPlatform\Metadata\ApiResource;
2122
use ApiPlatform\Metadata\Delete;
2223
use ApiPlatform\Metadata\Get;
2324
use ApiPlatform\Metadata\GetCollection;
2425
use ApiPlatform\Metadata\HttpOperation;
2526
use ApiPlatform\Metadata\Operations;
27+
use ApiPlatform\Metadata\Patch;
28+
use ApiPlatform\Metadata\Post;
29+
use ApiPlatform\Metadata\Put;
2630
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
2731
use ApiPlatform\Metadata\Resource\ResourceMetadataCollection;
2832
use Doctrine\ORM\EntityManagerInterface;
33+
use Doctrine\ORM\Mapping\ClassMetadata;
2934
use Doctrine\Persistence\ManagerRegistry;
3035
use PHPUnit\Framework\TestCase;
3136
use Prophecy\PhpUnit\ProphecyTrait;
@@ -66,6 +71,7 @@ public function testWithoutManager(): void
6671
public function testWithProvider(HttpOperation $operation, ?string $expectedProvider = null, ?string $expectedProcessor = null): void
6772
{
6873
$objectManager = $this->prophesize(EntityManagerInterface::class);
74+
$objectManager->getClassMetadata($operation->getClass())->willReturn(new ClassMetadata(Dummy::class));
6975
$managerRegistry = $this->prophesize(ManagerRegistry::class);
7076
$managerRegistry->getManagerForClass($operation->getClass())->willReturn($objectManager->reveal());
7177
$resourceMetadataCollectionFactory = new DoctrineOrmResourceCollectionMetadataFactory($managerRegistry->reveal(), $this->getResourceMetadataCollectionFactory($operation));
@@ -85,4 +91,46 @@ public static function operationProvider(): iterable
8591
yield [(new GetCollection())->withOperation($default), CollectionProvider::class, 'api_platform.doctrine.orm.state.persist_processor'];
8692
yield [(new Delete())->withOperation($default), ItemProvider::class, 'api_platform.doctrine.orm.state.remove_processor'];
8793
}
94+
95+
public function testReadOnlyEntitiesShouldNotIncludeUpdateOperations(): void
96+
{
97+
$objectManager = $this->createMock(EntityManagerInterface::class);
98+
$readOnlyMetadata = new ClassMetadata(DummyReadOnly::class);
99+
$readOnlyMetadata->markReadOnly();
100+
$objectManager->method('getClassMetadata')->willReturn($readOnlyMetadata);
101+
$managerRegistry = $this->createMock(ManagerRegistry::class);
102+
$managerRegistry->method('getManagerForClass')->with(DummyReadOnly::class)->willReturn($objectManager);
103+
104+
$resourceMetadataCollectionFactory = $this->createMock(ResourceMetadataCollectionFactoryInterface::class);
105+
$resourceMetadataCollectionFactory
106+
->method('create')
107+
->with(DummyReadOnly::class)
108+
->willReturn(new ResourceMetadataCollection(DummyReadOnly::class, [
109+
(new ApiResource())
110+
->withOperations(
111+
new Operations([
112+
'get' => (new Get())->withClass(DummyReadOnly::class),
113+
'get_collection' => (new GetCollection())->withClass(DummyReadOnly::class),
114+
'post' => (new Post())->withClass(DummyReadOnly::class),
115+
'put' => (new Put())->withClass(DummyReadOnly::class),
116+
'patch' => (new Patch())->withClass(DummyReadOnly::class),
117+
'delete' => (new Delete())->withClass(DummyReadOnly::class),
118+
])
119+
),
120+
]));
121+
122+
$resourceMetadataCollectionFactory = new DoctrineOrmResourceCollectionMetadataFactory($managerRegistry, $resourceMetadataCollectionFactory);
123+
124+
$resourceMetadataCollection = $resourceMetadataCollectionFactory->create(DummyReadOnly::class);
125+
/** @var ApiResource $apiResource */
126+
$apiResource = $resourceMetadataCollection->getIterator()->current();
127+
$operations = $apiResource->getOperations();
128+
$this->assertNotNull($operations);
129+
$this->assertTrue($operations->has('get'));
130+
$this->assertTrue($operations->has('get_collection'));
131+
$this->assertTrue($operations->has('post'));
132+
$this->assertFalse($operations->has('put'));
133+
$this->assertFalse($operations->has('path'));
134+
$this->assertTrue($operations->has('delete'));
135+
}
88136
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <dunglas@gmail.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity;
15+
16+
use ApiPlatform\Metadata\ApiResource;
17+
use Doctrine\ORM\Mapping as ORM;
18+
19+
#[ApiResource]
20+
#[ORM\Entity(readOnly: true)]
21+
class DummyReadOnly
22+
{
23+
/**
24+
* @var int|null The id
25+
*/
26+
#[ORM\Column(type: 'integer', nullable: true)]
27+
#[ORM\Id]
28+
#[ORM\GeneratedValue(strategy: 'AUTO')]
29+
private $id;
30+
31+
#[ORM\Column(type: 'string')]
32+
private string $name;
33+
34+
public function getId()
35+
{
36+
return $this->id;
37+
}
38+
39+
public function setId($id): void
40+
{
41+
$this->id = $id;
42+
}
43+
44+
public function getName(): string
45+
{
46+
return $this->name;
47+
}
48+
49+
public function setName(string $name): void
50+
{
51+
$this->name = $name;
52+
}
53+
}
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <dunglas@gmail.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\Tests\Functional\Doctrine;
15+
16+
use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
17+
use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyReadOnly;
18+
use ApiPlatform\Tests\RecreateSchemaTrait;
19+
use ApiPlatform\Tests\SetupClassResourcesTrait;
20+
21+
final class EntityReadOnlyTest extends ApiTestCase
22+
{
23+
use RecreateSchemaTrait;
24+
use SetupClassResourcesTrait;
25+
26+
protected static ?bool $alwaysBootKernel = false;
27+
28+
/**
29+
* @return class-string[]
30+
*/
31+
public static function getResources(): array
32+
{
33+
return [DummyReadOnly::class];
34+
}
35+
36+
public function testCannotUpdateOrPatchReadonlyEntity(): void
37+
{
38+
if ($this->isMongoDB()) {
39+
$this->markTestSkipped('This test is not for MongoDB.');
40+
}
41+
42+
$this->recreateSchema([DummyReadOnly::class]);
43+
$manager = static::getContainer()->get('doctrine')->getManager();
44+
45+
$dummy = new DummyReadOnly();
46+
$dummy->setName('foo');
47+
$manager->persist($dummy);
48+
$manager->flush();
49+
50+
$client = static::createClient();
51+
$response = $client->request('GET', '/dummy_read_onlies', ['headers' => ['Accept' => 'application/ld+json']]);
52+
$this->assertResponseStatusCodeSame(200);
53+
54+
$response = $client->request('GET', '/dummy_read_onlies/'.$dummy->getId(), ['headers' => ['Accept' => 'application/ld+json']]);
55+
$this->assertResponseStatusCodeSame(200);
56+
57+
$response = $client->request('POST', '/dummy_read_onlies', [
58+
'headers' => ['Content-Type' => 'application/ld+json'],
59+
'json' => [
60+
'name' => 'bar',
61+
],
62+
]);
63+
$this->assertResponseStatusCodeSame(201);
64+
65+
$response = $client->request('DELETE', '/dummy_read_onlies/'.$dummy->getId(), ['headers' => ['Content-Type' => 'application/ld+json']]);
66+
$this->assertResponseStatusCodeSame(204);
67+
68+
$response = $client->request('PUT', '/dummy_read_onlies'.$dummy->getId(), [
69+
'headers' => ['Content-Type' => 'application/ld+json'],
70+
'json' => [
71+
'name' => 'baz',
72+
],
73+
]);
74+
$this->assertResponseStatusCodeSame(404);
75+
76+
$response = $client->request('PATCH', '/dummy_read_onlies'.$dummy->getId(), [
77+
'headers' => ['Content-Type' => 'application/ld+json'],
78+
'json' => [
79+
'name' => 'baz',
80+
],
81+
]);
82+
$this->assertResponseStatusCodeSame(404);
83+
}
84+
}

0 commit comments

Comments
 (0)