Skip to content

Commit 94c70a0

Browse files
committed
Merge remote-tracking branch 'upstream/5.0' into main
# Conflicts: # src/JsonApi/Serializer/ItemNormalizerTrait.php # tests/Functional/JsonApi/RequestDocumentValidationTest.php
2 parents 9090a0d + 80d8690 commit 94c70a0

40 files changed

Lines changed: 1231 additions & 134 deletions

‎composer.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@
3838
"doctrine/orm": "<2.14.0 || 3.0.0",
3939
"doctrine/mongodb-odm": "<2.4",
4040
"doctrine/persistence": "<1.3",
41-
"symfony/framework-bundle": "6.4.6 || 7.0.6",
41+
"symfony/framework-bundle": "<7.4",
4242
"symfony/object-mapper": "<7.3.4",
4343
"symfony/var-exporter": "<6.1.1",
4444
"phpunit/phpunit": "<9.5",

‎src/Hydra/JsonSchema/SchemaFactory.php‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ public function buildSchema(string $className, string $format = 'jsonld', string
109109
// Strictly speaking, it is possible to include `@id` or `@context` in the input,
110110
// but the generated JSON Schema does not include `"additionalProperties": false` by default,
111111
// so it is possible to include `@id` or `@context` in the input even if the input schema is a JSON schema.
112-
if (Schema::TYPE_INPUT === $type) {
112+
if (Schema::TYPE_INPUT === $type && 'jsonapi' !== $format) {
113113
$format = 'json';
114114
}
115115

‎src/JsonApi/JsonSchema/SchemaFactory.php‎

Lines changed: 22 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,9 @@ public function buildSchema(string $className, string $format = 'jsonapi', strin
128128

129129
$schema = $this->schemaFactory->buildSchema($className, 'json', $type, $operation, $schema, $jsonApiSerializerContext, $forceCollection);
130130
$definitionName = $this->definitionNameFactory->create($className, $format, $inputOrOutputClass, $operation, $jsonApiSerializerContext + ['schema_type' => $type]);
131+
if (Schema::TYPE_INPUT === $type) {
132+
$definitionName .= '.input';
133+
}
131134
$prefix = $this->getSchemaUriPrefix($schema->getVersion());
132135
$definitions = $schema->getDefinitions();
133136
$collectionKey = $schema->getItemsDefinitionKey();
@@ -161,7 +164,7 @@ public function buildSchema(string $className, string $format = 'jsonapi', strin
161164
}
162165

163166
if (!$collectionKey) {
164-
$definitions[$definitionName]['properties'] = $this->buildDefinitionPropertiesSchema($key, $className, $format, $type, $operation, $schema, []);
167+
$definitions[$definitionName]['properties'] = $this->buildDefinitionPropertiesSchema($key, $definitionName, $inputOrOutputClass, $format, $type, $operation, $schema, []);
165168
$schema['$ref'] = $prefix.$definitionName;
166169

167170
return $schema;
@@ -198,6 +201,7 @@ public function buildSchema(string $className, string $format = 'jsonapi', strin
198201
],
199202
'data' => [
200203
'type' => 'array',
204+
'items' => ['type' => 'object'],
201205
],
202206
],
203207
'required' => ['data'],
@@ -258,7 +262,7 @@ public function buildSchema(string $className, string $format = 'jsonapi', strin
258262
unset($schema['items']);
259263
unset($schema['type']);
260264

261-
$properties = $this->buildDefinitionPropertiesSchema($key, $className, $format, $type, $operation, $schema, []);
265+
$properties = $this->buildDefinitionPropertiesSchema($key, $definitionName, $inputOrOutputClass, $format, $type, $operation, $schema, []);
262266

263267
$properties['data'] = [
264268
'type' => 'array',
@@ -281,7 +285,7 @@ public function setSchemaFactory(SchemaFactoryInterface $schemaFactory): void
281285
}
282286
}
283287

284-
private function buildDefinitionPropertiesSchema(string $key, string $className, string $format, string $type, ?Operation $operation, Schema $schema, ?array $serializerContext): array
288+
private function buildDefinitionPropertiesSchema(string $key, string $documentDefinitionName, string $propertyClass, string $format, string $type, ?Operation $operation, Schema $schema, ?array $serializerContext): array
285289
{
286290
// Capture the resource's operation up front; the relationship loop below reassigns $operation.
287291
$resourceOperation = $operation;
@@ -292,14 +296,10 @@ private function buildDefinitionPropertiesSchema(string $key, string $className,
292296
$relationships = [];
293297
$relatedDefinitions = [];
294298
foreach ($properties as $propertyName => $property) {
295-
if ($relation = $this->getRelationship($className, $propertyName, $serializerContext)) {
299+
if ($relation = $this->getRelationship($propertyClass, $propertyName, $serializerContext)) {
296300
[$isOne, $relatedClasses] = $relation;
297301
$refs = [];
298-
foreach ($relatedClasses as $relatedClassName => $hasOperations) {
299-
if (false === $hasOperations) {
300-
continue;
301-
}
302-
302+
foreach (Schema::TYPE_OUTPUT === $type ? $relatedClasses : [] as $relatedClassName) {
303303
$operation = $this->findOperation($relatedClassName, $type, null, $serializerContext);
304304
$inputOrOutputClass = $this->findOutputClass($relatedClassName, $type, $operation, $serializerContext);
305305
$serializerContext ??= $this->getSerializerContext($operation, $type);
@@ -324,7 +324,7 @@ private function buildDefinitionPropertiesSchema(string $key, string $className,
324324
}
325325
// keep one entry per related definition: a polymorphic relation targets several resource classes, all of which may appear in "included"
326326
foreach (array_keys($refs) as $ref) {
327-
$relatedDefinitions[$ref] = ['$ref' => $ref];
327+
$relatedDefinitions[$ref.'.resource'] = ['$ref' => $ref.'.resource'];
328328
}
329329
// A relationship literally named "relationships"/"included" is prefixed at runtime too.
330330
$relationshipName = ReservedAttributeNameConverter::getReservedAttributes($this->strictMemberNames)[$propertyName] ?? $propertyName;
@@ -358,6 +358,9 @@ private function buildDefinitionPropertiesSchema(string $key, string $className,
358358
'type' => 'object',
359359
'properties' => $relationships,
360360
];
361+
}
362+
363+
if (Schema::TYPE_OUTPUT === $type && \count($relationships) > 0) {
361364
$included = [
362365
'included' => [
363366
'description' => 'Related resources requested via the "include" query parameter.',
@@ -377,12 +380,15 @@ private function buildDefinitionPropertiesSchema(string $key, string $className,
377380
// Only relax the requirement on the input schema; responses still always carry an `id`.
378381
$required = Schema::TYPE_INPUT === $type && $resourceOperation instanceof HttpOperation && 'POST' === $resourceOperation->getMethod() ? ['type'] : ['type', 'id'];
379382

383+
$resourceDefinitionName = $documentDefinitionName.'.resource';
384+
$definitions[$resourceDefinitionName] = [
385+
'type' => 'object',
386+
'properties' => $replacement,
387+
'required' => $required,
388+
];
389+
380390
return [
381-
'data' => [
382-
'type' => 'object',
383-
'properties' => $replacement,
384-
'required' => $required,
385-
],
391+
'data' => ['$ref' => $this->getSchemaUriPrefix($schema->getVersion()).$resourceDefinitionName],
386392
] + $included;
387393
}
388394

@@ -400,9 +406,7 @@ private function getRelationship(string $resourceClass, string $property, ?array
400406
$relatedClasses = [];
401407
foreach ($relationships as [$className, $isCollection]) {
402408
$isOne = $isOne || !$isCollection;
403-
// @see https://github.com/api-platform/core/issues/5501
404-
// @see https://github.com/api-platform/core/pull/5722
405-
$relatedClasses[$className] = $this->resourceMetadataFactory->create($className)->getOperation()->canRead();
409+
$relatedClasses[$className] = $className;
406410
}
407411

408412
return [$isOne, $relatedClasses];

‎src/JsonApi/Serializer/CollectionNormalizer.php‎

Lines changed: 41 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,12 @@
1313

1414
namespace ApiPlatform\JsonApi\Serializer;
1515

16+
use ApiPlatform\Metadata\Exception\InvalidArgumentException;
1617
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
1718
use ApiPlatform\Metadata\ResourceClassResolverInterface;
1819
use ApiPlatform\Metadata\Util\IriHelper;
1920
use ApiPlatform\Serializer\AbstractCollectionNormalizer;
21+
use ApiPlatform\State\Util\RequestParser;
2022
use Symfony\Component\Serializer\Exception\UnexpectedValueException;
2123

2224
/**
@@ -41,29 +43,29 @@ public function __construct(ResourceClassResolverInterface $resourceClassResolve
4143
protected function getPaginationData(iterable $object, array $context = []): array
4244
{
4345
[$paginator, $paginated, $currentPage, $itemsPerPage, $lastPage, $pageTotalItems, $totalItems] = $this->getPaginationConfig($object, $context);
44-
$parsed = IriHelper::parseIri($context['uri'] ?? '/', $this->pageParameterName);
46+
$parsed = $this->parseUri($context['uri'] ?? '/');
4547

4648
$operation = $context['operation'] ?? $this->getOperation($context);
4749
$urlGenerationStrategy = $operation->getUrlGenerationStrategy();
4850

4951
$data = [
5052
'links' => [
51-
'self' => IriHelper::createIri($parsed['parts'], $parsed['parameters'], $this->pageParameterName, $paginated ? $currentPage : null, $urlGenerationStrategy),
53+
'self' => $this->createPageIri($parsed, $paginated ? $currentPage : null, $urlGenerationStrategy),
5254
],
5355
];
5456

5557
if ($paginated) {
5658
if (null !== $lastPage) {
57-
$data['links']['first'] = IriHelper::createIri($parsed['parts'], $parsed['parameters'], $this->pageParameterName, 1., $urlGenerationStrategy);
58-
$data['links']['last'] = IriHelper::createIri($parsed['parts'], $parsed['parameters'], $this->pageParameterName, $lastPage, $urlGenerationStrategy);
59+
$data['links']['first'] = $this->createPageIri($parsed, 1., $urlGenerationStrategy);
60+
$data['links']['last'] = $this->createPageIri($parsed, $lastPage, $urlGenerationStrategy);
5961
}
6062

6163
if (1. !== $currentPage) {
62-
$data['links']['prev'] = IriHelper::createIri($parsed['parts'], $parsed['parameters'], $this->pageParameterName, $currentPage - 1., $urlGenerationStrategy);
64+
$data['links']['prev'] = $this->createPageIri($parsed, $currentPage - 1., $urlGenerationStrategy);
6365
}
6466

6567
if (null !== $lastPage && $currentPage !== $lastPage || null === $lastPage && $pageTotalItems >= $itemsPerPage) {
66-
$data['links']['next'] = IriHelper::createIri($parsed['parts'], $parsed['parameters'], $this->pageParameterName, $currentPage + 1., $urlGenerationStrategy);
68+
$data['links']['next'] = $this->createPageIri($parsed, $currentPage + 1., $urlGenerationStrategy);
6769
}
6870
}
6971

@@ -79,6 +81,39 @@ protected function getPaginationData(iterable $object, array $context = []): arr
7981
return $data;
8082
}
8183

84+
/**
85+
* @return array{parts: array<string, mixed>, parameters: array<string, mixed>}
86+
*/
87+
private function parseUri(string $uri): array
88+
{
89+
$parts = parse_url($uri);
90+
if (false === $parts) {
91+
throw new InvalidArgumentException(\sprintf('The request URI "%s" is malformed.', $uri));
92+
}
93+
94+
return ['parts' => $parts, 'parameters' => isset($parts['query']) ? RequestParser::parseRequestParams($parts['query']) : []];
95+
}
96+
97+
/**
98+
* @param array{parts: array<string, mixed>, parameters: array<string, mixed>} $parsed
99+
*/
100+
private function createPageIri(array $parsed, ?float $page, ?int $urlGenerationStrategy): string
101+
{
102+
$parameters = $parsed['parameters'];
103+
$pageParameters = \is_array($parameters['page'] ?? null) ? $parameters['page'] : [];
104+
unset($parameters['page'], $parameters[$this->pageParameterName], $pageParameters[$this->pageParameterName]);
105+
106+
if (null !== $page) {
107+
$pageParameters = [$this->pageParameterName => $page] + $pageParameters;
108+
}
109+
110+
if ([] !== $pageParameters) {
111+
$parameters['page'] = $pageParameters;
112+
}
113+
114+
return IriHelper::createIri($parsed['parts'], $parameters, null, null, $urlGenerationStrategy);
115+
}
116+
82117
/**
83118
* {@inheritdoc}
84119
*

‎src/JsonApi/Serializer/ConstraintViolationListNormalizer.php‎

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
namespace ApiPlatform\JsonApi\Serializer;
1515

16+
use ApiPlatform\Metadata\HttpOperation;
1617
use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface;
1718
use Symfony\Component\Serializer\NameConverter\NameConverterInterface;
1819
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
@@ -38,9 +39,13 @@ public function __construct(private readonly PropertyMetadataFactoryInterface $p
3839
*/
3940
public function normalize(mixed $data, ?string $format = null, array $context = []): array
4041
{
42+
$operation = $context['operation'] ?? null;
43+
$status = (string) ($operation instanceof HttpOperation ? $operation->getStatus() : null) ?: '422';
4144
$violations = [];
4245
foreach ($data as $violation) {
4346
$violations[] = [
47+
'status' => $status,
48+
'title' => 'Validation Failed',
4449
'detail' => $violation->getMessage(),
4550
'source' => [
4651
'pointer' => $this->getSourcePointerFromViolation($violation),
@@ -72,13 +77,13 @@ private function getSourcePointerFromViolation(ConstraintViolationInterface $vio
7277
$fieldName = $violation->getPropertyPath();
7378

7479
if (!$fieldName) {
75-
return 'data';
80+
return '/data';
7681
}
7782

7883
$root = $violation->getRoot();
7984

8085
if (!\is_object($root)) {
81-
return "data/attributes/$fieldName";
86+
return "/data/attributes/$fieldName";
8287
}
8388

8489
$class = $root::class;
@@ -94,9 +99,9 @@ private function getSourcePointerFromViolation(ConstraintViolationInterface $vio
9499
}
95100

96101
if ($propertyMetadata->getNativeType()?->isSatisfiedBy(static fn ($t) => $t instanceof ObjectType)) {
97-
return "data/relationships/$fieldName";
102+
return "/data/relationships/$fieldName";
98103
}
99104

100-
return "data/attributes/$fieldName";
105+
return "/data/attributes/$fieldName";
101106
}
102107
}

‎src/JsonApi/Serializer/ErrorNormalizer.php‎

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ final class ErrorNormalizer implements NormalizerInterface
2525
{
2626
public const FORMAT = 'jsonapi';
2727

28-
public function __construct(private ?NormalizerInterface $itemNormalizer = null)
28+
public function __construct(private ?NormalizerInterface $itemNormalizer = null, private ?NormalizerInterface $problemNormalizer = null)
2929
{
3030
}
3131

@@ -34,9 +34,24 @@ public function __construct(private ?NormalizerInterface $itemNormalizer = null)
3434
*/
3535
public function normalize(mixed $data, ?string $format = null, array $context = []): array
3636
{
37-
$jsonApiObject = $this->itemNormalizer->normalize($data, $format, $context);
38-
$error = $jsonApiObject['data']['attributes'] ?? [];
39-
$error['id'] = $jsonApiObject['data']['id'];
37+
if ($data instanceof FlattenException) {
38+
$type = \sprintf('/errors/%d', $data->getStatusCode());
39+
unset($context['exception']);
40+
$problem = $this->problemNormalizer->normalize($data, $format, ['type' => $type] + $context);
41+
$error = [
42+
'id' => $problem['type'],
43+
'title' => $problem['title'],
44+
'detail' => $problem['detail'],
45+
'status' => $problem['status'],
46+
'type' => $problem['type'],
47+
'code' => (string) $problem['status'],
48+
];
49+
} else {
50+
$jsonApiObject = $this->itemNormalizer->normalize($data, $format, $context);
51+
$error = $jsonApiObject['data']['attributes'] ?? [];
52+
$error['id'] = $jsonApiObject['data']['id'];
53+
}
54+
4055
if (isset($error['type'])) {
4156
$error['links'] = ['type' => $error['type']];
4257
unset($error['type']);
@@ -77,7 +92,7 @@ public function normalize(mixed $data, ?string $format = null, array $context =
7792
*/
7893
public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool
7994
{
80-
return self::FORMAT === $format && ($data instanceof \Exception || $data instanceof FlattenException);
95+
return self::FORMAT === $format && ($data instanceof \Exception || ($data instanceof FlattenException && null !== $this->problemNormalizer));
8196
}
8297

8398
/**
@@ -88,7 +103,7 @@ public function getSupportedTypes(?string $format): array
88103
if (self::FORMAT === $format) {
89104
return [
90105
\Exception::class => true,
91-
FlattenException::class => true,
106+
FlattenException::class => null !== $this->problemNormalizer,
92107
];
93108
}
94109

‎src/JsonApi/Serializer/ItemNormalizerTrait.php‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -85,10 +85,10 @@ public function denormalize(mixed $data, string $type, ?string $format = null, a
8585
}
8686
}
8787

88-
$dataToDenormalize = array_merge(
89-
$data['data']['attributes'] ?? [],
90-
$data['data']['relationships'] ?? []
91-
);
88+
$dataToDenormalize = $data['data']['attributes'] ?? [];
89+
foreach ($data['data']['relationships'] ?? [] as $name => $relationship) {
90+
$dataToDenormalize[$name] = \is_array($relationship) && \array_key_exists('data', $relationship) ? $relationship['data'] : $relationship;
91+
}
9292

9393
// Surface the client-generated id so the entity setter receives it.
9494
if ($isPostOperation && $allowClientGeneratedId && isset($data['data']['id'])) {
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
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\JsonApi\Tests\Fixtures;
15+
16+
final class Order
17+
{
18+
public string $reference = '';
19+
20+
/** @var list<RelatedDummy> */
21+
public array $lines = [];
22+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
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\JsonApi\Tests\Fixtures;
15+
16+
final class OrderInput
17+
{
18+
public string $reference = '';
19+
20+
/** @var list<OrderLineInput> */
21+
public array $lines = [];
22+
}

0 commit comments

Comments
 (0)