Completed
Push — master ( 5a1ced...5e31e4 )
by Lukas Kahwe
18:21 queued 11:48
created

RestActionReader::getMethodArguments()   C

Complexity

Conditions 7
Paths 5

Size

Total Lines 33
Code Lines 18

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 18
CRAP Score 7.143

Importance

Changes 4
Bugs 1 Features 0
Metric Value
c 4
b 1
f 0
dl 0
loc 33
ccs 18
cts 21
cp 0.8571
rs 6.7273
cc 7
eloc 18
nc 5
nop 1
crap 7.143
1
<?php
2
3
/*
4
 * This file is part of the FOSRestBundle package.
5
 *
6
 * (c) FriendsOfSymfony <http://friendsofsymfony.github.com/>
7
 *
8
 * For the full copyright and license information, please view the LICENSE
9
 * file that was distributed with this source code.
10
 */
11
12
namespace FOS\RestBundle\Routing\Loader\Reader;
13
14
use Doctrine\Common\Annotations\Reader;
15
use FOS\RestBundle\Controller\Annotations\Route as RouteAnnotation;
16
use FOS\RestBundle\Inflector\InflectorInterface;
17
use FOS\RestBundle\Request\ParamReader;
18
use FOS\RestBundle\Routing\RestRouteCollection;
19
use Symfony\Component\Routing\Route;
20
use FOS\RestBundle\Request\ParamReaderInterface;
21
22
/**
23
 * REST controller actions reader.
24
 *
25
 * @author Konstantin Kudryashov <[email protected]>
26
 */
