Completed
Pull Request — 2.x (#2152)
by Christian
03:49 queued 01:57
created

RestActionReader::setVersions()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 4

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 3
CRAP Score 1

Importance

Changes 0
Metric Value
dl 0
loc 4
ccs 3
cts 3
cp 1
rs 10
c 0
b 0
f 0
cc 1
nc 1
nop 1
crap 1
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 1
@trigger_error(sprintf('The %s\RestActionReader class is deprecated since FOSRestBundle 2.8.', __NAMESPACE__), E_USER_DEPRECATED);
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition here. This can introduce security issues, and is generally not recommended.

If you suppress an error, we recommend checking for the error condition explicitly:

// For example instead of
@mkdir($dir);

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