Scrutinizer GitHub App not installed

We could not synchronize checks via GitHub's checks API since Scrutinizer's GitHub App is not installed for this repository.

Install GitHub App

Completed
Pull Request — master (#695)
by Timur
29:16 queued 03:42
created

enumAnnotationToGQLConfiguration()   B

Complexity

Conditions 7
Paths 10

Size

Total Lines 28
Code Lines 15

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 14
CRAP Score 7

Importance

Changes 0
Metric Value
eloc 15
dl 0
loc 28
ccs 14
cts 14
cp 1
rs 8.8333
c 0
b 0
f 0
cc 7
nc 10
nop 3
crap 7
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Overblog\GraphQLBundle\Config\Parser;
6
7
use Doctrine\Common\Annotations\AnnotationReader;
8
use Doctrine\Common\Annotations\AnnotationRegistry;
9
use Doctrine\ORM\Mapping\Column;
10
use Doctrine\ORM\Mapping\JoinColumn;
11
use Doctrine\ORM\Mapping\ManyToMany;
12
use Doctrine\ORM\Mapping\ManyToOne;
13
use Doctrine\ORM\Mapping\OneToMany;
14
use Doctrine\ORM\Mapping\OneToOne;
15
use Exception;
16
use Overblog\GraphQLBundle\Annotation as GQL;
17
use Overblog\GraphQLBundle\Relay\Connection\ConnectionInterface;
18
use Overblog\GraphQLBundle\Relay\Connection\EdgeInterface;
19
use ReflectionClass;
20
use ReflectionException;
21
use ReflectionMethod;
22
use ReflectionNamedType;
23
use RuntimeException;
24
use SplFileInfo;
25
use Symfony\Component\Config\Resource\FileResource;
26
use Symfony\Component\DependencyInjection\ContainerBuilder;
27
use Symfony\Component\DependencyInjection\Exception\InvalidArgumentException;
28
use function array_filter;
29
use function array_keys;
30
use function array_map;
31
use function array_unshift;
32
use function class_exists;
33
use function current;
34
use function file_get_contents;
35
use function get_class;
36
use function implode;
37
use function in_array;
38
use function is_array;
39
use function is_string;
40
use function preg_match;
41
use function sprintf;
42
use function str_replace;
43
use function strlen;
44
use function strpos;
45
use function substr;
46
use function trim;
47
48
class AnnotationParser implements PreParserInterface
49 20
{
50
    private static ?AnnotationReader $annotationReader = null;
51 20
    private static array $classesMap = [];
52 20
    private static array $providers = [];
53
    private static array $doctrineMapping = [];
54
    private static array $classAnnotationsCache = [];
55
56
    private const GQL_SCALAR = 'scalar';
57
    private const GQL_ENUM = 'enum';
58
    private const GQL_TYPE = 'type';
59
    private const GQL_INPUT = 'input';
60 20
    private const GQL_UNION = 'union';
61
    private const GQL_INTERFACE = 'interface';
62 20
63
    /**
64
     * @see https://facebook.github.io/graphql/draft/#sec-Input-and-Output-Types
65
     */
66
    private const VALID_INPUT_TYPES = [self::GQL_SCALAR, self::GQL_ENUM, self::GQL_INPUT];
67
    private const VALID_OUTPUT_TYPES = [self::GQL_SCALAR, self::GQL_TYPE, self::GQL_INTERFACE, self::GQL_UNION, self::GQL_ENUM];
68 58
69
    /**
70 58
     * {@inheritdoc}
71 58
     *
72 58
     * @throws InvalidArgumentException
73 58
     */
74 58
    public static function preParse(SplFileInfo $file, ContainerBuilder $container, array $configs = []): void
75
    {
76
        $container->setParameter('overblog_graphql_types.classes_map', self::processFile($file, $container, $configs, true));
77
    }
78
79
    /**
80
     * @throws InvalidArgumentException
81
     */
82 20
    public static function parse(SplFileInfo $file, ContainerBuilder $container, array $configs = []): array
83
    {
84 20
        return self::processFile($file, $container, $configs, false);
85 20
    }
86
87
    /**
88 20
     * @internal
89 20
     */
90 20
    public static function reset(): void
91
    {
92 20
        self::$classesMap = [];
93 20
        self::$providers = [];
94
        self::$classAnnotationsCache = [];
95 20
        self::$annotationReader = null;
96 20
    }
97 20
98
    /**
99
     * Process a file.
100
     *
101
     * @throws InvalidArgumentException
102
     * @throws ReflectionException
103
     */
104
    private static function processFile(SplFileInfo $file, ContainerBuilder $container, array $configs, bool $preProcess): array
105
    {
106
        self::$doctrineMapping = $configs['doctrine']['types_mapping'];
107
        $container->addResource(new FileResource($file->getRealPath()));
108 20
109 8
        try {
110 8
            $className = $file->getBasename('.php');
111
            if (preg_match('#namespace (.+);#', file_get_contents($file->getRealPath()), $matches)) {
112
                $className = trim($matches[1]).'\\'.$className;
113
            }
114
            [$reflectionEntity, $classAnnotations, $properties, $methods] = self::extractClassAnnotations($className);
115
            $gqlTypes = [];
116
117 20
            foreach ($classAnnotations as $classAnnotation) {
118
                $gqlTypes = self::classAnnotationsToGQLConfiguration(
119
                    $reflectionEntity,
120
                    $classAnnotation,
121
                    $configs,
122
                    $classAnnotations,
123
                    $properties,
124
                    $methods,
125
                    $gqlTypes,
126
                    $preProcess
127 20
                );
128
            }
129
130 20
            return $preProcess ? self::$classesMap : $gqlTypes;
131 20
        } catch (\InvalidArgumentException $e) {
132 20
            throw new InvalidArgumentException(sprintf('Failed to parse GraphQL annotations from file "%s".', $file), $e->getCode(), $e);
133 20
        }
134 20
    }
135 20
136
    private static function classAnnotationsToGQLConfiguration(
137
        ReflectionClass $reflectionEntity,
138 20
        object $classAnnotation,
139 19
        array $configs,
140
        array $classAnnotations,
141
        array $properties,
142
        array $methods,
143 19
        array $gqlTypes,
144
        bool $preProcess
145
    ): array {
146
        $gqlConfiguration = $gqlType = $gqlName = null;
147 19
148 19
        switch (true) {
149 19
            case $classAnnotation instanceof GQL\Type:
150 19
                $gqlType = self::GQL_TYPE;
151 19
                $gqlName = $classAnnotation->name ?: $reflectionEntity->getShortName();
152
                if (!$preProcess) {
153
                    $gqlConfiguration = self::typeAnnotationToGQLConfiguration(
154 19
                        $reflectionEntity, $classAnnotation, $gqlName, $classAnnotations, $properties, $methods, $configs
155
                    );
156
157
                    if ($classAnnotation instanceof GQL\Relay\Connection) {
158
                        if (!$reflectionEntity->implementsInterface(ConnectionInterface::class)) {
159 19
                            throw new InvalidArgumentException(sprintf('The annotation @Connection on class "%s" can only be used on class implementing the ConnectionInterface.', $reflectionEntity->getName()));
160 19
                        }
161
162 19
                        if (!($classAnnotation->edge xor $classAnnotation->node)) {
163
                            throw new InvalidArgumentException(sprintf('The annotation @Connection on class "%s" is invalid. You must define the "edge" OR the "node" attribute.', $reflectionEntity->getName()));
164
                        }
165 20
166
                        $edgeType = $classAnnotation->edge;
167 19
                        if (!$edgeType) {
168 19
                            $edgeType = sprintf('%sEdge', $gqlName);
169 19
                            $gqlTypes[$edgeType] = [
170 19
                                'type' => 'object',
171 19
                                'config' => [
172 19
                                    'builders' => [
173
                                        ['builder' => 'relay-edge', 'builderConfig' => ['nodeType' => $classAnnotation->node]],
174
                                    ],
175 19
                                ],
176
                            ];
177 19
                        }
178 19
                        if (!isset($gqlConfiguration['config']['builders'])) {
179 19
                            $gqlConfiguration['config']['builders'] = [];
180 19
                        }
181 19
                        array_unshift($gqlConfiguration['config']['builders'], ['builder' => 'relay-connection', 'builderConfig' => ['edgeType' => $edgeType]]);
182
                    }
183
                }
184 19
                break;
185
186 19
            case $classAnnotation instanceof GQL\Input:
187 19
                $gqlType = self::GQL_INPUT;
188 19
                $gqlName = $classAnnotation->name ?: self::suffixName($reflectionEntity->getShortName(), 'Input');
189 19
                if (!$preProcess) {
190 19
                    $gqlConfiguration = self::inputAnnotationToGQLConfiguration(
191
                        $classAnnotation, $classAnnotations, $properties, $reflectionEntity->getNamespaceName()
192
                    );
193 19
                }
194
                break;
195 19
196 19
            case $classAnnotation instanceof GQL\Scalar:
197 19
                $gqlType = self::GQL_SCALAR;
198 19
                if (!$preProcess) {
199 19
                    $gqlConfiguration = self::scalarAnnotationToGQLConfiguration(
200
                        $reflectionEntity->getName(), $classAnnotation, $classAnnotations
201
                    );
202 19
                }
203
                break;
204 19
205 19
            case $classAnnotation instanceof GQL\Enum:
206 19
                $gqlType = self::GQL_ENUM;
207 19
                if (!$preProcess) {
208 19
                    $gqlConfiguration = self::enumAnnotationToGQLConfiguration(
209
                        $classAnnotation, $classAnnotations, $reflectionEntity->getConstants()
210
                    );
211 19
                }
212
                break;
213 19
214 19
            case $classAnnotation instanceof GQL\Union:
215 19
                $gqlType = self::GQL_UNION;
216
                if (!$preProcess) {
217 19
                    $gqlConfiguration = self::unionAnnotationToGQLConfiguration(
218
                        $reflectionEntity->getName(), $classAnnotation, $classAnnotations, $methods
219
                    );
220 20
                }
221 20
                break;
222 19
223
            case $classAnnotation instanceof GQL\TypeInterface:
224
                $gqlType = self::GQL_INTERFACE;
225 20
                if (!$preProcess) {
226 20
                    $gqlConfiguration = self::typeInterfaceAnnotationToGQLConfiguration(
227 1
                        $classAnnotation, $classAnnotations, $properties, $methods, $reflectionEntity->getNamespaceName()
228
                    );
229 20
                }
230
                break;
231 20
232
            case $classAnnotation instanceof GQL\Provider:
233
                if ($preProcess) {
234
                    self::$providers[$reflectionEntity->getName()] = ['annotation' => $classAnnotation, 'methods' => $methods, 'annotations' => $classAnnotations];
235 20
                }
236
                break;
237
        }
238 20
239
        if (null !== $gqlType) {
240 20
            if (!$gqlName) {
241 20
                $gqlName = $classAnnotation->name ?: $reflectionEntity->getShortName();
0 ignored issues
show
Bug introduced by
The property name does not seem to exist on Overblog\GraphQLBundle\Annotation\Provider.
Loading history...
242 20
            }
243 20
244
            if ($preProcess) {
245 20
                if (isset(self::$classesMap[$gqlName])) {
246 20
                    throw new InvalidArgumentException(sprintf('The GraphQL type "%s" has already been registered in class "%s"', $gqlName, self::$classesMap[$gqlName]['class']));
247
                }
248 20
                self::$classesMap[$gqlName] = ['type' => $gqlType, 'class' => $reflectionEntity->getName()];
249 19
            } else {
250 19
                $gqlTypes = [$gqlName => $gqlConfiguration] + $gqlTypes;
251
            }
252 19
        }
253
254 20
        return $gqlTypes;
255
    }
256 20
257 20
    /**
258 19
     * @throws ReflectionException
259
     */
260
    private static function extractClassAnnotations(string $className): array
261 20
    {
262
        if (!isset(self::$classAnnotationsCache[$className])) {
263
            $annotationReader = self::getAnnotationReader();
264 20
            $reflectionEntity = new ReflectionClass($className); // @phpstan-ignore-line
265
            $classAnnotations = $annotationReader->getClassAnnotations($reflectionEntity);
266
267 20
            $properties = [];
268
            $reflectionClass = new ReflectionClass($className); // @phpstan-ignore-line
269
            do {
270
                foreach ($reflectionClass->getProperties() as $property) {
271
                    if (isset($properties[$property->getName()])) {
272
                        continue;
273
                    }
274
                    $properties[$property->getName()] = ['property' => $property, 'annotations' => $annotationReader->getPropertyAnnotations($property)];
275
                }
276 20
            } while ($reflectionClass = $reflectionClass->getParentClass());
277 20
278 20
            $methods = [];
279 20
            foreach ($reflectionEntity->getMethods() as $method) {
280 20
                $methods[$method->getName()] = ['method' => $method, 'annotations' => $annotationReader->getMethodAnnotations($method)];
281
            }
282 20
283 20
            self::$classAnnotationsCache[$className] = [$reflectionEntity, $classAnnotations, $properties, $methods];
284 20
        }
285
286 20
        return self::$classAnnotationsCache[$className];
287 19
    }
288
289
    private static function typeAnnotationToGQLConfiguration(
290 19
        ReflectionClass $reflectionEntity,
291 19
        GQL\Type $classAnnotation,
292
        string $gqlName,
293 19
        array $classAnnotations,
294
        array $properties,
295
        array $methods,
296 20
        array $configs
297
    ): array {
298
        $rootQueryType = $configs['definitions']['schema']['default']['query'] ?? null;
299 20
        $rootMutationType = $configs['definitions']['schema']['default']['mutation'] ?? null;
300
        $isRootQuery = ($rootQueryType && $gqlName === $rootQueryType);
301 20
        $isRootMutation = ($rootMutationType && $gqlName === $rootMutationType);
302 20
        $currentValue = ($isRootQuery || $isRootMutation) ? sprintf("service('%s')", self::formatNamespaceForExpression($reflectionEntity->getName())) : 'value';
303 20
304
        $gqlConfiguration = self::graphQLTypeConfigFromAnnotation($classAnnotation, $classAnnotations, $properties, $methods, $reflectionEntity->getNamespaceName(), $currentValue);
305
        $providerFields = self::getGraphQLFieldsFromProviders($reflectionEntity->getNamespaceName(), $isRootMutation ? 'Mutation' : 'Query', $gqlName, ($isRootQuery || $isRootMutation));
306
        $gqlConfiguration['config']['fields'] = $providerFields + $gqlConfiguration['config']['fields'];
307 20
308 20
        if ($classAnnotation instanceof GQL\Relay\Edge) {
309
            if (!$reflectionEntity->implementsInterface(EdgeInterface::class)) {
310
                throw new InvalidArgumentException(sprintf('The annotation @Edge on class "%s" can only be used on class implementing the EdgeInterface.', $reflectionEntity->getName()));
311 20
            }
312
            if (!isset($gqlConfiguration['config']['builders'])) {
313
                $gqlConfiguration['config']['builders'] = [];
314 20
            }
315
            array_unshift($gqlConfiguration['config']['builders'], ['builder' => 'relay-edge', 'builderConfig' => ['nodeType' => $classAnnotation->node]]);
316 20
        }
317
318 20
        return $gqlConfiguration;
319 20
    }
320
321 20
    private static function getAnnotationReader(): AnnotationReader
322 20
    {
323
        if (null === self::$annotationReader) {
324 20
            if (!class_exists('\\Doctrine\\Common\\Annotations\\AnnotationReader') ||
325 19
                !class_exists('\\Doctrine\\Common\\Annotations\\AnnotationRegistry')) {
326
                throw new RuntimeException('In order to use graphql annotation, you need to require doctrine annotations');
327
            }
328 20
329 19
            AnnotationRegistry::registerLoader('class_exists');
0 ignored issues
show
Deprecated Code introduced by
The function Doctrine\Common\Annotati...istry::registerLoader() has been deprecated: This method is deprecated and will be removed in doctrine/annotations 2.0. Annotations will be autoloaded in 2.0. ( Ignorable by Annotation )

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

329
            /** @scrutinizer ignore-deprecated */ AnnotationRegistry::registerLoader('class_exists');

This function has been deprecated. The supplier of the function has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the function will be removed and what other function to use instead.

Loading history...
330
            self::$annotationReader = new AnnotationReader();
331
        }
332 20
333
        return self::$annotationReader;
334 19
    }
335 19
336
    private static function graphQLTypeConfigFromAnnotation(GQL\Type $typeAnnotation, array $classAnnotations, array $properties, array $methods, string $namespace, string $currentValue): array
337
    {
338 20
        $typeConfiguration = [];
339 20
340 19
        $fields = self::getGraphQLFieldsFromAnnotations($namespace, $properties, false, false, $currentValue);
341
        $fields = self::getGraphQLFieldsFromAnnotations($namespace, $methods, false, true, $currentValue) + $fields;
342
343 20
        $typeConfiguration['fields'] = $fields;
344 20
        $typeConfiguration = self::getDescriptionConfiguration($classAnnotations) + $typeConfiguration;
345 19
346
        if ($typeAnnotation->interfaces) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $typeAnnotation->interfaces of type string[] is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
347
            $typeConfiguration['interfaces'] = $typeAnnotation->interfaces;
348 20
        }
349
350
        if ($typeAnnotation->resolveField) {
351
            $typeConfiguration['resolveField'] = self::formatExpression($typeAnnotation->resolveField);
352
        }
353
354
        if ($typeAnnotation->builders && !empty($typeAnnotation->builders)) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $typeAnnotation->builders of type Overblog\GraphQLBundle\Annotation\FieldsBuilder[] is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
355
            $typeConfiguration['builders'] = array_map(function ($fieldsBuilderAnnotation) {
356 19
                return ['builder' => $fieldsBuilderAnnotation->builder, 'builderConfig' => $fieldsBuilderAnnotation->builderConfig];
357
            }, $typeAnnotation->builders);
358 19
        }
359
360 19
        $publicAnnotation = self::getFirstAnnotationMatching($classAnnotations, GQL\IsPublic::class);
361 19
        if ($publicAnnotation) {
362
            $typeConfiguration['fieldsDefaultPublic'] = self::formatExpression($publicAnnotation->value);
363 19
        }
364 19
365
        $accessAnnotation = self::getFirstAnnotationMatching($classAnnotations, GQL\Access::class);
366 19
        if ($accessAnnotation) {
367
            $typeConfiguration['fieldsDefaultAccess'] = self::formatExpression($accessAnnotation->value);
368 19
        }
369
370
        return ['type' => $typeAnnotation->isRelay ? 'relay-mutation-payload' : 'object', 'config' => $typeConfiguration];
371
    }
372
373
    /**
374 19
     * Create a GraphQL Interface type configuration from annotations on properties.
375
     */
376 19
    private static function typeInterfaceAnnotationToGQLConfiguration(GQL\TypeInterface $interfaceAnnotation, array $classAnnotations, array $properties, array $methods, string $namespace): array
377 19
    {
378
        $interfaceConfiguration = [];
379 19
380 19
        $fields = self::getGraphQLFieldsFromAnnotations($namespace, $properties);
381
        $fields = self::getGraphQLFieldsFromAnnotations($namespace, $methods, false, true) + $fields;
382 19
383
        $interfaceConfiguration['fields'] = $fields;
384
        $interfaceConfiguration = self::getDescriptionConfiguration($classAnnotations) + $interfaceConfiguration;
385
386
        $interfaceConfiguration['resolveType'] = self::formatExpression($interfaceAnnotation->resolveType);
387
388 19
        return ['type' => 'interface', 'config' => $interfaceConfiguration];
389
    }
390 19
391
    /**
392 19
     * Create a GraphQL Input type configuration from annotations on properties.
393 19
     */
394
    private static function inputAnnotationToGQLConfiguration(GQL\Input $inputAnnotation, array $classAnnotations, array $properties, string $namespace): array
395
    {
396 19
        $inputConfiguration = [];
397 19
        $fields = self::getGraphQLFieldsFromAnnotations($namespace, $properties, true);
398 19
399
        $inputConfiguration['fields'] = $fields;
400
        $inputConfiguration = self::getDescriptionConfiguration($classAnnotations) + $inputConfiguration;
401
402 19
        return ['type' => $inputAnnotation->isRelay ? 'relay-mutation-input' : 'input-object', 'config' => $inputConfiguration];
403
    }
