Code Coverage
 
Lines
Branches
Paths
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
57 / 57
93.65% covered (success)
93.65%
59 / 63
50.68% covered (warning)
50.68%
37 / 73
92.00% covered (success)
92.00%
23 / 25
CRAP
0.00% covered (danger)
0.00%
0 / 1
Route
100.00% covered (success)
100.00%
57 / 57
93.65% covered (success)
93.65%
59 / 63
50.68% covered (warning)
50.68%
37 / 73
100.00% covered (success)
100.00%
25 / 25
211.18
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 any
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 map
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 get
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 post
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 put
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 patch
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 delete
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 head
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 options
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 withMethods
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 methods
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 prefix
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
5 / 5
75.00% covered (warning)
75.00%
3 / 4
100.00% covered (success)
100.00%
1 / 1
3.14
 controller
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 replaceMiddleware
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setBeforeHandlers
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setAfterHandlers
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 name
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 url
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 view
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 pattern
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 match
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 routePattern
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 pathWithoutPrefix
100.00% covered (success)
100.00%
12 / 12
85.00% covered (warning)
85.00%
17 / 20
15.00% covered (danger)
15.00%
6 / 40
100.00% covered (success)
100.00%
1 / 1
37.09
 normalizePrefix
100.00% covered (success)
100.00%
1 / 1
85.71% covered (warning)
85.71%
6 / 7
66.67% covered (warning)
66.67%
2 / 3
100.00% covered (success)
100.00%
1 / 1
2.15
1<?php
2
3declare(strict_types=1);
4
5namespace Celema\Router;
6
7use Celema\Router\Exception\ValueError;
8use Closure;
9use Psr\Http\Server\MiddlewareInterface as Middleware;
10
11/** @psalm-api */
12class Route
13{
14    use AddsBeforeAfter;
15    use AddsMiddleware;
16
17    /** @var null|list<string> */
18    protected ?array $methods = null;
19
20    /** @var Closure|list{string, string}|string */
21    protected Closure|array|string $view;
22
23    protected ?RoutePattern $routePattern = null;
24
25    /**
26     * @param string $pattern The URL pattern of the route
27     *
28     * @param callable|list{string, string}|non-empty-string $view The callable view. Can be a closure, an invokable object or any other callable
29     *
30     * @param string $name The name of the route, used to generate its URL. Routes without a name cannot be looked up.
31     */
32    public function __construct(
33        protected string $pattern,
34        callable|array|string $view,
35        protected string $name = '',
36    ) {
37        if (is_callable($view)) {
38            $this->view = Closure::fromCallable($view);
39        } else {
40            $this->view = $view;
41        }
42    }
43
44    /** @param callable|list{string, string}|non-empty-string $view */
45    public static function any(string $pattern, callable|array|string $view, string $name = ''): self
46    {
47        return new self($pattern, $view, $name);
48    }
49
50    /**
51     * @param array<array-key, string> $methods
52     * @param callable|list{string, string}|non-empty-string $view
53     */
54    public static function map(
55        array $methods,
56        string $pattern,
57        callable|array|string $view,
58        string $name = '',
59    ): self {
60        if ($methods === []) {
61            throw new ValueError('Route method list cannot be empty.');
62        }
63
64        return new self($pattern, $view, $name)->withMethods(array_values($methods));
65    }
66
67    /** @param callable|list{string, string}|non-empty-string $view */
68    public static function get(string $pattern, callable|array|string $view, string $name = ''): self
69    {
70        return new self($pattern, $view, $name)->withMethods(['GET']);
71    }
72
73    /** @param callable|list{string, string}|non-empty-string $view */
74    public static function post(string $pattern, callable|array|string $view, string $name = ''): self
75    {
76        return new self($pattern, $view, $name)->withMethods(['POST']);
77    }
78
79    /** @param callable|list{string, string}|non-empty-string $view */
80    public static function put(string $pattern, callable|array|string $view, string $name = ''): self
81    {
82        return new self($pattern, $view, $name)->withMethods(['PUT']);
83    }
84
85    /** @param callable|list{string, string}|non-empty-string $view */
86    public static function patch(string $pattern, callable|array|string $view, string $name = ''): self
87    {
88        return new self($pattern, $view, $name)->withMethods(['PATCH']);
89    }
90
91    /** @param callable|list{string, string}|non-empty-string $view */
92    public static function delete(
93        string $pattern,
94        callable|array|string $view,
95        string $name = '',
96    ): self {
97        return new self($pattern, $view, $name)->withMethods(['DELETE']);
98    }
99
100    /** @param callable|list{string, string}|non-empty-string $view */
101    public static function head(string $pattern, callable|array|string $view, string $name = ''): self
102    {
103        return new self($pattern, $view, $name)->withMethods(['HEAD']);
104    }
105
106    /** @param callable|list{string, string}|non-empty-string $view */
107    public static function options(
108        string $pattern,
109        callable|array|string $view,
110        string $name = '',
111    ): self {
112        return new self($pattern, $view, $name)->withMethods(['OPTIONS']);
113    }
114
115    /** @param list<string> $methods */
116    private function withMethods(array $methods): self
117    {
118        $this->methods = array_map(static fn(string $method): string => strtoupper($method), $methods);
119
120        return $this;
121    }
122
123    /** @return list<string> */
124    public function methods(): array
125    {
126        return $this->methods ?? [];
127    }
128
129    /** @internal */
130    public function prefix(string $pattern = '', string $name = ''): static
131    {
132        if ($pattern !== '') {
133            $this->pattern = $pattern . $this->pattern;
134            $this->routePattern = null;
135        }
136
137        if ($name !== '') {
138            $this->name = $name . $this->name;
139        }
140
141        return $this;
142    }
143
144    /** @internal */
145    public function controller(string $controller): static
146    {
147        if (is_string($this->view)) {
148            $this->view = [$controller, $this->view];
149
150            return $this;
151        }
152
153        throw new ValueError(
154            'Cannot add controller to route action. Controller groups require bare method names.',
155        );
156    }
157
158    /**
159     * @internal
160     * @param list<Middleware> $middleware
161     */
162    public function replaceMiddleware(array $middleware): static
163    {
164        $this->middleware = $middleware;
165
166        return $this;
167    }
168
169    /**
170     * @internal
171     * @param list<Before> $beforeHandlers
172     */
173    public function setBeforeHandlers(array $beforeHandlers): static
174    {
175        $this->beforeHandlers = $beforeHandlers;
176
177        return $this;
178    }
179
180    /**
181     * @internal
182     * @param list<After> $afterHandlers
183     */
184    public function setAfterHandlers(array $afterHandlers): static
185    {
186        $this->afterHandlers = $afterHandlers;
187
188        return $this;
189    }
190
191    public function name(): string
192    {
193        return $this->name;
194    }
195
196    /** @param array<array-key, mixed> $params */
197    public function url(array $params = []): string
198    {
199        return $this->routePattern()->generate($params);
200    }
201
202    /** @return Closure|list{string, string}|string */
203    public function view(): Closure|array|string
204    {
205        return $this->view;
206    }
207
208    public function pattern(): string
209    {
210        return $this->pattern;
211    }
212
213    /** @return null|array<string, string> */
214    public function match(string $url, string $prefix = ''): ?array
215    {
216        $path = $this->pathWithoutPrefix($url, $prefix);
217
218        if ($path === null) {
219            return null;
220        }
221
222        return $this->routePattern()->match($path);
223    }
224
225    private function routePattern(): RoutePattern
226    {
227        return $this->routePattern ??= new RoutePattern($this->pattern);
228    }
229
230    private function pathWithoutPrefix(string $url, string $prefix): ?string
231    {
232        $path = $url === '' ? '/' : $url;
233        $prefix = self::normalizePrefix($prefix);
234
235        if ($prefix === '') {
236            return $path;
237        }
238
239        if ($path === $prefix) {
240            return '/';
241        }
242
243        if (!str_starts_with($path, $prefix . '/')) {
244            return null;
245        }
246
247        $path = substr($path, strlen($prefix));
248
249        if ($path === '/' && $this->routePattern()->pattern() === '/') {
250            return null;
251        }
252
253        return $path;
254    }
255
256    private static function normalizePrefix(string $prefix): string
257    {
258        return $prefix === '' ? '' : '/' . trim($prefix, '/');
259    }
260}