Completed
Push — master ( 13968e...09f82b )
by Stefano
02:42
created

RouteGroup::__construct()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 4
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 3
c 0
b 0
f 0
nc 1
nop 0
dl 0
loc 4
rs 10
1
<?php
2
3
/**
4
 * Route
5
 *
6
 * URL Router and action dispatcher.
7
 *
8
 * @package core
9
 * @author [email protected]
10
 * @copyright Caffeina srl - 2016 - http://caffeina.com
11
 */
12
13
class Route {
0 ignored issues
show
Coding Style Compatibility introduced by
PSR1 recommends that each class must be in a namespace of at least one level to avoid collisions.

You can fix this by adding a namespace to your class:

namespace YourVendor;

class YourClass { }

When choosing a vendor namespace, try to pick something that is not too generic to avoid conflicts with other libraries.

Loading history...
14
    use Module,
15
        Events {
16
          on as onEvent;
17
        }
18
19
    public static $routes,
0 ignored issues
show
Coding Style introduced by
It is generally advisable to only define one property per statement.

Only declaring a single property per statement allows you to later on add doc comments more easily.

It is also recommended by PSR2, so it is a common style that many people expect.

Loading history...
20
                  $base           = '',
21
                  $prefix         = [],
22
                  $group          = [],
23
                  $tags           = [],
24
                  $optimized_tree = [];
25
26
    protected     $URLPattern         = '',
0 ignored issues
show
Coding Style introduced by
It is generally advisable to only define one property per statement.

Only declaring a single property per statement allows you to later on add doc comments more easily.

It is also recommended by PSR2, so it is a common style that many people expect.

Loading history...
27
                  $pattern            = '',
28
                  $matcher_pattern    = '',
29
                  $dynamic            = false,
30
                  $callback           = null,
31
                  $methods            = [],
32
                  $befores            = [],
33
                  $afters             = [],
34
35
                  $rules              = [],
36
                  $response           = '',
37
                  $tag                = '';
38
39
40
    /**
41
     * Create a new route definition. This method permits a fluid interface.
42
     *
43
     * @param string $URLPattern The URL pattern, can be used named parameters for variables extraction
44
     * @param $callback The callback to invoke on route match.
45
     * @param string $method The HTTP method for which the route must respond.
46
     * @return Route
47
     */
48
    public function __construct($URLPattern, $callback = null, $method='get'){
49
      $prefix  = static::$prefix ? rtrim(implode('',static::$prefix),'/') : '';
50
      $pattern = '/' . trim($URLPattern, "/");
51
52
      $this->callback         = $callback;
53
54
      // Adjust / optionality with dynamic patterns
55
      // Ex:  /test/(:a) ===> /test(/:a)
56
      $this->URLPattern       = str_replace('//','/',str_replace('/(','(/', rtrim("{$prefix}{$pattern}","/")));
57
58
      $this->dynamic          = $this->isDynamic($this->URLPattern);
59
60
      $this->pattern          = $this->dynamic
61
                                ? $this->compilePatternAsRegex($this->URLPattern, $this->rules)
62
                                : $this->URLPattern;
63
64
      $this->matcher_pattern  = $this->dynamic
65
                                ? $this->compilePatternAsRegex($this->URLPattern, $this->rules, false)
66
                                : '';
67
68
      // We will use hash-checks, for O(1) complexity vs O(n)
69
      $this->methods[$method] = 1;
70
      return static::add($this);
71
    }
72
73
    /**
74
     * Check if route match on a specified URL and HTTP Method.
75
     * @param  [type] $URL The URL to check against.
76
     * @param  string $method The HTTP Method to check against.
77
     * @return boolean
78
     */
79
    public function match($URL, $method='get'){
80
      $method = strtolower($method);
81
82
      // * is an http method wildcard
83
      if (empty($this->methods[$method]) && empty($this->methods['*'])) return false;
84
85
      return (bool) (
86
        $this->dynamic
87
           ? preg_match($this->matcher_pattern, '/'.trim($URL,'/'))
88
           : rtrim($URL,'/') == rtrim($this->pattern,'/')
89
      );
90
    }
91
92
    /**
93
     * Clears all stored routes definitions to pristine conditions.
94
     * @return void
95
     */
96
    public static function reset(){
97
      static::$routes = [];
98
      static::$base   = '';
99
      static::$prefix = [];
100
      static::$group  = [];
101
      static::$optimized_tree = [];
102
    }
103
104
    /**
105
     * Run one of the mapped callbacks to a passed HTTP Method.
106
     * @param  array  $args The arguments to be passed to the callback
107
     * @param  string $method The HTTP Method requested.
108
     * @return array The callback response.
109
     */
110
    public function run(array $args, $method='get'){
111
      $method = strtolower($method);
112
      $append_echoed_text = Options::get('core.route.append_echoed_text',true);
113
      static::trigger('start', $this, $args, $method);
114
115
      // Call direct befores
116 View Code Duplication
      if ( $this->befores ) {
117
        // Reverse befores order
118
        foreach (array_reverse($this->befores) as $mw) {
119
          static::trigger('before', $this, $mw);
120
          Event::trigger('core.route.before', $this, $mw);
121
          ob_start();
122
          $mw_result  = call_user_func($mw);
123
          $raw_echoed = ob_get_clean();
124
          if ($append_echoed_text) Response::add($raw_echoed);
125
          if ( false  === $mw_result ) {
126
            return [''];
127
          } else {
128
            Response::add($mw_result);
129
          }
130
        }
131
      }
132
133
      $callback = (is_array($this->callback) && isset($this->callback[$method]))
134
                  ? $this->callback[$method]
135
                  : $this->callback;
136
137
      if (is_callable($callback) || is_a($callback, "View") ) {
138
        Response::type( Options::get('core.route.response_default_type', Response::TYPE_HTML) );
139
140
        ob_start();
141
        if (is_a($callback, "View")) {
142
          // Get the rendered view
143
          $view_results = (string)$callback;
144
        } else {
145
          $view_results = call_user_func_array($callback, $args);
146
        }
147
        $raw_echoed   = ob_get_clean();
148
149
        if ($append_echoed_text) Response::add($raw_echoed);
150
        Response::add($view_results);
151
      }
152
153
      // Apply afters
154 View Code Duplication
      if ( $this->afters ) {
155
        foreach ($this->afters as $mw) {
156
          static::trigger('after', $this, $mw);
157
          Event::trigger('core.route.after', $this, $mw);
158
          ob_start();
159
          $mw_result  = call_user_func($mw);
160
          $raw_echoed = ob_get_clean();
161
          if ($append_echoed_text) Response::add($raw_echoed);
162
          if ( false  === $mw_result ) {
163
            return [''];
164
          } else {
165
            Response::add($mw_result);
166
          }
167
        }
168
      }
169
170
      static::trigger('end', $this, $args, $method);
171
      Event::trigger('core.route.end', $this);
172
173
      return [Filter::with('core.route.response', Response::body())];
174
     }
175
176
    /**
177
     * Check if route match URL and HTTP Method and run if it is valid.
178
     * @param  [type] $URL The URL to check against.
179
     * @param  string $method The HTTP Method to check against.
180
     * @return array The callback response.
181
     */
182
    public function runIfMatch($URL, $method='get'){
183
      return $this->match($URL,$method) ? $this->run($this->extractArgs($URL),$method) : null;
184
    }
185
186
    /**
187
     * Start a route definition, default to HTTP GET.
188
     * @param  string $URLPattern The URL to match against, you can define named segments to be extracted and passed to the callback.
189
     * @param  $callback The callback to be invoked (with variables extracted from the route if present) when the route match the request URI.
190
     * @return Route
191
     */
192
    public static function on($URLPattern, $callback = null){
193
      return new Route($URLPattern,$callback);
194
    }
195
196
    /**
197
     * Start a route definition with HTTP Method via GET.
198
     * @param  string $URLPattern The URL to match against, you can define named segments to be extracted and passed to the callback.
199
     * @param  $callback The callback to be invoked (with variables extracted from the route if present) when the route match the request URI.
200
     * @return Route
201
     */
202
    public static function get($URLPattern, $callback = null){
203
      return (new Route($URLPattern,$callback))->via('get');
204
    }
205
206
    /**
207
     * Start a route definition with HTTP Method via POST.
208
     * @param  string $URLPattern The URL to match against, you can define named segments to be extracted and passed to the callback.
209
     * @param  $callback The callback to be invoked (with variables extracted from the route if present) when the route match the request URI.
210
     * @return Route
211
     */
212
    public static function post($URLPattern, $callback = null){
213
      return (new Route($URLPattern,$callback))->via('post');
214
    }
215
216
    /**
217
     * Start a route definition, for any HTTP Method (using * wildcard).
218
     * @param  string $URLPattern The URL to match against, you can define named segments to be extracted and passed to the callback.
219
     * @param  $callback The callback to be invoked (with variables extracted from the route if present) when the route match the request URI.
220
     * @return Route
221
     */
222
    public static function any($URLPattern, $callback = null){
223
      return (new Route($URLPattern,$callback))->via('*');
224
    }
225
226
    /**
227
     * Bind a callback to the route definition
228
     * @param  $callback The callback to be invoked (with variables extracted from the route if present) when the route match the request URI.
229
     * @return Route
230
     */
231
    public function & with($callback){
232
      $this->callback = $callback;
233
      return $this;
234
    }
235
236
    /**
237
     * Bind a middleware callback to invoked before the route definition
238
     * @param  callable $before The callback to be invoked ($this is binded to the route object).
239
     * @return Route
240
     */
241
    public function & before($callback){
242
      $this->befores[] = $callback;
243
      return $this;
244
    }
245
246
    /**
247
     * Bind a middleware callback to invoked after the route definition
248
     * @param  $callback The callback to be invoked ($this is binded to the route object).
249
     * @return Route
250
     */
251
    public function & after($callback){
252
      $this->afters[] = $callback;
253
      return $this;
254
    }
255
256
    /**
257
     * Defines the HTTP Methods to bind the route onto.
258
     *
259
     * Example:
260
     * <code>
261
     *  Route::on('/test')->via('get','post','delete');
262
     * </code>
263
     *
264
     * @return Route
265
     */
266
    public function & via(...$methods){
267
      $this->methods = [];
268
      foreach ($methods as $method){
269
        $this->methods[strtolower($method)] = true;
270
      }
271
      return $this;
272
    }
273
274
    /**
275
     * Defines the regex rules for the named parameter in the current URL pattern
276
     *
277
     * Example:
278
     * <code>
279
     *  Route::on('/proxy/:number/:url')
280
     *    ->rules([
281
     *      'number'  => '\d+',
282
     *      'url'     => '.+',
283
     *    ]);
284
     * </code>
285
     *
286
     * @param  array  $rules The regex rules
287
     * @return Route
288
     */
289
    public function & rules(array $rules){
290
      foreach ((array)$rules as $varname => $rule){
291
        $this->rules[$varname] = $rule;
292
      }
293
      $this->pattern         = $this->compilePatternAsRegex( $this->URLPattern, $this->rules );
294
      $this->matcher_pattern = $this->compilePatternAsRegex( $this->URLPattern, $this->rules, false );
295
      return $this;
296
    }
297
298
    /**
299
     * Map a HTTP Method => callable array to a route.
300
     *
301
     * Example:
302
     * <code>
303
     *  Route::map('/test'[
304
     *      'get'     => function(){ echo "HTTP GET"; },
305
     *      'post'    => function(){ echo "HTTP POST"; },
306
     *      'put'     => function(){ echo "HTTP PUT"; },
307
     *      'delete'  => function(){ echo "HTTP DELETE"; },
308
     *    ]);
309
     * </code>
310
     *
311
     * @param  string $URLPattern The URL to match against, you can define named segments to be extracted and passed to the callback.
312
     * @param  array $callbacks The HTTP Method => callable map.
313
     * @return Route
314
     */
315
    public static function & map($URLPattern, $callbacks = []){
316
      $route           = new static($URLPattern);
317
      $route->callback = [];
318
      foreach ($callbacks as $method => $callback) {
319
        $method = strtolower($method);
320
        if (Request::method() !== $method) continue;
321
        $route->callback[$method] = $callback;
322
        $route->methods[$method]  = 1;
323
      }
324
      return $route;
325
    }
326
327
    /**
328
     * Assign a name tag to the route
329
     * @param  string $name The name tag of the route.
330
     * @return Route
331
     */
332
    public function & tag($name){
333
      if ($this->tag = $name) static::$tags[$name] =& $this;
334
      return $this;
335
    }
336
337
    /**
338
     * Reverse routing : obtain a complete URL for a named route with passed parameters
339
     * @param  array $params The parameter map of the route dynamic values.
340
     * @return URL
341
     */
342
    public function getURL($params = []){
343
      $params = (array)$params;
344
      return new URL(rtrim(preg_replace('(/+)','/',preg_replace_callback('(:(\w+))',function($m) use ($params){
345
        return isset($params[$m[1]]) ? $params[$m[1]].'/' : '';
346
      },strtr($this->URLPattern,['('=>'',')'=>'']))),'/')?:'/');
347
    }
348
349
    /**
350
     * Get a named route
351
     * @param  string $name The name tag of the route.
352
     * @return Route or false if not found
353
     */
354
    public static function tagged($name){
355
      return isset(static::$tags[$name]) ? static::$tags[$name] : false;
356
    }
357
358
   /**
359
     * Helper for reverse routing : obtain a complete URL for a named route with passed parameters
360
     * @param  string $name The name tag of the route.
361
     * @param  array $params The parameter map of the route dynamic values.
362
     * @return string
363
     */
364
    public static function URL($name, $params = []){
365
      return ($r = static::tagged($name)) ? $r-> getURL($params) : new URL();
366
    }
367
368
    /**
369
     * Compile an URL schema to a PREG regular expression.
370
     * @param  string $pattern The URL schema.
371
     * @return string The compiled PREG RegEx.
372
     */
373
    protected static function compilePatternAsRegex($pattern, $rules=[], $extract_params=true){
374
375
      return '#^'.preg_replace_callback('#:([a-zA-Z]\w*)#',$extract_params
376
        // Extract named parameters
377
        ? function($g) use (&$rules){
378
            return '(?<' . $g[1] . '>' . (isset($rules[$g[1]])?$rules[$g[1]]:'[^/]+') .')';
379
          }
380
        // Optimized for matching
381
        : function($g) use (&$rules){
382
            return isset($rules[$g[1]]) ? $rules[$g[1]] : '[^/]+';
383
          },
384
      str_replace(['.',')','*'],['\.',')?','.+'],$pattern)).'$#';
385
    }
386
387
    /**
388
     * Extract the URL schema variables from the passed URL.
389
     * @param  string  $pattern The URL schema with the named parameters
390
     * @param  string  $URL The URL to process, if omitted the current request URI will be used.
391
     * @param  boolean $cut If true don't limit the matching to the whole URL (used for group pattern extraction)
392
     * @return array The extracted variables
393
     */
394
    protected static function extractVariablesFromURL($pattern, $URL=null, $cut=false){
395
      $URL     = $URL ?: Request::URI();
396
      $pattern = $cut ? str_replace('$#','',$pattern).'#' : $pattern;
397
      $args    = [];
398
      if ( !preg_match($pattern,'/'.trim($URL,'/'),$args) ) return false;
399
      foreach ($args as $key => $value) {
400
        if (false === is_string($key)) unset($args[$key]);
401
      }
402
      return $args;
403
    }
404
405
406
    public function extractArgs($URL){
407
      $args = [];
408
      if ( $this->dynamic ) {
409
        preg_match($this->pattern, '/'.trim($URL,'/'), $args);
410
        foreach ($args as $key => $value) {
411
          if (false === is_string($key)) unset($args[$key]);
412
        }
413
      }
414
      return $args;
415
    }
416
417
    /**
418
     * Check if an URL schema need dynamic matching (regex).
419
     * @param  string  $pattern The URL schema.
420
     * @return boolean
421
     */
422
    protected static function isDynamic($pattern){
423
      return strlen($pattern) != strcspn($pattern,':(?[*+');
424
    }
425
426
    /**
427
     * Add a route to the internal route repository.
428
     * @param Route $route
429
     * @return Route
430
     */
431
    public static function add($route){
432
      if (is_a($route, 'Route')){
433
434
        // Add to tag map
435
        if ($route->tag) static::$tags[$route->tag] =& $route;
436
437
        // Optimize tree
438
        if (Options::get('core.route.auto_optimize', true)){
439
          $base =& static::$optimized_tree;
440
          foreach (explode('/',trim(preg_replace('#^(.+?)\(?:.+$#','$1',$route->URLPattern),'/')) as $segment) {
441
            $segment = trim($segment,'(');
442
            if (!isset($base[$segment])) $base[$segment] = [];
443
            $base =& $base[$segment];
444
          }
445
          $base[] =& $route;
446
        }
447
      }
448
449
      // Add route to active group
450
      if ( isset(static::$group[0]) ) static::$group[0]->add($route);
451
452
      return static::$routes[implode('', static::$prefix)][] = $route;
453
    }
454
455
    /**
456
     * Define a route group, if not immediately matched internal code will not be invoked.
457
     * @param  string $prefix The url prefix for the internal route definitions.
458
     * @param  string $callback This callback is invoked on $prefix match of the current request URI.
459
     */
460
    public static function group($prefix, $callback){
461
462
      // Skip definition if current request doesn't match group.
463
      $pre_prefix = rtrim(implode('',static::$prefix),'/');
464
      $URI   = Request::URI();
465
      $args  = [];
466
      $group = false;
467
468
      switch (true) {
469
470
        // Dynamic group
471
        case static::isDynamic($prefix) :
0 ignored issues
show
Coding Style introduced by
There must be no space before the colon in a CASE statement

As per the PSR-2 coding standard, there must not be a space in front of the colon in case statements.

switch ($selector) {
    case "A": //right
        doSomething();
        break;
    case "B" : //wrong
        doSomethingElse();
        break;
}

To learn more about the PSR-2 coding standard, please refer to the PHP-Fig.

Loading history...
472
          $args = static::extractVariablesFromURL($prx=static::compilePatternAsRegex("$pre_prefix$prefix"), null, true);
473
          if ( $args !== false ) {
474
            // Burn-in $prefix as static string
475
            $partial = preg_match_all(str_replace('$#', '#', $prx), $URI, $partial) ? $partial[0][0] : '';
476
            $prefix = $partial ? preg_replace('#^'.implode('',static::$prefix).'#', '', $partial) : $prefix;
477
          }
478
479
        // Static group
480
        case ( 0 === strpos("$URI/", "$pre_prefix$prefix/") )
0 ignored issues
show
Coding Style introduced by
There must be no space before the colon in a CASE statement

As per the PSR-2 coding standard, there must not be a space in front of the colon in case statements.

switch ($selector) {
    case "A": //right
        doSomething();
        break;
    case "B" : //wrong
        doSomethingElse();
        break;
}

To learn more about the PSR-2 coding standard, please refer to the PHP-Fig.

Loading history...
Coding Style introduced by
The case body in a switch statement must start on the line following the statement.

According to the PSR-2, the body of a case statement must start on the line immediately following the case statement.

switch ($expr) {
case "A":
    doSomething(); //right
    break;
case "B":

    doSomethingElse(); //wrong
    break;

}

To learn more about the PSR-2 coding standard, please refer to the PHP-Fig.

Loading history...
481
             || ( ! Options::get('core.route.pruning', true) ) :
482
483
          static::$prefix[] = $prefix;
484
          if (empty(static::$group)) static::$group = [];
485
          array_unshift(static::$group, $group = new RouteGroup());
486
487
          // Call the group body function
488
          call_user_func_array($callback, $args ?: []);
489
490
          array_shift(static::$group);
491
          array_pop(static::$prefix);
492
          if (empty(static::$prefix)) static::$prefix = [''];
493
        break;
494
495
      }
496
497
      return $group ?: new RouteGroup();
498
    }
499
500
    public static function exitWithError($code, $message="Application Error"){
501
      Response::error($code,$message);
502
      Response::send();
503
      exit;
504
    }
505
506
    /**
507
     * Start the route dispatcher and resolve the URL request.
508
     * @param  string $URL The URL to match onto.
509
     * @param  string $method The HTTP method.
510
     * @param  bool $return_route If setted to true it will *NOT* execute the route but it will return her.
511
     * @return boolean true if a route callback was executed.
512
     */
513
    public static function dispatch($URL=null, $method=null, $return_route=false){
514
        if (!$URL)     $URL     = Request::URI();
515
        if (!$method)  $method  = Request::method();
0 ignored issues
show
Bug Best Practice introduced by
The expression $method of type string|null is loosely compared to false; this is ambiguous if the string can be empty. You might want to explicitly use === null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
516
517
        $__deferred_send = new Deferred(function(){
518
          if (Options::get('core.response.autosend',true)){
519
            Response::send();
520
          }
521
        });
522
523
        if (empty(static::$optimized_tree)) {
524 View Code Duplication
          foreach ((array)static::$routes as $group => $routes){
525
              foreach ($routes as $route) {
526
                  if (is_a($route, 'Route') && $route->match($URL,$method)){
527
                    if ($return_route){
528
                      return $route;
529
                    } else {
530
                      $route->run($route->extractArgs($URL),$method);
531
                      return true;
532
                    }
533
                  }
534
              }
535
          }
536
        } else {
537
          $routes =& static::$optimized_tree;
538
          foreach (explode('/',trim($URL,'/')) as $segment) {
539
            if (isset($routes[$segment])) $routes =& $routes[$segment];
540
              // Root-level dynamic routes Ex: "/:param"
541
              else if (isset($routes[''])) $routes =& $routes[''];
542
            else break;
543
          }
544 View Code Duplication
          if (isset($routes[0]) && !is_array($routes[0])) foreach ((array)$routes as $route) {
545
              if (is_a($route, __CLASS__) && $route->match($URL, $method)){
546
                    if ($return_route){
547
                      return $route;
548
                    } else {
549
                      $route->run($route->extractArgs($URL),$method);
550
                      return true;
551
                    }
552
              }
553
          }
554
        }
555
556
        Response::status(404, '404 Resource not found.');
557
        foreach (array_filter(array_merge(
558
          (static::trigger(404)?:[]),
559
          (Event::trigger(404)?:[])
560
        )) as $res){
561
           Response::add($res);
562
        }
563
        return false;
564
    }
565
566
    public function push($links, $type = 'text'){
567
      Response::push($links, $type);
568
      return $this;
569
    }
570
571
}
572
573
class RouteGroup {
0 ignored issues
show
Coding Style Compatibility introduced by
PSR1 recommends that each class should be in its own file to aid autoloaders.

Having each class in a dedicated file usually plays nice with PSR autoloaders and is therefore a well established practice. If you use other autoloaders, you might not want to follow this rule.

Loading history...
Coding Style Compatibility introduced by
PSR1 recommends that each class must be in a namespace of at least one level to avoid collisions.

You can fix this by adding a namespace to your class:

namespace YourVendor;

class YourClass { }

When choosing a vendor namespace, try to pick something that is not too generic to avoid conflicts with other libraries.

Loading history...
574
  protected $routes;
575
576
  public function __construct(){
577
    $this->routes = new SplObjectStorage;
578
    return Route::add($this);
579
  }
580
581
  public function has($r){
582
    return $this->routes->contains($r);
583
  }
584
585
  public function add($r){
586
    $this->routes->attach($r);
587
    return $this;
588
  }
589
590
  public function remove($r){
591
    if ($this->routes->contains($r)) $this->routes->detach($r);
592
    return $this;
593
  }
594
595
  public function before($callbacks){
596
    foreach ($this->routes as $route){
597
      $route->before($callbacks);
598
    }
599
    return $this;
600
  }
601
602
  public function after($callbacks){
603
    foreach ($this->routes as $route){
604
      $route->after($callbacks);
605
    }
606
    return $this;
607
  }
608
609
  public function push($links, $type = 'text'){
610
    Response::push($links, $type);
611
    return $this;
612
  }
613
614
}
615
616