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
17:55 queued 15:16
created

AnnotationParser::getFirstAnnotationMatching()   A

Complexity

Conditions 5
Paths 8

Size

Total Lines 15
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 8
CRAP Score 5

Importance

Changes 0
Metric Value
eloc 7
dl 0
loc 15
ccs 8
cts 8
cp 1
rs 9.6111
c 0
b 0
f 0
cc 5
nc 8
nop 2
crap 5
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
{
50
    private static ?AnnotationReader $annotationReader = null;
51
    private static array $classesMap = [];
52
    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
    private const GQL_UNION = 'union';
61
    private const GQL_INTERFACE = 'interface';
62
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
69
    /**
70
     * {@inheritdoc}
71
     *
72
     * @throws InvalidArgumentException
73
     */
74 20
    public static function preParse(SplFileInfo $file, ContainerBuilder $container, array $configs = []): void
75
    {
76 20
        $container->setParameter('overblog_graphql_types.classes_map', self::processFile($file, $container, $configs, true));
77 20
    }
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
    }
86
87
    /**
88
     * @internal
89
     */
90 58
    public static function reset(): void
91
    {
92 58
        self::$classesMap = [];
93 58
        self::$providers = [];
94 58
        self::$classAnnotationsCache = [];
95 58
        self::$annotationReader = null;
96 58
    }
97
98
    /**
99
     * Process a file.
100
     *
101
     * @throws InvalidArgumentException
102
     * @throws ReflectionException
103
     */
104 20
    private static function processFile(SplFileInfo $file, ContainerBuilder $container, array $configs, bool $preProcess): array
105
    {
106 20
        self::$doctrineMapping = $configs['doctrine']['types_mapping'];
107 20
        $container->addResource(new FileResource($file->getRealPath()));
108
109
        try {
110 20
            $className = $file->getBasename('.php');
111 20
            if (preg_match('#namespace (.+);#', file_get_contents($file->getRealPath()), $matches)) {
112 20
                $className = trim($matches[1]).'\\'.$className;
113
            }
114 20
            [$reflectionEntity, $classAnnotations, $properties, $methods] = self::extractClassAnnotations($className);
115 20
            $gqlTypes = [];
116
117 20
            foreach ($classAnnotations as $classAnnotation) {
118 20
                $gqlTypes = self::classAnnotationsToGQLConfiguration(
119 20
                    $reflectionEntity,
120
                    $classAnnotation,
121
                    $configs,
122
                    $classAnnotations,
123
                    $properties,
124
                    $methods,
125
                    $gqlTypes,
126
                    $preProcess
127
                );
128
            }
129
130 20
            return $preProcess ? self::$classesMap : $gqlTypes;
131 8
        } catch (\InvalidArgumentException $e) {
132 8
            throw new InvalidArgumentException(sprintf('Failed to parse GraphQL annotations from file "%s".', $file), $e->getCode(), $e);
133
        }
134
    }
135
136 20
    private static function classAnnotationsToGQLConfiguration(
137
        ReflectionClass $reflectionEntity,
138
        object $classAnnotation,
139
        array $configs,
140
        array $classAnnotations,
141
        array $properties,
142
        array $methods,
143
        array $gqlTypes,
144
        bool $preProcess
145
    ): array {
146 20
        $gqlConfiguration = $gqlType = $gqlName = null;
147
148
        switch (true) {
149 20
            case $classAnnotation instanceof GQL\Type:
150 20
                $gqlType = self::GQL_TYPE;
151 20
                $gqlName = $classAnnotation->name ?: $reflectionEntity->getShortName();
152 20
                if (!$preProcess) {
153 20
                    $gqlConfiguration = self::typeAnnotationToGQLConfiguration(
154 20
                        $reflectionEntity, $classAnnotation, $gqlName, $classAnnotations, $properties, $methods, $configs
155
                    );
156
157 20
                    if ($classAnnotation instanceof GQL\Relay\Connection) {
158 19
                        if (!$reflectionEntity->implementsInterface(ConnectionInterface::class)) {
159
                            throw new InvalidArgumentException(sprintf('The annotation @Connection on class "%s" can only be used on class implementing the ConnectionInterface.', $reflectionEntity->getName()));
160
                        }
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
166 19
                        $edgeType = $classAnnotation->edge;
167 19
                        if (!$edgeType) {
168 19
                            $edgeType = sprintf('%sEdge', $gqlName);
169 19
                            $gqlTypes[$edgeType] = [
170 19
                                'type' => 'object',
171
                                'config' => [
172
                                    'builders' => [
173 19
                                        ['builder' => 'relay-edge', 'builderConfig' => ['nodeType' => $classAnnotation->node]],
174
                                    ],
175
                                ],
176
                            ];
177
                        }
178 19
                        if (!isset($gqlConfiguration['config']['builders'])) {
179 19
                            $gqlConfiguration['config']['builders'] = [];
180
                        }
181 19
                        array_unshift($gqlConfiguration['config']['builders'], ['builder' => 'relay-connection', 'builderConfig' => ['edgeType' => $edgeType]]);
182
                    }
183
                }
184 20
                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 19
                        $classAnnotation, $classAnnotations, $properties, $reflectionEntity->getNamespaceName()
192
                    );
193
                }
