Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
100.00% |
62 / 62 |
|
100.00% |
9 / 9 |
CRAP | |
100.00% |
1 / 1 |
| Router | |
100.00% |
62 / 62 |
|
100.00% |
9 / 9 |
29 | |
100.00% |
1 / 1 |
| __construct | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
| currentRoute | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| currentSegment | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
2 | |||
| addPrefixVariable | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| matchCurrentRoute | |
100.00% |
14 / 14 |
|
100.00% |
1 / 1 |
7 | |||
| match | |
100.00% |
17 / 17 |
|
100.00% |
1 / 1 |
6 | |||
| url | |
100.00% |
15 / 15 |
|
100.00% |
1 / 1 |
7 | |||
| add | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
3 | |||
| routes | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| 1 | <?php |
| 2 | |
| 3 | namespace Dynart\Micro; |
| 4 | |
| 5 | /** |
| 6 | * Handles the routing |
| 7 | * |
| 8 | * Every web application needs a router for handling routes. A route is a HTTP method and path for a specific action. |
| 9 | * |
| 10 | * Example routes: |
| 11 | * * GET /books - returns a list of books |
| 12 | * * GET /books/123 - returns the details of a book with ID 123 |
| 13 | * * POST /books/123/save - saves the details of a book with ID 123 |
| 14 | * |
| 15 | * @package Dynart\Micro |
| 16 | */ |
| 17 | class Router |
| 18 | { |
| 19 | /** |
| 20 | * Constant used for the case when no route found |
| 21 | */ |
| 22 | const ROUTE_NOT_FOUND = [null, null]; |
| 23 | |
| 24 | const CONFIG_INDEX_FILE = 'router.index_file'; |
| 25 | const CONFIG_ROUTE_PARAMETER = 'router.route_parameter'; |
| 26 | const CONFIG_USE_REWRITE = 'router.use_rewrite'; |
| 27 | |
| 28 | const DEFAULT_INDEX_FILE = 'index.php'; |
| 29 | const DEFAULT_ROUTE_PARAMETER = 'route'; |
| 30 | const DEFAULT_USE_REWRITE = false; |
| 31 | |
| 32 | /** |
| 33 | * Stores all of the routes in ['HTTP method' => ['/route' => callable]] format |
| 34 | * |
| 35 | * Callables: https://www.php.net/manual/en/language.types.callable.php |
| 36 | * But you can use the [ExampleClass::class, 'exampleMethod'] format too. |
| 37 | * |
| 38 | * @var array |
| 39 | */ |
| 40 | protected $routes = []; |
| 41 | |
| 42 | /** |
| 43 | * Stores callables for the start segments of all of the routes. For example the locale is a |
| 44 | * prefix variable, because it is always in every route and the value depends on the incoming |
| 45 | * request. |
| 46 | * |
| 47 | * In the route '/en/books' the '/en' is a prefix variable and the '/books' is the stored route |
| 48 | * |
| 49 | * @var array |
| 50 | */ |
| 51 | protected $prefixVariables = []; |
| 52 | |
| 53 | /** |
| 54 | * All segments of the current route |
| 55 | * |
| 56 | * For example if the route is '/en/book/123/save' |
| 57 | * this will has the following value: ['en', 'book', '123', 'save'] |
| 58 | * |
| 59 | * @var array |
| 60 | */ |
| 61 | protected $segments = []; |
| 62 | |
| 63 | /** @var Config */ |
| 64 | protected $config; |
| 65 | |
| 66 | /** @var Request */ |
| 67 | protected $request; |
| 68 | |
| 69 | /** |
| 70 | * It will fill up the `$segments` array |
| 71 | * |
| 72 | * @param Config $config |
| 73 | * @param Request $request |
| 74 | */ |
| 75 | public function __construct(Config $config, Request $request) { |
| 76 | $this->config = $config; |
| 77 | $this->request = $request; |
| 78 | $this->segments = explode('/', $this->currentRoute()); |
| 79 | array_shift($this->segments); |
| 80 | } |
| 81 | |
| 82 | /** |
| 83 | * Returns with the current route |
| 84 | * |
| 85 | * The route HTTP query parameter name can be configured with the `router.route_parameter`. |
| 86 | * If no parameter exists the default will be the home route '/' |
| 87 | * |
| 88 | * @see Config |
| 89 | * @return string |
| 90 | */ |
| 91 | public function currentRoute(): string { |
| 92 | $routeParameter = $this->config->get(self::CONFIG_ROUTE_PARAMETER, self::DEFAULT_ROUTE_PARAMETER); |
| 93 | return $this->request->get($routeParameter, '/'); |
| 94 | } |
| 95 | |
| 96 | /** |
| 97 | * Returns with a segment value of the current route by index |
| 98 | * |
| 99 | * @param int $index The index of the segment |
| 100 | * @param mixed|null $default The default value if the segment doesn't exist |
| 101 | * @return mixed|null The value of the segment by index |
| 102 | */ |
| 103 | public function currentSegment(int $index, $default = null) { |
| 104 | return isset($this->segments[$index]) ? $this->segments[$index] : $default; |
| 105 | } |
| 106 | |
| 107 | /** |
| 108 | * Adds a prefix variable callable for all of the routes |
| 109 | * |
| 110 | * For example, if you have a prefix variable that calls the `Translation::locale()` method |
| 111 | * and then you call the `url` method with '/something' and you configured the `app.base_url` |
| 112 | * to 'https://example.com' it will return with |
| 113 | * |
| 114 | * <pre> |
| 115 | * https://example.com/en/something |
| 116 | * </pre> |
| 117 | * |
| 118 | * @link https://www.php.net/manual/en/language.types.callable.php |
| 119 | * |
| 120 | * @param $callable |
| 121 | * @return int The segment index of the newly added prefix variable |
| 122 | */ |
| 123 | public function addPrefixVariable($callable): int { |
| 124 | $this->prefixVariables[] = $callable; |
| 125 | return count($this->prefixVariables) - 1; |
| 126 | } |
| 127 | |
| 128 | /** |
| 129 | * Matches the current route and returns with the associated callable and parameters |
| 130 | * |
| 131 | * For example, the current route is '/books/123/comments/45', |
| 132 | * the stored route is '/books/?/comments/?' and it matches |
| 133 | * then the result will be: |
| 134 | * |
| 135 | * <p>[the stored callable for the route, ['123', '45']]</p> |
| 136 | * |
| 137 | * If no match for the current route it will return with `ROUTE_NOT_FOUND` alias [null, null] |
| 138 | * |
| 139 | * @return array The associated callable and parameters with the current route in [callable, [parameters]] format |
| 140 | */ |
| 141 | public function matchCurrentRoute(): array { |
| 142 | $method = $this->request->httpMethod(); |
| 143 | $routes = array_key_exists($method, $this->routes) ? $this->routes[$method] : []; |
| 144 | $segments = $this->segments; |
| 145 | foreach ($this->prefixVariables as $prefixVariable) { // remove prefix variables from the segments |
| 146 | array_shift($segments); |
| 147 | } |
| 148 | $segmentsCount = count($segments); |
| 149 | if (!$segmentsCount && isset($this->routes[$method]['/'])) { // if no segments and having home route |
| 150 | return [$this->routes[$method]['/'], []]; // return with that |
| 151 | } |
| 152 | $found = self::ROUTE_NOT_FOUND; |
| 153 | foreach ($routes as $route => $callable) { |
| 154 | $found = $this->match($route, $callable, $segments, $segmentsCount); |
| 155 | if ($found[0]) { |
| 156 | break; |
| 157 | } |
| 158 | } |
| 159 | return $found; |
| 160 | } |
| 161 | |
| 162 | /** |
| 163 | * Matches a route and returns with the given callable |
| 164 | * |
| 165 | * The `$callable` can be in a [Example::class, 'exampleMethod'] format as well, |
| 166 | * so you don't have to create an instance only when this callable is used. |
| 167 | * |
| 168 | * @param string $route The route we want to match |
| 169 | * @param callable $callable The callable we want to return if the route matches |
| 170 | * @param array $currentParts An array of the current route segments WITHOUT the prefix variables |
| 171 | * @param int $currentPartsCount The count of the `$currentParts` array |
| 172 | * @return array The callable and the fetched parameters in [callable, [parameters]] format |
| 173 | */ |
| 174 | protected function match(string $route, $callable, array $currentParts, int $currentPartsCount): array { |
| 175 | $parts = explode('/', $route); |
| 176 | array_shift($parts); |
| 177 | if (count($parts) != $currentPartsCount) { |
| 178 | return self::ROUTE_NOT_FOUND; |
| 179 | } |
| 180 | $found = true; |
| 181 | $params = []; |
| 182 | foreach ($parts as $i => $part) { |
| 183 | if ($part == $currentParts[$i]) { |
| 184 | continue; |
| 185 | } |
| 186 | if ($part == '?') { |
| 187 | $params[] = $currentParts[$i]; |
| 188 | continue; |
| 189 | } |
| 190 | $found = false; |
| 191 | break; |
| 192 | } |
| 193 | if ($found) { |
| 194 | return [$callable, $params]; |
| 195 | } |
| 196 | return self::ROUTE_NOT_FOUND; |
| 197 | } |
| 198 | |
| 199 | /** |
| 200 | * Returns with a URL for the given route |
| 201 | * |
| 202 | * Heavily depends on the configuration of the application. |
| 203 | |
| 204 | * For example the parameters are the following: |
| 205 | * * `$route` = '/books/123' |
| 206 | * * `$params` = ['name' => 'joe'] |
| 207 | * * `$amp` = '&' |
| 208 | * |
| 209 | * and the application config is the following: |
| 210 | * |
| 211 | * <pre> |
| 212 | * app.base_url = "http://example.com" |
| 213 | * router.use_rewrite = false |
| 214 | * router.index_file = "index.php" |
| 215 | * router.route_parameter = "route" |
| 216 | * </pre> |
| 217 | * |
| 218 | * then the result will be: |
| 219 | * |
| 220 | * <pre> |
| 221 | * http://example.com/index.php?route=/books/123&name=joe |
| 222 | * </pre> |
| 223 | * |
| 224 | * If the `router.use_rewrite` set to true, the `router.index_file` and the `router.route_parameter` will not be |
| 225 | * in the result, but for this you have to configure your webserver to redirect non existing |
| 226 | * file & directory HTTP queries to your /index.php?route={URI} |
| 227 | * |
| 228 | * <pre> |
| 229 | * http://example.com/books/123?name=joe |
| 230 | * </pre> |
| 231 | * |
| 232 | * If you have a prefix variable added (usually locale) and that has the value 'en', the result will be: |
| 233 | * |
| 234 | * <pre> |
| 235 | * http://example.com/en/books/123?name=joe |
| 236 | * </pre> |
| 237 | * |
| 238 | * @param string $route The route |
| 239 | * @param array $params The HTTP query parameters for the route |
| 240 | * @param string $amp The ampersand symbol. The default is '\&' but you can change it to '&' if needed. |
| 241 | * @return string The full URL for the route |
| 242 | */ |
| 243 | public function url($route, $params = [], $amp = '&'): string { |
| 244 | $prefix = ''; |
| 245 | foreach ($this->prefixVariables as $callable) { |
| 246 | $prefix .= '/'.call_user_func($callable); |
| 247 | } |
| 248 | $result = $this->config->get(App::CONFIG_BASE_URL); |
| 249 | $useRewrite = $this->config->get(self::CONFIG_USE_REWRITE, self::DEFAULT_USE_REWRITE); |
| 250 | if ($useRewrite) { |
| 251 | $result .= $route == null ? '' : $prefix.$route; |
| 252 | } else { |
| 253 | $indexFile = $this->config->get(self::CONFIG_INDEX_FILE, self::DEFAULT_INDEX_FILE); |
| 254 | $result .= '/'.$indexFile; |
| 255 | if ($route && $route != '/') { |
| 256 | $routeParameter = $this->config->get(self::CONFIG_ROUTE_PARAMETER, self::DEFAULT_ROUTE_PARAMETER); |
| 257 | $params[$routeParameter] = $prefix.$route; |
| 258 | } |
| 259 | } |
| 260 | if ($params) { |
| 261 | $result .= '?'.http_build_query($params, '', $amp); |
| 262 | } |
| 263 | return str_replace('%2F', '/', $result); |
| 264 | } |
| 265 | |
| 266 | /** |
| 267 | * Stores a route with a callable |
| 268 | * |
| 269 | * The `$callable` can use the [ExampleClass::class, 'exampleMethod'] format too. |
| 270 | * |
| 271 | * The route can have variables with question mark: '/route/?' |
| 272 | * then the callable method in this case must have one parameter! |
| 273 | * |
| 274 | * The `$method` can be any of the HTTP methods or BOTH. BOTH will add the route to the GET and to the POST as well. |
| 275 | * |
| 276 | * For example, the route is GET /books/?: |
| 277 | * |
| 278 | * <pre> |
| 279 | * $router->add('/books/?', [BooksController::class, 'view']); |
| 280 | * </pre> |
| 281 | * |
| 282 | * then the callable should look like |
| 283 | * |
| 284 | * <pre> |
| 285 | * class BooksController { |
| 286 | * function view($id) { |
| 287 | * } |
| 288 | * } |
| 289 | * </pre> |
| 290 | * |
| 291 | * @param string $route The route, for example: '/route' |
| 292 | * @param callable $callable The callable for the route |
| 293 | * @param string $method Any of HTTP methods or 'BOTH' |
| 294 | */ |
| 295 | public function add(string $route, $callable, $method = 'GET'): void { |
| 296 | if ($method == 'BOTH') { |
| 297 | $this->add($route, $callable, 'GET'); |
| 298 | $this->add($route, $callable, 'POST'); |
| 299 | } else { |
| 300 | if (!array_key_exists($method, $this->routes)) { |
| 301 | $this->routes[$method] = []; |
| 302 | } |
| 303 | $this->routes[$method][$route] = $callable; |
| 304 | } |
| 305 | } |
| 306 | |
| 307 | /** |
| 308 | * Returns with all of the stored routes |
| 309 | * |
| 310 | * The result format will be ['HTTP method' => ['/route' => callable]] |
| 311 | * |
| 312 | * @return array The routes |
| 313 | */ |
| 314 | public function routes() { |
| 315 | return $this->routes; |
| 316 | } |
| 317 | } |