1 <?php
2
3 /*
4 * This file is part of the ICanBoogie package.
5 *
6 * (c) Olivier Laviale <olivier.laviale@gmail.com>
7 *
8 * For the full copyright and license information, please view the LICENSE
9 * file that was distributed with this source code.
10 */
11
12 namespace ICanBoogie\HTTP;
13
14 /**
15 * Dispatches requests.
16 *
17 * Events:
18 *
19 * - `ICanBoogie\HTTP\Dispatcher::dispatch:before`: {@link Dispatcher\BeforeDispatchEvent}.
20 * - `ICanBoogie\HTTP\Dispatcher::dispatch`: {@link Dispatcher\DispatchEvent}.
21 * - `ICanBoogie\HTTP\Dispatcher::rescue`: {@link ICanBoogie\Exception\RescueEvent}.
22 */
23 class Dispatcher implements \ArrayAccess, \IteratorAggregate, IDispatcher
24 {
25 /**
26 * The dispatchers called during the dispatching of the request.
27 *
28 * @var array[string]callable|string
29 */
30 protected $dispatchers = array();
31
32 /**
33 * The weights of the dispatchers.
34 *
35 * @var array[string]mixed
36 */
37 protected $dispatchers_weight = array();
38
39 protected $dispatchers_order;
40
41 /**
42 * Initialiazes the {@link $dispatchers} property.
43 *
44 * Dispatchers can be defined as callable or class name. If a dispatcher definition is not a
45 * callable it is used as class name to instantiate a dispatcher.
46 */
47 public function __construct(array $dispatchers=array())
48 {
49 foreach ($dispatchers as $dispatcher_id => $dispatcher)
50 {
51 $this[$dispatcher_id] = $dispatcher;
52 }
53 }
54
55 /**
56 * Dispatches the request to retrieve a {@link Response}.
57 *
58 * The request is dispatched by the {@link dispatch()} method. If an exception is thrown
59 * during the dispatch the {@link rescue()} method is used to rescue the exception and
60 * retrieve a {@link Response}.
61 *
62 * ## HEAD requests
63 *
64 * If a {@link NotFound} exception is caught during the dispatching of a request with a
65 * {@link Request::METHOD_HEAD} method the following happens:
66 *
67 * 1. The request is cloned and the method of the cloned request is changed to
68 * {@link Request::METHOD_GET}.
69 * 2. The cloned method is dispatched.
70 * 3. If the result is *not* a {@link Response} instance, the result is returned.
71 * 4. Otherwise, a new {@link Response} instance is created with a `null` body, but the status
72 * code and headers of the original response.
73 * 5. The new response is returned.
74 *
75 * @param Request $request
76 *
77 * @return Response
78 */
79 public function __invoke(Request $request)
80 {
81 try
82 {
83 return $this->dispatch($request);
84 }
85 catch (\Exception $e)
86 {
87 if ($request->method === Request::METHOD_HEAD && $e instanceof NotFound)
88 {
89 $get_request = clone $request;
90 $get_request->method = Request::METHOD_GET;
91
92 $response = $this($get_request);
93
94 if (!($response instanceof Response))
95 {
96 return $response;
97 }
98
99 return new Response(null, $response->status, $response->headers);
100 }
101
102 return $this->rescue($e, $request);
103 }
104 }
105
106 /**
107 * Checks if the dispatcher is defined.
108 *
109 * @param string $dispatcher_id The identifier of the dispatcher.
110 *
111 * @return `true` if the dispatcher is defined, `false` otherwise.
112 */
113 public function offsetExists($dispatcher_id)
114 {
115 return isset($this->dispatchers[$dispatcher_id]);
116 }
117
118 /**
119 * Returns a dispatcher.
120 *
121 * @param string $dispatcher_id The identifier of the dispatcher.
122 */
123 public function offsetGet($dispatcher_id)
124 {
125 if (!$this->offsetExists($dispatcher_id))
126 {
127 throw new DispatcherNotDefined($dispatcher_id);
128 }
129
130 return $this->dispatchers[$dispatcher_id];
131 }
132
133 /**
134 * Defines a dispatcher.
135 *
136 * @param string $dispatcher_id The identifier of the dispatcher.
137 * @param mixed $dispatcher The dispatcher class or callback.
138 */
139 public function offsetSet($dispatcher_id, $dispatcher)
140 {
141 $weight = 0;
142
143 if ($dispatcher instanceof WeightedDispatcher)
144 {
145 $weight = $dispatcher->weight;
146 $dispatcher = $dispatcher->dispatcher;
147 }
148
149 $this->dispatchers[$dispatcher_id] = $dispatcher;
150 $this->dispatchers_weight[$dispatcher_id] = $weight;
151 $this->dispatchers_order = null;
152 }
153
154 /**
155 * Removes a dispatcher.
156 *
157 * @param string $dispatcher_id The identifier of the dispatcher.
158 */
159 public function offsetUnset($dispatcher_id)
160 {
161 unset($this->dispatchers[$dispatcher_id]);
162 }
163
164 public function getIterator()
165 {
166 if (!$this->dispatchers_order)
167 {
168 $weights = $this->dispatchers_weight;
169
170 $this->dispatchers_order = \ICanBoogie\sort_by_weight($this->dispatchers, function($v, $k) use($weights) {
171
172 return $weights[$k];
173
174 });
175 }
176
177 return new \ArrayIterator($this->dispatchers_order);
178 }
179
180 /**
181 * Dispatches a request using the defined dispatchers.
182 *
183 * The method iterates over the defined dispatchers until one of them returns a
184 * {@link Response} instance. If an exception is throw during the dispatcher execution and
185 * the dispatcher implements the {@link IDispatcher} interface then its
186 * {@link IDispatcher::rescue} method is invoked to rescue the exception, otherwise the
187 * exception is just rethrown.
188 *
189 * {@link Dispatcher\BeforeDispatchEvent} is fired before dispatchers are traversed. If a
190 * response is provided the dispatchers are skipped.
191 *
192 * {@link Dispatcher\DispatchEvent} is fired before the response is returned. The event is
193 * fired event if the dispatchers didn't return a response. It's the last chance to get one.
194 *
195 * @param Request $request
196 *
197 * @return Response
198 *
199 * @throws NotFound when neither the events nor the dispatchers were able to provide
200 * a {@link Response}.
201 */
202 protected function dispatch(Request $request)
203 {
204 $response = null;
205
206 new Dispatcher\BeforeDispatchEvent($this, $request, $response);
207
208 if (!$response)
209 {
210 foreach ($this as $id => &$dispatcher) // MOVE some to AGGREGATE
211 {
212 #
213 # If the dispatcher is not a callable then it is considered as a class name, which
214 # is used to instantiate a dispatcher.
215 #
216
217 if (!($dispatcher instanceof CallableDispatcher))
218 {
219 $dispatcher = is_callable($dispatcher) ? new CallableDispatcher($dispatcher) : new $dispatcher;
220 }
221
222 try
223 {
224 $request->context->dispatcher = $dispatcher;
225
226 $response = call_user_func($dispatcher, $request);
227 }
228 catch (\Exception $e)
229 {
230 if (!($dispatcher instanceof IDispatcher))
231 {
232 throw $e;
233 }
234
235 $response = $dispatcher->rescue($e, $request);
236 }
237
238 if ($response) break;
239
240 $request->context->dispatcher = null;
241 }
242 }
243
244 new Dispatcher\DispatchEvent($this, $request, $response);
245
246 if (!$response)
247 {
248 throw new NotFound;
249 }
250
251 return $response;
252 }
253
254 /**
255 * Tries to get a {@link Response} object from an exception.
256 *
257 * {@link \ICanBoogie\Exception\RescueEvent} is fired with the exception as target.
258 * The response provided by one of the event hooks is returned. If there is no response the
259 * exception is thrown again.
260 *
261 * If a response is finaly obtained, the `X-ICanBoogie-Rescued-Exception` header is added to
262 * indicate where the exception was thrown from.
263 *
264 * @param \Exception $exception The exception to rescue.
265 * @param Request $request The current request.
266 *
267 * @return Response
268 *
269 * @throws \Exception The exception is rethrown if it could not be rescued.
270 */
271 public function rescue(\Exception $exception, Request $request)
272 {
273 $response = null;
274
275 new \ICanBoogie\Exception\RescueEvent($exception, $request, $response);
276
277 if (!$response)
278 {
279 if ($exception instanceof ForceRedirect)
280 {
281 return new RedirectResponse($exception->location, $exception->getCode());
282 }
283
284 throw $exception;
285 }
286
287 $pathname = $exception->getFile();
288 $root = $_SERVER['DOCUMENT_ROOT'];
289
290 if ($root && strpos($pathname, $root) === 0)
291 {
292 $pathname = substr($pathname, strlen($root));
293 }
294
295 $response->headers['X-ICanBoogie-Rescued-Exception'] = $pathname . '@' . $exception->getLine();
296
297 return $response;
298 }
299 }
300
301 /**
302 * Dispatcher interface.
303 */
304 interface IDispatcher
305 {
306 /**
307 * Process the request.
308 *
309 * @param Request $request
310 *
311 * @return Response A response to the tequest.
312 */
313 public function __invoke(Request $request);
314
315 /**
316 * Rescues the exception that was thrown during the request process.
317 *
318 * @param \Exception $exception
319 *
320 * @return Response A response to the request exception.
321 *
322 * @throws \Exception when the request exception cannot be rescued.
323 */
324 public function rescue(\Exception $exception, Request $request);
325 }
326
327 /**
328 * Wrapper for callable dispatchers.
329 */
330 class CallableDispatcher implements IDispatcher
331 {
332 private $callable;
333
334 public function __construct($callable)
335 {
336 $this->callable = $callable;
337 }
338
339 public function __invoke(Request $request)
340 {
341 return call_user_func($this->callable, $request);
342 }
343
344 public function rescue(\Exception $exception, Request $request)
345 {
346 throw $exception;
347 }
348 }
349
350 /**
351 * Used to defined a dispatcher and its weight.
352 *
353 * <pre>
354 * <?php
355 *
356 * $dispatcher['my'] = new WeightedDispatcher('callback', 'before:that_other_dispatcher');
357 * </pre>
358 */
359 class WeightedDispatcher
360 {
361 public $dispatcher;
362
363 public $weight;
364
365 public function __construct($dispatcher, $weight)
366 {
367 $this->dispatcher = $dispatcher;
368 $this->weight = $weight;
369 }
370 }
371
372 /*
373 * Events
374 */
375
376 namespace ICanBoogie\HTTP\Dispatcher;
377
378 use ICanBoogie\HTTP\Dispatcher;
379 use ICanBoogie\HTTP\Response;
380 use ICanBoogie\HTTP\Request;
381
382 /**
383 * Event class for the `ICanBoogie\HTTP\Dispatcher::dispatch:before` event.
384 *
385 * Third parties may use this event to provide a response to the request before the dispatchers
386 * are invoked. The event is usually used by third parties to redirect requests or provide cached
387 * responses.
388 */
389 class BeforeDispatchEvent extends \ICanBoogie\Event
390 {
391 /**
392 * The HTTP request.
393 *
394 * @var Request
395 */
396 public $request;
397
398 /**
399 * Reference to the HTTP response.
400 *
401 * @var Response
402 */
403 public $response;
404
405 /**
406 * The event is constructed with the type `dispatch:before`.
407 *
408 * @param Dispatcher $target
409 * @param array $payload
410 */
411 public function __construct(Dispatcher $target, Request $request, &$response)
412 {
413 if ($response !== null && !($response instanceof Response))
414 {
415 throw new \InvalidArgumentException('$response must be an instance of ICanBoogie\HTTP\Response. Given: ' . get_class($response) . '.');
416 }
417
418 $this->request = $request;
419 $this->response = &$response;
420
421 parent::__construct($target, 'dispatch:before');
422 }
423 }
424
425 /**
426 * Event class for the `ICanBoogie\HTTP\Dispatcher::dispatch` event.
427 *
428 * Third parties may use this event to alter the response before it is returned by the dispatcher.
429 */
430 class DispatchEvent extends \ICanBoogie\Event
431 {
432 /**
433 * The request.
434 *
435 * @var Request
436 */
437 public $request;
438
439 /**
440 * Reference to the response.
441 *
442 * @var Response
443 */
444 public $response;
445
446 /**
447 * The event is constructed with the type `dispatch`.
448 *
449 * @param Dispatcher $target
450 * @param array $payload
451 */
452 public function __construct(Dispatcher $target, Request $request, &$response)
453 {
454 $this->request = $request;
455 $this->response = &$response;
456
457 parent::__construct($target, 'dispatch');
458 }
459 }
460
461 namespace ICanBoogie\Exception;
462
463 use ICanBoogie\HTTP\Request;
464 use ICanBoogie\HTTP\Response;
465
466 /**
467 * Event class for the `Exception:rescue` event type.
468 *
469 * Third parties may use this event to provide a response for the exception.
470 */
471 class RescueEvent extends \ICanBoogie\Event
472 {
473 /**
474 * Reference to the response.
475 *
476 * @var Response
477 */
478 public $response;
479
480 /**
481 * Reference tot the exception.
482 *
483 * @var \Exception
484 */
485 public $exception;
486
487 /**
488 * The request.
489 *
490 * @var Request
491 */
492 public $request;
493
494 /**
495 * The event is constructed with the type `rescue`.
496 *
497 * @param \Exception $target
498 * @param array $payload
499 */
500 public function __construct(\Exception &$target, Request $request, &$response)
501 {
502 if ($response !== null && !($response instanceof Response))
503 {
504 throw new \InvalidArgumentException('$response must be an instance of ICanBoogie\HTTP\Response. Given: ' . (is_object($response) ? get_class($response) : gettype($response)) . '.');
505 }
506
507 $this->response = &$response;
508 $this->exception = &$target;
509 $this->request = $request;
510
511 parent::__construct($target, 'rescue');
512 }
513 }