Passed
Push — main ( a1a461...2097df )
by Thomas
12:50
created

Router::collectMiddleware()   B

Complexity

Conditions 8
Paths 1

Size

Total Lines 35
Code Lines 21

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 22
CRAP Score 8

Importance

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