194 19
                break;
195
196 19
            case $classAnnotation instanceof GQL\Scalar:
197 19
                $gqlType = self::GQL_SCALAR;
198 19
                if (!$preProcess) {
199 19
                    $gqlConfiguration = self::scalarAnnotationToGQLConfiguration(
200 19
                        $reflectionEntity->getName(), $classAnnotation, $classAnnotations
201
                    );
202
                }
203 19
                break;
204
205 19
            case $classAnnotation instanceof GQL\Enum:
206 19
                $gqlType = self::GQL_ENUM;
207 19
                if (!$preProcess) {
208 19
                    $gqlConfiguration = self::enumAnnotationToGQLConfiguration(
209 19
                        $classAnnotation, $classAnnotations, $reflectionEntity->getConstants()
210
                    );
211
                }
212 19
                break;
213
214 19
            case $classAnnotation instanceof GQL\Union:
215 19
                $gqlType = self::GQL_UNION;
216 19
                if (!$preProcess) {
217 19
                    $gqlConfiguration = self::unionAnnotationToGQLConfiguration(
218 19
                        $reflectionEntity->getName(), $classAnnotation, $classAnnotations, $methods
219
                    );
220
                }
221 19
                break;
222
223 19
            case $classAnnotation instanceof GQL\TypeInterface:
224 19
                $gqlType = self::GQL_INTERFACE;
225 19
                if (!$preProcess) {
226 19
                    $gqlConfiguration = self::typeInterfaceAnnotationToGQLConfiguration(
227 19
                        $classAnnotation, $classAnnotations, $properties, $methods, $reflectionEntity->getNamespaceName()
228
                    );
229
                }
230 19
                break;
231
232 19
            case $classAnnotation instanceof GQL\Provider:
233 19
                if ($preProcess) {
234 19
                    self::$providers[$reflectionEntity->getName()] = ['annotation' => $classAnnotation, 'methods' => $methods, 'annotations' => $classAnnotations];
235
                }
236 19
                break;
237
        }
238
239 20
        if (null !== $gqlType) {
240 20
            if (!$gqlName) {
241 19
                $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
            }
243
244 20
            if ($preProcess) {
245 20
                if (isset(self::$classesMap[$gqlName])) {
246 1
                    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
            } else {
250 20
                $gqlTypes = [$gqlName => $gqlConfiguration] + $gqlTypes;
251
            }
252
        }
253
254 20
        return $gqlTypes;
255
    }
256
257
    /**
258
     * @throws ReflectionException
259
     */
260 20
    private static function extractClassAnnotations(string $className): array
261
    {
262 20
        if (!isset(self::$classAnnotationsCache[$className])) {
263 20
            $annotationReader = self::getAnnotationReader();
264 20
            $reflectionEntity = new ReflectionClass($className); // @phpstan-ignore-line
265 20
            $classAnnotations = $annotationReader->getClassAnnotations($reflectionEntity);
266
267 20
            $properties = [];
268 20
            $reflectionClass = new ReflectionClass($className); // @phpstan-ignore-line
269
            do {
270 20
                foreach ($reflectionClass->getProperties() as $property) {
271 19
                    if (isset($properties[$property->getName()])) {
272 19
                        continue;
273
                    }
274 19
                    $properties[$property->getName()] = ['property' => $property, 'annotations' => $annotationReader->getPropertyAnnotations($property)];
275
                }
276 20
            } while ($reflectionClass = $reflectionClass->getParentClass());
277
278 20
            $methods = [];
279 20
            foreach ($reflectionEntity->getMethods() as $method) {
280 19
                $methods[$method->getName()] = ['method' => $method, 'annotations' => $annotationReader->getMethodAnnotations($method)];
281
            }
282
283 20
            self::$classAnnotationsCache[$className] = [$reflectionEntity, $classAnnotations, $properties, $methods];
284
        }
285
286 20
        return self::$classAnnotationsCache[$className];
287
    }
