Completed
Push — master ( fe85dd...46f70f )
by Guilh
10:36 queued 07:29
created

RestActionReader::getNamePrefix()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 4
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 2
Metric Value
dl 0
loc 4
ccs 0
cts 2
cp 0
rs 10
cc 1
eloc 2
nc 1
nop 0
crap 2
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()];
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()];
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)) {
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)) {
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 type hinted arguments that are or extend from:
409
        // * Symfony\Component\HttpFoundation\Request
410
        // * FOS\RestBundle\Request\QueryFetcher
411
        // * Symfony\Component\Validator\ConstraintViolationList
412
        $ignoreClasses = [
413 24
            'Symfony\Component\HttpFoundation\Request',
414 24
            'FOS\RestBundle\Request\ParamFetcherInterface',
415 24
            'Symfony\Component\Validator\ConstraintViolationListInterface',
416 24
            'Sensio\Bundle\FrameworkExtraBundle\Configuration\ParamConverter',
417 24
        ];
418
419 24
        $arguments = [];
420 24
        foreach ($method->getParameters() as $argument) {
421 21
            if (isset($params[$argument->getName()])) {
422
                continue;
423
            }
424
425 21
            $argumentClass = $argument->getClass();
426 21
            if ($argumentClass) {
427 10
                foreach ($ignoreClasses as $class) {
428 10
                    if ($argumentClass->getName() === $class || $argumentClass->isSubclassOf($class)) {
429 10
                        continue 2;
430
                    }
431
                }
432
            }
433
434 21
            $arguments[] = $argument;
435 24
        }
436
437 24
        return $arguments;
438
    }
439
440
    /**
441
     * Generates final resource name.
442
     *
443
     * @param string|bool $resource
444
     *
445
     * @return string
446
     */
447 24
    private function generateResourceName($resource)
448
    {
449 24
        if (false === $this->pluralize) {
450 1
            return $resource;
451
        }
452
453 23
        return $this->inflector->pluralize($resource);
0 ignored issues
show
Bug introduced by
It seems like $resource defined by parameter $resource on line 447 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...
454
    }
455
456
    /**
457
     * Generates route name from resources list.
458
     *
459
     * @param string[] $resources
460
     *
461
     * @return string
462
     */
463 24
    private function generateRouteName(array $resources)
464
    {
465 24
        $routeName = '';
466 24
        foreach ($resources as $resource) {
467 24
            if (null !== $resource) {
468 24
                $routeName .= '_'.basename($resource);
469 24
            }
470 24
        }
471
472 24
        return $routeName;
473
    }
474
475
    /**
476
     * Generates URL parts for route from resources list.
477
     *
478
     * @param string[]               $resources
479
     * @param \ReflectionParameter[] $arguments
480
     * @param string                 $httpMethod
481
     *
482
     * @return array
483
     */
484 24
    private function generateUrlParts(array $resources, array $arguments, $httpMethod)
485
    {
486 24
        $urlParts = [];
487 24
        foreach ($resources as $i => $resource) {
488
            // if we already added all parent routes paths to URL & we have
489
            // prefix - add it
490 24
            if (!empty($this->routePrefix) && $i === count($this->parents)) {
491 3
                $urlParts[] = $this->routePrefix;
492 3
            }
493
494
            // if we have argument for current resource, then it's object.
495
            // otherwise - it's collection
496 24
            if (isset($arguments[$i])) {
497 21
                if (null !== $resource) {
498 21
                    $urlParts[] =
499 21
                        strtolower($this->generateResourceName($resource))
500 21
                        .'/{'.$arguments[$i]->getName().'}';
501 21
                } else {
502
                    $urlParts[] = '{'.$arguments[$i]->getName().'}';
503
                }
504 24
            } elseif (null !== $resource) {
505 24
                if ((0 === count($arguments) && !in_array($httpMethod, $this->availableHTTPMethods))
506
                    || 'new' === $httpMethod
507 23
                    || 'post' === $httpMethod
508 24
                ) {
509 17
                    $urlParts[] = $this->generateResourceName(strtolower($resource));
510 17
                } else {
511 23
                    $urlParts[] = strtolower($resource);
512
                }
513 24
            }
514 24
        }
515
516 24
        return $urlParts;
517
    }
518
519
    /**
520
     * Returns custom HTTP method for provided list of resources, arguments, method.
521
     *
522
     * @param string                 $httpMethod current HTTP method
523
     * @param string[]               $resources  resources list
524
     * @param \ReflectionParameter[] $arguments  list of method arguments
525
     *
526
     * @return string
527
     */
528 17
    private function getCustomHttpMethod($httpMethod, array $resources, array $arguments)
