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

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

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