27
class RestActionReader
28
{
29
    const COLLECTION_ROUTE_PREFIX = 'c';
30
31
    private $annotationReader;
32
    private $paramReader;
33
    private $inflector;
34
    private $formats;
35
    private $includeFormat;
36
    private $routePrefix;
37
    private $namePrefix;
38
    private $versions;
39
    private $pluralize;
40
    private $parents = [];
41
    private $availableHTTPMethods = ['get', 'post', 'put', 'patch', 'delete', 'link', 'unlink', 'head', 'options'];
42
    private $availableConventionalActions = ['new', 'edit', 'remove'];
43
44
    /**
45
     * Initializes controller reader.
46
     *
47
     * @param Reader               $annotationReader
48
     * @param ParamReaderInterface $paramReader
49
     * @param InflectorInterface   $inflector
50
     * @param bool                 $includeFormat
51
     * @param array                $formats
52
     */
53 39
    public function __construct(Reader $annotationReader, ParamReaderInterface $paramReader, InflectorInterface $inflector, $includeFormat, array $formats = [])
54
    {
55 39
        $this->annotationReader = $annotationReader;
56 39
        $this->paramReader = $paramReader;
57 39
        $this->inflector = $inflector;
58 39
        $this->includeFormat = $includeFormat;
59 39
        $this->formats = $formats;
60 39
    }
61
62
    /**
63
     * Sets routes prefix.
64
     *
65
     * @param string $prefix Routes prefix
66
     */
67 24
    public function setRoutePrefix($prefix = null)
68
    {
69 24
        $this->routePrefix = $prefix;
70 24
    }
71
72
    /**
73
     * Returns route prefix.
74
     *
75
     * @return string
76
     */
77 24
    public function getRoutePrefix()
78
    {
79 24
        return $this->routePrefix;
80
    }
81
82
    /**
83
     * Sets route names prefix.
84
     *
85
     * @param string $prefix Route names prefix
86
     */
87 24
    public function setNamePrefix($prefix = null)
88
    {
89 24
        $this->namePrefix = $prefix;
90 24
    }
91
92
    /**
93
     * Returns name prefix.
94
     *
95
     * @return string
96
     */
97
    public function getNamePrefix()
98
    {
99
        return $this->namePrefix;
100
    }
101
102
    /**
103
     * Sets route names versions.
104
     *
105
     * @param array|string|null $versions Route names versions
106
     */
107 24
    public function setVersions($versions = null)
108
    {
109 24
        $this->versions = (array) $versions;
110 24
    }
111
112
    /**
113
     * Returns versions.
114
     *
115
     * @return array|null
116
     */
117
    public function getVersions()
118
    {
119
        return $this->versions;
120
    }
121
122
    /**
123
     * Sets pluralize.
124
     *
125
     * @param bool|null $pluralize Specify if resource name must be pluralized
126
     */
127 24
    public function setPluralize($pluralize)
128
    {
129 24
        $this->pluralize = $pluralize;
130 24
    }
131
132
    /**
133
     * Returns pluralize.
134
     *
135
     * @return bool|null
136
     */
137
    public function getPluralize()
138
    {
139
        return $this->pluralize;
140
    }
141
142
    /**
143
     * Set parent routes.
144
     *
145
     * @param array $parents Array of parent resources names
146
     */
147 24
    public function setParents(array $parents)
148
    {
149 24
        $this->parents = $parents;
150 24
    }
151
152
    /**
153
     * Returns parents.
154
     *
155
     * @return array
156
     */
157
    public function getParents()
158
    {
159
        return $this->parents;
160
    }
161
162
    /**
163
     * Reads action route.
164
     *
165
     * @param RestRouteCollection $collection
166
     * @param \ReflectionMethod   $method
167
     * @param string[]            $resource
168
     *
169
     * @throws \InvalidArgumentException
170
     *
171
     * @return Route
172
     */
173 24
    public function read(RestRouteCollection $collection, \ReflectionMethod $method, $resource)
174
    {
175
        // check that every route parent has non-empty singular name
176 24
        foreach ($this->parents as $parent) {
177 5
            if (empty($parent) || '/' === substr($parent, -1)) {
178
                throw new \InvalidArgumentException(
179
                    "Every parent controller must have `get{SINGULAR}Action(\$id)` method\n".
180
                    'where {SINGULAR} is a singular form of associated object'
181
                );
182
            }
183 24
        }
184
185
        // if method is not readable - skip
186 24
        if (!$this->isMethodReadable($method)) {
187 14
            return;
188
        }
189
190
        // if we can't get http-method and resources from method name - skip
191 24
        $httpMethodAndResources = $this->getHttpMethodAndResourcesFromMethod($method, $resource);
192 24
        if (!$httpMethodAndResources) {
193 24
            return;
194
        }
195
196 24
        list($httpMethod, $resources, $isCollection, $isInflectable) = $httpMethodAndResources;
197 24
        $arguments = $this->getMethodArguments($method);
198
199
        // if we have only 1 resource & 1 argument passed, then it's object call, so
200
        // we can set collection singular name
201 24
        if (1 === count($resources) && 1 === count($arguments) - count($this->parents)) {
202 18
            $collection->setSingularName($resources[0]);
203 18
        }
204
205
        // if we have parents passed - merge them with own resource names
206 24
        if (count($this->parents)) {
207 5
            $resources = array_merge($this->parents, $resources);
208 5
        }
209
210 24
        if (empty($resources)) {
211
            $resources[] = null;
212
        }
213
214 24
        $routeName = $httpMethod.$this->generateRouteName($resources);
215 24
        $urlParts = $this->generateUrlParts($resources, $arguments, $httpMethod);
216
217
        // if passed method is not valid HTTP method then it's either
218
        // a hypertext driver, a custom object (PUT) or collection (GET)
219
        // method
220 24
        if (!in_array($httpMethod, $this->availableHTTPMethods)) {
221 17
            $urlParts[] = $httpMethod;
222 17
            $httpMethod = $this->getCustomHttpMethod($httpMethod, $resources, $arguments);
223 17
        }
224
225
        // generated parameters
226 24
        $routeName = strtolower($routeName);
227 24
        $path = implode('/', $urlParts);
228 24
        $defaults = ['_controller' => $method->getName()];
0 ignored issues
show
Bug introduced by
Consider using $method->name. There is an issue with getName() and APC-enabled PHP versions.
Loading history...
229 24
        $requirements = [];
230 24
        $options = [];
231 24
        $host = '';
232 24
        $condition = null;
233
234 24
        $annotations = $this->readRouteAnnotation($method);
235 24
        if (!empty($annotations)) {
236 8
            foreach ($annotations as $annotation) {
237 8
                $path = implode('/', $urlParts);
238 8
                $defaults = ['_controller' => $method->getName()];
0 ignored issues
show
Bug introduced by
Consider using $method->name. There is an issue with getName() and APC-enabled PHP versions.
Loading history...
239 8
                $requirements = [];
240 8
                $options = [];
241 8
                $methods = explode('|', $httpMethod);
242
243 8
                $annoRequirements = $annotation->getRequirements();
244 8
                $annoMethods = $annotation->getMethods();
245
246 8
                if (!empty($annoMethods)) {
247 8
                    $methods = $annoMethods;
248 8
                }
249
250 8
                $path = $annotation->getPath() !== null ? $this->routePrefix.$annotation->getPath() : $path;
251 8
                $requirements = array_merge($requirements, $annoRequirements);
252 8
                $options = array_merge($options, $annotation->getOptions());
253 8
                $defaults = array_merge($defaults, $annotation->getDefaults());
254 8
                $host = $annotation->getHost();
255 8
                $schemes = $annotation->getSchemes();
256 8
                $condition = $this->getCondition($method, $annotation);
257
258 8
                $this->includeFormatIfNeeded($path, $requirements);
259
260
                // add route to collection
261 8
                $route = new Route(
262 8
                    $path, $defaults, $requirements, $options, $host, $schemes, $methods, $condition
263 8
                );
264 8
                $this->addRoute($collection, $routeName, $route, $isCollection, $isInflectable, $annotation);
265 8
            }
266 8
        } else {
267 23
            $this->includeFormatIfNeeded($path, $requirements);
268
269 23
            $methods = explode('|', strtoupper($httpMethod));
270
271
            // add route to collection
272 23
            $route = new Route(
273 23
                $path, $defaults, $requirements, $options, $host, [], $methods, $condition
274 23
            );
275 23
            $this->addRoute($collection, $routeName, $route, $isCollection, $isInflectable);
276
        }
277 24
    }
278
279
    /**
280
     * Determine the Route condition by combining Route annotations with Version annotation.
281
     *
282
     * @param \ReflectionMethod $method
283
     * @param RouteAnnotation   $annotation
284
     *
285
     * @return string
286
     */
287 8
    private function getCondition(\ReflectionMethod $method, RouteAnnotation $annotation)
0 ignored issues
show
Unused Code introduced by
The parameter $method is not used and could be removed.

This check looks from parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
288
    {
289 8
        $condition = $annotation->getCondition();
290
291 8
        if (!empty($this->versions)) {
292 1
            $versionCondition = "request.attributes.get('version') == (";
293 1
            $first = true;
294 1
            foreach ($this->versions as $version) {
295 1
                if (!$first) {
296 1
                    $versionCondition .= ' or ';
297 1
                }
298 1
                $versionCondition .= '\''.$version.'\'';
299 1
                $first = false;
300 1
            }
301 1
            $versionCondition .= ')';
302 1
            $condition = $condition ? '('.$condition.') and '.$versionCondition : $versionCondition;
303 1
        }
304
305 8
        return $condition;
306
    }
307
308
    /**
309
     * Include the format in the path and requirements if its enabled.
310
     *
311
     * @param string $path
312
     * @param array  $requirements
313
     */
314 24
    private function includeFormatIfNeeded(&$path, &$requirements)
315
    {
316 24 View Code Duplication
        if ($this->includeFormat === true) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

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

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

Loading history...
317 24
            $path .= '.{_format}';
318
319 24
            if (!isset($requirements['_format']) && !empty($this->formats)) {
320 1
                $requirements['_format'] = implode('|', array_keys($this->formats));
321 1
            }
322 24
        }
323 24
    }
