UriHandler   B
last analyzed

Complexity

Total Complexity 48

Size/Duplication

Total Lines 351
Duplicated Lines 0 %

Test Coverage

Coverage 95.83%

Importance

Changes 1
Bugs 1 Features 0
Metric Value
wmc 48
eloc 143
c 1
b 1
f 0
dl 0
loc 351
ccs 138
cts 144
cp 0.9583
rs 8.5599

19 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 getConstrains() 0 3 1
B fetchOptions() 0 25 8
A uri() 0 25 6
A withConstrains() 0 8 1
A __construct() 0 9 1
A match() 0 17 3
A fetchTarget() 0 18 5
B compile() 0 52 9
A getPrefix() 0 3 1
A withBasePath() 0 11 2
A filterSegment() 0 3 1
A interpolate() 0 11 3
A prepareSegment() 0 10 1
A withPrefix() 0 7 1
A withPathSegmentEncoder() 0 6 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
        '//' => '/',
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
    private array $options = [];
49
50
    private \Closure $pathSegmentEncoder;
51
52
    /**
53
     * Note: SlugifyInterface will be removed in next major release.
54
     * @see UriHandler::withPathSegmentEncoder() for more details.
55
     */
56 478
    public function __construct(
57
        private readonly UriFactoryInterface $uriFactory,
58
        SlugifyInterface $slugify = null,
59
        ?RoutePatternRegistryInterface $patternRegistry = null,
60
    ) {
61 478
        $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...
62
63 478
        $slugify ??= new Slugify();
64 478
        $this->pathSegmentEncoder = static fn (string $segment): string => $slugify->slugify($segment);
65
    }
66
67
    /**
68
     * Set custom path segment encoder.
69
     *
70
     * @param \Closure(non-empty-string): non-empty-string $callable Callable must accept string and return string.
71
     */
72 4
    public function withPathSegmentEncoder(\Closure $callable): self
73
    {
74 4
        $uriHandler = clone $this;
75 4
        $uriHandler->pathSegmentEncoder = $callable;
76
77 4
        return $uriHandler;
78
    }
79
80
    public function getPattern(): ?string
81
    {
82
        return $this->pattern;
83
    }
84
85
    /**
86
     * @mutation-free
87
     */
88 414
    public function withConstrains(array $constrains, array $defaults = []): self
89
    {
90 414
        $uriHandler = clone $this;
91 414
        $uriHandler->compiled = null;
92 414
        $uriHandler->constrains = $constrains;
93 414
        $uriHandler->defaults = $defaults;
94
95 414
        return $uriHandler;
96
    }
97
98 1
    public function getConstrains(): array
99
    {
100 1
        return $this->constrains;
101
    }
102
103
    /**
104
     * @mutation-free
105
     */
106 351
    public function withPrefix(string $prefix): self
107
    {
108 351
        $uriHandler = clone $this;
109 351
        $uriHandler->compiled = null;
110 351
        $uriHandler->prefix = \trim($prefix, '/');
111
112 351
        return $uriHandler;
113
    }
114
115 119
    public function getPrefix(): string
116
    {
117 119
        return $this->prefix;
118
    }
119
120
    /**
121
     * @mutation-free
122
     */
123 396
    public function withBasePath(string $basePath): self
124
    {
125 396
        if (!\str_ends_with($basePath, '/')) {
126 1
            $basePath .= '/';
127
        }
128
129 396
        $uriHandler = clone $this;
130 396
        $uriHandler->compiled = null;
131 396
        $uriHandler->basePath = $basePath;
132
133 396
        return $uriHandler;
134
    }
135
136
    public function getBasePath(): string
137
    {
138
        return $this->basePath;
139
    }
140
141
    /**
142
     * @mutation-free
143
     */
144 433
    public function withPattern(string $pattern): self
145
    {
146 433
        $uriHandler = clone $this;
147 433
        $uriHandler->pattern = $pattern;
148 433
        $uriHandler->compiled = null;
149 433
        $uriHandler->matchHost = \str_starts_with($pattern, self::HOST_PREFIX);
150
151 433
        return $uriHandler;
152
    }
