Completed
Push — master ( 08f9e3...213db0 )
by Stefano
02:36
created

Route::map()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 11
Code Lines 9

Duplication

Lines 0
Ratio 0 %

Importance

Changes 3
Bugs 1 Features 0
Metric Value
cc 3
eloc 9
c 3
b 1
f 0
nc 3
nop 2
dl 0
loc 11
rs 9.4285
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
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
21
    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...
22
              $pattern        = '',
23
              $dynamic        = false,
24
              $callback       = null,
25
              $methods        = [],
26
              $befores        = [],
27
              $afters         = [],
28
29
              $rules          = [],
30
              $response       = '';
31
32
33
    /**
34
     * Create a new route definition. This method permits a fluid interface.
35
     *
36
     * @param string $URLPattern The URL pattern, can be used named parameters for variables extraction
37
     * @param $callback The callback to invoke on route match.
38
     * @param string $method The HTTP method for which the route must respond.
39
     * @return Route
40
     */
41
    public function __construct($URLPattern, $callback = null, $method='get'){
42
      $prefix  = static::$prefix ? rtrim(implode('',static::$prefix),'/') : '';
43
      $pattern = "/" . trim($URLPattern, "/");
44
      // Adjust / optionality with dynamic patterns
45
      // Ex:  /test/(:a) ===> /test(/:a)
46
      $this->URLPattern = str_replace('//','/',str_replace('/(','(/', rtrim("{$prefix}{$pattern}","/")));
47
48
      $this->dynamic    = $this->isDynamic($this->URLPattern);
49
      $this->pattern    = $this->dynamic ? $this->compilePatternAsRegex($this->URLPattern, $this->rules) : $this->URLPattern;
50
      $this->callback   = $callback;
51
52
      // We will use hash-checks, for O(1) complexity vs O(n)
53
      $this->methods[$method] = 1;
54
      return static::add($this);
55
    }
56
57
    /**
58
     * Check if route match on a specified URL and HTTP Method.
59
     * @param  [type] $URL The URL to check against.
60
     * @param  string $method The HTTP Method to check against.
61
     * @return boolean
62
     */
63
    public function match($URL,$method='get'){
64
      $method = strtolower($method);
65
66
      // * is an http method wildcard
67
      if (empty($this->methods[$method]) && empty($this->methods['*'])) return false;
68
      $URL  = rtrim($URL,'/');
69
      $args = [];
70
      if ( $this->dynamic
71
           ? preg_match($this->pattern,$URL,$args)
72
           : $URL == rtrim($this->pattern,'/')
73
      ){
74
        foreach ( $args as $key => $value ) {
75
          if ( false === is_string($key) ) unset($args[$key]);
76
        }
77
        return $args;
78
      }
79
      return false;
80
    }
81
82
    /**
83
     * Run one of the mapped callbacks to a passed HTTP Method.
84
     * @param  array  $args The arguments to be passed to the callback
85
     * @param  string $method The HTTP Method requested.
86
     * @return array The callback response.
87
     */
88
    public function run(array $args, $method='get'){
89
      $method = strtolower($method);
90
      $append_echoed_text = Options::get('core.route.append_echoed_text',true);
91
92
      // Call direct befores
93 View Code Duplication
      if ( $this->befores ) {
94
        // Reverse befores order
95
        foreach (array_reverse($this->befores) as $mw) {
96
          Event::trigger('core.route.before', $this, $mw);
97
          ob_start();
98
          $mw_result  = call_user_func($mw);
99
          $raw_echoed = ob_get_clean();
100
          if ($append_echoed_text) Response::add($raw_echoed);
101
          if ( false  === $mw_result ) {
102
            return [''];
103
          } else {
104
            Response::add($mw_result);
105
          }
106
        }
107
      }
108
109
      $callback = (is_array($this->callback) && isset($this->callback[$method]))
110
                  ? $this->callback[$method]
111
                  : $this->callback;
112
113
      if (is_callable($callback)) {
114
        Response::type( Options::get('core.route.response_default_type', Response::TYPE_HTML) );
115
116
        ob_start();
117
        $view_results = call_user_func_array($callback, $args);
118
        $raw_echoed   = ob_get_clean();
119
120
        if ($append_echoed_text) Response::add($raw_echoed);
121
        Response::add($view_results);
122
      }
123
124
      // Apply afters
125 View Code Duplication
      if ( $this->afters ) {
126
        foreach ($this->afters as $mw) {
127
          Event::trigger('core.route.after', $this, $mw);
128
          ob_start();
129
          $mw_result  = call_user_func($mw);
130
          $raw_echoed = ob_get_clean();
131
          if ($append_echoed_text) Response::add($raw_echoed);
132
          if ( false  === $mw_result ) {
133
            return [''];
134
          } else {
135
            Response::add($mw_result);
136
          }
137
        }
138
      }
139
140
      Event::trigger('core.route.end', $this);
141
142
      return [Filter::with('core.route.response', Response::body())];
143
     }
