Passed
Push — master ( f5a1ef...a14569 )
by butschster
29:16 queued 19:38
created

UriHandler   B

Complexity

Total Complexity 47

Size/Duplication

Total Lines 329
Duplicated Lines 0 %

Test Coverage

Coverage 94.85%

Importance

Changes 1
Bugs 1 Features 0
Metric Value
wmc 47
eloc 136
c 1
b 1
f 0
dl 0
loc 329
ccs 129
cts 136
cp 0.9485
rs 8.64

18 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 7 1
A match() 0 17 3
A fetchTarget() 0 18 5
B compile() 0 48 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

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
    /** @internal */
39
    private readonly SlugifyInterface $slugify;
40
    private readonly RoutePatternRegistryInterface $patternRegistry;
41
    private array $constrains = [];
42
    private array $defaults = [];
43
    private bool $matchHost = false;
44
    /** @readonly */
45
    private string $prefix = '';
46
    /** @readonly */
47
    private string $basePath = '/';
48
    private ?string $compiled = null;
49
    private ?string $template = null;
50
    private array $options = [];
51
52 430
    public function __construct(
53
        private readonly UriFactoryInterface $uriFactory,
54
        SlugifyInterface $slugify = null,
55
        ?RoutePatternRegistryInterface $patternRegistry = null,
56
    ) {
57 430
        $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...
58 430
        $this->slugify = $slugify ?? new Slugify();
0 ignored issues
show
Bug introduced by
The property slugify is declared read-only in Spiral\Router\UriHandler.
Loading history...
59
    }
60
61
    public function getPattern(): ?string
62
    {
63
        return $this->pattern;
64
    }
65
66
    /**
67
     * @mutation-free
68
     */
69 367
    public function withConstrains(array $constrains, array $defaults = []): self
70
    {
71 367
        $uriHandler = clone $this;
72 367
        $uriHandler->compiled = null;
73 367
        $uriHandler->constrains = $constrains;
74 367
        $uriHandler->defaults = $defaults;
75
76 367
        return $uriHandler;
77
    }
78
79 1
    public function getConstrains(): array
80
    {
81 1
        return $this->constrains;
82
    }
83
84
    /**
85
     * @mutation-free
86
     */
87 309
    public function withPrefix(string $prefix): self
88
    {
89 309
        $uriHandler = clone $this;
90 309
        $uriHandler->compiled = null;
91 309
        $uriHandler->prefix = \trim($prefix, '/');
92
93 309
        return $uriHandler;
94
    }
95
96 109
    public function getPrefix(): string
97
    {
98 109
        return $this->prefix;
99
    }
100
101
    /**
102
     * @mutation-free
103
     */
104 348
    public function withBasePath(string $basePath): self
105
    {
106 348
        if (!\str_ends_with($basePath, '/')) {
107
            $basePath .= '/';
108
        }
109
110 348
        $uriHandler = clone $this;
111 348
        $uriHandler->compiled = null;
112 348
        $uriHandler->basePath = $basePath;
113
114 348
        return $uriHandler;
115
    }
116
117
    public function getBasePath(): string
118
    {
119
        return $this->basePath;
120
    }
121
122
    /**
123
     * @mutation-free
124
     */
125 385
    public function withPattern(string $pattern): self
126
    {
127 385
        $uriHandler = clone $this;
128 385
        $uriHandler->pattern = $pattern;
129 385
        $uriHandler->compiled = null;
130 385
        $uriHandler->matchHost = \str_starts_with($pattern, self::HOST_PREFIX);
131
132 385
        return $uriHandler;
133
    }
134
135
    /**
136
     * @psalm-assert-if-false null $this->compiled
137
     * @psalm-assert-if-true !null $this->compiled
138
     * @psalm-assert-if-true !null $this->pattern
139
     * @psalm-assert-if-true !null $this->template
140
     */
141 102
    public function isCompiled(): bool
142
    {
143 102
        return $this->compiled !== null;
144
    }
145
146
    /**
147
     * Match given url against compiled template and return matches array or null if pattern does
148
     * not match.
149
     *
150
     * @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...
151
     *
152
     * @psalm-external-mutation-free
153
     * @psalm-suppress MoreSpecificReturnType, LessSpecificReturnStatement
154
     */
155 89
    public function match(UriInterface $uri, array $defaults): ?array
156
    {
157 89
        if (!$this->isCompiled()) {
158 89
            $this->compile();
159
        }
160
161 84
        $matches = [];
162 84
        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

162
        if (!\preg_match(/** @scrutinizer ignore-type */ $this->compiled, $this->fetchTarget($uri), $matches)) {
Loading history...
163 41
            return null;
164
        }
165
166 75
        $matches = \array_intersect_key(
167 75
            \array_filter($matches, static fn (string $value) => $value !== ''),
168 75
            $this->options
169 75
        );
170
171 75
        return \array_merge($this->options, $defaults, $matches);
172
    }
173
174
    /**
175
     * Generate Uri for a given parameters and default values.
176
     */
177 13
    public function uri(iterable $parameters = [], array $defaults = []): UriInterface
178
    {
179 13
        if (!$this->isCompiled()) {
180 13
            $this->compile();
181
        }
182
183 13
        $parameters = \array_merge(
184 13
            $this->options,
185 13
            $defaults,
186 13
            $this->fetchOptions($parameters, $query)
187 13
        );
188
189 13
        foreach ($this->constrains as $key => $_) {
190 11
            if (empty($parameters[$key])) {
191 2
                throw new UriHandlerException(\sprintf('Unable to generate Uri, parameter `%s` is missing', $key));
192
            }
193
        }
194
195
        //Uri without empty blocks (pretty stupid implementation)
196 11
        $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

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