288
289 20
    private static function typeAnnotationToGQLConfiguration(
290
        ReflectionClass $reflectionEntity,
291
        GQL\Type $classAnnotation,
292
        string $gqlName,
293
        array $classAnnotations,
294
        array $properties,
295
        array $methods,
296
        array $configs
297
    ): array {
298 20
        $rootQueryType = $configs['definitions']['schema']['default']['query'] ?? null;
299 20
        $rootMutationType = $configs['definitions']['schema']['default']['mutation'] ?? null;
300 20
        $isRootQuery = ($rootQueryType && $gqlName === $rootQueryType);
301 20
        $isRootMutation = ($rootMutationType && $gqlName === $rootMutationType);
302 20
        $currentValue = ($isRootQuery || $isRootMutation) ? sprintf("service('%s')", self::formatNamespaceForExpression($reflectionEntity->getName())) : 'value';
303
304 20
        $gqlConfiguration = self::graphQLTypeConfigFromAnnotation($classAnnotation, $classAnnotations, $properties, $methods, $reflectionEntity->getNamespaceName(), $currentValue);
305 20
        $providerFields = self::getGraphQLFieldsFromProviders($reflectionEntity->getNamespaceName(), $isRootMutation ? 'Mutation' : 'Query', $gqlName, ($isRootQuery || $isRootMutation));
306 20
        $gqlConfiguration['config']['fields'] = $providerFields + $gqlConfiguration['config']['fields'];
307
308 20
        if ($classAnnotation instanceof GQL\Relay\Edge) {
309 19
            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
            }
312 19
            if (!isset($gqlConfiguration['config']['builders'])) {
313 19
                $gqlConfiguration['config']['builders'] = [];
314
            }
315 19
            array_unshift($gqlConfiguration['config']['builders'], ['builder' => 'relay-edge', 'builderConfig' => ['nodeType' => $classAnnotation->node]]);
316
        }
317
318 20
        return $gqlConfiguration;
319
    }
320
321 20
    private static function getAnnotationReader(): AnnotationReader
322
    {
323 20
        if (null === self::$annotationReader) {
324 20
            if (!class_exists('\\Doctrine\\Common\\Annotations\\AnnotationReader') ||
325 20
                !class_exists('\\Doctrine\\Common\\Annotations\\AnnotationRegistry')) {
326
                throw new RuntimeException('In order to use graphql annotation, you need to require doctrine annotations');
327
            }
328
329 20
            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 20
            self::$annotationReader = new AnnotationReader();
331
        }
332
333 20
        return self::$annotationReader;
334
    }
335
336 20
    private static function graphQLTypeConfigFromAnnotation(GQL\Type $typeAnnotation, array $classAnnotations, array $properties, array $methods, string $namespace, string $currentValue): array
337
    {
338 20
        $typeConfiguration = [];
339
340 20
        $fields = self::getGraphQLFieldsFromAnnotations($namespace, $properties, false, false, $currentValue);
341 20
        $fields = self::getGraphQLFieldsFromAnnotations($namespace, $methods, false, true, $currentValue) + $fields;
342
343 20
        $typeConfiguration['fields'] = $fields;
344 20
        $typeConfiguration = self::getDescriptionConfiguration($classAnnotations) + $typeConfiguration;
345
346 20
        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 19
            $typeConfiguration['interfaces'] = $typeAnnotation->interfaces;
348
        }
349
350 20
        if ($typeAnnotation->resolveField) {
351 19
            $typeConfiguration['resolveField'] = self::formatExpression($typeAnnotation->resolveField);
352
        }
353
354 20
        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 19
            }, $typeAnnotation->builders);
358
        }
359
360 20
        $publicAnnotation = self::getFirstAnnotationMatching($classAnnotations, GQL\IsPublic::class);
361 20
        if ($publicAnnotation) {
362 19
            $typeConfiguration['fieldsDefaultPublic'] = self::formatExpression($publicAnnotation->value);
363
        }
