UriHandler   D
last analyzed

Complexity

Total Complexity 59

Size/Duplication

Total Lines 448
Duplicated Lines 0 %

Test Coverage

Coverage 96.67%

Importance

Changes 2
Bugs 1 Features 0
Metric Value
wmc 59
eloc 178
c 2
b 1
f 0
dl 0
loc 448
ccs 174
cts 180
cp 0.9667
rs 4.08

21 Methods

Rating   Name   Duplication   Size   Complexity  
A withPattern() 0 8 1
A getBasePath() 0 3 1
A getPattern() 0 3 1
A isCompiled() 0 3 1
A setStrict() 0 3 1
A getConstrains() 0 3 1
B fetchOptions() 0 25 8
B uri() 0 43 9
A withConstrains() 0 8 1
A __construct() 0 9 1
A match() 0 17 3
A getPrefix() 0 3 1
A withBasePath() 0 11 2
A withPrefix() 0 7 1
A withPathSegmentEncoder() 0 6 1
B findRequiredOptions() 0 50 7
A fetchTarget() 0 13 4
B compile() 0 62 10
A filterSegment() 0 3 1
A interpolate() 0 15 3
A prepareSegment() 0 10 1

How to fix   Complexity   

Complex Class

Complex classes like UriHandler often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use UriHandler, and based on these observations, apply Extract Interface, too.

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
    private readonly RoutePatternRegistryInterface $patternRegistry;
39
    private array $constrains = [];
40
    private array $defaults = [];
41
    private bool $matchHost = false;
42
43
    /** @readonly */
44
    private string $prefix = '';
45
46
    /** @readonly */
47
    private string $basePath = '/';
48
49
    private ?string $compiled = null;
50
    private ?string $template = null;
51
    private array $options = [];
52
    private array $requiredOptions = [];
53
    private bool $strict = false;
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 489
    public function withConstrains(array $constrains, array $defaults = []): self
