Passed
Pull Request — master (#831)
by Maxim
11:02 queued 04:50
created

UriHandler   B

Complexity

Total Complexity 47

Size/Duplication

Total Lines 303
Duplicated Lines 0 %

Test Coverage

Coverage 94.31%

Importance

Changes 0
Metric Value
wmc 47
eloc 138
c 0
b 0
f 0
dl 0
loc 303
ccs 116
cts 123
cp 0.9431
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 5 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
14
/**
15
 * UriMatcher provides ability to match and generate uris based on given parameters.
16
 */
17
final class UriHandler
18
{
19
    private const HOST_PREFIX      = '//';
20
    private const DEFAULT_SEGMENT  = '[^\/]+';
21
    private const PATTERN_REPLACES = ['/' => '\\/', '[' => '(?:', ']' => ')?', '.' => '\.'];
22
    private const SEGMENT_REPLACES = ['/' => '\\/', '.' => '\.'];
23
    private const SEGMENT_TYPES    = [
24
        'int'     => '\d+',
25
        'integer' => '\d+',
26
        'uuid'    => '[0-9a-fA-F]{8}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{12}',
27
    ];
28
    private const URI_FIXERS       = [
29
        '[]'  => '',
30
        '[/]' => '',
31
        '['   => '',
32
        ']'   => '',
33
        '://' => '://',
34
        '//'  => '/',
35
    ];
36
37
    private ?string $pattern = null;
38
39
    /** @internal */
40
    private readonly SlugifyInterface $slugify;
41
    private array $constrains = [];
42
    private array $defaults = [];
43
    private bool $matchHost = false;
44
    private string $prefix = '';
45
    private string $basePath = '/';
46
    private ?string $compiled = null;
47
    private ?string $template = null;
48
    private array $options = [];
49
50 409
    public function __construct(
51
        private readonly UriFactoryInterface $uriFactory,
52
        SlugifyInterface $slugify = null
53
    ) {
54 409
        $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...
55
    }
56
57
    public function getPattern(): ?string
58
    {
59
        return $this->pattern;
60
    }
61
62 348
    public function withConstrains(array $constrains, array $defaults = []): self
63
    {
64 348
        $uriHandler = clone $this;
65 348
        $uriHandler->compiled = null;
66 348
        $uriHandler->constrains = $constrains;
67 348
        $uriHandler->defaults = $defaults;
68
69 348
        return $uriHandler;
70
    }
71
72 1
    public function getConstrains(): array
73
    {
74 1
        return $this->constrains;
75
    }
76
77 290
    public function withPrefix(string $prefix): self
78
    {
79 290
        $uriHandler = clone $this;
80 290
        $uriHandler->compiled = null;
81 290
        $uriHandler->prefix = \trim($prefix, '/');
82
83 290
        return $uriHandler;
84
    }
85
86 109
    public function getPrefix(): string
87
    {
88 109
        return $this->prefix;
89
    }
90
91 329
    public function withBasePath(string $basePath): self
92
    {
93 329
        if (!\str_ends_with($basePath, '/')) {
94
            $basePath .= '/';
95
        }
96
97 329
        $uriHandler = clone $this;
98 329
        $uriHandler->compiled = null;
99 329
        $uriHandler->basePath = $basePath;
100
101 329
        return $uriHandler;
102
    }
103
104
    public function getBasePath(): string
105
    {
106
        return $this->basePath;
107
    }
108
109 366
    public function withPattern(string $pattern): self
110
    {
111 366
        $uriHandler = clone $this;
112 366
        $uriHandler->pattern = $pattern;
113 366
        $uriHandler->compiled = null;
114 366
        $uriHandler->matchHost = \str_starts_with($pattern, self::HOST_PREFIX);
115
116 366
        return $uriHandler;
117
    }
118
119 102
    public function isCompiled(): bool
120
    {
121 102
        return $this->compiled !== null;
122
    }
123
124
    /**
125
     * Match given url against compiled template and return matches array or null if pattern does
126
     * not match.
127
     */
128 89
    public function match(UriInterface $uri, array $defaults): ?array
129
    {
130 89
        if (!$this->isCompiled()) {
131 89
            $this->compile();
132
        }
133
134 84
        $matches = [];
135 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

135
        if (!\preg_match(/** @scrutinizer ignore-type */ $this->compiled, $this->fetchTarget($uri), $matches)) {
Loading history...
136 41
            return null;
137
        }
138
139 75
        $matches = \array_intersect_key(
140 75
            \array_filter($matches, static fn (string $value) => $value !== ''),
141 75
            $this->options
142
        );
143
144 75
        return \array_merge($this->options, $defaults, $matches);
145
    }
146
147
    /**
148
     * Generate Uri for a given parameters and default values.
149
     */
150 13
    public function uri(iterable $parameters = [], array $defaults = []): UriInterface
151
    {
152 13
        if (!$this->isCompiled()) {
153 13
            $this->compile();
154
        }
155
156 13
        $parameters = \array_merge(
157 13
            $this->options,
158
            $defaults,
159 13
            $this->fetchOptions($parameters, $query)
160
        );
161
162 13
        foreach ($this->constrains as $key => $_) {
163 11
            if (empty($parameters[$key])) {
164 2
                throw new UriHandlerException(\sprintf('Unable to generate Uri, parameter `%s` is missing', $key));
165
            }
166
        }
167
168
        //Uri without empty blocks (pretty stupid implementation)
169 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

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