404 19
405
    /**
406
     * Get a GraphQL scalar configuration from given scalar annotation.
407
     */
408
    private static function scalarAnnotationToGQLConfiguration(string $className, GQL\Scalar $scalarAnnotation, array $classAnnotations): array
409
    {
410 19
        $scalarConfiguration = [];
411
412 19
        if ($scalarAnnotation->scalarType) {
413
            $scalarConfiguration['scalarType'] = self::formatExpression($scalarAnnotation->scalarType);
414 19
        } else {
415
            $scalarConfiguration = [
416 19
                'serialize' => [$className, 'serialize'],
417
                'parseValue' => [$className, 'parseValue'],
418 19
                'parseLiteral' => [$className, 'parseLiteral'],
419 19
            ];
420 19
        }
421 19
422
        $scalarConfiguration = self::getDescriptionConfiguration($classAnnotations) + $scalarConfiguration;
423 19
424 19
        return ['type' => 'custom-scalar', 'config' => $scalarConfiguration];
425
    }
426
427 19
    /**
428 19
     * Get a GraphQL Enum configuration from given enum annotation.
429
     */
430
    private static function enumAnnotationToGQLConfiguration(GQL\Enum $enumAnnotation, array $classAnnotations, array $constants): array
431 19
    {
432
        $enumValues = $enumAnnotation->values ? $enumAnnotation->values : [];
433
434 19
        $values = [];
435 19
436
        foreach ($constants as $name => $value) {
437 19
            $valueAnnotation = current(array_filter($enumValues, function ($enumValueAnnotation) use ($name) {
438
                return $enumValueAnnotation->name == $name;
439
            }));
440
            $valueConfig = [];
441
            $valueConfig['value'] = $value;
442
443 19
            if ($valueAnnotation && $valueAnnotation->description) {
444
                $valueConfig['description'] = $valueAnnotation->description;
445 19
            }
446 19
447
            if ($valueAnnotation && $valueAnnotation->deprecationReason) {
448 19
                $valueConfig['deprecationReason'] = $valueAnnotation->deprecationReason;
449 19
            }
450
451 19
            $values[$name] = $valueConfig;
452 19
        }
453 19
454 19
        $enumConfiguration = ['values' => $values];
455
        $enumConfiguration = self::getDescriptionConfiguration($classAnnotations) + $enumConfiguration;
456 19
457
        return ['type' => 'enum', 'config' => $enumConfiguration];
458
    }