324
325
    /**
326
     * Checks whether provided method is readable.
327
     *
328
     * @param \ReflectionMethod $method
329
     *
330
     * @return bool
331
     */
332 24
    private function isMethodReadable(\ReflectionMethod $method)
333
    {
334
        // if method starts with _ - skip
335 24
        if ('_' === substr($method->getName(), 0, 1)) {
0 ignored issues
show
Bug introduced by
Consider using $method->name. There is an issue with getName() and APC-enabled PHP versions.
Loading history...
336 10
            return false;
337
        }
338
339 24
        $hasNoRouteMethod = (bool) $this->readMethodAnnotation($method, 'NoRoute');
340 24
        $hasNoRouteClass = (bool) $this->readClassAnnotation($method->getDeclaringClass(), 'NoRoute');
341
342 24
        $hasNoRoute = $hasNoRoute = $hasNoRouteMethod || $hasNoRouteClass;
343
        // since NoRoute extends Route we need to exclude all the method NoRoute annotations
344 24
        $hasRoute = (bool) $this->readMethodAnnotation($method, 'Route') && !$hasNoRouteMethod;
345
346
        // if method has NoRoute annotation and does not have Route annotation - skip
347 24
        if ($hasNoRoute && !$hasRoute) {
348 4
            return false;
349
        }
350
351 24
        return true;
352
    }
