Test Failed
Pull Request — master (#1192)
by butschster
10:57
created

UriHandler::match()   A

Complexity

Conditions 3
Paths 4

Size

Total Lines 17
Code Lines 10

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 8
CRAP Score 3

Importance

Changes 0
Metric Value
eloc 10
dl 0
loc 17
ccs 8
cts 8
cp 1
rs 9.9332
c 0
b 0
f 0
cc 3
nc 4
nop 2
crap 3
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 519
     * Note: SlugifyInterface will be removed in next major release.
57
     * @see UriHandler::withPathSegmentEncoder() for more details.
58
     */
59
    public function __construct(
60
        private readonly UriFactoryInterface $uriFactory,
61 519
        ?SlugifyInterface $slugify = null,
62
        ?RoutePatternRegistryInterface $patternRegistry = null,
63 519
    ) {
64 519
        $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
        $slugify ??= new Slugify();
67
        $this->pathSegmentEncoder = static fn(string $segment): string => $slugify->slugify($segment);
68
    }
69
70
    public function setStrict(bool $strict): void
71
    {
72 4
        $this->strict = $strict;
73
    }
74 4
75 4
    /**
76
     * Set custom path segment encoder.
77 4
     *
78
     * @param \Closure(non-empty-string): non-empty-string $callable Callable must accept string and return string.
79
     */
80
    public function withPathSegmentEncoder(\Closure $callable): self
81
    {
82
        $uriHandler = clone $this;
83
        $uriHandler->pathSegmentEncoder = $callable;
84
85
        return $uriHandler;
86
    }
87
88 454
    public function getPattern(): ?string
89
    {
90 454
        return $this->pattern;
91 454
    }
92 454
93 454
    /**
94
     * @mutation-free
95 454
     */
96
    public function withConstrains(array $constrains, array $defaults = []): self
97
    {
98 1
        $uriHandler = clone $this;
99
        $uriHandler->compiled = null;
100 1
        $uriHandler->constrains = $constrains;
101
        $uriHandler->defaults = $defaults;
102
103
        return $uriHandler;
104
    }
105
106 389
    public function getConstrains(): array
107
    {
108 389
        return $this->constrains;
109 389
    }
110 389
111
    /**
112 389
     * @mutation-free
113
     */
114
    public function withPrefix(string $prefix): self
115 123
    {
116
        $uriHandler = clone $this;
117 123
        $uriHandler->compiled = null;
118
        $uriHandler->prefix = \trim($prefix, '/');
119
120
        return $uriHandler;
121
    }
122
123 436
    public function getPrefix(): string
124
    {
125 436
        return $this->prefix;
126 1
    }
127
128
    /**
129 436
     * @mutation-free
130 436
     */
131 436
    public function withBasePath(string $basePath): self
132
    {
133 436
        if (!\str_ends_with($basePath, '/')) {
134
            $basePath .= '/';
135
        }
136
137
        $uriHandler = clone $this;
138
        $uriHandler->compiled = null;
139
        $uriHandler->basePath = $basePath;
140
141
        return $uriHandler;
142
    }
143
144 473
    public function getBasePath(): string
145
    {
146 473
        return $this->basePath;
147 473
    }
148 473
149 473
    /**
150
     * @mutation-free
151 473
     */
152
    public function withPattern(string $pattern): self
153
    {
154
        $uriHandler = clone $this;
155
        $uriHandler->pattern = $pattern;
156
        $uriHandler->compiled = null;
157
        $uriHandler->matchHost = \str_starts_with($pattern, self::HOST_PREFIX);
158
159
        return $uriHandler;
160 116
    }
161
162 116
    /**
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
    public function isCompiled(): bool
169
    {
170
        return $this->compiled !== null;
171
    }
172
173 98
    /**
174
     * Match given url against compiled template and return matches array or null if pattern does
175 98
     * not match.
176 98
     *
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 93
     * @psalm-external-mutation-free
180 93
     */
181 37
    public function match(UriInterface $uri, array $defaults): ?array
182
    {
183
        if (!$this->isCompiled()) {
184 84
            $this->compile();
185 84
        }
186 84
187 84
        $matches = [];
188
        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 84
            return null;
190
        }
191
192
        $matches = \array_intersect_key(
193
            \array_filter($matches, static fn(string $value): bool => $value !== ''),
194
            $this->options,
195 27
        );
196
197 27
        return \array_merge($this->options, $defaults, $matches);
198 18
    }
199
200
    /**
201 27
     * Generate Uri for a given parameters and default values.
202 27
     */
203 27
    public function uri(iterable $parameters = [], array $defaults = []): UriInterface
204 27
    {
205 27
        if (!$this->isCompiled()) {
206
            $this->compile();
207 27
        }
208 24
209 2
        $parameters = \array_merge(
210
            $this->options,
211
            $defaults,
212
            $this->fetchOptions($parameters, $query),
213
        );
214 25
215
        $required = \array_keys($this->constrains);
216
        $parametersToCheck = $parameters;
217 25
        if ($this->strict) {
218
            $required = \array_unique([...$this->requiredOptions, ...$required]);
219 25
            $parametersToCheck = \array_filter($parametersToCheck);
220
        }
221
222
        $missingParameters = \array_diff($required, \array_keys($parametersToCheck));
223
        if ($missingParameters !== []) {
224
            throw new UriHandlerException(
225
                \sprintf(
226
                    \count($missingParameters) === 1
227 27
                        ? 'Unable to generate Uri, parameter `%s` is missing'
228
                        : 'Unable to generate Uri, parameters `%s` are missing',
229 27
                    \implode('`, `', $missingParameters),
230
                ),
231 27
            );
232 27
        }
233 16
234
        //Uri without empty blocks (pretty stupid implementation)
235 2
        $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

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