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

RouteCollection::injectGroups()   B

Complexity

Conditions 7
Paths 11

Size

Total Lines 21
Code Lines 12

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 56

Importance

Changes 0
Metric Value
cc 7
eloc 12
nc 11
nop 2
dl 0
loc 21
ccs 0
cts 0
cp 0
crap 56
rs 8.8333
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
 * @method RouteCollection piped(string ...$to)
51
 *
52
 * @author Divine Niiquaye Ibok <[email protected]>
53
 */
54
final class RouteCollection
55
{
56
    /** @var self|null */
57
    private $parent;
58
59
    /** @var array<string,mixed[]>|null */
60
    private $prototypes = [];
61
62
    /** @var array<string,bool> */
63
    private $prototyped = [];
64
65
    /** @var Routes\FastRoute[] */
66
    private $routes = [];
67
68
    /** @var self[] */
69
    private $groups = [];
70
71
    /** @var string */
72
    private $uniqueId;
73
74
    /** @var string */
75
    private $namedPrefix;
76
77
    /** @var bool Prevent adding routes once already used or serialized */
78
    private $locked = false;
79 13
80
    /**
81 13
     * @param string $namedPrefix The unqiue name for this group
82 13
     */
83
    public function __construct(string $namedPrefix = '')
84 13
    {
85 1
        $this->namedPrefix = $namedPrefix;
86 1
        $this->uniqueId = (string) \uniqid($namedPrefix);
87
    }
88
89 1
    /**
90
     * Nested collection and routes should be cloned.
91 12
     */
92 12
    public function __clone()
93
    {
94
        foreach ($this->routes as $offset => $route) {
95
            $this->routes[$offset] = clone $route;
96 11
        }
97
    }
98
99
    /**
100
     * @param string[] $arguments
101
     */
102
    public function __call(string $routeMethod, array $arguments): self
103
    {
104 5
        $routeMethod = \strtolower($routeMethod);
105
106 5
        if (isset($this->prototyped[$this->uniqueId])) {
107
            $this->prototypes[$routeMethod] = \array_merge($this->prototypes[$routeMethod] ?? [], $arguments);
108
        } else {
109
            foreach ($this->routes as $route) {
110
                \call_user_func_array([$route, $routeMethod], $arguments);
111
            }
112
113
            if (\array_key_exists($routeMethod, $this->prototypes ?? [])) {
114
                unset($this->prototypes[$routeMethod]);
115
            }
116 81
117
            foreach ($this->groups as $group) {
118 81
                \call_user_func_array([$group, $routeMethod], $arguments);
119
            }
120
        }
121
122
        return $this;
123
    }
124
125
    /**
126
     * If true, new routes cannot be added.
127
     */
128
    public function isLocked(): bool
129
    {
130 70
        return $this->locked;
131
    }
132 70
133 70
    /**
134
     * @return array<int,Routes\FastRoute>
135 70
     */
136
    public function getRoutes(): array
137
    {
138 70
        if ($this->locked) {
139
            return $this->routes;
140
        }
141
142
        $this->locked = true; // Freeze the routes to avoid slow down on runtime.
143
144
        if (!empty($this->groups)) {
145
            $this->injectGroups('', $this);
146
            $this->groups = []; // Unset grouping ...
147
        }
148
149
        return self::sortRoutes($this->routes);
150 29
    }
151
152 29
    /**
153
     * Merge a collection into base.
154 29
     */
155
    public function populate(self $collection, bool $asGroup = false): void
156 29
    {
157
        if ($asGroup) {
158
            $this->groups[] = $collection;
159
        } else {
160
            if (!empty($collection->groups)) {
161
                $collection->injectGroups($collection->namedPrefix, $collection);
162
            }
163
164
            foreach ($collection->routes as $route) {
165
                $this->routes[] = $this->injectRoute($route);
166
            }
167 10
168
            $collection->groups = $collection->routes = $collection->prototypes = [];
169 10
        }
170 1
    }
171 1
172
    /**
173
     * Add route(s) to the collection.
174 10
     *
175 3
     * This method unset all setting from default route and use new settings
176 3
     * from new the route(s). If you want the default settings to be merged
177
     * into routes, use `addRoute` method instead.
178 3
     *
179 3
     * @param Routes\FastRoute ...$routes
180 7
     */
181 1
    public function add(Routes\FastRoute ...$routes): self
182
    {
183
        foreach ($routes as $route) {
184 9
            $this->routes[] = $this->injectRoute($route);
185
        }
186 9
187 9
        return $this;
188
    }
189 9
190
    /**
191
     * Maps a pattern to a handler.
192
     *
193
     * You can must specify HTTP methods that should be matched.
194
     *
195 1
     * @param string   $pattern Matched route pattern
196
     * @param string[] $methods Matched HTTP methods
197
     * @param mixed    $handler Handler that returns the response when matched
198 1
     */
199 1
    public function addRoute(string $pattern, array $methods, $handler = null): Routes\Route
200
    {
201
        return $this->routes[] = $this->injectRoute(new Routes\Route($pattern, $methods, $handler));
202 1
    }
203
204
    /**
205
     * Same as addRoute method, except uses Routes\FastRoute class.
206
     *
207
     * @param string   $pattern Matched route pattern
208
     * @param string[] $methods Matched HTTP methods
209
     * @param mixed    $handler Handler that returns the response when matched
210
     */
211 5
    public function fastRoute(string $pattern, array $methods, $handler = null): Routes\FastRoute
212
    {
213 5
        return $this->routes[] = $this->injectRoute(new Routes\FastRoute($pattern, $methods, $handler));
214
    }
215
216
    /**
217
     * Mounts controllers under the given route prefix.
218
     *
219
     * @param string                   $name        The route group prefixed name
220
     * @param callable|RouteCollection $controllers A RouteCollection instance or a callable for defining routes
221
     *
222 10
     * @throws \TypeError if $controllers not instance of route collection's class.
223
     * @throws \RuntimeException if locked
224 10
     */
225
    public function group(string $name, $controllers = null): self
226
    {
227
        if ($this->locked) {
228
            throw new \RuntimeException('Grouping cannot be added on runtime, do add all routes before runtime.');
229
        }
230
231
        if (\is_callable($controllers)) {
232
            $routes = new static($name);
233 2
            $routes->prototypes = $this->prototypes ?? [];
234
            $controllers($routes);
235 2
236
            return $this->groups[] = $routes;
237
        }
238
239
        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

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

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