353
354
    /**
355
     * Returns HTTP method and resources list from method signature.
356
     *
357
     * @param \ReflectionMethod $method
358
     * @param string[]          $resource
359
     *
360
     * @return bool|array
361
     */
362 24
    private function getHttpMethodAndResourcesFromMethod(\ReflectionMethod $method, $resource)
363
    {
364
        // if method doesn't match regex - skip
365 24
        if (!preg_match('/([a-z][_a-z0-9]+)(.*)Action/', $method->getName(), $matches)) {
0 ignored issues
show
Bug introduced by
Consider using $method->name. There is an issue with getName() and APC-enabled PHP versions.
Loading history...
366 24
            return false;
367
        }
368
369 24
        $httpMethod = strtolower($matches[1]);
370 24
        $resources = preg_split(
371 24
            '/([A-Z][^A-Z]*)/', $matches[2], -1, PREG_SPLIT_NO_EMPTY | PREG_SPLIT_DELIM_CAPTURE
372 24
        );
373 24
        $isCollection = false;
374 24
        $isInflectable = true;
375
376 24
        if (0 === strpos($httpMethod, self::COLLECTION_ROUTE_PREFIX)
377 24
            && in_array(substr($httpMethod, 1), $this->availableHTTPMethods)
378 24
        ) {
379 5
            $isCollection = true;
380 5
            $httpMethod = substr($httpMethod, 1);
381 24
        } elseif ('options' === $httpMethod) {
382 14
            $isCollection = true;
383 14
        }
384
385 24
        if ($isCollection && !empty($resource)) {
386 5
            $resourcePluralized = $this->generateResourceName(end($resource));
387 5
            $isInflectable = ($resourcePluralized != $resource[count($resource) - 1]);
388 5
            $resource[count($resource) - 1] = $resourcePluralized;
389 5
        }
390
391 24
        $resources = array_merge($resource, $resources);
392
393 24
        return [$httpMethod, $resources, $isCollection, $isInflectable];
394
    }
395
396
    /**
397
     * Returns readable arguments from method.
398
     *
399
     * @param \ReflectionMethod $method
400
     *
401
     * @return \ReflectionParameter[]
402
     */
403 24
    private function getMethodArguments(\ReflectionMethod $method)
404
    {
405
        // ignore all query params
406 24
        $params = $this->paramReader->getParamsFromMethod($method);
407
408
        // ignore several type hinted arguments
409
        $ignoreClasses = [
410 24
            \Symfony\Component\HttpFoundation\Request::class,
411 24
            \FOS\RestBundle\Request\ParamFetcherInterface::class,
412 24
            \Symfony\Component\Validator\ConstraintViolationListInterface::class,
413 24
            \Sensio\Bundle\FrameworkExtraBundle\Configuration\ParamConverter::class,
414 24
        ];
415
416 24
        $arguments = [];
417 24
        foreach ($method->getParameters() as $argument) {
418 21
            if (isset($params[$argument->getName()])) {
0 ignored issues
show
Bug introduced by
Consider using $argument->name. There is an issue with getName() and APC-enabled PHP versions.
Loading history...
419
                continue;
420
            }
421
422 21
            $argumentClass = $argument->getClass();
423 21
            if ($argumentClass) {
424 10
                foreach ($ignoreClasses as $class) {
425 10
                    if ($argumentClass->getName() === $class || $argumentClass->isSubclassOf($class)) {
0 ignored issues
show
Bug introduced by
Consider using $argumentClass->name. There is an issue with getName() and APC-enabled PHP versions.
Loading history...
426 10
                        continue 2;
427
                    }
428
                }
429
            }
430
431 21
            $arguments[] = $argument;
432 24
        }
433
434 24
        return $arguments;
435
    }
