Passed
Pull Request — master (#24)
by David
03:06
created

getMethodFromPropertyName()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 12
Code Lines 8

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 8
dl 0
loc 12
c 0
b 0
f 0
rs 10
cc 3
nc 3
nop 2
1
<?php
2
3
4
namespace TheCodingMachine\GraphQL\Controllers;
5
6
use phpDocumentor\Reflection\Type;
7
use phpDocumentor\Reflection\Types\Array_;
8
use phpDocumentor\Reflection\Types\Boolean;
9
use phpDocumentor\Reflection\Types\Float_;
10
use phpDocumentor\Reflection\Types\Mixed_;
11
use phpDocumentor\Reflection\Types\Null_;
12
use phpDocumentor\Reflection\Types\Object_;
13
use phpDocumentor\Reflection\Types\String_;
14
use Psr\Container\ContainerInterface;
15
use Roave\BetterReflection\Reflection\ReflectionClass;
16
use Roave\BetterReflection\Reflection\ReflectionMethod;
17
use Doctrine\Common\Annotations\Reader;
18
use phpDocumentor\Reflection\Types\Integer;
19
use TheCodingMachine\GraphQL\Controllers\Annotations\AbstractRequest;
20
use TheCodingMachine\GraphQL\Controllers\Annotations\ExposedField;
21
use TheCodingMachine\GraphQL\Controllers\Annotations\Logged;
22
use TheCodingMachine\GraphQL\Controllers\Annotations\Mutation;
23
use TheCodingMachine\GraphQL\Controllers\Annotations\Query;
24
use TheCodingMachine\GraphQL\Controllers\Annotations\Right;
25
use TheCodingMachine\GraphQL\Controllers\Reflection\CommentParser;
26
use TheCodingMachine\GraphQL\Controllers\Registry\EmptyContainer;
27
use TheCodingMachine\GraphQL\Controllers\Registry\Registry;
28
use TheCodingMachine\GraphQL\Controllers\Registry\RegistryInterface;
29
use TheCodingMachine\GraphQL\Controllers\Security\AuthenticationServiceInterface;
30
use TheCodingMachine\GraphQL\Controllers\Security\AuthorizationServiceInterface;
31
use Youshido\GraphQL\Field\Field;
32
use Youshido\GraphQL\Type\ListType\ListType;
33
use Youshido\GraphQL\Type\NonNullType;
34
use Youshido\GraphQL\Type\Scalar\BooleanType;
35
use Youshido\GraphQL\Type\Scalar\DateTimeType;
36
use Youshido\GraphQL\Type\Scalar\FloatType;
37
use Youshido\GraphQL\Type\Scalar\IntType;
38
use Youshido\GraphQL\Type\Scalar\StringType;
39
use Youshido\GraphQL\Type\TypeInterface;
40
41
/**
42
 * A query provider that looks for queries in a "controller"
43
 */
44
class ControllerQueryProvider implements QueryProviderInterface
45
{
46
    /**
47
     * @var object
48
     */
49
    private $controller;
50
    /**
51
     * @var Reader
52
     */
53
    private $annotationReader;
54
    /**
55
     * @var TypeMapperInterface
0 ignored issues
show
Bug introduced by
The type TheCodingMachine\GraphQL...ers\TypeMapperInterface was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
56
     */
57
    private $typeMapper;
58
    /**
59
     * @var HydratorInterface
60
     */
61
    private $hydrator;
62
    /**
63
     * @var AuthenticationServiceInterface
64
     */
65
    private $authenticationService;
66
    /**
67
     * @var AuthorizationServiceInterface
68
     */
69
    private $authorizationService;
70
    /**
71
     * @var RegistryInterface
72
     */
73
    private $registry;
74
75
    /**
76
     * @param object $controller
77
     */
78
    public function __construct($controller, RegistryInterface $registry)
79
    {
80
        $this->controller = $controller;
81
        $this->annotationReader = $registry->getAnnotationReader();
82
        $this->typeMapper = $registry->getTypeMapper();
0 ignored issues
show
Documentation Bug introduced by
It seems like $registry->getTypeMapper() of type TheCodingMachine\GraphQL...ers\TypeMapperInterface is incompatible with the declared type TheCodingMachine\GraphQL...ers\TypeMapperInterface of property $typeMapper.

Our type inference engine has found an assignment to a property that is incompatible with the declared type of that property.

Either this assignment is in error or the assigned type should be added to the documentation/type hint for that property..

Loading history...
83
        $this->hydrator = $registry->getHydrator();
84
        $this->authenticationService = $registry->getAuthenticationService();
85
        $this->authorizationService = $registry->getAuthorizationService();
86
        $this->registry = $registry;
87
    }
88
89
    /**
90
     * @return QueryField[]
91
     */
92
    public function getQueries(): array
93
    {
94
        return $this->getFieldsByAnnotations(Query::class, false);
0 ignored issues
show
introduced by
The expression return $this->getFieldsB...ns\Query::class, false) returns an array which contains values of type TheCodingMachine\GraphQL\Controllers\QueryField which are incompatible with the return type Youshido\GraphQL\Field\Field mandated by TheCodingMachine\GraphQL...Interface::getQueries().
Loading history...
95
    }
96
97
    /**
98
     * @return QueryField[]
99
     */
100
    public function getMutations(): array
101
    {
102
        return $this->getFieldsByAnnotations(Mutation::class, false);
0 ignored issues
show
introduced by
The expression return $this->getFieldsB...Mutation::class, false) returns an array which contains values of type TheCodingMachine\GraphQL\Controllers\QueryField which are incompatible with the return type Youshido\GraphQL\Field\Field mandated by TheCodingMachine\GraphQL...terface::getMutations().
Loading history...
103
    }
