Passed
Pull Request — master (#1192)
by butschster
12:18
created

UriHandler::setStrict()   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
c 0
b 0
f 0
dl 0
loc 3
ccs 2
cts 2
cp 1
rs 10
cc 1
nc 1
nop 1
crap 1
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
        // todo: probably should be removed. There are no examples of usage or cases where it is needed.
34
        '://' => '://',
35
    ];
36
37
    private ?string $pattern = null;
38
39
    private readonly RoutePatternRegistryInterface $patternRegistry;
40
    private array $constrains = [];
41
    private array $defaults = [];
42
    private bool $matchHost = false;
43
    /** @readonly */
44
    private string $prefix = '';
45
    /** @readonly */
46
    private string $basePath = '/';
47
    private ?string $compiled = null;
48
    private ?string $template = null;
49
50
    private array $options = [];
51
    private array $requiredOptions = [];
52
    private bool $strict = false;
53
54
    private \Closure $pathSegmentEncoder;
55
56
    /**
57
     * Note: SlugifyInterface will be removed in next major release.
58
     * @see UriHandler::withPathSegmentEncoder() for more details.
59
     */
60 555
    public function __construct(
61
        private readonly UriFactoryInterface $uriFactory,
62
        ?SlugifyInterface $slugify = null,
63
        ?RoutePatternRegistryInterface $patternRegistry = null,
64
    ) {
65 555
        $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...
66
67 555
        $slugify ??= new Slugify();
68 555
        $this->pathSegmentEncoder = static fn(string $segment): string => $slugify->slugify($segment);
69
    }
70
71 24
    public function setStrict(bool $strict): void
72
    {
73 24
        $this->strict = $strict;
74
    }
75
76
    /**
77
     * Set custom path segment encoder.
78
     *
79
     * @param \Closure(non-empty-string): non-empty-string $callable Callable must accept string and return string.
80
     */
81 40
    public function withPathSegmentEncoder(\Closure $callable): self
82
    {
83 40
        $uriHandler = clone $this;
84 40
        $uriHandler->pathSegmentEncoder = $callable;
85
86 40
        return $uriHandler;
87
    }
88
89
    public function getPattern(): ?string
90
    {
91
        return $this->pattern;
92
    }
93
94
    /**
95
     * @mutation-free
96
     */
97 490
    public function withConstrains(array $constrains, array $defaults = []): self
98
    {
99 490
        $uriHandler = clone $this;
100 490
        $uriHandler->compiled = null;
101 490
        $uriHandler->constrains = $constrains;
102 490
        $uriHandler->defaults = $defaults;
103
104 490
        return $uriHandler;
105
    }
106
107 1
    public function getConstrains(): array
108
    {
109 1
        return $this->constrains;
110
    }
111
112
    /**
113
     * @mutation-free
114
     */
115 389
    public function withPrefix(string $prefix): self
116
    {
117 389
        $uriHandler = clone $this;
118 389
        $uriHandler->compiled = null;
119 389
        $uriHandler->prefix = \trim($prefix, '/');
120
121 389
        return $uriHandler;
122
    }
123
124 159
    public function getPrefix(): string
125
    {
126 159
        return $this->prefix;
127
    }
128
129
    /**
130
     * @mutation-free
131
     */
132 472
    public function withBasePath(string $basePath): self
133
    {
134 472
        if (!\str_ends_with($basePath, '/')) {
135 1
            $basePath .= '/';
136
        }
137
138 472
        $uriHandler = clone $this;
139 472
        $uriHandler->compiled = null;
140 472
        $uriHandler->basePath = $basePath;
141
142 472
        return $uriHandler;
143
    }
144
145
    public function getBasePath(): string
146
    {
147
        return $this->basePath;
148
    }
149
150
    /**
151
     * @mutation-free
152
     */
153 509
    public function withPattern(string $pattern): self
154
    {
155 509
        $uriHandler = clone $this;
156 509
        $uriHandler->pattern = $pattern;
157 509
        $uriHandler->compiled = null;
158 509
        $uriHandler->matchHost = \str_starts_with($pattern, self::HOST_PREFIX);
159
160 509
        return $uriHandler;
161
    }
162
163
    /**
164
     * @psalm-assert-if-false null $this->compiled
165
     * @psalm-assert-if-true !null $this->compiled
166
     * @psalm-assert-if-true !null $this->pattern
167
     * @psalm-assert-if-true !null $this->template
168
     */
169 152
    public function isCompiled(): bool
170
    {
171 152
        return $this->compiled !== null;
172
    }
173
174
    /**
175
     * Match given url against compiled template and return matches array or null if pattern does
176
     * not match.
177
     *
178
     * @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...
179
     *
180
     * @psalm-external-mutation-free
181
     */
182 98
    public function match(UriInterface $uri, array $defaults): ?array
183
    {
184 98
        if (!$this->isCompiled()) {
185 98
            $this->compile();
186
        }
187
188 93
        $matches = [];
189 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

189
        if (!\preg_match(/** @scrutinizer ignore-type */ $this->compiled, $this->fetchTarget($uri), $matches)) {
Loading history...
190 37
            return null;
191
        }
192
193 84
        $matches = \array_intersect_key(
194 84
            \array_filter($matches, static fn(string $value): bool => $value !== ''),
195 84
            $this->options,
196 84
        );
197
198 84
        return \array_merge($this->options, $defaults, $matches);
199
    }
200
201
    /**
202
     * Generate Uri for a given parameters and default values.
203
     */
204 63
    public function uri(iterable $parameters = [], array $defaults = []): UriInterface
205
    {
206 63
        if (!$this->isCompiled()) {
207 54
            $this->compile();
208
        }
209
210 63
        $parameters = \array_merge(
211 63
            $this->options,
212 63
            $defaults,
213 63
            $this->fetchOptions($parameters, $query),
214 63
        );
215
216 63
        $required = \array_keys($this->constrains);
217 63
        if ($this->strict) {
218 24
            $required = \array_unique([...$this->requiredOptions, ...$required]);
219
        }
220
221 63
        $missingParameters = [];
222
223 63
        foreach ($required as $key) {
224 60
            if (empty($parameters[$key])) {
225 14
                $missingParameters[] = $key;
226
            }
227
        }
228
229 63
        if ($missingParameters !== []) {
230 14
            throw new UriHandlerException(
231 14
                \sprintf(
232 14
                    \count($missingParameters) === 1
233 11
                        ? 'Unable to generate Uri, parameter `%s` is missing'
234 14
                        : 'Unable to generate Uri, parameters `%s` are missing',
235 14
                    \implode('`, `', $missingParameters),
236 14
                ),
237 14
            );
238
        }
239
240
        //Uri without empty blocks (pretty stupid implementation)
241 49
        $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

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