Passed
Push — main ( 8347d6...d9426f )
by Thomas
02:39
created

Router::routes()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 6
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 4
CRAP Score 1

Importance

Changes 0
Metric Value
eloc 3
dl 0
loc 6
ccs 4
cts 4
cp 1
rs 10
c 0
b 0
f 0
cc 1
nc 1
nop 3
crap 1
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Conia\Chuck\Routing;
6
7
use Closure;
8
use Conia\Chuck\Dispatcher;
9
use Conia\Chuck\Exception\HttpMethodNotAllowed;
10
use Conia\Chuck\Exception\HttpNotFound;
11
use Conia\Chuck\Exception\RuntimeException;
12
use Conia\Chuck\MiddlewareInterface;
13
use Conia\Chuck\MiddlewareWrapper;
14
use Conia\Chuck\Registry\Registry;
15
use Conia\Chuck\Registry\Resolver;
16
use Conia\Chuck\Request;
17
use Conia\Chuck\Response;
18
use Conia\Chuck\View;
19
use Conia\Chuck\ViewHandler;
20
use Psr\Http\Server\MiddlewareInterface as PsrMiddlewareInterface;
21
use Throwable;
22
23
class Router implements RouteAdderInterface
24
{
25
    use AddsRoutes;
26
    use AddsMiddleware;
27
28
    protected const ALL = 'ALL';
29
30
    protected string $cacheFile = '';
31
    protected bool $shouldCache = false;
32
    protected ?Route $route = null;
33
34
    /** @psalm-var array<string, list<Route>> */
35
    protected array $routes = [];
36
37
    /** @var array<string, StaticRoute> */
38
    protected array $staticRoutes = [];
39
40
    /** @var array<string, Route> */
41
    protected array $names = [];
42
43 3
    public function getRoute(): Route
44
    {
45 3
        if (is_null($this->route)) {
46 1
            throw new RuntimeException('Route is not initialized');
47
        }
48
49 2
        return $this->route;
50
    }
51
52
    /** @psalm-param Closure(Router $router):void $creator */
53 2
    public function routes(Closure $creator, string $cacheFile = '', bool $shouldCache = true): void
54
    {
55 2
        $this->cacheFile = $cacheFile;
56 2
        $this->shouldCache = $shouldCache;
57
58 2
        $creator($this);
59
    }
60
61 72
    public function addRoute(Route $route): Route
62
    {
63 72
        $name = $route->name();
64 72
        $noMethodGiven = true;
65
66 72
        foreach ($route->methods() as $method) {
67 42
            $noMethodGiven = false;
68 42
            $this->routes[$method][] = $route;
69
        }
70
71 72
        if ($noMethodGiven) {
72 37
            $this->routes[self::ALL][] = $route;
73
        }
74
75 72
        if ($name) {
76 23
            if (array_key_exists($name, $this->names)) {
77 1
                throw new RuntimeException(
78 1
                    'Duplicate route: ' . $name . '. If you want to use the same ' .
79 1
                        'url pattern with different methods, you have to create routes with names.'
80 1
                );
81
            }
82
83 23
            $this->names[$name] = $route;
84
        }
85
86 72
        return $route;
87
    }
88
89 3
    public function addGroup(Group $group): void
90
    {
91 3
        $group->create($this);
92
    }
93
94 6
    public function addStatic(
95
        string $prefix,
96
        string $dir,
97
        string $name = '',
98
    ): void {
99 6
        if (empty($name)) {
100 4
            $name = $prefix;
101
        }
102
103 6
        if (array_key_exists($name, $this->staticRoutes)) {
104 2
            throw new RuntimeException(
105 2
                'Duplicate static route: ' . $name . '. If you want to use the same ' .
106 2
                    'url prefix you have to create static routes with names.'
107 2
            );
108
        }
109
110 6
        if (is_dir($dir)) {
111 5
            $this->staticRoutes[$name] = new StaticRoute(
112 5
                prefix: '/' . trim($prefix, '/') . '/',
113 5
                dir: $dir,
114 5
            );
115
        } else {
116 1
            throw new RuntimeException("The static directory does not exist: {$dir}");
117
        }
118
    }
119
120 3
    public function staticUrl(
121
        string $name,
122
        string $path,
123
        bool $bust = false,
124
        ?string $host = null
125
    ): string {
126 3
        $route = $this->staticRoutes[$name];
127
128 3
        if ($bust) {
129
            // Check if there is already a query parameter present
130 1
            if (strpos($path, '?')) {
131 1
                $file = strtok($path, '?');
132 1
                $sep = '&';
133
            } else {
134 1
                $file = $path;
135 1
                $sep = '?';
136
            }
137
138 1
            $buster = $this->getCacheBuster($route->dir, $file);
139
140 1
            if (!empty($buster)) {
141 1
                $path .= $sep . 'v=' . $buster;
142
            }
143
        }
144
145 3
        return ($host ? trim($host, '/') : '') . $route->prefix . trim($path, '/');
146
    }
147
148 14
    public function routeUrl(string $__routeName__, mixed ...$args): string
149
    {
150 14
        $route = $this->names[$__routeName__] ?? null;
151
152 14
        if ($route) {
153 13
            return $route->url(...$args);
154
        }
155
156 1
        throw new RuntimeException('Route not found: ' . $__routeName__);
157
    }
158
159 59
    public function match(Request $request): Route
160
    {
161 59
        $url = $request->uri()->getPath();
162 59
        $requestMethod = $request->method();
163
164 59
        foreach ([$requestMethod, self::ALL] as $method) {
165 59
            foreach ($this->routes[$method] ?? [] as $route) {
166 58
                if ($route->match($url)) {
167 57
                    return $route;
168
                }
169
            }
170
        }
171
172
        // We know now, that the route does not match.
173
        // Check if it would match one of the remaining methods
174 9
        $wrongMethod = false;
175 9
        $remainingMethods = array_keys($this->routes);
176
177 9
        foreach ([$requestMethod, self::ALL] as $method) {
178 9
            if (($key = array_search($method, $remainingMethods)) !== false) {
179 4
                unset($remainingMethods[$key]);
180
            }
181
        }
182
183 9
        foreach ($remainingMethods as $method) {
184 7
            foreach ($this->routes[$method] as $route) {
185 7
                if ($route->match($url)) {
186 7
                    $wrongMethod = true;
187
188 7
                    break;
189
                }
190
            }
191
        }
192
193 9
        if ($wrongMethod) {
194 7
            throw new HttpMethodNotAllowed();
195
        }
196
197 2
        throw new HttpNotFound();
198
    }
199
200
    /**
201
     * Looks up the matching route and generates the response.
202
     */
203 27
    public function dispatch(Request $request, Registry $registry): Response
204
    {
205 27
        $this->route = $this->match($request);
206
207 26
        $view = new View($this->route->view(), $this->route->args(), $registry);
208 24
        $queue = $this->collectMiddleware($view, $registry);
209 23
        $queue[] = new ViewHandler($view, $registry, $this->route);
210
211 23
        return (new Dispatcher($queue, $registry))->dispatch($request);
212
    }
213
214 1
    protected function getCacheBuster(string $dir, string $path): string
215
    {
216 1
        $ds = DIRECTORY_SEPARATOR;
217 1
        $file = $dir . $ds . ltrim(str_replace('/', $ds, $path), $ds);
218
219
        try {
220 1
            return hash('xxh32', (string)filemtime($file));
221 1
        } catch (Throwable) {
222 1
            return '';
223
        }
224
    }
225
226 24
    protected function collectMiddleware(View $view, Registry $registry): array
227
    {
228 24
        $middlewareAttributes = $view->attributes(MiddlewareInterface::class);
229
230 24
        return array_map(
231 24
            function (
232 24
                MiddlewareInterface|PsrMiddlewareInterface|callable|string $middleware
233 24
            ) use ($registry): MiddlewareInterface|PsrMiddlewareInterface {
234
                if (
235 6
                    ($middleware instanceof MiddlewareInterface)
236 6
                    || ($middleware instanceof PsrMiddlewareInterface)
237
                ) {
238 2
                    return $middleware;
239
                }
240
241 6
                if (is_string($middleware) && class_exists($middleware)) {
242 1
                    $object = (new Resolver($registry))->autowire($middleware);
243 1
                    assert($object instanceof MiddlewareInterface || $object instanceof PsrMiddlewareInterface);
244
245 1
                    return $object;
246
                }
247
248 5
                if (is_callable($middleware)) {
249
                    /** @psalm-var callable(Request, callable):Response $middleware */
250 4
                    return new MiddlewareWrapper($middleware);
251
                }
252
253 1
                throw new RuntimeException('Invalid middleware: ' .
254
                    /** @scrutinizer ignore-type */
255 1
                    print_r($middleware, true));
256 24
            },
257 24
            array_merge(
258 24
                $this->middleware,
259 24
                $this->route ? $this->route->getMiddleware() : [],
260 24
                $middlewareAttributes,
261 24
            )
262 24
        );
263
    }
264
}
265