Issues (8)

src/Router.php (2 issues)

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