459 1
460
    /**
461
     * Get a GraphQL Union configuration from given union annotation.
462
     */
463 19
    private static function unionAnnotationToGQLConfiguration(string $className, GQL\Union $unionAnnotation, array $classAnnotations, array $methods): array
464
    {
465
        $unionConfiguration = ['types' => $unionAnnotation->types];
466
        $unionConfiguration = self::getDescriptionConfiguration($classAnnotations) + $unionConfiguration;
467
468
        if ($unionAnnotation->resolveType) {
469 20
            $unionConfiguration['resolveType'] = self::formatExpression($unionAnnotation->resolveType);
470
        } else {
471 20
            if (isset($methods['resolveType'])) {
472 20
                $method = $methods['resolveType']['method'];
473 19
                if ($method->isStatic() && $method->isPublic()) {
474 19
                    $unionConfiguration['resolveType'] = self::formatExpression(sprintf("@=call('%s::%s', [service('overblog_graphql.type_resolver'), value], true)", self::formatNamespaceForExpression($className), 'resolveType'));
475
                } else {
476 19
                    throw new InvalidArgumentException(sprintf('The "resolveType()" method on class must be static and public. Or you must define a "resolveType" attribute on the @Union annotation.'));
477 19
                }
478 19
            } else {
479
                throw new InvalidArgumentException(sprintf('The annotation @Union has no "resolveType" attribute and the related class has no "resolveType()" public static method. You need to define of them.'));
480 19
            }
481 19
        }
482 1
483
        return ['type' => 'union', 'config' => $unionConfiguration];
484 19
    }