104
105
    /**
106
     * @return QueryField[]
107
     */
108
    public function getFields(): array
109
    {
110
        $fieldAnnotations = $this->getFieldsByAnnotations(Annotations\Field::class, true);
111
        $exposedFields = $this->getExposedFields();
112
113
        return array_merge($fieldAnnotations, $exposedFields);
114
    }
115
116
    /**
117
     * @param string $annotationName
118
     * @param bool $injectSource Whether to inject the source object or not as the first argument. True for @Field, false for @Query and @Mutation
119
     * @return QueryField[]
120
     * @throws \ReflectionException
121
     */
122
    private function getFieldsByAnnotations(string $annotationName, bool $injectSource): array
123
    {
124
        $refClass = ReflectionClass::createFromInstance($this->controller);
125
126
        $queryList = [];
127
128
        $typeResolver = new \phpDocumentor\Reflection\TypeResolver();
129
130
        foreach ($refClass->getMethods() as $refMethod) {
131
            $standardPhpMethod = new \ReflectionMethod(get_class($this->controller), $refMethod->getName());
132
            // First, let's check the "Query" or "Mutation" or "Field" annotation
133
            $queryAnnotation = $this->annotationReader->getMethodAnnotation($standardPhpMethod, $annotationName);
134
            /* @var $queryAnnotation AbstractRequest */
135
136
            if ($queryAnnotation !== null) {
137
                if (!$this->isAuthorized($standardPhpMethod)) {
138
                    continue;
139
                }
140
                $docBlock = new CommentParser($refMethod->getDocComment());
141
142
                $methodName = $refMethod->getName();
143
                $name = $queryAnnotation->getName() ?: $methodName;
144
145
                $args = $this->mapParameters($refMethod, $standardPhpMethod);
146
147
                $phpdocType = $typeResolver->resolve((string) $refMethod->getReturnType());
148
149
                if ($queryAnnotation->getReturnType()) {
150
                    $type = $this->registry->get($queryAnnotation->getReturnType());
151
                } else {
152
                    try {
153
                        $type = $this->mapType($phpdocType, $refMethod->getDocBlockReturnTypes(), $standardPhpMethod->getReturnType()->allowsNull(), false);
154
                    } catch (TypeMappingException $e) {
155
                        throw TypeMappingException::wrapWithReturnInfo($e, $refMethod);
156
                    }
157
                }
158
159
                //$sourceType = null;
0 ignored issues
show
Unused Code Comprehensibility introduced by
50% of this comment could be valid code. Did you maybe forget this after debugging?

Sometimes obsolete code just ends up commented out instead of removed. In this case it is better to remove the code once you have checked you do not need it.

The code might also have been commented out for debugging purposes. In this case it is vital that someone uncomments it again or your project may behave in very unexpected ways in production.

This check looks for comments that seem to be mostly valid code and reports them.

Loading history...
160
                if ($injectSource === true) {
161
                    /*$sourceArr = */\array_shift($args);
162
                    // Security check: if the first parameter of the correct type?
163
                    //$sourceType = $sourceArr['type'];
0 ignored issues
show
Unused Code Comprehensibility introduced by
67% of this comment could be valid code. Did you maybe forget this after debugging?

Sometimes obsolete code just ends up commented out instead of removed. In this case it is better to remove the code once you have checked you do not need it.

The code might also have been commented out for debugging purposes. In this case it is vital that someone uncomments it again or your project may behave in very unexpected ways in production.

This check looks for comments that seem to be mostly valid code and reports them.

Loading history...
164
                    /* @var $sourceType TypeInterface */
165
                    // TODO
166
                }
167
                $queryList[] = new QueryField($name, $type, $args, [$this->controller, $methodName], null, $this->hydrator, $docBlock->getComment(), $injectSource);
168
            }
169
        }
170
171
        return $queryList;
172
    }
