diff --git a/src/Metadata/ApiResource.php b/src/Metadata/ApiResource.php index 32922afb1d..5d136533d8 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 3674a5d6fe..61744470fd 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/Extractor/XmlResourceExtractor.php b/src/Metadata/Extractor/XmlResourceExtractor.php index 4d3c3206b5..d439c1e981 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 67848c5694..38ac28e057 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 8a1644c479..6019722d6b 100644 --- a/src/Metadata/Extractor/schema/resources.xsd +++ b/src/Metadata/Extractor/schema/resources.xsd @@ -526,6 +526,7 @@ + diff --git a/src/Metadata/Get.php b/src/Metadata/Get.php index 4c59d7ab95..82a01f83dc 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 6256366bd2..a94ae24099 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 32dfa15bb7..a8f28f22d8 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 009612c990..35e039c85a 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 cbd53751e5..343915a673 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 e6147a18da..100ac370e7 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 e68e4b0ec6..61a4a059c7 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 73632c786b..87529e9587 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/Metadata/Tests/Extractor/Adapter/XmlResourceAdapter.php b/src/Metadata/Tests/Extractor/Adapter/XmlResourceAdapter.php index 6e3a1296f1..4bebfa435e 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 06c90ebfd2..1588395319 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 6dc74676c4..fe1595bf15 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 fb4f177d81..0accd28d7c 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 b9d4dc2359..3447e2417e 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 7d58abe6ba..de25bcf7a9 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 c6c65a3888..734229d4ae 100644 --- a/src/State/Provider/ReadProvider.php +++ b/src/State/Provider/ReadProvider.php @@ -88,14 +88,18 @@ 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)) - ) - ) { - 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 92cfce527d..3b5f6ee209 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/tests/Fixtures/TestBundle/ApiResource/ThrowOnNotFound/Feeder.php b/tests/Fixtures/TestBundle/ApiResource/ThrowOnNotFound/Feeder.php new file mode 100644 index 0000000000..2456a1d148 --- /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(): null + { + return null; + } +} diff --git a/tests/Functional/ThrowOnNotFoundTest.php b/tests/Functional/ThrowOnNotFoundTest.php new file mode 100644 index 0000000000..060f1cedf4 --- /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()); + } +}