1: <?php
2: 3: 4: 5: 6: 7: 8: 9:
10:
11: namespace Autarky\Routing;
12:
13: use Closure;
14: use Symfony\Component\HttpFoundation\JsonResponse;
15: use Symfony\Component\HttpFoundation\Request;
16: use Symfony\Component\HttpFoundation\Response;
17: use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
18: use Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException;
19: use Symfony\Component\EventDispatcher\EventDispatcherInterface;
20:
21: use FastRoute\RouteCollector;
22: use FastRoute\RouteParser;
23: use FastRoute\Dispatcher\GroupCountBased as Dispatcher;
24: use FastRoute\DataGenerator\GroupCountBased as DataGenerator;
25:
26: use Autarky\Files\LockingFilesystem;
27:
28: 29: 30:
31: class Router implements RouterInterface
32: {
33: 34: 35:
36: protected $invoker;
37:
38: 39: 40:
41: protected $eventDispatcher;
42:
43: 44: 45:
46: protected $routeCollector;
47:
48: 49: 50:
51: protected $routeParser;
52:
53: 54: 55:
56: protected $dispatchData;
57:
58: 59: 60:
61: protected $cachePath;
62:
63: 64: 65:
66: protected $currentRoute;
67:
68: 69: 70: 71: 72:
73: protected $currentHooks = [];
74:
75: 76: 77: 78: 79:
80: protected $currentPrefix = '';
81:
82: 83: 84:
85: protected $hooks = [];
86:
87: 88: 89:
90: protected $routes;
91:
92: 93: 94:
95: protected $namedRoutes = [];
96:
97: 98: 99: 100: 101: 102:
103: public function __construct(
104: RouteParser $routeParser,
105: InvokerInterface $invoker,
106: EventDispatcherInterface $eventDispatcher = null,
107: $cachePath = null
108: ) {
109: $this->routes = new \SplObjectStorage;
110: $this->invoker = $invoker;
111: $this->eventDispatcher = $eventDispatcher;
112:
113: if ($cachePath) {
114: $this->cachePath = $cachePath;
115: if (file_exists($cachePath)) {
116: Route::setRouter($this);
117: $this->dispatchData = require $cachePath;
118: return;
119: }
120: }
121:
122: $this->routeCollector = new RouteCollector(
123: $routeParser, new DataGenerator
124: );
125: }
126:
127: 128: 129:
130: public function isCaching()
131: {
132: return $this->dispatchData !== null;
133: }
134:
135: 136: 137:
138: public function getRoutes()
139: {
140: return $this->routes;
141: }
142:
143: 144: 145:
146: public function getCurrentRoute()
147: {
148: return $this->currentRoute;
149: }
150:
151: 152: 153: 154: 155: 156: 157: 158: 159:
160: public function addBeforeHook($name, $handler, $priority = 0)
161: {
162: $this->addEventListener($name, $handler, 'before', $priority);
163: }
164:
165: 166: 167: 168: 169: 170: 171: 172: 173:
174: public function addAfterHook($name, $handler, $priority = 0)
175: {
176: $this->addEventListener($name, $handler, 'after', $priority);
177: }
178:
179: 180: 181: 182: 183: 184: 185: 186:
187: public function addGlobalBeforeHook($handler, $priority = 0)
188: {
189: $this->addEventListener(null, $handler, 'before', $priority);
190: }
191:
192: 193: 194: 195: 196: 197: 198: 199:
200: public function addGlobalAfterHook($handler, $priority = 0)
201: {
202: $this->addEventListener(null, $handler, 'after', $priority);
203: }
204:
205: protected function addEventListener($name, $handler, $when, $priority)
206: {
207: if ($this->eventDispatcher === null) {
208: return;
209: }
210:
211: if ($name) {
212: if (isset($this->hooks[$name])) {
213: throw new \LogicException("Hook with name $name already defined");
214: }
215:
216: $this->hooks[$name] = $name;
217: }
218:
219: $name = $name ? "route.$when.$name" : "route.$when";
220: $this->eventDispatcher->addListener($name, $handler, $priority);
221: }
222:
223: 224: 225:
226: public function mount(array $routes, $path = '/')
227: {
228: if ($this->isCaching()) {
229: return;
230: }
231:
232: (new Configuration($this, $routes))
233: ->mount($path);
234: }
235:
236: 237: 238:
239: public function group(array $flags, Closure $callback)
240: {
241: if ($this->isCaching()) {
242: return;
243: }
244:
245: $oldPrefix = $this->currentPrefix;
246: $oldHooks = $this->currentHooks;
247:
248: foreach (['before', 'after'] as $when) {
249: if (isset($flags[$when])) {
250: foreach ((array) $flags[$when] as $hook) {
251: $this->currentHooks[] = [$when, $this->getHook($hook)];
252: }
253: }
254: }
255:
256: if (isset($flags['prefix'])) {
257: $this->currentPrefix .= '/' . trim($flags['prefix'], '/');
258: }
259:
260: $callback($this);
261:
262: $this->currentPrefix = $oldPrefix;
263: $this->currentHooks = $oldHooks;
264: }
265:
266: protected function getHook($name)
267: {
268: if (!isset($this->hooks[$name])) {
269: throw new \InvalidArgumentException("Hook with name $name is not defined");
270: }
271:
272: return $this->hooks[$name];
273: }
274:
275: 276: 277:
278: public function addRoute($methods, $path, $controller, $name = null, array $options = [])
279: {
280: if ($this->isCaching()) {
281: return null;
282: }
283:
284: $methods = (array) $methods;
285: $path = $this->makePath($path);
286:
287: $route = $this->createRoute($methods, $path, $controller, $name, $options);
288: $this->routes->attach($route);
289:
290: if ($name) {
291: $this->addNamedRoute($name, $route);
292: }
293:
294: $this->routeCollector->addRoute($route->getMethods(), $path, $route);
295:
296: return $route;
297: }
298:
299: 300: 301: 302: 303: 304: 305:
306: public function addCachedRoute(Route $route)
307: {
308: $this->routes->attach($route);
309: if ($name = $route->getName()) {
310: $this->addNamedRoute($name, $route);
311: }
312: }
313:
314: protected function makePath($path)
315: {
316: if ($this->currentPrefix !== null) {
317: $path = rtrim($this->currentPrefix, '/') .'/'. ltrim($path, '/');
318: }
319:
320: if (substr($path, 0, 1) !== '/') {
321: $path = '/' . $path;
322: }
323:
324: if ($path == '/') {
325: return $path;
326: }
327:
328: return rtrim($path, '/');
329: }
330:
331: protected function addNamedRoute($name, Route $route)
332: {
333: if (isset($this->namedRoutes[$name])) {
334: throw new \InvalidArgumentException("Route with name $name already exists");
335: }
336:
337: $this->namedRoutes[$name] = $route;
338: }
339:
340: protected function createRoute($methods, $path, $controller, $name, array $options)
341: {
342: $route = new Route($methods, $path, $controller, $name, $options);
343:
344: foreach ($this->currentHooks as $hook) {
345: $route->addHook($hook[0], $hook[1]);
346: }
347:
348: return $route;
349: }
350:
351: 352: 353:
354: public function getRoute($name)
355: {
356: if (!isset($this->namedRoutes[$name])) {
357: throw new \InvalidArgumentException("Route with name $name not found.");
358: }
359:
360: return $this->namedRoutes[$name];
361: }
362:
363: 364: 365:
366: public function dispatch(Request $request)
367: {
368: $route = $this->getRouteForRequest($request);
369:
370: return $this->getResponse($request, $route, $route->getParams());
371: }
372:
373: 374: 375: 376: 377: 378: 379: 380: 381: 382:
383: public function getRouteForRequest(Request $request)
384: {
385: $method = $request->getMethod();
386: $path = $request->getPathInfo() ?: '/';
387:
388: $result = $this->getDispatcher()
389: ->dispatch($method, $path);
390:
391: if ($result[0] == \FastRoute\Dispatcher::NOT_FOUND) {
392: throw new NotFoundHttpException("No route match for path $path");
393: } else if ($result[0] == \FastRoute\Dispatcher::METHOD_NOT_ALLOWED) {
394: throw new MethodNotAllowedHttpException($result[1],
395: "Method $method not allowed for path $path");
396: } else if ($result[0] !== \FastRoute\Dispatcher::FOUND) {
397: throw new \RuntimeException('Unknown result from FastRoute: '.$result[0]);
398: }
399:
400: return $this->matchRoute($result[1], $result[2], $request);
401: }
402:
403: protected function matchRoute(Route $route, array $params, Request $request)
404: {
405: $route->setParams($params);
406:
407: if ($this->eventDispatcher !== null) {
408: $event = new Events\RouteMatchedEvent($request, $route);
409: $this->eventDispatcher->dispatch('route.match', $event);
410: $route = $event->getRoute();
411: }
412:
413: return $route;
414: }
415:
416: protected function getResponse(Request $request, Route $route, array $params)
417: {
418:
419: $params = $this->getContainerParams($route, $params, $request);
420:
421: $this->currentRoute = $route;
422:
423: if ($this->eventDispatcher !== null) {
424: $event = new Events\BeforeEvent($request, $route);
425: $this->eventDispatcher->dispatch("route.before", $event);
426:
427: foreach ($route->getBeforeHooks() as $hook) {
428: $this->eventDispatcher->dispatch("route.before.$hook", $event);
429: }
430: }
431:
432:
433:
434:
435:
436: if (isset($event)) {
437: $response = $event->getResponse();
438: if (!$response) {
439: $callable = $event->getController() ?: $route->getController();
440: }
441: } else {
442: $callable = $route->getController();
443: }
444:
445:
446:
447: if (!isset($response) || !$response) {
448: $constructorArgs = $route->getOption('constructor_params');
449: $response = $this->invoker->invoke($callable, $params, $constructorArgs);
450: }
451:
452: if (!$response instanceof Response) {
453: if (is_array($response) || $response instanceof \stdClass) {
454: $response = new JsonResponse($response);
455: } else {
456: $response = new Response($response);
457: }
458: }
459:
460: if ($this->eventDispatcher !== null) {
461: $event = new Events\AfterEvent($request, $route, $response);
462: $this->eventDispatcher->dispatch("route.after", $event);
463:
464: foreach ($route->getAfterHooks() as $hook) {
465: $this->eventDispatcher->dispatch("route.after.$hook", $event);
466: }
467:
468: if ($event->getResponse() !== $response) {
469: $response = $event->getResponse();
470: }
471: }
472:
473: $this->currentRoute = null;
474:
475: return $response;
476: }
477:
478: protected function getContainerParams(Route $route, array $routeParams, Request $request)
479: {
480: $params = [];
481:
482: if ($extraParams = $route->getOption('params')) {
483: $params = $extraParams;
484: }
485:
486:
487:
488: foreach ($routeParams as $key => $value) {
489: $params["\$$key"] = $value;
490: }
491:
492:
493:
494: $params['Symfony\Component\HttpFoundation\Request'] = $request;
495:
496: return $params;
497: }
498:
499: protected function getDispatcher()
500: {
501: if ($this->dispatchData !== null) {
502: $dispatchData = $this->dispatchData;
503: } else if ($this->routeCollector !== null) {
504: $dispatchData = $this->generateDispatchData();
505: } else {
506: throw new \RuntimeException('No dispatch data or route collector set');
507: }
508:
509: return new Dispatcher($dispatchData);
510: }
511:
512: protected function generateDispatchData()
513: {
514: $data = $this->routeCollector->getData();
515:
516: if ($this->cachePath !== null) {
517: $filesys = new LockingFilesystem;
518: $php = '<?php return '.var_export($data, true).";\n";
519: $filesys->write($this->cachePath, $php);
520: }
521:
522: return $data;
523: }
524: }
525: