diff --git a/src/JsonApi/Serializer/ItemNormalizer.php b/src/JsonApi/Serializer/ItemNormalizer.php index fbe92ffd24..c4a88060a9 100644 --- a/src/JsonApi/Serializer/ItemNormalizer.php +++ b/src/JsonApi/Serializer/ItemNormalizer.php @@ -62,6 +62,13 @@ final class ItemNormalizer extends AbstractItemNormalizer */ public const ALLOW_CLIENT_GENERATED_ID = 'allow_client_generated_id'; + /** + * Denormalization context flag making the resource object "type" mandatory (400) and, on POST, + * checked against the endpoint resource type (409). Set in the context or via the bundle + * configuration ("api_platform.jsonapi.strict_resource_type"). + */ + public const STRICT_RESOURCE_TYPE = 'strict_resource_type'; + private array $componentsCache = []; private bool $useIriAsId; private readonly ResourceLinkageResolver $resourceLinkageResolver; @@ -439,15 +446,4 @@ private function getIdStringFromIdentifiers(array $identifiers): string return CompositeIdentifierParser::stringify($identifiers); } - - private function getResourceShortName(string $resourceClass): string - { - if ($this->resourceClassResolver->isResourceClass($resourceClass)) { - $resourceMetadata = $this->resourceMetadataCollectionFactory->create($resourceClass); - - return $resourceMetadata->getOperation()->getShortName(); - } - - return (new \ReflectionClass($resourceClass))->getShortName(); - } } diff --git a/src/JsonApi/Serializer/ItemNormalizerTrait.php b/src/JsonApi/Serializer/ItemNormalizerTrait.php index 0dd9338363..f9140d65e2 100644 --- a/src/JsonApi/Serializer/ItemNormalizerTrait.php +++ b/src/JsonApi/Serializer/ItemNormalizerTrait.php @@ -14,6 +14,8 @@ namespace ApiPlatform\JsonApi\Serializer; use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\Exception\BadRequestException; +use ApiPlatform\Metadata\Exception\ConflictException; use ApiPlatform\Metadata\Exception\ItemNotFoundException; use ApiPlatform\Metadata\HttpOperation; use ApiPlatform\Metadata\UrlGeneratorInterface; @@ -53,6 +55,8 @@ public function denormalize(mixed $data, string $type, ?string $format = null, a } $operation = $context['operation'] ?? null; + $this->assertValidRequestDocument($data, $operation, $type, $context); + $isPostOperation = $operation instanceof HttpOperation && 'POST' === $operation->getMethod(); $allowClientGeneratedId = true === ($context[ItemNormalizer::ALLOW_CLIENT_GENERATED_ID] ?? $this->defaultContext[ItemNormalizer::ALLOW_CLIENT_GENERATED_ID] ?? false); @@ -89,6 +93,52 @@ public function denormalize(mixed $data, string $type, ?string $format = null, a return parent::denormalize($dataToDenormalize, $type, $format, $context); } + private function assertValidRequestDocument(mixed $data, mixed $operation, string $resourceClass, array $context): void + { + if (!\is_array($data) || !isset($data['data']) || !$this->isJsonObject($data['data'])) { + throw new BadRequestException('The request document must be an object with a "data" member holding a resource object.'); + } + + foreach (['attributes', 'relationships'] as $member) { + if (\array_key_exists($member, $data['data']) && !$this->isJsonObject($data['data'][$member])) { + throw new BadRequestException(\sprintf('The "%s" member of the resource object must be an object.', $member)); + } + } + + if (true !== ($context[ItemNormalizer::STRICT_RESOURCE_TYPE] ?? $this->defaultContext[ItemNormalizer::STRICT_RESOURCE_TYPE] ?? false)) { + return; + } + + if (!isset($data['data']['type']) || !\is_string($data['data']['type'])) { + throw new BadRequestException('The resource object must have a "type" member.'); + } + + if (!$operation instanceof HttpOperation || 'POST' !== $operation->getMethod()) { + return; + } + + $acceptedTypes = array_filter([$operation->getShortName(), $this->getResourceShortName($resourceClass)]); + if (!\in_array($data['data']['type'], $acceptedTypes, true)) { + throw new ConflictException(\sprintf('The resource object type "%s" does not match the type of this endpoint, expected one of "%s".', $data['data']['type'], implode('", "', array_unique($acceptedTypes)))); + } + } + + private function getResourceShortName(string $resourceClass): string + { + if ($this->resourceClassResolver->isResourceClass($resourceClass)) { + $resourceMetadata = $this->resourceMetadataCollectionFactory->create($resourceClass); + + return $resourceMetadata->getOperation()->getShortName(); + } + + return (new \ReflectionClass($resourceClass))->getShortName(); + } + + private function isJsonObject(mixed $value): bool + { + return \is_array($value) && ([] === $value || !array_is_list($value)); + } + protected function isAllowedAttribute(object|string $classOrObject, string $attribute, ?string $format = null, array $context = []): bool { return preg_match('/^\\w[-\\w_]*$/', $attribute) && parent::isAllowedAttribute($classOrObject, $attribute, $format, $context); diff --git a/src/Laravel/ApiPlatformProvider.php b/src/Laravel/ApiPlatformProvider.php index 152b7e9949..470080b2b4 100644 --- a/src/Laravel/ApiPlatformProvider.php +++ b/src/Laravel/ApiPlatformProvider.php @@ -1086,6 +1086,7 @@ public function register(): void $config = $app['config']; $defaultContext = $config->get('api-platform.serializer', []); $defaultContext[JsonApiItemNormalizer::ALLOW_CLIENT_GENERATED_ID] = (bool) $config->get('api-platform.jsonapi.allow_client_generated_id', false); + $defaultContext[JsonApiItemNormalizer::STRICT_RESOURCE_TYPE] = (bool) $config->get('api-platform.jsonapi.strict_resource_type', false); $useIriAsId = (bool) $config->get('api-platform.jsonapi.use_iri_as_id', false); return new JsonApiItemNormalizer( @@ -1109,6 +1110,7 @@ public function register(): void $this->app->singleton(JsonApiItemDenormalizer::class, static function (Application $app) { $config = $app['config']; $defaultContext = $config->get('api-platform.serializer', []); + $defaultContext[JsonApiItemNormalizer::STRICT_RESOURCE_TYPE] = (bool) $config->get('api-platform.jsonapi.strict_resource_type', false); return new JsonApiItemDenormalizer( $app->make(PropertyNameCollectionFactoryInterface::class), diff --git a/src/Laravel/Tests/JsonApiStrictResourceTypeTest.php b/src/Laravel/Tests/JsonApiStrictResourceTypeTest.php new file mode 100644 index 0000000000..db9d14e364 --- /dev/null +++ b/src/Laravel/Tests/JsonApiStrictResourceTypeTest.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\Laravel\Tests; + +use ApiPlatform\Laravel\Test\ApiTestAssertionsTrait; +use Illuminate\Contracts\Config\Repository; +use Illuminate\Foundation\Application; +use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Testing\TestResponse; +use Orchestra\Testbench\Concerns\WithWorkbench; +use Orchestra\Testbench\TestCase; +use Symfony\Component\HttpFoundation\Response; +use Workbench\App\Models\Author; +use Workbench\Database\Factories\AuthorFactory; + +class JsonApiStrictResourceTypeTest extends TestCase +{ + use ApiTestAssertionsTrait; + use RefreshDatabase; + use WithWorkbench; + + /** + * @param Application $app + */ + protected function defineEnvironment($app): void + { + tap($app['config'], static function (Repository $config): void { + $config->set('api-platform.formats', ['jsonapi' => ['application/vnd.api+json']]); + $config->set('api-platform.docs_formats', ['jsonapi' => ['application/vnd.api+json']]); + $config->set('api-platform.resources', [app_path('Models'), app_path('ApiResource')]); + $config->set('api-platform.jsonapi', ['use_iri_as_id' => false, 'strict_resource_type' => true]); + $config->set('api-platform.defaults', ['route_prefix' => '/api']); + $config->set('app.debug', true); + }); + } + + public function testMissingTypeIsABadRequest(): void + { + $this->postBook(['attributes' => ['name' => 'Don Quichotte', 'isbn' => '9781234567897']])->assertStatus(400); + } + + public function testWrongTypeIsAConflict(): void + { + $this->postBook(['type' => 'Author', 'attributes' => ['name' => 'Don Quichotte', 'isbn' => '9781234567897']])->assertStatus(409); + } + + public function testCorrectTypeIsAccepted(): void + { + AuthorFactory::new()->create(); + + $this->postBook([ + 'type' => 'Book', + 'attributes' => ['name' => 'Don Quichotte', 'isbn' => '9781234567897'], + 'relationships' => ['author' => ['data' => ['type' => 'Author', 'id' => $this->getIriFromResource(Author::find(1))]]], + ])->assertStatus(201); + } + + /** + * @param array $resource + * + * @return TestResponse + */ + private function postBook(array $resource): TestResponse + { + return $this->postJson('/api/books', ['data' => $resource], [ + 'accept' => 'application/vnd.api+json', + 'content_type' => 'application/vnd.api+json', + ]); + } +} diff --git a/src/Laravel/config/api-platform.php b/src/Laravel/config/api-platform.php index 52ae847eb3..e25f89d970 100644 --- a/src/Laravel/config/api-platform.php +++ b/src/Laravel/config/api-platform.php @@ -93,6 +93,11 @@ // https://jsonapi.org/format/#crud-creating-client-ids. Off by default // to avoid id spoofing on public endpoints. 'allow_client_generated_id' => false, + + // Require the "type" member of JSON:API request resource objects (400 + // when missing) and reject a "type" that does not match the endpoint + // resource on POST (409 Conflict). Off by default. + 'strict_resource_type' => false, ], 'graphql' => [ diff --git a/src/Metadata/Exception/ConflictException.php b/src/Metadata/Exception/ConflictException.php new file mode 100644 index 0000000000..39b4d7b794 --- /dev/null +++ b/src/Metadata/Exception/ConflictException.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\Metadata\Exception; + +/** + * Framework-agnostic 409 Conflict, mapped by both the Symfony and Laravel error handlers. + */ +class ConflictException extends \RuntimeException implements ExceptionInterface, HttpExceptionInterface +{ + public function getStatusCode(): int + { + return 409; + } + + /** + * @return array + */ + public function getHeaders(): array + { + return []; + } +} diff --git a/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php b/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php index 243eb2d26d..8d5e940783 100644 --- a/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php +++ b/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php @@ -721,12 +721,18 @@ private function registerJsonApiConfiguration(ContainerBuilder $container, array $useIriAsId = $config['jsonapi']['use_iri_as_id']; $itemNormalizer = $container->getDefinition('api_platform.jsonapi.normalizer.item'); - $itemNormalizer->replaceArgument(7, [JsonApiItemNormalizer::ALLOW_CLIENT_GENERATED_ID => $config['jsonapi']['allow_client_generated_id'] ?? false]); + $itemNormalizer->replaceArgument(7, [ + JsonApiItemNormalizer::ALLOW_CLIENT_GENERATED_ID => $config['jsonapi']['allow_client_generated_id'] ?? false, + JsonApiItemNormalizer::STRICT_RESOURCE_TYPE => $config['jsonapi']['strict_resource_type'] ?? false, + ]); $itemNormalizer->addArgument($useIriAsId); $itemNormalizer->addArgument(new Reference('api_platform.jsonapi.resource_linkage_resolver')); $itemDenormalizer = $container->getDefinition('api_platform.jsonapi.denormalizer.item'); - $itemDenormalizer->replaceArgument(7, [JsonApiItemNormalizer::ALLOW_CLIENT_GENERATED_ID => $config['jsonapi']['allow_client_generated_id'] ?? false]); + $itemDenormalizer->replaceArgument(7, [ + JsonApiItemNormalizer::ALLOW_CLIENT_GENERATED_ID => $config['jsonapi']['allow_client_generated_id'] ?? false, + JsonApiItemNormalizer::STRICT_RESOURCE_TYPE => $config['jsonapi']['strict_resource_type'] ?? false, + ]); $itemDenormalizer->addArgument($useIriAsId); } diff --git a/src/Symfony/Bundle/DependencyInjection/Configuration.php b/src/Symfony/Bundle/DependencyInjection/Configuration.php index 10db1dde0d..015952d8c6 100644 --- a/src/Symfony/Bundle/DependencyInjection/Configuration.php +++ b/src/Symfony/Bundle/DependencyInjection/Configuration.php @@ -109,6 +109,10 @@ public function getConfigTreeBuilder(): TreeBuilder ->defaultFalse() ->info('Allow client-generated IDs on JSON:API POST per https://jsonapi.org/format/#crud-creating-client-ids. Off by default to prevent id spoofing on public endpoints.') ->end() + ->booleanNode('strict_resource_type') + ->defaultFalse() + ->info('Require the "type" member of JSON:API request resource objects (400 when missing) and reject a "type" that does not match the endpoint resource on POST (409 Conflict), per https://jsonapi.org/format/#crud-creating-responses-409.') + ->end() ->end() ->end() ->arrayNode('eager_loading') diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonApi/StrictTypeResource.php b/tests/Fixtures/TestBundle/ApiResource/JsonApi/StrictTypeResource.php new file mode 100644 index 0000000000..596ada7b0d --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonApi/StrictTypeResource.php @@ -0,0 +1,65 @@ + + * + * 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\JsonApi; + +use ApiPlatform\JsonApi\Serializer\ItemNormalizer; +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Post; + +#[ApiResource( + shortName: 'JsonApiStrictType', + formats: ['jsonapi' => ['application/vnd.api+json']], + operations: [ + new Get(uriTemplate: '/jsonapi_strict_types/{id}', provider: [self::class, 'provide']), + new Post( + uriTemplate: '/jsonapi_strict_types', + denormalizationContext: [ItemNormalizer::STRICT_RESOURCE_TYPE => true], + processor: [self::class, 'process'], + ), + new Post( + uriTemplate: '/jsonapi_lenient_types', + processor: [self::class, 'process'], + ), + ], +)] +#[ApiResource( + shortName: 'JsonApiStrictTypeAlias', + formats: ['jsonapi' => ['application/vnd.api+json']], + operations: [ + new Post( + uriTemplate: '/jsonapi_strict_type_aliases', + denormalizationContext: [ItemNormalizer::STRICT_RESOURCE_TYPE => true], + processor: [self::class, 'process'], + ), + ], +)] +class StrictTypeResource +{ + #[ApiProperty(identifier: true)] + public int $id = 1; + + public ?string $name = null; + + public static function provide(): self + { + return new self(); + } + + public static function process(self $data): self + { + return $data; + } +} diff --git a/tests/Functional/JsonApi/RequestDocumentValidationTest.php b/tests/Functional/JsonApi/RequestDocumentValidationTest.php new file mode 100644 index 0000000000..15c1a45cdf --- /dev/null +++ b/tests/Functional/JsonApi/RequestDocumentValidationTest.php @@ -0,0 +1,125 @@ + + * + * 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\JsonApi; + +use ApiPlatform\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonApi\ClientGeneratedId; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonApi\StrictTypeResource; +use ApiPlatform\Tests\SetupClassResourcesTrait; +use PHPUnit\Framework\Attributes\DataProvider; + +final class RequestDocumentValidationTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [ClientGeneratedId::class, StrictTypeResource::class]; + } + + /** + * @return iterable + */ + public static function malformedDocuments(): iterable + { + yield 'body is a string' => ['Y']; + yield 'body is a number' => [42]; + yield 'no data member' => [['name' => 'A']]; + yield 'data is not an object' => [['data' => 'x']]; + yield 'attributes is a list' => [['data' => ['type' => 'JsonApiClientGeneratedId', 'attributes' => ['x']]]]; + yield 'attributes is null' => [['data' => ['type' => 'JsonApiClientGeneratedId', 'attributes' => null]]]; + yield 'relationships is null' => [['data' => ['type' => 'JsonApiClientGeneratedId', 'relationships' => null]]]; + yield 'relationships is a string' => [['data' => ['type' => 'JsonApiClientGeneratedId', 'relationships' => 'x']]]; + } + + #[DataProvider('malformedDocuments')] + public function testMalformedDocumentIsRejected(mixed $document): void + { + self::createClient()->request('POST', '/jsonapi_client_generated_ids', [ + 'headers' => [ + 'Accept' => 'application/vnd.api+json', + 'Content-Type' => 'application/vnd.api+json', + ], + 'json' => $document, + ]); + + $this->assertResponseStatusCodeSame(400); + } + + public function testDocumentWithoutTypeIsStillAccepted(): void + { + $response = self::createClient()->request('POST', '/jsonapi_client_generated_ids', [ + 'headers' => [ + 'Accept' => 'application/vnd.api+json', + 'Content-Type' => 'application/vnd.api+json', + ], + 'json' => ['data' => ['attributes' => ['name' => 'A']]], + ]); + + $this->assertResponseStatusCodeSame(201); + $this->assertSame('A', $response->toArray()['data']['attributes']['name']); + } + + public function testValidDocumentIsAccepted(): void + { + $response = self::createClient()->request('POST', '/jsonapi_client_generated_ids', [ + 'headers' => [ + 'Accept' => 'application/vnd.api+json', + 'Content-Type' => 'application/vnd.api+json', + ], + 'json' => ['data' => ['type' => 'JsonApiClientGeneratedId', 'attributes' => ['name' => 'A']]], + ]); + + $this->assertResponseStatusCodeSame(201); + $this->assertSame('A', $response->toArray()['data']['attributes']['name']); + } + + /** + * @param array $document + */ + #[DataProvider('strictTypeDocuments')] + public function testStrictResourceType(string $uri, array $document, int $expectedStatus): void + { + self::createClient()->request('POST', $uri, [ + 'headers' => [ + 'Accept' => 'application/vnd.api+json', + 'Content-Type' => 'application/vnd.api+json', + ], + 'json' => $document, + ]); + + $this->assertResponseStatusCodeSame($expectedStatus); + } + + /** + * @return iterable, int}> + */ + public static function strictTypeDocuments(): iterable + { + yield 'strict, missing type' => ['/jsonapi_strict_types', ['data' => ['attributes' => ['name' => 'A']]], 400]; + yield 'strict, non-string type' => ['/jsonapi_strict_types', ['data' => ['type' => 1]], 400]; + yield 'strict, wrong type' => ['/jsonapi_strict_types', ['data' => ['type' => 'Author']], 409]; + yield 'strict, correct type' => ['/jsonapi_strict_types', ['data' => ['type' => 'JsonApiStrictType']], 201]; + yield 'strict, operation shortName' => ['/jsonapi_strict_type_aliases', ['data' => ['type' => 'JsonApiStrictTypeAlias']], 201]; + yield 'strict, emitted type' => ['/jsonapi_strict_type_aliases', ['data' => ['type' => 'JsonApiStrictType']], 201]; + yield 'strict, neither' => ['/jsonapi_strict_type_aliases', ['data' => ['type' => 'Author']], 409]; + yield 'lenient, missing type' => ['/jsonapi_lenient_types', ['data' => ['attributes' => ['name' => 'A']]], 201]; + yield 'lenient, wrong type' => ['/jsonapi_lenient_types', ['data' => ['type' => 'Author']], 201]; + } +} diff --git a/tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php b/tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php index 0d47ca31b7..418cca96d4 100644 --- a/tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php +++ b/tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php @@ -248,6 +248,7 @@ private function runDefaultConfigTests(array $doctrineIntegrationsToLoad = ['orm 'jsonapi' => [ 'use_iri_as_id' => false, 'allow_client_generated_id' => false, + 'strict_resource_type' => false, ], 'enable_scalar' => true, 'enable_head_request_optimization' => true,