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 use ICanBoogie\Exception\RescueEvent;
15
16 /**
17 * Dispatches HTTP requests.
18 *
19 * ## Events
20 *
21 * - `ICanBoogie\HTTP\Dispatcher::dispatch:before` of class {@link Dispatcher\BeforeDispatchEvent}.
22 * - `ICanBoogie\HTTP\Dispatcher::dispatch` of class {@link Dispatcher\DispatchEvent}.
23 * - `ICanBoogie\HTTP\Dispatcher::rescue` of class {@link ICanBoogie\Exception\RescueEvent}.
24 */
25 class Dispatcher implements \ArrayAccess, \IteratorAggregate, DispatcherInterface
26 {
27 /**
28 * The dispatchers called during the dispatching of the request.
29 *
30 * @var array
31 */
32 protected $dispatchers = [];
33
34 /**
35 * The weights of the dispatchers.
36 *
37 * @var array
38 */
39 protected $dispatchers_weight = [];
40
41 protected $dispatchers_order;
42
43 /**
44 * Initializes the {@link $dispatchers} property.
45 *
46 * Dispatchers can be defined as callable or class name. If a dispatcher definition is not a
47 * callable it is used as class name to instantiate a dispatcher.
48 *
49 * @param array $dispatchers
50 */
51 public function __construct(array $dispatchers = [])
52 {
53 foreach ($dispatchers as $dispatcher_id => $dispatcher)
54 {
55 $this[$dispatcher_id] = $dispatcher;
56 }
57 }
58
59 /**
60 * Dispatches the request to retrieve a {@link Response}.
61 *
62 * The request is dispatched by the {@link dispatch()} method. If an exception is thrown
63 * during the dispatch the {@link rescue()} method is used to rescue the exception and
64 * retrieve a {@link Response}.
65 *
66 * ## HEAD requests
67 *
68 * If a {@link NotFound} exception is caught during the dispatching of a request with a
69 * {@link Request::METHOD_HEAD} method the following happens:
70 *
71 * 1. The request is cloned and the method of the cloned request is changed to
72 * {@link Request::METHOD_GET}.
73 * 2. The cloned method is dispatched.
74 * 3. If the result is *not* a {@link Response} instance, the result is returned.
75 * 4. Otherwise, a new {@link Response} instance is created with a `null` body, but the status
76 * code and headers of the original response.
77 * 5. The new response is returned.
78 *
79 * @param Request $request
80 *
81 * @return Response
82 */
83 public function __invoke(Request $request)
84 {
85 $response = $this->handle($request);
86
87 if ($request->is_head && $response->body)
88 {
89 return new Response(null, $response->status, $response->headers);
90 }
91
92 return $response;
93 }
94
95 private function handle(Request $request)
96 {
97 try
98 {
99 return $this->dispatch($request);
100 }
101 catch (\Exception $e)
102 {
103 if ($e instanceof NotFound && $request->is_head)
104 {
105 return $this->handle_head($request);
106 }
107
108 return $this->rescue($e, $request);
109 }
110 }
111
112 /**
113 * Trying to rescue a NotFound HEAD request using GET instead.
114 *
115 * @param Request $request
116 *
117 * @return Response
118 */
119 private function handle_head(Request $request)
120 {
121 $response = $this->handle($request->with([ 'is_get' => true ]));
122
123 if ($response->content_length === null)
124 {
125 try
126 {
127 $response->content_length = strlen((string) $response->body);
128 }
129 catch (\Exception $e)
130 {
131 #
132 # It's not that bad if we can't obtain the length of the body.
133 #
134 }
135 }
136
137 return $response;
138 }
139
140 /**
141 * Checks if the dispatcher is defined.
142 *
143 * @param string $dispatcher_id The identifier of the dispatcher.
144 *
145 * @return bool `true` if the dispatcher is defined, `false` otherwise.
146 */
147 public function offsetExists($dispatcher_id)
148 {
149 return isset($this->dispatchers[$dispatcher_id]);
150 }
151
152 /**
153 * Returns a dispatcher.
154 *
155 * @param string $dispatcher_id The identifier of the dispatcher.
156 *
157 * @return mixed
158 */
159 public function offsetGet($dispatcher_id)
160 {
161 if (!$this->offsetExists($dispatcher_id))
162 {
163 throw new DispatcherNotDefined($dispatcher_id);
164 }
165
166 return $this->dispatchers[$dispatcher_id];
167 }
168
169 /**
170 * Defines a dispatcher.
171 *
172 * @param string $dispatcher_id The identifier of the dispatcher.
173 * @param mixed $dispatcher The dispatcher class or callback.
174 */
175 public function offsetSet($dispatcher_id, $dispatcher)
176 {
177 $weight = 0;
178
179 if ($dispatcher instanceof WeightedDispatcher)
180 {
181 $weight = $dispatcher->weight;
182 $dispatcher = $dispatcher->dispatcher;
183 }
184
185 $this->dispatchers[$dispatcher_id] = $dispatcher;
186 $this->dispatchers_weight[$dispatcher_id] = $weight;
187 $this->dispatchers_order = null;
188 }
189
190 /**
191 * Removes a dispatcher.
192 *
193 * @param string $dispatcher_id The identifier of the dispatcher.
194 */
195 public function offsetUnset($dispatcher_id)
196 {
197 unset($this->dispatchers[$dispatcher_id]);
198 }
199
200 public function getIterator()
201 {
202 if (!$this->dispatchers_order)
203 {
204 $weights = $this->dispatchers_weight;
205
206 $this->dispatchers_order = \ICanBoogie\sort_by_weight($this->dispatchers, function($v, $k) use ($weights) {
207
208 return $weights[$k];
209
210 });
211 }
212
213 return new \ArrayIterator($this->dispatchers_order);
214 }
215
216 /**
217 * Dispatches a request using the defined dispatchers.
218 *
219 * The method iterates over the defined dispatchers until one of them returns a
220 * {@link Response} instance. If an exception is throw during the dispatcher execution and
221 * the dispatcher implements the {@link DispatcherInterface} interface then its
222 * {@link DispatcherInterface::rescue} method is invoked to rescue the exception, otherwise the
223 * exception is just re-thrown.
224 *
225 * {@link Dispatcher\BeforeDispatchEvent} is fired before dispatchers are traversed. If a
226 * response is provided the dispatchers are skipped.
227 *
228 * {@link Dispatcher\DispatchEvent} is fired before the response is returned. The event is
229 * fired event if the dispatchers did'nt return a response. It's the last chance to get one.
230 *
231 * @param Request $request
232 *
233 * @return Response
234 *
235 * @throws \Exception If the dispatcher that raised an exception during dispatch doesn't implement
236 * {@link DispatcherInterface}.
237 * @throws NotFound when neither the events nor the dispatchers were able to provide
238 * a {@link Response}.
239 */
240 protected function dispatch(Request $request)
241 {
242 $response = null;
243
244 new Dispatcher\BeforeDispatchEvent($this, $request, $response);
245
246 if (!$response)
247 {
248 foreach ($this as $id => $dispatcher)
249 {
250 #
251 # If the dispatcher is not a callable then it is considered as a class name, which
252 # is used to instantiate a dispatcher.
253 #
254
255 if (!($dispatcher instanceof CallableDispatcher))
256 {
257 $this->dispatchers[$id] = $dispatcher = is_callable($dispatcher) ? new CallableDispatcher($dispatcher) : new $dispatcher;
258 }
259
260 $response = $this->dispatch_with_dispatcher($dispatcher, $request);
261
262 if ($response) break;
263 }
264 }
265
266 new Dispatcher\DispatchEvent($this, $request, $response);
267
268 if (!$response)
269 {
270 throw new NotFound;
271 }
272
273 return $response;
274 }
275
276 /**
277 * Dispatches the request using a dispatcher.
278 *
279 * @param DispatcherInterface $dispatcher
280 * @param Request $request
281 *
282 * @return Response
283 *
284 * @throws \Exception
285 */
286 protected function dispatch_with_dispatcher(DispatcherInterface $dispatcher, Request $request)
287 {
288 try
289 {
290 $request->context->dispatcher = $dispatcher;
291
292 $response = $dispatcher($request);
293 }
294 catch (\Exception $e)
295 {
296 $response = $dispatcher->rescue($e, $request);
297 }
298
299 $request->context->dispatcher = null;
300
301 return $response;
302 }
303
304 /**
305 * Tries to get a {@link Response} object from an exception.
306 *
307 * {@link \ICanBoogie\Exception\RescueEvent} is fired with the exception as target.
308 * The response provided by one of the event hooks is returned. If there is no response the
309 * exception is thrown again.
310 *
311 * If a response is finally obtained, the `X-ICanBoogie-Rescued-Exception` header is added to
312 * indicate where the exception was thrown from.
313 *
314 * @param \Exception $exception The exception to rescue.
315 * @param Request $request The current request.
316 *
317 * @return Response
318 *
319 * @throws \Exception The exception is re-thrown if it could not be rescued.
320 */
321 public function rescue(\Exception $exception, Request $request)
322 {
323 /* @var $response Response */
324 $response = null;
325
326 new RescueEvent($exception, $request, $response);
327
328 if (!$response)
329 {
330 if ($exception instanceof ForceRedirect)
331 {
332 return new RedirectResponse($exception->location, $exception->getCode());
333 }
334
335 throw $exception;
336 }
337
338 $pathname = $exception->getFile();
339 $root = $_SERVER['DOCUMENT_ROOT'];
340
341 if ($root && strpos($pathname, $root) === 0)
342 {
343 $pathname = substr($pathname, strlen($root));
344 }
345
346 $response->headers['X-ICanBoogie-Rescued-Exception'] = $pathname . '@' . $exception->getLine();
347
348 return $response;
349 }
350 }
351