From fb39d29bb39abedb99bc9e1a46e0f4588b4686a7 Mon Sep 17 00:00:00 2001 From: soyuka Date: Thu, 10 Sep 2026 09:46:54 +0200 Subject: [PATCH] fix(serializer): resolve IRIs on borrowed routes Dispatch relation IRIs through the expected resource operation when it explicitly borrows the matched route. Keep unrelated route owners subject to the existing type guard. Fixes #8503 --- src/Symfony/Routing/IriConverter.php | 21 +++++-- .../SharedRouteIri/CategoryProjection.php | 33 ++++++++++ .../SharedRouteIri/CategoryResource.php | 35 +++++++++++ .../SharedRouteIri/TopicResource.php | 47 ++++++++++++++ .../Serializer/SharedRouteIriTest.php | 63 +++++++++++++++++++ 5 files changed, 195 insertions(+), 4 deletions(-) create mode 100644 tests/Fixtures/TestBundle/ApiResource/SharedRouteIri/CategoryProjection.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/SharedRouteIri/CategoryResource.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/SharedRouteIri/TopicResource.php create mode 100644 tests/Functional/Serializer/SharedRouteIriTest.php diff --git a/src/Symfony/Routing/IriConverter.php b/src/Symfony/Routing/IriConverter.php index ea2ee88ffc..834ed723c5 100644 --- a/src/Symfony/Routing/IriConverter.php +++ b/src/Symfony/Routing/IriConverter.php @@ -99,17 +99,30 @@ public function getResourceFromIri(string $iri, array $context = [], ?Operation } $attributes = AttributesExtractor::extractAttributes($parameters); + $dispatchOperation = $routeOperation; + $expectedClass = $context['resource_class'] ?? null; + if (\is_string($expectedClass) && $expectedClass !== $parameters['_api_resource_class'] && class_exists($expectedClass)) { + foreach ($this->resourceMetadataCollectionFactory->create($expectedClass) as $resourceMetadata) { + foreach ($resourceMetadata->getOperations() ?? [] as $candidate) { + if ($candidate instanceof HttpOperation && !$candidate instanceof CollectionOperationInterface && $candidate->getRouteName() === ($parameters['_route'] ?? null)) { + $dispatchOperation = $candidate; + break 2; + } + } + } + } + try { - $uriVariables = $this->getOperationUriVariables($routeOperation, $parameters, $attributes['resource_class']); + $uriVariables = $this->getOperationUriVariables($dispatchOperation, $parameters, $dispatchOperation->getClass() ?? $attributes['resource_class']); } catch (InvalidIdentifierException|InvalidUriVariableException $e) { throw new InvalidArgumentException($e->getMessage(), $e->getCode(), $e); } // If a caller-provided GraphQl operation carries its own provider, dispatch through it // so the user-defined Query(provider: X) wins over the route-matched HTTP operation. - $dispatchOperation = ($operation instanceof GraphQlOperation && null !== $operation->getProvider()) - ? $operation - : $routeOperation; + if ($operation instanceof GraphQlOperation && null !== $operation->getProvider()) { + $dispatchOperation = $operation; + } if ($item = $this->provider->provide($dispatchOperation, $uriVariables, $context)) { return $item; diff --git a/tests/Fixtures/TestBundle/ApiResource/SharedRouteIri/CategoryProjection.php b/tests/Fixtures/TestBundle/ApiResource/SharedRouteIri/CategoryProjection.php new file mode 100644 index 0000000000..dcef232ccf --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/SharedRouteIri/CategoryProjection.php @@ -0,0 +1,33 @@ + + * + * 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\Fixtures\TestBundle\ApiResource\SharedRouteIri; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Operation; + +#[ApiResource(shortName: 'SharedIriCategoryProjection', operations: [ + new Get(uriTemplate: '/shared_iri/categories/{id}', routeName: CategoryResource::ITEM_ROUTE_NAME, openapi: false, provider: [self::class, 'provide']), +])] +final class CategoryProjection +{ + public function __construct(public int $id = 0, public string $label = '') + { + } + + public static function provide(Operation $operation, array $uriVariables = []): self + { + return new self((int) $uriVariables['id'], 'Category '.$uriVariables['id']); + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/SharedRouteIri/CategoryResource.php b/tests/Fixtures/TestBundle/ApiResource/SharedRouteIri/CategoryResource.php new file mode 100644 index 0000000000..f7cbb535d3 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/SharedRouteIri/CategoryResource.php @@ -0,0 +1,35 @@ + + * + * 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\Fixtures\TestBundle\ApiResource\SharedRouteIri; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Operation; + +#[ApiResource(shortName: 'SharedIriCategory', operations: [ + new Get(uriTemplate: '/shared_iri/categories/{id}', provider: [self::class, 'provide']), +])] +final class CategoryResource +{ + public const ITEM_ROUTE_NAME = '_api_/shared_iri/categories/{id}_get'; + + public function __construct(public int $id = 0, public string $name = '') + { + } + + public static function provide(Operation $operation, array $uriVariables = []): self + { + return new self((int) $uriVariables['id'], 'Category '.$uriVariables['id']); + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/SharedRouteIri/TopicResource.php b/tests/Fixtures/TestBundle/ApiResource/SharedRouteIri/TopicResource.php new file mode 100644 index 0000000000..660c1b9a1e --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/SharedRouteIri/TopicResource.php @@ -0,0 +1,47 @@ + + * + * 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\Fixtures\TestBundle\ApiResource\SharedRouteIri; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Post; + +#[ApiResource(shortName: 'SharedIriTopic', operations: [ + new Get(uriTemplate: '/shared_iri/topics/{id}', provider: [self::class, 'provide']), + new Post(uriTemplate: '/shared_iri/topics', processor: [self::class, 'process']), +])] +final class TopicResource +{ + public ?int $id = null; + public string $title = ''; + public ?CategoryProjection $category = null; + + public static function provide(Operation $operation, array $uriVariables = []): self + { + $topic = new self(); + $topic->id = (int) $uriVariables['id']; + $topic->title = 'Topic '.$uriVariables['id']; + $topic->category = new CategoryProjection(1, 'Category 1'); + + return $topic; + } + + public static function process(self $data): self + { + $data->id = 42; + + return $data; + } +} diff --git a/tests/Functional/Serializer/SharedRouteIriTest.php b/tests/Functional/Serializer/SharedRouteIriTest.php new file mode 100644 index 0000000000..2e28514049 --- /dev/null +++ b/tests/Functional/Serializer/SharedRouteIriTest.php @@ -0,0 +1,63 @@ + + * + * 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\Serializer; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\SharedRouteIri\CategoryProjection; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\SharedRouteIri\CategoryResource; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\SharedRouteIri\TopicResource; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class SharedRouteIriTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [CategoryResource::class, CategoryProjection::class, TopicResource::class]; + } + + public function testBorrowedRouteIriCanBeWrittenBackToTheSameRelation(): void + { + $client = self::createClient(); + $response = $client->request('GET', '/shared_iri/topics/7', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + self::assertResponseIsSuccessful(); + $categoryIri = $response->toArray()['category']; + self::assertSame('/shared_iri/categories/1', $categoryIri); + + $client->request('POST', '/shared_iri/topics', [ + 'headers' => ['Content-Type' => 'application/ld+json', 'Accept' => 'application/ld+json'], + 'json' => ['title' => 'Hello', 'category' => $categoryIri], + ]); + + self::assertResponseStatusCodeSame(201); + self::assertJsonContains(['id' => 42, 'title' => 'Hello', 'category' => $categoryIri]); + } + + public function testUnrelatedResourceIriIsRejected(): void + { + self::createClient()->request('POST', '/shared_iri/topics', [ + 'headers' => ['Content-Type' => 'application/ld+json', 'Accept' => 'application/ld+json'], + 'json' => ['title' => 'Hello', 'category' => '/shared_iri/topics/1'], + ]); + + self::assertResponseStatusCodeSame(400); + self::assertJsonContains(['detail' => 'Invalid IRI "/shared_iri/topics/1".']); + } +}