diff --git a/src/Laravel/Routing/IriConverter.php b/src/Laravel/Routing/IriConverter.php index cb80414da9..e0bafb8887 100644 --- a/src/Laravel/Routing/IriConverter.php +++ b/src/Laravel/Routing/IriConverter.php @@ -103,7 +103,7 @@ public function getIriFromResource(object|string $resource, int $referenceType = $operation = $this->operationMetadataFactory->create($context['item_uri_template']); } - $localOperationCacheKey = ($operation?->getName() ?? '').$resourceClass.(\is_string($resource) ? '_c' : '_i'); + $localOperationCacheKey = ($operation?->getName() ?? '').$resourceClass.(\is_string($resource) ? '_s' : '_o').($operation instanceof CollectionOperationInterface ? '_c' : '_i'); if ($operation && isset($this->localOperationCache[$localOperationCacheKey])) { return $this->generateRoute($resource, $referenceType, $this->localOperationCache[$localOperationCacheKey], $context, $this->localIdentifiersExtractorOperationCache[$localOperationCacheKey] ?? null); } diff --git a/src/Laravel/Tests/Unit/Routing/IriConverterTest.php b/src/Laravel/Tests/Unit/Routing/IriConverterTest.php new file mode 100644 index 0000000000..c88de228bd --- /dev/null +++ b/src/Laravel/Tests/Unit/Routing/IriConverterTest.php @@ -0,0 +1,90 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Laravel\Tests\Unit\Routing; + +use ApiPlatform\Laravel\Routing\IriConverter; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\IdentifiersExtractorInterface; +use ApiPlatform\Metadata\Operation\Factory\OperationMetadataFactoryInterface; +use ApiPlatform\Metadata\Operations; +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; +use ApiPlatform\Metadata\ResourceClassResolverInterface; +use ApiPlatform\Metadata\UrlGeneratorInterface; +use ApiPlatform\State\ProviderInterface; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Routing\RouterInterface; +use Workbench\App\Models\Book; + +class IriConverterTest extends TestCase +{ + public function testLocalCacheKeyDistinguishesItemAndCollectionForStringResource(): void + { + $collectionOpName = 'collection_op'; + $itemOpName = 'item_op'; + + $collectionOp = (new GetCollection())->withName($collectionOpName)->withClass(Book::class); + $itemOp = (new Get())->withName($itemOpName)->withClass(Book::class); + + $router = $this->createMock(RouterInterface::class); + $router->expects($this->exactly(2)) + ->method('generate') + ->willReturnCallback(function (string $routeName, array $params) use ($collectionOpName, $itemOpName): string { + if ($collectionOpName === $routeName) { + return '/api/books'; + } + if ($itemOpName === $routeName) { + return '/api/books/'.$params['id']; + } + $this->fail(\sprintf('Unexpected route name "%s".', $routeName)); + }); + + $metadataCollection = new ResourceMetadataCollection(Book::class, [ + (new ApiResource())->withOperations(new Operations([ + $collectionOpName => $collectionOp, + $itemOpName => $itemOp, + ])), + ]); + + $resourceMetadataFactory = $this->createStub(ResourceMetadataCollectionFactoryInterface::class); + $resourceMetadataFactory->method('create')->willReturn($metadataCollection); + + $resourceClassResolver = $this->createStub(ResourceClassResolverInterface::class); + $resourceClassResolver->method('isResourceClass')->willReturn(true); + + $iriConverter = new IriConverter( + $this->createStub(ProviderInterface::class), + $this->createStub(OperationMetadataFactoryInterface::class), + $router, + $this->createStub(IdentifiersExtractorInterface::class), + $resourceClassResolver, + $resourceMetadataFactory, + ); + + $this->assertSame('/api/books', $iriConverter->getIriFromResource( + Book::class, + UrlGeneratorInterface::ABS_PATH, + new GetCollection(), + )); + + $this->assertSame('/api/books/1', $iriConverter->getIriFromResource( + Book::class, + UrlGeneratorInterface::ABS_PATH, + new Get(), + ['uri_variables' => ['id' => 1]], + )); + } +} diff --git a/src/Symfony/Routing/IriConverter.php b/src/Symfony/Routing/IriConverter.php index 0a69b77be4..5a381802b7 100644 --- a/src/Symfony/Routing/IriConverter.php +++ b/src/Symfony/Routing/IriConverter.php @@ -122,7 +122,7 @@ public function getIriFromResource(object|string $resource, int $referenceType = $operation = $this->operationMetadataFactory->create($context['item_uri_template']); } - $localOperationCacheKey = ($operation?->getName() ?? '').$resourceClass.((\is_string($resource) || $operation instanceof CollectionOperationInterface) ? '_c' : '_i'); + $localOperationCacheKey = ($operation?->getName() ?? '').$resourceClass.(\is_string($resource) ? '_s' : '_o').($operation instanceof CollectionOperationInterface ? '_c' : '_i'); if ($operation && isset($this->localOperationCache[$localOperationCacheKey])) { return $this->generateSymfonyRoute($resource, $referenceType, $this->localOperationCache[$localOperationCacheKey], $context, $this->localIdentifiersExtractorOperationCache[$localOperationCacheKey] ?? null); } diff --git a/tests/Functional/Json/RelationTest.php b/tests/Functional/Json/RelationTest.php new file mode 100644 index 0000000000..cbe39825f7 --- /dev/null +++ b/tests/Functional/Json/RelationTest.php @@ -0,0 +1,69 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Functional\Json; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ThirdLevel; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class RelationTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [ThirdLevel::class, RelatedDummy::class]; + } + + protected function setUp(): void + { + parent::setUp(); + + if ($this->isMongoDB()) { + $this->markTestSkipped('Not tested with MongoDB.'); + } + + $this->recreateSchema([ThirdLevel::class, RelatedDummy::class]); + } + + public function testCreateRelatedDummyWithPlainIdentifierForRelation(): void + { + // Creates a ThirdLevel; PurgeHttpCacheListener::postFlush caches the GetCollection + // operation under the '' + ThirdLevel + '_c' slot of IriConverter::$localOperationCache. + self::createClient()->request('POST', '/third_levels', [ + 'headers' => ['Content-Type' => 'application/json'], + 'json' => ['level' => 3], + ]); + $this->assertResponseStatusCodeSame(201); + + // RelatedDummyPlainIdentifierDenormalizer calls getIriFromResource(ThirdLevel::class, new Get(), …). + // Without the fix the '_c' slot collision returns the GetCollection op, producing + // "/third_levels?id=1" instead of "/third_levels/1". + $response = self::createClient()->request('POST', '/related_dummies', [ + 'headers' => ['Content-Type' => 'application/json'], + 'json' => ['thirdLevel' => '1'], + ]); + $this->assertResponseStatusCodeSame(201); + + $data = $response->toArray(false); + $this->assertArrayHasKey('thirdLevel', $data); + $this->assertIsArray($data['thirdLevel']); + $this->assertSame('/third_levels/1', $data['thirdLevel']['@id']); + } +}