485
486
    /**
487 19
     * Create GraphQL fields configuration based on annotations.
488 1
     */
489
    private static function getGraphQLFieldsFromAnnotations(string $namespace, array $propertiesOrMethods, bool $isInput = false, bool $isMethod = false, string $currentValue = 'value', string $fieldAnnotationName = 'Field'): array
490
    {
491
        $fields = [];
492 19
        foreach ($propertiesOrMethods as $target => $config) {
493
            $annotations = $config['annotations'];
494
            $method = $isMethod ? $config['method'] : false;
495
496 19
            $fieldAnnotation = self::getFirstAnnotationMatching($annotations, sprintf('Overblog\GraphQLBundle\Annotation\%s', $fieldAnnotationName));
497 19
            $accessAnnotation = self::getFirstAnnotationMatching($annotations, GQL\Access::class);
498 19
            $publicAnnotation = self::getFirstAnnotationMatching($annotations, GQL\IsPublic::class);
499 19
500 19
            if (!$fieldAnnotation) {
501 19
                if ($accessAnnotation || $publicAnnotation) {
502
                    throw new InvalidArgumentException(sprintf('The annotations "@Access" and/or "@Visible" defined on "%s" are only usable in addition of annotation "@Field"', $target));
503
                }
504
                continue;
505 19
            }
506
507
            if ($isMethod && !$method->isPublic()) {
508 19
                throw new InvalidArgumentException(sprintf('The Annotation "@Field" can only be applied to public method. The method "%s" is not public.', $target));
509
            }
510 19
511 19
            // Ignore field with resolver when the type is an Input
512
            if ($fieldAnnotation->resolve && $isInput) {
513 19
                continue;
514 19
            }
515
516
            $fieldName = $target;
517 19
            $fieldType = $fieldAnnotation->type;
518
            $fieldConfiguration = [];
519 19
            if ($fieldType) {
520 19
                $resolvedType = self::resolveClassFromType($fieldType);
521
                if (null !== $resolvedType && $isInput && !in_array($resolvedType['type'], self::VALID_INPUT_TYPES)) {
522 19
                    throw new InvalidArgumentException(sprintf('The type "%s" on "%s" is a "%s" not valid on an Input @Field. Only Input, Scalar and Enum are allowed.', $fieldType, $target, $resolvedType['type']));
523 19
                }
524
525 19
                $fieldConfiguration['type'] = $fieldType;
526
            }
527
528
            $fieldConfiguration = self::getDescriptionConfiguration($annotations, true) + $fieldConfiguration;
529
530
            if (!$isInput) {
531 19
                $args = self::getArgs($fieldAnnotation->args, $isMethod && !$fieldAnnotation->argsBuilder ? $method : null);
0 ignored issues
show
Bug introduced by
It seems like $isMethod && ! $fieldAnn...uilder ? $method : null can also be of type false; however, parameter $method of Overblog\GraphQLBundle\C...tationParser::getArgs() does only seem to accept ReflectionMethod|null, 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

531
                $args = self::getArgs($fieldAnnotation->args, /** @scrutinizer ignore-type */ $isMethod && !$fieldAnnotation->argsBuilder ? $method : null);
Loading history...
532 19
533
                if (!empty($args)) {
534 19
                    $fieldConfiguration['args'] = $args;
535 19
                }
536 19
537
                $fieldName = $fieldAnnotation->name ?: $fieldName;
538
539
                if ($fieldAnnotation->resolve) {
540
                    $fieldConfiguration['resolve'] = self::formatExpression($fieldAnnotation->resolve);
541
                } else {
542 19
                    if ($isMethod) {
543 19
                        $fieldConfiguration['resolve'] = self::formatExpression(sprintf('call(%s.%s, %s)', $currentValue, $target, self::formatArgsForExpression($args)));
544
                    } else {
545 19
                        if ($fieldName !== $target || 'value' !== $currentValue) {
546 19
                            $fieldConfiguration['resolve'] = self::formatExpression(sprintf('%s.%s', $currentValue, $target));
547 19
                        }
548 19
                    }
549
                }
550 19
551
                if ($fieldAnnotation->argsBuilder) {
552
                    if (is_string($fieldAnnotation->argsBuilder)) {
553 19
                        $fieldConfiguration['argsBuilder'] = $fieldAnnotation->argsBuilder;
554 19
                    } elseif (is_array($fieldAnnotation->argsBuilder)) {
555 19
                        list($builder, $builderConfig) = $fieldAnnotation->argsBuilder;
556
                        $fieldConfiguration['argsBuilder'] = ['builder' => $builder, 'config' => $builderConfig];
557 19
                    } else {
558
                        throw new InvalidArgumentException(sprintf('The attribute "argsBuilder" on GraphQL annotation "@%s" defined on "%s" must be a string or an array where first index is the builder name and the second is the config.', $fieldAnnotationName, $target));
559 19
                    }
560
                }
561
562 19
                if ($fieldAnnotation->fieldBuilder) {
563
                    if (is_string($fieldAnnotation->fieldBuilder)) {
564
                        $fieldConfiguration['builder'] = $fieldAnnotation->fieldBuilder;
565
                    } elseif (is_array($fieldAnnotation->fieldBuilder)) {
566 19
                        list($builder, $builderConfig) = $fieldAnnotation->fieldBuilder;
567 2
                        $fieldConfiguration['builder'] = $builder;
568 2
                        $fieldConfiguration['builderConfig'] = $builderConfig ?: [];
569
                    } else {
570
                        throw new InvalidArgumentException(sprintf('The attribute "argsBuilder" on GraphQL annotation "@%s" defined on "%s" must be a string or an array where first index is the builder name and the second is the config.', $fieldAnnotationName, $target));
571
                    }
572
                } else {
573
                    if (!$fieldType) {
574 19
                        if ($isMethod) {
575 19
                            if ($method->hasReturnType()) {
576
                                try {
577
                                    $fieldConfiguration['type'] = self::resolveGraphQLTypeFromReflectionType($method->getReturnType(), self::VALID_OUTPUT_TYPES);
578 19
                                } catch (Exception $e) {
579 19
                                    throw new InvalidArgumentException(sprintf('The attribute "type" on GraphQL annotation "@%s" is missing on method "%s" and cannot be auto-guessed from type hint "%s"', $fieldAnnotationName, $target, (string) $method->getReturnType()));
580
                                }
581
                            } else {
582 19
                                throw new InvalidArgumentException(sprintf('The attribute "type" on GraphQL annotation "@%s" is missing on method "%s" and cannot be auto-guessed as there is not return type hint.', $fieldAnnotationName, $target));
583 19
                            }
584
                        } else {
585
                            try {
586
                                $fieldConfiguration['type'] = self::guessType($namespace, $annotations);
587 19
                            } catch (Exception $e) {
588
                                throw new InvalidArgumentException(sprintf('The attribute "type" on "@%s" defined on "%s" is required and cannot be auto-guessed : %s.', $fieldAnnotationName, $target, $e->getMessage()));
589
                            }
590 20
                        }
591
                    }
592
                }
593
594
                if ($accessAnnotation) {
595
                    $fieldConfiguration['access'] = self::formatExpression($accessAnnotation->value);
596
                }
597
598 20
                if ($publicAnnotation) {
599
                    $fieldConfiguration['public'] = self::formatExpression($publicAnnotation->value);
600 20
                }
601 20
602 19
                if ($fieldAnnotation->complexity) {
603 19
                    $fieldConfiguration['complexity'] = self::formatExpression($fieldAnnotation->complexity);
604 19
                }
605
            }
606 19
607 19
            $fields[$fieldName] = $fieldConfiguration;
608
        }
609 19
610 19
        return $fields;
611
    }
