Route::head()   A
last analyzed

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 2
CRAP Score 1

Importance

Changes 0
Metric Value
eloc 1
dl 0
loc 3
ccs 2
cts 2
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;
6
7
use Closure;
8
use Conia\Chuck\Exception\InvalidArgumentException;
9
use Conia\Chuck\Exception\ValueError;
10
use Conia\Chuck\Renderer\Config as RendererConfig;
11
use Conia\Chuck\Routing\AddsMiddleware;
12
use Stringable;
13
14 1
const LEFT_BRACE = '§§§€§§§';
15 1
const RIGHT_BRACE = '§§§£§§§';
16
17
/**
18
 * @psalm-api
19
 *
20
 * @psalm-type View = callable|list{string, string}|non-empty-string
21
 */
22
class Route
23
{
24
    use AddsMiddleware;
25
26
    protected array $args = [];
27
28
    /** @psalm-var null|list<string> */
29
    protected ?array $methods = null;
30
31
    protected ?RendererConfig $renderer = null;
32
    protected array $attributes = [];
33
34
    /** @psalm-var Closure|list{string, string}|string */
35
    protected Closure|array|string $view;
36
37
    /**
38
     * @param string $pattern The URL pattern of the route
39
     *
40
     * @psalm-param View $view The callable view. Can be a closure, an invokable object or any other callable
41
     *
42
     * @param string $name The name of the route. If not given the pattern will be hashed and used as name.
43
     */
44 111
    public function __construct(
45
        protected string $pattern,
46
        callable|array|string $view,
47
        protected string $name = '',
48
    ) {
49 111
        if (is_callable($view)) {
50 60
            $this->view = Closure::fromCallable($view);
51
        } else {
52 57
            $this->view = $view;
53
        }
54
    }
55
56
    /** @psalm-param View $view */
57 23
    public static function get(string $pattern, callable|array|string $view, string $name = ''): static
58
    {
59 23
        return (new self($pattern, $view, $name))->method('GET');
60
    }
61
62
    /** @psalm-param View $view */
63 5
    public static function post(string $pattern, callable|array|string $view, string $name = ''): static
64
    {
65 5
        return (new self($pattern, $view, $name))->method('POST');
66
    }
67
68
    /** @psalm-param View $view */
69 3
    public static function put(string $pattern, callable|array|string $view, string $name = ''): static
70
    {
71 3
        return (new self($pattern, $view, $name))->method('PUT');
72
    }
73
74
    /** @psalm-param View $view */
75 3
    public static function patch(string $pattern, callable|array|string $view, string $name = ''): static
76
    {
77 3
        return (new self($pattern, $view, $name))->method('PATCH');
78
    }
79
80
    /** @psalm-param View $view */
81 3
    public static function delete(string $pattern, callable|array|string $view, string $name = ''): static
82
    {
83 3
        return (new self($pattern, $view, $name))->method('DELETE');
84
    }
85
86
    /** @psalm-param View $view */
87 3
    public static function head(string $pattern, callable|array|string $view, string $name = ''): static
88
    {
89 3
        return (new self($pattern, $view, $name))->method('HEAD');
90
    }
91
92
    /** @psalm-param View $view */
93 3
    public static function options(string $pattern, callable|array|string $view, string $name = ''): static
94
    {
95 3
        return (new self($pattern, $view, $name))->method('OPTIONS');
96
    }
97
98
    /** @no-named-arguments */
99 49
    public function method(string ...$args): static
100
    {
101 49
        $this->methods = array_merge($this->methods ?? [], array_map(fn ($m) => strtoupper($m), $args));
102
103 49
        return $this;
104
    }
105
106
    /** @psalm-return list<string> */
107 74
    public function methods(): array
108
    {
109 74
        return $this->methods ?? [];
110
    }
111
112 15
    public function prefix(string $pattern = '', string $name = ''): static
113
    {
114 15
        if (!empty($pattern)) {
115 15
            $this->pattern = $pattern . $this->pattern;
116
        }
117
118 15
        if (!empty($name)) {
119 9
            $this->name = $name . $this->name;
120
        }
121
122 15
        return $this;
123
    }
124
125 23
    public function render(string $renderer, mixed ...$args): static
126
    {
127 23
        $this->renderer = new RendererConfig($renderer, $args);
128
129 23
        return $this;
130
    }
131
132 12
    public function getRenderer(): ?RendererConfig
133
    {
134 12
        return $this->renderer;
135
    }
136
137
    /**
138
     * Simply prefixes the current $this->view string with $controller.
139
     */
140 3
    public function controller(string $controller): static
141
    {
142 3
        if (is_string($this->view)) {
143 1
            $this->view = [$controller, $this->view];
144
145 1
            return $this;
146
        }
147
148 2
        throw new ValueError('Cannot add controller to view of type Closure or array. ' .
149 2
            'Also, Endpoints cannot be used in a Group which utilises controllers');
150
    }
151
152 77
    public function name(): string
153
    {
154 77
        return $this->name;
155
    }
156
157 12
    public function attrs(mixed ...$attrs): static
158
    {
159 12
        $this->attributes = $attrs;
160
161 12
        return $this;
162
    }
163
164 2
    public function getAttrs(): array
165
    {
166 2
        return $this->attributes;
167
    }
168
169
    /**
170
     * @psalm-suppress MixedAssignment
171
     *
172
     * Types are checked in the body.
173
     */
174 17
    public function url(mixed ...$args): string
175
    {
176 17
        $url = '/' . ltrim($this->pattern, '/');
177
178 17
        if (count($args) > 0) {
179 8
            if (is_array($args[0] ?? null)) {
180 4
                $args = $args[0];
181
            } else {
182
                // Check if args is an associative array
183 6
                if (array_keys($args) === range(0, count($args) - 1)) {
184 1
                    throw new InvalidArgumentException(
185 1
                        'Route::url: either pass an associative array or named arguments'
186 1
                    );
187
                }
188
            }
189
190
            /**
191
             * @psalm-suppress MixedAssignment
192
             *
193
             * We check if $value can be transformed into a string, Psalm
194
             * complains anyway.
195
             */
196 7
            foreach ($args as $name => $value) {
197 7
                if (is_scalar($value) or ($value instanceof Stringable)) {
198
                    // basic variables
199 7
                    $url = preg_replace(
200 7
                        '/\{' . (string)$name . '(:.*?)?\}/',
201 7
                        urlencode((string)$value),
202 7
                        $url,
203 7
                    );
204
205
                    // remainder variables
206 7
                    $url = preg_replace(
207 7
                        '/\.\.\.' . (string)$name . '/',
208 7
                        urlencode((string)$value),
209 7
                        $url,
210 7
                    );
211
                } else {
212 1
                    throw new InvalidArgumentException('No valid url argument');
213
                }
214
            }
215
        }
216
217 15
        return $url;
218
    }
219
220
    /** @psalm-return Closure|list{string, string}|string */
221 52
    public function view(): Closure|array|string
222
    {
223 52
        return $this->view;
224
    }
225
226 51
    public function args(): array
227
    {
228 51
        return $this->args;
229
    }
230
231 6
    public function pattern(): string
232
    {
233 6
        return $this->pattern;
234
    }
235
236 82
    public function match(string $url): ?Route
237
    {
238 82
        $pattern = $this->compiledPattern();
239 79
        assert(strlen($pattern) > 0);
240
241
        /**
242
         * The previous assert does not satisfy psalm regarding
243
         * `preg_match` pattern must be a non-empty-string.
244
         *
245
         * @psalm-suppress ArgumentTypeCoercion
246
         */
247 79
        if (preg_match($pattern, $url, $matches)) {
248
            // Remove integer indexes from array
249 78
            $matches = array_filter(
250 78
                $matches,
251 78
                fn ($_, $k) => !is_int($k),
252 78
                ARRAY_FILTER_USE_BOTH
253 78
            );
254
255 78
            foreach ($matches as $key => $match) {
256 23
                $this->args[$key] = $match;
257
            }
258
259 78
            return $this;
260
        }
261
262 18
        return null;
263
    }
264
265 82
    protected function hideInnerBraces(string $str): string
266
    {
267 82
        if (strpos($str, '\{') || strpos($str, '\}')) {
268 2
            throw new ValueError('Escaped braces are not allowed: ' . $this->pattern);
269
        }
270
271 80
        $new = '';
272 80
        $level = 0;
273
274 80
        foreach (str_split($str) as $c) {
275 80
            if ($c === '{') {
276 23
                $level++;
277
278 23
                if ($level > 1) {
279 2
                    $new .= LEFT_BRACE;
280
                } else {
281 23
                    $new .= '{';
282
                }
283
284 23
                continue;
285
            }
286
287 80
            if ($c === '}') {
288 23
                if ($level > 1) {
289 2
                    $new .= RIGHT_BRACE;
290
                } else {
291 22
                    $new .= '}';
292
                }
293
294 23
                $level--;
295
296 23
                continue;
297
            }
298
299 80
            $new .= $c;
300
        }
301
302 80
        if ($level !== 0) {
303 1
            throw new ValueError('Unbalanced braces in route pattern: ' . $this->pattern);
304
        }
305
306 79
        return $new;
307
    }
308
309 79
    protected function restoreInnerBraces(string $str): string
310
    {
311 79
        return str_replace(LEFT_BRACE, '{', str_replace(RIGHT_BRACE, '}', $str));
312
    }
313
314 82
    protected function compiledPattern(): string
315
    {
316
        // Ensure leading slash
317 82
        $pattern = '/' . ltrim($this->pattern, '/');
318
319
        // Escape forward slashes
320
        //     /evil/chuck  to \/evil\/chuck
321 82
        $pattern = preg_replace('/\//', '\\/', $pattern);
322
323 82
        $pattern = $this->hideInnerBraces($pattern);
324
325
        // Convert variables to named group patterns
326
        //     /evil/{chuck}  to  /evil/(?P<chuck>[\w-]+)
327 79
        $pattern = preg_replace('/\{(\w+?)\}/', '(?P<\1>[.\w-]+)', $pattern);
328
329
        // Convert variables with custom patterns e.g. {evil:\d+}
330
        //     /evil/{chuck:\d+}  to  /evil/(?P<chuck>\d+)
331 79
        $pattern = preg_replace('/\{(\w+?):(.+?)\}/', '(?P<\1>\2)', $pattern);
332
333
        // Convert remainder pattern ...slug to (?P<slug>.*)
334 79
        $pattern = preg_replace('/\.\.\.(\w+?)$/', '(?P<\1>.*)', $pattern);
335
336 79
        $pattern = '/^' . $pattern . '$/';
337
338 79
        return $this->restoreInnerBraces($pattern);
339
    }
340
}
341