Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
62 / 62
100.00% covered (success)
100.00%
9 / 9
CRAP
100.00% covered (success)
100.00%
1 / 1
Router
100.00% covered (success)
100.00%
62 / 62
100.00% covered (success)
100.00%
9 / 9
29
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 currentRoute
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 currentSegment
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 addPrefixVariable
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 matchCurrentRoute
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
7
 match
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
6
 url
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
7
 add
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 routes
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3namespace 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 */
17class 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 &amp; 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 '\&amp;' 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}