Test Failed
Push — master ( 272d1d...4c1325 )
by Divine Niiquaye
02:09
created

RouteCollection::getIterator()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 15
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 4
CRAP Score 3

Importance

Changes 3
Bugs 0 Features 0
Metric Value
cc 3
eloc 7
nc 3
nop 0
dl 0
loc 15
ccs 4
cts 4
cp 1
crap 3
rs 10
c 3
b 0
f 0
1
<?php
2
3
declare(strict_types=1);
4
5
/*
6
 * This file is part of Flight Routing.
7
 *
8
 * PHP version 7.4 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
/**
21
 * A RouteCollection represents a set of Route instances.
22
 *
23
 * This class provides all(*) methods for creating path+HTTP method-based routes and
24
 * injecting them into the router:
25
 *
26
 * - head
27
 * - get
28
 * - post
29
 * - put
30
 * - patch
31
 * - delete
32
 * - options
33
 * - any
34
 * - resource
35
 *
36
 * A general `addRoute()` method allows specifying multiple request methods and/or
37
 * arbitrary request methods when creating a path-based route.
38
 *
39
 * @method RouteCollection assert(string $variable, string|string[] $regexp)
40
 * @method RouteCollection default(string $variable, mixed $default)
41
 * @method RouteCollection argument(string $variable, mixed $value)
42
 * @method RouteCollection method(string ...$methods)
43
 * @method RouteCollection scheme(string ...$schemes)
44
 * @method RouteCollection domain(string ...$hosts)
45
 * @method RouteCollection prefix(string $path)
46
 * @method RouteCollection defaults(array $values)
47
 * @method RouteCollection asserts(array $patterns)
48
 * @method RouteCollection arguments(array $parameters)
49
 * @method RouteCollection namespace(string $namespace)
50
 * @method RouteCollection piped(string ...$to)
51
 *
52
 * @author Divine Niiquaye Ibok <[email protected]>
53
 */
