Router::setServiceContainer()   A
last analyzed

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 1
eloc 1
c 1
b 0
f 0
nc 1
nop 1
dl 0
loc 3
rs 10
1
<?php
2
namespace Darya\Routing;
3
4
use Darya\Events\Dispatchable;
5
use Darya\Events\Subscribable;
6
use ReflectionClass;
7
use Darya\Events\Subscriber;
8
use Darya\Http\Request;
9
use Darya\Http\Response;
10
use Darya\Service\Contracts\Container;
11
use Darya\Service\Contracts\ContainerAware;
12
13
/**
14
 * Darya's request router.
15
 *
16
 * TODO: Implement route groups.
17
 *
18
 * @author Chris Andrew <chris.andrew>
19
 */
20
class Router implements ContainerAware
21
{
22
	/**
23
	 * Regular expression replacements for matching route paths to request URIs.
24
	 *
25
	 * @var array
26
	 */
27
	protected $patterns = array(
28
		'#/:([A-Za-z0-9_-]+)#' => '(?:/(?<$1>[^/]+))',
29
		'#/:params#'           => '(?:/(?<params>.*))?'
30
	);
31
32
	/**
33
	 * Base URI to expect when matching routes.
34
	 *
35
	 * @var string
36
	 */
37
	protected $base;
38
39
	/**
40
	 * Collection of routes to match requests against.
41
	 *
42
	 * @var Route[]
43
	 */
44
	protected $routes = array();
45
46
	/**
47
	 * Default values for the router to apply to matched routes.
48
	 *
49
	 * @var array
50
	 */
51
	protected $defaults = array(
52
		'namespace'  => null,
53
		'controller' => 'IndexController',
54
		'action'     => 'index'
55
	);
56
57
	/**
58
	 * Set of callbacks for filtering matched routes and their parameters.
59
	 *
60
	 * @var array
61
	 */
62
	protected $filters = array();
63
64
	/**
65
	 * The event dispatcher to use for routing events.
66
	 *
67
	 * @var Dispatchable
68
	 */
69
	protected $eventDispatcher;
70
71
	/**
72
	 * The service container to use to resolve controllers and other callables.
73
	 *
74
	 * @var Container
75
	 */
76
	protected $services;
77
78
	/**
79
	 * Callable for handling dispatched requests that don't match a route.
80
	 *
81
	 * @var callable
82
	 */
83
	protected $errorHandler;
84
85
	/**
86
	 * Replace a route path's placeholders with regular expressions using the
87
	 * router's registered replacement patterns.
88
	 *
89
	 * @param string $path Route path to prepare
90
	 * @return string Regular expression that matches a route's path
91
	 */
92
	public function preparePattern($path)
93
	{
94
		foreach (array_reverse($this->patterns) as $pattern => $replacement) {
95
			$path = preg_replace($pattern, $replacement, $path);
96
		}
97
98
		return '#/?^' . $path . '/?$#';
99
	}
100
101
	/**
102
	 * Prepares a controller name by PascalCasing the given value and appending
103
	 * 'Controller', if the provided name does not already end as such. The
104
	 * resulting string will start with an uppercase letter.
105
	 *
106
	 * For example, 'super-swag' would become 'SuperSwagController'
107
	 *
108
	 * @param string $controller Route path parameter controller string
109
	 * @return string Controller class name
110
	 */
111
	public static function prepareController($controller)
112
	{
113
		if (strpos($controller, 'Controller') === strlen($controller) - 10) {
114
			return $controller;
115
		}
116
117
		return preg_replace_callback('/^(.)|-(.)/', function ($matches) {
118
			return strtoupper($matches[1] ?: $matches[2]);
119
		}, $controller) . 'Controller';
120
	}
121
122
	/**
123
	 * Prepares an action name by camelCasing the given value. The resulting
124
	 * string will start with a lowercase letter.
125
	 *
126
	 * For example, 'super-swag' would become 'superSwag'
127
	 *
128
	 * @param string $action URL action name
129
	 * @return string Action method name
130
	 */
131
	public static function prepareAction($action)
132
	{
133
		return preg_replace_callback('/-(.)/', function ($matches) {
134
			return strtoupper($matches[1]);
135
		}, $action);
136
	}
137
138
	/**
139
	 * Instantiates a new request using the given argument.
140
	 *
141
	 * @param Request|string $request
142
	 * @return Request
143
	 */
144
	public static function prepareRequest($request)
145
	{
146
		if (!$request instanceof Request) {
147
			$request = Request::create($request);
148
		}
149
150
		return $request;
151
	}
152
153
	/**
154
	 * Prepare a response object using the given value.
155
	 *
156
	 * @param mixed $response
157
	 * @return Response
158
	 */
159
	public static function prepareResponse($response)
160
	{
161
		if (!$response instanceof Response) {
162
			$response = new Response($response);
163
		}
164
165
		return $response;
166
	}
167
168
	/**
169
	 * Initialise router with given array of routes where keys are patterns and
170
	 * values are either default controllers or a set of default values.
171
	 *
172
	 * Optionally accepts an array of default values for reserved route
173
	 * parameters to use for routes that don't match with them. These include
174
	 * 'namespace', 'controller' and 'action'.
175
	 *
176
	 * @param array $routes   Routes to match
177
	 * @param array $defaults Default router properties
178
	 */
179
	public function __construct(array $routes = [], array $defaults = [])
180
	{
181
		$this->add($routes);
182
		$this->defaults($defaults);
183
		$this->filter([$this, 'resolve']);
184
		$this->filter([$this, 'dispatchable']);
185
	}
186
187
	/**
188
	 * Set the optional event dispatcher for emitting routing events.
189
	 *
190
	 * @param Dispatchable $dispatcher
191
	 */
192
	public function setEventDispatcher(Dispatchable $dispatcher)
193
	{
194
		$this->eventDispatcher = $dispatcher;
195
	}
196
197
	/**
198
	 * Set an optional service container for resolving the dependencies of
199
	 * controllers and actions.
200
	 *
201
	 * @param Container $container
202
	 */
203
	public function setServiceContainer(Container $container)
204
	{
205
		$this->services = $container;
206
	}
207
208
	/**
209
	 * Set an error handler for dispatched requests that don't match a route.
210
	 *
211
	 * @param callable $handler
212
	 */
213
	public function setErrorHandler($handler)
214
	{
215
		if (is_callable($handler)) {
216
			$this->errorHandler = $handler;
217
		}
218
	}
219
220
	/**
221
	 * Invoke the error handler with the given request and response if one is
222
	 * set.
223
	 *
224
	 * Returns the given response if no error handler is set.
225
	 *
226
	 * @param Request  $request
227
	 * @param Response $response
228
	 * @param string   $message  [optional]
229
	 * @return Response
230
	 */
231
	protected function handleError(Request $request, Response $response, $message = null)
232
	{
233
		if ($this->errorHandler) {
234
			$errorHandler = $this->errorHandler;
235
			$response = static::prepareResponse($this->call($errorHandler, array($request, $response, $message)));
236
		}
237
238
		return $response;
239
	}
240
241
	/**
242
	 * Helper method for invoking callables. Silent if the given argument is
243
	 * not callable.
244
	 *
245
	 * Resolves parameters using the service container if available.
246
	 *
247
	 * @param mixed $callable
248
	 * @param array $arguments [optional]
249
	 * @return mixed
250
	 */
251
	protected function call($callable, array $arguments = array())
252
	{
253
		if (is_callable($callable)) {
254
			if ($this->services) {
255
				return $this->services->call($callable, $arguments);
256
			} else {
257
				return call_user_func_array($callable, $arguments);
258
			}
259
		}
260
261
		return null;
262
	}
263
264
	/**
265
	 * Helper method for instantiating classes.
266
	 *
267
	 * Instantiates the given class if it isn't already an object. Uses the
268
	 * service container if available.
269
	 *
270
	 * @param mixed $class     The class to instantiate.
271
	 * @param array $arguments [optional] The arguments to instantiate the class with.
272
	 * @return object
273
	 */
274
	protected function create($class, array $arguments = [])
275
	{
276
		if (!is_object($class) && class_exists($class)) {
277
			if ($this->services) {
278
				$class = $this->services->get($class, $arguments);
279
			} else {
280
				$reflection = new ReflectionClass($class);
281
				$class = $reflection->newInstanceArgs($arguments);
282
			}
283
		}
284
285
		return $class;
286
	}
287
288
	/**
289
	 * Helper method for dispatching events. Silent if an event dispatcher is
290
	 * not set.
291
	 *
292
	 * @param string $name
293
	 * @param mixed  $arguments [optional]
294
	 * @return array
295
	 */
296
	protected function event($name, array $arguments = array())
297
	{
298
		if ($this->eventDispatcher) {
299
			return $this->eventDispatcher->dispatch($name, $arguments);
300
		}
301
302
		return array();
303
	}
304
305
	/**
306
	 * Helper method for subscribing objects to the router's event dispatcher.
307
	 *
308
	 * Silent if $subscriber does not implement `Subscriber`.
309
	 *
310
	 * @param mixed $subscriber
311
	 * @return bool
312
	 */
313
	protected function subscribe($subscriber)
314
	{
315
		if ($this->eventDispatcher &&
316
			$this->eventDispatcher instanceof Subscribable &&
317
			$subscriber instanceof Subscriber
318
		) {
319
			$this->eventDispatcher->subscribe($subscriber);
320
			return true;
321
		}
322
323
		return false;
324
	}
325
326
	/**
327
	 * Helper method for unsubscribing objects from the router's event
328
	 * dispatcher.
329
	 *
330
	 * Silent if $subscriber does not implement `Subscriber`.
331
	 *
332
	 * @param mixed $subscriber
333
	 * @return bool
334
	 */
335
	protected function unsubscribe($subscriber)
336
	{
337
		if ($this->eventDispatcher &&
338
			$this->eventDispatcher instanceof Subscribable &&
339
			$subscriber instanceof Subscriber
340
		) {
341
			$this->eventDispatcher->unsubscribe($subscriber);
342
			return true;
343
		}
344
345
		return false;
346
	}
347
348
	/**
349
	 * Add routes to the router.
350
	 *
351
	 * When passed as an array, $routes elements can consist of either:
352
	 *   - Route path as the key, callable as the value
353
	 *   - Route name as the key, Route instance as the value
354
	 *
355
	 * An example using both:
356
	 * ```
357
	 *     $router->add([
358
	 *         '/route-path' => 'Namespace\Controller',
359
	 *         'route-name'  => new Route('/route-path', 'Namespace\Controller')
360
	 *     ]);
361
	 * ```
362
	 *
363
	 * @param string|array          $routes   Route definitions or a route path
364
	 * @param callable|array|string $defaults Default parameters for the route if $routes is a route path
365
	 */
366
	public function add($routes, $defaults = null)
367
	{
368
		if (is_array($routes)) {
369
			foreach ($routes as $path => $defaults) {
370
				if ($defaults instanceof Route) {
371
					$this->routes[$path] = $defaults;
372
				} else {
373
					$this->routes[] = new Route($path, $defaults);
374
				}
375
			}
376
		} else if ($defaults) {
377
			$path = $routes;
378
			$this->routes[] = new Route($path, $defaults);
379
		}
380
	}
381
382
	/**
383
	 * Add a single named route to the router.
384
	 *
385
	 * @param string $name     Name that identifies the route
386
	 * @param string $path     Path that matches the route
387
	 * @param mixed  $defaults Default route parameters
388
	 */
389
	public function set($name, $path, $defaults = array())
390
	{
391
		$this->routes[$name] = new Route($path, $defaults);
392
	}
393
394
	/**
395
	 * Get or set the router's base URI.
396
	 *
397
	 * @param string $uri [optional]
398
	 * @return string
399
	 */
400
	public function base($uri = null)
401
	{
402
		if (!is_null($uri)) {
403
			$this->base = $uri;
404
		}
405
406
		return $this->base;
407
	}
408
409
	/**
410
	 * Get and optionally set the router's default values for matched routes.
411
	 *
412
	 * Given key value pairs are merged with the current defaults.
413
	 *
414
	 * These are used when a route and the matched route's parameters haven't
415
	 * provided default values.
416
	 *
417
	 * @param array $defaults [optional]
418
	 * @return array Router's default route parameters
419
	 */
420
	public function defaults(array $defaults = array())
421
	{
422
		foreach ($defaults as $key => $value) {
423
			$property = strtolower($key);
424
			$this->defaults[$property] = $value;
425
		}
426
427
		return $this->defaults;
428
	}
429
430
	/**
431
	 * Register a callback for filtering matched routes and their parameters.
432
	 *
433
	 * Callbacks should return a bool determining whether the route matches.
434
	 * A route is passed by reference when matched by Router::match().
435
	 *
436
	 * @param callable $callback
437
	 * @return Router
438
	 */
439
	public function filter($callback)
440
	{
441
		if (is_callable($callback)) {
442
			$this->filters[] = $callback;
443
		}
444
445
		return $this;
446
	}
447
448
	/**
449
	 * Register a replacement pattern.
450
	 *
451
	 * @param string $pattern
452
	 * @param string $replacement
453
	 * @return Router
454
	 */
455
	public function pattern($pattern, $replacement)
456
	{
457
		$this->patterns[$pattern] = $replacement;
458
459
		return $this;
460
	}
461
462
	/**
463
	 * Attempt to resolve a matched route's controller class.
464
	 *
465
	 * Falls back to the router's default controller.
466
	 *
467
	 * @param Route $route
468
	 * @return Route
469
	 */
470
	protected function resolveRouteController(Route $route)
471
	{
472
		if (!$route->namespace) {
473
			$route->namespace = $this->defaults['namespace'];
474
		}
475
476
		if ($route->controller) {
477
			$controller = static::prepareController($route->controller);
478
479
			if ($route->namespace) {
480
				$controller = $route->namespace . '\\' . $controller;
481
			}
482
483
			if (class_exists($controller)) {
484
				$route->controller = $controller;
485
			}
486
		} else {
487
			$namespace = $route->namespace ? $route->namespace . '\\' : '';
488
			$route->controller = $namespace . $this->defaults['controller'];
489
		}
490
491
		return $route;
492
	}
493
494
	/**
495
	 * Attempt to resolve a matched route's action method.
496
	 *
497
	 * Falls back to the router's default action.
498
	 *
499
	 * @param Route $route
500
	 * @return Route
501
	 */
502
	protected function resolveRouteAction(Route $route)
503
	{
504
		if ($route->action) {
505
			if (!is_string($route->action)) {
506
				return $route;
507
			}
508
509
			$action = static::prepareAction($route->action);
510
511
			if (method_exists($route->controller, $action)) {
512
				$route->action = $action;
513
			} else if (method_exists($route->controller, $action . 'Action')) {
514
				$route->action = $action . 'Action';
515
			}
516
		} else {
517
			$route->action = $this->defaults['action'];
518
		}
519
520
		return $route;
521
	}
522
523
	/**
524
	 * Resolve a matched route's controller and action.
525
	 *
526
	 * Applies the router's defaults for these if they are not set.
527
	 *
528
	 * This is a built in route filter that is registered by default.
529
	 *
530
	 * TODO: Also apply any other default parameters.
531
	 *
532
	 * @param Route $route
533
	 * @return bool
534
	 */
535
	public function resolve(Route $route)
536
	{
537
		$this->resolveRouteController($route);
538
		$this->resolveRouteAction($route);
539
540
		return true;
541
	}
542
543
	/**
544
	 * Determine whether a given matched route can be dispatched based on
545
	 * whether the resolved controller action is callable.
546
	 *
547
	 * This is a built in route filter that is registered by default. It expects
548
	 * the `resolve` filter to have already been applied to the given route.
549
	 *
550
	 * @param Route $route
551
	 * @return bool
552
	 */
553
	public function dispatchable(Route $route)
554
	{
555
		$dispatchableAction = is_callable($route->action);
556
557
		$dispatchableController =
558
			(is_object($route->controller) || class_exists($route->controller))
559
			&& method_exists($route->controller, $route->action)
0 ignored issues
show
Bug introduced by
It seems like $route->action can also be of type callable; however, parameter $method of method_exists() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

559
			&& method_exists($route->controller, /** @scrutinizer ignore-type */ $route->action)
Loading history...
560
			&& is_callable(array($route->controller, $route->action));
561
562
		return $dispatchableAction || $dispatchableController;
563
	}
564
565
	/**
566
	 * Strip the router's base URI from the beginning of the given URI.
567
	 *
568
	 * @param string $uri
569
	 * @return string
570
	 */
571
	protected function stripBase($uri)
572
	{
573
		if (!empty($this->base) && strpos($uri, $this->base) === 0) {
574
			$uri = substr($uri, strlen($this->base));
575
		}
576
577
		return $uri;
578
	}
579
580
	/**
581
	 * Test a given route against the router's filters.
582
	 *
583
	 * Optionally test against the given callback after testing against filters.
584
	 *
585
	 * @param Route    $route
586
	 * @param callable $callback
587
	 * @return bool
588
	 */
589
	protected function testMatchFilters(Route $route, $callback = null)
590
	{
591
		$filters = is_callable($callback) ? array_merge($this->filters, array($callback)) : $this->filters;
592
593
		foreach ($filters as $filter) {
594
			if (!$this->call($filter, array(&$route))) {
595
				return false;
596
			}
597
		}
598
599
		return true;
600
	}
601
602
	/**
603
	 * Test a request against a route.
604
	 *
605
	 * Accepts an optional extra callback for filtering matched routes and their
606
	 * parameters. This callback is executed after testing the route against
607
	 * the router's filters.
608
	 *
609
	 * Fires the 'router.prefilter' event before testing against filters.
610
	 *
611
	 * @param Request  $request
612
	 * @param Route    $route
613
	 * @param callable $callback [optional]
614
	 * @return bool
615
	 */
616
	protected function testMatch(Request $request, Route $route, $callback = null)
617
	{
618
		$path = $this->stripBase($request->path());
619
		$pattern = $this->preparePattern($route->path());
620
621
		if (preg_match($pattern, $path, $matches)) {
622
			$route->matches($matches);
623
624
			$this->event('router.prefilter', array($route));
625
626
			if ($this->testMatchFilters($route, $callback)) {
627
				return true;
628
			}
629
		}
630
631
		return false;
632
	}
633
634
	/**
635
	 * Match a request to one of the router's routes.
636
	 *
637
	 * @param Request|string $request  A request URI or a Request object to match
638
	 * @param callable       $callback [optional] Callback for filtering matched routes
639
	 * @return Route|bool              The matched route
640
	 */
641
	public function match($request, $callback = null)
642
	{
643
		$request = static::prepareRequest($request);
644
645
		foreach ($this->routes as $route) {
646
			$route = clone $route;
647
648
			if ($this->testMatch($request, $route, $callback)) {
649
				$route->router = $this;
650
				$request->router = $this;
651
				$request->route = $route;
652
653
				return $route;
654
			}
655
		}
656
657
		return false;
658
	}
659
660
	/**
661
	 * Dispatch the given request and response objects with the given callable
662
	 * and optional arguments.
663
	 *
664
	 * An error handler can be set (@see Router::setErrorHandler()) to handle
665
	 * the request in the case that the given action is not callable.
666
	 *
667
	 * @param Request         $request
668
	 * @param Response        $response
669
	 * @param callable|string $callable
670
	 * @param array           $arguments [optional]
671
	 * @return Response
672
	 */
673
	protected function dispatchCallable(Request $request, Response $response, $callable, array $arguments = array())
674
	{
675
		$requestResponse = array($request, $response);
676
677
		$responses = $this->event('router.before', $requestResponse);
678
679
		if (is_callable($callable)) {
680
			$responses[] = $requestResponse[1] = $this->call($callable, $arguments);
681
		} else {
682
			$response->status(404);
683
			return $this->handleError($request, $response, 'Non-callable resolved from the matched route');
684
		}
685
686
		$responses = array_merge($responses, $this->event('router.after', $requestResponse));
687
		$requestResponse[1] = $responses[count($responses) - 1];
688
		$responses = array_merge($responses, $this->event('router.last', $requestResponse));
689
690
		foreach ($responses as $potential) {
691
			$potential = static::prepareResponse($potential);
692
693
			if ($potential->redirected() || $potential->hasContent()) {
694
				return $potential;
695
			}
696
		}
697
698
		return $response;
699
	}
700
701
	/**
702
	 * Match a request to a route and dispatch the resolved callable.
703
	 *
704
	 * An error handler can be set (@see Router::setErrorHandler()) to handle
705
	 * the request in the case that a route could not be matched. Returns null
706
	 * in this case if an error handler is not set.
707
	 *
708
	 * @param Request|string $request
709
	 * @param Response       $response [optional]
710
	 * @return Response
711
	 */
712
	public function dispatch($request, Response $response = null)
713
	{
714
		$request  = static::prepareRequest($request);
715
		$response = static::prepareResponse($response);
716
717
		$route = $this->match($request);
718
719
		if ($route) {
720
			$controllerArguments = array(
721
				'request'  => $request,
722
				'response' => $response
723
			);
724
725
			$controller = $this->create($route->controller, $controllerArguments);
726
			$action     = $route->action;
727
			$arguments  = $route->arguments();
728
729
			$callable = is_callable(array($controller, $action)) ? array($controller, $action) : $action;
730
731
			$this->subscribe($controller);
732
			$response = $this->dispatchCallable($request, $response, $callable, $arguments);
733
			$this->unsubscribe($controller);
734
735
			$response->header('X-Location: ' . $request->uri());
736
737
			return $response;
738
		}
739
740
		$response->status(404);
741
		$response = $this->handleError($request, $response, 'No route matches the request');
742
743
		return $response;
744
	}
745
746
	/**
747
	 * Dispatch a request, resolving a response and send it to the client.
748
	 *
749
	 * Optionally pass through an existing response object.
750
	 *
751
	 * @param Request|string $request
752
	 * @param Response       $response [optional]
753
	 */
754
	public function respond($request, Response $response = null)
755
	{
756
		$response = $this->dispatch($request, $response);
757
		$response->send();
758
	}
759
760
	/**
761
	 * Generate a request path using the given route path and parameters.
762
	 *
763
	 * TODO: Swap generate() & path() functionality?
764
	 *
765
	 * @param string $path
766
	 * @param array $parameters [optional]
767
	 * @return string
768
	 */
769
	public function generate($path, array $parameters = array())
770
	{
771
		return preg_replace_callback('#/(:[A-Za-z0-9_-]+(\??))#', function ($match) use ($parameters) {
772
			$parameter = trim($match[1], '?:');
773
774
			if ($parameter && isset($parameters[$parameter])) {
775
				return '/' . $parameters[$parameter];
776
			}
777
778
			if ($parameter !== 'params' && $match[2] !== '?') {
779
				return '/null';
780
			}
781
782
			return null;
783
		}, $path);
784
	}
785
786
	/**
787
	 * Generate a request path using the given route name/path and parameters.
788
	 *
789
	 * Any required parameters that are not satisfied by the given parameters
790
	 * or the route's defaults will be set to the string 'null'.
791
	 *
792
	 * @param string $name       Route name or path
793
	 * @param array  $parameters [optional]
794
	 * @return string
795
	 */
796
	public function path($name, array $parameters = array())
797
	{
798
		$path = $name;
799
800
		if (isset($this->routes[$name])) {
801
			$route = $this->routes[$name];
802
			$path = $route->path();
803
			$parameters = array_merge($route->defaults(), $parameters);
804
		}
805
806
		if (isset($parameters['params']) && is_array($parameters['params'])) {
807
			$parameters['params'] = implode('/', $parameters['params']);
808
		}
809
810
		return $this->generate($path, $parameters);
811
	}
812
813
	/**
814
	 * Generate an absolute URL using the given route name and parameters.
815
	 *
816
	 * @param string $name
817
	 * @param array  $parameters [optional]
818
	 * @return string
819
	 */
820
	public function url($name, array $parameters = array())
821
	{
822
		return $this->base . $this->path($name, $parameters);
823
	}
824
}
825