Completed
Push — master ( 6ac80e...ea27d1 )
by Chris
03:18
created

Router::resolveRouteAction()   A

Complexity

Conditions 5
Paths 5

Size

Total Lines 20

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 5
nc 5
nop 1
dl 0
loc 20
rs 9.2888
c 0
b 0
f 0
1
<?php
2
namespace Darya\Routing;
3
4
use ReflectionClass;
5
use Darya\Events\Dispatchable;
6
use Darya\Events\Subscriber;
7
use Darya\Http\Request;
8
use Darya\Http\Response;
9
use Darya\Service\Contracts\Container;
10
use Darya\Service\Contracts\ContainerAware;
11
12
/**
13
 * Darya's request router.
14
 *
15
 * TODO: Implement route groups.
16
 *
17
 * @author Chris Andrew <chris.andrew>
18
 */
19
class Router implements ContainerAware
20
{
21
	/**
22
	 * Regular expression replacements for matching route paths to request URIs.
23
	 *
24
	 * @var array
25
	 */
26
	protected $patterns = array(
27
		'#/:([A-Za-z0-9_-]+)#' => '(?:/(?<$1>[^/]+))',
28
		'#/:params#'           => '(?:/(?<params>.*))?'
29
	);
30
31
	/**
32
	 * Base URI to expect when matching routes.
33
	 *
34
	 * @var string
35
	 */
36
	protected $base;
37
38
	/**
39
	 * Collection of routes to match requests against.
40
	 *
41
	 * @var Route[]
42
	 */
43
	protected $routes = array();
44
45
	/**
46
	 * Default values for the router to apply to matched routes.
47
	 *
48
	 * @var array
49
	 */
50
	protected $defaults = array(
51
		'namespace'  => null,
52
		'controller' => 'IndexController',
53
		'action'     => 'index'
54
	);
55
56
	/**
57
	 * Set of callbacks for filtering matched routes and their parameters.
58
	 *
59
	 * @var array
60
	 */
61
	protected $filters = array();
62
63
	/**
64
	 * The event dispatcher to use for routing events.
65
	 *
66
	 * @var Dispatchable
67
	 */
68
	protected $eventDispatcher;
69
70
	/**
71
	 * The service container to use to resolve controllers and other callables.
72
	 *
73
	 * @var Container
74
	 */
75
	protected $services;
76
77
	/**
78
	 * Callable for handling dispatched requests that don't match a route.
79
	 *
80
	 * @var callable
81
	 */
82
	protected $errorHandler;
83
84
	/**
85
	 * Replace a route path's placeholders with regular expressions using the
86
	 * router's registered replacement patterns.
87
	 *
88
	 * @param string $path Route path to prepare
89
	 * @return string Regular expression that matches a route's path
90
	 */
91
	public function preparePattern($path)
92
	{
93
		foreach (array_reverse($this->patterns) as $pattern => $replacement) {
94
			$path = preg_replace($pattern, $replacement, $path);
95
		}
96
97
		return '#/?^' . $path . '/?$#';
98
	}
99
100
	/**
101
	 * Prepares a controller name by PascalCasing the given value and appending
102
	 * 'Controller', if the provided name does not already end as such. The
103
	 * resulting string will start with an uppercase letter.
104
	 *
105
	 * For example, 'super-swag' would become 'SuperSwagController'
106
	 *
107
	 * @param string $controller Route path parameter controller string
108
	 * @return string Controller class name
109
	 */
110
	public static function prepareController($controller)
111
	{
112
		if (strpos($controller, 'Controller') === strlen($controller) - 10) {
113
			return $controller;
114
		}
115
116
		return preg_replace_callback('/^(.)|-(.)/', function ($matches) {
117
			return strtoupper($matches[1] ?: $matches[2]);
118
		}, $controller) . 'Controller';
119
	}
120
121
	/**
122
	 * Prepares an action name by camelCasing the given value. The resulting
123
	 * string will start with a lowercase letter.
124
	 *
125
	 * For example, 'super-swag' would become 'superSwag'
126
	 *
127
	 * @param string $action URL action name
128
	 * @return string Action method name
129
	 */
130
	public static function prepareAction($action)
131
	{
132
		return preg_replace_callback('/-(.)/', function ($matches) {
133
			return strtoupper($matches[1]);
134
		}, $action);
135
	}
136
137
	/**
138
	 * Instantiates a new request using the given argument.
139
	 *
140
	 * @param Request|string $request
141
	 * @return Request
142
	 */
143
	public static function prepareRequest($request)
144
	{
145
		if (!$request instanceof Request) {
146
			$request = Request::create($request);
147
		}
148
149
		return $request;
150
	}
151
152
	/**
153
	 * Prepare a response object using the given value.
154
	 *
155
	 * @param mixed $response
156
	 * @return Response
157
	 */
158
	public static function prepareResponse($response)
159
	{
160
		if (!$response instanceof Response) {
161
			$response = new Response($response);
162
		}
163
164
		return $response;
165
	}
166
167
	/**
168
	 * Initialise router with given array of routes where keys are patterns and
169
	 * values are either default controllers or a set of default values.
170
	 *
171
	 * Optionally accepts an array of default values for reserved route
172
	 * parameters to use for routes that don't match with them. These include
173
	 * 'namespace', 'controller' and 'action'.
174
	 *
175
	 * @param array $routes   Routes to match
176
	 * @param array $defaults Default router properties
177
	 */
178
	public function __construct(array $routes = array(), array $defaults = array())
179
	{
180
		$this->add($routes);
181
		$this->defaults($defaults);
182
		$this->filter(array($this, 'resolve'));
183
		$this->filter(array($this, 'dispatchable'));
184
	}
185
186
	/**
187
	 * Set the optional event dispatcher for emitting routing events.
188
	 *
189
	 * @param Dispatchable $dispatcher
190
	 */
191
	public function setEventDispatcher(Dispatchable $dispatcher)
192
	{
193
		$this->eventDispatcher = $dispatcher;
194
	}
195
196
	/**
197
	 * Set an optional service container for resolving the dependencies of
198
	 * controllers and actions.
199
	 *
200
	 * @param Container $container
201
	 */
202
	public function setServiceContainer(Container $container)
203
	{
204
		$this->services = $container;
205
	}
206
207
	/**
208
	 * Set an error handler for dispatched requests that don't match a route.
209
	 *
210
	 * @param callable $handler
211
	 */
212
	public function setErrorHandler($handler)
213
	{
214
		if (is_callable($handler)) {
215
			$this->errorHandler = $handler;
216
		}
217
	}
218
219
	/**
220
	 * Invoke the error handler with the given request and response if one is
221
	 * set.
222
	 *
223
	 * Returns the given response if no error handler is set.
224
	 *
225
	 * @param Request  $request
226
	 * @param Response $response
227
	 * @param string   $message  [optional]
228
	 * @return Response
229
	 */
230
	protected function handleError(Request $request, Response $response, $message = null)
231
	{
232
		if ($this->errorHandler) {
233
			$errorHandler = $this->errorHandler;
234
			$response = static::prepareResponse($this->call($errorHandler, array($request, $response, $message)));
235
		}
236
237
		return $response;
238
	}
239
240
	/**
241
	 * Helper method for invoking callables. Silent if the given argument is
242
	 * not callable.
243
	 *
244
	 * Resolves parameters using the service container if available.
245
	 *
246
	 * @param mixed $callable
247
	 * @param array $arguments [optional]
248
	 * @return mixed
249
	 */
250
	protected function call($callable, array $arguments = array())
251
	{
252
		if (is_callable($callable)) {
253
			if ($this->services) {
254
				return $this->services->call($callable, $arguments);
255
			} else {
256
				return call_user_func_array($callable, $arguments);
257
			}
258
		}
259
260
		return null;
261
	}
262
263
	/**
264
	 * Helper method for instantiating classes.
265
	 *
266
	 * Instantiates the given class if it isn't already an object. Uses the
267
	 * service container if available.
268
	 *
269
	 * @param mixed $class
270
	 * @param array $arguments [optional]
271
	 * @return object
272
	 */
273
	protected function create($class, $arguments)
274
	{
275
		if (!is_object($class) && class_exists($class)) {
276
			if ($this->services) {
277
				$class = $this->services->create($class, $arguments);
278
			} else {
279
				$reflection = new ReflectionClass($class);
280
				$class = $reflection->newInstanceArgs($arguments);
281
			}
282
		}
283
284
		return $class;
285
	}
286
287
	/**
288
	 * Helper method for dispatching events. Silent if an event dispatcher is
289
	 * not set.
290
	 *
291
	 * @param string $name
292
	 * @param mixed  $arguments [optional]
293
	 * @return array
294
	 */
295
	protected function event($name, array $arguments = array())
296
	{
297
		if ($this->eventDispatcher) {
298
			return $this->eventDispatcher->dispatch($name, $arguments);
299
		}
300
301
		return array();
302
	}
303
304
	/**
305
	 * Helper method for subscribing objects to the router's event dispatcher.
306
	 *
307
	 * Silent if $subscriber does not implement `Subscriber`.
308
	 *
309
	 * @param mixed $subscriber
310
	 * @return bool
311
	 */
312
	protected function subscribe($subscriber)
313
	{
314
		if ($this->eventDispatcher && $subscriber instanceof Subscriber) {
315
			$this->eventDispatcher->subscribe($subscriber);
316
			return true;
317
		}
318
319
		return false;
320
	}
321
322
	/**
323
	 * Helper method for unsubscribing objects from the router's event
324
	 * dispatcher.
325
	 *
326
	 * Silent if $subscriber does not implement `Subscriber`.
327
	 *
328
	 * @param mixed $subscriber
329
	 * @return bool
330
	 */
331
	protected function unsubscribe($subscriber)
332
	{
333
		if ($this->eventDispatcher && $subscriber instanceof Subscriber) {
334
			$this->eventDispatcher->unsubscribe($subscriber);
335
			return true;
336
		}
337
338
		return false;
339
	}
340
341
	/**
342
	 * Add routes to the router.
343
	 *
344
	 * When passed as an array, $routes elements can consist of either:
345
	 *   - Route path as the key, callable as the value
346
	 *   - Route name as the key, Route instance as the value
347
	 *
348
	 * An example using both:
349
	 * ```
350
	 *     $router->add(array(
351
	 *         '/route-path' => 'Namespace\Controller',
352
	 *         'route-name'  => new Route('/route-path', 'Namespace\Controller')
353
	 *     ));
354
	 * ```
355
	 *
356
	 * @param string|array          $routes   Route definitions or a route path
357
	 * @param callable|array|string $defaults Default parameters for the route if $routes is a route path
358
	 */
359
	public function add($routes, $defaults = null)
360
	{
361
		if (is_array($routes)) {
362
			foreach ($routes as $path => $defaults) {
363
				if ($defaults instanceof Route) {
364
					$this->routes[$path] = $defaults;
365
				} else {
366
					$this->routes[] = new Route($path, $defaults);
367
				}
368
			}
369
		} else if ($defaults) {
370
			$path = $routes;
371
			$this->routes[] = new Route($path, $defaults);
372
		}
373
	}
374
375
	/**
376
	 * Add a single named route to the router.
377
	 *
378
	 * @param string $name     Name that identifies the route
379
	 * @param string $path     Path that matches the route
380
	 * @param mixed  $defaults Default route parameters
381
	 */
382
	public function set($name, $path, $defaults = array())
383
	{
384
		$this->routes[$name] = new Route($path, $defaults);
385
	}
386
387
	/**
388
	 * Get or set the router's base URI.
389
	 *
390
	 * @param string $uri [optional]
391
	 * @return string
392
	 */
393
	public function base($uri = null)
394
	{
395
		if (!is_null($uri)) {
396
			$this->base = $uri;
397
		}
398
399
		return $this->base;
400
	}
401
402
	/**
403
	 * Get and optionally set the router's default values for matched routes.
404
	 *
405
	 * Given key value pairs are merged with the current defaults.
406
	 *
407
	 * These are used when a route and the matched route's parameters haven't
408
	 * provided default values.
409
	 *
410
	 * @param array $defaults [optional]
411
	 * @return array Router's default route parameters
412
	 */
413
	public function defaults(array $defaults = array())
414
	{
415
		foreach ($defaults as $key => $value) {
416
			$property = strtolower($key);
417
			$this->defaults[$property] = $value;
418
		}
419
420
		return $this->defaults;
421
	}
422
423
	/**
424
	 * Register a callback for filtering matched routes and their parameters.
425
	 *
426
	 * Callbacks should return a bool determining whether the route matches.
427
	 * A route is passed by reference when matched by Router::match().
428
	 *
429
	 * @param callable $callback
430
	 * @return Router
431
	 */
432
	public function filter($callback)
433
	{
434
		if (is_callable($callback)) {
435
			$this->filters[] = $callback;
436
		}
437
438
		return $this;
439
	}
440
441
	/**
442
	 * Register a replacement pattern.
443
	 *
444
	 * @param string $pattern
445
	 * @param string $replacement
446
	 * @return Router
447
	 */
448
	public function pattern($pattern, $replacement)
449
	{
450
		$this->patterns[$pattern] = $replacement;
451
452
		return $this;
453
	}
454
455
	/**
456
	 * Attempt to resolve a matched route's controller class.
457
	 *
458
	 * Falls back to the router's default controller.
459
	 *
460
	 * @param Route $route
461
	 * @return Route
462
	 */
463
	protected function resolveRouteController(Route $route)
464
	{
465
		if (!$route->namespace) {
466
			$route->namespace = $this->defaults['namespace'];
467
		}
468
469
		if ($route->controller) {
470
			$controller = static::prepareController($route->controller);
471
472
			if ($route->namespace) {
473
				$controller = $route->namespace . '\\' . $controller;
474
			}
475
476
			if (class_exists($controller)) {
477
				$route->controller = $controller;
478
			}
479
		} else {
480
			$namespace = $route->namespace ? $route->namespace . '\\' : '';
481
			$route->controller = $namespace . $this->defaults['controller'];
482
		}
483
484
		return $route;
485
	}
486
487
	/**
488
	 * Attempt to resolve a matched route's action method.
489
	 *
490
	 * Falls back to the router's default action.
491
	 *
492
	 * @param Route $route
493
	 * @return Route
494
	 */
495
	protected function resolveRouteAction(Route $route)
496
	{
497
		if ($route->action) {
498
			if (!is_string($route->action)) {
499
				return $route;
500
			}
501
502
			$action = static::prepareAction($route->action);
503
504
			if (method_exists($route->controller, $action)) {
505
				$route->action = $action;
506
			} else if (method_exists($route->controller, $action . 'Action')) {
507
				$route->action = $action . 'Action';
508
			}
509
		} else {
510
			$route->action = $this->defaults['action'];
511
		}
512
513
		return $route;
514
	}
515
516
	/**
517
	 * Resolve a matched route's controller and action.
518
	 *
519
	 * Applies the router's defaults for these if they are not set.
520
	 *
521
	 * This is a built in route filter that is registered by default.
522
	 *
523
	 * TODO: Also apply any other default parameters.
524
	 *
525
	 * @param Route $route
526
	 * @return bool
527
	 */
528
	public function resolve(Route $route)
529
	{
530
		$this->resolveRouteController($route);
531
		$this->resolveRouteAction($route);
532
533
		return true;
534
	}
535
536
	/**
537
	 * Determine whether a given matched route can be dispatched based on
538
	 * whether the resolved controller action is callable.
539
	 *
540
	 * This is a built in route filter that is registered by default. It expects
541
	 * the `resolve` filter to have already been applied to the given route.
542
	 *
543
	 * @param Route $route
544
	 * @return bool
545
	 */
546
	public function dispatchable(Route $route)
547
	{
548
		$dispatchableAction = is_callable($route->action);
549
550
		$dispatchableController =
551
			(is_object($route->controller) || class_exists($route->controller))
552
			&& method_exists($route->controller, $route->action)
553
			&& is_callable(array($route->controller, $route->action));
554
555
		return $dispatchableAction || $dispatchableController;
556
	}
557
558
	/**
559
	 * Strip the router's base URI from the beginning of the given URI.
560
	 *
561
	 * @param string $uri
562
	 * @return string
563
	 */
564
	protected function stripBase($uri)
565
	{
566
		if (!empty($this->base) && strpos($uri, $this->base) === 0) {
567
			$uri = substr($uri, strlen($this->base));
568
		}
569
570
		return $uri;
571
	}
572
573
	/**
574
	 * Test a given route against the router's filters.
575
	 *
576
	 * Optionally test against the given callback after testing against filters.
577
	 *
578
	 * @param Route    $route
579
	 * @param callable $callback
580
	 * @return bool
581
	 */
582
	protected function testMatchFilters(Route $route, $callback = null)
583
	{
584
		$filters = is_callable($callback) ? array_merge($this->filters, array($callback)) : $this->filters;
585
586
		foreach ($filters as $filter) {
587
			if (!$this->call($filter, array(&$route))) {
588
				return false;
589
			}
590
		}
591
592
		return true;
593
	}
594
595
	/**
596
	 * Test a request against a route.
597
	 *
598
	 * Accepts an optional extra callback for filtering matched routes and their
599
	 * parameters. This callback is executed after testing the route against
600
	 * the router's filters.
601
	 *
602
	 * Fires the 'router.prefilter' event before testing against filters.
603
	 *
604
	 * @param Request  $request
605
	 * @param Route    $route
606
	 * @param callable $callback [optional]
607
	 * @return bool
608
	 */
609
	protected function testMatch(Request $request, Route $route, $callback = null)
610
	{
611
		$path = $this->stripBase($request->path());
612
		$pattern = $this->preparePattern($route->path());
613
614
		if (preg_match($pattern, $path, $matches)) {
615
			$route->matches($matches);
616
617
			$this->event('router.prefilter', array($route));
618
619
			if ($this->testMatchFilters($route, $callback)) {
620
				return true;
621
			}
622
		}
623
624
		return false;
625
	}
626
627
	/**
628
	 * Match a request to one of the router's routes.
629
	 *
630
	 * @param Request|string $request  A request URI or a Request object to match
631
	 * @param callable       $callback [optional] Callback for filtering matched routes
632
	 * @return Route|bool              The matched route
633
	 */
634
	public function match($request, $callback = null)
635
	{
636
		$request = static::prepareRequest($request);
637
638
		foreach ($this->routes as $route) {
639
			$route = clone $route;
640
641
			if ($this->testMatch($request, $route, $callback)) {
642
				$route->router = $this;
643
				$request->router = $this;
644
				$request->route = $route;
645
646
				return $route;
647
			}
648
		}
649
650
		return false;
651
	}
652
653
	/**
654
	 * Dispatch the given request and response objects with the given callable
655
	 * and optional arguments.
656
	 *
657
	 * An error handler can be set (@see Router::setErrorHandler()) to handle
658
	 * the request in the case that the given action is not callable.
659
	 *
660
	 * @param Request         $request
661
	 * @param Response        $response
662
	 * @param callable|string $callable
663
	 * @param array           $arguments [optional]
664
	 * @return Response
665
	 */
666
	protected function dispatchCallable(Request $request, Response $response, $callable, array $arguments = array())
667
	{
668
		$requestResponse = array($request, $response);
669
670
		$responses = $this->event('router.before', $requestResponse);
671
672
		if (is_callable($callable)) {
673
			$responses[] = $requestResponse[1] = $this->call($callable, $arguments);
674
		} else {
675
			$response->status(404);
676
			return $this->handleError($request, $response, 'Non-callable resolved from the matched route');
677
		}
678
679
		$responses = array_merge($responses, $this->event('router.after', $requestResponse));
680
		$requestResponse[1] = $responses[count($responses) - 1];
681
		$responses = array_merge($responses, $this->event('router.last', $requestResponse));
682
683
		foreach ($responses as $potential) {
684
			$potential = static::prepareResponse($potential);
685
686
			if ($potential->redirected() || $potential->hasContent()) {
687
				return $potential;
688
			}
689
		}
690
691
		return $response;
692
	}
693
694
	/**
695
	 * Match a request to a route and dispatch the resolved callable.
696
	 *
697
	 * An error handler can be set (@see Router::setErrorHandler()) to handle
698
	 * the request in the case that a route could not be matched. Returns null
699
	 * in this case if an error handler is not set.
700
	 *
701
	 * @param Request|string $request
702
	 * @param Response       $response [optional]
703
	 * @return Response
704
	 */
705
	public function dispatch($request, Response $response = null)
706
	{
707
		$request  = static::prepareRequest($request);
708
		$response = static::prepareResponse($response);
709
710
		$route = $this->match($request);
711
712
		if ($route) {
713
			$controllerArguments = array(
714
				'request'  => $request,
715
				'response' => $response
716
			);
717
718
			$controller = $this->create($route->controller, $controllerArguments);
719
			$action     = $route->action;
720
			$arguments  = $route->arguments();
721
722
			$callable = is_callable(array($controller, $action)) ? array($controller, $action) : $action;
723
724
			$this->subscribe($controller);
725
			$response = $this->dispatchCallable($request, $response, $callable, $arguments);
726
			$this->unsubscribe($controller);
727
728
			$response->header('X-Location: ' . $request->uri());
729
730
			return $response;
731
		}
732
733
		$response->status(404);
734
		$response = $this->handleError($request, $response, 'No route matches the request');
735
736
		return $response;
737
	}
738
739
	/**
740
	 * Dispatch a request, resolving a response and send it to the client.
741
	 *
742
	 * Optionally pass through an existing response object.
743
	 *
744
	 * @param Request|string $request
745
	 * @param Response       $response [optional]
746
	 */
747
	public function respond($request, Response $response = null)
748
	{
749
		$response = $this->dispatch($request, $response);
750
		$response->send();
751
	}
752
753
	/**
754
	 * Generate a request path using the given route path and parameters.
755
	 *
756
	 * TODO: Swap generate() & path() functionality?
757
	 *
758
	 * @param string $path
759
	 * @param array $parameters [optional]
760
	 * @return string
761
	 */
762
	public function generate($path, array $parameters = array())
763
	{
764
		return preg_replace_callback('#/(:[A-Za-z0-9_-]+(\??))#', function ($match) use ($parameters) {
765
			$parameter = trim($match[1], '?:');
766
767
			if ($parameter && isset($parameters[$parameter])) {
768
				return '/' . $parameters[$parameter];
769
			}
770
771
			if ($parameter !== 'params' && $match[2] !== '?') {
772
				return '/null';
773
			}
774
775
			return null;
776
		}, $path);
777
	}
778
779
	/**
780
	 * Generate a request path using the given route name/path and parameters.
781
	 *
782
	 * Any required parameters that are not satisfied by the given parameters
783
	 * or the route's defaults will be set to the string 'null'.
784
	 *
785
	 * @param string $name       Route name or path
786
	 * @param array  $parameters [optional]
787
	 * @return string
788
	 */
789
	public function path($name, array $parameters = array())
790
	{
791
		$path = $name;
792
793
		if (isset($this->routes[$name])) {
794
			$route = $this->routes[$name];
795
			$path = $route->path();
796
			$parameters = array_merge($route->defaults(), $parameters);
797
		}
798
799
		if (isset($parameters['params']) && is_array($parameters['params'])) {
800
			$parameters['params'] = implode('/', $parameters['params']);
801
		}
802
803
		return $this->generate($path, $parameters);
804
	}
805
806
	/**
807
	 * Generate an absolute URL using the given route name and parameters.
808
	 *
809
	 * @param string $name
810
	 * @param array  $parameters [optional]
811
	 * @return string
812
	 */
813
	public function url($name, array $parameters = array())
814
	{
815
		return $this->base . $this->path($name, $parameters);
816
	}
817
}
818