Issues (25)

src/Route.php (5 issues)

1
<?php
2
namespace Lead\Router;
3
4
use Closure;
5
6
/**
7
 * The Route class.
8
 */
9
class Route
10
{
11
    const FOUND = 0;
12
13
    const NOT_FOUND = 404;
14
15
    /**
16
     * Class dependencies.
17
     *
18
     * @var array
19
     */
20
    protected $_classes = [];
21
22
    /**
23
     * The route's error.
24
     *
25
     * @var integer
26
     */
27
    protected $_error = 0;
28
29
    /**
30
     * The route's message.
31
     *
32
     * @var string
33
     */
34
    protected $_message = 'OK';
35
36
    /**
37
     * Route's name.
38
     *
39
     * @var string
40
     */
41
    public $name = '';
42
43
    /**
44
     * Named parameter.
45
     *
46
     * @var array
47
     */
48
    public $params = [];
49
50
    /**
51
     * List of parameters that should persist during dispatching.
52
     *
53
     * @var array
54
     */
55
    public $persist = [];
56
57
    /**
58
     * The attached namespace.
59
     *
60
     * @var string
61
     */
62
    public $namespace = '';
63
64
    /**
65
     * The attached request.
66
     *
67
     * @var mixed
68
     */
69
    public $request = null;
70
71
    /**
72
     * The attached response.
73
     *
74
     * @var mixed
75
     */
76
    public $response = null;
77
78
    /**
79
     * The dispatched instance (custom).
80
     *
81
     * @var object
82
     */
83
    public $dispatched = null;
84
85
    /**
86
     * The route scope.
87
     *
88
     * @var array
89
     */
90
    protected $_scope = null;
91
92
    /**
93
     * The route's host.
94
     *
95
     * @var object
96
     */
97
    protected $_host = null;
98
99
    /**
100
     * Route's allowed methods.
101
     *
102
     * @var array
103
     */
104
    protected $_methods = [];
105
106
    /**
107
     * Route's prefix.
108
     *
109
     * @var array
110
     */
111
    protected $_prefix = '';
112
113
    /**
114
     * Route's pattern.
115
     *
116
     * @var string
117
     */
118
    protected $_pattern = '';
119
120
    /**
121
     * The tokens structure extracted from route's pattern.
122
     *
123
     * @see Parser::tokenize()
124
     * @var array
125
     */
126
    protected $_token = null;
127
128
    /**
129
     * The route's regular expression pattern.
130
     *
131
     * @see Parser::compile()
132
     * @var string
133
     */
134
    protected $_regex = null;
135
136
    /**
137
     * The route's variables.
138
     *
139
     * @see Parser::compile()
140
     * @var array
141
     */
142
    protected $_variables = null;
143
144
    /**
145
     * The route's handler to execute when a request match.
146
     *
147
     * @var Closure
148
     */
149
    protected $_handler = null;
150
151
    /**
152
     * The middlewares.
153
     *
154
     * @var array
155
     */
156
    protected $_middleware = [];
157
158
    /**
159
     * Constructs a route
160
     *
161
     * @param array $config The config array.
162
     */
163
    public function __construct($config = [])
164
    {
165
        $defaults = [
166
            'error'          => static::FOUND,
167
            'message'        => 'OK',
168
            'scheme'         => '*',
169
            'host'           => null,
170
            'methods'        => '*',
171
            'prefix'         => '',
172
            'pattern'        => '',
173
            'name'           => '',
174
            'namespace'      => '',
175
            'handler'        => null,
176
            'params'         => [],
177
            'persist'        => [],
178
            'scope'          => null,
179
            'middleware'     => [],
180
            'classes'        => [
181
                'parser' => 'Lead\Router\Parser',
182
                'host'   => 'Lead\Router\Host'
183
            ]
184 35
        ];
185 35
        $config += $defaults;
186
187 35
        $this->name = $config['name'];
188 35
        $this->namespace = $config['namespace'];
189 35
        $this->params = $config['params'];
190 35
        $this->persist = $config['persist'];
191 35
        $this->handler($config['handler']);
192
193 35
        $this->_classes = $config['classes'];
194
195 35
        $this->_prefix = trim($config['prefix'], '/');
196
        if ($this->_prefix) {
197 15
            $this->_prefix = '/' . $this->_prefix;
198
        }
199
200 35
        $this->host($config['host'], $config['scheme']);
201 35
        $this->methods($config['methods']);
202
203 35
        $this->_scope = $config['scope'];
204 35
        $this->_middleware = (array) $config['middleware'];
205 35
        $this->_error = $config['error'];
206 35
        $this->_message = $config['message'];
207
208 35
        $this->pattern($config['pattern']);
209
    }
210
211
    /**
212
     * Gets/sets the route host.
213
     *
214
     * @param  object      $host The host instance to set or none to get the setted one.
215
     * @return object|self       The current host on get or `$this` on set.
216
     */
217
    public function host($host = null, $scheme = '*')
218
    {
219
        if (!func_num_args()) {
220 27
            return $this->_host;
221
        }
222
        if (!is_string($host)) {
223 24
            $this->_host = $host;
224 24
            return $this;
225
        }
226
        if ($host !== '*' || $scheme !== '*') {
227 12
            $class = $this->_classes['host'];
228 12
            $this->_host = new $class(['scheme' => $scheme, 'pattern' => $host]);
229
        }
230 27
        return $this;
231
    }
232
233
    /**
234
     * Gets/sets the allowed methods.
235
     *
236
     * @param  string|array $allowedMethods The allowed methods set or none to get the setted one.
237
     * @return array|self                   The allowed methods on get or `$this` on set.
238
     */
239
    public function methods($methods = null)
240
    {
241
        if (!func_num_args()) {
242 8
            return array_keys($this->_methods);
243
        }
244 35
        $methods = $methods ? (array) $methods : [];
245 35
        $methods = array_map('strtoupper', $methods);
246 35
        $this->_methods = array_fill_keys($methods, true);
247 35
        return $this;
248
    }
249
250
    /**
251
     * Allows additionnal methods.
252
     *
253
     * @param  string|array $methods The methods to allow.
254
     * @return self
255
     */
256
    public function allow($methods = [])
257
    {
258 2
        $methods = $methods ? (array) $methods : [];
259 2
        $methods = array_map('strtoupper', $methods);
260 2
        $this->_methods = array_fill_keys($methods, true) + $this->_methods;
261 2
        return $this;
262
    }
263
264
    /**
265
     * Gets/sets the route scope.
266
     *
267
     * @param  object      $scope The scope instance to set or none to get the setted one.
268
     * @return object|self        The current scope on get or `$this` on set.
269
     */
270
    public function scope($scope = null)
271
    {
272
        if (!func_num_args()) {
273 10
            return $this->_scope;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $this->_scope returns the type array which is incompatible with the documented return type Lead\Router\Route|object.
Loading history...
274
        }
275 2
        $this->_scope = $scope;
276 2
        return $this;
277
    }
278
279
    /**
280
     * Gets the routing error number.
281
     *
282
     * @return integer The routing error number.
283
     */
284
    public function error()
285
    {
286 18
        return $this->_error;
287
    }
288
289
    /**
290
     * Gets the routing error message.
291
     *
292
     * @return string The routing error message.
293
     */
294
    public function message()
295
    {
296 14
        return $this->_message;
297
    }
298
299
    /**
300
     * Gets the route's pattern.
301
     *
302
     * @return array The route's pattern.
303
     */
304
    public function pattern($pattern = null)
305
    {
306
        if (!func_num_args()) {
307 4
            return $this->_pattern;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $this->_pattern returns the type string which is incompatible with the documented return type array.
Loading history...
308
        }
309 35
        $this->_token = null;
310 35
        $this->_regex = null;
311 35
        $this->_variables = null;
312
313
        if (!$pattern || $pattern[0] !== '[') {
314 35
            $pattern = '/' . trim($pattern, '/');
315
        }
316
317 35
        $this->_pattern = $this->_prefix . $pattern;
318 35
        return $this;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $this returns the type Lead\Router\Route which is incompatible with the documented return type array.
Loading history...
319
    }
320
321
    /**
322
     * Returns the route's token structures.
323
     *
324
     * @return array A collection route's token structure.
325
     */
326
    public function token()
327
    {
328
        if ($this->_token === null) {
329 28
            $parser = $this->_classes['parser'];
330 28
            $this->_token = [];
331 28
            $this->_regex = null;
332 28
            $this->_variables = null;
333 28
            $this->_token = $parser::tokenize($this->_pattern, '/');
334
        }
335 28
        return $this->_token;
336
    }
337
338
    /**
339
     * Gets the route's regular expression pattern.
340
     *
341
     * @return string the route's regular expression pattern.
342
     */
343
    public function regex()
344
    {
345
        if ($this->_regex !== null) {
346 10
            return $this->_regex;
347
        }
348 26
        $this->_compile();
349 26
        return $this->_regex;
350
    }
351
352
    /**
353
     * Gets the route's variables and their associated pattern in case of array variables.
354
     *
355
     * @return array The route's variables and their associated pattern.
356
     */
357
    public function variables()
358
    {
359
        if ($this->_variables !== null) {
360 26
            return $this->_variables;
361
        }
362 2
        $this->_compile();
363 2
        return $this->_variables;
364
    }
365
366
    /**
367
     * Compiles the route's patten.
368
     */
369
    protected function _compile()
370
    {
371 26
        $parser = $this->_classes['parser'];
372 26
        $rule = $parser::compile($this->token());
373 26
        $this->_regex = $rule[0];
374 26
        $this->_variables = $rule[1];
375
    }
376
377
    /**
378
     * Gets/sets the route's handler.
379
     *
380
     * @param  array      $handler The route handler.
381
     * @return array|self
382
     */
383
    public function handler($handler = null)
384
    {
385
        if (func_num_args() === 0) {
386 8
            return $this->_handler;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $this->_handler returns the type Closure which is incompatible with the documented return type Lead\Router\Route|array.
Loading history...
387
        }
388 35
        $this->_handler = $handler;
389 35
        return $this;
390
    }
391
392
    /**
393
     * Checks if the route instance matches a request.
394
     *
395
     * @param  array   $request a request.
396
     * @return boolean
397
     */
398
    public function match($request, &$variables = null, &$hostVariables = null)
399
    {
400 24
        $hostVariables = [];
401
402
        if (($host = $this->host()) && !$host->match($request, $hostVariables)) {
403 4
            return false;
404
        }
405
406 24
        $path = isset($request['path']) ? $request['path'] : '';
407 24
        $method = isset($request['method']) ? $request['method'] : '*';
408
409
        if (!isset($this->_methods['*']) && $method !== '*' && !isset($this->_methods[$method])) {
410
            if ($method !== 'HEAD' && !isset($this->_methods['GET'])) {
411
                return false;
412
            }
413
        }
414
415 24
        $path = '/' . trim($path, '/');
416
417
        if (!preg_match('~^' . $this->regex() . '$~', $path, $matches)) {
418 10
            return false;
419
        }
420 24
        $variables = $this->_buildVariables($matches);
421 24
        $this->params = $hostVariables + $variables;
422 24
        return true;
423
    }
424
425
    /**
426
     * Combines route's variables names with the regex matched route's values.
427
     *
428
     * @param  array $varNames The variable names array with their corresponding pattern segment when applicable.
429
     * @param  array $values   The matched values.
430
     * @return array           The route's variables.
431
     */
432
    protected function _buildVariables($values)
433
    {
434 24
        $variables = [];
435 24
        $parser = $this->_classes['parser'];
436
437 24
        $i = 1;
438
        foreach ($this->variables() as $name => $pattern) {
439
            if (!isset($values[$i])) {
440 8
                $variables[$name] = !$pattern ? null : [];
441 8
                continue;
442
            }
443
            if (!$pattern) {
444 16
                $variables[$name] = $values[$i] ?: null;
445
            } else {
446 2
                $token = $parser::tokenize($pattern, '/');
447 2
                $rule = $parser::compile($token);
448
                if (preg_match_all('~' . $rule[0] . '~', $values[$i], $parts)) {
449
                    foreach ($parts[1] as $value) {
450
                        if (strpos($value, '/') !== false) {
451 2
                            $variables[$name][] = explode('/', $value);
452
                        } else {
453 2
                            $variables[$name][] = $value;
454
                        }
455
                    }
456
                } else {
457 2
                    $variables[$name] = [];
458
                }
459
            }
460 16
            $i++;
461
        }
462 24
        return $variables;
463
    }
464
465
    /**
466
     * Dispatches the route.
467
     *
468
     * @param  mixed $response The outgoing response.
469
     * @return mixed           The handler return value.
470
     */
471
    public function dispatch($response = null)
472
    {
473
        if ($error = $this->error()) {
474 2
            throw new RouterException($this->message(), $error);
475
        }
476 8
        $this->response = $response;
477 8
        $request = $this->request;
478
479 8
        $generator = $this->middleware();
480
481
        $next = function() use ($request, $response, $generator, &$next) {
482 8
            $handler = $generator->current();
483 8
            $generator->next();
484 8
            return $handler($request, $response, $next);
485
        };
486 8
        return $next();
487
    }
488
489
    /**
490
     * Middleware generator.
491
     *
492
     * @return callable
493
     */
494
    public function middleware()
495
    {
496
        foreach ($this->_middleware as $middleware) {
497 2
            yield $middleware;
0 ignored issues
show
Bug Best Practice introduced by
The expression yield $middleware returns the type Generator which is incompatible with the documented return type callable.
Loading history...
498
        }
499
500
        if ($scope = $this->scope()) {
501
            foreach ($scope->middleware() as $middleware) {
502 2
                yield $middleware;
503
            }
504
        }
505
506
        yield function() {
507 8
            $handler = $this->handler();
508 8
            return $handler($this, $this->response);
509
        };
510
    }
511
512
    /**
513
     * Adds a middleware to the list of middleware.
514
     *
515
     * @param object|Closure A callable middleware.
516
     */
517
    public function apply($middleware)
518
    {
519
        foreach (func_get_args() as $mw) {
520 2
            array_unshift($this->_middleware, $mw);
521
        }
522 2
        return $this;
523
    }
524
525
    /**
526
     * Returns the route's link.
527
     *
528
     * @param  array  $params  The route parameters.
529
     * @param  array  $options Options for generating the proper prefix. Accepted values are:
530
     *                         - `'absolute'` _boolean_: `true` or `false`.
531
     *                         - `'scheme'`   _string_ : The scheme.
532
     *                         - `'host'`     _string_ : The host name.
533
     *                         - `'basePath'` _string_ : The base path.
534
     *                         - `'query'`    _string_ : The query string.
535
     *                         - `'fragment'` _string_ : The fragment string.
536
     * @return string          The link.
537
     */
538
    public function link($params = [], $options = [])
539
    {
540
        $defaults = [
541
            'absolute' => false,
542
            'basePath' => '',
543
            'query'    => '',
544
            'fragment' => ''
545 4
        ];
546
547 4
        $options = array_filter($options, function($value) { return $value !== '*'; });
548 4
        $options += $defaults;
549
550 4
        $params = $params + $this->params;
551
552 4
        $link = $this->_link($this->token(), $params);
553
554 4
        $basePath = trim($options['basePath'], '/');
555
        if ($basePath) {
556 4
            $basePath = '/' . $basePath;
557
        }
558 4
        $link = isset($link) ? ltrim($link, '/') : '';
559 4
        $link = $basePath . ($link ? '/' . $link : $link);
560 4
        $query = $options['query'] ? '?' . $options['query'] : '';
561 4
        $fragment = $options['fragment'] ? '#' . $options['fragment'] : '';
562
563
        if ($options['absolute']) {
564
            if ($host = $this->host()) {
565 4
                $link = $host->link($params, $options) . "{$link}";
566
            } else {
567
                $scheme = !empty($options['scheme']) ? $options['scheme'] . '://' : '//';
568
                $host = isset($options['host']) ? $options['host'] : 'localhost';
569
                $link = "{$scheme}{$host}{$link}";
570
            }
571
        }
572
573 4
        return $link . $query . $fragment;
574
    }
575
576
    /**
577
     * Helper for `Route::link()`.
578
     *
579
     * @param  array  $token    The token structure array.
580
     * @param  array  $params   The route parameters.
581
     * @return string           The URL path representation of the token structure array.
582
     */
583
    protected function _link($token, $params)
584
    {
585 4
        $link = '';
586
        foreach ($token['tokens'] as $child) {
587
            if (is_string($child)) {
588 4
                $link .= $child;
589 4
                continue;
590
            }
591
            if (isset($child['tokens'])) {
592
                if ($child['repeat']) {
593 2
                    $name = $child['repeat'];
594 2
                    $values = isset($params[$name]) && $params[$name] !== null ? (array) $params[$name] : [];
595
                    if (!$values && !$child['optional']) {
596 2
                        throw new RouterException("Missing parameters `'{$name}'` for route: `'{$this->name}#{$this->_pattern}'`.");
597
                    }
598
                    foreach ($values as $value) {
599 2
                        $link .= $this->_link($child, [$name => $value] + $params);
600
                    }
601
                } else {
602 4
                    $link .= $this->_link($child, $params);
603
                }
604 4
                continue;
605
            }
606
607
            if (!isset($params[$child['name']])) {
608
                if (!$token['optional']) {
609 2
                    throw new RouterException("Missing parameters `'{$child['name']}'` for route: `'{$this->name}#{$this->_pattern}'`.");
610
                }
611 2
                return '';
612
            }
613
614
            if ($data = $params[$child['name']]) {
615 4
                $parts = is_array($data) ? $data : [$data];
616
            } else {
617
                $parts = [];
618
            }
619
            foreach ($parts as $key => $value) {
620 4
                $parts[$key] = rawurlencode($value);
621
            }
622 4
            $value = join('/', $parts);
623
624
            if (!preg_match('~^' . $child['pattern'] . '$~', $value)) {
625 2
                throw new RouterException("Expected `'" . $child['name'] . "'` to match `'" . $child['pattern'] . "'`, but received `'" . $value . "'`.");
626
            }
627 4
            $link .= $value;
628
        }
629 4
        return $link;
630
    }
631
}
632