Completed
Push — master ( 98955b...63d555 )
by Kévin
03:49
created

SchemaBuilder::getResourceFieldConfiguration()   F

Complexity

Conditions 21
Paths 1384

Size

Total Lines 76
Code Lines 49

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 76
rs 2.3464
c 0
b 0
f 0
cc 21
eloc 49
nc 1384
nop 7

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

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\Core\GraphQl\Type;
15
16
use ApiPlatform\Core\Exception\ResourceClassNotFoundException;
17
use ApiPlatform\Core\GraphQl\Resolver\Factory\ResolverFactoryInterface;
18
use ApiPlatform\Core\GraphQl\Serializer\ItemNormalizer;
19
use ApiPlatform\Core\Metadata\Property\Factory\PropertyMetadataFactoryInterface;
20
use ApiPlatform\Core\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface;
21
use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface;
22
use ApiPlatform\Core\Metadata\Resource\Factory\ResourceNameCollectionFactoryInterface;
23
use ApiPlatform\Core\Metadata\Resource\ResourceMetadata;
24
use ApiPlatform\Core\Util\ClassInfoTrait;
25
use Doctrine\Common\Util\Inflector;
26
use GraphQL\Type\Definition\InputObjectType;
27
use GraphQL\Type\Definition\InterfaceType;
28
use GraphQL\Type\Definition\ObjectType;
29
use GraphQL\Type\Definition\Type as GraphQLType;
30
use GraphQL\Type\Definition\WrappingType;
31
use GraphQL\Type\Schema;
32
use Psr\Container\ContainerInterface;
33
use Symfony\Component\Config\Definition\Exception\InvalidTypeException;
34
use Symfony\Component\PropertyInfo\Type;
35
36
/**
37
 * Builds the GraphQL schema.
38
 *
39
 * @experimental
40
 *
41
 * @author Raoul Clais <[email protected]>
42
 * @author Alan Poulain <[email protected]>
43
 * @author Kévin Dunglas <[email protected]>
44
 */
