Completed
Push — master ( 5e3850...372527 )
by
unknown
09:24
created

Routing/Loader/Reader/RestActionReader.php (1 issue)

Labels
Severity

Upgrade to new PHP Analysis Engine

These results are based on our legacy PHP analysis, consider migrating to our new PHP analysis engine instead. Learn more

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\ParamFetcherInterface;
18
use FOS\RestBundle\Request\ParamReaderInterface;
19
use FOS\RestBundle\Routing\RestRouteCollection;
20
use Psr\Http\Message\MessageInterface;
21
use Sensio\Bundle\FrameworkExtraBundle\Configuration\ParamConverter;
22
use Symfony\Component\HttpFoundation\Request;
23
use Symfony\Component\Routing\Route;
24
use Symfony\Component\Validator\ConstraintViolationListInterface;
25
26
/**
27
 * REST controller actions reader.
28
 *
29
 * @author Konstantin Kudryashov <[email protected]>
30
 */
31
class RestActionReader
32
{
33
    const COLLECTION_ROUTE_PREFIX = 'c';
34
35
    /**
36
     * @var Reader
37
     */
38
    private $annotationReader;
39
40
    /**
41
     * @var ParamReaderInterface
42
     */
43
    private $paramReader;
44
45
    /**
46
     * @var InflectorInterface
47
     */
48
    private $inflector;
49
50
    /**
51
     * @var array
52
     */
53
    private $formats;
54
55
    /**
56
     * @var bool
57
     */
58
    private $includeFormat;
59
60
    /**
61
     * @var string|null
62
     */
63
    private $routePrefix;
64
65
    /**
66
     * @var string|null
67
     */
68
    private $namePrefix;
69
70
    /**
71
     * @var array|string|null
72
     */
73
    private $versions;
74
75
    /**
76
     * @var bool|null
77
     */
78
    private $pluralize;
79
80
    /**
81
     * @var array
82
     */
83
    private $parents = [];
84
85
    /**
86
     * @var array
87
     */
88
    private $availableHTTPMethods = [
89
        'get',
90
        'post',
91
        'put',
92
        'patch',
93
        'delete',
94
        'link',
95
        'unlink',
96
        'head',
97
        'options',
98
        'mkcol',
99
        'propfind',
100
        'proppatch',
101
        'move',
102
        'copy',
103
        'lock',
104
        'unlock',
105
    ];
106
107
    /**
108
     * @var array
109
     */
110
    private $availableConventionalActions = ['new', 'edit', 'remove'];
111
112
    /**
113
     * @var bool
114
     */
115
    private $hasMethodPrefix;
116
117 56
    /**
118
     * Initializes controller reader.
119 56
     *
120 56
     * @param Reader               $annotationReader
121 56
     * @param ParamReaderInterface $paramReader
122 56
     * @param InflectorInterface   $inflector
123 56
     * @param bool                 $includeFormat
124 56
     * @param array                $formats
125
     * @param bool                 $hasMethodPrefix
126
     */
127
    public function __construct(Reader $annotationReader, ParamReaderInterface $paramReader, InflectorInterface $inflector, $includeFormat, array $formats = [], $hasMethodPrefix = true)
128
    {
129
        $this->annotationReader = $annotationReader;
130
        $this->paramReader = $paramReader;
131 37
        $this->inflector = $inflector;
132
        $this->includeFormat = $includeFormat;
133 37
        $this->formats = $formats;
134 37
        $this->hasMethodPrefix = $hasMethodPrefix;
135
    }
136
137
    /**
138
     * Sets routes prefix.
139
     *
140
     * @param string $prefix Routes prefix
141 37
     */
142
    public function setRoutePrefix($prefix = null)
143 37
    {
144
        $this->routePrefix = $prefix;
145
    }
146
147
    /**
148
     * Returns route prefix.
149
     *
150
     * @return string
151 37
     */
152
    public function getRoutePrefix()
153 37
    {
154 37
        return $this->routePrefix;
155
    }
156
157
    /**
158
     * Sets route names prefix.
159
     *
160
     * @param string $prefix Route names prefix
161
     */
162
    public function setNamePrefix($prefix = null)
163
    {
164
        $this->namePrefix = $prefix;
165
    }
166
167
    /**
168
     * Returns name prefix.
169
     *
170
     * @return string
171 37
     */
172
    public function getNamePrefix()
173 37
    {
174 37
        return $this->namePrefix;
175
    }
176
177
    /**
178
     * Sets route names versions.
179
     *
180
     * @param array|string|null $versions Route names versions
181
     */
182
    public function setVersions($versions = null)
183
    {
184
        $this->versions = (array) $versions;
185
    }
186
187
    /**
188
     * Returns versions.
189
     *
190
     * @return array|null
191 37
     */
192
    public function getVersions()
193 37
    {
194 37
        return $this->versions;
195
    }
196
197
    /**
198
     * Sets pluralize.
199
     *
200
     * @param bool|null $pluralize Specify if resource name must be pluralized
201
     */
202
    public function setPluralize($pluralize)
203
    {
204
        $this->pluralize = $pluralize;
205
    }
206
207
    /**
208
     * Returns pluralize.
209
     *
210
     * @return bool|null
211 37
     */
212
    public function getPluralize()
213 37
    {
214 37
        return $this->pluralize;
215
    }
216
217
    /**
218
     * Set parent routes.
219
     *
220
     * @param array $parents Array of parent resources names
221
     */
222
    public function setParents(array $parents)
223
    {
224
        $this->parents = $parents;
225
    }
226
227
    /**
228
     * Returns parents.
229
     *
230
     * @return array
231
     */
232
    public function getParents()
233
    {
234
        return $this->parents;
235
    }
236
237 37
    /**
238
     * Reads action route.
239
     *
240 37
     * @param RestRouteCollection $collection
241 5
     * @param \ReflectionMethod   $method
242
     * @param string[]            $resource
243
     *
244
     * @throws \InvalidArgumentException
245
     *
246
     * @return Route
247 37
     */
248
    public function read(RestRouteCollection $collection, \ReflectionMethod $method, $resource)
249
    {
250 37
        // check that every route parent has non-empty singular name
251 14
        foreach ($this->parents as $parent) {
252
            if (empty($parent) || '/' === substr($parent, -1)) {
253
                throw new \InvalidArgumentException('Every parent controller must have `get{SINGULAR}Action(\$id)` method where {SINGULAR} is a singular form of associated object');
254
            }
255 37
        }
256 37
257 35
        // if method is not readable - skip
258
        if (!$this->isMethodReadable($method)) {
259
            return;
260 37
        }
261 37
262
        // if we can't get http-method and resources from method name - skip
263
        $httpMethodAndResources = $this->getHttpMethodAndResourcesFromMethod($method, $resource);
264
        if (!$httpMethodAndResources) {
265 37
            return;
266 21
        }
267 21
268
        list($httpMethod, $resources, $isCollection, $isInflectable) = $httpMethodAndResources;
269
        $arguments = $this->getMethodArguments($method);
270 37
271 5
        // if we have only 1 resource & 1 argument passed, then it's object call, so
272 5
        // we can set collection singular name
273
        if (1 === count($resources) && 1 === count($arguments) - count($this->parents)) {
274 37
            $collection->setSingularName($resources[0]);
275 10
        }
276 10
277
        // if we have parents passed - merge them with own resource names
278 37
        if (count($this->parents)) {
279 37
            $resources = array_merge($this->parents, $resources);
280
        }
281
282
        if (empty($resources)) {
283
            $resources[] = null;
284 37
        }
285 27
286 27
        $routeName = $httpMethod.$this->generateRouteName($resources);
287 27
        $urlParts = $this->generateUrlParts($resources, $arguments, $httpMethod);
288
289
        // if passed method is not valid HTTP method then it's either
290 37
        // a hypertext driver, a custom object (PUT) or collection (GET)
291 37
        // method
292 37
        if (!in_array($httpMethod, $this->availableHTTPMethods)) {
293 37
            $urlParts[] = $httpMethod;
294 37
            $httpMethod = $this->getCustomHttpMethod($httpMethod, $resources, $arguments);
295 37
        }
296 37
297
        // generated parameters
298 37
        $routeName = strtolower($routeName);
299 37
        $path = implode('/', $urlParts);
300 18
        $defaults = ['_controller' => $method->getName()];
301 18
        $requirements = [];
302 18
        $options = [];
303 18
        $host = '';
304 18
        $versionCondition = $this->getVersionCondition();
305 18
306
        $annotations = $this->readRouteAnnotation($method);
307 18
        if (!empty($annotations)) {
308 18
            foreach ($annotations as $annotation) {
309
                $path = implode('/', $urlParts);
310 18
                $defaults = ['_controller' => $method->getName()];
311 18
                $requirements = [];
312 18
                $options = [];
313
                $methods = explode('|', $httpMethod);
314 18
315 18
                $annoRequirements = $annotation->getRequirements();
316 18
                $annoMethods = $annotation->getMethods();
317 18
318 18
                if (!empty($annoMethods)) {
319 18
                    $methods = $annoMethods;
320 18
                }
321
322 18
                $path = null !== $annotation->getPath() ? $this->routePrefix.$annotation->getPath() : $path;
323
                $requirements = array_merge($requirements, $annoRequirements);
324
                $options = array_merge($options, $annotation->getOptions());
325 18
                $defaults = array_merge($defaults, $annotation->getDefaults());
326 18
                $host = $annotation->getHost();
327 18
                $schemes = $annotation->getSchemes();
328 18
                $combinedCondition = $this->combineConditions($versionCondition, $annotation->getCondition());
329 18
330 18
                $this->includeFormatIfNeeded($path, $requirements);
331 37
332
                // add route to collection
333 37
                $route = new Route(
334
                    $path, $defaults, $requirements, $options, $host, $schemes, $methods, $combinedCondition
335
                );
336 37
                $this->addRoute($collection, $routeName, $route, $isCollection, $isInflectable, $annotation);
337 37
            }
338 37
        } else {
339 37
            $this->includeFormatIfNeeded($path, $requirements);
340
341 37
            $methods = explode('|', strtoupper($httpMethod));
342
343
            // add route to collection
344
            $route = new Route(
345
                $path, $defaults, $requirements, $options, $host, [], $methods, $versionCondition
346 37
            );
347
            $this->addRoute($collection, $routeName, $route, $isCollection, $isInflectable);
348 37
        }
349 36
    }
350
351
    /**
352 11
     * @return string|null
353
     */
354
    private function getVersionCondition()
355
    {
356
        if (empty($this->versions)) {
357
            return;
358
        }
359
360
        return sprintf("request.attributes.get('version') in ['%s']", implode("', '", $this->versions));
361 18
    }
362
363 18
    /**
364 7
     * @param string|null $conditionOne
365
     * @param string|null $conditionTwo
366
     *
367 11
     * @return string|null
368 11
     */
369
    private function combineConditions($conditionOne, $conditionTwo)
370
    {
371 1
        if (null === $conditionOne) {
372
            return $conditionTwo;
373
        }
374
375
        if (null === $conditionTwo) {
376
            return $conditionOne;
377
        }
378
379
        return sprintf('(%s) and (%s)', $conditionOne, $conditionTwo);
380 37
    }
381
382 37
    /**
383 37
     * Include the format in the path and requirements if its enabled.
384
     *
385 37
     * @param string $path
386 12
     * @param array  $requirements
387 12
     */
388 37
    private function includeFormatIfNeeded(&$path, &$requirements)
389 37
    {
390 View Code Duplication
        if (true === $this->includeFormat) {
391
            $path .= '.{_format}';
392
393
            if (!isset($requirements['_format']) && !empty($this->formats)) {
394
                $requirements['_format'] = implode('|', array_keys($this->formats));
395
            }
396
        }
397
    }
398 37
399
    /**
400
     * Checks whether provided method is readable.
401 37
     *
402 10
     * @param \ReflectionMethod $method
403
     *
404
     * @return bool
405 37
     */
406 37
    private function isMethodReadable(\ReflectionMethod $method)
407
    {
408 37
        // if method starts with _ - skip
409
        if ('_' === substr($method->getName(), 0, 1)) {
410 37
            return false;
411
        }
412
413 37
        $hasNoRouteMethod = (bool) $this->readMethodAnnotation($method, 'NoRoute');
414 4
        $hasNoRouteClass = (bool) $this->readClassAnnotation($method->getDeclaringClass(), 'NoRoute');
415
416
        $hasNoRoute = $hasNoRouteMethod || $hasNoRouteClass;
417 37
        // since NoRoute extends Route we need to exclude all the method NoRoute annotations
418
        $hasRoute = (bool) $this->readMethodAnnotation($method, 'Route') && !$hasNoRouteMethod;
419
420
        // if method has NoRoute annotation and does not have Route annotation - skip
421
        if ($hasNoRoute && !$hasRoute) {
422
            return false;
423
        }
424
425
        return true;
426
    }
427
428 37
    /**
429
     * Returns HTTP method and resources list from method signature.
430
     *
431 37
     * @param \ReflectionMethod $method
432 35
     * @param string[]          $resource
433
     *
434
     * @return bool|array
435 37
     */
436 37
    private function getHttpMethodAndResourcesFromMethod(\ReflectionMethod $method, $resource)
437 37
    {
438 37
        // if method doesn't match regex - skip
439 37
        if (!preg_match('/([a-z][_a-z0-9]+)(.*)Action/', $method->getName(), $matches)) {
440 37
            return false;
441
        }
442 37
443 37
        $httpMethod = strtolower($matches[1]);
444 37
        $resources = preg_split(
445 16
            '/([A-Z][^A-Z]*)/', $matches[2], -1, PREG_SPLIT_NO_EMPTY | PREG_SPLIT_DELIM_CAPTURE
446 16
        );
447 37
        $isCollection = false;
448 14
        $isInflectable = true;
449 14
450
        if (0 === strpos($httpMethod, self::COLLECTION_ROUTE_PREFIX)
451 37
            && in_array(substr($httpMethod, 1), $this->availableHTTPMethods)
452 16
        ) {
453 16
            $isCollection = true;
454 16
            $httpMethod = substr($httpMethod, 1);
455 16
        } elseif ('options' === $httpMethod) {
456
            $isCollection = true;
457 37
        }
458
459 37
        if ($isCollection && !empty($resource)) {
460
            $resourcePluralized = $this->generateResourceName(end($resource));
461
            $isInflectable = ($resourcePluralized != $resource[count($resource) - 1]);
462
            $resource[count($resource) - 1] = $resourcePluralized;
463
        }
464
465
        $resources = array_merge($resource, $resources);
466
467
        return [$httpMethod, $resources, $isCollection, $isInflectable];
468
    }
469 37
470
    /**
471
     * Returns readable arguments from method.
472 37
     *
473
     * @param \ReflectionMethod $method
474
     *
475
     * @return \ReflectionParameter[]
476 37
     */
477 37
    private function getMethodArguments(\ReflectionMethod $method)
478 37
    {
479 37
        // ignore all query params
480 37
        $params = $this->paramReader->getParamsFromMethod($method);
481 37
482
        // check if a parameter is coming from the request body
483 37
        $ignoreParameters = [];
484 37
        if (class_exists(ParamConverter::class)) {
485 34
            $ignoreParameters = array_map(function ($annotation) {
486
                return
487
                    $annotation instanceof ParamConverter &&
488
                    'fos_rest.request_body' === $annotation->getConverter()
489 34
                        ? $annotation->getName() : null;
490 34
            }, $this->annotationReader->getMethodAnnotations($method));
491 22
        }
492 22
493 22
        // ignore several type hinted arguments
494 21
        $ignoreClasses = [
495
            Request::class,
496 3
            ParamFetcherInterface::class,
497 1
            ConstraintViolationListInterface::class,
498
            ParamConverter::class,
499 34
            MessageInterface::class,
500 37
        ];
501
502 37
        $arguments = [];
503
        foreach ($method->getParameters() as $argument) {
504
            if (isset($params[$argument->getName()])) {
505
                continue;
506
            }
507
508
            $argumentClass = $argument->getClass();
509
            if ($argumentClass) {
510
                $className = $argumentClass->getName();
511
                foreach ($ignoreClasses as $class) {
512 37
                    if ($className === $class || is_subclass_of($className, $class)) {
513
                        continue 2;
514 37
                    }
515 1
                }
516
            }
517
518 36
            if (in_array($argument->getName(), $ignoreParameters, true)) {
0 ignored issues
show
Consider using $argument->name. There is an issue with getName() and APC-enabled PHP versions.
Loading history...
519
                continue;
520
            }
521
522
            $arguments[] = $argument;
523
        }
524
525
        return $arguments;
526
    }
527
528 37
    /**
529
     * Generates final resource name.
530 37
     *
531 37
     * @param string|bool $resource
532 37
     *
533 37
     * @return string
534 37
     */
535 37
    private function generateResourceName($resource)
536
    {
537 37
        if (false === $this->pluralize) {
538
            return $resource;
539
        }
540
541
        return $this->inflector->pluralize($resource);
542
    }
543
544
    /**
545
     * Generates route name from resources list.
546
     *
547
     * @param string[] $resources
548
     *
549 37
     * @return string
550
     */
551 37
    private function generateRouteName(array $resources)
552 37
    {
553
        $routeName = '';
554
        foreach ($resources as $resource) {
555 37
            if (null !== $resource) {
556 3
                $routeName .= '_'.basename($resource);
557 3
            }
558
        }
559
560
        return $routeName;
561 37
    }
562 34
563 24
    /**
564 24
     * Generates URL parts for route from resources list.
565 24
     *
566 24
     * @param string[]               $resources
567 10
     * @param \ReflectionParameter[] $arguments
568
     * @param string                 $httpMethod
569 37
     *
570 36
     * @return array
571
     */
572 35
    private function generateUrlParts(array $resources, array $arguments, $httpMethod)
573 36
    {
574 29
        $urlParts = [];
575 29
        foreach ($resources as $i => $resource) {
576 35
            // if we already added all parent routes paths to URL & we have
577
            // prefix - add it
578 36
            if (!empty($this->routePrefix) && $i === count($this->parents)) {
579 37
                $urlParts[] = $this->routePrefix;
580
            }
581 37
582
            // if we have argument for current resource, then it's object.
583
            // otherwise - it's collection
584
            if (isset($arguments[$i])) {
585
                if (null !== $resource) {
586
                    $urlParts[] =
587
                        strtolower($this->generateResourceName($resource))
588
                        .'/{'.$arguments[$i]->getName().'}';
589
                } else {
590
                    $urlParts[] = '{'.$arguments[$i]->getName().'}';
591
                }
592
            } elseif (null !== $resource) {
593 27
                if ((0 === count($arguments) && !in_array($httpMethod, $this->availableHTTPMethods))
594
                    || 'new' === $httpMethod
595 27
                    || 'post' === $httpMethod
596
                ) {
597
                    $urlParts[] = $this->generateResourceName(strtolower($resource));
598 12
                } else {
599
                    $urlParts[] = strtolower($resource);
600
                }
601 25
            }
602
        }
603 15
604
        return $urlParts;
605
    }
606
607 24
    /**
608
     * Returns custom HTTP method for provided list of resources, arguments, method.
609
     *
610
     * @param string                 $httpMethod current HTTP method
611
     * @param string[]               $resources  resources list
612
     * @param \ReflectionParameter[] $arguments  list of method arguments
613
     *
614
     * @return string
615
     */
616
    private function getCustomHttpMethod($httpMethod, array $resources, array $arguments)
617 37
    {
618
        if (in_array($httpMethod, $this->availableConventionalActions)) {
619 37
            // allow hypertext as the engine of application state
620
            // through conventional GET actions
621 37
            return 'get';
622 18
        }
623 18
624
        if (count($arguments) < count($resources)) {
625 37
            // resource collection
626
            return 'get';
627
        }
628
629
        // custom object
630
        return 'patch';
631
    }
632
633
    /**
634
     * Returns first route annotation for method.
635
     *
636 37
     * @param \ReflectionMethod $reflectionMethod
637
     *
638 37
     * @return RouteAnnotation[]
639
     */
640 37
    private function readRouteAnnotation(\ReflectionMethod $reflectionMethod)
641
    {
642
        $annotations = [];
643 37
644
        if ($newAnnotations = $this->readMethodAnnotations($reflectionMethod, 'Route')) {
645
            $annotations = array_merge($annotations, $newAnnotations);
646
        }
647
648
        return $annotations;
649
    }
650
651
    /**
652
     * Reads class annotations.
653 37
     *
654
     * @param \ReflectionClass $reflectionClass
655 37
     * @param string           $annotationName
656
     *
657 37
     * @return RouteAnnotation|null
658 18
     */
659
    private function readClassAnnotation(\ReflectionClass $reflectionClass, $annotationName)
660 37
    {
661
        $annotationClass = "FOS\\RestBundle\\Controller\\Annotations\\$annotationName";
662
663
        if ($annotation = $this->annotationReader->getClassAnnotation($reflectionClass, $annotationClass)) {
664
            return $annotation;
665
        }
666
    }
667
668
    /**
669
     * Reads method annotations.
670 37
     *
671
     * @param \ReflectionMethod $reflectionMethod
672 37
     * @param string            $annotationName
673 37
     *
674
     * @return RouteAnnotation|null
675 37
     */
676 18
    private function readMethodAnnotation(\ReflectionMethod $reflectionMethod, $annotationName)
677 18
    {
678 18
        $annotationClass = "FOS\\RestBundle\\Controller\\Annotations\\$annotationName";
679 18
680 18
        if ($annotation = $this->annotationReader->getMethodAnnotation($reflectionMethod, $annotationClass)) {
681 18
            return $annotation;
682
        }
683 37
    }
684
685
    /**
686
     * Reads method annotations.
687
     *
688
     * @param \ReflectionMethod $reflectionMethod
689
     * @param string            $annotationName
690
     *
691
     * @return RouteAnnotation[]
692
     */
693
    private function readMethodAnnotations(\ReflectionMethod $reflectionMethod, $annotationName)
694 37
    {
695
        $annotations = [];
696 37
        $annotationClass = "FOS\\RestBundle\\Controller\\Annotations\\$annotationName";
697 4
698
        if ($annotations_new = $this->annotationReader->getMethodAnnotations($reflectionMethod)) {
699 4
            foreach ($annotations_new as $annotation) {
700 3
                if ($annotation instanceof $annotationClass) {
701 3
                    $annotations[] = $annotation;
702 4
                }
703
            }
704 4
        }
705
706 37
        return $annotations;
707
    }
708 37
709 4
    /**
710 4
     * @param RestRouteCollection $collection
711 4
     * @param string              $routeName
712 4
     * @param Route               $route
713 4
     * @param bool                $isCollection
714 35
     * @param bool                $isInflectable
715
     * @param RouteAnnotation     $annotation
716 37
     */
717
    private function addRoute(RestRouteCollection $collection, $routeName, $route, $isCollection, $isInflectable, RouteAnnotation $annotation = null)
718
    {
719
        if ($annotation && null !== $annotation->getName()) {
720
            $options = $annotation->getOptions();
721
722
            if (false === $this->hasMethodPrefix || (isset($options['method_prefix']) && false === $options['method_prefix'])) {
723
                $routeName = $annotation->getName();
724
            } else {
725
                $routeName .= $annotation->getName();
726
            }
727
        }
728
729
        $fullRouteName = $this->namePrefix.$routeName;
730
731
        if ($isCollection && !$isInflectable) {
732
            $collection->add($this->namePrefix.self::COLLECTION_ROUTE_PREFIX.$routeName, $route);
733
            if (!$collection->get($fullRouteName)) {
734
                $collection->add($fullRouteName, clone $route);
735
            }
736
        } else {
737
            $collection->add($fullRouteName, $route);
738
        }
739
    }
740
}
741