153
154
    /**
155
     * @psalm-assert-if-false null $this->compiled
156
     * @psalm-assert-if-true !null $this->compiled
157
     * @psalm-assert-if-true !null $this->pattern
158
     * @psalm-assert-if-true !null $this->template
159
     */
160 112
    public function isCompiled(): bool
161
    {
162 112
        return $this->compiled !== null;
163
    }
164
165
    /**
166
     * Match given url against compiled template and return matches array or null if pattern does
167
     * not match.
168
     *
169
     * @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...
170
     *
171
     * @psalm-external-mutation-free
172
     */
173 94
    public function match(UriInterface $uri, array $defaults): ?array
174
    {
175 94
        if (!$this->isCompiled()) {
176 94
            $this->compile();
177
        }
178
179 89
        $matches = [];
180 89
        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

180
        if (!\preg_match(/** @scrutinizer ignore-type */ $this->compiled, $this->fetchTarget($uri), $matches)) {
Loading history...
181 35
            return null;
182
        }
183
184 80
        $matches = \array_intersect_key(
185 80
            \array_filter($matches, static fn (string $value) => $value !== ''),
186 80
            $this->options
187 80
        );
188
189 80
        return \array_merge($this->options, $defaults, $matches);
190
    }
191
192
    /**
193
     * Generate Uri for a given parameters and default values.
194
     */
195 27
    public function uri(iterable $parameters = [], array $defaults = []): UriInterface
196
    {
197 27
        if (!$this->isCompiled()) {
198 18
            $this->compile();
199
        }
200
201 27
        $parameters = \array_merge(
202 27
            $this->options,
203 27
            $defaults,
204 27
            $this->fetchOptions($parameters, $query)
205 27
        );
206
207 27
        foreach ($this->constrains as $key => $_) {
208 24
            if (empty($parameters[$key])) {
209 2
                throw new UriHandlerException(\sprintf('Unable to generate Uri, parameter `%s` is missing', $key));
210
            }
211
        }
212
213
        //Uri without empty blocks (pretty stupid implementation)
214 25
        $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

214
        $path = $this->interpolate(/** @scrutinizer ignore-type */ $this->template, $parameters);
Loading history...
215
216
        //Uri with added base path and prefix
217 25
        $uri = $this->uriFactory->createUri(($this->matchHost ? '' : $this->basePath) . \trim($path, '/'));
218
219 25
        return empty($query) ? $uri : $uri->withQuery(\http_build_query($query));
220
    }
221
222
    /**
223
     * Fetch uri segments and query parameters.
224
     *
225
     * @param array|null $query Query parameters.
226
     */
227 27
    private function fetchOptions(iterable $parameters, ?array &$query): array
228
    {
229 27
        $allowed = \array_keys($this->options);
230
231 27
        $result = [];
232 27
        foreach ($parameters as $key => $parameter) {
233 16
            if (\is_int($key) && isset($allowed[$key])) {
234
                // this segment fetched keys from given parameters either by name or by position
235 2
                $key = $allowed[$key];
236 14
            } elseif (!\array_key_exists($key, $this->options) && \is_array($parameters)) {
237
                // all additional parameters given in array form can be glued to query string
238 1
                $query[$key] = $parameter;
239 1
                continue;
240
            }
241
242
            // String must be normalized here
243 16
            if (\is_string($parameter) && !\preg_match('/^[a-z\-_0-9]+$/i', $parameter)) {
244 5
                $result[$key] = ($this->pathSegmentEncoder)($parameter);
245 5
                continue;
246
            }
247
248 16
            $result[$key] = (string)$parameter;
249
        }
250
251 27
        return $result;
252
    }
253
254
    /**
255
     * Part of uri path which is being matched.
256
     */
257 89
    private function fetchTarget(UriInterface $uri): string
258
    {
259 89
        $path = $uri->getPath();
260
261 89
        if (empty($path) || $path[0] !== '/') {
262 12
            $path = '/' . $path;
263
        }
264
265 89
        if ($this->matchHost) {
266 3
            $uriString = $uri->getHost() . $path;
267
        } else {
268 86
            $uriString = \substr($path, \strlen($this->basePath));
269 86
            if ($uriString === false) {
270
                $uriString = '';
271
            }
272
        }
273
274 89
        return \trim($uriString, '/');
275
    }
