From c7e951e05e8361b54b7afcbf2b5fd1b4a87d86f2 Mon Sep 17 00:00:00 2001 From: soyuka Date: Mon, 19 Oct 2026 11:39:45 +0200 Subject: [PATCH 1/3] fix(jsonapi): reject malformed request documents A request body that is not a JSON:API document, has no data object, or has non-object attributes/relationships was accepted with 201 or crashed in array_merge() with a 500. Return 400 instead. A missing type is still accepted. Refs #8703 --- .../Serializer/ItemNormalizerTrait.php | 21 +++++ .../JsonApi/RequestDocumentValidationTest.php | 91 +++++++++++++++++++ 2 files changed, 112 insertions(+) create mode 100644 tests/Functional/JsonApi/RequestDocumentValidationTest.php diff --git a/src/JsonApi/Serializer/ItemNormalizerTrait.php b/src/JsonApi/Serializer/ItemNormalizerTrait.php index 0dd9338363..52dac773b2 100644 --- a/src/JsonApi/Serializer/ItemNormalizerTrait.php +++ b/src/JsonApi/Serializer/ItemNormalizerTrait.php @@ -14,6 +14,7 @@ namespace ApiPlatform\JsonApi\Serializer; use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\Exception\BadRequestException; use ApiPlatform\Metadata\Exception\ItemNotFoundException; use ApiPlatform\Metadata\HttpOperation; use ApiPlatform\Metadata\UrlGeneratorInterface; @@ -52,6 +53,8 @@ public function denormalize(mixed $data, string $type, ?string $format = null, a return parent::denormalize($data, $type, $format, $context); } + $this->assertValidRequestDocument($data); + $operation = $context['operation'] ?? null; $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 +92,24 @@ public function denormalize(mixed $data, string $type, ?string $format = null, a return parent::denormalize($dataToDenormalize, $type, $format, $context); } + private function assertValidRequestDocument(mixed $data): 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)); + } + } + } + + 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/tests/Functional/JsonApi/RequestDocumentValidationTest.php b/tests/Functional/JsonApi/RequestDocumentValidationTest.php new file mode 100644 index 0000000000..4094c4d9f2 --- /dev/null +++ b/tests/Functional/JsonApi/RequestDocumentValidationTest.php @@ -0,0 +1,91 @@ + + * + * 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\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]; + } + + /** + * @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']); + } +} From c66b63b37811af5a1a73d7270880970beb6e7ff3 Mon Sep 17 00:00:00 2001 From: soyuka Date: Mon, 19 Oct 2026 11:54:35 +0200 Subject: [PATCH 2/3] feat(jsonapi): opt-in strict resource type check on write Add `jsonapi.strict_resource_type` (default false). When enabled, a request resource object without a string type is a 400, and on POST a type that is neither the operation shortName nor the type the serializer emits for the class is a 409 Conflict. Refs #8703 --- src/JsonApi/Serializer/ItemNormalizer.php | 18 ++--- .../Serializer/ItemNormalizerTrait.php | 35 ++++++++- src/Laravel/ApiPlatformProvider.php | 2 + .../Tests/JsonApiStrictResourceTypeTest.php | 78 +++++++++++++++++++ src/Laravel/config/api-platform.php | 5 ++ src/Metadata/Exception/ConflictException.php | 33 ++++++++ .../ApiPlatformExtension.php | 10 ++- .../DependencyInjection/Configuration.php | 4 + .../JsonApi/StrictTypeResource.php | 65 ++++++++++++++++ .../JsonApi/RequestDocumentValidationTest.php | 36 ++++++++- .../DependencyInjection/ConfigurationTest.php | 1 + 11 files changed, 270 insertions(+), 17 deletions(-) create mode 100644 src/Laravel/Tests/JsonApiStrictResourceTypeTest.php create mode 100644 src/Metadata/Exception/ConflictException.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonApi/StrictTypeResource.php 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 52dac773b2..f9140d65e2 100644 --- a/src/JsonApi/Serializer/ItemNormalizerTrait.php +++ b/src/JsonApi/Serializer/ItemNormalizerTrait.php @@ -15,6 +15,7 @@ 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,9 +54,9 @@ public function denormalize(mixed $data, string $type, ?string $format = null, a return parent::denormalize($data, $type, $format, $context); } - $this->assertValidRequestDocument($data); - $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); @@ -92,7 +93,7 @@ public function denormalize(mixed $data, string $type, ?string $format = null, a return parent::denormalize($dataToDenormalize, $type, $format, $context); } - private function assertValidRequestDocument(mixed $data): void + 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.'); @@ -103,6 +104,34 @@ private function assertValidRequestDocument(mixed $data): void 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 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..524de5e291 --- /dev/null +++ b/src/Laravel/Tests/JsonApiStrictResourceTypeTest.php @@ -0,0 +1,78 @@ + + * + * 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 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 + */ + 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 index 4094c4d9f2..15c1a45cdf 100644 --- a/tests/Functional/JsonApi/RequestDocumentValidationTest.php +++ b/tests/Functional/JsonApi/RequestDocumentValidationTest.php @@ -15,6 +15,7 @@ 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; @@ -29,7 +30,7 @@ final class RequestDocumentValidationTest extends ApiTestCase */ public static function getResources(): array { - return [ClientGeneratedId::class]; + return [ClientGeneratedId::class, StrictTypeResource::class]; } /** @@ -88,4 +89,37 @@ public function testValidDocumentIsAccepted(): void $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, From ad354acc7b3b384e9cf7b67eeca423f7cc2f9dbf Mon Sep 17 00:00:00 2001 From: soyuka Date: Mon, 19 Oct 2026 14:10:38 +0200 Subject: [PATCH 3/3] test(laravel): type strict resource type test response --- src/Laravel/Tests/JsonApiStrictResourceTypeTest.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/Laravel/Tests/JsonApiStrictResourceTypeTest.php b/src/Laravel/Tests/JsonApiStrictResourceTypeTest.php index 524de5e291..db9d14e364 100644 --- a/src/Laravel/Tests/JsonApiStrictResourceTypeTest.php +++ b/src/Laravel/Tests/JsonApiStrictResourceTypeTest.php @@ -20,6 +20,7 @@ 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; @@ -67,6 +68,8 @@ public function testCorrectTypeIsAccepted(): void /** * @param array $resource + * + * @return TestResponse */ private function postBook(array $resource): TestResponse {