612 19
613 19
    /**
614 19
     * Return fields config from Provider methods.
615
     */
616 19
    private static function getGraphQLFieldsFromProviders(string $namespace, string $annotationName, string $targetType, bool $isRoot = false): array
617 19
    {
618 19
        $fields = [];
619
        foreach (self::$providers as $className => $configuration) {
620
            $providerMethods = $configuration['methods'];
621 19
            $providerAnnotation = $configuration['annotation'];
622 19
            $providerAnnotations = $configuration['annotations'];
623 19
624
            $defaultAccessAnnotation = self::getFirstAnnotationMatching($providerAnnotations, GQL\Access::class);
625
            $defaultIsPublicAnnotation = self::getFirstAnnotationMatching($providerAnnotations, GQL\IsPublic::class);
626 19
627 19
            $defaultAccess = $defaultAccessAnnotation ? self::formatExpression($defaultAccessAnnotation->value) : false;
628
            $defaultIsPublic = $defaultIsPublicAnnotation ? self::formatExpression($defaultIsPublicAnnotation->value) : false;
629
630 19
            $filteredMethods = [];
631
            foreach ($providerMethods as $methodName => $config) {
632
                $annotations = $config['annotations'];
633 19
634 19
                $annotation = self::getFirstAnnotationMatching($annotations, sprintf('Overblog\\GraphQLBundle\\Annotation\\%s', $annotationName));
635 19
                if (!$annotation) {
636 19
                    continue;
637 19
                }
638
639
                $annotationTarget = 'Query' === $annotationName ? $annotation->targetType : null;
640 19
                if (!$annotationTarget && $isRoot) {
641 19
                    $annotationTarget = $targetType;
642
                }
643
644 19
                if ($annotationTarget !== $targetType) {
645 19
                    continue;
646
                }
647
648 19
                $filteredMethods[$methodName] = $config;
649
            }
650
651
            $currentValue = sprintf("service('%s')", self::formatNamespaceForExpression($className));
652 20
            $providerFields = self::getGraphQLFieldsFromAnnotations($namespace, $filteredMethods, false, true, $currentValue, $annotationName);
653
            foreach ($providerFields as $fieldName => $fieldConfig) {
654
                if ($providerAnnotation->prefix) {
655
                    $fieldName = sprintf('%s%s', $providerAnnotation->prefix, $fieldName);
656
                }
657
658 20
                if ($defaultAccess && !isset($fieldConfig['access'])) {
659
                    $fieldConfig['access'] = $defaultAccess;
660 20
                }
661 20
662 20
                if ($defaultIsPublic && !isset($fieldConfig['public'])) {
663 19
                    $fieldConfig['public'] = $defaultIsPublic;
664
                }
665
666 20
                $fields[$fieldName] = $fieldConfig;
667 19
            }
668 19
        }
669 19
670
        return $fields;
671
    }
