Test Failed
Branch master (4153a4)
by Divine Niiquaye
13:02
created

PrototypeTrait::set()   B

Complexity

Conditions 8
Paths 5

Size

Total Lines 30
Code Lines 23

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 72

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 8
eloc 23
nc 5
nop 2
dl 0
loc 30
rs 8.4444
c 1
b 0
f 0
ccs 0
cts 0
cp 0
crap 72
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\Traits;
19
20
use Flight\Routing\Exceptions\{InvalidControllerException, UriHandlerException};
21
use Flight\Routing\Handlers\ResourceHandler;
22
23
/**
24
 * A trait providing route method prototyping.
25
 *
26
 * @author Divine Niiquaye Ibok <[email protected]>
27
 */
28
trait PrototypeTrait
29
{
30
    protected int $defaultIndex = -1;
31
    protected bool $asRoute = false, $sorted = false;
32
33
    /** @var array<string,mixed> */
34
    protected array $prototypes = [];
35
36
    /** @var array<int,array<string,mixed>> */
37
    protected array $routes = [];
38
39
    /** @var array<int,self> */
40 4
    protected array $groups = [];
41
42 4
    /**
43 4
     * Set route's data by calling supported route method in collection.
44
     *
45 4
     * @param array<string,mixed>|true $routeData An array is a list of route method bindings
46 1
     *                                            Else if true, route bindings can be prototyped
47
     *                                            to all registered routes
48
     *
49 3
     * @return $this
50
     *
51
     * @throws \InvalidArgumentException if route not defined before calling this method
52
     */
53 3
    public function prototype(array|bool $routeData): self
54
    {
55
        if (true === $routeData) {
56 2
            $this->asRoute = false;
57
58
            return $this;
59
        }
60
61
        foreach ($routeData as $routeMethod => $arguments) {
62
            \call_user_func_array([$this, $routeMethod], \is_array($arguments) ? $arguments : [$arguments]);
63
        }
64
65
        return $this;
66
    }
67 10
68
    /**
69 10
     * Ending of group chaining stack. (use with caution!).
70 9
     *
71 9
     * RISK: This method can break the collection, call this method
72
     * only on the last route of a group stack which the $return parameter
73 9
     * of the group method is set true.
74 9
     *
75
     * @return $this
76 9
     */
77 7
    public function end(): self
78
    {
79
        return $this->parent ?? $this;
80
    }
81 5
82
    /**
83
     * Set the route's path.
84
     *
85
     * @return $this
86
     *
87
     * @throws \InvalidArgumentException if you is not set
88
     */
89
    public function path(string $pattern): self
90
    {
91
        if (!$this->asRoute) {
92
            throw new \InvalidArgumentException('Cannot use the "path()" method if route not defined.');
93 16
        }
94
95 16
        if (1 === \preg_match(static::RCA_PATTERN, $pattern, $matches, \PREG_UNMATCHED_AS_NULL)) {
96 1
            isset($matches[1]) && $this->routes[$this->defaultIndex]['schemes'][$matches[1]] = true;
97
98
            if (isset($matches[2])) {
99 15
                if ('/' !== ($matches[3][0] ?? '')) {
100
                    throw new UriHandlerException(\sprintf('The route pattern "%s" is invalid as route path must be present in pattern.', $pattern));
101 15
                }
102
                $this->routes[$this->defaultIndex]['hosts'][$matches[2]] = true;
103
            }
104
105
            if (isset($matches[5])) {
106
                $handler = $matches[4] ?? $this->routes[$this->defaultIndex]['handler'] ?? null;
107
                $this->routes[$this->defaultIndex]['handler'] = !empty($handler) ? [$handler, $matches[5]] : $matches[5];
108
            }
109
110
            \preg_match(static::PRIORITY_REGEX, $pattern = $matches[3], $m, \PREG_UNMATCHED_AS_NULL);
111
            $this->routes[$this->defaultIndex]['prefix'] = $m[1] ?? null;
112
        }
113 1
114
        $this->routes[$this->defaultIndex]['path'] = '/'.\ltrim($pattern, '/');
115 1
116 1
        return $this;
117
    }
118
119 1
    /**
120
     * Set the route's unique name identifier,.
121 1
     *
122
     * @return $this
123
     *
124
     * @throws \InvalidArgumentException if you is not set
125
     */
126
    public function bind(string $routeName): self
127
    {
128
        if (!$this->asRoute) {
129
            throw new \InvalidArgumentException('Cannot use the "bind()" method if route not defined.');
130
        }
131
        $this->routes[$this->defaultIndex]['name'] = $routeName;
132
133 4
        return $this;
134
    }
135 4
136
    /**
137
     * Set the route's handler.
138
     *
139
     * @param mixed $to PHP class, object or callable that returns the response when matched
140
     *
141
     * @return $this
142
     *
143
     * @throws \InvalidArgumentException if you is not set
144
     */
145
    public function run(mixed $to): self
146
    {
147 1
        if (!$this->asRoute) {
148
            throw new \InvalidArgumentException('Cannot use the "run()" method if route not defined.');
149 1
        }
150
151
        if (!empty($namespace = $this->routes[$this->defaultIndex]['namespace'] ?? null)) {
152
            unset($this->routes[$this->defaultIndex]['namespace']);
153
        }
154
        $this->routes[$this->defaultIndex]['handler'] = $this->resolveHandler($to, $namespace);
155
156
        return $this;
157
    }
158
159
    /**
160
     * Set the route(s) default value for it's placeholder or required argument.
161 5
     *
162
     * @return $this
163 5
     */
164
    public function default(string $variable, mixed $default): self
165
    {
166
        if ($this->asRoute) {
167
            $this->routes[$this->defaultIndex]['defaults'][$variable] = $default;
168
        } elseif (-1 === $this->defaultIndex && empty($this->groups)) {
169
            $this->prototypes['defaults'] = \array_merge_recursive($this->prototypes['defaults'] ?? [], [$variable => $default]);
170
        } else {
171
            foreach ($this->routes as &$route) {
172
                $route['defaults'] = \array_merge_recursive($route['defaults'] ?? [], [$variable => $default]);
173
            }
174
            $this->resolveGroup(__FUNCTION__, [$variable, $default]);
175 1
        }
176
177 1
        return $this;
178
    }
179
180
    /**
181
     * Set the routes(s) default value for it's placeholder or required argument.
182
     *
183
     * @param array<string,mixed> $values
184
     *
185
     * @return $this
186
     */
187
    public function defaults(array $values): self
188
    {
189 5
        foreach ($values as $variable => $default) {
190
            $this->default($variable, $default);
191 5
        }
192
193
        return $this;
194
    }
195
196
    /**
197
     * Set the route(s) placeholder requirement.
198
     *
199
     * @param array<int,string>|string $regexp The regexp to apply
200
     *
201
     * @return $this
202
     */
203 1
    public function placeholder(string $variable, string|array $regexp): self
204
    {
205 1
        if ($this->asRoute) {
206
            $this->routes[$this->defaultIndex]['placeholders'][$variable] = $regexp;
207
        } elseif (-1 === $this->defaultIndex && empty($this->groups)) {
208
            $this->prototypes['placeholders'] = \array_merge_recursive($this->prototypes['placeholders'] ?? [], [$variable => $regexp]);
209
        } else {
210
            foreach ($this->routes as &$route) {
211
                $route['placeholders'] = \array_merge_recursive($route['placeholders'] ?? [], [$variable => $regexp]);
212
            }
213
214
            $this->resolveGroup(__FUNCTION__, [$variable, $regexp]);
215 1
        }
216
217 1
        return $this;
218
    }
219
220
    /**
221
     * Set the route(s) placeholder requirements.
222
     *
223
     * @param array<string,array<int,string>|string> $placeholders The regexps to apply
224
     *
225
     * @return $this
226
     */
227 4
    public function placeholders(array $placeholders): self
228
    {
229 4
        foreach ($placeholders as $placeholder => $value) {
230
            $this->placeholder($placeholder, $value);
231
        }
232
233
        return $this;
234
    }
235
236
    /**
237
     * Set the named parameter supplied to route(s) handler's constructor/factory.
238
     *
239 6
     * @return $this
240
     */
241 6
    public function argument(string $parameter, mixed $value): self
242
    {
243
        $resolver = fn ($value) => \is_numeric($value) ? (int) $value : (\is_string($value) ? \rawurldecode($value) : $value);
244
245
        if ($this->asRoute) {
246
            $this->routes[$this->defaultIndex]['arguments'][$parameter] = $resolver($value);
247
        } elseif (-1 === $this->defaultIndex && empty($this->groups)) {
248
            $this->prototypes['arguments'] = \array_merge_recursive($this->prototypes['arguments'] ?? [], [$parameter => $value]);
249
        } else {
250
            foreach ($this->routes as &$route) {
251 9
                $route['arguments'] = \array_merge_recursive($route['arguments'] ?? [], [$parameter => $resolver($value)]);
252
            }
253 9
            $this->resolveGroup(__FUNCTION__, [$parameter, $value]);
254
        }
255
256
        return $this;
257
    }
258
259
    /**
260
     * Set the named parameters supplied to route(s) handler's constructor/factory.
261
     *
262
     * @param array<string,mixed> $parameters The route handler parameters
263 9
     *
264
     * @return $this
265 9
     */
266
    public function arguments(array $parameters): self
267
    {
268
        foreach ($parameters as $parameter => $value) {
269
            $this->argument($parameter, $value);
270
        }
271
272
        return $this;
273
    }
274
275 4
    /**
276
     * Set the missing namespace for route(s) handler(s).
277 4
     *
278
     * @return $this
279
     *
280
     * @throws InvalidControllerException if namespace does not ends with a \
281
     */
282
    public function namespace(string $namespace): self
283
    {
284
        if ('\\' !== $namespace[-1]) {
285
            throw new InvalidControllerException(\sprintf('Cannot set a route\'s handler namespace "%s" without an ending "\\".', $namespace));
286
        }
287
288
        if ($this->asRoute) {
289
            $handler = &$this->routes[$this->defaultIndex]['handler'] ?? null;
290
291
            if (!empty($handler)) {
292
                $handler = $this->resolveHandler($handler, $namespace);
293
            } else {
294
                $this->routes[$this->defaultIndex][__FUNCTION__] = $namespace;
295
            }
296
        } elseif (-1 === $this->defaultIndex && empty($this->groups)) {
297 20
            $this->prototypes[__FUNCTION__][] = $namespace;
298
        } else {
299 20
            foreach ($this->routes as &$route) {
300 1
                $route['handler'] = $this->resolveHandler($route['handler'] ?? null, $namespace);
301
            }
302
            $this->resolveGroup(__FUNCTION__, [$namespace]);
303 20
        }
304 16
305 10
        return $this;
306 10
    }
307
308 6
    /**
309 6
     * Set the route(s) HTTP request method(s).
310
     *
311
     * @return $this
312 6
     */
313 3
    public function method(string ...$methods): self
314
    {
315
        if ($this->asRoute) {
316
            foreach ($methods as $method) {
317 20
                $this->routes[$this->defaultIndex]['methods'][\strtoupper($method)] = true;
318
            }
319
320
            return $this;
321
        }
322
323
        $routeMethods = \array_fill_keys(\array_map('strtoupper', $methods), true);
324
325
        if (-1 === $this->defaultIndex && empty($this->groups)) {
326
            $this->prototypes['methods'] = \array_merge($this->prototypes['methods'] ?? [], $routeMethods);
327
        } else {
328
            foreach ($this->routes as &$route) {
329
                $route['methods'] += $routeMethods;
330
            }
331
            $this->resolveGroup(__FUNCTION__, $methods);
332
        }
333
334
        return $this;
335
    }
336
337
    /**
338
     * Set route(s) HTTP host scheme(s).
339
     *
340
     * @return $this
341
     */
342
    public function scheme(string ...$schemes): self
343
    {
344
        if ($this->asRoute) {
345
            foreach ($schemes as $scheme) {
346
                $this->routes[$this->defaultIndex]['schemes'][$scheme] = true;
347
            }
348
349
            return $this;
350
        }
351
        $routeSchemes = \array_fill_keys($schemes, true);
352
353
        if (-1 === $this->defaultIndex && empty($this->groups)) {
354
            $this->prototypes['schemes'] = \array_merge($this->prototypes['schemes'] ?? [], $routeSchemes);
355
        } else {
356
            foreach ($this->routes as &$route) {
357
                $route['schemes'] = \array_merge($route['schemes'] ?? [], $routeSchemes);
358
            }
359
            $this->resolveGroup(__FUNCTION__, $schemes);
360
        }
361
362
        return $this;
363
    }
364
365
    /**
366
     * Set the route(s) HTTP host name(s).
367
     *
368
     * @return $this
369
     */
370
    public function domain(string ...$domains): self
371
    {
372
        $resolver = static function (array &$route, array $domains): void {
373
            foreach ($domains as $domain) {
374
                if (1 === \preg_match('/^(?:([a-z]+)\:\/{2})?([^\/]+)?$/u', $domain, $m, \PREG_UNMATCHED_AS_NULL)) {
375
                    if (isset($m[1])) {
376
                        $route['schemes'][$m[1]] = true;
377
                    }
378
379
                    if (isset($m[2])) {
380
                        $route['hosts'][$m[2]] = true;
381
                    }
382
                }
383
            }
384
        };
385
386
        if ($this->asRoute) {
387
            $resolver($this->routes[$this->defaultIndex], $domains);
388
        } elseif (-1 === $this->defaultIndex && empty($this->groups)) {
389
            $this->prototypes[__FUNCTION__] = \array_merge($this->prototypes[__FUNCTION__] ?? [], $domains);
390
        } else {
391
            foreach ($this->routes as &$route) {
392
                $resolver($route, $domains);
393
            }
394
            $this->resolveGroup(__FUNCTION__, $domains);
395
        }
396
397
        return $this;
398
    }
399
400
    /**
401
     * Set prefix path which should be prepended to route(s) path.
402
     *
403
     * @return $this
404
     */
405
    public function prefix(string $path): self
406
    {
407
        $resolver = static function (string $prefix, string $path): string {
408
            if ('/' !== ($prefix[0] ?? '')) {
409
                $prefix = '/'.$prefix;
410
            }
411
412
            if ($prefix[-1] === $path[0] || 1 === \preg_match('/^\W+$/', $prefix[-1])) {
413
                return $prefix.\substr($path, 1);
414
            }
415
416
            return $prefix.$path;
417
        };
418
419
        if ($this->asRoute) {
420
            \preg_match(
421
                static::PRIORITY_REGEX,
422
                $this->routes[$this->defaultIndex]['path'] = $resolver(
423
                    $path,
424
                    $this->routes[$this->defaultIndex]['path'] ?? '',
425
                ),
426
                $m,
427
                \PREG_UNMATCHED_AS_NULL
428
            );
429
            $this->routes[$this->defaultIndex]['prefix'] = $m[1] ?? null;
430
        } elseif (-1 === $this->defaultIndex && empty($this->groups)) {
431
            $this->prototypes[__FUNCTION__][] = $path;
432
        } else {
433
            foreach ($this->routes as &$route) {
434
                \preg_match(static::PRIORITY_REGEX, $route['path'] = $resolver($path, $route['path']), $m);
435
                $route['prefix'] = $m[1] ?? null;
436
            }
437
438
            $this->resolveGroup(__FUNCTION__, [$path]);
439
        }
440
441
        return $this;
442
    }
443
444
    /**
445
     * Set a set of named grouped middleware(s) to route(s).
446
     *
447
     * @return $this
448
     */
449
    public function piped(string ...$to): self
450
    {
451
        if ($this->asRoute) {
452
            foreach ($to as $middleware) {
453
                $this->routes[$this->defaultIndex]['middlewares'][$middleware] = true;
454
            }
455
456
            return $this;
457
        }
458
        $routeMiddlewares = \array_fill_keys($to, true);
459
460
        if (-1 === $this->defaultIndex && empty($this->groups)) {
461
            $this->prototypes['middlewares'] = \array_merge($this->prototypes['middlewares'] ?? [], $routeMiddlewares);
462
        } else {
463
            foreach ($this->routes as &$route) {
464
                $route['middlewares'] = \array_merge($route['middlewares'] ?? [], $routeMiddlewares);
465
            }
466
            $this->resolveGroup(__FUNCTION__, $to);
467
        }
468
469
        return $this;
470
    }
471
472
    /**
473
     * Set a custom key and value to route(s).
474
     *
475
     * @return $this
476
     */
477
    public function set(string $key, mixed $value): self
478
    {
479
        if (\in_array($key, [
480
            'name',
481
            'handler',
482
            'arguments',
483
            'namespace',
484
            'middlewares',
485
            'methods',
486
            'placeholders',
487
            'prefix',
488
            'hosts',
489
            'schemes',
490
            'defaults',
491
        ], true)) {
492
            throw new \InvalidArgumentException(\sprintf('Cannot replace the default "%s" route binding.', $key));
493
        }
494
495
        if ($this->asRoute) {
496
            $this->routes[$this->defaultIndex][$key] = $value;
497
        } elseif (-1 === $this->defaultIndex && empty($this->groups)) {
498
            $this->prototypes[$key] = !\is_array($value) ? $value : \array_merge($this->prototypes[$key] ?? [], $value);
499
        } else {
500
            foreach ($this->routes as &$route) {
501
                $route[$key] = \is_array($value) ? \array_merge($route[$key] ?? [], $value) : $value;
502
            }
503
            $this->resolveGroup(__FUNCTION__, [$key, $value]);
504
        }
505
506
        return $this;
507
    }
508
509
    protected function resolveHandler(mixed $handler, string $namespace = null): mixed
510
    {
511
        if (empty($namespace)) {
512
            return $handler;
513
        }
514
515
        if (\is_string($handler)) {
516
            if ('\\' === $handler[0] || \str_starts_with($handler, $namespace)) {
517
                return $handler;
518
            }
519
            $handler = $namespace.$handler;
520
        } elseif (\is_array($handler)) {
521
            if (2 !== \count($handler, \COUNT_RECURSIVE)) {
522
                throw new InvalidControllerException('Cannot use a non callable like array as route handler.');
523
            }
524
525
            if (\is_string($handler[0]) && !\str_starts_with($handler[0], $namespace)) {
526
                $handler[0] = $this->resolveHandler($handler[0], $namespace);
527
            }
528
        } elseif ($handler instanceof ResourceHandler) {
529
            $handler = $handler->namespace($namespace);
530
        }
531
532
        return $handler;
533
    }
534
535
    /**
536
     * @param array<int,mixed> $arguments
537
     */
538
    protected function resolveGroup(string $method, array $arguments): void
539
    {
540
        foreach ($this->groups as $group) {
541
            $asRoute = $group->asRoute;
542
            $group->asRoute = false;
543
            \call_user_func_array([$group, $method], $arguments);
544
            $group->asRoute = $asRoute;
545
        }
546
    }
547
}
548