Passed
Push — main ( fc9a18...113eee )
by Thomas
02:49
created

Route::params()   A

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