From 294072a4850d280519e1fb0fe3e2c2ee90bc85f9 Mon Sep 17 00:00:00 2001 From: soyuka Date: Thu, 14 May 2026 08:30:36 +0200 Subject: [PATCH 1/2] fix(symfony): prevent IriConverter local cache key collision between item and collection ops The `_c` suffix was shared by string-resource and collection-op calls, so two unrelated callsites with the same operation name and resource class returned each other's cached operation. Surfaces under worker runtimes (FrankenPHP, Swoole) where the converter persists across requests. Encode both axes independently: `_s|_o` for string vs object resource, `_c|_i` for collection vs item operation. --- src/Symfony/Routing/IriConverter.php | 2 +- tests/Functional/Json/RelationTest.php | 69 ++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 1 deletion(-) create mode 100644 tests/Functional/Json/RelationTest.php diff --git a/src/Symfony/Routing/IriConverter.php b/src/Symfony/Routing/IriConverter.php index 0a69b77be49..5a381802b7b 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 00000000000..cbe39825f7f --- /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']); + } +} From bd752181c04b0a2a8d486d98eb9ab5e2bf523ccf Mon Sep 17 00:00:00 2001 From: soyuka Date: Fri, 15 May 2026 08:05:25 +0200 Subject: [PATCH 2/2] fix(laravel): prevent IriConverter local cache key collision between item and collection ops MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same collision as the Symfony converter — actually broader here, since the Laravel key didn't check `CollectionOperationInterface` at all: any string-resource call shared one slot regardless of operation type. Surfaces under Octane/worker mode where the converter persists across requests. Encode both axes independently: `_s|_o` for string vs object resource, `_c|_i` for collection vs item operation. --- src/Laravel/Routing/IriConverter.php | 2 +- .../Tests/Unit/Routing/IriConverterTest.php | 90 +++++++++++++++++++ 2 files changed, 91 insertions(+), 1 deletion(-) create mode 100644 src/Laravel/Tests/Unit/Routing/IriConverterTest.php diff --git a/src/Laravel/Routing/IriConverter.php b/src/Laravel/Routing/IriConverter.php index cb80414da95..e0bafb88878 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 00000000000..c88de228bda --- /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]], + )); + } +}