Passed
Push — master ( f8678d...8bd912 )
by Alan
05:56
created

FieldsBuilder::getCollectionQueryFields()   A

Complexity

Conditions 4
Paths 2

Size

Total Lines 15
Code Lines 8

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 4
eloc 8
nc 2
nop 4
dl 0
loc 15
rs 10
c 0
b 0
f 0
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\Type\Definition\TypeInterface;
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\ResourceMetadata;
23
use Doctrine\Common\Inflector\Inflector;
24
use GraphQL\Type\Definition\InputObjectType;
25
use GraphQL\Type\Definition\NullableType;
26
use GraphQL\Type\Definition\Type as GraphQLType;
27
use GraphQL\Type\Definition\WrappingType;
28
use Psr\Container\ContainerInterface;
29
use Symfony\Component\Config\Definition\Exception\InvalidTypeException;
30
use Symfony\Component\PropertyInfo\Type;
31
32
/**
33
 * Builds the GraphQL fields.
34
 *
35
 * @experimental
36
 *
37
 * @author Alan Poulain <[email protected]>
38
 */
39
final class FieldsBuilder implements FieldsBuilderInterface
40
{
41
    private $propertyNameCollectionFactory;
42
    private $propertyMetadataFactory;
43
    private $resourceMetadataFactory;
44
    private $typesContainer;
45
    private $typeBuilder;
46
    private $typeConverter;
47
    private $itemResolverFactory;
48
    private $collectionResolverFactory;
49
    private $itemMutationResolverFactory;
50
    private $filterLocator;
51
    private $paginationEnabled;
52
53
    public function __construct(PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, ResourceMetadataFactoryInterface $resourceMetadataFactory, TypesContainerInterface $typesContainer, TypeBuilderInterface $typeBuilder, TypeConverterInterface $typeConverter, ResolverFactoryInterface $itemResolverFactory, ResolverFactoryInterface $collectionResolverFactory, ResolverFactoryInterface $itemMutationResolverFactory, ContainerInterface $filterLocator, bool $paginationEnabled)
54
    {
55
        $this->propertyNameCollectionFactory = $propertyNameCollectionFactory;
56
        $this->propertyMetadataFactory = $propertyMetadataFactory;
57
        $this->resourceMetadataFactory = $resourceMetadataFactory;
58
        $this->typesContainer = $typesContainer;
59
        $this->typeBuilder = $typeBuilder;
60
        $this->typeConverter = $typeConverter;
61
        $this->itemResolverFactory = $itemResolverFactory;
62
        $this->collectionResolverFactory = $collectionResolverFactory;
63
        $this->itemMutationResolverFactory = $itemMutationResolverFactory;
64
        $this->filterLocator = $filterLocator;
65
        $this->paginationEnabled = $paginationEnabled;
66
    }
67
68
    /**
69
     * {@inheritdoc}
70
     */
71
    public function getNodeQueryFields(): array
72
    {
73
        return [
74
            'type' => $this->typeBuilder->getNodeInterface(),
75
            'args' => [
76
                'id' => ['type' => GraphQLType::nonNull(GraphQLType::id())],
77
            ],
78
            'resolve' => ($this->itemResolverFactory)(),
79
        ];
80
    }
81
82
    /**
83
     * {@inheritdoc}
84
     */
85
    public function getItemQueryFields(string $resourceClass, ResourceMetadata $resourceMetadata, string $queryName, array $configuration): array
86
    {
87
        $shortName = $resourceMetadata->getShortName();
88
        $fieldName = lcfirst('item_query' === $queryName ? $shortName : $queryName.$shortName);
89
90
        $deprecationReason = (string) $resourceMetadata->getGraphqlAttribute($queryName, 'deprecation_reason', '', true);
91
92
        if ($fieldConfiguration = $this->getResourceFieldConfiguration(null, null, $deprecationReason, new Type(Type::BUILTIN_TYPE_OBJECT, true, $resourceClass), $resourceClass, false, $queryName, null)) {
93
            $args = $this->resolveResourceArgs($configuration['args'] ?? [], $queryName, $shortName);
0 ignored issues
show
Bug introduced by
It seems like $shortName can also be of type null; however, parameter $shortName of ApiPlatform\Core\GraphQl...::resolveResourceArgs() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

93
            $args = $this->resolveResourceArgs($configuration['args'] ?? [], $queryName, /** @scrutinizer ignore-type */ $shortName);
Loading history...
94
            $configuration['args'] = $args ?: $configuration['args'] ?? ['id' => ['type' => GraphQLType::nonNull(GraphQLType::id())]];
95
96
            return [$fieldName => array_merge($fieldConfiguration, $configuration)];
97
        }
98
99
        return [];
100
    }
101
102
    /**
103
     * {@inheritdoc}
104
     */
105
    public function getCollectionQueryFields(string $resourceClass, ResourceMetadata $resourceMetadata, string $queryName, array $configuration): array
106
    {
107
        $shortName = $resourceMetadata->getShortName();
108
        $fieldName = lcfirst('collection_query' === $queryName ? $shortName : $queryName.$shortName);
109
110
        $deprecationReason = (string) $resourceMetadata->getGraphqlAttribute($queryName, 'deprecation_reason', '', true);
111
112
        if ($fieldConfiguration = $this->getResourceFieldConfiguration(null, null, $deprecationReason, new Type(Type::BUILTIN_TYPE_OBJECT, false, null, true, null, new Type(Type::BUILTIN_TYPE_OBJECT, false, $resourceClass)), $resourceClass, false, $queryName, null)) {
113
            $args = $this->resolveResourceArgs($configuration['args'] ?? [], $queryName, $shortName);
0 ignored issues
show
Bug introduced by
It seems like $shortName can also be of type null; however, parameter $shortName of ApiPlatform\Core\GraphQl...::resolveResourceArgs() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

113
            $args = $this->resolveResourceArgs($configuration['args'] ?? [], $queryName, /** @scrutinizer ignore-type */ $shortName);
Loading history...
114
            $configuration['args'] = $args ?: $configuration['args'] ?? $fieldConfiguration['args'];
115
116
            return [Inflector::pluralize($fieldName) => array_merge($fieldConfiguration, $configuration)];
117
        }
118
119
        return [];
120
    }
121
122
    /**
123
     * {@inheritdoc}
124
     */
125
    public function getMutationFields(string $resourceClass, ResourceMetadata $resourceMetadata, string $mutationName): array
126
    {
127
        $mutationFields = [];
128
        $shortName = $resourceMetadata->getShortName();
129
        $resourceType = new Type(Type::BUILTIN_TYPE_OBJECT, true, $resourceClass);
130
        $deprecationReason = $resourceMetadata->getGraphqlAttribute($mutationName, 'deprecation_reason', '', true);
131
132
        if ($fieldConfiguration = $this->getResourceFieldConfiguration(null, ucfirst("{$mutationName}s a $shortName."), $deprecationReason, $resourceType, $resourceClass, false, null, $mutationName)) {
133
            $fieldConfiguration['args'] += ['input' => $this->getResourceFieldConfiguration(null, null, $deprecationReason, $resourceType, $resourceClass, true, null, $mutationName)];
134
135
            if (!$this->typeBuilder->isCollection($resourceType)) {
136
                $fieldConfiguration['resolve'] = ($this->itemMutationResolverFactory)($resourceClass, null, $mutationName);
137
            }
138
        }
139
140
        $mutationFields[$mutationName.$resourceMetadata->getShortName()] = $fieldConfiguration ?? [];
141
142
        return $mutationFields;
143
    }
144
145
    /**
146
     * {@inheritdoc}
147
     */
148
    public function getResourceObjectTypeFields(?string $resourceClass, ResourceMetadata $resourceMetadata, bool $input, ?string $queryName, ?string $mutationName, int $depth = 0, ?array $ioMetadata = null): array
149
    {
150
        $fields = [];
151
        $idField = ['type' => GraphQLType::nonNull(GraphQLType::id())];
152
        $clientMutationId = GraphQLType::string();
153
154
        if (null !== $ioMetadata && \array_key_exists('class', $ioMetadata) && null === $ioMetadata['class']) {
155
            if ($input) {
156
                return ['clientMutationId' => $clientMutationId];
157
            }
158
159
            return [];
160
        }
161
162
        if ('delete' === $mutationName) {
163
            $fields = [
164
                'id' => $idField,
165
            ];
166
167
            if ($input) {
168
                $fields['clientMutationId'] = $clientMutationId;
169
            }
170
171
            return $fields;
172
        }
173
174
        if (!$input || 'create' !== $mutationName) {
175
            $fields['id'] = $idField;
176
        }
177
178
        ++$depth; // increment the depth for the call to getResourceFieldConfiguration.
179
180
        if (null !== $resourceClass) {
181
            foreach ($this->propertyNameCollectionFactory->create($resourceClass) as $property) {
182
                $propertyMetadata = $this->propertyMetadataFactory->create($resourceClass, $property, ['graphql_operation_name' => $mutationName ?? $queryName]);
183
                if (
184
                    null === ($propertyType = $propertyMetadata->getType())
185
                    || (!$input && false === $propertyMetadata->isReadable())
186
                    || ($input && null !== $mutationName && false === $propertyMetadata->isWritable())
187
                ) {
188
                    continue;
189
                }
190
191
                if ($fieldConfiguration = $this->getResourceFieldConfiguration($property, $propertyMetadata->getDescription(), $propertyMetadata->getAttribute('deprecation_reason', ''), $propertyType, $resourceClass, $input, $queryName, $mutationName, $depth)) {
192
                    $fields['id' === $property ? '_id' : $property] = $fieldConfiguration;
193
                }
194
            }
195
        }
196
197
        if (null !== $mutationName && $input) {
198
            $fields['clientMutationId'] = $clientMutationId;
199
        }
200
201
        return $fields;
202
    }
203
204
    /**
205
     * {@inheritdoc}
206
     */
207
    public function resolveResourceArgs(array $args, string $operationName, string $shortName): array
208
    {
209
        foreach ($args as $id => $arg) {
210
            if (!isset($arg['type'])) {
211
                throw new \InvalidArgumentException(sprintf('The argument "%s" of the custom operation "%s" in %s needs a "type" option.', $id, $operationName, $shortName));
212
            }
213
214
            $args[$id]['type'] = $this->typeConverter->resolveType($arg['type']);
215
        }
216
217
        return $args;
218
    }
219
220
    /**
221
     * Get the field configuration of a resource.
222
     *
223
     * @see http://webonyx.github.io/graphql-php/type-system/object-types/
224
     */
225
    private function getResourceFieldConfiguration(?string $property, ?string $fieldDescription, string $deprecationReason, Type $type, string $rootResource, bool $input, ?string $queryName, ?string $mutationName, int $depth = 0): ?array
226
    {
227
        try {
228
            $resourceClass = $this->typeBuilder->isCollection($type) && ($collectionValueType = $type->getCollectionValueType()) ? $collectionValueType->getClassName() : $type->getClassName();
229
230
            if (null === $graphqlType = $this->convertType($type, $input, $queryName, $mutationName, $resourceClass ?? '', $rootResource, $property, $depth)) {
0 ignored issues
show
introduced by
The condition null === $graphqlType = ...rce, $property, $depth) is always false.
Loading history...
231
                return null;
232
            }
233
234
            $graphqlWrappedType = $graphqlType instanceof WrappingType ? $graphqlType->getWrappedType() : $graphqlType;
235
            $isStandardGraphqlType = \in_array($graphqlWrappedType, GraphQLType::getStandardTypes(), true);
236
            if ($isStandardGraphqlType) {
237
                $resourceClass = '';
238
            }
239
240
            $resourceMetadata = null;
241
            if (!empty($resourceClass)) {
242
                try {
243
                    $resourceMetadata = $this->resourceMetadataFactory->create($resourceClass);
244
                } catch (ResourceClassNotFoundException $e) {
0 ignored issues
show
Coding Style Comprehensibility introduced by
Consider adding a comment why this CATCH block is empty.
Loading history...
245
                }
246
            }
247
248
            $args = [];
249
            if (!$input && null === $mutationName && !$isStandardGraphqlType && $this->typeBuilder->isCollection($type)) {
250
                if ($this->paginationEnabled) {
251
                    $args = [
252
                        'first' => [
253
                            'type' => GraphQLType::int(),
254
                            'description' => 'Returns the first n elements from the list.',
255
                        ],
256
                        'last' => [
257
                            'type' => GraphQLType::int(),
258
                            'description' => 'Returns the last n elements from the list.',
259
                        ],
260
                        'before' => [
261
                            'type' => GraphQLType::string(),
262
                            'description' => 'Returns the elements in the list that come before the specified cursor.',
263
                        ],
264
                        'after' => [
265
                            'type' => GraphQLType::string(),
266
                            'description' => 'Returns the elements in the list that come after the specified cursor.',
267
                        ],
268
                    ];
269
                }
270
271
                $args = $this->getFilterArgs($args, $resourceClass, $resourceMetadata, $rootResource, $property, $queryName, $mutationName, $depth);
272
            }
273
274
            if ($isStandardGraphqlType || $input) {
275
                $resolve = null;
276
            } elseif ($this->typeBuilder->isCollection($type)) {
277
                $resolve = ($this->collectionResolverFactory)($resourceClass, $rootResource, $queryName);
278
            } else {
279
                $resolve = ($this->itemResolverFactory)($resourceClass, $rootResource, $queryName);
280
            }
281
282
            return [
283
                'type' => $graphqlType,
284
                'description' => $fieldDescription,
285
                'args' => $args,
286
                'resolve' => $resolve,
287
                'deprecationReason' => $deprecationReason,
288
            ];
289
        } catch (InvalidTypeException $e) {
290
            // just ignore invalid types
291
        }
292
293
        return null;
294
    }
