Passed
Push — main ( a1a461...2097df )
by Thomas
12:50
created

Route::hideInnerBraces()   B

Complexity

Conditions 9
Paths 13

Size

Total Lines 42
Code Lines 23

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 22
CRAP Score 9

Importance

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