Passed
Push — refactor ( 6a891c...eadb4f )
by Florian
01:44
created

Route::handler()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 1
CRAP Score 1

Importance

Changes 2
Bugs 0 Features 0
Metric Value
eloc 1
c 2
b 0
f 0
dl 0
loc 3
ccs 1
cts 1
cp 1
rs 10
cc 1
nc 1
nop 0
crap 1
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Lead\Router;
6
7
use Closure;
8
use Generator;
9
use InvalidArgumentException;
10
use Lead\Router\Exception\RouterException;
11
use RuntimeException;
12
13
/**
14
 * The Route class.
15
 */
16
class Route implements RouteInterface
17
{
18
    /**
19
     * Valid HTTP methods.
20
     *
21
     * @var array
22
     */
23
    protected const VALID_METHODS = [
24
        'GET', 'PUT', 'POST', 'PATCH', 'DELETE', 'OPTIONS', 'HEAD'
25
    ];
26
27
    /**
28
     * Class dependencies.
29
     *
30
     * @var array
31
     */
32
    protected $classes = [];
33
34
    /**
35
     * Route's name.
36
     *
37
     * @var string
38
     */
39
    public $name = '';
40
41
    /**
42
     * Named parameter.
43
     *
44
     * @var array
45
     */
46
    public $params = [];
47
48
    /**
49
     * List of parameters that should persist during dispatching.
50
     *
51
     * @var array
52
     */
53
    public $persist = [];
54
55
    /**
56
     * The attached namespace.
57
     *
58
     * @var string
59
     */
60
    public $namespace = '';
61
62
    /**
63
     * The attached request.
64
     *
65
     * @var mixed
66
     */
67
    public $request = null;
68
69
    /**
70
     * The attached response.
71
     *
72
     * @var mixed
73
     */
74
    public $response = null;
75
76
    /**
77
     * The dispatched instance (custom).
78
     *
79
     * @var object
80
     */
81
    public $dispatched = null;
82
83
    /**
84
     * The route scope.
85
     *
86
     * @var \Lead\Router\ScopeInterface|null
87
     */
88
    protected $scope = null;
89
90
    /**
91
     * The route's host.
92
     *
93
     * @var \Lead\Router\HostInterface|null
94
     */
95
    protected $host = null;
96
97
    /**
98
     * Route's allowed methods.
99
     *
100
     * @var array
101
     */
102
    protected $methods = [];
103
104
    /**
105
     * Route's prefix.
106
     *
107
     * @var string
108
     */
109
    protected $prefix = '';
110
111
    /**
112
     * Route's pattern.
113
     *
114
     * @var string
115
     */
116
    protected $pattern = '';
117
118
    /**
119
     * The tokens structure extracted from route's pattern.
120
     *
121
     * @see Parser::tokenize()
122
     * @var array|null
123
     */
124
    protected $token = null;
125
126
    /**
127
     * The route's regular expression pattern.
128
     *
129
     * @see Parser::compile()
130
     * @var string|null
131
     */
132
    protected $regex = null;
133
134
    /**
135
     * The route's variables.
136
     *
137
     * @see Parser::compile()
138
     * @var array|null
139
     */
140
    protected $variables = null;
141
142
    /**
143
     * The route's handler to execute when a request match.
144
     *
145
     * @var \Closure|null
146
     */
147
    protected $handler = null;
148
149
    /**
150
     * The middlewares.
151
     *
152
     * @var array
153
     */
154
    protected $middleware = [];
155
156
    /**
157
     * Attributes
158
     *
159
     * @var array
160
     */
161
    protected $attributes = [];
162
163
    /**
164
     * Constructs a route
165
     *
166
     * @param array $config The config array.
167
     */
168
    public function __construct(array $config = [])
169
    {
170 66
        $config = $this->defaultConfig($config);
171
172 66
        $this->classes = $config['classes'];
173 66
        $this->setNamespace($config['namespace']);
174 66
        $this->setName($config['name']);
175 66
        $this->setParams($config['params']);
176 66
        $this->setPersistentParams($config['persist']);
177 66
        $this->setHandler($config['handler']);
178 66
        $this->setPrefix($config['prefix']);
179 66
        $this->setHost($config['host'], $config['scheme']);
180 66
        $this->setMethods($config['methods']);
181 66
        $this->setScope($config['scope']);
182 66
        $this->setPattern($config['pattern']);
183 66
        $this->setMiddleware((array)$config['middleware']);
184
    }
185
186
    /**
187
     * Sets the middlewares
188
     *
189
     * @param array $middleware Middlewares
190
     * @return \Lead\Router\Route
191
     */
192
    public function setMiddleware(array $middleware)
193
    {
194 66
        $this->middleware = (array)$middleware;
195
196 66
        return $this;
197
    }
198
199
    /**
200
     * Gets the default config
201
     *
202
     * @param array $config Values to merge
203
     * @return array
204
     */
205
    protected function defaultConfig($config = []): array
206
    {
207
        $defaults = [
208
            'scheme' => '*',
209
            'host' => null,
210
            'methods' => '*',
211
            'prefix' => '',
212
            'pattern' => '',
213
            'name' => '',
214
            'namespace' => '',
215
            'handler' => null,
216
            'params' => [],
217
            'persist' => [],
218
            'scope' => null,
219
            'middleware' => [],
220
            'classes' => [
221
                'parser' => 'Lead\Router\Parser',
222
                'host' => 'Lead\Router\Host'
223
            ]
224 66
        ];
225 66
        $config += $defaults;
226
227 66
        return $config;
228
    }
229
230
    /**
231
     * Sets a route attribute
232
     *
233
     * This method can be used to set arbitrary date attributes to a route.
234
     *
235
     * @param string $name Name
236
     * @param mixed $value Value
237
     * @return \Lead\Router\RouteInterface
238
     */
239
    public function setAttribute($name, $value): RouteInterface
0 ignored issues
show
Unused Code introduced by
The parameter $value is not used and could be removed. ( Ignorable by Annotation )

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

239
    public function setAttribute($name, /** @scrutinizer ignore-unused */ $value): RouteInterface

This check looks for parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
240
    {
241
        if (isset($this->attributes[$name])) {
242
            return $this->attributes[$name];
243
        }
244
        return $this;
245
    }
246
247
    /**
248
     * Gets a route attribute
249
     *
250
     * @param string $name Attribute name
251
     * @return mixed
252
     */
253
    public function Attribute(string $name)
0 ignored issues
show
Coding Style introduced by
Method name "Route::Attribute" is not in camel caps format
Loading history...
254
    {
255
        if (isset($this->attributes[$name])) {
256
            return $this->attributes[$name];
257
        }
258
259
        return null;
260
    }
261
262
    /**
263
     * Sets namespace
264
     *
265
     * @param string $namespace Namespace
266
     * @return self
267
     */
268
    public function setNamespace(string $namespace): self
269
    {
270 66
        $this->namespace = $namespace;
271
272 66
        return $this;
273
    }
274
275
    /**
276
     * Get namespace
277
     *
278
     * @return string
279
     */
280
    public function namespace(): string
281
    {
282
        return $this->namespace;
283
    }
284
285
    /**
286
     * Sets params
287
     *
288
     * @param array $params Params
289
     * @return self
290
     */
291
    public function setParams(array $params): self
292
    {
293 66
        $this->params = $params;
294
295 66
        return $this;
296
    }
297
298
    /**
299
     * Get parameters
300
     *
301
     * @return array
302
     */
303
    public function params(): array
304
    {
305 2
        return $this->params;
306
    }
307
308
    /**
309
     * Sets persistent params
310
     *
311
     * @param array $params Params
312
     * @return self
313
     */
314
    public function setPersistentParams(array $params): self
315
    {
316 66
        $this->persist = $params;
317
318 66
        return $this;
319
    }
320
321
    /**
322
     * Get persistent parameters
323
     *
324
     * @return array
325
     */
326
    public function persistentParams(): array
327
    {
328 35
        return $this->persist;
329
    }
330
331
    /**
332
     * Gets the routes name
333
     *
334
     * @return string
335
     */
336
    public function name(): string
337
    {
338
        return $this->name;
339
    }
340
341
    /**
342
     * Sets the routes name
343
     *
344
     * @param string $name Name
345
     * @return self
346
     */
347
    public function setName(string $name): RouteInterface
348
    {
349 66
        $this->name = $name;
350
351 66
        return $this;
352
    }
353
354
    /**
355
     * Gets the prefix
356
     *
357
     * @return string
358
     */
359
    public function prefix(): string
360
    {
361
        return $this->prefix;
362
    }
363
364
    /**
365
     * Sets the routes prefix
366
     *
367
     * @param string $prefix Prefix
368
     * @return self
369
     */
370
    public function setPrefix(string $prefix): RouteInterface
371
    {
372 66
        $this->prefix = trim($prefix, '/');
373
        if ($this->prefix) {
374 15
            $this->prefix = '/' . $this->prefix;
375
        }
376
377 66
        return $this;
378
    }
379
380
    /**
381
     * Gets the host
382
     *
383
     * @return mixed
384
     */
385
    public function host(): ?HostInterface
386
    {
387 45
        return $this->host;
388
    }
389
390
    /**
391
     * Sets the route host.
392
     *
393
     * @param string|\Lead\Router\HostInterface $host The host instance to set or none to get the set one
394
     * @param string $scheme HTTP Scheme
395
     * @return $this The current host on get or `$this` on set
396
     */
397
    public function setHost($host = null, string $scheme = '*'): RouteInterface
398
    {
399
        if (!is_string($host) && $host instanceof Host && $host !== null) {
400
            throw new InvalidArgumentException();
401
        }
402
403
        if ($host instanceof HostInterface || $host === null) {
404 19
            $this->host = $host;
405
406 19
            return $this;
407
        }
408
409
        if ($host !== '*' || $scheme !== '*') {
410 11
            $class = $this->classes['host'];
411 11
            $host = new $class(['scheme' => $scheme, 'pattern' => $host]);
412
            if (!$host instanceof HostInterface) {
413
                throw new RuntimeException('Must be an instance of HostInterface');
414
            }
415 11
            $this->host = $host;
416
417 11
            return $this;
418
        }
419
420 36
        $this->host = null;
421
422 36
        return $this;
423
    }
424
425
    /**
426
     * Gets allowed methods
427
     *
428
     * @return array
429
     */
430
    public function methods(): array
431
    {
432 9
        return array_keys($this->methods);
433
    }
434
435
    /**
436
     * Sets methods
437
     *
438
     * @param  string|array $methods
439
     * @return self
440
     */
441
    public function setMethods($methods): self
442
    {
443 66
        $methods = $methods ? (array)$methods : [];
444 66
        $methods = array_map('strtoupper', $methods);
445 66
        $methods = array_fill_keys($methods, true);
446
447
        foreach ($methods as $method) {
448
            if (is_string($method) && !in_array($method, self::VALID_METHODS, true)) {
449 64
                throw new InvalidArgumentException(sprintf('`%s` is not an allowed HTTP method', $method));
450
            }
451
        }
452
453 66
        $this->methods = $methods;
454
455 66
        return $this;
456
    }
457
458
    /**
459
     * Allows additional methods.
460
     *
461
     * @param  string|array $methods The methods to allow.
462
     * @return self
463
     */
464
    public function allow($methods = [])
465
    {
466 47
        $methods = $methods ? (array)$methods : [];
467 47
        $methods = array_map('strtoupper', $methods);
468 47
        $methods = array_fill_keys($methods, true) + $this->methods;
469
470
        foreach ($methods as $method) {
471
            if (!in_array($method, self::VALID_METHODS)) {
472 47
                throw new InvalidArgumentException(sprintf('`%s` is not an allowed HTTP method', $method));
473
            }
474
        }
475
476 47
        $this->methods = $methods;
477
478 47
        return $this;
479
    }
480
481
    /**
482
     * Gets the routes Scope
483
     *
484
     * @return \Lead\Router\Scope
485
     */
486
    public function scope(): ?ScopeInterface
487
    {
488 5
        return $this->scope;
489
    }
490
491
    /**
492
     * Sets a routes scope
493
     *
494
     * @param  \Lead\Router\Scope|null $scope Scope
495
     * @return $this;
496
     */
497
    public function setScope(?Scope $scope): RouteInterface
498
    {
499 66
        $this->scope = $scope;
500
501 66
        return $this;
502
    }
503
504
    /**
505
     * Gets the routes pattern
506
     *
507
     * @return string
508
     */
509
    public function pattern(): string
510
    {
511 2
        return $this->pattern;
512
    }
513
514
    /**
515
     * Sets the routes pattern
516
     *
517
     * @return $this
518
     */
519
    public function setPattern(string $pattern): RouteInterface
520
    {
521 66
        $this->token = null;
522 66
        $this->regex = null;
523 66
        $this->variables = null;
524
525
        if (!$pattern || $pattern[0] !== '[') {
526 61
            $pattern = '/' . trim($pattern, '/');
527
        }
528
529 66
        $this->pattern = $this->prefix . $pattern;
530
531 66
        return $this;
532
    }
533
534
    /**
535
     * Returns the route's token structures.
536
     *
537
     * @return array A collection route's token structure.
538
     */
539
    public function token(): array
540
    {
541
        if ($this->token === null) {
542 56
            $parser = $this->classes['parser'];
543 56
            $this->token = [];
544 56
            $this->regex = null;
545 56
            $this->variables = null;
546 56
            $this->token = $parser::tokenize($this->pattern, '/');
547
        }
548
549 56
        return $this->token;
550
    }
551
552
    /**
553
     * Gets the route's regular expression pattern.
554
     *
555
     * @return string the route's regular expression pattern.
556
     */
557
    public function regex(): string
558
    {
559
        if ($this->regex !== null) {
560 15
            return $this->regex;
561
        }
562 40
        $this->compile();
563
564 40
        return $this->regex;
565
    }
566
567
    /**
568
     * Gets the route's variables and their associated pattern in case of array variables.
569
     *
570
     * @return array The route's variables and their associated pattern.
571
     */
572
    public function variables(): array
573
    {
574
        if ($this->variables !== null) {
575 36
            return $this->variables;
576
        }
577 1
        $this->compile();
578
579 1
        return $this->variables;
580
    }
581
582
    /**
583
     * Compiles the route's patten.
584
     */
585
    protected function compile(): void
586
    {
587 41
        $parser = $this->classes['parser'];
588 41
        $rule = $parser::compile($this->token());
589 41
        $this->regex = $rule[0];
590 41
        $this->variables = $rule[1];
591
    }
592
593
    /**
594
     * Gets the routes handler
595
     *
596
     * @return mixed
597
     */
598
    public function handler()
599
    {
600 4
        return $this->handler;
601
    }
602
603
    /**
604
     * Gets/sets the route's handler.
605
     *
606
     * @param mixed $handler The route handler.
607
     * @return self
608
     */
609
    public function setHandler($handler): RouteInterface
610
    {
611
        if (!is_callable($handler) && !is_string($handler) && $handler !== null) {
612
            throw new InvalidArgumentException('Handler must be a callable, string or null');
613
        }
614
615 66
        $this->handler = $handler;
616
617 66
        return $this;
618
    }
619
620
    /**
621
     * Checks if the route instance matches a request.
622
     *
623
     * @param  array $request a request.
624
     * @return bool
625
     */
626
    public function match($request, &$variables = null, &$hostVariables = null): bool
627
    {
628 39
        $hostVariables = [];
629
630
        if (($host = $this->host()) && !$host->match($request, $hostVariables)) {
0 ignored issues
show
Bug introduced by
$hostVariables of type array is incompatible with the type string expected by parameter $hostVariables of Lead\Router\HostInterface::match(). ( Ignorable by Annotation )

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

630
        if (($host = $this->host()) && !$host->match($request, /** @scrutinizer ignore-type */ $hostVariables)) {
Loading history...
Bug introduced by
$request of type array is incompatible with the type string expected by parameter $request of Lead\Router\HostInterface::match(). ( Ignorable by Annotation )

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

630
        if (($host = $this->host()) && !$host->match(/** @scrutinizer ignore-type */ $request, $hostVariables)) {
Loading history...
631 3
            return false;
632
        }
633
634 38
        $path = isset($request['path']) ? $request['path'] : '';
635 38
        $method = isset($request['method']) ? $request['method'] : '*';
636
637
        if (!isset($this->methods['*']) && $method !== '*' && !isset($this->methods[$method])) {
638
            if ($method !== 'HEAD' && !isset($this->methods['GET'])) {
639
                return false;
640
            }
641
        }
642
643 38
        $path = '/' . trim($path, '/');
644
645
        if (!preg_match('~^' . $this->regex() . '$~', $path, $matches)) {
646 10
            return false;
647
        }
648 35
        $variables = $this->_buildVariables($matches);
649 35
        $this->params = $hostVariables + $variables;
650
651 35
        return true;
652
    }
653
654
    /**
655
     * Combines route's variables names with the regex matched route's values.
656
     *
657
     * @param  array $varNames The variable names array with their corresponding pattern segment when applicable.
658
     * @param  array $values   The matched values.
659
     * @return array           The route's variables.
660
     */
661
    protected function _buildVariables(array $values): array
662
    {
663 35
        $variables = [];
664 35
        $parser = $this->classes['parser'];
665
666 35
        $i = 1;
667
        foreach ($this->variables() as $name => $pattern) {
668
            if (!isset($values[$i])) {
669 8
                $variables[$name] = !$pattern ? null : [];
670 8
                continue;
671
            }
672
            if (!$pattern) {
673 17
                $variables[$name] = $values[$i] ?: null;
674
            } else {
675 2
                $token = $parser::tokenize($pattern, '/');
676 2
                $rule = $parser::compile($token);
677
                if (preg_match_all('~' . $rule[0] . '~', $values[$i], $parts)) {
678
                    foreach ($parts[1] as $value) {
679
                        if (strpos($value, '/') !== false) {
680 1
                            $variables[$name][] = explode('/', $value);
681
                        } else {
682 2
                            $variables[$name][] = $value;
683
                        }
684
                    }
685
                } else {
686 1
                    $variables[$name] = [];
687
                }
688
            }
689 18
            $i++;
690
        }
691
692 35
        return $variables;
693
    }
694
695
    /**
696
     * Dispatches the route.
697
     *
698
     * @param  mixed $response The outgoing response.
699
     * @return mixed The handler return value.
700
     */
701
    public function dispatch($response = null)
702
    {
703 4
        $this->response = $response;
704 4
        $request = $this->request;
705
706 4
        $generator = $this->middleware();
707
708
        $next = function () use ($request, $response, $generator, &$next) {
709 4
            $handler = $generator->current();
710 4
            $generator->next();
711
712 4
            return $handler($request, $response, $next);
713
        };
714
715 4
        return $next();
716
    }
717
718
    /**
719
     * Middleware generator.
720
     *
721
     * @return \Generator
722
     */
723
    public function middleware(): Generator
724
    {
725
        foreach ($this->middleware as $middleware) {
726 1
            yield $middleware;
727
        }
728
729 4
        $scope = $this->scope();
730
        if ($scope !== null) {
731
            foreach ($scope->middleware() as $middleware) {
732 2
                yield $middleware;
733
            }
734
        }
735
736
        yield function () {
737 4
            $handler = $this->handler();
738
            if ($handler === null) {
739
                return null;
740
            }
741
742 4
            return $handler($this, $this->response);
743
        };
744
    }
745
746
    /**
747
     * Adds a middleware to the list of middleware.
748
     *
749
     * @param object|Closure A callable middleware.
0 ignored issues
show
Bug introduced by
The type Lead\Router\A was not found. Did you mean A? If so, make sure to prefix the type with \.
Loading history...
750
     * @return $this
751
     */
752
    public function apply($middleware)
753
    {
754
        foreach (func_get_args() as $mw) {
755 1
            array_unshift($this->middleware, $mw);
756
        }
757
758 1
        return $this;
759
    }
760
761
    /**
762
     * Returns the route's link.
763
     *
764
     * @param  array $params  The route parameters.
765
     * @param  array $options Options for generating the proper prefix. Accepted values are:
766
     *                        - `'absolute'` _boolean_: `true` or `false`. - `'scheme'`
767
     *                        _string_ : The scheme. - `'host'`     _string_ : The host
768
     *                        name. - `'basePath'` _string_ : The base path. - `'query'`
769
     *                        _string_ : The query string. - `'fragment'` _string_ : The
770
     *                        fragment string.
771
     * @return string          The link.
772
     */
773
    public function link(array $params = [], array $options = []): string
774
    {
775
        $defaults = [
776
            'absolute' => false,
777
            'basePath' => '',
778
            'query' => '',
779
            'fragment' => ''
780 17
        ];
781
782
        $options = array_filter(
783
            $options,
784
            function ($value) {
785 6
                return $value !== '*';
786
            }
787
        );
788 17
        $options += $defaults;
789
790 17
        $params = $params + $this->params;
791
792 17
        $link = $this->_link($this->token(), $params);
793
794 13
        $basePath = trim($options['basePath'], '/');
795
        if ($basePath) {
796 3
            $basePath = '/' . $basePath;
797
        }
798 13
        $link = isset($link) ? ltrim($link, '/') : '';
799 13
        $link = $basePath . ($link ? '/' . $link : $link);
800 13
        $query = $options['query'] ? '?' . $options['query'] : '';
801 13
        $fragment = $options['fragment'] ? '#' . $options['fragment'] : '';
802
803
        if ($options['absolute']) {
804
            if ($this->host !== null) {
805 3
                $link = $this->host->link($params, $options) . "{$link}";
806
            } else {
807
                $scheme = !empty($options['scheme']) ? $options['scheme'] . '://' : '//';
808
                $host = isset($options['host']) ? $options['host'] : 'localhost';
809
                $link = "{$scheme}{$host}{$link}";
810
            }
811
        }
812
813 13
        return $link . $query . $fragment;
814
    }
815
816
    /**
817
     * Helper for `Route::link()`.
818
     *
819
     * @param  array $token  The token structure array.
820
     * @param  array $params The route parameters.
821
     * @return string The URL path representation of the token structure array.
822
     */
823
    protected function _link(array $token, array $params): string
824
    {
825 17
        $link = '';
826
        foreach ($token['tokens'] as $child) {
827
            if (is_string($child)) {
828 17
                $link .= $child;
829 17
                continue;
830
            }
831
            if (isset($child['tokens'])) {
832
                if ($child['repeat']) {
833 5
                    $name = $child['repeat'];
834 5
                    $values = isset($params[$name]) && $params[$name] !== null ? (array)$params[$name] : [];
835
                    if (!$values && !$child['optional']) {
836 1
                        throw new RouterException("Missing parameters `'{$name}'` for route: `'{$this->name}#{$this->pattern}'`.");
837
                    }
838
                    foreach ($values as $value) {
839 4
                        $link .= $this->_link($child, [$name => $value] + $params);
840
                    }
841
                } else {
842 5
                    $link .= $this->_link($child, $params);
843
                }
844 7
                continue;
845
            }
846
847
            if (!isset($params[$child['name']])) {
848
                if (!$token['optional']) {
849
                    throw new RouterException("Missing parameters `'{$child['name']}'` for route: `'{$this->name}#{$this->pattern}'`.");
850
                }
851
852 3
                return '';
853
            }
854
855
            if ($data = $params[$child['name']]) {
856 16
                $parts = is_array($data) ? $data : [$data];
857
            } else {
858
                $parts = [];
859
            }
860
            foreach ($parts as $key => $value) {
861 16
                $parts[$key] = rawurlencode((string)$value);
862
            }
863 16
            $value = join('/', $parts);
864
865
            if (!preg_match('~^' . $child['pattern'] . '$~', $value)) {
866 3
                throw new RouterException("Expected `'" . $child['name'] . "'` to match `'" . $child['pattern'] . "'`, but received `'" . $value . "'`.");
867
            }
868 14
            $link .= $value;
869
        }
870
871 14
        return $link;
872
    }
873
}
874