173
174
    /**
175
     * @return QueryField[]
176
     */
177
    private function getExposedFields(): array
178
    {
179
        $refClass = new \ReflectionClass($this->controller);
180
181
        /** @var ExposedField[] $exposedFields */
182
        $exposedFields = $this->annotationReader->getClassAnnotations($refClass);
183
        $exposedFields = \array_filter($exposedFields, function($annotation): bool {
184
            return $annotation instanceof ExposedField;
185
        });
186
187
        if (empty($exposedFields)) {
188
            return [];
189
        }
190
191
        /** @var \TheCodingMachine\GraphQL\Controllers\Annotations\Type $typeField */
192
        $typeField = $this->annotationReader->getClassAnnotation($refClass, \TheCodingMachine\GraphQL\Controllers\Annotations\Type::class);
193
194
        if ($typeField === null) {
195
            throw MissingAnnotationException::missingTypeException();
196
        }
197
198
        $objectClass = $typeField->getClass();
199
        $objectRefClass = ReflectionClass::createFromName($objectClass);
200
201
        $typeResolver = new \phpDocumentor\Reflection\TypeResolver();
202
203
        foreach ($exposedFields as $exposedField) {
204
            // Ignore the field if we must be logged.
205
            if ($exposedField->isLogged() && !$this->authenticationService->isLogged()) {
206
                continue;
207
            }
208
209
            $right = $exposedField->getRight();
210
            if ($right !== null && !$this->authorizationService->isAllowed($right->getName())) {
211
                continue;
212
            }
213
214
            try {
215
                $refMethod = $this->getMethodFromPropertyName($objectRefClass, $exposedField->getName());
216
            } catch (FieldNotFoundException $e) {
217
                throw FieldNotFoundException::wrapWithCallerInfo($e, $refClass->getName());
218
            }
219
220
            $docBlock = new CommentParser($refMethod->getDocComment());
221
222
            $methodName = $refMethod->getName();
223
224
            $standardPhpMethod = new \ReflectionMethod($objectRefClass->getName(), $refMethod->getName());
225
            $args = $this->mapParameters($refMethod, $standardPhpMethod);
226
227
            $phpdocType = $typeResolver->resolve((string) $refMethod->getReturnType());
228
229
            if ($exposedField->getReturnType()) {
230
                $type = $this->registry->get($exposedField->getReturnType());
231
            } else {
232
                try {
233
                    $type = $this->mapType($phpdocType, $refMethod->getDocBlockReturnTypes(), $standardPhpMethod->getReturnType()->allowsNull(), false);
234
                } catch (TypeMappingException $e) {
235
                    throw TypeMappingException::wrapWithReturnInfo($e, $refMethod);
236
                }
237
            }
238
239
            $queryList[] = new QueryField($exposedField->getName(), $type, $args, null, $methodName, $this->hydrator, $docBlock->getComment(), false);
240
241
        }
242
        return $queryList;
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable $queryList does not seem to be defined for all execution paths leading up to this point.
Loading history...
243
    }
