Completed
Push — master ( b666ee...170c5b )
by Stefano
03:32
created

Route::extractVariablesFromURL()   B

Complexity

Conditions 6
Paths 16

Size

Total Lines 9
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Importance

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