diff --git a/src/Symfony/Action/EntrypointAction.php b/src/Symfony/Action/EntrypointAction.php index 50b9b2442c..aa43e9afbc 100644 --- a/src/Symfony/Action/EntrypointAction.php +++ b/src/Symfony/Action/EntrypointAction.php @@ -29,7 +29,7 @@ */ final class EntrypointAction { - private static ResourceNameCollection $resourceNameCollection; + private ?ResourceNameCollection $resourceNameCollection = null; public function __construct( private readonly ResourceNameCollectionFactoryInterface $resourceNameCollectionFactory, @@ -41,7 +41,7 @@ public function __construct( public function __invoke(Request $request): mixed { - static::$resourceNameCollection = $this->resourceNameCollectionFactory->create(); + $this->resourceNameCollection ??= $this->resourceNameCollectionFactory->create(); $context = [ 'request' => $request, 'spec_version' => (string) $request->query->get(LegacyOpenApiNormalizer::SPEC_VERSION), @@ -52,7 +52,7 @@ public function __invoke(Request $request): mixed read: true, serialize: true, class: Entrypoint::class, - provider: [self::class, 'provide'] + provider: [$this, 'provide'] ); $request->attributes->set('_api_operation', $operation); $body = $this->provider->provide($operation, [], $context); @@ -61,8 +61,8 @@ class: Entrypoint::class, return $this->processor->process($body, $operation, [], $context); } - public static function provide(): Entrypoint + public function provide(): Entrypoint { - return new Entrypoint(static::$resourceNameCollection); + return new Entrypoint($this->resourceNameCollection); } } diff --git a/src/Symfony/Tests/Action/EntrypointActionTest.php b/src/Symfony/Tests/Action/EntrypointActionTest.php index 572606a904..40fcda7613 100644 --- a/src/Symfony/Tests/Action/EntrypointActionTest.php +++ b/src/Symfony/Tests/Action/EntrypointActionTest.php @@ -30,13 +30,84 @@ class EntrypointActionTest extends TestCase public function testGetEntrypointWithProviderProcessor(): void { $expected = new Entrypoint(new ResourceNameCollection(['dummies'])); + $resourceNameCollectionFactory = $this->createMock(ResourceNameCollectionFactoryInterface::class); $resourceNameCollectionFactory->method('create')->willReturn(new ResourceNameCollection(['dummies'])); + $provider = $this->createMock(ProviderInterface::class); $provider->expects($this->once())->method('provide')->willReturn($expected); + $processor = $this->createMock(ProcessorInterface::class); $processor->expects($this->once())->method('process')->willReturnArgument(0); + $entrypoint = new EntrypointAction($resourceNameCollectionFactory, $provider, $processor); $this->assertEquals($expected, $entrypoint(Request::create('/'))); } + + public function testInvokeCachesResourceNameCollection(): void + { + $resourceNameCollectionFactory = $this->createMock(ResourceNameCollectionFactoryInterface::class); + $resourceNameCollectionFactory->expects($this->once()) + ->method('create') + ->willReturn(new ResourceNameCollection(['Dummy'])); + + $provider = $this->createMock(ProviderInterface::class); + $processor = $this->createMock(ProcessorInterface::class); + + $action = new EntrypointAction($resourceNameCollectionFactory, $provider, $processor); + + $request = new Request(); + + $provider->expects($this->exactly(2)) + ->method('provide') + ->willReturn(new Entrypoint(new ResourceNameCollection(['Dummy']))); + + $processor->expects($this->exactly(2)) + ->method('process'); + + $action($request); + + // Test that second call does not call factory again (lazy-loading/caching) + $action($request); + } + + /** + * This test ensures that instances are isolated and don't leak state. + * In Worker mode (FrankenPHP/Swoole), static properties would cause a state leak between instances. + */ + public function testInstancesAreIsolated(): void + { + $processor = $this->createMock(ProcessorInterface::class); + $provider = $this->createMock(ProviderInterface::class); + + // Instance 1: configured with ResourceA + $factory1 = $this->createMock(ResourceNameCollectionFactoryInterface::class); + $factory1->method('create')->willReturn(new ResourceNameCollection(['ResourceA'])); + $action1 = new EntrypointAction($factory1, $provider, $processor); + + // Instance 2: configured with ResourceB + $factory2 = $this->createMock(ResourceNameCollectionFactoryInterface::class); + $factory2->method('create')->willReturn(new ResourceNameCollection(['ResourceB'])); + $action2 = new EntrypointAction($factory2, $provider, $processor); + + $request = new Request(); + + // 1. Trigger action 1 + $action1($request); + // 2. Trigger action 2 (if static were used, this would overwrite action 1's state) + $action2($request); + + // Verification of isolation: + $this->assertEquals( + new ResourceNameCollection(['ResourceA']), + $action1->provide()->getResourceNameCollection(), + "Instance 1 was polluted by Instance 2 (likely due to a static property)" + ); + + $this->assertEquals( + new ResourceNameCollection(['ResourceB']), + $action2->provide()->getResourceNameCollection(), + "Instance 2 has incorrect state." + ); + } }