Route::runIfMatch()   A
last analyzed

Complexity

Conditions 2
Paths 2

Size

Total Lines 3

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 2
nc 2
nop 2
dl 0
loc 3
rs 10
c 0
b 0
f 0
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 {
14
    use Module,
15
        Events {
16
          on as onEvent;
17
        }
18
19
    public static $routes,
20
                  $base           = '',
21
                  $prefix         = [],
22
                  $group          = [],
23
                  $tags           = [],
24
                  $optimized_tree = [];
25
26
    protected     $URLPattern         = '',
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) :
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/") )
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 (is_array($routes) && isset($routes[$segment])) $routes =& $routes[$segment];
540
              // Root-level dynamic routes Ex: "/:param"
541
              else if (is_array($routes) && isset($routes[''])) $routes =& $routes[''];
542
            else break;
543
          }
544 View Code Duplication
          if (is_array($routes) && 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 {
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