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 | } |