244
245
    private function getMethodFromPropertyName(ReflectionClass $reflectionClass, string $propertyName): ReflectionMethod
246
    {
247
        $upperCasePropertyName = \ucfirst($propertyName);
248
        if ($reflectionClass->hasMethod('get'.$upperCasePropertyName)) {
249
            $methodName = 'get'.$upperCasePropertyName;
250
        } elseif ($reflectionClass->hasMethod('is'.$upperCasePropertyName)) {
251
            $methodName = 'is'.$upperCasePropertyName;
252
        } else {
253
            throw FieldNotFoundException::missingField($reflectionClass->getName(), $propertyName);
254
        }
255
256
        return $reflectionClass->getMethod($methodName);
257
    }
258
259
    /**
260
     * Checks the @Logged and @Right annotations.
261
     *
262
     * @param \ReflectionMethod $reflectionMethod
263
     * @return bool
264
     */
265
    private function isAuthorized(\ReflectionMethod $reflectionMethod) : bool
266
    {
267
        $loggedAnnotation = $this->annotationReader->getMethodAnnotation($reflectionMethod, Logged::class);
268
269
        if ($loggedAnnotation !== null && !$this->authenticationService->isLogged()) {
270
            return false;
271
        }
272
273
        $rightAnnotation = $this->annotationReader->getMethodAnnotation($reflectionMethod, Right::class);
274
        /** @var $rightAnnotation Right */
275
276
        if ($rightAnnotation !== null && !$this->authorizationService->isAllowed($rightAnnotation->getName())) {
277
            return false;
278
        }
279
280
        return true;
281
    }
282
283
    /**
284
     * Note: there is a bug in $refMethod->allowsNull that forces us to use $standardRefMethod->allowsNull instead.
285
     *
286
     * @param ReflectionMethod $refMethod
287
     * @param \ReflectionMethod $standardRefMethod
288
     * @return array[] An array of ['type'=>TypeInterface, 'default'=>val]
289
     * @throws MissingTypeHintException
290
     */
291
    private function mapParameters(ReflectionMethod $refMethod, \ReflectionMethod $standardRefMethod): array
292
    {
293
        $args = [];
294
295
        $typeResolver = new \phpDocumentor\Reflection\TypeResolver();
296
297
        foreach ($standardRefMethod->getParameters() as $standardParameter) {
298
            $allowsNull = $standardParameter->allowsNull();
299
            $parameter = $refMethod->getParameter($standardParameter->getName());
300
301
            $type = (string) $parameter->getType();
302
            if ($type === '') {
303
                throw MissingTypeHintException::missingTypeHint($parameter);
304
            }
305
            $phpdocType = $typeResolver->resolve($type);
306
307
            try {
308
                $arr = [
309
                    'type' => $this->mapType($phpdocType, $parameter->getDocBlockTypes(), $allowsNull || $parameter->isDefaultValueAvailable(), true),
310
                ];
311
            } catch (TypeMappingException $e) {
312
                throw TypeMappingException::wrapWithParamInfo($e, $parameter);
313
            }
314
315
            if ($standardParameter->allowsNull()) {
316
                $arr['default'] = null;
317
            }
318
            if ($standardParameter->isDefaultValueAvailable()) {
319
                $arr['default'] = $standardParameter->getDefaultValue();
320
            }
321
322
            $args[$parameter->getName()] = $arr;
323
        }
324
325
        return $args;
326
    }
327
328
    /**
329
     * @param Type $type
330
     * @param Type[] $docBlockTypes
331
     * @return TypeInterface
332
     */
333
    private function mapType(Type $type, array $docBlockTypes, bool $isNullable, bool $mapToInputType): TypeInterface