276
277
    /**
278
     * Compile route matcher into regexp.
279
     * @psalm-assert !null $this->pattern
280
     * @psalm-assert !null $this->template
281
     * @psalm-assert !null $this->compiled
282
     */
283 112
    private function compile(): void
284
    {
285 112
        if ($this->pattern === null) {
286
            throw new UriHandlerException('Unable to compile UriHandler, pattern is not set');
287
        }
288
289 112
        $options = [];
290 112
        $replaces = [];
291
292 112
        $prefix = \rtrim($this->getPrefix(), '/ ');
293 112
        $pattern = \ltrim($this->pattern, '/ ');
294 112
        $pattern = $prefix . '/' . $pattern;
295 112
        $pattern = \rtrim(\ltrim($pattern, ':/'), '/');
296
297
        // correct [/ first occurrence]
298 112
        if (\str_starts_with($pattern, '[/')) {
299 4
            $pattern = '[' . \substr($pattern, 2);
300
        }
301
302 112
        if (\preg_match_all('/<(\w+):?(.*?)?>/', $pattern, $matches)) {
303 86
            $variables = \array_combine($matches[1], $matches[2]);
304
305 86
            foreach ($variables as $key => $segment) {
306 86
                $segment = $this->prepareSegment($key, $segment);
307 86
                $replaces[\sprintf('<%s>', $key)] = \sprintf('(?P<%s>%s)', $key, $segment);
308 86
                $options[] = $key;
309
            }
310
        }
311
312 112
        $template = \preg_replace('/<(\w+):?.*?>/', '<\1>', $pattern);
313 112
        $options = \array_fill_keys($options, null);
314
315 112
        foreach ($this->constrains as $key => $value) {
316 100
            if ($value instanceof Autofill) {
317
                // only forces value replacement, not required to be presented as parameter
318 45
                continue;
319
            }
320
321 73
            if (!\array_key_exists($key, $options) && !isset($this->defaults[$key])) {
322 5
                throw new ConstrainException(
323 5
                    \sprintf(
324 5
                        'Route `%s` does not define routing parameter `<%s>`.',
325 5
                        $this->pattern,
326 5
                        $key
327 5
                    )
328 5
                );
329
            }
330
        }
331
332 107
        $this->compiled = '/^' . \strtr($template, $replaces + self::PATTERN_REPLACES) . '$/iu';
333 107
        $this->template = \stripslashes(\str_replace('?', '', $template));
334 107
        $this->options = $options;
335
    }
336
337
    /**
338
     * Interpolate string with given values.
339
     */
340 25
    private function interpolate(string $string, array $values): string
341
    {
342 25
        $replaces = [];
343 25
        foreach ($values as $key => $value) {
344 25
            $replaces[\sprintf('<%s>', $key)] = match (true) {
345 25
                $value instanceof \Stringable || \is_scalar($value) => (string)$value,
346 25
                default => '',
347 25
            };
348
        }
349
350 25
        return \strtr($string, $replaces + self::URI_FIXERS);
351
    }
352
353
    /**
354
     * Prepares segment pattern with given constrains.
355
     */
356 86
    private function prepareSegment(string $name, string $segment): string
357
    {
358 86
        return match (true) {
359 86
            $segment !== '' => $this->patternRegistry->all()[$segment] ?? $segment,
360 86
            !isset($this->constrains[$name]) => self::DEFAULT_SEGMENT,
361 86
            \is_array($this->constrains[$name]) => \implode(
362 86
                '|',
363 86
                \array_map(fn (string $segment): string => $this->filterSegment($segment), $this->constrains[$name])
364 86
            ),
365 86
            default => $this->filterSegment((string)$this->constrains[$name])
366 86
        };
367
    }
368
369 27
    private function filterSegment(string $segment): string
370
    {
371 27
        return \strtr($segment, self::SEGMENT_REPLACES);
372
    }
373
}
374