From ef13a4f694bfce5c36b14b3177dd40ed5240af3f Mon Sep 17 00:00:00 2001 From: soyuka Date: Tue, 28 Apr 2026 23:11:14 +0200 Subject: [PATCH] feat(symfony): api_platform_iris route loader for graphql-only setups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit | Q | A | ------------- | --- | Branch? | 4.3 | Tickets | #7915 | License | MIT | Doc PR | ∅ Adds a new `api_platform_iris` Symfony route loader type that registers only the auto-generated `NotExposed` operations, allowing graphql-only APIs to keep IRIs working without exposing REST endpoints. Replaces the earlier IriConverter decorator approach. --- src/Symfony/Routing/ApiLoader.php | 16 ++++++++++++++-- tests/Symfony/Routing/ApiLoaderTest.php | 21 +++++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/src/Symfony/Routing/ApiLoader.php b/src/Symfony/Routing/ApiLoader.php index f3abea05d35..4c546ae8399 100644 --- a/src/Symfony/Routing/ApiLoader.php +++ b/src/Symfony/Routing/ApiLoader.php @@ -14,6 +14,7 @@ namespace ApiPlatform\Symfony\Routing; use ApiPlatform\Metadata\Exception\RuntimeException; +use ApiPlatform\Metadata\NotExposed; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Metadata\Resource\Factory\ResourceNameCollectionFactoryInterface; use ApiPlatform\OpenApi\Attributes\Webhook; @@ -34,6 +35,8 @@ final class ApiLoader extends Loader { public const DEFAULT_ACTION_PATTERN = 'api_platform.action.'; + public const TYPE = 'api_platform'; + public const TYPE_IRIS = 'api_platform_iris'; private readonly PhpFileLoader $fileLoader; @@ -48,15 +51,24 @@ public function __construct(KernelInterface $kernel, private readonly ResourceNa */ public function load(mixed $data, ?string $type = null): RouteCollection { + $notExposedOnly = self::TYPE_IRIS === $type; + $routeCollection = new RouteCollection(); foreach ($this->resourceClassDirectories as $directory) { $routeCollection->addResource(new DirectoryResource($directory, '/\.php$/')); } - $this->loadExternalFiles($routeCollection); + if (!$notExposedOnly) { + $this->loadExternalFiles($routeCollection); + } + foreach ($this->resourceNameCollectionFactory->create() as $resourceClass) { foreach ($this->resourceMetadataFactory->create($resourceClass) as $resourceMetadata) { foreach ($resourceMetadata->getOperations() as $operationName => $operation) { + if ($notExposedOnly && !$operation instanceof NotExposed) { + continue; + } + if ($operation->getOpenapi() instanceof Webhook) { continue; } @@ -119,7 +131,7 @@ public function load(mixed $data, ?string $type = null): RouteCollection */ public function supports(mixed $resource, ?string $type = null): bool { - return 'api_platform' === $type; + return self::TYPE === $type || self::TYPE_IRIS === $type; } /** diff --git a/tests/Symfony/Routing/ApiLoaderTest.php b/tests/Symfony/Routing/ApiLoaderTest.php index da4d2256e05..77de66e6f78 100644 --- a/tests/Symfony/Routing/ApiLoaderTest.php +++ b/tests/Symfony/Routing/ApiLoaderTest.php @@ -19,6 +19,7 @@ use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\Link; +use ApiPlatform\Metadata\NotExposed; use ApiPlatform\Metadata\Operations; use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; @@ -257,6 +258,25 @@ public function testApiLoaderWithPrefix(): void ); } + public function testApiLoaderIrisTypeOnlyEmitsNotExposedRoutes(): void + { + $resourceCollection = new ResourceMetadataCollection(Dummy::class, [ + (new ApiResource())->withShortName('dummy')->withOperations(new Operations([ + 'api_dummies_get_item' => (new Get())->withUriTemplate('/dummies/{id}{._format}')->withController('api_platform.action.get_item'), + 'api_dummies_get_collection' => (new GetCollection())->withUriTemplate('/dummies{._format}'), + 'api_dummies_not_exposed_item' => (new NotExposed())->withUriTemplate('/dummies/{id}{._format}'), + ])), + ]); + + $routeCollection = $this->getApiLoaderWithResourceMetadataCollection($resourceCollection)->load(null, ApiLoader::TYPE_IRIS); + + $this->assertNull($routeCollection->get('api_dummies_get_item')); + $this->assertNull($routeCollection->get('api_dummies_get_collection')); + $this->assertNotNull($routeCollection->get('api_dummies_not_exposed_item')); + $this->assertNull($routeCollection->get('api_jsonld_context')); + $this->assertNull($routeCollection->get('api_entrypoint')); + } + public function testApiLoaderWithUndefinedControllerService(): void { $this->expectExceptionObject(new \RuntimeException('Operation "api_dummies_my_undefined_controller_method_item" is defining an unknown service as controller "Foo\\Bar\\MyUndefinedController". Make sure it is properly registered in the dependency injection container.')); @@ -284,6 +304,7 @@ private function getApiLoaderWithResourceMetadataCollection(ResourceMetadataColl 'api_platform.action.get_item', 'api_platform.action.put_item', 'api_platform.action.delete_item', + 'api_platform.action.not_exposed', 'Foo\\Bar\\MyController', ]; $containerProphecy = $this->prophesize(ContainerInterface::class);