529
    {
530 17
        if (in_array($httpMethod, $this->availableConventionalActions)) {
531
            // allow hypertext as the engine of application state
532
            // through conventional GET actions
533 12
            return 'get';
534
        }
535
536 15
        if (count($arguments) < count($resources)) {
537
            // resource collection
538 15
            return 'get';
539
        }
540
541
        //custom object
542 14
        return 'patch';
543
    }
544
545
    /**
546
     * Returns first route annotation for method.
547
     *
548
     * @param \ReflectionMethod $reflectionMethod
549
     *
550
     * @return RouteAnnotation[]
551
     */
552 24
    private function readRouteAnnotation(\ReflectionMethod $reflectionMethod)
553
    {
554 24
        $annotations = [];
555
556 24
        foreach (['Route', 'Get', 'Post', 'Put', 'Patch', 'Delete', 'Link', 'Unlink', 'Head', 'Options'] as $annotationName) {
557 24
            if ($annotations_new = $this->readMethodAnnotations($reflectionMethod, $annotationName)) {
558 8
                $annotations = array_merge($annotations, $annotations_new);
559 8
            }
560 24
        }
561
562 24
        return $annotations;
563
    }
564
565
    /**
566
     * Reads class annotations.
567
     *
568
     * @param \ReflectionClass $reflectionClass
569
     * @param string           $annotationName
570
     *
571
     * @return RouteAnnotation|null
572
     */
573 24
    private function readClassAnnotation(\ReflectionClass $reflectionClass, $annotationName)
574
    {
575 24
        $annotationClass = "FOS\\RestBundle\\Controller\\Annotations\\$annotationName";
576
577 24
        if ($annotation = $this->annotationReader->getClassAnnotation($reflectionClass, $annotationClass)) {
578
            return $annotation;
579
        }
580 24
    }
581
582
    /**
583
     * Reads method annotations.
584
     *
585
     * @param \ReflectionMethod $reflectionMethod
586
     * @param string            $annotationName
587
     *
588
     * @return RouteAnnotation|null
589
     */
590 24
    private function readMethodAnnotation(\ReflectionMethod $reflectionMethod, $annotationName)
591
    {
592 24
        $annotationClass = "FOS\\RestBundle\\Controller\\Annotations\\$annotationName";
593
594 24
        if ($annotation = $this->annotationReader->getMethodAnnotation($reflectionMethod, $annotationClass)) {
595 8
            return $annotation;
596
        }
597 24
    }
598
599
    /**
600
     * Reads method annotations.
601
     *
602
     * @param \ReflectionMethod $reflectionMethod
603
     * @param string            $annotationName
604
     *
605
     * @return RouteAnnotation[]
606
     */
607 24
    private function readMethodAnnotations(\ReflectionMethod $reflectionMethod, $annotationName)
608
    {
609 24
        $annotations = [];
610 24
        $annotationClass = "FOS\\RestBundle\\Controller\\Annotations\\$annotationName";
611
612 24
        if ($annotations_new = $this->annotationReader->getMethodAnnotations($reflectionMethod)) {
613 8
            foreach ($annotations_new as $annotation) {
614 8
                if ($annotation instanceof $annotationClass) {
615 8
                    $annotations[] = $annotation;
616 8
                }
617 8
            }
618 8
        }
619
620 24
        return $annotations;
621
    }
622
623
    /**
624
     * @param RestRouteCollection $collection
625
     * @param string              $routeName
626
     * @param Route               $route
627
     * @param bool                $isCollection
628
     * @param bool                $isInflectable
629
     * @param RouteAnnotation     $annotation
630
     */
631 24
    private function addRoute(RestRouteCollection $collection, $routeName, $route, $isCollection, $isInflectable, RouteAnnotation $annotation = null)
632
    {
633 24
        if ($annotation && null !== $annotation->getName()) {
634 4
            $options = $annotation->getOptions();
635
636 4
            if (isset($options['method_prefix']) && false === $options['method_prefix']) {
637 3
                $routeName = $annotation->getName();
638 3
            } else {
639 4
                $routeName = $routeName.$annotation->getName();
640
            }
641 4
        }
642
643 24
        $fullRouteName = $this->namePrefix.$routeName;
644
645 24
        if ($isCollection && !$isInflectable) {
646 4
            $collection->add($this->namePrefix.self::COLLECTION_ROUTE_PREFIX.$routeName, $route);
647 4
            if (!$collection->get($fullRouteName)) {
648 4
                $collection->add($fullRouteName, clone $route);
649 4
            }
650 4
        } else {
651 22
            $collection->add($fullRouteName, $route);
652
        }
653 24
    }
654
}
655