144
145
    /**
146
     * Check if route match URL and HTTP Method and run if it is valid.
147
     * @param  [type] $URL The URL to check against.
148
     * @param  string $method The HTTP Method to check against.
149
     * @return array The callback response.
150
     */
151
    public function runIfMatch($URL, $method='get'){
152
      return ($args = $this->match($URL,$method)) ? $this->run($args,$method) : null;
153
    }
154
155
    /**
156
     * Start a route definition, default to HTTP GET.
157
     * @param  string $URLPattern The URL to match against, you can define named segments to be extracted and passed to the callback.
158
     * @param  $callback The callback to be invoked (with variables extracted from the route if present) when the route match the request URI.
159
     * @return Route
160
     */
161
    public static function on($URLPattern, $callback = null){
162
      return new Route($URLPattern,$callback);
163
    }
164
165
    /**
166
     * Start a route definition with HTTP Method via GET.
167
     * @param  string $URLPattern The URL to match against, you can define named segments to be extracted and passed to the callback.
168
     * @param  $callback The callback to be invoked (with variables extracted from the route if present) when the route match the request URI.
169
     * @return Route
170
     */
171
    public static function get($URLPattern, $callback = null){
172
      return (new Route($URLPattern,$callback))->via('get');
173
    }
174
175
    /**
176
     * Start a route definition with HTTP Method via POST.
177
     * @param  string $URLPattern The URL to match against, you can define named segments to be extracted and passed to the callback.
178
     * @param  $callback The callback to be invoked (with variables extracted from the route if present) when the route match the request URI.
179
     * @return Route
180
     */
181
    public static function post($URLPattern, $callback = null){
182
      return (new Route($URLPattern,$callback))->via('post');
183
    }
184
185
    /**
186
     * Start a route definition, for any HTTP Method (using * wildcard).
187
     * @param  string $URLPattern The URL to match against, you can define named segments to be extracted and passed to the callback.
188
     * @param  $callback The callback to be invoked (with variables extracted from the route if present) when the route match the request URI.
189
     * @return Route
190
     */
191
    public static function any($URLPattern, $callback = null){
192
      return (new Route($URLPattern,$callback))->via('*');
193
    }
194
195
    /**
196
     * Bind a callback to the route definition
197
     * @param  $callback The callback to be invoked (with variables extracted from the route if present) when the route match the request URI.
198
     * @return Route
199
     */
200
    public function & with($callback){
201
      $this->callback = $callback;
202
      return $this;
203
    }
204
205
    /**
206
     * Bind a middleware callback to invoked before the route definition
207
     * @param  callable $before The callback to be invoked ($this is binded to the route object).
208
     * @return Route
209
     */
210
    public function & before($callback){
211
      $this->befores[] = $callback;
212
      return $this;
213
    }
214
215
    /**
216
     * Bind a middleware callback to invoked after the route definition
217
     * @param  $callback The callback to be invoked ($this is binded to the route object).
218
     * @return Route
219
     */
220
    public function & after($callback){
221
      $this->afters[] = $callback;
222
      return $this;
223
    }
224
225
    /**
226
     * Defines the HTTP Methods to bind the route onto.
227
     *
228
     * Example:
229
     * <code>
230
     *  Route::on('/test')->via('get','post','delete');
231
     * </code>
232
     *
233
     * @return Route
234
     */
235
    public function & via(...$methods){
236
      $this->methods = [];
237
      foreach ($methods as $method){
238
        $this->methods[strtolower($method)] = true;
239
      }
240
      return $this;
241
    }
