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