From c3fe07f8fd5b4093390063ffa6d8764ed4cdd250 Mon Sep 17 00:00:00 2001 From: soyuka Date: Thu, 7 Dec 2023 11:32:00 +0100 Subject: [PATCH 1/4] feat(metadata): throwOnNotFound option fixes #6014 --- src/Metadata/ApiResource.php | 2 ++ src/Metadata/Delete.php | 2 ++ src/Metadata/Get.php | 2 ++ src/Metadata/GetCollection.php | 2 ++ src/Metadata/HttpOperation.php | 2 ++ src/Metadata/Metadata.php | 14 ++++++++++++++ src/Metadata/Operation.php | 2 ++ src/Metadata/Patch.php | 2 ++ src/Metadata/Post.php | 2 ++ src/Metadata/Put.php | 2 ++ src/State/Provider/ReadProvider.php | 9 +-------- src/Symfony/Controller/MainController.php | 10 ++++++++++ 12 files changed, 43 insertions(+), 8 deletions(-) diff --git a/src/Metadata/ApiResource.php b/src/Metadata/ApiResource.php index 32922afb1d7..5d136533d83 100644 --- a/src/Metadata/ApiResource.php +++ b/src/Metadata/ApiResource.php @@ -978,6 +978,7 @@ public function __construct( protected ?bool $strictQueryParameterValidation = null, protected ?bool $hideHydraOperation = null, protected ?bool $jsonStream = null, + protected ?bool $throwOnNotFound = null, protected array $extraProperties = [], ?bool $map = null, protected ?array $mcp = null, @@ -1026,6 +1027,7 @@ class: $class, strictQueryParameterValidation: $strictQueryParameterValidation, hideHydraOperation: $hideHydraOperation, jsonStream: $jsonStream, + throwOnNotFound: $throwOnNotFound, extraProperties: $extraProperties, map: $map, ); diff --git a/src/Metadata/Delete.php b/src/Metadata/Delete.php index 3674a5d6fe9..61744470fd6 100644 --- a/src/Metadata/Delete.php +++ b/src/Metadata/Delete.php @@ -102,6 +102,7 @@ public function __construct( ?bool $strictQueryParameterValidation = null, protected ?bool $hideHydraOperation = null, ?bool $jsonStream = null, + ?bool $throwOnNotFound = null, array $extraProperties = [], ?bool $map = null, ) { @@ -187,6 +188,7 @@ class: $class, parameters: $parameters, strictQueryParameterValidation: $strictQueryParameterValidation, hideHydraOperation: $hideHydraOperation, + throwOnNotFound: $throwOnNotFound, stateOptions: $stateOptions, map: $map ); diff --git a/src/Metadata/Get.php b/src/Metadata/Get.php index 4c59d7ab957..82a01f83dc8 100644 --- a/src/Metadata/Get.php +++ b/src/Metadata/Get.php @@ -102,6 +102,7 @@ public function __construct( ?bool $strictQueryParameterValidation = null, protected ?bool $hideHydraOperation = null, ?bool $jsonStream = null, + ?bool $throwOnNotFound = null, array $extraProperties = [], ?bool $map = null, ) { @@ -186,6 +187,7 @@ class: $class, strictQueryParameterValidation: $strictQueryParameterValidation, hideHydraOperation: $hideHydraOperation, jsonStream: $jsonStream, + throwOnNotFound: $throwOnNotFound, extraProperties: $extraProperties, map: $map ); diff --git a/src/Metadata/GetCollection.php b/src/Metadata/GetCollection.php index 6256366bd27..a94ae240999 100644 --- a/src/Metadata/GetCollection.php +++ b/src/Metadata/GetCollection.php @@ -103,6 +103,7 @@ public function __construct( protected ?bool $hideHydraOperation = null, ?bool $jsonStream = null, array $extraProperties = [], + ?bool $throwOnNotFound = null, private ?string $itemUriTemplate = null, ?bool $map = null, ) { @@ -181,6 +182,7 @@ class: $class, processor: $processor, parameters: $parameters, jsonStream: $jsonStream, + throwOnNotFound: $throwOnNotFound, extraProperties: $extraProperties, rules: $rules, policy: $policy, diff --git a/src/Metadata/HttpOperation.php b/src/Metadata/HttpOperation.php index 32dfa15bb7e..a8f28f22d83 100644 --- a/src/Metadata/HttpOperation.php +++ b/src/Metadata/HttpOperation.php @@ -222,6 +222,7 @@ public function __construct( array|string|null $middleware = null, ?bool $queryParameterValidationEnabled = null, ?bool $jsonStream = null, + ?bool $throwOnNotFound = null, array $extraProperties = [], ?bool $map = null, ) { @@ -283,6 +284,7 @@ class: $class, strictQueryParameterValidation: $strictQueryParameterValidation, hideHydraOperation: $hideHydraOperation, jsonStream: $jsonStream, + throwOnNotFound: $throwOnNotFound, extraProperties: $extraProperties, map: $map ); diff --git a/src/Metadata/Metadata.php b/src/Metadata/Metadata.php index 009612c9900..35e039c85a8 100644 --- a/src/Metadata/Metadata.php +++ b/src/Metadata/Metadata.php @@ -82,6 +82,7 @@ public function __construct( protected ?bool $hideHydraOperation = null, protected ?bool $jsonStream = null, protected ?bool $map = null, + protected ?bool $throwOnNotFound = null, protected array $extraProperties = [], ) { if (\is_array($parameters) && $parameters) { @@ -655,6 +656,19 @@ public function withMiddleware(string|array $middleware): static return $self; } + public function getThrowOnNotFound(): ?bool + { + return $this->throwOnNotFound; + } + + public function withThrowOnNotFound(bool $throwOnNotFound): static + { + $self = clone $this; + $self->throwOnNotFound = $throwOnNotFound; + + return $self; + } + public function getExtraProperties(): ?array { return $this->extraProperties; diff --git a/src/Metadata/Operation.php b/src/Metadata/Operation.php index cbd53751e59..343915a673c 100644 --- a/src/Metadata/Operation.php +++ b/src/Metadata/Operation.php @@ -814,6 +814,7 @@ public function __construct( protected ?bool $strictQueryParameterValidation = null, protected ?bool $hideHydraOperation = null, protected ?bool $jsonStream = null, + protected ?bool $throwOnNotFound = null, protected array $extraProperties = [], ?bool $map = null, ) { @@ -862,6 +863,7 @@ class: $class, strictQueryParameterValidation: $strictQueryParameterValidation, hideHydraOperation: $hideHydraOperation, jsonStream: $jsonStream, + throwOnNotFound: $throwOnNotFound, extraProperties: $extraProperties, map: $map ); diff --git a/src/Metadata/Patch.php b/src/Metadata/Patch.php index e6147a18dad..100ac370e7a 100644 --- a/src/Metadata/Patch.php +++ b/src/Metadata/Patch.php @@ -102,6 +102,7 @@ public function __construct( ?bool $strictQueryParameterValidation = null, ?bool $hideHydraOperation = null, ?bool $jsonStream = null, + ?bool $throwOnNotFound = null, array $extraProperties = [], ?bool $map = null, ) { @@ -187,6 +188,7 @@ class: $class, strictQueryParameterValidation: $strictQueryParameterValidation, hideHydraOperation: $hideHydraOperation, jsonStream: $jsonStream, + throwOnNotFound: $throwOnNotFound, extraProperties: $extraProperties, map: $map ); diff --git a/src/Metadata/Post.php b/src/Metadata/Post.php index e68e4b0ec66..61a4a059c7c 100644 --- a/src/Metadata/Post.php +++ b/src/Metadata/Post.php @@ -100,6 +100,7 @@ public function __construct( ?string $policy = null, array|string|null $middleware = null, ?bool $jsonStream = null, + ?bool $throwOnNotFound = null, array $extraProperties = [], private ?string $itemUriTemplate = null, ?bool $strictQueryParameterValidation = null, @@ -188,6 +189,7 @@ class: $class, strictQueryParameterValidation: $strictQueryParameterValidation, hideHydraOperation: $hideHydraOperation, jsonStream: $jsonStream, + throwOnNotFound: $throwOnNotFound, extraProperties: $extraProperties, map: $map ); diff --git a/src/Metadata/Put.php b/src/Metadata/Put.php index 73632c786bc..87529e95879 100644 --- a/src/Metadata/Put.php +++ b/src/Metadata/Put.php @@ -100,6 +100,7 @@ public function __construct( ?string $policy = null, array|string|null $middleware = null, ?bool $jsonStream = null, + ?bool $throwOnNotFound = null, array $extraProperties = [], ?bool $strictQueryParameterValidation = null, ?bool $hideHydraOperation = null, @@ -188,6 +189,7 @@ class: $class, strictQueryParameterValidation: $strictQueryParameterValidation, hideHydraOperation: $hideHydraOperation, jsonStream: $jsonStream, + throwOnNotFound: $throwOnNotFound, extraProperties: $extraProperties, map: $map ); diff --git a/src/State/Provider/ReadProvider.php b/src/State/Provider/ReadProvider.php index c6c65a3888b..f2780e1cc5b 100644 --- a/src/State/Provider/ReadProvider.php +++ b/src/State/Provider/ReadProvider.php @@ -15,7 +15,6 @@ use ApiPlatform\Metadata\HttpOperation; use ApiPlatform\Metadata\Operation; -use ApiPlatform\Metadata\Put; use ApiPlatform\Metadata\Util\CloneTrait; use ApiPlatform\State\Exception\ProviderNotFoundException; use ApiPlatform\State\ProviderInterface; @@ -88,13 +87,7 @@ public function provide(Operation $operation, array $uriVariables = [], array $c $data = null; } - if ( - null === $data - && 'POST' !== $operation->getMethod() - && ('PUT' !== $operation->getMethod() - || ($operation instanceof Put && !($operation->getAllowCreate() ?? false)) - ) - ) { + if (null === $data && $operation->getThrowOnNotFound()) { throw new NotFoundHttpException('Not Found', $e ?? null); } diff --git a/src/Symfony/Controller/MainController.php b/src/Symfony/Controller/MainController.php index f50c49ec105..817b0f5d0f5 100644 --- a/src/Symfony/Controller/MainController.php +++ b/src/Symfony/Controller/MainController.php @@ -17,6 +17,7 @@ use ApiPlatform\Metadata\Exception\InvalidUriVariableException; use ApiPlatform\Metadata\Exception\RuntimeException; use ApiPlatform\Metadata\HttpOperation; +use ApiPlatform\Metadata\Put; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Metadata\UriVariablesConverterInterface; use ApiPlatform\State\ProcessorInterface; @@ -91,6 +92,15 @@ public function __invoke(Request $request): Response $operation = $operation->withDenormalizationContext($denormalizationContext + [SerializerContextBuilderInterface::ASSIGN_OBJECT_TO_POPULATE => $assignObjectToPopulate]); } + if (null === $operation->getThrowOnNotFound()) { + $operation = $operation->withThrowOnNotFound( + 'POST' !== $operation->getMethod() + && ('PUT' !== $operation->getMethod() + || ($operation instanceof Put && !($operation->getAllowCreate() ?? false)) + ) + ); + } + $body = $this->provider->provide($operation, $uriVariables, $context); // The provider can change the Operation, extract it again from the Request attributes From a047d764f3f9876863d084db39add56c950b0453 Mon Sep 17 00:00:00 2001 From: soyuka Date: Mon, 1 Jun 2026 10:50:13 +0200 Subject: [PATCH 2/4] test(metadata): wire throwOnNotFound through extractors and fixtures Preserve legacy POST/PUT default in ReadProvider when option unset; remove now-redundant override in MainController. Adds XSD attr, XML/YAML extractor parsing, ResourceMetadataCompatibilityTest fixture + BASE entry, XmlResourceAdapter ATTRIBUTES entry, and XmlExtractorTest/YamlExtractorTest expectations. Six new ReadProviderTest cases cover explicit true/false and default GET/POST/PUT/PUT+allowCreate paths. --- .../Extractor/XmlResourceExtractor.php | 1 + .../Extractor/YamlResourceExtractor.php | 1 + src/Metadata/Extractor/schema/resources.xsd | 1 + .../Extractor/Adapter/XmlResourceAdapter.php | 1 + .../Tests/Extractor/Adapter/resources.xml | 2 +- .../Tests/Extractor/Adapter/resources.yaml | 1 + .../ResourceMetadataCompatibilityTest.php | 2 + .../Tests/Extractor/XmlExtractorTest.php | 8 +++ .../Tests/Extractor/YamlExtractorTest.php | 12 ++++ src/State/Provider/ReadProvider.php | 15 +++- src/State/Tests/Provider/ReadProviderTest.php | 70 +++++++++++++++++++ src/Symfony/Controller/MainController.php | 10 --- 12 files changed, 111 insertions(+), 13 deletions(-) diff --git a/src/Metadata/Extractor/XmlResourceExtractor.php b/src/Metadata/Extractor/XmlResourceExtractor.php index 4d3c3206b53..d439c1e9815 100644 --- a/src/Metadata/Extractor/XmlResourceExtractor.php +++ b/src/Metadata/Extractor/XmlResourceExtractor.php @@ -149,6 +149,7 @@ private function buildBase(\SimpleXMLElement $resource): array 'write' => $this->phpize($resource, 'write', 'bool'), 'jsonStream' => $this->phpize($resource, 'jsonStream', 'bool'), 'map' => $this->phpize($resource, 'map', 'bool'), + 'throwOnNotFound' => $this->phpize($resource, 'throwOnNotFound', 'bool'), ]; } diff --git a/src/Metadata/Extractor/YamlResourceExtractor.php b/src/Metadata/Extractor/YamlResourceExtractor.php index 67848c56942..38ac28e057d 100644 --- a/src/Metadata/Extractor/YamlResourceExtractor.php +++ b/src/Metadata/Extractor/YamlResourceExtractor.php @@ -176,6 +176,7 @@ private function buildBase(array $resource): array 'write' => $this->phpize($resource, 'write', 'bool'), 'jsonStream' => $this->phpize($resource, 'jsonStream', 'bool'), 'map' => $this->phpize($resource, 'map', 'bool'), + 'throwOnNotFound' => $this->phpize($resource, 'throwOnNotFound', 'bool'), ]; } diff --git a/src/Metadata/Extractor/schema/resources.xsd b/src/Metadata/Extractor/schema/resources.xsd index 8a1644c4790..6019722d6bb 100644 --- a/src/Metadata/Extractor/schema/resources.xsd +++ b/src/Metadata/Extractor/schema/resources.xsd @@ -526,6 +526,7 @@ + diff --git a/src/Metadata/Tests/Extractor/Adapter/XmlResourceAdapter.php b/src/Metadata/Tests/Extractor/Adapter/XmlResourceAdapter.php index 6e3a1296f1b..4bebfa435e7 100644 --- a/src/Metadata/Tests/Extractor/Adapter/XmlResourceAdapter.php +++ b/src/Metadata/Tests/Extractor/Adapter/XmlResourceAdapter.php @@ -66,6 +66,7 @@ final class XmlResourceAdapter implements ResourceAdapterInterface 'stateOptions', 'collectDenormalizationErrors', 'jsonStream', + 'throwOnNotFound', 'links', 'parameters', ]; diff --git a/src/Metadata/Tests/Extractor/Adapter/resources.xml b/src/Metadata/Tests/Extractor/Adapter/resources.xml index 06c90ebfd20..15883953196 100644 --- a/src/Metadata/Tests/Extractor/Adapter/resources.xml +++ b/src/Metadata/Tests/Extractor/Adapter/resources.xml @@ -1,3 +1,3 @@ -someirischemaanotheririschemaCommentapplication/vnd.openxmlformats-officedocument.spreadsheetml.sheetapplication/merge-patch+json+ldapplication/merge-patch+json+ld_foo\d+bazhttps
60120AuthorizationAccept-LanguageAcceptcomment:read_collectioncomment:writebazhttp://purl.org/dc/terms/bazbarcomment.another_custom_filteruserIdLorem ipsum dolor sit ametDolor sit ametbarstringapplication/vnd.ms-excelapplication/merge-patch+jsonapplication/merge-patch+jsonpouet\d+barhttphttps60120AuthorizationAccept-Languagecomment:readcomment:writecomment:custombazhttp://purl.org/dc/terms/bazbarcomment.custom_filterfoobarcustombazcustomquxcomment:read_collectioncomment:writebarcomment.another_custom_filteruserIdLorem ipsum dolor sit ametDolor sit ametbar/v1/v1Lorem ipsum dolor sit ametDolor sit amet/v1Lorem ipsum dolor sit ametDolor sit amet/v1Lorem ipsum dolor sit ametDolor sit ametLorem ipsum dolor sit ametDolor sit amet +someirischemaanotheririschemaCommentapplication/vnd.openxmlformats-officedocument.spreadsheetml.sheetapplication/merge-patch+json+ldapplication/merge-patch+json+ld_foo\d+bazhttps
60120AuthorizationAccept-LanguageAcceptcomment:read_collectioncomment:writebazhttp://purl.org/dc/terms/bazbarcomment.another_custom_filteruserIdLorem ipsum dolor sit ametDolor sit ametbarstringapplication/vnd.ms-excelapplication/merge-patch+jsonapplication/merge-patch+jsonpouet\d+barhttphttps60120AuthorizationAccept-Languagecomment:readcomment:writecomment:custombazhttp://purl.org/dc/terms/bazbarcomment.custom_filterfoobarcustombazcustomquxcomment:read_collectioncomment:writebarcomment.another_custom_filteruserIdLorem ipsum dolor sit ametDolor sit ametbar/v1/v1Lorem ipsum dolor sit ametDolor sit amet/v1Lorem ipsum dolor sit ametDolor sit amet/v1Lorem ipsum dolor sit ametDolor sit ametLorem ipsum dolor sit ametDolor sit amet diff --git a/src/Metadata/Tests/Extractor/Adapter/resources.yaml b/src/Metadata/Tests/Extractor/Adapter/resources.yaml index 6dc74676c48..fe1595bf154 100644 --- a/src/Metadata/Tests/Extractor/Adapter/resources.yaml +++ b/src/Metadata/Tests/Extractor/Adapter/resources.yaml @@ -343,6 +343,7 @@ resources: strictQueryParameterValidation: false hideHydraOperation: false jsonStream: true + throwOnNotFound: true extraProperties: custom_property: 'Lorem ipsum dolor sit amet' another_custom_property: diff --git a/src/Metadata/Tests/Extractor/ResourceMetadataCompatibilityTest.php b/src/Metadata/Tests/Extractor/ResourceMetadataCompatibilityTest.php index fb4f177d81f..0accd28d7ca 100644 --- a/src/Metadata/Tests/Extractor/ResourceMetadataCompatibilityTest.php +++ b/src/Metadata/Tests/Extractor/ResourceMetadataCompatibilityTest.php @@ -168,6 +168,7 @@ final class ResourceMetadataCompatibilityTest extends TestCase ], ], 'jsonStream' => true, + 'throwOnNotFound' => true, 'mercure' => true, 'stateOptions' => [ 'elasticsearchOptions' => [ @@ -481,6 +482,7 @@ final class ResourceMetadataCompatibilityTest extends TestCase 'order', 'extraProperties', 'jsonStream', + 'throwOnNotFound', ]; private const EXTENDED_BASE = [ 'uriTemplate', diff --git a/src/Metadata/Tests/Extractor/XmlExtractorTest.php b/src/Metadata/Tests/Extractor/XmlExtractorTest.php index b9d4dc23594..3447e2417ea 100644 --- a/src/Metadata/Tests/Extractor/XmlExtractorTest.php +++ b/src/Metadata/Tests/Extractor/XmlExtractorTest.php @@ -107,6 +107,8 @@ public function testValidXML(): void 'jsonStream' => null, 'map' => null, 'jsonldContext' => null, + + 'throwOnNotFound' => null, ], [ 'uriTemplate' => '/users/{author}/comments{._format}', @@ -285,6 +287,8 @@ public function testValidXML(): void 'jsonStream' => null, 'map' => null, 'jsonldContext' => null, + + 'throwOnNotFound' => null, ], [ 'name' => null, @@ -400,6 +404,8 @@ public function testValidXML(): void 'jsonStream' => null, 'map' => null, 'jsonldContext' => null, + + 'throwOnNotFound' => null, ], ], 'graphQlOperations' => null, @@ -414,6 +420,8 @@ public function testValidXML(): void 'jsonStream' => null, 'map' => null, 'jsonldContext' => null, + + 'throwOnNotFound' => null, ], ], ], $extractor->getResources()); diff --git a/src/Metadata/Tests/Extractor/YamlExtractorTest.php b/src/Metadata/Tests/Extractor/YamlExtractorTest.php index 7d58abe6ba3..de25bcf7a9b 100644 --- a/src/Metadata/Tests/Extractor/YamlExtractorTest.php +++ b/src/Metadata/Tests/Extractor/YamlExtractorTest.php @@ -106,6 +106,8 @@ public function testValidYaml(): void 'jsonStream' => null, 'map' => null, 'jsonldContext' => null, + + 'throwOnNotFound' => null, ], ], Program::class => [ @@ -180,6 +182,8 @@ public function testValidYaml(): void 'jsonStream' => null, 'map' => null, 'jsonldContext' => null, + + 'throwOnNotFound' => null, ], [ 'uriTemplate' => '/users/{author}/programs{._format}', @@ -325,6 +329,8 @@ public function testValidYaml(): void 'jsonStream' => null, 'map' => null, 'jsonldContext' => null, + + 'throwOnNotFound' => null, ], [ 'name' => null, @@ -413,6 +419,8 @@ public function testValidYaml(): void 'jsonStream' => null, 'map' => null, 'jsonldContext' => null, + + 'throwOnNotFound' => null, ], ], 'graphQlOperations' => null, @@ -427,6 +435,8 @@ public function testValidYaml(): void 'jsonStream' => null, 'map' => null, 'jsonldContext' => null, + + 'throwOnNotFound' => null, ], ], SingleFileConfigDummy::class => [ @@ -501,6 +511,8 @@ public function testValidYaml(): void 'jsonStream' => null, 'map' => null, 'jsonldContext' => null, + + 'throwOnNotFound' => null, ], ], ], $extractor->getResources()); diff --git a/src/State/Provider/ReadProvider.php b/src/State/Provider/ReadProvider.php index f2780e1cc5b..734229d4aea 100644 --- a/src/State/Provider/ReadProvider.php +++ b/src/State/Provider/ReadProvider.php @@ -15,6 +15,7 @@ use ApiPlatform\Metadata\HttpOperation; use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Put; use ApiPlatform\Metadata\Util\CloneTrait; use ApiPlatform\State\Exception\ProviderNotFoundException; use ApiPlatform\State\ProviderInterface; @@ -87,8 +88,18 @@ public function provide(Operation $operation, array $uriVariables = [], array $c $data = null; } - if (null === $data && $operation->getThrowOnNotFound()) { - throw new NotFoundHttpException('Not Found', $e ?? null); + if (null === $data) { + $throwOnNotFound = $operation->getThrowOnNotFound(); + if (null === $throwOnNotFound) { + $throwOnNotFound = 'POST' !== $operation->getMethod() + && ('PUT' !== $operation->getMethod() + || ($operation instanceof Put && !($operation->getAllowCreate() ?? false)) + ); + } + + if ($throwOnNotFound) { + throw new NotFoundHttpException('Not Found', $e ?? null); + } } $request?->attributes->set('data', $data); diff --git a/src/State/Tests/Provider/ReadProviderTest.php b/src/State/Tests/Provider/ReadProviderTest.php index 92cfce527da..3b5f6ee2092 100644 --- a/src/State/Tests/Provider/ReadProviderTest.php +++ b/src/State/Tests/Provider/ReadProviderTest.php @@ -15,11 +15,14 @@ use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Post; +use ApiPlatform\Metadata\Put; use ApiPlatform\State\Provider\ReadProvider; use ApiPlatform\State\ProviderInterface; use ApiPlatform\State\SerializerContextBuilderInterface; use PHPUnit\Framework\TestCase; use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; class ReadProviderTest extends TestCase { @@ -61,4 +64,71 @@ public function testWithoutRequest(): void $readProvider = new ReadProvider($provider, $serializerContextBuilder); $this->assertEquals($readProvider->provide($operation), ['ok']); } + + public function testThrowOnNotFoundExplicitTrueThrowsForPost(): void + { + $operation = new Post(read: true, throwOnNotFound: true); + $decorated = $this->createStub(ProviderInterface::class); + $decorated->method('provide')->willReturn(null); + + $provider = new ReadProvider($decorated); + $this->expectException(NotFoundHttpException::class); + $provider->provide($operation, ['id' => 1], ['request' => new Request()]); + } + + public function testThrowOnNotFoundExplicitFalseSkipsThrowForGet(): void + { + $operation = new Get(read: true, throwOnNotFound: false); + $decorated = $this->createStub(ProviderInterface::class); + $decorated->method('provide')->willReturn(null); + + $provider = new ReadProvider($decorated); + $request = new Request(); + $this->assertNull($provider->provide($operation, ['id' => 1], ['request' => $request])); + $this->assertNull($request->attributes->get('data')); + } + + public function testThrowOnNotFoundDefaultThrowsForGet(): void + { + $operation = new Get(read: true); + $decorated = $this->createStub(ProviderInterface::class); + $decorated->method('provide')->willReturn(null); + + $provider = new ReadProvider($decorated); + $this->expectException(NotFoundHttpException::class); + $provider->provide($operation, ['id' => 1], ['request' => new Request()]); + } + + public function testThrowOnNotFoundDefaultSkipsThrowForPost(): void + { + $operation = new Post(read: true); + $decorated = $this->createStub(ProviderInterface::class); + $decorated->method('provide')->willReturn(null); + + $provider = new ReadProvider($decorated); + $request = new Request(); + $this->assertNull($provider->provide($operation, [], ['request' => $request])); + } + + public function testThrowOnNotFoundDefaultThrowsForPutWithoutAllowCreate(): void + { + $operation = new Put(read: true); + $decorated = $this->createStub(ProviderInterface::class); + $decorated->method('provide')->willReturn(null); + + $provider = new ReadProvider($decorated); + $this->expectException(NotFoundHttpException::class); + $provider->provide($operation, ['id' => 1], ['request' => new Request()]); + } + + public function testThrowOnNotFoundDefaultSkipsThrowForPutWithAllowCreate(): void + { + $operation = new Put(read: true, allowCreate: true); + $decorated = $this->createStub(ProviderInterface::class); + $decorated->method('provide')->willReturn(null); + + $provider = new ReadProvider($decorated); + $request = new Request(); + $this->assertNull($provider->provide($operation, ['id' => 1], ['request' => $request])); + } } diff --git a/src/Symfony/Controller/MainController.php b/src/Symfony/Controller/MainController.php index 817b0f5d0f5..f50c49ec105 100644 --- a/src/Symfony/Controller/MainController.php +++ b/src/Symfony/Controller/MainController.php @@ -17,7 +17,6 @@ use ApiPlatform\Metadata\Exception\InvalidUriVariableException; use ApiPlatform\Metadata\Exception\RuntimeException; use ApiPlatform\Metadata\HttpOperation; -use ApiPlatform\Metadata\Put; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Metadata\UriVariablesConverterInterface; use ApiPlatform\State\ProcessorInterface; @@ -92,15 +91,6 @@ public function __invoke(Request $request): Response $operation = $operation->withDenormalizationContext($denormalizationContext + [SerializerContextBuilderInterface::ASSIGN_OBJECT_TO_POPULATE => $assignObjectToPopulate]); } - if (null === $operation->getThrowOnNotFound()) { - $operation = $operation->withThrowOnNotFound( - 'POST' !== $operation->getMethod() - && ('PUT' !== $operation->getMethod() - || ($operation instanceof Put && !($operation->getAllowCreate() ?? false)) - ) - ); - } - $body = $this->provider->provide($operation, $uriVariables, $context); // The provider can change the Operation, extract it again from the Request attributes From 0bc28ab949fd7bacaee9daf82da3e326d6c5c6db Mon Sep 17 00:00:00 2001 From: soyuka Date: Mon, 1 Jun 2026 10:56:44 +0200 Subject: [PATCH 3/4] test: functional coverage for throwOnNotFound option Adds Feeder fixture with two Post operations (one throwOnNotFound: true, one default) backed by a state provider that returns null. Verifies the explicit option triggers a 404 (issue #6014) while default POST behavior remains unchanged. --- .../ApiResource/ThrowOnNotFound/Feeder.php | 40 +++++++++++++++ tests/Functional/ThrowOnNotFoundTest.php | 50 +++++++++++++++++++ 2 files changed, 90 insertions(+) create mode 100644 tests/Fixtures/TestBundle/ApiResource/ThrowOnNotFound/Feeder.php create mode 100644 tests/Functional/ThrowOnNotFoundTest.php diff --git a/tests/Fixtures/TestBundle/ApiResource/ThrowOnNotFound/Feeder.php b/tests/Fixtures/TestBundle/ApiResource/ThrowOnNotFound/Feeder.php new file mode 100644 index 00000000000..d2b490cbd95 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/ThrowOnNotFound/Feeder.php @@ -0,0 +1,40 @@ + + * + * 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\ThrowOnNotFound; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Post; + +#[ApiResource(operations: [ + new Post( + uriTemplate: '/throw_on_not_found_feeders/{id}/feed', + throwOnNotFound: true, + provider: [Feeder::class, 'provide'], + read: true, + ), + new Post( + uriTemplate: '/throw_on_not_found_feeders/{id}/feed_default', + provider: [Feeder::class, 'provide'], + read: true, + ), +])] +final class Feeder +{ + public ?int $id = null; + + public static function provide(): ?self + { + return null; + } +} diff --git a/tests/Functional/ThrowOnNotFoundTest.php b/tests/Functional/ThrowOnNotFoundTest.php new file mode 100644 index 00000000000..060f1cedf46 --- /dev/null +++ b/tests/Functional/ThrowOnNotFoundTest.php @@ -0,0 +1,50 @@ + + * + * 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; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\ThrowOnNotFound\Feeder; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class ThrowOnNotFoundTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [Feeder::class]; + } + + public function testPostWithThrowOnNotFoundReturns404WhenProviderReturnsNull(): void + { + self::createClient()->request('POST', '/throw_on_not_found_feeders/42/feed', [ + 'headers' => ['Content-Type' => 'application/ld+json', 'Accept' => 'application/ld+json'], + 'body' => '{}', + ]); + + $this->assertResponseStatusCodeSame(404); + } + + public function testPostDefaultDoesNotReturn404WhenProviderReturnsNull(): void + { + self::createClient()->request('POST', '/throw_on_not_found_feeders/42/feed_default', [ + 'headers' => ['Content-Type' => 'application/ld+json', 'Accept' => 'application/ld+json'], + 'body' => '{}', + ]); + + $this->assertNotSame(404, self::getClient()->getResponse()->getStatusCode()); + } +} From adb1e2e4888eb1a3c85c4569eda6ef7ec3937b82 Mon Sep 17 00:00:00 2001 From: soyuka Date: Mon, 1 Jun 2026 11:22:54 +0200 Subject: [PATCH 4/4] fix(tests): narrow Feeder::provide() return type to null PHPStan flagged ?self as never returning self. Provider exists to return null and trigger NotFound assertions, so narrowing to the standalone null type satisfies the analyser without altering test behavior. --- .../Fixtures/TestBundle/ApiResource/ThrowOnNotFound/Feeder.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Fixtures/TestBundle/ApiResource/ThrowOnNotFound/Feeder.php b/tests/Fixtures/TestBundle/ApiResource/ThrowOnNotFound/Feeder.php index d2b490cbd95..2456a1d1482 100644 --- a/tests/Fixtures/TestBundle/ApiResource/ThrowOnNotFound/Feeder.php +++ b/tests/Fixtures/TestBundle/ApiResource/ThrowOnNotFound/Feeder.php @@ -33,7 +33,7 @@ final class Feeder { public ?int $id = null; - public static function provide(): ?self + public static function provide(): null { return null; }