45
final class SchemaBuilder implements SchemaBuilderInterface
46
{
47
    use ClassInfoTrait;
48
49
    private $propertyNameCollectionFactory;
50
    private $propertyMetadataFactory;
51
    private $resourceNameCollectionFactory;
52
    private $resourceMetadataFactory;
53
    private $collectionResolverFactory;
54
    private $itemResolver;
55
    private $itemMutationResolverFactory;
56
    private $defaultFieldResolver;
57
    private $filterLocator;
58
    private $paginationEnabled;
59
    private $graphqlTypes = [];
60
61
    public function __construct(PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, ResourceNameCollectionFactoryInterface $resourceNameCollectionFactory, ResourceMetadataFactoryInterface $resourceMetadataFactory, ResolverFactoryInterface $collectionResolverFactory, ResolverFactoryInterface $itemMutationResolverFactory, callable $itemResolver, callable $defaultFieldResolver, ContainerInterface $filterLocator = null, bool $paginationEnabled = true)
62
    {
63
        $this->propertyNameCollectionFactory = $propertyNameCollectionFactory;
64
        $this->propertyMetadataFactory = $propertyMetadataFactory;
65
        $this->resourceNameCollectionFactory = $resourceNameCollectionFactory;
66
        $this->resourceMetadataFactory = $resourceMetadataFactory;
67
        $this->collectionResolverFactory = $collectionResolverFactory;
68
        $this->itemResolver = $itemResolver;
69
        $this->itemMutationResolverFactory = $itemMutationResolverFactory;
70
        $this->defaultFieldResolver = $defaultFieldResolver;
71
        $this->filterLocator = $filterLocator;
72
        $this->paginationEnabled = $paginationEnabled;
73
    }
74
75
    public function getSchema(): Schema
76
    {
77
        $queryFields = ['node' => $this->getNodeQueryField()];
78
        $mutationFields = [];
79
80
        foreach ($this->resourceNameCollectionFactory->create() as $resourceClass) {
81
            $resourceMetadata = $this->resourceMetadataFactory->create($resourceClass);
82
            $graphqlConfiguration = $resourceMetadata->getGraphql() ?? [];
83
            foreach ($graphqlConfiguration as $operationName => $value) {
84
                if ('query' === $operationName) {
85
                    $queryFields += $this->getQueryFields($resourceClass, $resourceMetadata);
86
87
                    continue;
88
                }
89
90
                $mutationFields[$operationName.$resourceMetadata->getShortName()] = $this->getMutationFields($resourceClass, $resourceMetadata, $operationName);
91
            }
92
        }
93
94
        return new Schema([
95
            'query' => new ObjectType([
96
                'name' => 'Query',
97
                'fields' => $queryFields,
98
            ]),
99
            'mutation' => new ObjectType([
100
                'name' => 'Mutation',
101
                'fields' => $mutationFields,
102
            ]),
103
        ]);
104
    }
105
106
    private function getNodeInterface(): InterfaceType
107
    {
108
        if (isset($this->graphqlTypes['#node'])) {
109
            return $this->graphqlTypes['#node'];
110
        }
111
112
        return $this->graphqlTypes['#node'] = new InterfaceType([
113
            'name' => 'Node',
114
            'description' => 'A node, according to the Relay specification.',
115
            'fields' => [
116
                'id' => [
117
                    'type' => GraphQLType::nonNull(GraphQLType::id()),
118
                    'description' => 'The id of this node.',
119
                ],
120
            ],
121
            'resolveType' => function ($value) {
122
                if (!isset($value[ItemNormalizer::ITEM_KEY])) {
123
                    return null;
124
                }
125
126
                $resourceClass = $this->getObjectClass(unserialize($value[ItemNormalizer::ITEM_KEY]));
127
128
                return $this->graphqlTypes[$resourceClass][null][false] ?? null;
129
            },
130
        ]);
131
    }
132
133
    private function getNodeQueryField(): array
134
    {
135
        return [
136
            'type' => $this->getNodeInterface(),
137
            'args' => [
138
                'id' => ['type' => GraphQLType::nonNull(GraphQLType::id())],
139
            ],
140
            'resolve' => $this->itemResolver,
141
        ];
142
    }
143
144
    /**
145
     * Gets the query fields of the schema.
146
     */
147
    private function getQueryFields(string $resourceClass, ResourceMetadata $resourceMetadata): array
148
    {
149
        $queryFields = [];
150
        $shortName = $resourceMetadata->getShortName();
151
152
        if ($fieldConfiguration = $this->getResourceFieldConfiguration($resourceClass, $resourceMetadata, null, new Type(Type::BUILTIN_TYPE_OBJECT, true, $resourceClass), $resourceClass)) {
153
            $fieldConfiguration['args'] += ['id' => ['type' => GraphQLType::id()]];
154
            $queryFields[lcfirst($shortName)] = $fieldConfiguration;
155
        }
156
157
        if ($fieldConfiguration = $this->getResourceFieldConfiguration($resourceClass, $resourceMetadata, null, new Type(Type::BUILTIN_TYPE_OBJECT, false, null, true, null, new Type(Type::BUILTIN_TYPE_OBJECT, false, $resourceClass)), $resourceClass)) {
158
            $queryFields[lcfirst(Inflector::pluralize($shortName))] = $fieldConfiguration;
159
        }
160
161
        return $queryFields;
162
    }
163
164
    /**
165
     * Gets the mutation field for the given operation name.
166
     */
167
    private function getMutationFields(string $resourceClass, ResourceMetadata $resourceMetadata, string $mutationName): array
168
    {
169
        $shortName = $resourceMetadata->getShortName();
170
        $resourceType = new Type(Type::BUILTIN_TYPE_OBJECT, true, $resourceClass);
171
172
        if ($fieldConfiguration = $this->getResourceFieldConfiguration($resourceClass, $resourceMetadata, ucfirst("{$mutationName}s a $shortName."), $resourceType, $resourceClass, false, $mutationName)) {
173
            $fieldConfiguration['args'] += ['input' => $this->getResourceFieldConfiguration($resourceClass, $resourceMetadata, null, $resourceType, $resourceClass, true, $mutationName)];
174
175
            if (!$resourceType->isCollection()) {
176
                $itemMutationResolverFactory = $this->itemMutationResolverFactory;
177
                $fieldConfiguration['resolve'] = $itemMutationResolverFactory($resourceClass, null, $mutationName);
178
            }
179
        }
180
181
        return $fieldConfiguration;
182
    }
183
184
    /**
185
     * Get the field configuration of a resource.
186
     *
187
     * @see http://webonyx.github.io/graphql-php/type-system/object-types/
188
     *
189
     * @return array|null
190
     */
191
    private function getResourceFieldConfiguration(string $resourceClass, ResourceMetadata $resourceMetadata, string $fieldDescription = null, Type $type, string $rootResource, bool $input = false, string $mutationName = null)
192
    {
193
        try {
194
            if (null === $graphqlType = $this->convertType($type, $input, $mutationName)) {
195
                return null;
196
            }
197
198
            $graphqlWrappedType = $graphqlType instanceof WrappingType ? $graphqlType->getWrappedType() : $graphqlType;
199
            $isInternalGraphqlType = \in_array($graphqlWrappedType, GraphQLType::getInternalTypes(), true);
200
            if ($isInternalGraphqlType) {
201
                $className = '';
202
            } else {
203
                $className = $type->isCollection() ? $type->getCollectionValueType()->getClassName() : $type->getClassName();
204
            }
205
206
            $args = [];
207
            if (!$input && null === $mutationName && !$isInternalGraphqlType && $type->isCollection()) {
208
                if ($this->paginationEnabled) {
209
                    $args = [
210
                        'first' => [
211
                            'type' => GraphQLType::int(),
212
                            'description' => 'Returns the first n elements from the list.',
213
                        ],
214
                        'after' => [
215
                            'type' => GraphQLType::string(),
216
                            'description' => 'Returns the elements in the list that come after the specified cursor.',
217
                        ],
218
                    ];
219
                }
220
221
                foreach ($resourceMetadata->getGraphqlAttribute('query', 'filters', [], true) as $filterId) {
222
                    if (!$this->filterLocator->has($filterId)) {
223
                        continue;
224
                    }
225
226
                    foreach ($this->filterLocator->get($filterId)->getDescription($resourceClass) as $key => $value) {
227
                        $nullable = isset($value['required']) ? !$value['required'] : true;
228
                        $filterType = \in_array($value['type'], Type::$builtinTypes, true) ? new Type($value['type'], $nullable) : new Type('object', $nullable, $value['type']);
229
                        $graphqlFilterType = $this->convertType($filterType);
230
231
                        if ('[]' === $newKey = substr($key, -2)) {
232
                            $key = $newKey;
233
                            $graphqlFilterType = GraphQLType::listOf($graphqlFilterType);
234
                        }
235
236
                        parse_str($key, $parsed);
237
                        array_walk_recursive($parsed, function (&$value) use ($graphqlFilterType) {
238
                            $value = $graphqlFilterType;
239
                        });
240
                        $args = $this->mergeFilterArgs($args, $parsed, $resourceMetadata, $key);
241
                    }
242
                }
243
                $args = $this->convertFilterArgsToTypes($args);
244
            }
245
246
            if ($isInternalGraphqlType || $input || null !== $mutationName) {
247
                $resolve = null;
248
            } elseif ($type->isCollection()) {
249
                $resolverFactory = $this->collectionResolverFactory;
250
                $resolve = $resolverFactory($className, $rootResource);
251
            } else {
252
                $resolve = $this->itemResolver;
253
            }
254
255
            return [
256
                'type' => $graphqlType,
257
                'description' => $fieldDescription,
258
                'args' => $args,
259
                'resolve' => $resolve,
260
            ];
261
        } catch (InvalidTypeException $e) {
262
            // just ignore invalid types
263
        }
264
265
        return null;
266
    }
267
268
    private function mergeFilterArgs(array $args, array $parsed, ResourceMetadata $resourceMetadata = null, $original = ''): array
269
    {
270
        foreach ($parsed as $key => $value) {
271
            // Never override keys that cannot be merged
272
            if (isset($args[$key]) && !\is_array($args[$key])) {
273
                continue;
274
            }
275
276
            if (\is_array($value)) {
277
                $value = $this->mergeFilterArgs($args[$key] ?? [], $value);
278
                if (!isset($value['#name'])) {
279
                    $name = (false === $pos = strrpos($original, '[')) ? $original : substr($original, 0, $pos);
280
                    $value['#name'] = $resourceMetadata->getShortName().'Filter_'.strtr($name, ['[' => '_', ']' => '', '.' => '__']);
0 ignored issues
show
Bug introduced by
It seems like $resourceMetadata is not always an object, but can also be of type null. Maybe add an additional type check?

If a variable is not always an object, we recommend to add an additional type check to ensure your method call is safe:

function someFunction(A $objectMaybe = null)
{
    if ($objectMaybe instanceof A) {
        $objectMaybe->doSomething();
    }
}
Loading history...
281
                }
282
            }
283
284
            $args[$key] = $value;
285
        }
286
287
        return $args;
288
    }
289
290
    private function convertFilterArgsToTypes(array $args): array
291
    {
292
        foreach ($args as $key => $value) {
293
            if (!\is_array($value) || !isset($value['#name'])) {
294
                continue;
295
            }
296
297
            if (isset($this->graphqlTypes[$value['#name']])) {
298
                $args[$key] = $this->graphqlTypes[$value['#name']];
299
                continue;
300
            }
301
302
            $name = $value['#name'];
303
            unset($value['#name']);
304
305
            $this->graphqlTypes[$name] = $args[$key] = new InputObjectType([
306
                'name' => $name,
307
                'fields' => $this->convertFilterArgsToTypes($value),
308
            ]);
309
        }
310
311
        return $args;
312
    }
313
314
    /**
315
     * Converts a built-in type to its GraphQL equivalent.
316
     *
317
     * @throws InvalidTypeException
318
     */
319
    private function convertType(Type $type, bool $input = false, string $mutationName = null)
320
    {
321
        $resourceClass = null;
322
        switch ($builtinType = $type->getBuiltinType()) {
323
            case Type::BUILTIN_TYPE_BOOL:
0 ignored issues
show
Coding Style introduced by
case statements should be defined using a colon.

As per the PSR-2 coding standard, case statements should not be wrapped in curly braces. There is no need for braces, since each case is terminated by the next break.

There is also the option to use a semicolon instead of a colon, this is discouraged because many programmers do not even know it works and the colon is universal between programming languages.

switch ($expr) {
    case "A": { //wrong
        doSomething();
        break;
    }
    case "B"; //wrong
        doSomething();
        break;
    case "C": //right
        doSomething();
        break;
}

To learn more about the PSR-2 coding standard, please refer to the PHP-Fig.

Loading history...
324
                $graphqlType = GraphQLType::boolean();
325
                break;
326
            case Type::BUILTIN_TYPE_INT:
327
                $graphqlType = GraphQLType::int();
328
                break;
329
            case Type::BUILTIN_TYPE_FLOAT:
330
                $graphqlType = GraphQLType::float();
331
                break;
332
            case Type::BUILTIN_TYPE_STRING:
333
                $graphqlType = GraphQLType::string();
334
                break;
335
            case Type::BUILTIN_TYPE_OBJECT:
336
                if (is_a($type->getClassName(), \DateTimeInterface::class, true)) {
337
                    $graphqlType = GraphQLType::string();
338
                    break;
339
                }
340
341
                $resourceClass = $type->isCollection() ? $type->getCollectionValueType()->getClassName() : $type->getClassName();
342
                try {
343
                    $resourceMetadata = $this->resourceMetadataFactory->create($resourceClass);
344
                    if ([] === $resourceMetadata->getGraphql() ?? []) {
345
                        return null;
346
                    }
347
                } catch (ResourceClassNotFoundException $e) {
348
                    // Skip objects that are not resources for now
349
                    return null;
350
                }
351
352
                $graphqlType = $this->getResourceObjectType($resourceClass, $resourceMetadata, $input, $mutationName);
353
                break;
354
            default:
0 ignored issues
show
Coding Style introduced by
DEFAULT statements must be defined using a colon

As per the PSR-2 coding standard, default statements should not be wrapped in curly braces.

switch ($expr) {
    default: { //wrong
        doSomething();
        break;
    }
}

switch ($expr) {
    default: //right
        doSomething();
        break;
}

To learn more about the PSR-2 coding standard, please refer to the PHP-Fig.

Loading history...
355
                throw new InvalidTypeException(sprintf('The type "%s" is not supported.', $builtinType));
356
        }
357
358
        if ($type->isCollection()) {
359
            return $this->paginationEnabled ? $this->getResourcePaginatedCollectionType($resourceClass, $graphqlType, $input) : GraphQLType::listOf($graphqlType);
360
        }
361
362
        return $type->isNullable() || (null !== $mutationName && 'update' === $mutationName) ? $graphqlType : GraphQLType::nonNull($graphqlType);
363
    }
364
365
    /**
366
     * Gets the object type of the given resource.
367
     *
368
     * @return ObjectType|InputObjectType
369
     */
370
    private function getResourceObjectType(string $resourceClass, ResourceMetadata $resourceMetadata, bool $input = false, string $mutationName = null): GraphQLType
371
    {
372 View Code Duplication
        if (isset($this->graphqlTypes[$resourceClass][$mutationName][$input])) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
373
            return $this->graphqlTypes[$resourceClass][$mutationName][$input];
374
        }
375
376
        $shortName = $resourceMetadata->getShortName();
377
        if (null !== $mutationName) {
378
            $shortName = $mutationName.ucfirst($shortName);
379
        }
380
        if ($input) {
381
            $shortName .= 'Input';
382
        } elseif (null !== $mutationName) {
383
            $shortName .= 'Payload';
384
        }
385
386
        $configuration = [
387
            'name' => $shortName,
388
            'description' => $resourceMetadata->getDescription(),
389
            'resolveField' => $this->defaultFieldResolver,
390
            'fields' => function () use ($resourceClass, $resourceMetadata, $input, $mutationName) {
391
                return $this->getResourceObjectTypeFields($resourceClass, $resourceMetadata, $input, $mutationName);
392
            },
393
            'interfaces' => [$this->getNodeInterface()],
394
        ];
395
396
        return $this->graphqlTypes[$resourceClass][$mutationName][$input] = $input ? new InputObjectType($configuration) : new ObjectType($configuration);
397
    }
