Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
98.36% covered (success)
98.36%
60 / 61
90.00% covered (success)
90.00%
9 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
Router
98.36% covered (success)
98.36%
60 / 61
90.00% covered (success)
90.00%
9 / 10
29
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
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
1
 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
 prefixVariables
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3namespace Dynart\Micro;
4
5class Router implements RouterInterface
6{
7    /**
8     * Constant used for the case when no route found
9     */
10    const ROUTE_NOT_FOUND = [null, null];
11
12    const CONFIG_INDEX_FILE = 'router.index_file';
13    const CONFIG_ROUTE_PARAMETER = 'router.route_parameter';
14    const CONFIG_USE_REWRITE = 'router.use_rewrite';
15
16    const DEFAULT_INDEX_FILE = 'index.php';
17    const DEFAULT_ROUTE_PARAMETER = 'route';
18    const DEFAULT_USE_REWRITE = false;
19
20    /**
21     * Stores all of the routes in ['HTTP method' => ['/route' => callable]] format
22     *
23     * Callables: https://www.php.net/manual/en/language.types.callable.php
24     * But you can use the [ExampleClass::class, 'exampleMethod'] format too.
25     */
26    protected array $routes = [];
27
28    /**
29     * Stores callables for the start segments of all the routes. For example the locale is a
30     * prefix variable, because it is always in every route and the value depends on the incoming
31     * request.
32     *
33     * In the route '/en/books' the '/en' is a prefix variable and the '/books' is the stored route
34     */
35    protected array $prefixVariables = [];
36
37    /**
38     * All segments of the current route
39     *
40     * For example if the route is '/en/book/123/save'
41     * this will have the following value: ['en', 'book', '123', 'save']
42     */
43    protected array $segments = [];
44
45    /**
46     * Fill up the `$segments` array
47     */
48    public function __construct(protected ConfigInterface $config, protected RequestInterface $request) {
49        $this->segments = explode('/', $this->currentRoute());
50        array_shift($this->segments);
51    }
52
53    public function currentRoute(): string {
54        $routeParameter = $this->config->get(self::CONFIG_ROUTE_PARAMETER, self::DEFAULT_ROUTE_PARAMETER);
55        return $this->request->get($routeParameter, '/');
56    }
57
58    public function currentSegment(int $index, mixed $default = null): mixed {
59        return $this->segments[$index] ?? $default;
60    }
61
62    public function addPrefixVariable(callable|array $callable): int {
63        $this->prefixVariables[] = $callable;
64        return count($this->prefixVariables) - 1;
65    }
66
67    public function matchCurrentRoute(): array {
68        $method = $this->request->httpMethod();
69        $routes = array_key_exists($method, $this->routes) ? $this->routes[$method] : [];
70        $segments = $this->segments;
71        foreach ($this->prefixVariables as $prefixVariable) { // remove prefix variables from the segments
72            array_shift($segments);
73        }
74        $segmentsCount = count($segments);
75        if (!$segmentsCount && isset($this->routes[$method]['/'])) { // if no segments and having home route
76            return [$this->routes[$method]['/'], []]; // return with that
77        }
78        $found = self::ROUTE_NOT_FOUND;
79        foreach ($routes as $route => $callable) {
80            $found = $this->match($route, $callable, $segments, $segmentsCount);
81            if ($found[0]) {
82                break;
83            }
84        }
85        return $found;
86    }
87
88    /**
89     * Matches a route and returns with the given callable
90     *
91     * The `$callable` can be in a [Example::class, 'exampleMethod'] format as well,
92     * so you don't have to create an instance only when this callable is used.
93     */
94    protected function match(string $route, callable|array $callable, array $currentParts, int $currentPartsCount): array {
95        $parts = explode('/', $route);
96        array_shift($parts);
97        if (count($parts) != $currentPartsCount) {
98            return self::ROUTE_NOT_FOUND;
99        }
100        $found = true;
101        $params = [];
102        foreach ($parts as $i => $part) {
103            if ($part == $currentParts[$i]) {
104                continue;
105            }
106            if ($part == '?') {
107                $params[] = $currentParts[$i];
108                continue;
109            }
110            $found = false;
111            break;
112        }
113        if ($found) {
114            return [$callable, $params];
115        }
116        return self::ROUTE_NOT_FOUND;
117    }
118
119    public function url(?string $route = null, array $params = [], string $amp = '&'): string {
120        $prefix = '';
121        foreach ($this->prefixVariables as $callable) {
122            $prefix .= '/'.call_user_func($callable);
123        }
124        $result = $this->config->get(AbstractApp::CONFIG_BASE_URL);
125        $useRewrite = $this->config->get(self::CONFIG_USE_REWRITE, self::DEFAULT_USE_REWRITE);
126        if ($useRewrite) {
127            $result .= $route == null ? '' : $prefix.$route;
128        } else {
129            $indexFile = $this->config->get(self::CONFIG_INDEX_FILE, self::DEFAULT_INDEX_FILE);
130            $result .= '/'.$indexFile;
131            if ($route && $route != '/') {
132                $routeParameter = $this->config->get(self::CONFIG_ROUTE_PARAMETER, self::DEFAULT_ROUTE_PARAMETER);
133                $params[$routeParameter] = $prefix.$route;
134            }
135        }
136        if ($params) {
137            $result .= '?'.http_build_query($params, '', $amp);
138        }
139        return str_replace('%2F', '/', $result);
140    }
141
142    public function add(string $route, callable|array $callable, string $method = 'GET'): void {
143        if ($method == 'BOTH') {
144            $this->add($route, $callable); // GET
145            $this->add($route, $callable, 'POST');
146        } else {
147            if (!array_key_exists($method, $this->routes)) {
148                $this->routes[$method] = [];
149            }
150            $this->routes[$method][$route] = $callable;
151        }
152    }
153
154    public function routes(): array {
155        return $this->routes;
156    }
157
158    public function prefixVariables(): array {
159        return $this->prefixVariables;
160    }
161}