436
437
    /**
438
     * Generates final resource name.
439
     *
440
     * @param string|bool $resource
441
     *
442
     * @return string
443
     */
444 24
    private function generateResourceName($resource)
445
    {
446 24
        if (false === $this->pluralize) {
447 1
            return $resource;
448
        }
449
450 23
        return $this->inflector->pluralize($resource);
0 ignored issues
show
Bug introduced by
It seems like $resource defined by parameter $resource on line 444 can also be of type boolean; however, FOS\RestBundle\Inflector...rInterface::pluralize() does only seem to accept string, maybe add an additional type check?

This check looks at variables that have been passed in as parameters and are passed out again to other methods.

If the outgoing method call has stricter type requirements than the method itself, an issue is raised.

An additional type check may prevent trouble.

Loading history...
451
    }
452
453
    /**
454
     * Generates route name from resources list.
455
     *
456
     * @param string[] $resources
457
     *
458
     * @return string
459
     */
460 24
    private function generateRouteName(array $resources)
461
    {
462 24
        $routeName = '';
463 24
        foreach ($resources as $resource) {
464 24
            if (null !== $resource) {
465 24
                $routeName .= '_'.basename($resource);
466 24
            }
467 24
        }
468
469 24
        return $routeName;
470
    }
471
472
    /**
473
     * Generates URL parts for route from resources list.
474
     *
475
     * @param string[]               $resources
476
     * @param \ReflectionParameter[] $arguments
477
     * @param string                 $httpMethod
478
     *
479
     * @return array
480
     */
481 24
    private function generateUrlParts(array $resources, array $arguments, $httpMethod)
482
    {
483 24
        $urlParts = [];
484 24
        foreach ($resources as $i => $resource) {
485
            // if we already added all parent routes paths to URL & we have
486
            // prefix - add it
487 24
            if (!empty($this->routePrefix) && $i === count($this->parents)) {
488 3
                $urlParts[] = $this->routePrefix;
489 3
            }
490
491
            // if we have argument for current resource, then it's object.
492
            // otherwise - it's collection
493 24
            if (isset($arguments[$i])) {
494 21
                if (null !== $resource) {
495 21
                    $urlParts[] =
496 21
                        strtolower($this->generateResourceName($resource))
497 21
                        .'/{'.$arguments[$i]->getName().'}';
0 ignored issues
show
Bug introduced by
Consider using $arguments[$i]->name. There is an issue with getName() and APC-enabled PHP versions.
Loading history...
498 21
                } else {
499
                    $urlParts[] = '{'.$arguments[$i]->getName().'}';
0 ignored issues
show
Bug introduced by
Consider using $arguments[$i]->name. There is an issue with getName() and APC-enabled PHP versions.
Loading history...
500
                }
501 24
            } elseif (null !== $resource) {
502 24
                if ((0 === count($arguments) && !in_array($httpMethod, $this->availableHTTPMethods))
503
                    || 'new' === $httpMethod
504 23
                    || 'post' === $httpMethod
505 24
                ) {
506 17
                    $urlParts[] = $this->generateResourceName(strtolower($resource));
507 17
                } else {
508 23
                    $urlParts[] = strtolower($resource);
509
                }
510 24
            }
511 24
        }
512
513 24
        return $urlParts;
514
    }
515
516
    /**
517
     * Returns custom HTTP method for provided list of resources, arguments, method.
518
     *
519
     * @param string                 $httpMethod current HTTP method
520
     * @param string[]               $resources  resources list
521
     * @param \ReflectionParameter[] $arguments  list of method arguments
522
     *
523
     * @return string
524
     */
525 17
    private function getCustomHttpMethod($httpMethod, array $resources, array $arguments)
526
    {
527 17
        if (in_array($httpMethod, $this->availableConventionalActions)) {
528
            // allow hypertext as the engine of application state
529
            // through conventional GET actions
530 12
            return 'get';
531
        }
532
533 15
        if (count($arguments) < count($resources)) {
534
            // resource collection
535 15
            return 'get';
536
        }
537
538
        //custom object
539 14
        return 'patch';
540
    }
