Passed
Pull Request — master (#1192)
by butschster
14:46 queued 03:21
created

UriHandler::uri()   B

Complexity

Conditions 9
Paths 36

Size

Total Lines 43
Code Lines 23

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 27
CRAP Score 9

Importance

Changes 1
Bugs 1 Features 0
Metric Value
eloc 23
c 1
b 1
f 0
dl 0
loc 43
ccs 27
cts 27
cp 1
rs 8.0555
cc 9
nc 36
nop 2
crap 9
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Spiral\Router;
6
7
use Cocur\Slugify\Slugify;
8
use Cocur\Slugify\SlugifyInterface;
9
use Psr\Http\Message\UriFactoryInterface;
10
use Psr\Http\Message\UriInterface;
11
use Spiral\Router\Exception\ConstrainException;
12
use Spiral\Router\Exception\UriHandlerException;
13
use Spiral\Router\Registry\DefaultPatternRegistry;
14
use Spiral\Router\Registry\RoutePatternRegistryInterface;
15
16
/**
17
 * UriMatcher provides ability to match and generate uris based on given parameters.
18
 *
19
 * @psalm-type Matches = array{controller: non-empty-string, action: non-empty-string, ...}
20
 */
21
final class UriHandler
22
{
23
    private const HOST_PREFIX = '//';
24
    private const DEFAULT_SEGMENT = '[^\/]+';
25
    private const PATTERN_REPLACES = ['/' => '\\/', '[' => '(?:', ']' => ')?', '.' => '\.'];
26
    private const SEGMENT_REPLACES = ['/' => '\\/', '.' => '\.'];
27
    private const URI_FIXERS = [
28
        '[]' => '',
29
        '[/]' => '',
30
        '[' => '',
31
        ']' => '',
32
        '://' => '://',
33
        '//' => '/',
34
    ];
35
36
    private ?string $pattern = null;
37
38
    private readonly RoutePatternRegistryInterface $patternRegistry;
39
    private array $constrains = [];
40
    private array $defaults = [];
41
    private bool $matchHost = false;
42
    /** @readonly */
43
    private string $prefix = '';
44
    /** @readonly */
45
    private string $basePath = '/';
46
    private ?string $compiled = null;
47
    private ?string $template = null;
48
49
    private array $options = [];
50
    private array $requiredOptions = [];
51
    private bool $strict = false;
52
53
    private \Closure $pathSegmentEncoder;
54
55
    /**
56
     * Note: SlugifyInterface will be removed in next major release.
57
     * @see UriHandler::withPathSegmentEncoder() for more details.
58
     */
59 549
    public function __construct(
60
        private readonly UriFactoryInterface $uriFactory,
61
        ?SlugifyInterface $slugify = null,
62
        ?RoutePatternRegistryInterface $patternRegistry = null,
63
    ) {
64 549
        $this->patternRegistry = $patternRegistry ?? new DefaultPatternRegistry();
0 ignored issues
show
Bug introduced by
The property patternRegistry is declared read-only in Spiral\Router\UriHandler.
Loading history...
65
66 549
        $slugify ??= new Slugify();
67 549
        $this->pathSegmentEncoder = static fn(string $segment): string => $slugify->slugify($segment);
68
    }
69
70 20
    public function setStrict(bool $strict): void
71
    {
72 20
        $this->strict = $strict;
73
    }
74
75
    /**
76
     * Set custom path segment encoder.
77
     *
78
     * @param \Closure(non-empty-string): non-empty-string $callable Callable must accept string and return string.
79
     */
80 34
    public function withPathSegmentEncoder(\Closure $callable): self
81
    {
82 34
        $uriHandler = clone $this;
83 34
        $uriHandler->pathSegmentEncoder = $callable;
84
85 34
        return $uriHandler;
86
    }
87
88
    public function getPattern(): ?string
89
    {
90
        return $this->pattern;
91
    }
92
93
    /**
94
     * @mutation-free
95
     */
96 484
    public function withConstrains(array $constrains, array $defaults = []): self
97
    {
98 484
        $uriHandler = clone $this;
99 484
        $uriHandler->compiled = null;
100 484
        $uriHandler->constrains = $constrains;
101 484
        $uriHandler->defaults = $defaults;
102
103 484
        return $uriHandler;
104
    }
105
106 1
    public function getConstrains(): array
107
    {
108 1
        return $this->constrains;
109
    }
110
111
    /**
112
     * @mutation-free
113
     */
114 389
    public function withPrefix(string $prefix): self
115
    {
116 389
        $uriHandler = clone $this;
117 389
        $uriHandler->compiled = null;
118 389
        $uriHandler->prefix = \trim($prefix, '/');
119
120 389
        return $uriHandler;
121
    }
122
123 153
    public function getPrefix(): string
124
    {
125 153
        return $this->prefix;
126
    }
127
128
    /**
129
     * @mutation-free
130
     */
131 466
    public function withBasePath(string $basePath): self
132
    {
133 466
        if (!\str_ends_with($basePath, '/')) {
134 1
            $basePath .= '/';
135
        }
136
137 466
        $uriHandler = clone $this;
138 466
        $uriHandler->compiled = null;
139 466
        $uriHandler->basePath = $basePath;
140
141 466
        return $uriHandler;
142
    }
143
144
    public function getBasePath(): string
145
    {
146
        return $this->basePath;
147
    }
148
149
    /**
150
     * @mutation-free
151
     */
152 503
    public function withPattern(string $pattern): self
153
    {
154 503
        $uriHandler = clone $this;
155 503
        $uriHandler->pattern = $pattern;
156 503
        $uriHandler->compiled = null;
157 503
        $uriHandler->matchHost = \str_starts_with($pattern, self::HOST_PREFIX);
158
159 503
        return $uriHandler;
160
    }
161
162
    /**
163
     * @psalm-assert-if-false null $this->compiled
164
     * @psalm-assert-if-true !null $this->compiled
165
     * @psalm-assert-if-true !null $this->pattern
166
     * @psalm-assert-if-true !null $this->template
167
     */
168 146
    public function isCompiled(): bool
169
    {
170 146
        return $this->compiled !== null;
171
    }
172
173
    /**
174
     * Match given url against compiled template and return matches array or null if pattern does
175
     * not match.
176
     *
177
     * @return Matches|null
0 ignored issues
show
Bug introduced by
The type Spiral\Router\Matches was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
178
     *
179
     * @psalm-external-mutation-free
180
     */
181 98
    public function match(UriInterface $uri, array $defaults): ?array
182
    {
183 98
        if (!$this->isCompiled()) {
184 98
            $this->compile();
185
        }
186
187 93
        $matches = [];
188 93
        if (!\preg_match($this->compiled, $this->fetchTarget($uri), $matches)) {
0 ignored issues
show
Bug introduced by
It seems like $this->compiled can also be of type null; however, parameter $pattern of preg_match() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

188
        if (!\preg_match(/** @scrutinizer ignore-type */ $this->compiled, $this->fetchTarget($uri), $matches)) {
Loading history...
189 36
            return null;
190
        }
191
192 84
        $matches = \array_intersect_key(
193 84
            \array_filter($matches, static fn(string $value): bool => $value !== ''),
194 84
            $this->options,
195 84
        );
196
197 84
        return \array_merge($this->options, $defaults, $matches);
198
    }
199
200
    /**
201
     * Generate Uri for a given parameters and default values.
202
     */
203 57
    public function uri(iterable $parameters = [], array $defaults = []): UriInterface
204
    {
205 57
        if (!$this->isCompiled()) {
206 48
            $this->compile();
207
        }
208
209 57
        $parameters = \array_merge(
210 57
            $this->options,
211 57
            $defaults,
212 57
            $this->fetchOptions($parameters, $query),
213 57
        );
214
215 57
        $required = \array_keys($this->constrains);
216 57
        if ($this->strict) {
217 20
            $required = \array_unique([...$this->requiredOptions, ...$required]);
218
        }
219
220 57
        $missingParameters = [];
221
222 57
        foreach ($required as $key) {
223 54
            if (empty($parameters[$key])) {
224 12
                $missingParameters[] = $key;
225
            }
226
        }
227
228 57
        if ($missingParameters !== []) {
229 12
            throw new UriHandlerException(
230 12
                \sprintf(
231 12
                    \count($missingParameters) === 1
232 11
                        ? 'Unable to generate Uri, parameter `%s` is missing'
233 12
                        : 'Unable to generate Uri, parameters `%s` are missing',
234 12
                    \implode('`, `', $missingParameters),
235 12
                ),
236 12
            );
237
        }
238
239
        //Uri without empty blocks (pretty stupid implementation)
240 45
        $path = $this->interpolate($this->template, $parameters);
0 ignored issues
show
Bug introduced by
It seems like $this->template can also be of type null; however, parameter $string of Spiral\Router\UriHandler::interpolate() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

240
        $path = $this->interpolate(/** @scrutinizer ignore-type */ $this->template, $parameters);
Loading history...
241
242
        //Uri with added base path and prefix
243 45
        $uri = $this->uriFactory->createUri(($this->matchHost ? '' : $this->basePath) . \trim($path, '/'));
244
245 45
        return empty($query) ? $uri : $uri->withQuery(\http_build_query($query));
246
    }
247
248
    /**
249
     * Fetch uri segments and query parameters.
250
     *
251
     * @param array|null $query Query parameters.
252
     */
253 57
    private function fetchOptions(iterable $parameters, ?array &$query): array
254
    {
255 57
        $allowed = \array_keys($this->options);
256
257 57
        $result = [];
258 57
        foreach ($parameters as $key => $parameter) {
259 26
            if (\is_int($key) && isset($allowed[$key])) {
260
                // this segment fetched keys from given parameters either by name or by position
261 2
                $key = $allowed[$key];
262 24
            } elseif (!\array_key_exists($key, $this->options) && \is_array($parameters)) {
263
                // all additional parameters given in array form can be glued to query string
264 1
                $query[$key] = $parameter;
265 1
                continue;
266
            }
267
268
            // String must be normalized here
269 26
            if (\is_string($parameter) && !\preg_match('/^[a-z\-_0-9]+$/i', $parameter)) {
270 5
                $result[$key] = ($this->pathSegmentEncoder)($parameter);
271 5
                continue;
272
            }
273
274 26
            $result[$key] = (string) $parameter;
275
        }
276
277 57
        return $result;
278
    }
279
280
    /**
281
     * Part of uri path which is being matched.
282
     */
283 93
    private function fetchTarget(UriInterface $uri): string
284
    {
285 93
        $path = $uri->getPath();
286
287 93
        if (empty($path) || $path[0] !== '/') {
288 12
            $path = '/' . $path;
289
        }
290
291 93
        if ($this->matchHost) {
292 3
            $uriString = $uri->getHost() . $path;
293
        } else {
294 90
            $uriString = \substr($path, \strlen($this->basePath)) ?: '';
295
        }
296
297 93
        return \trim($uriString, '/');
298
    }
299
300
    /**
301
     * Compile route matcher into regexp.
302
     * @psalm-assert !null $this->pattern
303
     * @psalm-assert !null $this->template
304
     * @psalm-assert !null $this->compiled
305
     */
306 146
    private function compile(): void
307
    {
308 146
        if ($this->pattern === null) {
309
            throw new UriHandlerException('Unable to compile UriHandler, pattern is not set');
310
        }
311
312 146
        $options = [];
313 146
        $replaces = [];
314
315
        // 1) Build full pattern
316 146
        $prefix = \rtrim($this->getPrefix(), '/ ');
317 146
        $pattern = \ltrim($this->pattern, '/ ');
318 146
        $pattern = $prefix . '/' . $pattern;
319 146
        $pattern = \rtrim(\ltrim($pattern, ':/'), '/');
320
321
        // correct [/ first occurrence]
322 146
        if (\str_starts_with($pattern, '[/')) {
323 4
            $pattern = '[' . \substr($pattern, 2);
324
        }
325
326
        // 2) Extract variables from the pattern
327 146
        if (\preg_match_all('/<(\w+):?(.*?)?>/', $pattern, $matches)) {
328 121
            $variables = \array_combine($matches[1], $matches[2]);
329
330 121
            foreach ($variables as $key => $segment) {
331 121
                $segment = $this->prepareSegment($key, $segment);
332 121
                $replaces[\sprintf('<%s>', $key)] = \sprintf('(?P<%s>%s)', $key, $segment);
333 121
                $options[] = $key;
334
            }
335
        }
336
337
        // Simplify template
338 146
        $template = \preg_replace('/<(\w+):?.*?>/', '<\1>', $pattern);
339 146
        $options = \array_fill_keys($options, null);
340
341
        // 3) Validate constraints
342 146
        foreach ($this->constrains as $key => $value) {
343 134
            if ($value instanceof Autofill) {
344
                // only forces value replacement, not required to be presented as parameter
345 75
                continue;
346
            }
347
348
            // If a constraint references a param that doesn't appear in the pattern or defaults
349 77
            if (!\array_key_exists($key, $options) && !isset($this->defaults[$key])) {
350 5
                throw new ConstrainException(
351 5
                    \sprintf(
352 5
                        'Route `%s` does not define routing parameter `<%s>`.',
353 5
                        $this->pattern,
354 5
                        $key,
355 5
                    ),
356 5
                );
357
            }
358
        }
359
360
        // 4) Compile your final regex pattern
361 141
        $this->compiled = '/^' . \strtr($template, $replaces + self::PATTERN_REPLACES) . '$/iu';
362 141
        $this->template = \stripslashes(\str_replace('?', '', $template));
363 141
        $this->options = $options;
364
365
        // 5) Mark which parameters are required vs. optional
366 141
        if ($this->strict) {
367 20
            $this->requiredOptions = $this->findRequiredOptions($pattern, \array_keys($options));
368
        }
369
    }
370
371
    /**
372
     * Find which parameters are required based on bracket notation and defaults.
373
     *
374
     * @param string $pattern The full pattern (with optional segments in [ ])
375
     * @param array $paramNames All the parameter names found (e.g. ['id','controller','action'])
376
     * @return array List of required parameter names
377
     */
378 20
    private function findRequiredOptions(string $pattern, array $paramNames): array
379
    {
380
        // This array will collect optional vars, either because they're in [ ] or have defaults
381 20
        $optionalVars = [];
382
383
        // 1) Identify any variables that appear in optional bracket segments
384 20
        $stack = [];
385 20
        $pos = 0;
386 20
        $length = \strlen($pattern);
387
388 20
        while ($pos < $length) {
389 20
            $char = $pattern[$pos];
390
391
            // We enter an optional segment
392 20
            if ($char === '[') {
393 4
                \array_push($stack, '[');
394
            } // We exit an optional segment
395 20
            elseif ($char === ']') {
396 4
                \array_pop($stack);
397
            } // We see a parameter like <id> or <action:\d+>
398 20
            elseif ($char === '<') {
399
                // Find the closing '>'
400 20
                $endPos = \strpos($pattern, '>', $pos);
401 20
                if ($endPos === false) {
402
                    break;
403
                }
404
405
                // The inside is something like 'id:\d+' or just 'id'
406 20
                $varPart = \substr($pattern, $pos + 1, $endPos - $pos - 1);
407
408
                // The first chunk is the variable name (before any :)
409 20
                $varName = \explode(':', $varPart)[0];
410
411
                // If we are inside a bracket, that var is optional
412 20
                if ($stack !== []) {
413 4
                    $optionalVars[] = $varName;
414
                }
415
416
                // Move past this variable
417 20
                $pos = $endPos;
418
            }
419
420 20
            $pos++;
421
        }
422
423
        // 2) Also mark anything that has a default value as optional
424
        // so we merge them into $optionalVars
425 20
        $optionalVars = \array_unique($optionalVars);
426
427
        // 3) Required = everything in $paramNames that is not in optionalVars
428 20
        return \array_diff($paramNames, $optionalVars);
429
    }
430
431
    /**
432
     * Interpolate string with given values.
433
     */
434 45
    private function interpolate(string $string, array $values): string
435
    {
436 45
        $replaces = [];
437 45
        foreach ($values as $key => $value) {
438 45
            $replaces[\sprintf('<%s>', $key)] = match (true) {
439 45
                $value instanceof \Stringable || \is_scalar($value) => (string) $value,
440 20
                default => '',
441 45
            };
442
        }
443
444 45
        return \strtr($string, $replaces + self::URI_FIXERS);
445
    }
446
447
    /**
448
     * Prepares segment pattern with given constrains.
449
     */
450 121
    private function prepareSegment(string $name, string $segment): string
451
    {
452
        return match (true) {
453 121
            $segment !== '' => $this->patternRegistry->all()[$segment] ?? $segment,
454 107
            !isset($this->constrains[$name]) => self::DEFAULT_SEGMENT,
455 30
            \is_array($this->constrains[$name]) => \implode(
456 30
                '|',
457 30
                \array_map(fn(string $segment): string => $this->filterSegment($segment), $this->constrains[$name]),
458 30
            ),
459 121
            default => $this->filterSegment((string) $this->constrains[$name])
460
        };
461
    }
462
463 30
    private function filterSegment(string $segment): string
464
    {
465 30
        return \strtr($segment, self::SEGMENT_REPLACES);
466
    }
467
}
468