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

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

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