From a5408ddbf23b6521486c4c8c16062b46bf4fc688 Mon Sep 17 00:00:00 2001 From: soyuka Date: Fri, 1 May 2026 11:32:11 +0200 Subject: [PATCH] fix(state): scope ReadLinkParameterProvider to current Link's class MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit | Q | A | ------------- | --- | Branch? | 4.3 | Tickets | Closes #7939 | License | MIT | Doc PR | ∅ getUriVariables() previously assigned the same scalar to every URI variable of the linked operation, breaking nested resources whose primary Get has multiple URI variables. Resolution now sets only the URI variable matching the current Link's fromClass. --- .../ReadLinkParameterProvider.php | 36 +++++---- .../ApiResource/Issue7939BarResource.php | 62 ++++++++++++++ .../ApiResource/Issue7939BazResource.php | 81 +++++++++++++++++++ .../ApiResource/Issue7939FooResource.php | 39 +++++++++ .../Parameters/LinkProviderParameterTest.php | 46 ++++++++++- 5 files changed, 249 insertions(+), 15 deletions(-) create mode 100644 tests/Fixtures/TestBundle/ApiResource/Issue7939BarResource.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/Issue7939BazResource.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/Issue7939FooResource.php diff --git a/src/State/ParameterProvider/ReadLinkParameterProvider.php b/src/State/ParameterProvider/ReadLinkParameterProvider.php index 9b64676d5c..906eb0ac9d 100644 --- a/src/State/ParameterProvider/ReadLinkParameterProvider.php +++ b/src/State/ParameterProvider/ReadLinkParameterProvider.php @@ -105,11 +105,13 @@ public function provide(Parameter $parameter, array $parameters = [], array $con } /** - * @return array + * @return array */ private function getUriVariables(mixed $value, Parameter $parameter, Operation $operation): array { - $extraProperties = $parameter->getExtraProperties(); + if (\is_array($value)) { + return $value; + } if ($operation instanceof HttpOperation) { $links = $operation->getUriVariables(); @@ -119,24 +121,30 @@ private function getUriVariables(mixed $value, Parameter $parameter, Operation $ $links = []; } - if (!\is_array($value)) { - $uriVariables = []; + $extraProperties = $parameter->getExtraProperties(); + $linkClass = $parameter instanceof Link + ? ($parameter->getFromClass() ?? $parameter->getToClass()) + : null; + + $fallbackKey = null; + foreach ($links as $key => $link) { + if (!\is_string($key)) { + $key = $link->getParameterName() ?? $extraProperties['uri_variable'] ?? $link->getFromProperty(); + } - foreach ($links as $key => $link) { - if (!\is_string($key)) { - $key = $link->getParameterName() ?? $extraProperties['uri_variable'] ?? $link->getFromProperty(); - } + if (!$key || !\is_string($key)) { + continue; + } - if (!$key || !\is_string($key)) { - continue; - } + $linkFromClass = $link instanceof Link ? ($link->getFromClass() ?? $link->getToClass()) : null; - $uriVariables[$key] = $value; + if (null !== $linkClass && $linkFromClass === $linkClass) { + return [$key => $value]; } - return $uriVariables; + $fallbackKey ??= $key; } - return $value; + return null === $fallbackKey ? [] : [$fallbackKey => $value]; } } diff --git a/tests/Fixtures/TestBundle/ApiResource/Issue7939BarResource.php b/tests/Fixtures/TestBundle/ApiResource/Issue7939BarResource.php new file mode 100644 index 0000000000..485c9e75ae --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/Issue7939BarResource.php @@ -0,0 +1,62 @@ + + * + * 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; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Link; +use ApiPlatform\Metadata\Operation; + +#[ApiResource( + operations: [ + new Get( + uriTemplate: '/issue7939_foos/{fooId}/bars/{id}', + uriVariables: [ + 'fooId' => new Link(fromClass: Issue7939FooResource::class, toProperty: 'foo'), + 'id' => new Link(fromClass: self::class), + ], + provider: [self::class, 'provide'], + ), + ], +)] +final class Issue7939BarResource +{ + private const PARENTS = ['B' => 'F2']; + + public string $id = ''; + public ?Issue7939FooResource $foo = null; + + public static function parentOf(string $barId): ?string + { + return self::PARENTS[$barId] ?? null; + } + + public static function provide(Operation $operation, array $uriVariables = []) + { + $id = (string) ($uriVariables['id'] ?? ''); + $parent = self::parentOf($id); + + if (null === $parent) { + return null; + } + + $bar = new self(); + $bar->id = $id; + $foo = new Issue7939FooResource(); + $foo->id = $parent; + $bar->foo = $foo; + + return $bar; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/Issue7939BazResource.php b/tests/Fixtures/TestBundle/ApiResource/Issue7939BazResource.php new file mode 100644 index 0000000000..51f49c569c --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/Issue7939BazResource.php @@ -0,0 +1,81 @@ + + * + * 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; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Link; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Parameter; +use ApiPlatform\State\ParameterProvider\ReadLinkParameterProvider; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; + +#[ApiResource( + operations: [ + new Get( + uriTemplate: '/issue7939_foos/{fooId}/bars/{barId}/baz', + uriVariables: [ + 'fooId' => new Link(fromClass: Issue7939FooResource::class), + 'barId' => new Link( + fromClass: Issue7939BarResource::class, + identifiers: ['id'], + provider: ReadLinkParameterProvider::class, + ), + ], + provider: [self::class, 'provide'], + ), + new Get( + uriTemplate: '/issue7939_foos/{fooId}/bars/{barId}/baz_strict', + uriVariables: [ + 'fooId' => new Link( + fromClass: Issue7939FooResource::class, + provider: [self::class, 'validateParent'], + ), + 'barId' => new Link( + fromClass: Issue7939BarResource::class, + identifiers: ['id'], + provider: ReadLinkParameterProvider::class, + ), + ], + provider: [self::class, 'provide'], + ), + ], +)] +final class Issue7939BazResource +{ + public string $id = '1'; + public string $barId = ''; + public string $fooId = ''; + + public static function provide(Operation $operation, array $uriVariables = []) + { + $r = new self(); + $r->fooId = (string) ($uriVariables['fooId'] ?? ''); + $r->barId = (string) ($uriVariables['barId'] ?? ''); + + return $r; + } + + public static function validateParent(Parameter $parameter, array $values = [], array $context = []): ?Operation + { + $barId = (string) ($values['barId'] ?? ''); + $fooId = (string) ($values['fooId'] ?? ''); + + if (Issue7939BarResource::parentOf($barId) !== $fooId) { + throw new NotFoundHttpException('Bar does not belong to the requested Foo.'); + } + + return $context['operation'] ?? null; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/Issue7939FooResource.php b/tests/Fixtures/TestBundle/ApiResource/Issue7939FooResource.php new file mode 100644 index 0000000000..670179b82c --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/Issue7939FooResource.php @@ -0,0 +1,39 @@ + + * + * 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; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Operation; + +#[ApiResource( + operations: [ + new Get( + uriTemplate: '/issue7939_foos/{id}', + provider: [self::class, 'provide'], + ), + ], +)] +final class Issue7939FooResource +{ + public string $id = ''; + + public static function provide(Operation $operation, array $uriVariables = []) + { + $r = new self(); + $r->id = (string) ($uriVariables['id'] ?? ''); + + return $r; + } +} diff --git a/tests/Functional/Parameters/LinkProviderParameterTest.php b/tests/Functional/Parameters/LinkProviderParameterTest.php index 9702582119..cd7ef7d5a4 100644 --- a/tests/Functional/Parameters/LinkProviderParameterTest.php +++ b/tests/Functional/Parameters/LinkProviderParameterTest.php @@ -15,6 +15,9 @@ use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue7469TestResource; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue7939BarResource; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue7939BazResource; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue7939FooResource; use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\LinkParameterProviderResource; use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\WithParameter; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Company; @@ -40,7 +43,7 @@ final class LinkProviderParameterTest extends ApiTestCase */ public static function getResources(): array { - return [WithParameter::class, Dummy::class, Employee::class, Company::class, LinkParameterProviderResource::class, Issue7469TestResource::class, Issue7469Dummy::class, Pairing::class, Plan::class]; + return [WithParameter::class, Dummy::class, Employee::class, Company::class, LinkParameterProviderResource::class, Issue7469TestResource::class, Issue7469Dummy::class, Pairing::class, Plan::class, Issue7939FooResource::class, Issue7939BarResource::class, Issue7939BazResource::class]; } /** @@ -236,6 +239,47 @@ public function testSecurityLinkWithDifferentFromClassDoesNotBreakDoctrine(): vo ]); } + /** + * @see https://github.com/api-platform/core/issues/7939 + */ + public function testReadLinkParameterProviderResolvesNestedUriVariables(): void + { + $container = static::getContainer(); + if ('mongodb' === $container->getParameter('kernel.environment')) { + $this->markTestSkipped(); + } + + $response = self::createClient()->request('GET', '/issue7939_foos/F/bars/B/baz'); + self::assertResponseStatusCodeSame(200); + self::assertJsonContains([ + 'fooId' => 'F', + 'barId' => 'B', + ]); + } + + /** + * @see https://github.com/api-platform/core/issues/7939 + */ + public function testParentLinkProviderEnforcesParentScope(): void + { + $container = static::getContainer(); + if ('mongodb' === $container->getParameter('kernel.environment')) { + $this->markTestSkipped(); + } + + $client = self::createClient(); + + $client->request('GET', '/issue7939_foos/F2/bars/B/baz_strict'); + self::assertResponseStatusCodeSame(200); + self::assertJsonContains([ + 'fooId' => 'F2', + 'barId' => 'B', + ]); + + $client->request('GET', '/issue7939_foos/F1/bars/B/baz_strict'); + self::assertResponseStatusCodeSame(404); + } + public function testIssue7469IriGenerationFailsForLinkedResource(): void { $container = static::getContainer();