From c9dfe03f6aaace0e1b35395af4593027634227ed Mon Sep 17 00:00:00 2001 From: CRC <46287826+pysocrates@users.noreply.github.com> Date: Wed, 11 Nov 2026 15:03:13 -0500 Subject: [PATCH 1/7] fix(state): allow invalid IRI exceptions to propagate --- .../IriConverterParameterProvider.php | 4 +- .../IriConverterParameterProviderTest.php | 56 +++++++++++++++++++ .../TestBundle/Entity/Issue8834Resource.php | 36 ++++++++++++ .../Parameters/Issue8834InvalidIriTest.php | 49 ++++++++++++++++ 4 files changed, 143 insertions(+), 2 deletions(-) create mode 100644 src/State/Tests/IriConverterParameterProviderTest.php create mode 100644 tests/Fixtures/TestBundle/Entity/Issue8834Resource.php create mode 100644 tests/Functional/Parameters/Issue8834InvalidIriTest.php diff --git a/src/State/ParameterProvider/IriConverterParameterProvider.php b/src/State/ParameterProvider/IriConverterParameterProvider.php index e7b2309e484..03082c9e1fe 100644 --- a/src/State/ParameterProvider/IriConverterParameterProvider.php +++ b/src/State/ParameterProvider/IriConverterParameterProvider.php @@ -52,7 +52,7 @@ public function provide(Parameter $parameter, array $parameters = [], array $con try { $entities[] = $this->iriConverter->getResourceFromIri($v, $iriConverterContext); } catch (InvalidArgumentException|ItemNotFoundException $exception) { - if ($exception instanceof ItemNotFoundException && true === ($extraProperties['throw_not_found'] ?? false)) { + if (($exception instanceof ItemNotFoundException && true === ($extraProperties['throw_not_found'] ?? false)) || ($exception instanceof InvalidArgumentException && true === ($extraProperties['throw_invalid_argument'] ?? false))) { throw $exception; } @@ -75,7 +75,7 @@ public function provide(Parameter $parameter, array $parameters = [], array $con try { $parameter->setValue($this->iriConverter->getResourceFromIri($value, $iriConverterContext)); } catch (InvalidArgumentException|ItemNotFoundException $exception) { - if ($exception instanceof ItemNotFoundException && true === ($extraProperties['throw_not_found'] ?? false)) { + if (($exception instanceof ItemNotFoundException && true === ($extraProperties['throw_not_found'] ?? false)) || ($exception instanceof InvalidArgumentException && true === ($extraProperties['throw_invalid_argument'] ?? false))) { throw $exception; } diff --git a/src/State/Tests/IriConverterParameterProviderTest.php b/src/State/Tests/IriConverterParameterProviderTest.php new file mode 100644 index 00000000000..dc45cda18f2 --- /dev/null +++ b/src/State/Tests/IriConverterParameterProviderTest.php @@ -0,0 +1,56 @@ + + * + * 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\State\Tests; + +use ApiPlatform\Metadata\Exception\InvalidArgumentException; +use ApiPlatform\Metadata\IriConverterInterface; +use ApiPlatform\Metadata\QueryParameter; +use ApiPlatform\State\ParameterProvider\IriConverterParameterProvider; +use PHPUnit\Framework\TestCase; +use Psr\Log\NullLogger; + +final class IriConverterParameterProviderTest extends TestCase +{ + public function testInvalidIriCanBeThrownForScalarAndArrayValues(): void + { + foreach (['/invalid', ['/invalid']] as $value) { + $converter = $this->createStub(IriConverterInterface::class); + $exception = new InvalidArgumentException('Invalid IRI'); + $converter->method('getResourceFromIri')->willThrowException($exception); + $parameter = new QueryParameter(extraProperties: ['throw_invalid_argument' => true]); + $parameter->setValue($value); + + try { + (new IriConverterParameterProvider($converter, new NullLogger()))->provide($parameter); + self::fail('The invalid IRI exception should be propagated.'); + } catch (InvalidArgumentException $caught) { + self::assertSame($exception, $caught); + } + } + } + + public function testInvalidIriIsStillIgnoredByDefault(): void + { + foreach (['/invalid', ['/invalid']] as $value) { + $converter = $this->createStub(IriConverterInterface::class); + $converter->method('getResourceFromIri')->willThrowException(new InvalidArgumentException('Invalid IRI')); + $parameter = new QueryParameter(); + $parameter->setValue($value); + + (new IriConverterParameterProvider($converter, new NullLogger()))->provide($parameter); + + self::assertSame(\is_array($value) ? [] : $value, $parameter->getValue()); + } + } +} diff --git a/tests/Fixtures/TestBundle/Entity/Issue8834Resource.php b/tests/Fixtures/TestBundle/Entity/Issue8834Resource.php new file mode 100644 index 00000000000..bcac7a2a1cd --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/Issue8834Resource.php @@ -0,0 +1,36 @@ + + * + * 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\Entity; + +use ApiPlatform\Doctrine\Orm\Filter\IriFilter; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\QueryParameter; +use Doctrine\ORM\Mapping as ORM; + +#[ORM\Entity] +#[ApiResource(operations: [new Get(), new GetCollection(parameters: [ + 'related' => new QueryParameter(filter: new IriFilter(), extraProperties: ['throw_invalid_argument' => true]), +])])] +class Issue8834Resource +{ + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column] + public ?int $id = null; + + #[ORM\ManyToOne(targetEntity: self::class)] + public ?self $related = null; +} diff --git a/tests/Functional/Parameters/Issue8834InvalidIriTest.php b/tests/Functional/Parameters/Issue8834InvalidIriTest.php new file mode 100644 index 00000000000..35e9f99cb3a --- /dev/null +++ b/tests/Functional/Parameters/Issue8834InvalidIriTest.php @@ -0,0 +1,49 @@ + + * + * 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\Parameters; + +use ApiPlatform\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Issue8834Resource; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class Issue8834InvalidIriTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [Issue8834Resource::class]; + } + + protected function setUp(): void + { + $this->recreateSchema(self::getResources()); + } + + public function testInvalidScalarIriReturnsBadRequest(): void + { + self::createClient()->request('GET', '/issue8834_resources?related=/invalid/iri'); + self::assertResponseStatusCodeSame(400); + } + + public function testInvalidArrayIriReturnsBadRequest(): void + { + self::createClient()->request('GET', '/issue8834_resources?related[]=/invalid/iri'); + self::assertResponseStatusCodeSame(400); + } +} From a03bdd39936e0cd109d5dd4a5b9d82df09f472b4 Mon Sep 17 00:00:00 2001 From: CRC <46287826+pysocrates@users.noreply.github.com> Date: Wed, 11 Nov 2026 15:08:35 -0500 Subject: [PATCH 2/7] fix(state): distinguish missing and invalid IRIs --- .../IriConverterParameterProvider.php | 4 ++-- .../IriConverterParameterProviderTest.php | 24 +++++++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/src/State/ParameterProvider/IriConverterParameterProvider.php b/src/State/ParameterProvider/IriConverterParameterProvider.php index 03082c9e1fe..6860c496535 100644 --- a/src/State/ParameterProvider/IriConverterParameterProvider.php +++ b/src/State/ParameterProvider/IriConverterParameterProvider.php @@ -52,7 +52,7 @@ public function provide(Parameter $parameter, array $parameters = [], array $con try { $entities[] = $this->iriConverter->getResourceFromIri($v, $iriConverterContext); } catch (InvalidArgumentException|ItemNotFoundException $exception) { - if (($exception instanceof ItemNotFoundException && true === ($extraProperties['throw_not_found'] ?? false)) || ($exception instanceof InvalidArgumentException && true === ($extraProperties['throw_invalid_argument'] ?? false))) { + if ($exception instanceof ItemNotFoundException ? true === ($extraProperties['throw_not_found'] ?? false) : true === ($extraProperties['throw_invalid_argument'] ?? false)) { throw $exception; } @@ -75,7 +75,7 @@ public function provide(Parameter $parameter, array $parameters = [], array $con try { $parameter->setValue($this->iriConverter->getResourceFromIri($value, $iriConverterContext)); } catch (InvalidArgumentException|ItemNotFoundException $exception) { - if (($exception instanceof ItemNotFoundException && true === ($extraProperties['throw_not_found'] ?? false)) || ($exception instanceof InvalidArgumentException && true === ($extraProperties['throw_invalid_argument'] ?? false))) { + if ($exception instanceof ItemNotFoundException ? true === ($extraProperties['throw_not_found'] ?? false) : true === ($extraProperties['throw_invalid_argument'] ?? false)) { throw $exception; } diff --git a/src/State/Tests/IriConverterParameterProviderTest.php b/src/State/Tests/IriConverterParameterProviderTest.php index dc45cda18f2..cf099e815bf 100644 --- a/src/State/Tests/IriConverterParameterProviderTest.php +++ b/src/State/Tests/IriConverterParameterProviderTest.php @@ -14,6 +14,7 @@ namespace ApiPlatform\State\Tests; use ApiPlatform\Metadata\Exception\InvalidArgumentException; +use ApiPlatform\Metadata\Exception\ItemNotFoundException; use ApiPlatform\Metadata\IriConverterInterface; use ApiPlatform\Metadata\QueryParameter; use ApiPlatform\State\ParameterProvider\IriConverterParameterProvider; @@ -22,6 +23,29 @@ final class IriConverterParameterProviderTest extends TestCase { + public function testNotFoundAndInvalidIriOptionsAreIndependent(): void + { + foreach (['/missing', ['/missing']] as $value) { + $converter = $this->createStub(IriConverterInterface::class); + $exception = new ItemNotFoundException('Missing resource'); + $converter->method('getResourceFromIri')->willThrowException($exception); + $provider = new IriConverterParameterProvider($converter, new NullLogger()); + $parameter = new QueryParameter(extraProperties: ['throw_invalid_argument' => true]); + $parameter->setValue($value); + $provider->provide($parameter); + self::assertSame(\is_array($value) ? [] : $value, $parameter->getValue()); + + $parameter = new QueryParameter(extraProperties: ['throw_not_found' => true]); + $parameter->setValue($value); + try { + $provider->provide($parameter); + self::fail('The not-found exception should be propagated.'); + } catch (ItemNotFoundException $caught) { + self::assertSame($exception, $caught); + } + } + } + public function testInvalidIriCanBeThrownForScalarAndArrayValues(): void { foreach (['/invalid', ['/invalid']] as $value) { From f3e1f857635a19089dbe7e92bf97507d4d02162b Mon Sep 17 00:00:00 2001 From: CRC <46287826+pysocrates@users.noreply.github.com> Date: Wed, 11 Nov 2026 16:27:20 -0500 Subject: [PATCH 3/7] test: skip ORM-only IRI test with MongoDB --- tests/Functional/Parameters/Issue8834InvalidIriTest.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/Functional/Parameters/Issue8834InvalidIriTest.php b/tests/Functional/Parameters/Issue8834InvalidIriTest.php index 35e9f99cb3a..964a7cc3b3b 100644 --- a/tests/Functional/Parameters/Issue8834InvalidIriTest.php +++ b/tests/Functional/Parameters/Issue8834InvalidIriTest.php @@ -32,6 +32,10 @@ public static function getResources(): array protected function setUp(): void { + if ('mongodb' === ($_SERVER['APP_ENV'] ?? null)) { + $this->markTestSkipped('This test uses a Doctrine ORM fixture.'); + } + $this->recreateSchema(self::getResources()); } From f4c3dd184633c167a1b8669f79014e634aa3aa65 Mon Sep 17 00:00:00 2001 From: CRC <46287826+pysocrates@users.noreply.github.com> Date: Wed, 11 Nov 2026 16:33:08 -0500 Subject: [PATCH 4/7] fix(laravel): allow supported MCP bundle version --- src/Laravel/composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Laravel/composer.json b/src/Laravel/composer.json index 72a91813430..1ca34ab97d2 100644 --- a/src/Laravel/composer.json +++ b/src/Laravel/composer.json @@ -65,7 +65,7 @@ "phpstan/phpdoc-parser": "^1.29 || ^2.0", "phpunit/phpunit": "^11.5 || ^12.2 || ^13.0", "symfony/http-client": "^7.4 || ^8.0", - "symfony/mcp-bundle": "^0.13", + "symfony/mcp-bundle": "^0.13 || ^0.15", "symfony/object-mapper": "^7.4 || ^8.0" }, "autoload": { From b6523c517db6a762af293ac49ee7b4034b168bc0 Mon Sep 17 00:00:00 2001 From: soyuka Date: Thu, 12 Nov 2026 11:37:16 +0100 Subject: [PATCH 5/7] revert: drop out-of-scope changes --- src/Laravel/composer.json | 2 +- .../IriConverterParameterProvider.php | 4 +- .../IriConverterParameterProviderTest.php | 80 ------------------- 3 files changed, 3 insertions(+), 83 deletions(-) delete mode 100644 src/State/Tests/IriConverterParameterProviderTest.php diff --git a/src/Laravel/composer.json b/src/Laravel/composer.json index 1ca34ab97d2..72a91813430 100644 --- a/src/Laravel/composer.json +++ b/src/Laravel/composer.json @@ -65,7 +65,7 @@ "phpstan/phpdoc-parser": "^1.29 || ^2.0", "phpunit/phpunit": "^11.5 || ^12.2 || ^13.0", "symfony/http-client": "^7.4 || ^8.0", - "symfony/mcp-bundle": "^0.13 || ^0.15", + "symfony/mcp-bundle": "^0.13", "symfony/object-mapper": "^7.4 || ^8.0" }, "autoload": { diff --git a/src/State/ParameterProvider/IriConverterParameterProvider.php b/src/State/ParameterProvider/IriConverterParameterProvider.php index 6860c496535..e7b2309e484 100644 --- a/src/State/ParameterProvider/IriConverterParameterProvider.php +++ b/src/State/ParameterProvider/IriConverterParameterProvider.php @@ -52,7 +52,7 @@ public function provide(Parameter $parameter, array $parameters = [], array $con try { $entities[] = $this->iriConverter->getResourceFromIri($v, $iriConverterContext); } catch (InvalidArgumentException|ItemNotFoundException $exception) { - if ($exception instanceof ItemNotFoundException ? true === ($extraProperties['throw_not_found'] ?? false) : true === ($extraProperties['throw_invalid_argument'] ?? false)) { + if ($exception instanceof ItemNotFoundException && true === ($extraProperties['throw_not_found'] ?? false)) { throw $exception; } @@ -75,7 +75,7 @@ public function provide(Parameter $parameter, array $parameters = [], array $con try { $parameter->setValue($this->iriConverter->getResourceFromIri($value, $iriConverterContext)); } catch (InvalidArgumentException|ItemNotFoundException $exception) { - if ($exception instanceof ItemNotFoundException ? true === ($extraProperties['throw_not_found'] ?? false) : true === ($extraProperties['throw_invalid_argument'] ?? false)) { + if ($exception instanceof ItemNotFoundException && true === ($extraProperties['throw_not_found'] ?? false)) { throw $exception; } diff --git a/src/State/Tests/IriConverterParameterProviderTest.php b/src/State/Tests/IriConverterParameterProviderTest.php deleted file mode 100644 index cf099e815bf..00000000000 --- a/src/State/Tests/IriConverterParameterProviderTest.php +++ /dev/null @@ -1,80 +0,0 @@ - - * - * 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\State\Tests; - -use ApiPlatform\Metadata\Exception\InvalidArgumentException; -use ApiPlatform\Metadata\Exception\ItemNotFoundException; -use ApiPlatform\Metadata\IriConverterInterface; -use ApiPlatform\Metadata\QueryParameter; -use ApiPlatform\State\ParameterProvider\IriConverterParameterProvider; -use PHPUnit\Framework\TestCase; -use Psr\Log\NullLogger; - -final class IriConverterParameterProviderTest extends TestCase -{ - public function testNotFoundAndInvalidIriOptionsAreIndependent(): void - { - foreach (['/missing', ['/missing']] as $value) { - $converter = $this->createStub(IriConverterInterface::class); - $exception = new ItemNotFoundException('Missing resource'); - $converter->method('getResourceFromIri')->willThrowException($exception); - $provider = new IriConverterParameterProvider($converter, new NullLogger()); - $parameter = new QueryParameter(extraProperties: ['throw_invalid_argument' => true]); - $parameter->setValue($value); - $provider->provide($parameter); - self::assertSame(\is_array($value) ? [] : $value, $parameter->getValue()); - - $parameter = new QueryParameter(extraProperties: ['throw_not_found' => true]); - $parameter->setValue($value); - try { - $provider->provide($parameter); - self::fail('The not-found exception should be propagated.'); - } catch (ItemNotFoundException $caught) { - self::assertSame($exception, $caught); - } - } - } - - public function testInvalidIriCanBeThrownForScalarAndArrayValues(): void - { - foreach (['/invalid', ['/invalid']] as $value) { - $converter = $this->createStub(IriConverterInterface::class); - $exception = new InvalidArgumentException('Invalid IRI'); - $converter->method('getResourceFromIri')->willThrowException($exception); - $parameter = new QueryParameter(extraProperties: ['throw_invalid_argument' => true]); - $parameter->setValue($value); - - try { - (new IriConverterParameterProvider($converter, new NullLogger()))->provide($parameter); - self::fail('The invalid IRI exception should be propagated.'); - } catch (InvalidArgumentException $caught) { - self::assertSame($exception, $caught); - } - } - } - - public function testInvalidIriIsStillIgnoredByDefault(): void - { - foreach (['/invalid', ['/invalid']] as $value) { - $converter = $this->createStub(IriConverterInterface::class); - $converter->method('getResourceFromIri')->willThrowException(new InvalidArgumentException('Invalid IRI')); - $parameter = new QueryParameter(); - $parameter->setValue($value); - - (new IriConverterParameterProvider($converter, new NullLogger()))->provide($parameter); - - self::assertSame(\is_array($value) ? [] : $value, $parameter->getValue()); - } - } -} From 7ecab7c670b42f720c48db964c16de6c87e5a2d4 Mon Sep 17 00:00:00 2001 From: soyuka Date: Thu, 12 Nov 2026 11:37:49 +0100 Subject: [PATCH 6/7] test(doctrine): invalid IRI on ORM IriFilter --- .../TestBundle/Entity/Issue8834Resource.php | 2 +- .../Parameters/Issue8834InvalidIriTest.php | 18 +++++++++++------- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/tests/Fixtures/TestBundle/Entity/Issue8834Resource.php b/tests/Fixtures/TestBundle/Entity/Issue8834Resource.php index bcac7a2a1cd..c652e72d8da 100644 --- a/tests/Fixtures/TestBundle/Entity/Issue8834Resource.php +++ b/tests/Fixtures/TestBundle/Entity/Issue8834Resource.php @@ -22,7 +22,7 @@ #[ORM\Entity] #[ApiResource(operations: [new Get(), new GetCollection(parameters: [ - 'related' => new QueryParameter(filter: new IriFilter(), extraProperties: ['throw_invalid_argument' => true]), + 'related' => new QueryParameter(filter: new IriFilter()), ])])] class Issue8834Resource { diff --git a/tests/Functional/Parameters/Issue8834InvalidIriTest.php b/tests/Functional/Parameters/Issue8834InvalidIriTest.php index 964a7cc3b3b..bca50e6c21d 100644 --- a/tests/Functional/Parameters/Issue8834InvalidIriTest.php +++ b/tests/Functional/Parameters/Issue8834InvalidIriTest.php @@ -39,15 +39,19 @@ protected function setUp(): void $this->recreateSchema(self::getResources()); } - public function testInvalidScalarIriReturnsBadRequest(): void + public function testInvalidIriIsIgnored(): void { + $manager = $this->getContainer()->get('doctrine')->getManager(); + $related = new Issue8834Resource(); + $manager->persist($related); + $resource = new Issue8834Resource(); + $resource->related = $related; + $manager->persist($resource); + $manager->flush(); + self::createClient()->request('GET', '/issue8834_resources?related=/invalid/iri'); - self::assertResponseStatusCodeSame(400); - } - public function testInvalidArrayIriReturnsBadRequest(): void - { - self::createClient()->request('GET', '/issue8834_resources?related[]=/invalid/iri'); - self::assertResponseStatusCodeSame(400); + self::assertResponseStatusCodeSame(200); + self::assertJsonContains(['hydra:totalItems' => 0]); } } From f82b3dd8e5be3fd98ab32ba9da4395326bfb37d3 Mon Sep 17 00:00:00 2001 From: soyuka Date: Thu, 12 Nov 2026 11:37:49 +0100 Subject: [PATCH 7/7] fix(doctrine): ignore unresolved IRIs in ORM IriFilter --- src/Doctrine/Orm/Filter/IriFilter.php | 10 ++++++++++ .../Orm/Tests/Filter/NestedPropertyJoinTypeTest.php | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/Doctrine/Orm/Filter/IriFilter.php b/src/Doctrine/Orm/Filter/IriFilter.php index e340eb01301..a8e3f637f3e 100644 --- a/src/Doctrine/Orm/Filter/IriFilter.php +++ b/src/Doctrine/Orm/Filter/IriFilter.php @@ -68,6 +68,16 @@ public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $q return; } + if (is_iterable($value)) { + $value = array_values(array_filter(\is_array($value) ? $value : iterator_to_array($value), is_object(...))); + } + + if ([] === $value || (!\is_array($value) && !\is_object($value))) { + $queryBuilder->{$context['whereClause'] ?? 'andWhere'}('1 = 0'); + + return; + } + // Collection associations (OneToMany/ManyToMany) require a JOIN to compare individual elements. if ($ormLeafMetadata['is_collection_valued']) { if (Join::LEFT_JOIN === $this->joinTypeFor($context)) { diff --git a/src/Doctrine/Orm/Tests/Filter/NestedPropertyJoinTypeTest.php b/src/Doctrine/Orm/Tests/Filter/NestedPropertyJoinTypeTest.php index 6d9ead76b20..3c8f8908419 100644 --- a/src/Doctrine/Orm/Tests/Filter/NestedPropertyJoinTypeTest.php +++ b/src/Doctrine/Orm/Tests/Filter/NestedPropertyJoinTypeTest.php @@ -63,7 +63,7 @@ public function testIriFilterCollectionJoinTypeDependsOnWhereClause(string $wher $queryBuilder = (new QueryBuilder($entityManager))->select('o')->from(Dummy::class, 'o'); $parameter = (new QueryParameter(key: 'relatedDummies', property: 'relatedDummies')) ->withExtraProperties([ - '_api_values' => 'x', + '_api_values' => new RelatedDummy(), 'nested_properties_info' => ['relatedDummies' => [ 'relation_classes' => [], 'converted_relation_segments' => [],