diff --git a/src/Symfony/Routing/IriConverter.php b/src/Symfony/Routing/IriConverter.php index eb5ce19576f..00a353081a2 100644 --- a/src/Symfony/Routing/IriConverter.php +++ b/src/Symfony/Routing/IriConverter.php @@ -161,11 +161,16 @@ public function getIriFromResource(object|string $resource, int $referenceType = !$operation->getName() || ($operation instanceof HttpOperation && 'POST' === $operation->getMethod()) ) { - $forceCollection = $operation instanceof CollectionOperationInterface; - try { - $operation = $this->resourceMetadataCollectionFactory->create($resourceClass)->getOperation(null, $forceCollection, true); - $identifiersExtractorOperation = $operation; - } catch (OperationNotFoundException) { + if (isset($this->localOperationCache[$localOperationCacheKey])) { + $operation = $this->localOperationCache[$localOperationCacheKey]; + $identifiersExtractorOperation = $this->localIdentifiersExtractorOperationCache[$localOperationCacheKey] ?? $operation; + } else { + $forceCollection = $operation instanceof CollectionOperationInterface; + try { + $operation = $this->resourceMetadataCollectionFactory->create($resourceClass)->getOperation(null, $forceCollection, true); + $identifiersExtractorOperation = $operation; + } catch (OperationNotFoundException) { + } } } diff --git a/tests/Symfony/Routing/IriConverterTest.php b/tests/Symfony/Routing/IriConverterTest.php index 0e05743a303..b4be06edf49 100644 --- a/tests/Symfony/Routing/IriConverterTest.php +++ b/tests/Symfony/Routing/IriConverterTest.php @@ -110,6 +110,92 @@ public function testGetIriFromItemWithContextOperation(): void $this->assertSame('/dummies/1', $iriConverter->getIriFromResource($item, UrlGeneratorInterface::ABS_URL, $operation)); } + public function testGetIriFromItemWithoutOperationUsesTheLocalOperationCache(): void + { + $item = new Dummy(); + $item->setId(1); + + $operationName = 'operation_name'; + $operation = (new Get())->withName($operationName); + + $routerProphecy = $this->prophesize(RouterInterface::class); + $routerProphecy->generate($operationName, ['id' => 1], UrlGeneratorInterface::ABS_PATH)->shouldBeCalledTimes(2)->willReturn('/dummies/1'); + + $identifiersExtractorProphecy = $this->prophesize(IdentifiersExtractorInterface::class); + $identifiersExtractorProphecy->getIdentifiersFromItem($item, $operation, Argument::any())->shouldBeCalledTimes(2)->willReturn(['id' => 1]); + + $resourceMetadataCollectionFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); + $resourceMetadataCollectionFactoryProphecy->create(Dummy::class)->shouldBeCalledOnce()->willReturn(new ResourceMetadataCollection(Dummy::class, [ + (new ApiResource())->withOperations(new Operations([$operationName => $operation])), + ])); + + $iriConverter = $this->getIriConverter(null, $routerProphecy, $identifiersExtractorProphecy, $resourceMetadataCollectionFactoryProphecy); + + $this->assertSame('/dummies/1', $iriConverter->getIriFromResource($item)); + $this->assertSame('/dummies/1', $iriConverter->getIriFromResource($item)); + } + + public function testGetIriFromItemWithoutOperationReusesTheCachedOperation(): void + { + $item = new Dummy(); + $item->setId(1); + + $cachedOperation = (new Get())->withName('cached_operation'); + $staleOperation = (new Get())->withName('stale_operation'); + + $routerProphecy = $this->prophesize(RouterInterface::class); + $routerProphecy->generate('cached_operation', ['id' => 1], UrlGeneratorInterface::ABS_PATH)->willReturn('/dummies/1'); + $routerProphecy->generate('stale_operation', ['id' => 1], UrlGeneratorInterface::ABS_PATH)->willReturn('/stale/1'); + + $identifiersExtractorProphecy = $this->prophesize(IdentifiersExtractorInterface::class); + $identifiersExtractorProphecy->getIdentifiersFromItem($item, Argument::cetera())->willReturn(['id' => 1]); + + // Prophecy returns these in order across consecutive calls, repeating the last. + $resourceMetadataCollectionFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); + $resourceMetadataCollectionFactoryProphecy->create(Dummy::class)->willReturn( + new ResourceMetadataCollection(Dummy::class, [(new ApiResource())->withOperations(new Operations(['cached_operation' => $cachedOperation]))]), + new ResourceMetadataCollection(Dummy::class, [(new ApiResource())->withOperations(new Operations(['stale_operation' => $staleOperation]))]), + ); + + $iriConverter = $this->getIriConverter(null, $routerProphecy, $identifiersExtractorProphecy, $resourceMetadataCollectionFactoryProphecy); + + $this->assertSame('/dummies/1', $iriConverter->getIriFromResource($item)); + $this->assertSame('/dummies/1', $iriConverter->getIriFromResource($item)); + } + + public function testLocalOperationCacheDistinguishesItemAndCollectionIris(): void + { + $item = new Dummy(); + $item->setId(1); + + $itemOperation = (new Get())->withName('item_operation')->withClass(Dummy::class); + $collectionOperation = (new GetCollection())->withName('collection_operation')->withClass(Dummy::class); + + $routerProphecy = $this->prophesize(RouterInterface::class); + $routerProphecy->generate('item_operation', ['id' => 1], UrlGeneratorInterface::ABS_PATH)->shouldBeCalledTimes(2)->willReturn('/dummies/1'); + $routerProphecy->generate('collection_operation', [], UrlGeneratorInterface::ABS_PATH)->shouldBeCalledTimes(2)->willReturn('/dummies'); + + $identifiersExtractorProphecy = $this->prophesize(IdentifiersExtractorInterface::class); + $identifiersExtractorProphecy->getIdentifiersFromItem($item, Argument::cetera())->willReturn(['id' => 1]); + + // getOperation(null, $forceCollection, true) picks between the two according to $forceCollection. + $resourceMetadataCollectionFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); + $resourceMetadataCollectionFactoryProphecy->create(Dummy::class)->shouldBeCalledTimes(2)->willReturn(new ResourceMetadataCollection(Dummy::class, [ + (new ApiResource())->withOperations(new Operations([ + 'item_operation' => $itemOperation, + 'collection_operation' => $collectionOperation, + ])), + ])); + + $iriConverter = $this->getIriConverter(null, $routerProphecy, $identifiersExtractorProphecy, $resourceMetadataCollectionFactoryProphecy); + + // Interleaved on purpose: the third and fourth calls must read the cache entry the first two wrote. + $this->assertSame('/dummies/1', $iriConverter->getIriFromResource($item)); + $this->assertSame('/dummies', $iriConverter->getIriFromResource(Dummy::class, UrlGeneratorInterface::ABS_PATH, new GetCollection())); + $this->assertSame('/dummies/1', $iriConverter->getIriFromResource($item)); + $this->assertSame('/dummies', $iriConverter->getIriFromResource(Dummy::class, UrlGeneratorInterface::ABS_PATH, new GetCollection())); + } + public function testGetIriFromItemWithNoOperations(): void { $this->expectExceptionMessage(\sprintf('Unable to generate an IRI for the item of type "%s"', Dummy::class));