334
    {
335
        $graphQlType = null;
336
337
        if ($type instanceof Array_ || $type instanceof Mixed_) {
338
            if (!$isNullable) {
339
                // Let's check a "null" value in the docblock
340
                $isNullable = $this->isNullable($docBlockTypes);
341
            }
342
            $filteredDocBlockTypes = $this->typesWithoutNullable($docBlockTypes);
343
            if (empty($filteredDocBlockTypes)) {
344
                throw TypeMappingException::createFromType($type);
345
            } elseif (count($filteredDocBlockTypes) === 1) {
346
                $graphQlType = $this->toGraphQlType($filteredDocBlockTypes[0], $mapToInputType);
347
            } else {
348
                throw new GraphQLException('Union types are not supported (yet)');
349
                //$graphQlTypes = array_map([$this, 'toGraphQlType'], $filteredDocBlockTypes);
0 ignored issues
show
Unused Code Comprehensibility introduced by
65% of this comment could be valid code. Did you maybe forget this after debugging?

Sometimes obsolete code just ends up commented out instead of removed. In this case it is better to remove the code once you have checked you do not need it.

The code might also have been commented out for debugging purposes. In this case it is vital that someone uncomments it again or your project may behave in very unexpected ways in production.

This check looks for comments that seem to be mostly valid code and reports them.

Loading history...
350
                //$$graphQlType = new UnionType($graphQlTypes);
0 ignored issues
show
Unused Code Comprehensibility introduced by
59% of this comment could be valid code. Did you maybe forget this after debugging?

Sometimes obsolete code just ends up commented out instead of removed. In this case it is better to remove the code once you have checked you do not need it.

The code might also have been commented out for debugging purposes. In this case it is vital that someone uncomments it again or your project may behave in very unexpected ways in production.

This check looks for comments that seem to be mostly valid code and reports them.

Loading history...
351
            }
352
        } else {
353
            $graphQlType = $this->toGraphQlType($type, $mapToInputType);
354
        }
355
356
        if (!$isNullable) {
357
            $graphQlType = new NonNullType($graphQlType);
358
        }
359
360
        return $graphQlType;
361
    }
362
363
    /**
364
     * Casts a Type to a GraphQL type.
365
     * Does not deal with nullable.
366
     *
367
     * @param Type $type
368
     * @param bool $mapToInputType
369
     * @return TypeInterface
370
     */
371
    private function toGraphQlType(Type $type, bool $mapToInputType): TypeInterface
372
    {
373
        if ($type instanceof Integer) {
374
            return new IntType();
375
        } elseif ($type instanceof String_) {
376
            return new StringType();
377
        } elseif ($type instanceof Boolean) {
378
            return new BooleanType();
379
        } elseif ($type instanceof Float_) {
380
            return new FloatType();
381
        } elseif ($type instanceof Object_) {
382
            $fqcn = (string) $type->getFqsen();
383
            if ($fqcn === '\\DateTimeImmutable' || $fqcn === '\\DateTimeInterface') {
384
                return new DateTimeType();
385
            } elseif ($fqcn === '\\DateTime') {
386
                throw new GraphQLException('Type-hinting a parameter against DateTime is not allowed. Please use the DateTimeImmutable type instead.');
387
            }
388
389
            $className = ltrim($type->getFqsen(), '\\');
390
            if ($mapToInputType) {
391
                return $this->typeMapper->mapClassToInputType($className);
392
            } else {
393
                return $this->typeMapper->mapClassToType($className);
394
            }
395
        } elseif ($type instanceof Array_) {
396
            return new ListType(new NonNullType($this->toGraphQlType($type->getValueType(), $mapToInputType)));
397
        } else {
398
            throw TypeMappingException::createFromType($type);
399
        }
400
    }
401
402
    /**
403
     * Removes "null" from the list of types.
404
     *
405
     * @param Type[] $docBlockTypeHints
406
     * @return array
407
     */
408
    private function typesWithoutNullable(array $docBlockTypeHints): array
409
    {
410
        return array_filter($docBlockTypeHints, function ($item) {
411
            return !$item instanceof Null_;
412
        });
413
    }
414
415
    /**
416
     * @param Type[] $docBlockTypeHints
417
     * @return bool
418
     */
419
    private function isNullable(array $docBlockTypeHints): bool
420
    {
421
        foreach ($docBlockTypeHints as $docBlockTypeHint) {
422
            if ($docBlockTypeHint instanceof Null_) {
423
                return true;
424
            }
425
        }
426
        return false;
427
    }
428
}
429