242
243
    /**
244
     * Defines the regex rules for the named parameter in the current URL pattern
245
     *
246
     * Example:
247
     * <code>
248
     *  Route::on('/proxy/:number/:url')
249
     *    ->rules([
250
     *      'number'  => '\d+',
251
     *      'url'     => '.+',
252
     *    ]);
253
     * </code>
254
     *
255
     * @param  array  $rules The regex rules
256
     * @return Route
257
     */
258
    public function & rules(array $rules){
259
      foreach ((array)$rules as $varname => $rule){
260
        $this->rules[$varname] = $rule;
261
      }
262
      $this->pattern = $this->compilePatternAsRegex( $this->URLPattern, $this->rules );
263
      return $this;
264
    }
265
266
    /**
267
     * Map a HTTP Method => callable array to a route.
268
     *
269
     * Example:
270
     * <code>
271
     *  Route::map('/test'[
272
     *      'get'     => function(){ echo "HTTP GET"; },
273
     *      'post'    => function(){ echo "HTTP POST"; },
274
     *      'put'     => function(){ echo "HTTP PUT"; },
275
     *      'delete'  => function(){ echo "HTTP DELETE"; },
276
     *    ]);
277
     * </code>
278
     *
279
     * @param  string $URLPattern The URL to match against, you can define named segments to be extracted and passed to the callback.
280
     * @param  array $callbacks The HTTP Method => callable map.
281
     * @return Route
282
     */
283
    public static function & map($URLPattern, $callbacks = []){
284
      $route           = new static($URLPattern);
285
      $route->callback = [];
286
      foreach ($callbacks as $method => $callback) {
287
        $method = strtolower($method);
288
        if (Request::method() !== $method) continue;
289
        $route->callback[$method] = $callback;
290
        $route->methods[$method]  = 1;
291
      }
292
      return $route;
293
    }
294
295
    /**
296
     * Compile an URL schema to a PREG regular expression.
297
     * @param  string $pattern The URL schema.
298
     * @return string The compiled PREG RegEx.
299
     */
300
    protected static function compilePatternAsRegex($pattern, $rules=[]){
301
      return '#^'.preg_replace_callback('#:([a-zA-Z]\w*)#S',function($g) use (&$rules){
302
        return '(?<' . $g[1] . '>' . (isset($rules[$g[1]])?$rules[$g[1]]:'[^/]+') .')';
303
      },str_replace(['.',')','*'],['\.',')?','.+'],$pattern)).'$#';
304
    }
305
306
    /**
307
     * Extract the URL schema variables from the passed URL.
308
     * @param  string  $pattern The URL schema with the named parameters
309
     * @param  string  $URL The URL to process, if omitted the current request URI will be used.
310
     * @param  boolean $cut If true don't limit the matching to the whole URL (used for group pattern extraction)
311
     * @return array The extracted variables
312
     */
313
    protected static function extractVariablesFromURL($pattern, $URL=null, $cut=false){
314
      $URL     = $URL ?: Request::URI();
315
      $pattern = $cut ? str_replace('$#','',$pattern).'#' : $pattern;
316
      if ( !preg_match($pattern,$URL,$args) ) return false;
317
      foreach ($args as $key => $value) {
318
        if (false === is_string($key)) unset($args[$key]);
319
      }
320
      return $args;
321
    }
322
323
    /**
324
     * Check if an URL schema need dynamic matching (regex).
325
     * @param  string  $pattern The URL schema.
326
     * @return boolean
327
     */
328
    protected static function isDynamic($pattern){
329
      return strlen($pattern) != strcspn($pattern,':(?[*+');
330
    }
331
332
    /**
333
     * Add a route to the internal route repository.
334
     * @param Route $r
0 ignored issues
show
Bug introduced by
There is no parameter named $r. Was it maybe removed?

This check looks for PHPDoc comments describing methods or function parameters that do not exist on the corresponding method or function.

Consider the following example. The parameter $italy is not defined by the method finale(...).

/**
 * @param array $germany
 * @param array $island
 * @param array $italy
 */
function finale($germany, $island) {
    return "2:1";
}

The most likely cause is that the parameter was removed, but the annotation was not.

Loading history...
335
     * @return Route
336
     */
337
    public static function add($route){
338
      if ( isset(static::$group[0]) ) static::$group[0]->add($route);
339
      return static::$routes[implode('', static::$prefix)][] = $route;
340
    }
