Skip to content

Commit 9090a0d

Browse files
authored
feat(jsonapi): spec-compliant PATCH conflicts and member names (#8752)
* feat(jsonapi): 409 on PATCH id/type mismatch Fixes #8750 * feat(jsonapi): opt-in valid reserved member names Fixes #8749
1 parent eb915e2 commit 9090a0d

13 files changed

Lines changed: 319 additions & 14 deletions

File tree

‎src/JsonApi/JsonSchema/SchemaFactory.php‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ final class SchemaFactory implements SchemaFactoryInterface, SchemaFactoryAwareI
8282

8383
private readonly ResourceLinkageResolver $resourceLinkageResolver;
8484

85-
public function __construct(private readonly SchemaFactoryInterface $schemaFactory, private readonly PropertyMetadataFactoryInterface $propertyMetadataFactory, ResourceClassResolverInterface $resourceClassResolver, ?ResourceMetadataCollectionFactoryInterface $resourceMetadataFactory = null, private ?DefinitionNameFactoryInterface $definitionNameFactory = null, ?ResourceLinkageResolver $resourceLinkageResolver = null)
85+
public function __construct(private readonly SchemaFactoryInterface $schemaFactory, private readonly PropertyMetadataFactoryInterface $propertyMetadataFactory, ResourceClassResolverInterface $resourceClassResolver, ?ResourceMetadataCollectionFactoryInterface $resourceMetadataFactory = null, private ?DefinitionNameFactoryInterface $definitionNameFactory = null, ?ResourceLinkageResolver $resourceLinkageResolver = null, private readonly bool $strictMemberNames = false)
8686
{
8787
if (!$definitionNameFactory) {
8888
$this->definitionNameFactory = new DefinitionNameFactory();
@@ -327,7 +327,7 @@ private function buildDefinitionPropertiesSchema(string $key, string $className,
327327
$relatedDefinitions[$ref] = ['$ref' => $ref];
328328
}
329329
// A relationship literally named "relationships"/"included" is prefixed at runtime too.
330-
$relationshipName = ReservedAttributeNameConverter::JSON_API_RESERVED_ATTRIBUTES[$propertyName] ?? $propertyName;
330+
$relationshipName = ReservedAttributeNameConverter::getReservedAttributes($this->strictMemberNames)[$propertyName] ?? $propertyName;
331331
if ($isOne) {
332332
$relationships[$relationshipName]['properties']['data'] = [
333333
'oneOf' => [
@@ -346,7 +346,7 @@ private function buildDefinitionPropertiesSchema(string $key, string $className,
346346

347347
// Reserved names (id, type, links, relationships, included) are prefixed by the
348348
// ReservedAttributeNameConverter at runtime; mirror that single source of truth here.
349-
$attributes[ReservedAttributeNameConverter::JSON_API_RESERVED_ATTRIBUTES[$propertyName] ?? $propertyName] = $property;
349+
$attributes[ReservedAttributeNameConverter::getReservedAttributes($this->strictMemberNames)[$propertyName] ?? $propertyName] = $property;
350350
}
351351

352352
$replacement = self::PROPERTY_PROPS;

‎src/JsonApi/Serializer/ItemNormalizerTrait.php‎

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,11 @@ public function denormalize(mixed $data, string $type, ?string $format = null, a
5858
$this->assertValidRequestDocument($data, $operation, $type, $context);
5959

6060
$isPostOperation = $operation instanceof HttpOperation && 'POST' === $operation->getMethod();
61+
if (!$isPostOperation && $operation instanceof HttpOperation && \is_object($context[AbstractItemNormalizer::OBJECT_TO_POPULATE] ?? null)) {
62+
if (isset($data['data']['id'])) {
63+
$this->assertIdMatchesEndpoint((string) $data['data']['id'], $context[AbstractItemNormalizer::OBJECT_TO_POPULATE], $operation, $type);
64+
}
65+
}
6166
$allowClientGeneratedId = true === ($context[ItemNormalizer::ALLOW_CLIENT_GENERATED_ID] ?? $this->defaultContext[ItemNormalizer::ALLOW_CLIENT_GENERATED_ID] ?? false);
6267

6368
// Avoid issues with proxies if we populated the object
@@ -87,7 +92,7 @@ public function denormalize(mixed $data, string $type, ?string $format = null, a
8792

8893
// Surface the client-generated id so the entity setter receives it.
8994
if ($isPostOperation && $allowClientGeneratedId && isset($data['data']['id'])) {
90-
$dataToDenormalize[ReservedAttributeNameConverter::JSON_API_RESERVED_ATTRIBUTES['id']] = $data['data']['id'];
95+
$dataToDenormalize[$this->nameConverter?->normalize('id', $type, self::FORMAT) ?? 'id'] = $data['data']['id'];
9196
}
9297

9398
return parent::denormalize($dataToDenormalize, $type, $format, $context);
@@ -113,7 +118,7 @@ private function assertValidRequestDocument(mixed $data, mixed $operation, strin
113118
throw new BadRequestException('The resource object must have a "type" member.');
114119
}
115120

116-
if (!$operation instanceof HttpOperation || 'POST' !== $operation->getMethod()) {
121+
if (!$operation instanceof HttpOperation) {
117122
return;
118123
}
119124

@@ -123,6 +128,23 @@ private function assertValidRequestDocument(mixed $data, mixed $operation, strin
123128
}
124129
}
125130

131+
private function assertIdMatchesEndpoint(string $id, object $object, HttpOperation $operation, string $resourceClass): void
132+
{
133+
if ($this->useIriAsId) {
134+
$requested = $id;
135+
$actual = $this->iriConverter->getIriFromResource($object);
136+
} elseif (\count($operation->getUriVariables() ?? []) > 1) {
137+
return;
138+
} else {
139+
$requested = $this->reconstructIri($resourceClass, $id, $operation);
140+
$actual = $this->iriConverter->getIriFromResource($object, UrlGeneratorInterface::ABS_PATH, $operation);
141+
}
142+
143+
if (parse_url($requested, \PHP_URL_PATH) !== parse_url($actual, \PHP_URL_PATH)) {
144+
throw new ConflictException(\sprintf('The resource object id "%s" does not match the id of this endpoint.', $id));
145+
}
146+
}
147+
126148
private function getResourceShortName(string $resourceClass): string
127149
{
128150
if ($this->resourceClassResolver->isResourceClass($resourceClass)) {

‎src/JsonApi/Serializer/ReservedAttributeNameConverter.php‎

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,21 @@ final class ReservedAttributeNameConverter implements NameConverterInterface
3131
'included' => '_included',
3232
];
3333

34-
public function __construct(private readonly ?NameConverterInterface $nameConverter = null)
34+
public const STRICT_JSON_API_RESERVED_ATTRIBUTES = [
35+
'id' => 'resourceId',
36+
'type' => 'resourceType',
37+
];
38+
39+
public function __construct(private readonly ?NameConverterInterface $nameConverter = null, private readonly bool $strictMemberNames = false)
40+
{
41+
}
42+
43+
/**
44+
* @return array<string, string>
45+
*/
46+
public static function getReservedAttributes(bool $strictMemberNames = false): array
3547
{
48+
return $strictMemberNames ? self::STRICT_JSON_API_RESERVED_ATTRIBUTES : self::JSON_API_RESERVED_ATTRIBUTES;
3649
}
3750

3851
/**
@@ -48,8 +61,9 @@ public function normalize(string $propertyName, ?string $class = null, ?string $
4861
return $propertyName;
4962
}
5063

51-
if (isset(self::JSON_API_RESERVED_ATTRIBUTES[$propertyName])) {
52-
$propertyName = self::JSON_API_RESERVED_ATTRIBUTES[$propertyName];
64+
$reserved = self::getReservedAttributes($this->strictMemberNames);
65+
if (isset($reserved[$propertyName])) {
66+
$propertyName = $reserved[$propertyName];
5367
}
5468

5569
return $propertyName;
@@ -60,8 +74,9 @@ public function normalize(string $propertyName, ?string $class = null, ?string $
6074
*/
6175
public function denormalize(string $propertyName, ?string $class = null, ?string $format = null, array $context = []): string
6276
{
63-
if (\in_array($propertyName, self::JSON_API_RESERVED_ATTRIBUTES, true)) {
64-
$propertyName = substr($propertyName, 1);
77+
$original = array_search($propertyName, self::getReservedAttributes($this->strictMemberNames), true);
78+
if (false !== $original) {
79+
$propertyName = $original;
6580
}
6681

6782
if (null !== $this->nameConverter) {

‎src/JsonApi/Tests/Serializer/ReservedAttributeNameConverterTest.php‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,4 +58,29 @@ public function testDenormalize(string $expectedPropertyName, string $propertyNa
5858
{
5959
$this->assertSame($expectedPropertyName, $this->reservedAttributeNameConverter->denormalize($propertyName));
6060
}
61+
62+
public static function strictPropertiesProvider(): array
63+
{
64+
return [
65+
['id', 'resourceId'],
66+
['type', 'resourceType'],
67+
['links', 'links'],
68+
['relationships', 'relationships'],
69+
['included', 'included'],
70+
['foo', 'foo'],
71+
['nameConverted', 'name_converted'],
72+
];
73+
}
74+
75+
#[DataProvider('strictPropertiesProvider')]
76+
public function testStrictMemberNamesNormalize(string $propertyName, string $expectedPropertyName): void
77+
{
78+
$this->assertSame($expectedPropertyName, (new ReservedAttributeNameConverter(new CustomConverter(), true))->normalize($propertyName));
79+
}
80+
81+
#[DataProvider('strictPropertiesProvider')]
82+
public function testStrictMemberNamesDenormalize(string $expectedPropertyName, string $propertyName): void
83+
{
84+
$this->assertSame($expectedPropertyName, (new ReservedAttributeNameConverter(new CustomConverter(), true))->denormalize($propertyName));
85+
}
6186
}

‎src/Laravel/ApiPlatformProvider.php‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -976,6 +976,7 @@ public function register(): void
976976
$app->make(ResourceClassResolverInterface::class),
977977
$app->make(ResourceMetadataCollectionFactoryInterface::class),
978978
$app->make(DefinitionNameFactoryInterface::class),
979+
strictMemberNames: (bool) $app['config']->get('api-platform.jsonapi.strict_member_names', false),
979980
);
980981
});
981982
$this->app->singleton(HydraSchemaFactory::class, static function (Application $app) {
@@ -1052,7 +1053,7 @@ public function register(): void
10521053
});
10531054

10541055
$this->app->singleton(ReservedAttributeNameConverter::class, static function (Application $app) {
1055-
return new ReservedAttributeNameConverter($app->make(NameConverterInterface::class));
1056+
return new ReservedAttributeNameConverter($app->make(NameConverterInterface::class), (bool) $app['config']->get('api-platform.jsonapi.strict_member_names', false));
10561057
});
10571058

10581059
if (interface_exists(FieldsBuilderEnumInterface::class)) {

‎src/Laravel/config/api-platform.php‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,8 +96,13 @@
9696

9797
// Require the "type" member of JSON:API request resource objects (400
9898
// when missing) and reject a "type" that does not match the endpoint
99-
// resource on POST (409 Conflict). Off by default.
99+
// resource on POST, PUT and PATCH (409 Conflict). Off by default.
100100
'strict_resource_type' => false,
101+
102+
// Expose the "id" and "type" properties as "resourceId" and "resourceType"
103+
// attributes instead of "_id" and "_type", which are not valid JSON:API
104+
// member names. Off by default.
105+
'strict_member_names' => false,
101106
],
102107

103108
'graphql' => [

‎src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -719,6 +719,10 @@ private function registerJsonApiConfiguration(ContainerBuilder $container, array
719719
$loader->load('state/jsonapi.php');
720720

721721
$useIriAsId = $config['jsonapi']['use_iri_as_id'];
722+
$strictMemberNames = $config['jsonapi']['strict_member_names'] ?? false;
723+
724+
$container->getDefinition('api_platform.jsonapi.name_converter.reserved_attribute_name')->replaceArgument(1, $strictMemberNames);
725+
$container->getDefinition('api_platform.jsonapi.json_schema.schema_factory')->replaceArgument(6, $strictMemberNames);
722726

723727
$itemNormalizer = $container->getDefinition('api_platform.jsonapi.normalizer.item');
724728
$itemNormalizer->replaceArgument(7, [

‎src/Symfony/Bundle/DependencyInjection/Configuration.php‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,11 @@ public function getConfigTreeBuilder(): TreeBuilder
111111
->end()
112112
->booleanNode('strict_resource_type')
113113
->defaultFalse()
114-
->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.')
114+
->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, PUT and PATCH (409 Conflict), per https://jsonapi.org/format/#crud-creating-responses-409.')
115+
->end()
116+
->booleanNode('strict_member_names')
117+
->defaultFalse()
118+
->info('Expose the "id" and "type" properties as "resourceId" and "resourceType" attributes instead of "_id" and "_type", which are not valid member names per https://jsonapi.org/format/#document-member-names. The "links", "relationships" and "included" properties are no longer renamed.')
115119
->end()
116120
->end()
117121
->end()

‎src/Symfony/Bundle/Resources/config/jsonapi.php‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,14 +42,15 @@
4242
service('api_platform.metadata.resource.metadata_collection_factory')->ignoreOnInvalid(),
4343
service('api_platform.json_schema.definition_name_factory')->ignoreOnInvalid(),
4444
service('api_platform.jsonapi.resource_linkage_resolver'),
45+
false,
4546
]);
4647

4748
$services->set('api_platform.jsonapi.encoder', JsonEncoder::class)
4849
->args(['jsonapi'])
4950
->tag('serializer.encoder');
5051

5152
$services->set('api_platform.jsonapi.name_converter.reserved_attribute_name', ReservedAttributeNameConverter::class)
52-
->args([service('api_platform.name_converter')->ignoreOnInvalid()]);
53+
->args([service('api_platform.name_converter')->ignoreOnInvalid(), false]);
5354

5455
$services->set('api_platform.jsonapi.normalizer.entrypoint', EntrypointNormalizer::class)
5556
->args([
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <dunglas@gmail.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonApi;
15+
16+
use ApiPlatform\JsonApi\Serializer\ItemNormalizer;
17+
use ApiPlatform\Metadata\ApiProperty;
18+
use ApiPlatform\Metadata\ApiResource;
19+
use ApiPlatform\Metadata\Get;
20+
use ApiPlatform\Metadata\Operation;
21+
use ApiPlatform\Metadata\Patch;
22+
23+
#[ApiResource(
24+
shortName: 'JsonApiPatchableDummy',
25+
formats: ['jsonapi' => ['application/vnd.api+json']],
26+
operations: [
27+
new Get(
28+
uriTemplate: '/jsonapi_patchable_dummies/{id}',
29+
provider: [self::class, 'provide'],
30+
),
31+
new Patch(
32+
uriTemplate: '/jsonapi_patchable_dummies/{id}',
33+
provider: [self::class, 'provide'],
34+
processor: [self::class, 'process'],
35+
),
36+
new Patch(
37+
uriTemplate: '/jsonapi_strict_patchable_dummies/{id}',
38+
denormalizationContext: [ItemNormalizer::STRICT_RESOURCE_TYPE => true],
39+
provider: [self::class, 'provide'],
40+
processor: [self::class, 'process'],
41+
),
42+
],
43+
)]
44+
class PatchableDummy
45+
{
46+
#[ApiProperty(identifier: true)]
47+
public int $id = 1;
48+
49+
public string $name = 'original';
50+
51+
public static function provide(Operation $operation, array $uriVariables = []): self
52+
{
53+
$resource = new self();
54+
$resource->id = (int) $uriVariables['id'];
55+
56+
return $resource;
57+
}
58+
59+
public static function process(self $data): self
60+
{
61+
return $data;
62+
}
63+
}

0 commit comments

Comments
 (0)