98
    {
99 489
        $uriHandler = clone $this;
100 489
        $uriHandler->compiled = null;
101 489
        $uriHandler->constrains = $constrains;
102 489
        $uriHandler->defaults = $defaults;
103
104 489
        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 160
    public function getPrefix(): string
125
    {
126 160
        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 153
    public function isCompiled(): bool
170
    {
171 153
        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 36
            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 64
    public function uri(iterable $parameters = [], array $defaults = []): UriInterface
205
    {
206 64
        if (!$this->isCompiled()) {
207 55
            $this->compile();
208
        }
209
210 64
        $parameters = \array_merge(
211 64
            $this->options,
212 64
            $defaults,
213 64
            $this->fetchOptions($parameters, $query),
214 64
        );
215
216 64
        $required = \array_keys($this->constrains);
217 64
        if ($this->strict) {
218 24
            $required = \array_unique([...$this->requiredOptions, ...$required]);
219
        }
220
221 64
        $missingParameters = [];
222
223 64
        foreach ($required as $key) {
224 60
            if (empty($parameters[$key])) {
225 14
                $missingParameters[] = $key;
226
            }
227
        }
228
229 64
        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 50
        $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 50
        $uri = $this->uriFactory->createUri(($this->matchHost ? '' : $this->basePath) . \trim($path, '/'));
245
246 50
        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 64
    private function fetchOptions(iterable $parameters, ?array &$query): array
255
    {
256 64
        $allowed = \array_keys($this->options);
257
258 64
        $result = [];
259 64
        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 64
        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
        $uriString = $this->matchHost
293 3
            ? $uri->getHost() . $path
294 90
            : \substr($path, \strlen($this->basePath));
295
296 93
        return \trim($uriString, '/');
297
    }
298
299
    /**
300
     * Compile route matcher into regexp.
301
     * @psalm-assert !null $this->pattern
302
     * @psalm-assert !null $this->template
303
     * @psalm-assert !null $this->compiled
304
     */
305 153
    private function compile(): void
306
    {
307 153
        if ($this->pattern === null) {
308
            throw new UriHandlerException('Unable to compile UriHandler, pattern is not set');
309
        }
310
311 153
        $options = [];
312 153
        $replaces = [];
313
314
        // 1) Build full pattern
315 153
        $prefix = \rtrim($this->getPrefix(), '/ ');
316 153
        $pattern = \ltrim($this->pattern, '/ ');
317 153
        $pattern = $prefix . '/' . $pattern;
318 153
        $pattern = \rtrim(\ltrim($pattern, ':/'), '/');
319
320
        // correct [/ first occurrence]
321 153
        if (\str_starts_with($pattern, '[/')) {
322 4
            $pattern = '[' . \substr($pattern, 2);
323
        }
324
325
        // 2) Extract variables from the pattern
326 153
        if (\preg_match_all('/<(\w+):?(.*?)?>/', $pattern, $matches)) {
327 127
            $variables = \array_combine($matches[1], $matches[2]);
328
329 127
            foreach ($variables as $key => $segment) {
330 127
                $segment = $this->prepareSegment($key, $segment);
331 127
                $replaces[\sprintf('<%s>', $key)] = \sprintf('(?P<%s>%s)', $key, $segment);
332 127
                $options[] = $key;
333
            }
334
        }
335
336
        // Simplify template
337 153
        $template = \preg_replace('/<(\w+):?.*?>/', '<\1>', $pattern);
338 153
        $options = \array_fill_keys($options, null);
339
340
        // 3) Validate constraints
341 153
        foreach ($this->constrains as $key => $value) {
342 140
            if ($value instanceof Autofill) {
343
                // only forces value replacement, not required to be presented as parameter
344 81
                continue;
345
            }
346
347
            // If a constraint references a param that doesn't appear in the pattern or defaults
348 77
            if (!\array_key_exists($key, $options) && !isset($this->defaults[$key])) {
349 5
                throw new ConstrainException(
350 5
                    \sprintf(
351 5
                        'Route `%s` does not define routing parameter `<%s>`.',
352 5
                        $this->pattern,
353 5
                        $key,
354 5
                    ),
355 5
                );
356
            }
357
        }
358
359
        // 4) Compile your final regex pattern
360 148
        $this->compiled = '/^' . \strtr($template, $replaces + self::PATTERN_REPLACES) . '$/iu';
361 148
        $this->template = \stripslashes(\str_replace('?', '', $template));
362 148
        $this->options = $options;
363
364
        // 5) Mark which parameters are required vs. optional
365 148
        if ($this->strict) {
366 24
            $this->requiredOptions = $this->findRequiredOptions($pattern, \array_keys($options));
367
        }
368
    }
369
370
    /**
371
     * Find which parameters are required based on bracket notation and defaults.
372
     *
373
     * @param string $pattern The full pattern (with optional segments in [ ])
374
     * @param array $paramNames All the parameter names found (e.g. ['id','controller','action'])
375
     * @return array List of required parameter names
376
     */
377 24
    private function findRequiredOptions(string $pattern, array $paramNames): array
378
    {
379
        // This array will collect optional vars, either because they're in [ ] or have defaults
380 24
        $optionalVars = [];
381
382
        // 1) Identify any variables that appear in optional bracket segments
383 24
        $optLevel = 0;
384 24
        $pos = 0;
385 24
        $length = \strlen($pattern);
386
387 24
        while ($pos < $length) {
388 24
            $char = $pattern[$pos];
389
390 24
            if ($char === '[') {
391
                // We enter an optional segment
392 8
                ++$optLevel;
393 24
            } elseif ($char === ']') {
394
                // We exit an optional segment
395 8
                $optLevel = \max(0, $optLevel - 1);
396 24
            } elseif ($char === '<') {
397
                // We see a parameter like <id> or <action:\d+>
398
399
                // Find the closing '>'
400 24
                $endPos = \strpos($pattern, '>', $pos);
401 24
                if ($endPos === false) {
402
                    break;
403
                }
404
405
                // The inside is something like 'id:\d+' or just 'id'
406 24
                $varPart = \substr($pattern, $pos + 1, $endPos - $pos - 1);
407
408
                // The first chunk is the variable name (before any :)
409 24
                $varName = \explode(':', $varPart)[0];
410
411
                // If we are inside a bracket, that var is optional
412 24
                $optLevel > 0 and $optionalVars[] = $varName;
413
414
                // Move past this variable
415 24
                $pos = $endPos;
416
            }
417
418 24
            $pos++;
419
        }
420
421
        // 2) Also mark anything that has a default value as optional
422
        // so we merge them into $optionalVars
423 24
        $optionalVars = \array_unique($optionalVars);
424
425
        // 3) Required = everything in $paramNames that is not in optionalVars
426 24
        return \array_diff($paramNames, $optionalVars);
427
    }
428
429
    /**
430
     * Interpolate string with given values.
431
     * @psalm-suppress InvalidNullableReturnType,NullableReturnStatement
432
     */
433 50
    private function interpolate(string $string, array $values): string
434
    {
435 50
        $replaces = [];
436 50
        foreach ($values as $key => $value) {
437 49
            $replaces[\sprintf('<%s>', $key)] = match (true) {
438 49
                $value instanceof \Stringable || \is_scalar($value) => (string) $value,
439 24
                default => '',
440 49
            };
441
        }
442
443
        // Replace all variables
444 50
        $path = \strtr($string, $replaces + self::URI_FIXERS);
445
446
        // Remove all empty segments
447 50
        return \preg_replace('/\/{2,}/', '/', $path);
448
    }
449
450
    /**
451
     * Prepares segment pattern with given constrains.
452
     */
453 127
    private function prepareSegment(string $name, string $segment): string
454
    {
455
        return match (true) {
456 127
            $segment !== '' => $this->patternRegistry->all()[$segment] ?? $segment,
457 113
            !isset($this->constrains[$name]) => self::DEFAULT_SEGMENT,
458 30
            \is_array($this->constrains[$name]) => \implode(
459 30
                '|',
460 30
                \array_map(fn(string $segment): string => $this->filterSegment($segment), $this->constrains[$name]),
461 30
            ),
462 127
            default => $this->filterSegment((string) $this->constrains[$name]),
463
        };
464
    }
465
466 30
    private function filterSegment(string $segment): string
467
    {
468 30
        return \strtr($segment, self::SEGMENT_REPLACES);
469
    }
470
}
471