364
365 20
        $accessAnnotation = self::getFirstAnnotationMatching($classAnnotations, GQL\Access::class);
366 20
        if ($accessAnnotation) {
367 19
            $typeConfiguration['fieldsDefaultAccess'] = self::formatExpression($accessAnnotation->value);
368
        }
369
370 20
        return ['type' => $typeAnnotation->isRelay ? 'relay-mutation-payload' : 'object', 'config' => $typeConfiguration];
371
    }
372
373
    /**
374
     * 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
    {
378 19
        $interfaceConfiguration = [];
379
380 19
        $fields = self::getGraphQLFieldsFromAnnotations($namespace, $properties);
381 19
        $fields = self::getGraphQLFieldsFromAnnotations($namespace, $methods, false, true) + $fields;
382
383 19
        $interfaceConfiguration['fields'] = $fields;
384 19
        $interfaceConfiguration = self::getDescriptionConfiguration($classAnnotations) + $interfaceConfiguration;
385
386 19
        $interfaceConfiguration['resolveType'] = self::formatExpression($interfaceAnnotation->resolveType);
387
388 19
        return ['type' => 'interface', 'config' => $interfaceConfiguration];
389
    }
390
391
    /**
392
     * Create a GraphQL Input type configuration from annotations on properties.
393
     */
394 19
    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
399 19
        $inputConfiguration['fields'] = $fields;
400 19
        $inputConfiguration = self::getDescriptionConfiguration($classAnnotations) + $inputConfiguration;
401
402 19
        return ['type' => $inputAnnotation->isRelay ? 'relay-mutation-input' : 'input-object', 'config' => $inputConfiguration];
403
    }
404
405
    /**
406
     * Get a GraphQL scalar configuration from given scalar annotation.
407
     */
408 19
    private static function scalarAnnotationToGQLConfiguration(string $className, GQL\Scalar $scalarAnnotation, array $classAnnotations): array
409
    {
410 19
        $scalarConfiguration = [];
411
412 19
        if ($scalarAnnotation->scalarType) {
413 19
            $scalarConfiguration['scalarType'] = self::formatExpression($scalarAnnotation->scalarType);
414
        } else {
415
            $scalarConfiguration = [
416 19
                'serialize' => [$className, 'serialize'],
417 19
                'parseValue' => [$className, 'parseValue'],
418 19
                'parseLiteral' => [$className, 'parseLiteral'],
419
            ];
420
        }
421
422 19
        $scalarConfiguration = self::getDescriptionConfiguration($classAnnotations) + $scalarConfiguration;
423
424 19
        return ['type' => 'custom-scalar', 'config' => $scalarConfiguration];
425
    }
426
427
    /**
428
     * Get a GraphQL Enum configuration from given enum annotation.
429
     */
430 19
    private static function enumAnnotationToGQLConfiguration(GQL\Enum $enumAnnotation, array $classAnnotations, array $constants): array
431
    {
432 19
        $enumValues = $enumAnnotation->values ? $enumAnnotation->values : [];
433
434 19
        $values = [];
435
436 19
        foreach ($constants as $name => $value) {
437
            $valueAnnotation = current(array_filter($enumValues, function ($enumValueAnnotation) use ($name) {
438 19
                return $enumValueAnnotation->name == $name;
439 19
            }));
440 19
            $valueConfig = [];
441 19
            $valueConfig['value'] = $value;
442
443 19
            if ($valueAnnotation && $valueAnnotation->description) {
444 19
                $valueConfig['description'] = $valueAnnotation->description;
445
            }
446
447 19
            if ($valueAnnotation && $valueAnnotation->deprecationReason) {
448 19
                $valueConfig['deprecationReason'] = $valueAnnotation->deprecationReason;
449
            }
450
451 19
            $values[$name] = $valueConfig;
452
        }
453
454 19
        $enumConfiguration = ['values' => $values];
455 19
        $enumConfiguration = self::getDescriptionConfiguration($classAnnotations) + $enumConfiguration;
456
457 19
        return ['type' => 'enum', 'config' => $enumConfiguration];
458
    }
459
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 19
        $unionConfiguration = ['types' => $unionAnnotation->types];
466 19
        $unionConfiguration = self::getDescriptionConfiguration($classAnnotations) + $unionConfiguration;