341
342
    /**
343
     * Define a route group, if not immediately matched internal code will not be invoked.
344
     * @param  string $prefix The url prefix for the internal route definitions.
345
     * @param  string $callback This callback is invoked on $prefix match of the current request URI.
346
     */
347
    public static function group($prefix, $callback){
348
349
      // Skip definition if current request doesn't match group.
350
      $pre_prefix = rtrim(implode('',static::$prefix),'/');
351
      $URI   = Request::URI();
352
      $args  = [];
353
      $group = false;
354
355
      switch (true) {
356
357
        // Dynamic group
358
        case static::isDynamic($prefix) 
0 ignored issues
show
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...
359
             && ($args = static::extractVariablesFromURL($prx=static::compilePatternAsRegex("$pre_prefix$prefix"), null, true)):              
360
361
          // Burn-in $prefix as static string
362
          $partial = preg_match_all(str_replace('$#', '#', $prx), $URI, $partial) ? $partial[0][0] : '';
0 ignored issues
show
Bug introduced by
The variable $prx seems only to be defined at a later point. Did you maybe move this code here without moving the variable definition?

This error can happen if you refactor code and forget to move the variable initialization.

Let’s take a look at a simple example:

function someFunction() {
    $x = 5;
    echo $x;
}

The above code is perfectly fine. Now imagine that we re-order the statements:

function someFunction() {
    echo $x;
    $x = 5;
}

In that case, $x would be read before it is initialized. This was a very basic example, however the principle is the same for the found issue.

Loading history...
363
          $prefix = $partial ? preg_replace('#^'.implode('',static::$prefix).'#', '', $partial) : $prefix;
364
365
        // Static group
366
        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...
367
368
          static::$prefix[] = $prefix;
369
          if (empty(static::$group)) static::$group = [];
370
          array_unshift(static::$group, $group = new RouteGroup());
371
372
          // Call the group body function
373
          call_user_func_array($callback, $args ?: []);
374
          
375
          array_shift(static::$group);
376
          array_pop(static::$prefix);
377
          if (empty(static::$prefix)) static::$prefix = [''];
378
        break;
379
380
      }
381
382
      return $group ?: new RouteGroup();
383
    }
384
385
    public static function exitWithError($code,$message="Application Error"){
386
      Response::error($code,$message);
387
      Response::send();
388
      exit;
389
    }
390
391
    /**
392
     * Start the route dispatcher and resolve the URL request.
393
     * @param  string $URL The URL to match onto.
394
     * @return boolean true if a route callback was executed.
395
     */
396
    public static function dispatch($URL=null,$method=null){
397
        if (!$URL)     $URL     = Request::URI();
398
        if (!$method)  $method  = Request::method();
399
400
        $__deferred_send = new Deferred(function(){
401
          if (Options::get('core.response.autosend',true)){
402
            Response::send();
403
          }
404
        });
405
406
        foreach ((array)static::$routes as $group => $routes){
407
            foreach ($routes as $route) {
408
                if (is_a($route, 'Route') && false !== ($args = $route->match($URL,$method))){
409
                    $route->run($args,$method);
410
                    return true;
411
                }
412
            }
413
        }
414
415
        Response::status(404, '404 Resource not found.');
416
        foreach (array_filter(Event::trigger(404)?:[]) as $res){
417
           Response::add($res);
418
        }
419
        return false;
420
    }
421
}
422
423
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...
424
  protected $routes;
425
426
  public function __construct(){
427
    $this->routes = new SplObjectStorage;
428
    return Route::add($this);
429
  }
430
431
  public function has($r){
432
    return $this->routes->contains($r);
433
  }
434
435
  public function add($r){
436
    $this->routes->attach($r);
437
    return $this;
438
  }
439
440
  public function remove($r){
441
    if ($this->routes->contains($r)) $this->routes->detach($r);
442
    return $this;
443
  }
444
445
  public function before($callbacks){
446
    foreach ($this->routes as $route){
447
      $route->before($callbacks);
448
    }
449
    return $this;
450
  }
451
452
  public function after($callbacks){
453
    foreach ($this->routes as $route){
454
      $route->after($callbacks);
455
    }
456
    return $this;
457
  }
458
459
}
460
461