Skip to content

Commit 678eb4f

Browse files
fix(jsonapi): add missing "included" schema parts (#6277)
* fix(jsonapi): add missing "included" schema parts * fix(test): test correct format * chore(jsonschema): refactor definition name logic * remove useless comment * remove empty line * add on invalid --------- Co-authored-by: Antoine Bluchet <[email protected]>
1 parent 93f8b5f commit 678eb4f

File tree

15 files changed

+444
-164
lines changed

15 files changed

+444
-164
lines changed

src/JsonApi/JsonSchema/SchemaFactory.php

Lines changed: 46 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,14 @@
1414
namespace ApiPlatform\JsonApi\JsonSchema;
1515

1616
use ApiPlatform\Api\ResourceClassResolverInterface as LegacyResourceClassResolverInterface;
17+
use ApiPlatform\JsonSchema\DefinitionNameFactoryInterface;
18+
use ApiPlatform\JsonSchema\ResourceMetadataTrait;
1719
use ApiPlatform\JsonSchema\Schema;
1820
use ApiPlatform\JsonSchema\SchemaFactoryAwareInterface;
1921
use ApiPlatform\JsonSchema\SchemaFactoryInterface;
2022
use ApiPlatform\Metadata\Operation;
2123
use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface;
24+
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
2225
use ApiPlatform\Metadata\ResourceClassResolverInterface;
2326

2427
/**
@@ -28,6 +31,7 @@
2831
*/
2932
final class SchemaFactory implements SchemaFactoryInterface, SchemaFactoryAwareInterface
3033
{
34+
use ResourceMetadataTrait;
3135
private const LINKS_PROPS = [
3236
'type' => 'object',
3337
'properties' => [
@@ -102,22 +106,26 @@ final class SchemaFactory implements SchemaFactoryInterface, SchemaFactoryAwareI
102106
],
103107
];
104108

105-
public function __construct(private readonly SchemaFactoryInterface $schemaFactory, private readonly PropertyMetadataFactoryInterface $propertyMetadataFactory, private readonly ResourceClassResolverInterface|LegacyResourceClassResolverInterface $resourceClassResolver)
109+
public function __construct(private readonly SchemaFactoryInterface $schemaFactory, private readonly PropertyMetadataFactoryInterface $propertyMetadataFactory, ResourceClassResolverInterface|LegacyResourceClassResolverInterface $resourceClassResolver, ?ResourceMetadataCollectionFactoryInterface $resourceMetadataFactory = null, private readonly ?DefinitionNameFactoryInterface $definitionNameFactory = null)
106110
{
107111
if ($this->schemaFactory instanceof SchemaFactoryAwareInterface) {
108112
$this->schemaFactory->setSchemaFactory($this);
109113
}
114+
$this->resourceClassResolver = $resourceClassResolver;
115+
$this->resourceMetadataFactory = $resourceMetadataFactory;
110116
}
111117

112118
/**
113119
* {@inheritdoc}
114120
*/
115121
public function buildSchema(string $className, string $format = 'jsonapi', string $type = Schema::TYPE_OUTPUT, ?Operation $operation = null, ?Schema $schema = null, ?array $serializerContext = null, bool $forceCollection = false): Schema
116122
{
117-
$schema = $this->schemaFactory->buildSchema($className, $format, $type, $operation, $schema, $serializerContext, $forceCollection);
118123
if ('jsonapi' !== $format) {
119-
return $schema;
124+
return $this->schemaFactory->buildSchema($className, $format, $type, $operation, $schema, $serializerContext, $forceCollection);
120125
}
126+
// We don't use the serializer context here as JSON:API doesn't leverage serializer groups for related resources.
127+
// That is done by query parameter. @see https://jsonapi.org/format/#fetching-includes
128+
$schema = $this->schemaFactory->buildSchema($className, $format, $type, $operation, $schema, [], $forceCollection);
121129

122130
if (($key = $schema->getRootDefinitionKey()) || ($key = $schema->getItemsDefinitionKey())) {
123131
$definitions = $schema->getDefinitions();
@@ -128,7 +136,7 @@ public function buildSchema(string $className, string $format = 'jsonapi', strin
128136
return $schema;
129137
}
130138

131-
$definitions[$key]['properties'] = $this->buildDefinitionPropertiesSchema($key, $className, $schema, $serializerContext);
139+
$definitions[$key]['properties'] = $this->buildDefinitionPropertiesSchema($key, $className, $format, $type, $operation, $schema, []);
132140

133141
if ($schema->getRootDefinitionKey()) {
134142
return $schema;
@@ -166,17 +174,27 @@ public function setSchemaFactory(SchemaFactoryInterface $schemaFactory): void
166174
}
167175
}
168176

169-
private function buildDefinitionPropertiesSchema(string $key, string $className, Schema $schema, ?array $serializerContext): array
177+
private function buildDefinitionPropertiesSchema(string $key, string $className, string $format, string $type, ?Operation $operation, Schema $schema, ?array $serializerContext): array
170178
{
171179
$definitions = $schema->getDefinitions();
172180
$properties = $definitions[$key]['properties'] ?? [];
173181

174182
$attributes = [];
175183
$relationships = [];
184+
$relatedDefinitions = [];
176185
foreach ($properties as $propertyName => $property) {
177186
if ($relation = $this->getRelationship($className, $propertyName, $serializerContext)) {
178-
[$isOne, $isMany] = $relation;
187+
[$isOne, $hasOperations, $relatedClassName] = $relation;
188+
if (false === $hasOperations) {
189+
continue;
190+
}
179191

192+
$operation = $this->findOperation($relatedClassName, $type, $operation, $serializerContext);
193+
$inputOrOutputClass = $this->findOutputClass($relatedClassName, $type, $operation, $serializerContext);
194+
$serializerContext ??= $this->getSerializerContext($operation, $type);
195+
$definitionName = $this->definitionNameFactory->create($relatedClassName, $format, $inputOrOutputClass, $operation, $serializerContext);
196+
$ref = Schema::VERSION_OPENAPI === $schema->getVersion() ? '#/components/schemas/'.$definitionName : '#/definitions/'.$definitionName;
197+
$relatedDefinitions[$propertyName] = ['$ref' => $ref];
180198
if ($isOne) {
181199
$relationships[$propertyName]['properties']['data'] = self::RELATION_PROPS;
182200
continue;
@@ -197,11 +215,25 @@ private function buildDefinitionPropertiesSchema(string $key, string $className,
197215
$replacement = self::PROPERTY_PROPS;
198216
$replacement['attributes']['properties'] = $attributes;
199217

218+
$included = [];
200219
if (\count($relationships) > 0) {
201220
$replacement['relationships'] = [
202221
'type' => 'object',
203222
'properties' => $relationships,
204223
];
224+
$included = [
225+
'included' => [
226+
'description' => 'Related resources requested via the "include" query parameter.',
227+
'type' => 'array',
228+
'items' => [
229+
'anyOf' => array_values($relatedDefinitions),
230+
],
231+
'readOnly' => true,
232+
'externalDocs' => [
233+
'url' => 'https://jsonapi.org/format/#fetching-includes',
234+
],
235+
],
236+
];
205237
}
206238

207239
if ($required = $definitions[$key]['required'] ?? null) {
@@ -223,7 +255,7 @@ private function buildDefinitionPropertiesSchema(string $key, string $className,
223255
'properties' => $replacement,
224256
'required' => ['type', 'id'],
225257
],
226-
];
258+
] + $included;
227259
}
228260

229261
private function getRelationship(string $resourceClass, string $property, ?array $serializerContext): ?array
@@ -232,6 +264,7 @@ private function getRelationship(string $resourceClass, string $property, ?array
232264
$types = $propertyMetadata->getBuiltinTypes() ?? [];
233265
$isRelationship = false;
234266
$isOne = $isMany = false;
267+
$className = $hasOperations = null;
235268

236269
foreach ($types as $type) {
237270
if ($type->isCollection()) {
@@ -244,8 +277,13 @@ private function getRelationship(string $resourceClass, string $property, ?array
244277
continue;
245278
}
246279
$isRelationship = true;
280+
$resourceMetadata = $this->resourceMetadataFactory->create($className);
281+
$operation = $resourceMetadata->getOperation();
282+
// @see https://github.com/api-platform/core/issues/5501
283+
// @see https://github.com/api-platform/core/pull/5722
284+
$hasOperations ??= $operation->canRead();
247285
}
248286

249-
return $isRelationship ? [$isOne, $isMany] : null;
287+
return $isRelationship ? [$isOne, $hasOperations, $className] : null;
250288
}
251289
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <[email protected]>
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\JsonSchema;
15+
16+
use ApiPlatform\Metadata\Operation;
17+
use ApiPlatform\Metadata\Util\ResourceClassInfoTrait;
18+
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
19+
20+
final class DefinitionNameFactory implements DefinitionNameFactoryInterface
21+
{
22+
use ResourceClassInfoTrait;
23+
24+
public function __construct(private ?array $distinctFormats)
25+
{
26+
}
27+
28+
public function create(string $className, string $format = 'json', ?string $inputOrOutputClass = null, ?Operation $operation = null, array $serializerContext = []): string
29+
{
30+
if ($operation) {
31+
$prefix = $operation->getShortName();
32+
}
33+
34+
if (!isset($prefix)) {
35+
$prefix = (new \ReflectionClass($className))->getShortName();
36+
}
37+
38+
if (null !== $inputOrOutputClass && $className !== $inputOrOutputClass) {
39+
$parts = explode('\\', $inputOrOutputClass);
40+
$shortName = end($parts);
41+
$prefix .= '.'.$shortName;
42+
}
43+
44+
if ('json' !== $format && ($this->distinctFormats[$format] ?? false)) {
45+
// JSON is the default, and so isn't included in the definition name
46+
$prefix .= '.'.$format;
47+
}
48+
49+
$definitionName = $serializerContext[SchemaFactory::OPENAPI_DEFINITION_NAME] ?? null;
50+
if ($definitionName) {
51+
$name = sprintf('%s-%s', $prefix, $definitionName);
52+
} else {
53+
$groups = (array) ($serializerContext[AbstractNormalizer::GROUPS] ?? []);
54+
$name = $groups ? sprintf('%s-%s', $prefix, implode('_', $groups)) : $prefix;
55+
}
56+
57+
return $this->encodeDefinitionName($name);
58+
}
59+
60+
private function encodeDefinitionName(string $name): string
61+
{
62+
return preg_replace('/[^a-zA-Z0-9.\-_]/', '.', $name);
63+
}
64+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <[email protected]>
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\JsonSchema;
15+
16+
use ApiPlatform\Metadata\Operation;
17+
18+
/**
19+
* Factory for creating definition names for resources in a JSON Schema document.
20+
*
21+
* @author Gwendolen Lynch <[email protected]>
22+
*/
23+
interface DefinitionNameFactoryInterface
24+
{
25+
/**
26+
* Creates a resource definition name.
27+
*
28+
* @param class-string $className
29+
*
30+
* @return string the definition name
31+
*/
32+
public function create(string $className, string $format = 'json', ?string $inputOrOutputClass = null, ?Operation $operation = null, array $serializerContext = []): string;
33+
}
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <[email protected]>
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\JsonSchema;
15+
16+
use ApiPlatform\Metadata\CollectionOperationInterface;
17+
use ApiPlatform\Metadata\Exception\OperationNotFoundException;
18+
use ApiPlatform\Metadata\HttpOperation;
19+
use ApiPlatform\Metadata\Operation;
20+
use ApiPlatform\Metadata\Resource\ResourceMetadataCollection;
21+
use ApiPlatform\Metadata\Util\ResourceClassInfoTrait;
22+
23+
/**
24+
* @internal
25+
*/
26+
trait ResourceMetadataTrait
27+
{
28+
use ResourceClassInfoTrait;
29+
30+
private function findOutputClass(string $className, string $type, Operation $operation, ?array $serializerContext): ?string
31+
{
32+
$inputOrOutput = ['class' => $className];
33+
$inputOrOutput = Schema::TYPE_OUTPUT === $type ? ($operation->getOutput() ?? $inputOrOutput) : ($operation->getInput() ?? $inputOrOutput);
34+
$forceSubschema = $serializerContext[SchemaFactory::FORCE_SUBSCHEMA] ?? false;
35+
36+
return $forceSubschema ? ($inputOrOutput['class'] ?? $inputOrOutput->class ?? $operation->getClass()) : ($inputOrOutput['class'] ?? $inputOrOutput->class ?? null);
37+
}
38+
39+
private function findOperation(string $className, string $type, ?Operation $operation, ?array $serializerContext): Operation
40+
{
41+
if (null === $operation) {
42+
if (null === $this->resourceMetadataFactory) {
43+
return new HttpOperation();
44+
}
45+
$resourceMetadataCollection = $this->resourceMetadataFactory->create($className);
46+
47+
try {
48+
$operation = $resourceMetadataCollection->getOperation();
49+
} catch (OperationNotFoundException $e) {
50+
$operation = new HttpOperation();
51+
}
52+
$forceSubschema = $serializerContext[SchemaFactory::FORCE_SUBSCHEMA] ?? false;
53+
if ($operation->getShortName() === $this->getShortClassName($className) && $forceSubschema) {
54+
$operation = new HttpOperation();
55+
}
56+
57+
return $this->findOperationForType($resourceMetadataCollection, $type, $operation);
58+
}
59+
60+
// The best here is to use an Operation when calling `buildSchema`, we try to do a smart guess otherwise
61+
if ($this->resourceMetadataFactory && !$operation->getClass()) {
62+
$resourceMetadataCollection = $this->resourceMetadataFactory->create($className);
63+
64+
if ($operation->getName()) {
65+
return $resourceMetadataCollection->getOperation($operation->getName());
66+
}
67+
68+
return $this->findOperationForType($resourceMetadataCollection, $type, $operation);
69+
}
70+
71+
return $operation;
72+
}
73+
74+
private function findOperationForType(ResourceMetadataCollection $resourceMetadataCollection, string $type, Operation $operation): Operation
75+
{
76+
// Find the operation and use the first one that matches criterias
77+
foreach ($resourceMetadataCollection as $resourceMetadata) {
78+
foreach ($resourceMetadata->getOperations() ?? [] as $op) {
79+
if ($operation instanceof CollectionOperationInterface && $op instanceof CollectionOperationInterface) {
80+
$operation = $op;
81+
break 2;
82+
}
83+
84+
if (Schema::TYPE_INPUT === $type && \in_array($op->getMethod(), ['POST', 'PATCH', 'PUT'], true)) {
85+
$operation = $op;
86+
break 2;
87+
}
88+
}
89+
}
90+
91+
return $operation;
92+
}
93+
94+
private function getSerializerContext(Operation $operation, string $type = Schema::TYPE_OUTPUT): array
95+
{
96+
return Schema::TYPE_OUTPUT === $type ? ($operation->getNormalizationContext() ?? []) : ($operation->getDenormalizationContext() ?? []);
97+
}
98+
99+
private function getShortClassName(string $fullyQualifiedName): string
100+
{
101+
$parts = explode('\\', $fullyQualifiedName);
102+
return end($parts);
103+
}
104+
}

0 commit comments

Comments
 (0)