672
673 20
    /**
674
     * Get the config for description & deprecation reason.
675
     */
676
    private static function getDescriptionConfiguration(array $annotations, bool $withDeprecation = false): array
677
    {
678
        $config = [];
679
        $descriptionAnnotation = self::getFirstAnnotationMatching($annotations, GQL\Description::class);
680
        if ($descriptionAnnotation) {
681
            $config['description'] = $descriptionAnnotation->value;
682 19
        }
683
684 19
        if ($withDeprecation) {
685 19
            $deprecatedAnnotation = self::getFirstAnnotationMatching($annotations, GQL\Deprecated::class);
686 19
            if ($deprecatedAnnotation) {
687 19
                $config['deprecationReason'] = $deprecatedAnnotation->value;
688 19
            }
689 19
        }
690
691 19
        return $config;
692 19
    }
693
694
    /**
695 19
     * Get args config from an array of @Arg annotation or by auto-guessing if a method is provided.
696
     */
697
    private static function getArgs(?array $args, ReflectionMethod $method = null): array
698
    {
699
        $config = [];
700
        if (!empty($args)) {
701 19
            foreach ($args as $arg) {
702
                $config[$arg->name] = ['type' => $arg->type]
703 19
                    + ($arg->description ? ['description' => $arg->description] : [])
704 19
                    + ($arg->default ? ['defaultValue' => $arg->default] : []);
705 19
            }
706
        } elseif ($method) {
707
            $config = self::guessArgs($method);
708 19
        }
709
710
        return $config;
711
    }
712
713
    /**
714 19
     * Format an array of args to a list of arguments in an expression.
715
     */
716 19
    private static function formatArgsForExpression(array $args): string
717
    {
718
        $mapping = [];
719
        foreach ($args as $name => $config) {
720
            $mapping[] = sprintf('%s: "%s"', $name, $config['type']);
721
        }
722
723
        return sprintf('arguments({%s}, args)', implode(', ', $mapping));
724
    }
725
726 20
    /**
727
     * Format a namespace to be used in an expression (double escape).
728 20
     */
729 20
    private static function formatNamespaceForExpression(string $namespace): string
730
    {
731
        return str_replace('\\', '\\\\', $namespace);
732 20
    }
733 20
734 20
    /**
735 19
     * Get the first annotation matching given class.
736
     *
737
     * @param string|array $annotationClass
738
     *
739
     * @return mixed
740 20
     */
741
    private static function getFirstAnnotationMatching(array $annotations, $annotationClass)
742
    {
743
        if (is_string($annotationClass)) {
744
            $annotationClass = [$annotationClass];
745
        }
746
747
        foreach ($annotations as $annotation) {
748 19
            foreach ($annotationClass as $class) {
749
                if ($annotation instanceof $class) {
750 19
                    return $annotation;
751
                }
752
            }
753
        }
754
755
        return false;
756
    }
757
758 19
    /**
759
     * Format an expression (ie. add "@=" if not set).
760 19
     */
761
    private static function formatExpression(string $expression): string
762
    {
763
        return '@=' === substr($expression, 0, 2) ? $expression : sprintf('@=%s', $expression);
764
    }
765
766
    /**
767
     * Suffix a name if it is not already.
768 19
     */
769
    private static function suffixName(string $name, string $suffix): string
770 19
    {
771 19
        return substr($name, -strlen($suffix)) === $suffix ? $name : sprintf('%s%s', $name, $suffix);
772 19
    }
773 19
774 19
    /**
775 19
     * Try to guess a field type base on is annotations.
776
     *
777 1
     * @throws RuntimeException
778
     */
779
    private static function guessType(string $namespace, array $annotations): string
780
    {
781
        $columnAnnotation = self::getFirstAnnotationMatching($annotations, Column::class);
782 19
        if ($columnAnnotation) {
783
            $type = self::resolveTypeFromDoctrineType($columnAnnotation->type);
784
            $nullable = $columnAnnotation->nullable;
785
            if ($type) {
786
                return $nullable ? $type : sprintf('%s!', $type);
787
            } else {
788 19
                throw new RuntimeException(sprintf('Unable to auto-guess GraphQL type from Doctrine type "%s"', $columnAnnotation->type));
789 19
            }
790 19
        }
791 19
792
        $associationAnnotations = [
793 19
            OneToMany::class => true,
794 19
            OneToOne::class => false,
795 19
            ManyToMany::class => true,
796 19
            ManyToOne::class => false,
797
        ];
798 19
799 19
        $associationAnnotation = self::getFirstAnnotationMatching($annotations, array_keys($associationAnnotations));
800 19
        if ($associationAnnotation) {
801 19
            $target = self::fullyQualifiedClassName($associationAnnotation->targetEntity, $namespace);
802
            $type = self::resolveTypeFromClass($target, ['type']);
803
804 19
            if ($type) {
805
                $isMultiple = $associationAnnotations[get_class($associationAnnotation)];
806
                if ($isMultiple) {
807 1
                    return sprintf('[%s]!', $type);
808
                } else {
809
                    $isNullable = false;
810
                    $joinColumn = self::getFirstAnnotationMatching($annotations, JoinColumn::class);
811
                    if ($joinColumn) {
812
                        $isNullable = $joinColumn->nullable;
813
                    }
814
815
                    return sprintf('%s%s', $type, $isNullable ? '' : '!');
816
                }
817
            } else {
818
                throw new RuntimeException(sprintf('Unable to auto-guess GraphQL type from Doctrine target class "%s" (check if the target class is a GraphQL type itself (with a @GQL\Type annotation).', $target));
819 19
            }
820
        }
821 19
822 19
        throw new InvalidArgumentException(sprintf('No Doctrine ORM annotation found.'));
823
    }
