Test Failed
Pull Request — master (#16)
by Divine Niiquaye
02:28
created

RouteCollection::sortRoutes()   A

Complexity

Conditions 3
Paths 1

Size

Total Lines 12
Code Lines 6

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 12

Importance

Changes 0
Metric Value
cc 3
eloc 6
nc 1
nop 1
dl 0
loc 12
ccs 0
cts 0
cp 0
crap 12
rs 10
c 0
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.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
/**
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
 *
51
 * @author Divine Niiquaye Ibok <[email protected]>
52
 */
53
final class RouteCollection
54
{
55
    /** @var self|null */
56
    private $parent;
57
58
    /** @var array<string,mixed[]>|null */
59
    private $prototypes = [];
60
61
    /** @var array<string,bool> */
62
    private $prototyped = [];
63
64
    /** @var array<string,Routes\FastRoute> */
65
    private $routes = [];
66
67
    /** @var self[] */
68
    private $groups = [];
69
70
    /** @var int */
71
    private $uniqueId;
72
73
    /** @var string */
74
    private $namedPrefix;
75
76
    /** @var bool Prevent adding routes once already used or serialized */
77
    private $locked = false;
78
79 13
    /**
80
     * @param string $namedPrefix The unqiue name for this group
81 13
     */
82 13
    public function __construct(string $namedPrefix = '')
83
    {
84 13
        $this->namedPrefix = $namedPrefix;
85 1
        $this->uniqueId = (string) \uniqid($namedPrefix);
0 ignored issues
show
Documentation Bug introduced by
The property $uniqueId was declared of type integer, but (string)uniqid($namedPrefix) is of type string. Maybe add a type cast?

This check looks for assignments to scalar types that may be of the wrong type.

To ensure the code behaves as expected, it may be a good idea to add an explicit type cast.

$answer = 42;

$correct = false;

$correct = (bool) $answer;
Loading history...
86 1
    }
87
88
    /**
89 1
     * Nested collection and routes should be cloned.
90
     */
91 12
    public function __clone(): void
92 12
    {
93
        foreach ($this->routes as $offset => $route) {
94
            $this->routes[$offset] = clone $route;
95
        }
96 11
    }
97
98
    /**
99
     * @param string[] $arguments
100
     */
101
    public function __call(string $routeMethod, array $arguments): self
102
    {
103
        $routeMethod = \strtolower($routeMethod);
104 5
105
        if (isset($this->prototyped[$this->uniqueId])) {
106 5
            $this->prototypes[$routeMethod] = \array_merge($this->prototypes[$routeMethod] ?? [], $arguments);
107
        } else {
108
            foreach ($this->routes as $route) {
109
                \call_user_func_array([$route, $routeMethod], $arguments);
110
            }
111
112
            if (\array_key_exists($routeMethod, $this->prototypes)) {
113
                unset($this->prototypes[$routeMethod]);
114
            }
115
        }
116 81
117
        return $this;
118 81
    }
119
120
    /**
121
     * If true, new routes cannot be added.
122
     */
123
    public function isLocked(): bool
124
    {
125
        return $this->locked;
126
    }
127
128
    /**
129
     * @return array<int,Routes\FastRoute>
130 70
     */
131
    public function getRoutes(): array
132 70
    {
133 70
        if ($this->locked) {
134
            return $this->routes;
135 70
        }
136
137
        $this->locked = true; // Freeze the routes to avoid slow down on runtime.
138 70
139
        if (!empty($this->groups)) {
140
            $this->injectGroups('', $this);
141
            $this->groups = []; // Unset grouping ...
142
        }
143
144
        return self::sortRoutes($this->routes);
145
    }
146
147
    /**
148
     * Merge a collection into base.
149
     */
150 29
    public function populate(self $collection, bool $asGroup = false): void
151
    {
152 29
        if ($asGroup) {
153
            $this->groups[] = $collection;
154 29
        } else {
155
            if (!empty($collection->groups)) {
156 29
                $collection->injectGroups($collection->namedPrefix, $collection);
157
            }
158
159
            foreach ($collection->routes as $route) {
160
                $this->routes[] = $this->injectRoute($route);
161
            }
162
163
            $collection->groups = $collection->routes = $collection->prototypes = [];
164
        }
165
    }
166
167 10
    /**
168
     * Add route(s) to the collection.
169 10
     *
170 1
     * This method unset all setting from default route and use new settings
171 1
     * from new the route(s). If you want the default settings to be merged
172
     * into routes, use `addRoute` method instead.
173
     *
174 10
     * @param Routes\FastRoute ...$routes
175 3
     */
176 3
    public function add(Routes\FastRoute ...$routes): self
177
    {
178 3
        foreach ($routes as $route) {
179 3
            $this->routes[] = $this->injectRoute($route);
180 7
        }
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
     * Mounts controllers under the given route prefix.
213 5
     *
214
     * @param string                   $name        The route group prefixed name
215
     * @param callable|RouteCollection $controllers A RouteCollection instance or a callable for defining routes
216
     *
217
     * @throws \TypeError if $controllers not instance of route collection's class.
218
     * @throws \RuntimeException if locked
219
     */
220
    public function group(string $name, $controllers = null): self
221
    {
222 10
        if ($this->locked) {
223
            throw new \RuntimeException('Grouping cannot be added on runtime, do add all routes before runtime.');
224 10
        }
225
226
        if (\is_callable($controllers)) {
227
            $routes = new static($name);
228
            $routes->parent = $this;
229
            $controllers($routes);
230
231
            return $this->groups[] = $routes;
232
        }
233 2
234
        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

234
        return $this->groups[] = $this->injectGroup($name, /** @scrutinizer ignore-type */ $controllers ?? new static($name));
Loading history...
235 2
    }
236
237
    /**
238
     * Allows a proxied method call to route's.
239
     */
240
    public function prototype(): self
241
    {
242
        $this->prototypes = (null !== $this->parent) ? ($this->parent->prototypes ?? []) : [];
243
        $this->prototyped[$this->uniqueId] = true; // Prototyping calls to routes ...
244 1
245
        return $this;
246 1
    }
247
248
    /**
249
     * Unmounts a group collection to continue routes stalk.
250
     */
251
    public function end(): self
252
    {
253
        if (isset($this->prototyped[$this->uniqueId])) {
254
            unset($this->prototyped[$this->uniqueId]);
255 2
256
            // Remove last element from stack.
257 2
            if (null !== $stack = $this->prototypes) {
258
                unset($stack[\count($stack) - 1]);
259
            }
260
261
            return $this;
262
        }
263
264
        return $this->parent ?? $this;
265
    }
266 1
267
    /**
268 1
     * Maps a HEAD request to a handler.
269
     *
270
     * @param string $pattern Matched route pattern
271
     * @param mixed  $handler Handler that returns the response when matched
272
     */
273
    public function head(string $pattern, $handler = null): Routes\Route
274
    {
275
        return $this->addRoute($pattern, [Router::METHOD_HEAD], $handler);
276
    }
277 1
278
    /**
279 1
     * Maps a GET and HEAD request to a handler.
280
     *
281
     * @param string $pattern Matched route pattern
282
     * @param mixed  $handler Handler that returns the response when matched
283
     */
284
    public function get(string $pattern, $handler = null): Routes\Route
285
    {
286
        return $this->addRoute($pattern, [Router::METHOD_GET, Router::METHOD_HEAD], $handler);
287
    }
288 3
289
    /**
290 3
     * Maps a POST request to a handler.
291
     *
292
     * @param string $pattern Matched route pattern
293
     * @param mixed  $handler Handler that returns the response when matched
294
     */
295
    public function post(string $pattern, $handler = null): Routes\Route
296
    {
297
        return $this->addRoute($pattern, [Router::METHOD_POST], $handler);
298
    }
299
300
    /**
301
     * Maps a PUT request to a handler.
302 2
     *
303
     * @param string $pattern Matched route pattern
304 2
     * @param mixed  $handler Handler that returns the response when matched
305
     */
306
    public function put(string $pattern, $handler = null): Routes\Route
307
    {
308
        return $this->addRoute($pattern, [Router::METHOD_PUT], $handler);
309
    }
310
311
    /**
312
     * Maps a PATCH request to a handler.
313
     *
314 71
     * @param string $pattern Matched route pattern
315
     * @param mixed  $handler Handler that returns the response when matched
316
     */
317 71
    public function patch(string $pattern, $handler = null): Routes\Route
318 26
    {
319 6
        return $this->addRoute($pattern, [Router::METHOD_PATCH], $handler);
320
    }
321
322
    /**
323 71
     * Maps a DELETE request to a handler.
324
     *
325
     * @param string $pattern Matched route pattern
326
     * @param mixed  $handler Handler that returns the response when matched
327
     */
328
    public function delete(string $pattern, $handler = null): Routes\Route
329 92
    {
330
        return $this->addRoute($pattern, [Router::METHOD_DELETE], $handler);
331 92
    }
332 1
333
    /**
334 1
     * Maps a OPTIONS request to a handler.
335 1
     *
336
     * @param string $pattern Matched route pattern
337
     * @param mixed  $handler Handler that returns the response when matched
338
     */
339 92
    public function options(string $pattern, $handler = null): Routes\Route
340 2
    {
341
        return $this->addRoute($pattern, [Router::METHOD_OPTIONS], $handler);
342 92
    }
343
344
    /**
345
     * Maps any request to a handler.
346
     *
347 81
     * @param string $pattern Matched route pattern
348
     * @param mixed  $handler Handler that returns the response when matched
349 81
     */
350
    public function any(string $pattern, $handler = null): Routes\Route
351
    {
352 81
        return $this->addRoute($pattern, Router::HTTP_METHODS_STANDARD, $handler);
353 78
    }
354 9
355
    /**
356 9
     * Maps any Router::HTTP_METHODS_STANDARD request to a resource handler prefixed to $action's method name.
357
     *
358
     * E.g: Having pattern as "/accounts/{userId}", all request made from supported request methods
359 78
     * are to have the same url.
360 18
     *
361
     * @param string              $action   The prefixed name attached to request method
362 18
     * @param string              $pattern  matched path where request should be sent to
363 2
     * @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...
364
     */
365
    public function resource(string $pattern, $resource, string $action = 'action'): Routes\Route
366
    {
367 78
        return $this->any($pattern, new Handlers\ResourceHandler($resource, $action));
368
    }
369 81
370
    /**
371 81
     * Rearranges routes, sorting static paths before dynamic paths.
372
     *
373
     * @param Routes\FastRoute[] $routes
374
     *
375
     * @return Routes\FastRoute[]
376
     */
377
    private static function sortRoutes(array $routes): array
378
    {
379
        $sortRegex = '#^[\w+' . \implode('\\', Routes\Route::URL_PREFIX_SLASHES) . ']+$#';
380
381
        \usort($routes, static function (Routes\FastRoute $a, Routes\FastRoute $b) use ($sortRegex): int {
382
            $aRegex = \preg_match($sortRegex, $a->get('path'));
0 ignored issues
show
Bug introduced by
It seems like $a->get('path') can also be of type array and null; however, parameter $subject of preg_match() does only seem to accept string, 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

382
            $aRegex = \preg_match($sortRegex, /** @scrutinizer ignore-type */ $a->get('path'));
Loading history...
383
            $bRegex = \preg_match($sortRegex, $b->get('path'));
384
385
            return $aRegex == $bRegex ? 0 : ($aRegex < $bRegex ? +1 : -1);
386
        });
387
388
        return $routes;
389
    }
390
391
    /**
392
     * @throws \RuntimeException if locked
393
     */
394
    private function injectRoute(Routes\FastRoute $route): Routes\FastRoute
395
    {
396
        if ($this->locked) {
397
            throw new \RuntimeException('Routes cannot be added on runtime, do add all routes before runtime.');
398
        }
399
400
        foreach ($this->prototypes ?? [] as $routeMethod => $arguments) {
401
            if (empty($arguments)) {
402
                continue;
403
            }
404
405
            \call_user_func_array([$route, $routeMethod], 'prefix' === $routeMethod ? [\implode('', $arguments)] : $arguments);
406
        }
407
408
        if (null !== $this->parent) {
409
            $route->belong($this); // Attach grouping to route.
410
        }
411
412
        return $route;
413
    }
414
415
    private function injectGroup(string $prefix, self $controllers): self
416
    {
417
        $controllers->parent = $this;
418
419
        if (empty($controllers->namedPrefix)) {
420
            $controllers->namedPrefix = $prefix;
421
        }
422
423
        return $controllers;
424
    }
425
426
    private function injectGroups(string $prefix, self $collection): void
427
    {
428
        $unnamedRoutes = [];
429
430
        foreach ($this->groups as $group) {
431
            foreach ($group->routes as $route) {
432
                if (empty($name = $route->get('name'))) {
433
                    $name = $route->generateRouteName('');
434
435
                    if (isset($unnamedRoutes[$name])) {
436
                        $name .= ('_' !== $name[-1] ? '_' : '') . ++$unnamedRoutes[$name];
437
                    } else {
438
                        $unnamedRoutes[$name] = 0;
439
                    }
440
                }
441
442
                $collection->routes[] = $route->bind($prefix . $group->namedPrefix . $name);
443
            }
444
445
            if (!empty($group->groups)) {
446
                $group->injectGroups($prefix . $group->namedPrefix, $collection);
447
            }
448
        }
449
    }
450
}
451