54
final class RouteCollection implements \IteratorAggregate
55
{
56
    private ?self $parent = null;
57
58
    /** @var array<string,mixed[]>|null */
59
    private ?array $prototypes = [];
60
61
    /** @var array<string,bool> */
62
    private array $prototyped = [];
63
64
    /** @var Routes\FastRoute[] */
65
    private iterable $routes = [];
66
67
    /** @var self[] */
68
    private array $groups = [];
69
70
    private ?string $uniqueId;
71
72
    private string $namedPrefix;
73
74
    /**
75
     * @param string $namedPrefix The unqiue name for this group
76
     */
77
    public function __construct(string $namedPrefix = '')
78
    {
79 13
        $this->namedPrefix = $namedPrefix;
80
        $this->uniqueId = (string) \uniqid($namedPrefix);
81 13
    }
82 13
83
    /**
84 13
     * Nested collection and routes should be cloned.
85 1
     */
86 1
    public function __clone()
87
    {
88
        foreach ($this->routes as $offset => $route) {
89 1
            $this->routes[$offset] = clone $route;
90
        }
91 12
    }
92 12
93
    /**
94
     * @param string[] $arguments
95
     */
96 11
    public function __call(string $routeMethod, array $arguments): self
97
    {
98
        $routeMethod = \strtolower($routeMethod);
99
100
        if (isset($this->prototyped[$this->uniqueId])) {
101
            $this->prototypes[$routeMethod] = \array_merge($this->prototypes[$routeMethod] ?? [], $arguments);
102
        } else {
103
            foreach ($this->routes as $route) {
104 5
                \call_user_func_array([$route, $routeMethod], $arguments);
105
            }
106 5
107
            if (\array_key_exists($routeMethod, $this->prototypes ?? [])) {
108
                unset($this->prototypes[$routeMethod]);
109
            }
110
111
            foreach ($this->groups as $group) {
112
                \call_user_func_array([$group, $routeMethod], $arguments);
113
            }
114
        }
115
116 81
        return $this;
117
    }
118 81
119
    /**
120
     * {@inheritdoc}
121
     */
122
    public function getIterator(): \Traversable
123
    {
124
        $routes = $this->routes;
0 ignored issues
show
Documentation Bug introduced by
It seems like $this->routes of type iterable is incompatible with the declared type Flight\Routing\Routes\FastRoute[] of property $routes.

Our type inference engine has found an assignment to a property that is incompatible with the declared type of that property.

Either this assignment is in error or the assigned type should be added to the documentation/type hint for that property..

Loading history...
125
126
        if ($routes instanceof \SplFixedArray) {
127
            return $routes;
128
        }
129
130 70
        if (!empty($this->groups)) {
131
            $this->injectGroups('', $routes);
132 70
        }
133 70
134
        $this->uniqueId = null; // Lock grouping and prototyping
135 70
136
        return $this->routes = self::sortRoutes($routes);
137
    }
138 70
139
    /**
140
     * @return iterable<Routes\FastRoute>
141
     */
142
    public function getRoutes(): iterable
143
    {
144
        return $this->getIterator();
145
    }
146
147
    /**
148
     * Merge a collection into base.
149
     *
150 29
     * @throws \RuntimeException if locked
151
     */
152 29
    public function populate(self $collection, bool $asGroup = false): void
153
    {
154 29
        if ($asGroup) {
155
            if (null === $this->uniqueId) {
156 29
                throw new \RuntimeException('Populating a route collection as group must be done before calling the getRoutes() method.');
157
            }
158
159
            $this->groups[] = $collection;
160
        } else {
161
            $routes = $collection->routes;
0 ignored issues
show
Documentation Bug introduced by
It seems like $collection->routes of type iterable is incompatible with the declared type Flight\Routing\Routes\FastRoute[] of property $routes.

Our type inference engine has found an assignment to a property that is incompatible with the declared type of that property.

Either this assignment is in error or the assigned type should be added to the documentation/type hint for that property..

Loading history...
162
163
            if (!empty($collection->groups)) {
164
                $collection->injectGroups($collection->namedPrefix, $routes);
165
            }
166
167 10
            foreach ($routes as $route) {
168
                $this->routes[] = $this->injectRoute($route);
169 10
            }
170 1
171 1
            $collection->routes = $collection->prototypes = [];
172
        }
173
    }
174 10
175 3
    /**
176 3
     * Add route to the collection.
177
     */
178 3
    public function add(Routes\FastRoute $route): self
179 3
    {
180 7
        $this->routes[] = $this->injectRoute($route);
181 1
182
        return $this;
183
    }
184 9
185
    /**
186 9
     * Maps a pattern to a handler.
187 9
     *
188
     * You can must specify HTTP methods that should be matched.
189 9
     *
190
     * @param string   $pattern Matched route pattern
191
     * @param string[] $methods Matched HTTP methods
192
     * @param mixed    $handler Handler that returns the response when matched
193
     */
194
    public function addRoute(string $pattern, array $methods, $handler = null): Routes\Route
195 1
    {
196
        return $this->routes[] = $this->injectRoute(new Routes\Route($pattern, $methods, $handler));
197
    }
198 1
199 1
    /**
200
     * Same as addRoute method, except uses Routes\FastRoute class.
201
     *
202 1
     * @param string   $pattern Matched route pattern
203
     * @param string[] $methods Matched HTTP methods
204
     * @param mixed    $handler Handler that returns the response when matched
205
     */
206
    public function fastRoute(string $pattern, array $methods, $handler = null): Routes\FastRoute
207
    {
208
        return $this->routes[] = $this->injectRoute(new Routes\FastRoute($pattern, $methods, $handler));
209
    }
210
211 5
    /**
212
     * Add routes to the collection.
213 5
     *
214
     * @param Routes\FastRoute[] $routes
215
     *
216
     * @throws \TypeError if $routes doesn't contain a fast route instance
217
     * @throws \RuntimeException if locked
218
     */
219
    public function routes(array $routes): self
220
    {
221
        foreach ($routes as $route) {
222 10
            $this->routes[] = $this->injectRoute($route);
223
        }
224 10
225
        return $this;
226
    }
227
228
    /**
229
     * Mounts controllers under the given route prefix.
230
     *
231
     * @param string                   $name        The route group prefixed name
232
     * @param callable|RouteCollection $controllers A RouteCollection instance or a callable for defining routes
233 2
     *
234
     * @throws \TypeError        if $controllers not instance of route collection's class
235 2
     * @throws \RuntimeException if locked
236
     */
237
    public function group(string $name, $controllers = null): self
238
    {
239
        if (null === $this->uniqueId) {
240
            throw new \RuntimeException('Grouping index invalid or out of range, add group before calling the getRoutes() method.');
241
        }
242
243
        if (\is_callable($controllers)) {
244 1
            $routes = new static($name);
245
            $routes->prototypes = $this->prototypes ?? [];
246 1
            $controllers($routes);
247
248
            return $this->groups[] = $routes;
249
        }
250
251
        return $this->groups[] = $this->injectGroup($name, $controllers ?? new static($name));
0 ignored issues
show
Bug introduced by
It seems like $controllers ?? new static($name) can also be of type callable and null; however, parameter $controllers of Flight\Routing\RouteCollection::injectGroup() does only seem to accept Flight\Routing\RouteCollection, maybe add an additional type check? ( Ignorable by Annotation )

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

251
        return $this->groups[] = $this->injectGroup($name, /** @scrutinizer ignore-type */ $controllers ?? new static($name));
Loading history...
252
    }
253
254
    /**
255 2
     * Allows a proxied method call to route's.
256
     *
257 2
     * @throws \RuntimeException if locked
258
     */
259
    public function prototype(): self
260
    {
261
        if (null === $uniqueId = $this->uniqueId) {
262
            throw new \RuntimeException('Routes method prototyping must be done before calling the getRoutes() method.');
263
        }
264
265
        $this->prototypes = (null !== $this->parent) ? ($this->parent->prototypes ?? []) : [];
266 1
        $this->prototyped[$uniqueId] = true; // Prototyping calls to routes ...
267
268 1
        return $this;
269
    }
270
271
    /**
272
     * Unmounts a group collection to continue routes stalk.
273
     */
274
    public function end(): self
275
    {
276
        if (isset($this->prototyped[$this->uniqueId])) {
277 1
            unset($this->prototyped[$this->uniqueId]);
278
279 1
            // Remove last element from stack.
280
            if (null !== $stack = $this->prototypes) {
281
                \array_pop($stack);
282
            }
283
284
            return $this;
285
        }
286
287
        return $this->parent ?? $this;
288 3
    }
289
290 3
    /**
291
     * Maps a HEAD request to a handler.
292
     *
293
     * @param string $pattern Matched route pattern
294
     * @param mixed  $handler Handler that returns the response when matched
295
     */
296
    public function head(string $pattern, $handler = null): Routes\Route
297
    {
298
        return $this->addRoute($pattern, [Router::METHOD_HEAD], $handler);
299
    }
300
301
    /**
302 2
     * Maps a GET and HEAD request to a handler.
303
     *
304 2
     * @param string $pattern Matched route pattern
305
     * @param mixed  $handler Handler that returns the response when matched
306
     */
307
    public function get(string $pattern, $handler = null): Routes\Route
308
    {
309
        return $this->addRoute($pattern, [Router::METHOD_GET, Router::METHOD_HEAD], $handler);
310
    }
311
312
    /**
313
     * Maps a POST request to a handler.
314 71
     *
315
     * @param string $pattern Matched route pattern
316
     * @param mixed  $handler Handler that returns the response when matched
317 71
     */
318 26
    public function post(string $pattern, $handler = null): Routes\Route
319 6
    {
320
        return $this->addRoute($pattern, [Router::METHOD_POST], $handler);
321
    }
322
323 71
    /**
324
     * Maps a PUT request to a handler.
325
     *
326
     * @param string $pattern Matched route pattern
327
     * @param mixed  $handler Handler that returns the response when matched
328
     */
329 92
    public function put(string $pattern, $handler = null): Routes\Route
330
    {
331 92
        return $this->addRoute($pattern, [Router::METHOD_PUT], $handler);
332 1
    }
333
334 1
    /**
335 1
     * Maps a PATCH request to a handler.
336
     *
337
     * @param string $pattern Matched route pattern
338
     * @param mixed  $handler Handler that returns the response when matched
339 92
     */
340 2
    public function patch(string $pattern, $handler = null): Routes\Route
341
    {
342 92
        return $this->addRoute($pattern, [Router::METHOD_PATCH], $handler);
343
    }
344
345
    /**
346
     * Maps a DELETE request to a handler.
347 81
     *
348
     * @param string $pattern Matched route pattern
349 81
     * @param mixed  $handler Handler that returns the response when matched
350
     */
351
    public function delete(string $pattern, $handler = null): Routes\Route
352 81
    {
353 78
        return $this->addRoute($pattern, [Router::METHOD_DELETE], $handler);
354 9
    }
355
356 9
    /**
357
     * Maps a OPTIONS request to a handler.
358
     *
359 78
     * @param string $pattern Matched route pattern
360 18
     * @param mixed  $handler Handler that returns the response when matched
361
     */
362 18
    public function options(string $pattern, $handler = null): Routes\Route
363 2
    {
364
        return $this->addRoute($pattern, [Router::METHOD_OPTIONS], $handler);
365
    }
366
367 78
    /**
368
     * Maps any request to a handler.
369 81
     *
370
     * @param string $pattern Matched route pattern
371 81
     * @param mixed  $handler Handler that returns the response when matched
372
     */
373
    public function any(string $pattern, $handler = null): Routes\Route
374
    {
375
        return $this->addRoute($pattern, Router::HTTP_METHODS_STANDARD, $handler);
376
    }
377
378
    /**
379
     * Maps any Router::HTTP_METHODS_STANDARD request to a resource handler prefixed to $action's method name.
380
     *
381
     * E.g: Having pattern as "/accounts/{userId}", all request made from supported request methods
382
     * are to have the same url.
383
     *
384
     * @param string              $action   The prefixed name attached to request method
385
     * @param string              $pattern  matched path where request should be sent to
386
     * @param class-string|object $resource Handler that returns the response
0 ignored issues
show
Documentation Bug introduced by
The doc comment class-string|object at position 0 could not be parsed: Unknown type name 'class-string' at position 0 in class-string|object.
Loading history...
387
     */
388
    public function resource(string $pattern, $resource, string $action = 'action'): Routes\Route
389
    {
390
        return $this->any($pattern, new Handlers\ResourceHandler($resource, $action));
391
    }
392
393
    /**
394
     * Rearranges routes, sorting static paths before dynamic paths.
395
     *
396
     * @param Routes\FastRoute[] $routes
397
     *
398
     * @return \SplFixedArray<Routes\FastRoute>
399
     */
400
    private static function sortRoutes(array $routes): \SplFixedArray
401
    {
402
        $sortRegex = '#^\\/[\\/\\w]+$#';
403
404
        \usort($routes, static function (Routes\FastRoute $a, Routes\FastRoute $b) use ($sortRegex): int {
405
            $aB = \preg_match($sortRegex, $aP = $a->getPath());
406
            $bB = \preg_match($sortRegex, $bP = $b->getPath());
407
408
            return $aB && $bB ? strnatcmp($aP, $bP) : ($aB < $bB ? +1 : ($aB > $bB ? -1 : \strnatcmp($aP, $bP)));
409
        });
410
411
        return \SplFixedArray::fromArray($routes);
412
    }
413
414
    /**
415
     * @throws \RuntimeException if locked
416
     */
417
    private function injectRoute(Routes\FastRoute $route): Routes\FastRoute
418
    {
419
        foreach ($this->prototypes ?? [] as $routeMethod => $arguments) {
420
            if (empty($arguments)) {
421
                continue;
422
            }
423
424
            \call_user_func_array([$route, $routeMethod], 'prefix' === $routeMethod ? [\implode('', $arguments)] : $arguments);
425
        }
426
427
        if (null !== $this->parent) {
428
            $route->belong($this); // Attach grouping to route.
429
        }
430
431
        return $route;
432
    }
433
434
    private function injectGroup(string $prefix, self $controllers): self
435
    {
436
        $controllers->prototypes = $this->prototypes ?? [];
437
        $controllers->parent = $this;
438
439
        if (empty($controllers->namedPrefix)) {
440
            $controllers->namedPrefix = $prefix;
441
        }
442
443
        return $controllers;
444
    }
445
446
    /**
447
     * @param iterable<int,Routes\FastRoute> $collection
448
     */
449
    private function injectGroups(string $prefix, iterable &$collection): void
450
    {
451
        $unnamedRoutes = [];
452
453
        foreach ($this->groups as $group) {
454
            foreach ($group->routes as $route) {
455
                if (empty($name = $route->getName())) {
456
                    $name = $route->generateRouteName('');
457
458
                    if (isset($unnamedRoutes[$name])) {
459
                        $name .= ('_' !== $name[-1] ? '_' : '') . ++$unnamedRoutes[$name];
460
                    } else {
461
                        $unnamedRoutes[$name] = 0;
462
                    }
463
                }
464
465
                $collection[] = $route->bind($prefix . $group->namedPrefix . $name);
466
            }
467
468
            if (!empty($group->groups)) {
469
                $group->injectGroups($prefix . $group->namedPrefix, $collection);
470
            }
471
        }
472
473
        $this->groups = [];
474
    }
475
}
476