398
399
    /**
400
     * Gets the fields of the type of the given resource.
401
     */
402
    private function getResourceObjectTypeFields(string $resourceClass, ResourceMetadata $resourceMetadata, bool $input = false, string $mutationName = null): array
403
    {
404
        $fields = [];
405
        $idField = ['type' => GraphQLType::nonNull(GraphQLType::id())];
406
        $clientMutationId = GraphQLType::nonNull(GraphQLType::string());
407
408
        if ('delete' === $mutationName) {
409
            return [
410
                'id' => $idField,
411
                'clientMutationId' => $clientMutationId,
412
            ];
413
        }
414
415
        if (!$input || 'create' !== $mutationName) {
416
            $fields['id'] = $idField;
417
        }
418
419
        foreach ($this->propertyNameCollectionFactory->create($resourceClass) as $property) {
420
            $propertyMetadata = $this->propertyMetadataFactory->create($resourceClass, $property);
421
            if (
422
                null === ($propertyType = $propertyMetadata->getType())
423
                || (!$input && null === $mutationName && !$propertyMetadata->isReadable())
0 ignored issues
show
Bug Best Practice introduced by
The expression $propertyMetadata->isReadable() of type null|boolean is loosely compared to false; this is ambiguous if the boolean can be false. You might want to explicitly use !== null instead.

If an expression can have both false, and null as possible values. It is generally a good practice to always use strict comparison to clearly distinguish between those two values.

$a = canBeFalseAndNull();

// Instead of
if ( ! $a) { }

// Better use one of the explicit versions:
if ($a !== null) { }
if ($a !== false) { }
if ($a !== null && $a !== false) { }
Loading history...
424
                || (null !== $mutationName && !$propertyMetadata->isWritable())
0 ignored issues
show
Bug Best Practice introduced by
The expression $propertyMetadata->isWritable() of type null|boolean is loosely compared to false; this is ambiguous if the boolean can be false. You might want to explicitly use !== null instead.

If an expression can have both false, and null as possible values. It is generally a good practice to always use strict comparison to clearly distinguish between those two values.

$a = canBeFalseAndNull();

// Instead of
if ( ! $a) { }

// Better use one of the explicit versions:
if ($a !== null) { }
if ($a !== false) { }
if ($a !== null && $a !== false) { }
Loading history...
425
            ) {
426
                continue;
427
            }
428
429
            if ($fieldConfiguration = $this->getResourceFieldConfiguration($resourceClass, $resourceMetadata, $propertyMetadata->getDescription(), $propertyType, $resourceClass, $input, $mutationName)) {
430
                $fields['id' === $property ? '_id' : $property] = $fieldConfiguration;
431
            }
432
        }
433
434
        if (null !== $mutationName) {
435
            $fields['clientMutationId'] = $clientMutationId;
436
        }
437
438
        return $fields;
439
    }