824
825 1
    /**
826
     * Resolve a FQN from classname and namespace.
827
     *
828
     * @internal
829
     */
830
    public static function fullyQualifiedClassName(string $className, string $namespace): string
831 19
    {
832
        if (false === strpos($className, '\\') && $namespace) {
833 19
            return $namespace.'\\'.$className;
834 19
        }
835
836
        return $className;
837
    }
838 19
839 19
    /**
840 19
     * Resolve a GraphQLType from a doctrine type.
841 19
     */
842 19
    private static function resolveTypeFromDoctrineType(string $doctrineType): ?string
843 1
    {
844 19
        if (isset(self::$doctrineMapping[$doctrineType])) {
845 1
            return self::$doctrineMapping[$doctrineType];
846 1
        }
847
848 1
        switch ($doctrineType) {
849 1
            case 'integer':
850
            case 'smallint':
851
            case 'bigint':
852 1
                return 'Int';
853
            case 'string':
854
            case 'text':
855
                return 'String';
856
            case 'bool':
857
            case 'boolean':
858
                return 'Boolean';
859 19
            case 'float':
860
            case 'decimal':
861 19
                return 'Float';
862 19
            default:
863 19
                return null;
864 1
        }
865
    }
866
867
    /**
868 19
     * Transform a method arguments from reflection to a list of GraphQL argument.
869
     */
870
    private static function guessArgs(ReflectionMethod $method): array
871
    {
872
        $arguments = [];
873 19
        foreach ($method->getParameters() as $index => $parameter) {
874 19
            if (!$parameter->hasType()) {
875 19
                throw new InvalidArgumentException(sprintf('Argument n°%s "$%s" on method "%s" cannot be auto-guessed as there is not type hint.', $index + 1, $parameter->getName(), $method->getName()));
876
            }
877
878 19
            try {
879
                // @phpstan-ignore-next-line
880 19
                $gqlType = self::resolveGraphQLTypeFromReflectionType($parameter->getType(), self::VALID_INPUT_TYPES, $parameter->isDefaultValueAvailable());
881
            } catch (Exception $e) {
882
                throw new InvalidArgumentException(sprintf('Argument n°%s "$%s" on method "%s" cannot be auto-guessed : %s".', $index + 1, $parameter->getName(), $method->getName(), $e->getMessage()));
883 19
            }
884
885
            $argumentConfig = [];
886 19
            if ($parameter->isDefaultValueAvailable()) {
887
                $argumentConfig['defaultValue'] = $parameter->getDefaultValue();
888 19
            }
889 19
890 19
            $argumentConfig['type'] = $gqlType;
891 19
892 19
            $arguments[$parameter->getName()] = $argumentConfig;
893
        }
894
895 19
        return $arguments;
896 19
    }
897
898
    private static function resolveGraphQLTypeFromReflectionType(ReflectionNamedType $type, array $filterGraphQLTypes = [], bool $isOptional = false): string
899
    {
900
        $sType = $type->getName();
901 19
        if ($type->isBuiltin()) {
902
            $gqlType = self::resolveTypeFromPhpType($sType);
903
            if (null === $gqlType) {
904
                throw new RuntimeException(sprintf('No corresponding GraphQL type found for builtin type "%s"', $sType));
905
            }
906
        } else {
907
            $gqlType = self::resolveTypeFromClass($sType, $filterGraphQLTypes);
908
            if (null === $gqlType) {
909 19
                throw new RuntimeException(sprintf('No corresponding GraphQL %s found for class "%s"', $filterGraphQLTypes ? implode(',', $filterGraphQLTypes) : 'object', $sType));
910
            }
911 19
        }
912 19
913 19
        return sprintf('%s%s', $gqlType, ($type->allowsNull() || $isOptional) ? '' : '!');
914 19
    }
915
916
    /**
917
     * Resolve a GraphQL Type from a class name.
918
     */
919 1
    private static function resolveTypeFromClass(string $className, array $wantedTypes = []): ?string
920
    {
921
        foreach (self::$classesMap as $gqlType => $config) {
922
            if ($config['class'] === $className) {
923
                if (in_array($config['type'], $wantedTypes)) {
924
                    return $gqlType;
925
                }
926
            }
927 19
        }
928
929 19
        return null;
930
    }
931
932
    /**
933
     * Resolve a PHP class from a GraphQL type.
934
     *
935 19
     * @return string|array|null
936
     */
937
    private static function resolveClassFromType(string $type)
938 19
    {
939 19
        return self::$classesMap[$type] ?? null;
940 19
    }
941 19
942 19
    /**
943 19
     * Convert a PHP Builtin type to a GraphQL type.
944 19
     */
945 19
    private static function resolveTypeFromPhpType(string $phpType): ?string
946 19
    {
947 19
        switch ($phpType) {
948 19
            case 'boolean':
949
            case 'bool':
950
                return 'Boolean';
951
            case 'integer':
952
            case 'int':
953
                return 'Int';
954
            case 'float':
955
            case 'double':
956
                return 'Float';
957
            case 'string':
958
                return 'String';
959
            default:
960
                return null;
961
        }
962
    }
963
}
964