295
296
    private function getFilterArgs(array $args, ?string $resourceClass, ?ResourceMetadata $resourceMetadata, string $rootResource, ?string $property, ?string $queryName, ?string $mutationName, int $depth): array
297
    {
298
        if (null === $resourceMetadata || null === $resourceClass) {
299
            return $args;
300
        }
301
302
        foreach ($resourceMetadata->getGraphqlAttribute($queryName, 'filters', [], true) as $filterId) {
0 ignored issues
show
Bug introduced by
It seems like $queryName can also be of type null; however, parameter $operationName of ApiPlatform\Core\Metadat...::getGraphqlAttribute() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

302
        foreach ($resourceMetadata->getGraphqlAttribute(/** @scrutinizer ignore-type */ $queryName, 'filters', [], true) as $filterId) {
Loading history...
303
            if (null === $this->filterLocator || !$this->filterLocator->has($filterId)) {
304
                continue;
305
            }
306
307
            foreach ($this->filterLocator->get($filterId)->getDescription($resourceClass) as $key => $value) {
308
                $nullable = isset($value['required']) ? !$value['required'] : true;
309
                $filterType = \in_array($value['type'], Type::$builtinTypes, true) ? new Type($value['type'], $nullable) : new Type('object', $nullable, $value['type']);
310
                $graphqlFilterType = $this->convertType($filterType, false, $queryName, $mutationName, $resourceClass, $rootResource, $property, $depth);
311
312
                if ('[]' === substr($key, -2)) {
313
                    $graphqlFilterType = GraphQLType::listOf($graphqlFilterType);
314
                    $key = substr($key, 0, -2).'_list';
315
                }
316
317
                parse_str($key, $parsed);
318
                if (\array_key_exists($key, $parsed) && \is_array($parsed[$key])) {
319
                    $parsed = [$key => ''];
320
                }
321
                array_walk_recursive($parsed, function (&$value) use ($graphqlFilterType) {
322
                    $value = $graphqlFilterType;
323
                });
324
                $args = $this->mergeFilterArgs($args, $parsed, $resourceMetadata, $key);
325
            }
326
        }
327
328
        return $this->convertFilterArgsToTypes($args);
329
    }
330
331
    private function mergeFilterArgs(array $args, array $parsed, ResourceMetadata $resourceMetadata = null, $original = ''): array
332
    {
333
        foreach ($parsed as $key => $value) {
334
            // Never override keys that cannot be merged
335
            if (isset($args[$key]) && !\is_array($args[$key])) {
336
                continue;
337
            }
338
339
            if (\is_array($value)) {
340
                $value = $this->mergeFilterArgs($args[$key] ?? [], $value);
341
                if (!isset($value['#name'])) {
342
                    $name = (false === $pos = strrpos($original, '[')) ? $original : substr($original, 0, (int) $pos);
343
                    $value['#name'] = ($resourceMetadata ? $resourceMetadata->getShortName() : '').'Filter_'.strtr($name, ['[' => '_', ']' => '', '.' => '__']);
344
                }
345
            }
346
347
            $args[$key] = $value;
348
        }
349
350
        return $args;
351
    }
352
353
    private function convertFilterArgsToTypes(array $args): array
354
    {
355
        foreach ($args as $key => $value) {
356
            if (strpos($key, '.')) {
357
                // Declare relations/nested fields in a GraphQL compatible syntax.
358
                $args[str_replace('.', '_', $key)] = $value;
359
                unset($args[$key]);
360
            }
361
        }
362
363
        foreach ($args as $key => $value) {
364
            if (!\is_array($value) || !isset($value['#name'])) {
365
                continue;
366
            }
367
368
            $name = $value['#name'];
369
370
            if ($this->typesContainer->has($name)) {
371
                $args[$key] = $this->typesContainer->get($name);
372
                continue;
373
            }
374
375
            unset($value['#name']);
376
377
            $filterArgType = new InputObjectType([
378
                'name' => $name,
379
                'fields' => $this->convertFilterArgsToTypes($value),
380
            ]);
381
382
            $this->typesContainer->set($name, $filterArgType);
383
384
            $args[$key] = $filterArgType;
385
        }
386
387
        return $args;
388
    }
389
390
    /**
391
     * Converts a built-in type to its GraphQL equivalent.
392
     *
393
     * @throws InvalidTypeException
394
     */
395
    private function convertType(Type $type, bool $input, ?string $queryName, ?string $mutationName, string $resourceClass, string $rootResource, ?string $property, int $depth)
396
    {
397
        $graphqlType = $this->typeConverter->convertType($type, $input, $queryName, $mutationName, $resourceClass, $rootResource, $property, $depth);
398
399
        if (null === $graphqlType) {
400
            throw new InvalidTypeException(sprintf('The type "%s" is not supported.', $type->getBuiltinType()));
401
        }
402
403
        if (\is_string($graphqlType)) {
404
            if (!$this->typesContainer->has($graphqlType)) {
405
                throw new InvalidTypeException(sprintf('The GraphQL type %s is not valid. Valid types are: %s. Have you registered this type by implementing %s?', $graphqlType, implode(', ', array_keys($this->typesContainer->all())), TypeInterface::class));
406
            }
407
408
            $graphqlType = $this->typesContainer->get($graphqlType);
409
        }
410
411
        if ($this->typeBuilder->isCollection($type)) {
412
            return $this->paginationEnabled && !$input ? $this->typeBuilder->getResourcePaginatedCollectionType($graphqlType) : GraphQLType::listOf($graphqlType);
413
        }
414
415
        return !$graphqlType instanceof NullableType || $type->isNullable() || (null !== $mutationName && 'update' === $mutationName)
416
            ? $graphqlType
417
            : GraphQLType::nonNull($graphqlType);
418
    }
419
}
420