541
542
    /**
543
     * Returns first route annotation for method.
544
     *
545
     * @param \ReflectionMethod $reflectionMethod
546
     *
547
     * @return RouteAnnotation[]
548
     */
549 24
    private function readRouteAnnotation(\ReflectionMethod $reflectionMethod)
550
    {
551 24
        $annotations = [];
552
553 24
        foreach (['Route', 'Get', 'Post', 'Put', 'Patch', 'Delete', 'Link', 'Unlink', 'Head', 'Options'] as $annotationName) {
554 24
            if ($annotations_new = $this->readMethodAnnotations($reflectionMethod, $annotationName)) {
555 8
                $annotations = array_merge($annotations, $annotations_new);
556 8
            }
557 24
        }
558
559 24
        return $annotations;
560
    }
561
562
    /**
563
     * Reads class annotations.
564
     *
565
     * @param \ReflectionClass $reflectionClass
566
     * @param string           $annotationName
567
     *
568
     * @return RouteAnnotation|null
569
     */
570 24
    private function readClassAnnotation(\ReflectionClass $reflectionClass, $annotationName)
571
    {
572 24
        $annotationClass = "FOS\\RestBundle\\Controller\\Annotations\\$annotationName";
573
574 24
        if ($annotation = $this->annotationReader->getClassAnnotation($reflectionClass, $annotationClass)) {
575
            return $annotation;
576
        }
577 24
    }
578
579
    /**
580
     * Reads method annotations.
581
     *
582
     * @param \ReflectionMethod $reflectionMethod
583
     * @param string            $annotationName
584
     *
585
     * @return RouteAnnotation|null
586
     */
587 24
    private function readMethodAnnotation(\ReflectionMethod $reflectionMethod, $annotationName)
588
    {
589 24
        $annotationClass = "FOS\\RestBundle\\Controller\\Annotations\\$annotationName";
590
591 24
        if ($annotation = $this->annotationReader->getMethodAnnotation($reflectionMethod, $annotationClass)) {
592 8
            return $annotation;
593
        }
594 24
    }
595
596
    /**
597
     * Reads method annotations.
598
     *
599
     * @param \ReflectionMethod $reflectionMethod
600
     * @param string            $annotationName
601
     *
602
     * @return RouteAnnotation[]
603
     */
604 24
    private function readMethodAnnotations(\ReflectionMethod $reflectionMethod, $annotationName)
605
    {
606 24
        $annotations = [];
607 24
        $annotationClass = "FOS\\RestBundle\\Controller\\Annotations\\$annotationName";
608
609 24
        if ($annotations_new = $this->annotationReader->getMethodAnnotations($reflectionMethod)) {
610 8
            foreach ($annotations_new as $annotation) {
611 8
                if ($annotation instanceof $annotationClass) {
612 8
                    $annotations[] = $annotation;
613 8
                }
614 8
            }
615 8
        }
616
617 24
        return $annotations;
618
    }
619
620
    /**
621
     * @param RestRouteCollection $collection
622
     * @param string              $routeName
623
     * @param Route               $route
624
     * @param bool                $isCollection
625
     * @param bool                $isInflectable
626
     * @param RouteAnnotation     $annotation
627
     */
628 24
    private function addRoute(RestRouteCollection $collection, $routeName, $route, $isCollection, $isInflectable, RouteAnnotation $annotation = null)
629
    {
630 24
        if ($annotation && null !== $annotation->getName()) {
631 4
            $options = $annotation->getOptions();
632
633 4
            if (isset($options['method_prefix']) && false === $options['method_prefix']) {
634 3
                $routeName = $annotation->getName();
635 3
            } else {
636 4
                $routeName = $routeName.$annotation->getName();
637
            }
638 4
        }
639
640 24
        $fullRouteName = $this->namePrefix.$routeName;
641
642 24
        if ($isCollection && !$isInflectable) {
643 4
            $collection->add($this->namePrefix.self::COLLECTION_ROUTE_PREFIX.$routeName, $route);
644 4
            if (!$collection->get($fullRouteName)) {
645 4
                $collection->add($fullRouteName, clone $route);
646 4
            }
647 4
        } else {
648 22
            $collection->add($fullRouteName, $route);
649
        }
650 24
    }
651
}
652