467
468 19
        if ($unionAnnotation->resolveType) {
469 19
            $unionConfiguration['resolveType'] = self::formatExpression($unionAnnotation->resolveType);
470
        } else {
471 19
            if (isset($methods['resolveType'])) {
472 19
                $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
                }
478
            } else {
479 1
                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
            }
481
        }
482
483 19
        return ['type' => 'union', 'config' => $unionConfiguration];
484
    }
485
486
    /**
487
     * Create GraphQL fields configuration based on annotations.
488
     */
489 20
    private static function getGraphQLFieldsFromAnnotations(string $namespace, array $propertiesOrMethods, bool $isInput = false, bool $isMethod = false, string $currentValue = 'value', string $fieldAnnotationName = 'Field'): array
490
    {
491 20
        $fields = [];
492 20
        foreach ($propertiesOrMethods as $target => $config) {
493 19
            $annotations = $config['annotations'];
494 19
            $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
500 19
            if (!$fieldAnnotation) {
501 19
                if ($accessAnnotation || $publicAnnotation) {
502 1
                    throw new InvalidArgumentException(sprintf('The annotations "@Access" and/or "@Visible" defined on "%s" are only usable in addition of annotation "@Field"', $target));
503
                }
504 19
                continue;
505
            }
506
507 19
            if ($isMethod && !$method->isPublic()) {
508 1
                throw new InvalidArgumentException(sprintf('The Annotation "@Field" can only be applied to public method. The method "%s" is not public.', $target));
509
            }
510
511
            // Ignore field with resolver when the type is an Input
512 19
            if ($fieldAnnotation->resolve && $isInput) {
513
                continue;
514
            }
515
516 19
            $fieldName = $target;
517 19
            $fieldType = $fieldAnnotation->type;
518 19
            $fieldConfiguration = [];
519 19
            if ($fieldType) {
520 19
                $resolvedType = self::resolveClassFromType($fieldType);
521 19
                if (null !== $resolvedType && $isInput && !in_array($resolvedType['type'], self::VALID_INPUT_TYPES)) {
522
                    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
                }
524
525 19
                $fieldConfiguration['type'] = $fieldType;
526
            }
527
528 19
            $fieldConfiguration = self::getDescriptionConfiguration($annotations, true) + $fieldConfiguration;
529
530 19
            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
533 19
                if (!empty($args)) {
534 19
                    $fieldConfiguration['args'] = $args;
535
                }
536
537 19
                $fieldName = $fieldAnnotation->name ?: $fieldName;
538
539 19
                if ($fieldAnnotation->resolve) {
540 19
                    $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
                            $fieldConfiguration['resolve'] = self::formatExpression(sprintf('%s.%s', $currentValue, $target));
547
                        }
548
                    }
549
                }
550
551 19
                if ($fieldAnnotation->argsBuilder) {
552 19
                    if (is_string($fieldAnnotation->argsBuilder)) {
553
                        $fieldConfiguration['argsBuilder'] = $fieldAnnotation->argsBuilder;
554 19
                    } elseif (is_array($fieldAnnotation->argsBuilder)) {
555 19
                        list($builder, $builderConfig) = $fieldAnnotation->argsBuilder;
556 19
                        $fieldConfiguration['argsBuilder'] = ['builder' => $builder, 'config' => $builderConfig];
557
                    } 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
                    }
560
                }
561
562 19
                if ($fieldAnnotation->fieldBuilder) {
563 19
                    if (is_string($fieldAnnotation->fieldBuilder)) {
564
                        $fieldConfiguration['builder'] = $fieldAnnotation->fieldBuilder;
565 19
                    } elseif (is_array($fieldAnnotation->fieldBuilder)) {
566 19
                        list($builder, $builderConfig) = $fieldAnnotation->fieldBuilder;
567 19
                        $fieldConfiguration['builder'] = $builder;
568 19
                        $fieldConfiguration['builderConfig'] = $builderConfig ?: [];
569
                    } else {
570 19
                        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 19
                    if (!$fieldType) {
574 19
                        if ($isMethod) {
575 19
                            if ($method->hasReturnType()) {
576
                                try {
577 19
                                    $fieldConfiguration['type'] = self::resolveGraphQLTypeFromReflectionType($method->getReturnType(), self::VALID_OUTPUT_TYPES);
578
                                } 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
                            }
584
                        } else {
585
                            try {
586 19
                                $fieldConfiguration['type'] = self::guessType($namespace, $annotations);
587 2
                            } catch (Exception $e) {
588 2
                                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
                        }
591
                    }
592
                }
593
594 19
                if ($accessAnnotation) {
595 19
                    $fieldConfiguration['access'] = self::formatExpression($accessAnnotation->value);
596
                }
597
598 19
                if ($publicAnnotation) {
599 19
                    $fieldConfiguration['public'] = self::formatExpression($publicAnnotation->value);
600
                }
601
602 19
                if ($fieldAnnotation->complexity) {
603 19
                    $fieldConfiguration['complexity'] = self::formatExpression($fieldAnnotation->complexity);
604
                }
605
            }
606
607 19
            $fields[$fieldName] = $fieldConfiguration;
608
        }