440
441
    /**
442
     * Gets the type of a paginated collection of the given resource type.
443
     *
444
     * @param ObjectType|InputObjectType $resourceType
445
     *
446
     * @return ObjectType|InputObjectType
447
     */
448
    private function getResourcePaginatedCollectionType(string $resourceClass, GraphQLType $resourceType, bool $input = false): GraphQLType
449
    {
450
        $shortName = $resourceType->name;
451
        if ($input) {
452
            $shortName .= 'Input';
453
        }
454
455 View Code Duplication
        if (isset($this->graphqlTypes[$resourceClass]['connection'][$input])) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
456
            return $this->graphqlTypes[$resourceClass]['connection'][$input];
457
        }
458
459
        $edgeObjectTypeConfiguration = [
460
            'name' => "{$shortName}Edge",
461
            'description' => "Edge of $shortName.",
462
            'fields' => [
463
                'node' => $resourceType,
464
                'cursor' => GraphQLType::nonNull(GraphQLType::string()),
465
            ],
466
        ];
467
        $edgeObjectType = $input ? new InputObjectType($edgeObjectTypeConfiguration) : new ObjectType($edgeObjectTypeConfiguration);
468
        $pageInfoObjectTypeConfiguration = [
469
            'name' => "{$shortName}PageInfo",
470
            'description' => 'Information about the current page.',
471
            'fields' => [
472
                'endCursor' => GraphQLType::string(),
473
                'hasNextPage' => GraphQLType::nonNull(GraphQLType::boolean()),
474
            ],
475
        ];
476
        $pageInfoObjectType = $input ? new InputObjectType($pageInfoObjectTypeConfiguration) : new ObjectType($pageInfoObjectTypeConfiguration);
477
478
        $configuration = [
479
            'name' => "{$shortName}Connection",
480
            'description' => "Connection for $shortName.",
481
            'fields' => [
482
                'edges' => GraphQLType::listOf($edgeObjectType),
483
                'pageInfo' => GraphQLType::nonNull($pageInfoObjectType),
484
            ],
485
        ];
486
487
        return $this->graphqlTypes[$resourceClass]['connection'][$input] = $input ? new InputObjectType($configuration) : new ObjectType($configuration);
488
    }
489
}
490