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".']); + } +}