609
610 20
        return $fields;
611
    }
612
613
    /**
614
     * Return fields config from Provider methods.
615
     */
616 20
    private static function getGraphQLFieldsFromProviders(string $namespace, string $annotationName, string $targetType, bool $isRoot = false): array
617
    {
618 20
        $fields = [];
619 20
        foreach (self::$providers as $className => $configuration) {
620 19
            $providerMethods = $configuration['methods'];
621 19
            $providerAnnotation = $configuration['annotation'];
622 19
            $providerAnnotations = $configuration['annotations'];
623
624 19
            $defaultAccessAnnotation = self::getFirstAnnotationMatching($providerAnnotations, GQL\Access::class);
625 19
            $defaultIsPublicAnnotation = self::getFirstAnnotationMatching($providerAnnotations, GQL\IsPublic::class);
626
627 19
            $defaultAccess = $defaultAccessAnnotation ? self::formatExpression($defaultAccessAnnotation->value) : false;
628 19
            $defaultIsPublic = $defaultIsPublicAnnotation ? self::formatExpression($defaultIsPublicAnnotation->value) : false;
629
630 19
            $filteredMethods = [];
631 19
            foreach ($providerMethods as $methodName => $config) {
632 19
                $annotations = $config['annotations'];
633
634 19
                $annotation = self::getFirstAnnotationMatching($annotations, sprintf('Overblog\\GraphQLBundle\\Annotation\\%s', $annotationName));
635 19
                if (!$annotation) {
636 19
                    continue;
637
                }
638
639 19
                $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 19
            $currentValue = sprintf("service('%s')", self::formatNamespaceForExpression($className));
652 19
            $providerFields = self::getGraphQLFieldsFromAnnotations($namespace, $filteredMethods, false, true, $currentValue, $annotationName);
653 19
            foreach ($providerFields as $fieldName => $fieldConfig) {
654 19
                if ($providerAnnotation->prefix) {
655 19
                    $fieldName = sprintf('%s%s', $providerAnnotation->prefix, $fieldName);
656
                }
657
658 19
                if ($defaultAccess && !isset($fieldConfig['access'])) {
659 19
                    $fieldConfig['access'] = $defaultAccess;
660
                }
661
662 19
                if ($defaultIsPublic && !isset($fieldConfig['public'])) {
663 19
                    $fieldConfig['public'] = $defaultIsPublic;
664
                }
665
666 19
                $fields[$fieldName] = $fieldConfig;
667
            }
668
        }
669
670 20
        return $fields;
671
    }
672
673
    /**
674
     * Get the config for description & deprecation reason.
675
     */
676 20
    private static function getDescriptionConfiguration(array $annotations, bool $withDeprecation = false): array
677
    {
678 20
        $config = [];
679 20
        $descriptionAnnotation = self::getFirstAnnotationMatching($annotations, GQL\Description::class);
680 20
        if ($descriptionAnnotation) {
681 19
            $config['description'] = $descriptionAnnotation->value;
682
        }
683
684 20
        if ($withDeprecation) {
685 19
            $deprecatedAnnotation = self::getFirstAnnotationMatching($annotations, GQL\Deprecated::class);
686 19
            if ($deprecatedAnnotation) {
687 19
                $config['deprecationReason'] = $deprecatedAnnotation->value;
688
            }
689
        }
690
691 20
        return $config;
692
    }
693
694
    /**
695
     * Get args config from an array of @Arg annotation or by auto-guessing if a method is provided.
696
     */
697 19
    private static function getArgs(?array $args, ReflectionMethod $method = null): array
698
    {
699 19
        $config = [];
700 19
        if (!empty($args)) {
701 19
            foreach ($args as $arg) {
702 19
                $config[$arg->name] = ['type' => $arg->type]
703 19
                    + ($arg->description ? ['description' => $arg->description] : [])
704 19
                    + ($arg->default ? ['defaultValue' => $arg->default] : []);
705
            }
706 19
        } elseif ($method) {
707 19
            $config = self::guessArgs($method);
708
        }
709
710 19
        return $config;
711
    }
712
713
    /**
714
     * 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 19
        $mapping = [];
719 19
        foreach ($args as $name => $config) {
720 19
            $mapping[] = sprintf('%s: "%s"', $name, $config['type']);
721
        }
722
723 19
        return sprintf('arguments({%s}, args)', implode(', ', $mapping));
724
    }
725
726
    /**
727
     * Format a namespace to be used in an expression (double escape).
728
     */
729 19
    private static function formatNamespaceForExpression(string $namespace): string
730
    {
731 19
        return str_replace('\\', '\\\\', $namespace);
732
    }
733
734
    /**
735
     * Get the first annotation matching given class.
736
     *
737
     * @param string|array $annotationClass
738
     *
739
     * @return mixed
740
     */
741 20
    private static function getFirstAnnotationMatching(array $annotations, $annotationClass)
742
    {
743 20
        if (is_string($annotationClass)) {
744 20
            $annotationClass = [$annotationClass];
745
        }
746
747 20
        foreach ($annotations as $annotation) {
748 20
            foreach ($annotationClass as $class) {
749 20
                if ($annotation instanceof $class) {
750 19
                    return $annotation;
751
                }
752
            }
753
        }
754
755 20
        return false;
756
    }
757
758
    /**
759
     * Format an expression (ie. add "@=" if not set).
760
     */
761 19
    private static function formatExpression(string $expression): string
762
    {
763 19
        return '@=' === substr($expression, 0, 2) ? $expression : sprintf('@=%s', $expression);
764
    }
765
766
    /**
767
     * Suffix a name if it is not already.
768
     */
769 19
    private static function suffixName(string $name, string $suffix): string
770
    {
771 19
        return substr($name, -strlen($suffix)) === $suffix ? $name : sprintf('%s%s', $name, $suffix);
772
    }
773
774
    /**
775
     * Try to guess a field type base on is annotations.
776
     *
777
     * @throws RuntimeException
778
     */
779 19
    private static function guessType(string $namespace, array $annotations): string
780
    {
781 19
        $columnAnnotation = self::getFirstAnnotationMatching($annotations, Column::class);
782 19
        if ($columnAnnotation) {
783 19
            $type = self::resolveTypeFromDoctrineType($columnAnnotation->type);
784 19
            $nullable = $columnAnnotation->nullable;
785 19
            if ($type) {
786 19
                return $nullable ? $type : sprintf('%s!', $type);
787
            } else {
788 1
                throw new RuntimeException(sprintf('Unable to auto-guess GraphQL type from Doctrine type "%s"', $columnAnnotation->type));
789
            }
790
        }
791
792
        $associationAnnotations = [
793 19
            OneToMany::class => true,
794
            OneToOne::class => false,
795
            ManyToMany::class => true,
796
            ManyToOne::class => false,
797
        ];
798
799 19
        $associationAnnotation = self::getFirstAnnotationMatching($annotations, array_keys($associationAnnotations));
800 19
        if ($associationAnnotation) {
801 19
            $target = self::fullyQualifiedClassName($associationAnnotation->targetEntity, $namespace);
802 19
            $type = self::resolveTypeFromClass($target, ['type']);
803
804 19
            if ($type) {
805 19
                $isMultiple = $associationAnnotations[get_class($associationAnnotation)];
806 19
                if ($isMultiple) {
807 19
                    return sprintf('[%s]!', $type);
808
                } else {
809 19
                    $isNullable = false;
810 19
                    $joinColumn = self::getFirstAnnotationMatching($annotations, JoinColumn::class);
811 19
                    if ($joinColumn) {
812 19
                        $isNullable = $joinColumn->nullable;
813
                    }
814
815 19
                    return sprintf('%s%s', $type, $isNullable ? '' : '!');
816
                }
817
            } else {
818 1
                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
            }
820
        }
821
822
        throw new InvalidArgumentException(sprintf('No Doctrine ORM annotation found.'));
823
    }
824
825
    /**
826
     * Resolve a FQN from classname and namespace.
827
     *
828
     * @internal
829
     */
830 19
    public static function fullyQualifiedClassName(string $className, string $namespace): string
831
    {
832 19
        if (false === strpos($className, '\\') && $namespace) {
833 19
            return $namespace.'\\'.$className;
834
        }
835
836 1
        return $className;
837
    }
838
839
    /**
840
     * Resolve a GraphQLType from a doctrine type.
841
     */
842 19
    private static function resolveTypeFromDoctrineType(string $doctrineType): ?string
843
    {
844 19
        if (isset(self::$doctrineMapping[$doctrineType])) {
845 19
            return self::$doctrineMapping[$doctrineType];
846
        }
847
848
        switch ($doctrineType) {
849 19
            case 'integer':
850 19
            case 'smallint':
851 19
            case 'bigint':
852 19
                return 'Int';
853 19
            case 'string':
854 1
            case 'text':
855 19
                return 'String';
856 1
            case 'bool':
857 1
            case 'boolean':
858
                return 'Boolean';
859 1
            case 'float':
860 1
            case 'decimal':
861
                return 'Float';
862
            default:
863 1
                return null;
864
        }
865
    }
866
867
    /**
868
     * Transform a method arguments from reflection to a list of GraphQL argument.
869
     */
870 19
    private static function guessArgs(ReflectionMethod $method): array
871
    {
872 19
        $arguments = [];
873 19
        foreach ($method->getParameters() as $index => $parameter) {
874 19
            if (!$parameter->hasType()) {
875 1
                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
            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
            }
884
885 19
            $argumentConfig = [];
886 19
            if ($parameter->isDefaultValueAvailable()) {
887 19
                $argumentConfig['defaultValue'] = $parameter->getDefaultValue();
888
            }
889
890 19
            $argumentConfig['type'] = $gqlType;
891
892 19
            $arguments[$parameter->getName()] = $argumentConfig;
893
        }
894
895 19
        return $arguments;
896
    }
897
898 19
    private static function resolveGraphQLTypeFromReflectionType(ReflectionNamedType $type, array $filterGraphQLTypes = [], bool $isOptional = false): string
899
    {
900 19
        $sType = $type->getName();
901 19
        if ($type->isBuiltin()) {
902 19
            $gqlType = self::resolveTypeFromPhpType($sType);
903 19
            if (null === $gqlType) {
904 19
                throw new RuntimeException(sprintf('No corresponding GraphQL type found for builtin type "%s"', $sType));
905
            }
906
        } else {
907 19
            $gqlType = self::resolveTypeFromClass($sType, $filterGraphQLTypes);
908 19
            if (null === $gqlType) {
909
                throw new RuntimeException(sprintf('No corresponding GraphQL %s found for class "%s"', $filterGraphQLTypes ? implode(',', $filterGraphQLTypes) : 'object', $sType));
910
            }
911
        }
912
913 19
        return sprintf('%s%s', $gqlType, ($type->allowsNull() || $isOptional) ? '' : '!');
914
    }
915
916
    /**
917
     * Resolve a GraphQL Type from a class name.
918
     */
919 19
    private static function resolveTypeFromClass(string $className, array $wantedTypes = []): ?string
920
    {
921 19
        foreach (self::$classesMap as $gqlType => $config) {
922 19
            if ($config['class'] === $className) {
923 19
                if (in_array($config['type'], $wantedTypes)) {
924 19
                    return $gqlType;
925
                }
926
            }
927
        }
928
929 1
        return null;
930
    }
931
932
    /**
933
     * Resolve a PHP class from a GraphQL type.
934
     *
935
     * @return string|array|null
936
     */
937 19
    private static function resolveClassFromType(string $type)
938
    {
939 19
        return self::$classesMap[$type] ?? null;
940
    }
941
942
    /**
943
     * Convert a PHP Builtin type to a GraphQL type.
944
     */
945 19
    private static function resolveTypeFromPhpType(string $phpType): ?string
946
    {
947
        switch ($phpType) {
948 19
            case 'boolean':
949 19
            case 'bool':
950 19
                return 'Boolean';
951 19
            case 'integer':
952 19
            case 'int':
953 19
                return 'Int';
954 19
            case 'float':
955 19
            case 'double':
956 19
                return 'Float';
957 19
            case 'string':
958 19
                return 'String';
959
            default:
960
                return null;
961
        }
962
    }
963
}
964