Passed
Push — master ( d17b70...413065 )
by Divine Niiquaye
03:12
created

RouteLoader::addRouteGroup()   A

Complexity

Conditions 4
Paths 5

Size

Total Lines 25
Code Lines 15

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 17
CRAP Score 4

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 4
eloc 15
c 1
b 0
f 0
nc 5
nop 2
dl 0
loc 25
ccs 17
cts 17
cp 1
crap 4
rs 9.7666
1
<?php
2
3
declare(strict_types=1);
4
5
/*
6
 * This file is part of Flight Routing.
7
 *
8
 * PHP version 7.1 and above required
9
 *
10
 * @author    Divine Niiquaye Ibok <[email protected]>
11
 * @copyright 2019 Biurad Group (https://biurad.com/)
12
 * @license   https://opensource.org/licenses/BSD-3-Clause License
13
 *
14
 * For the full copyright and license information, please view the LICENSE
15
 * file that was distributed with this source code.
16
 */
17
18
namespace Flight\Routing;
19
20
use Doctrine\Common\Annotations\Reader as AnnotationReader;
21
use Doctrine\Common\Annotations\SimpleAnnotationReader;
22
use FilesystemIterator;
23
use Flight\Routing\Exceptions\InvalidAnnotationException;
24
use Flight\Routing\Interfaces\RouteCollectionInterface;
25
use Flight\Routing\Interfaces\RouteCollectorInterface;
26
use RecursiveDirectoryIterator;
27
use RecursiveIteratorIterator;
28
use ReflectionClass;
29
use ReflectionMethod;
30
use Reflector;
31
use RegexIterator;
32
33
class RouteLoader
34
{
35
    /** @var RouteCollectorInterface */
36
    private $collector;
37
38
    /** @var null|AnnotationReader */
39
    private $annotation;
40
41
    /** @var string[] */
42
    private $resources = [];
43
44
    /** @var int */
45
    private $defaultRouteIndex = 0;
46
47
    /**
48
     * @param RouteCollectorInterface $collector
49
     * @param null|AnnotationReader   $reader
50
     */
51 15
    public function __construct(RouteCollectorInterface $collector, ?AnnotationReader $reader = null)
52
    {
53 15
        $this->collector  = $collector;
54 15
        $this->annotation = $reader;
55
56 15
        if (null === $reader && \interface_exists(AnnotationReader::class)) {
57 13
            $this->annotation = new SimpleAnnotationReader();
0 ignored issues
show
Deprecated Code introduced by
The class Doctrine\Common\Annotations\SimpleAnnotationReader has been deprecated: Deprecated in favour of using AnnotationReader ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-deprecated  annotation

57
            $this->annotation = /** @scrutinizer ignore-deprecated */ new SimpleAnnotationReader();
Loading history...
58
        }
59
60 15
        if ($this->annotation instanceof SimpleAnnotationReader) {
61 14
            $this->annotation->addNamespace('Flight\Routing\Annotation');
62
        }
63 15
    }
64
65
    /**
66
     * Attaches the given resource to the loader
67
     *
68
     * @param string $resource
69
     */
70 15
    public function attach(string $resource): void
71
    {
72 15
        $this->resources[] = $resource;
73 15
    }
74
75
    /**
76
     * Attaches the given array with resources to the loader
77
     *
78
     * @param string[] $resources
79
     */
80 2
    public function attachArray(array $resources): void
81
    {
82 2
        foreach ($resources as $resource) {
83 2
            $this->attach($resource);
84
        }
85 2
    }
86
87
    /**
88
     * Loads routes from attached resources
89
     *
90
     * @return RouteCollectionInterface
91
     */
92 15
    public function load(): RouteCollectionInterface
93
    {
94 15
        $annotations = [];
95 15
        $collector   = clone $this->collector;
96
97 15
        foreach ($this->resources as $resource) {
98 15
            if (\class_exists($resource) || \is_dir($resource)) {
99 15
                $annotations += $this->findAnnotations($resource);
100
101 4
                continue;
102
            }
103
104 2
            if (!\file_exists($resource) || \is_dir($resource)) {
105 1
                continue;
106
            }
107
108 1
            (function () use ($resource, $collector): void {
109 1
                require $resource;
110 1
            })->call($this->collector);
111
        }
112
113 4
        return $this->resolveAnnotations($collector, $annotations);
114
    }
115
116
    /**
117
     * Add a route from annotation
118
     *
119
     * @param RouteCollectorInterface $collector
120
     * @param Annotation\Route        $annotation
121
     * @param string|string[]         $handler
122
     */
123 4
    private function addRoute(RouteCollectorInterface $collector, Annotation\Route $annotation, $handler): void
124
    {
125 4
        $routeName    = $annotation->getName() ?? $this->getDefaultRouteName($handler);
126 4
        $routeMethods = $annotation->getMethods();
127
128
        // Incase of API Resource
129 4
        if (str_ends_with($routeName, '__restful')) {
130 4
            $routeMethods = $collector::HTTP_METHODS_STANDARD;
131
        }
132
133 4
        $route = $collector->map($routeName, $routeMethods, $annotation->getPath(), $handler)
134 4
        ->setScheme(...$annotation->getSchemes())
135 4
        ->setPatterns($annotation->getPatterns())
136 4
        ->setDefaults($annotation->getDefaults())
137 4
        ->addMiddleware(...$annotation->getMiddlewares());
138
139 4
        if (null !== $annotation->getDomain()) {
140 4
            $route->setDomain($annotation->getDomain());
141
        }
142 4
    }
143
144
    /**
145
     * Add a routes from annotation into group
146
     *
147
     * @param null|Annotation\Route $grouping
148
     * @param array                 $methods
149
     */
150 4
    private function addRouteGroup(?Annotation\Route $grouping, array $methods): void
151
    {
152 4
        if (null === $grouping) {
153 4
            $this->mergeAnnotations($this->collector, $methods);
154
155 4
            return;
156
        }
157
158 4
        $group = $this->collector->group(
159 4
            function (RouteCollectorInterface $group) use ($methods): void {
160 4
                $this->mergeAnnotations($group, $methods);
161 4
            }
162
        )
163 4
        ->addMethod(...$grouping->getMethods())
164 4
        ->addPrefix($grouping->getPath())
165 4
        ->addScheme(...$grouping->getSchemes())
166 4
        ->addMiddleware(...$grouping->getMiddlewares())
167 4
        ->setDefaults($grouping->getDefaults());
168
169 4
        if (null !== $grouping->getName()) {
170 4
            $group->setName($grouping->getName());
171
        }
172
173 4
        if (null !== $grouping->getDomain()) {
174 4
            $group->addDomain($grouping->getDomain());
175
        }
176 4
    }
177
178
    /**
179
     * @param RouteCollectorInterface $collector
180
     * @param array<string,mixed>     $annotations
181
     */
182 4
    private function resolveAnnotations(RouteCollectorInterface $collector, array $annotations): RouteCollectionInterface
183
    {
184 4
        foreach ($annotations as $class => $collection) {
185 4
            if (isset($collection['method'])) {
186 4
                $this->addRouteGroup($collection['global'] ?? null, $collection['method']);
187
188 4
                continue;
189
            }
190 4
            $this->defaultRouteIndex = 0;
191
192 4
            foreach ($this->getAnnotations(new ReflectionClass($class)) as $annotation) {
193 4
                $this->addRoute($this->collector, $annotation, $class);
194
            }
195
        }
196
197 4
        \gc_mem_caches();
198
199 4
        return $collector->getCollection();
200
    }
201
202
    /**
203
     * @param RouteCollectorInterface $route
204
     * @param mixed[]                 $methods
205
     */
206 4
    private function mergeAnnotations(RouteCollectorInterface $route, array $methods): void
207
    {
208 4
        foreach ($methods as [$method, $annotation]) {
209 4
            $this->addRoute($route, $annotation, [$method->class, $method->getName()]);
210
        }
211 4
    }
212
213
    /**
214
     * Finds annotations in the given resource
215
     *
216
     * @param string $resource
217
     *
218
     * @return mixed[]
219
     */
220 15
    private function findAnnotations(string $resource): array
221
    {
222 15
        $classes = $annotations = [];
223
224 15
        if (\is_dir($resource)) {
225 5
            $classes = \array_merge($this->findClasses($resource), $classes);
226 10
        } elseif (\class_exists($resource)) {
227 10
            $classes[] = $resource;
228
        }
229
230 15
        foreach ($classes as $class) {
231 15
            $classReflection = new ReflectionClass($class);
232
233 15
            if ($classReflection->isAbstract()) {
234 1
                throw new InvalidAnnotationException(\sprintf(
235 1
                    'Annotations from class "%s" cannot be read as it is abstract.',
236 1
                    $classReflection->getName()
237
                ));
238
            }
239
240
            if (
241 14
                \PHP_VERSION_ID >= 80000 &&
242
                ($attribute = $classReflection->getAttributes(Annotation\Route::class)[0] ?? null)
0 ignored issues
show
Bug introduced by
The method getAttributes() does not exist on ReflectionClass. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

242
                ($attribute = $classReflection->/** @scrutinizer ignore-call */ getAttributes(Annotation\Route::class)[0] ?? null)

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
243
            ) {
244
                $annotations[$class]['global'] = $attribute->newInstance();
245
            }
246
247 14
            if (!isset($annotations[$class]) && $this->annotation instanceof AnnotationReader) {
248 14
                $annotations[$class]['global'] = $this->annotation->getClassAnnotation(
249 14
                    $classReflection,
250 14
                    Annotation\Route::class
251
                );
252
            }
253
254 4
            foreach ($classReflection->getMethods() as $method) {
255 4
                if ($method->isAbstract() || $method->isPrivate() || $method->isProtected()) {
256 4
                    continue;
257
                }
258 4
                $this->defaultRouteIndex = 0;
259
260 4
                foreach ($this->getAnnotations($method) as $annotation) {
261 4
                    $annotations[$method->class]['method'][] = [$method, $annotation];
262
                }
263
            }
264
        }
265
266 4
        return $annotations;
267
    }
268
269
    /**
270
     * @param ReflectionClass|ReflectionMethod $reflection
271
     *
272
     * @return Annotation\Route[]|iterable
273
     */
274 4
    private function getAnnotations(Reflector $reflection): iterable
275
    {
276 4
        if (\PHP_VERSION_ID >= 80000) {
277
            foreach ($reflection->getAttributes(Annotation\Route::class) as $attribute) {
0 ignored issues
show
Bug introduced by
The method getAttributes() does not exist on Reflector. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

277
            foreach ($reflection->/** @scrutinizer ignore-call */ getAttributes(Annotation\Route::class) as $attribute) {

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
278
                yield $attribute->newInstance();
0 ignored issues
show
Bug Best Practice introduced by
The expression yield $attribute->newInstance() returns the type Generator which is incompatible with the documented return type Flight\Routing\Annotation\Route[]|iterable.
Loading history...
279
            }
280
        }
281
282 4
        if (null === $this->annotation) {
283
            return;
284
        }
285
286 4
        $anntotations = $reflection instanceof ReflectionClass
287 4
            ? $this->annotation->getClassAnnotations($reflection)
288 4
            : $this->annotation->getMethodAnnotations($reflection);
289
290 4
        foreach ($anntotations as $annotation) {
291 4
            if ($annotation instanceof Annotation\Route) {
292 4
                yield $annotation;
293
            }
294
        }
295 4
    }
296
297
    /**
298
     * Gets the default route name for a class method.
299
     *
300
     * @param string|string[] $handler
301
     *
302
     * @return string
303
     */
304 4
    private function getDefaultRouteName($handler): string
305
    {
306 4
        $classReflection = new ReflectionClass(\is_string($handler) ? $handler : $handler[0]);
307 4
        $name            = \str_replace('\\', '_', $classReflection->name);
308
309 4
        if (\is_array($handler) || $classReflection->hasMethod('__invoke')) {
310 4
            $name .= '_' . $handler[1] ?? '__invoke';
311
        }
312
313 4
        if ($this->defaultRouteIndex > 0) {
314 4
            $name .= '_' . $this->defaultRouteIndex;
315
        }
316 4
        ++$this->defaultRouteIndex;
317
318 4
        return \strtolower($name);
319
    }
320
321
    /**
322
     * Finds classes in the given resource directory
323
     *
324
     * @param string $resource
325
     *
326
     * @return string[]
327
     */
328 5
    private function findClasses(string $resource): array
329
    {
330 5
        $files    = $this->findFiles($resource);
331 5
        $declared = \get_declared_classes();
332
333 5
        foreach ($files as $file) {
334 5
            require_once $file;
335
        }
336
337 5
        return \array_diff(\get_declared_classes(), $declared);
338
    }
339
340
    /**
341
     * Finds files in the given resource
342
     *
343
     * @param string $resource
344
     *
345
     * @return string[]
346
     */
347 5
    private function findFiles(string $resource): array
348
    {
349 5
        $flags = FilesystemIterator::CURRENT_AS_PATHNAME;
350
351 5
        $directory = new RecursiveDirectoryIterator($resource, $flags);
352 5
        $iterator  = new RecursiveIteratorIterator($directory);
353 5
        $files     = new RegexIterator($iterator, '/\.php$/');
354
355 5
        return \iterator_to_array($files);
356
    }
357
}
358