Passed
Push — master ( 83779e...f4b75d )
by Evgeniy
01:22
created

Route   D

Complexity

Total Complexity 58

Size/Duplication

Total Lines 443
Duplicated Lines 0 %

Test Coverage

Coverage 99.12%

Importance

Changes 3
Bugs 0 Features 0
Metric Value
eloc 101
c 3
b 0
f 0
dl 0
loc 443
ccs 113
cts 114
cp 0.9912
rs 4.5599
wmc 58

23 Methods

Rating   Name   Duplication   Size   Complexity  
A getHost() 0 3 1
A __construct() 0 12 3
A getHandler() 0 3 1
A getPattern() 0 3 1
A defaults() 0 11 3
A getTokens() 0 3 1
A getMatchedParameters() 0 3 1
A getMethods() 0 3 1
B path() 0 26 7
A host() 0 4 1
A getName() 0 3 1
A getDefaults() 0 3 1
A isAllowedMethod() 0 3 2
A tokens() 0 16 5
B match() 0 28 7
B url() 0 18 8
A isOptionalParameter() 0 3 1
A normalizeParameter() 0 21 5
A isMatchedHost() 0 3 2
A getPatternParameterReplacement() 0 3 1
A parsePatternOptionalParameters() 0 3 1
A getPatternOptionalParametersReplacement() 0 10 2
A isRootPath() 0 3 2

How to fix   Complexity   

Complex Class

Complex classes like Route 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 Route, and based on these observations, apply Extract Interface, too.

1
<?php
2
3
declare(strict_types=1);
4
5
namespace HttpSoft\Router;
6
7
use HttpSoft\Router\Exception\InvalidRouteParameterException;
8
use Psr\Http\Message\ServerRequestInterface;
9
10
use function array_filter;
11
use function array_key_exists;
12
use function explode;
13
use function in_array;
14
use function is_scalar;
15
use function is_string;
16
use function preg_match;
17
use function preg_replace_callback;
18
use function rawurldecode;
19
use function strtoupper;
20
use function str_replace;
21
use function trim;
22
23
final class Route
24
{
25
    /**
26
     * The regexp pattern for placeholder (parameter name).
27
     */
28
    private const PLACEHOLDER = '~(?:\{([a-zA-Z_][a-zA-Z0-9_-]*|\[[\/a-zA-Z_][\/a-zA-Z0-9_-]*\])\})~';
29
30
    /**
31
     * The default regexp pattern for parameter token.
32
     */
33
    private const DEFAULT_TOKEN = '[^\/]+';
34
35
    /**
36
     * The default regexp for an empty path pattern or "/".
37
     */
38
    private const ROOT_PATH_PATTERN = '\/?';
39
40
    /**
41
     * @var string unique route name.
42
     */
43
    private string $name;
44
45
    /**
46
     * @var string path pattern with parameters.
47
     */
48
    private string $pattern;
49
50
    /**
51
     * @var mixed action, controller, callable, closure, etc.
52
     */
53
    private $handler;
54
55
    /**
56
     * @var string[] allowed request methods of the route.
57
     */
58
    private array $methods = [];
59
60
    /**
61
     * @var array<string, string|null> parameter names and regexp tokens.
62
     */
63
    private array $tokens = [];
64
65
    /**
66
     * @var array<string, string> parameter names and default parameter values.
67
     */
68
    private array $defaults = [];
69
70
    /**
71
     * @var string|null hostname or host regexp.
72
     */
73
    private ?string $host = null;
74
75
    /**
76
     * @var array<string, string> matched parameter names and matched parameter values.
77
     */
78
    private array $matchedParameters = [];
79
80
    /**
81
     * @param string $name unique route name.
82
     * @param string $pattern path pattern with parameters.
83
     * @param mixed $handler action, controller, callable, closure, etc.
84
     * @param array $methods allowed request methods of the route.
85
     * @psalm-suppress MixedAssignment
86
     */
87 143
    public function __construct(string $name, string $pattern, $handler, array $methods = [])
88
    {
89 143
        $this->name = $name;
90 143
        $this->pattern = $pattern;
91 143
        $this->handler = $handler;
92
93 143
        foreach ($methods as $method) {
94 56
            if (!is_string($method)) {
95 9
                throw InvalidRouteParameterException::forMethods($method);
96
            }
97
98 47
            $this->methods[] = strtoupper($method);
99
        }
100 134
    }
101
102
    /**
103
     * Gets the unique route name.
104
     *
105
     * @return string
106
     */
107 89
    public function getName(): string
108
    {
109 89
        return $this->name;
110
    }
111
112
    /**
113
     * Gets the path pattern with parameters.
114
     *
115
     * @return string
116
     */
117 57
    public function getPattern(): string
118
    {
119 57
        return $this->pattern;
120
    }
121
122
    /**
123
     * Gets the route handler.
124
     *
125
     * @return mixed
126
     */
127 64
    public function getHandler()
128
    {
129 64
        return $this->handler;
130
    }
131
132
    /**
133
     * Gets the allowed request methods of the route.
134
     *
135
     * @return string[]
136
     */
137 17
    public function getMethods(): array
138
    {
139 17
        return $this->methods;
140
    }
141
142
    /**
143
     * Gets the parameter tokens, as `parameter names` => `regexp tokens`.
144
     *
145
     * @return array<string, string|null>
146
     */
147 9
    public function getTokens(): array
148
    {
149 9
        return $this->tokens;
150
    }
151
152
    /**
153
     * Gets the default parameter values, as `parameter names` => `default values`.
154
     *
155
     * @return array<string, string>
156
     */
157 6
    public function getDefaults(): array
158
    {
159 6
        return $this->defaults;
160
    }
161
162
    /**
163
     * Gets the host of the route, or null if no host has been set.
164
     *
165
     * @return string
166
     */
167 3
    public function getHost(): ?string
168
    {
169 3
        return $this->host;
170
    }
171
172
    /**
173
     * Gets the matched parameters as `parameter names` => `parameter values`.
174
     *
175
     * The matched parameters appear may after successful execution of the `match()` method.
176
     *
177
     * @return array<string, string>
178
     * @see match()
179
     */
180 10
    public function getMatchedParameters(): array
181
    {
182 10
        return $this->matchedParameters;
183
    }
184
185
    /**
186
     * Checks whether the request method is allowed for the current route.
187
     *
188
     * @param string $method
189
     * @return bool
190
     */
191 13
    public function isAllowedMethod(string $method): bool
192
    {
193 13
        return ($this->methods === [] || in_array(strtoupper($method), $this->methods, true));
194
    }
195
196
    /**
197
     * Adds the parameter tokens.
198
     *
199
     * @param array<string, mixed> $tokens `parameter names` => `regexp tokens`
200
     * @return self
201
     * @throws InvalidRouteParameterException if the parameter token is not scalar or null.
202
     * @psalm-suppress MixedAssignment
203
     */
204 86
    public function tokens(array $tokens): self
205
    {
206 86
        foreach ($tokens as $key => $token) {
207 81
            if ($token === null) {
208 4
                $this->tokens[$key] = null;
209 4
                continue;
210
            }
211
212 81
            if (!is_string($token) || $token === '') {
213 9
                throw InvalidRouteParameterException::forTokens($token);
214
            }
215
216 72
            $this->tokens[$key] = $token;
217
        }
218
219 77
        return $this;
220
    }
221
222
    /**
223
     * Adds the default parameter values.
224
     *
225
     * @param array<string, mixed> $defaults `parameter names` => `default values`
226
     * @return self
227
     * @throws InvalidRouteParameterException if the default parameter value is not scalar.
228
     * @psalm-suppress MixedAssignment
229
     */
230 26
    public function defaults(array $defaults): self
231
    {
232 26
        foreach ($defaults as $key => $default) {
233 14
            if (!is_scalar($default)) {
234 5
                throw InvalidRouteParameterException::forDefaults($default);
235
            }
236
237 9
            $this->defaults[$key] = (string) $default;
238
        }
239
240 21
        return $this;
241
    }
242
243
    /**
244
     * Sets the route host.
245
     *
246
     * @param string $host hostname or host regexp.
247
     * @return self
248
     */
249 14
    public function host(string $host): self
250
    {
251 14
        $this->host = trim($host, '/');
252 14
        return $this;
253
    }
254
255
    /**
256
     * Checks whether the request URI matches the current route.
257
     *
258
     * If there is a match and the route has matched parameters, they will
259
     * be saved and available via the `Route::getMatchedParameters()` method.
260
     *
261
     * @param ServerRequestInterface $request
262
     * @return bool whether the route matches the request URI.
263
     * @psalm-suppress MixedArgument
264
     * @psalm-suppress MixedAssignment
265
     */
266 43
    public function match(ServerRequestInterface $request): bool
267
    {
268 43
        $this->matchedParameters = [];
269
270 43
        if (!$this->isMatchedHost($request->getUri()->getHost())) {
271
            return false;
272
        }
273
274 43
        $pattern = !$this->isRootPath() ? preg_replace_callback(self::PLACEHOLDER, function (array $matches): string {
275 28
            $parameter = $matches[1];
276
277 28
            return ($this->isOptionalParameter($parameter))
278 17
                ? $this->getPatternOptionalParametersReplacement($parameter)
279 28
                : $this->getPatternParameterReplacement($parameter)
280
            ;
281 43
        }, $this->pattern) : self::ROOT_PATH_PATTERN;
282
283 43
        if (preg_match('~^' . $pattern . '$~i', rawurldecode($request->getUri()->getPath()), $matches)) {
284 35
            foreach ($matches as $key => $parameter) {
285 35
                if (is_string($key)) {
286 21
                    $this->matchedParameters[$key] = $parameter;
287
                }
288
            }
289
290 35
            return true;
291
        }
292
293 10
        return false;
294
    }
295
296
    /**
297
     * Generates the URL path from the route parameters.
298
     *
299
     * @param array $parameters parameter-value set.
300
     * @return string URL path generated.
301
     * @throws InvalidRouteParameterException if the value does not match its regexp or the required parameter is null.
302
     * @psalm-suppress MixedArgument
303
     * @psalm-suppress MixedAssignment
304
     */
305 46
    public function path(array $parameters = []): string
306
    {
307 46
        $path = preg_replace_callback(self::PLACEHOLDER, function (array $matches) use ($parameters): string {
308 44
            $parameter = $matches[1];
309
310 44
            if (!$this->isOptionalParameter($parameter)) {
311 41
                $pattern = $this->tokens[$parameter] ?? self::DEFAULT_TOKEN;
312 41
                $value = $parameters[$parameter] ?? $this->defaults[$parameter] ?? null;
313 41
                return $this->normalizeParameter($value, $parameter, $pattern, false);
314
            }
315
316 13
            $params = '';
317
318 13
            foreach ($this->parsePatternOptionalParameters($parameter) as $param) {
319 13
                $pattern = $this->tokens[$param] ?? self::DEFAULT_TOKEN;
320 13
                $value = array_key_exists($param, $parameters) ? $parameters[$param] : $this->defaults[$param] ?? null;
321
322 13
                if (($normalizeParameter = $this->normalizeParameter($value, $param, $pattern, true)) !== '') {
323 3
                    $params .= '/' . $normalizeParameter;
324
                }
325
            }
326
327 4
            return $params;
328 46
        }, $this->pattern);
329
330 15
        return ($path && $path[0] !== '/') ? '/' . $path : $path;
331
    }
332
333
    /**
334
     * Generates the URL from the route parameters.
335
     *
336
     * @param array $parameters parameter-value set.
337
     * @param string|null $host host component of the URI.
338
     * @param bool|null $secure if `true`, then `https`. If `false`, then `http`. If `null`, then without the protocol.
339
     * @return string URL generated.
340
     * @throws InvalidRouteParameterException If the host or the parameter value does not match its regexp.
341
     * @psalm-suppress PossiblyNullArgument
342
     */
343 18
    public function url(array $parameters = [], string $host = null, bool $secure = null): string
344
    {
345 18
        $path = $this->path($parameters);
346 4
        $host = $host ? trim($host, '/') : null;
347
348 4
        if (!$host) {
349 4
            return $path;
350
        }
351
352 4
        if (!$this->isMatchedHost($host)) {
353 2
            throw InvalidRouteParameterException::forNotHostMatched($host, $this->host);
354
        }
355
356 4
        if ($secure === null) {
357 4
            return '//' . $host . ($path === '/' ? '' : $path);
358
        }
359
360 4
        return ($secure ? 'https' : 'http') . '://' . $host . ($path === '/' ? '' : $path);
361
    }
362
363
    /**
364
     * Gets the replacement for required parameter in the regexp.
365
     *
366
     * @param string $parameter
367
     * @return string
368
     */
369 28
    private function getPatternParameterReplacement(string $parameter): string
370
    {
371 28
        return '(?P<' . $parameter . '>' . ($this->tokens[$parameter] ?? self::DEFAULT_TOKEN) . ')';
372
    }
373
374
    /**
375
     * Gets the replacement for optional parameters in the regexp.
376
     *
377
     * @param string $parameters
378
     * @return string
379
     */
380 17
    private function getPatternOptionalParametersReplacement(string $parameters): string
381
    {
382 17
        $head = $tail = '';
383
384 17
        foreach ($this->parsePatternOptionalParameters($parameters) as $parameter) {
385 17
            $head .= '(?:/' . $this->getPatternParameterReplacement($parameter);
386 17
            $tail .= ')?';
387
        }
388
389 17
        return $head . $tail;
390
    }
391
392
    /**
393
     * Parses the optional parameters pattern.
394
     *
395
     * @param string $parameters
396
     * @return string[]
397
     */
398 30
    private function parsePatternOptionalParameters(string $parameters): array
399
    {
400 30
        return array_filter(explode('/', trim($parameters, '[]')));
401
    }
402
403
    /**
404
     * Validates, normalizes and gets the parameter value.
405
     *
406
     * @param mixed $value
407
     * @param string $name
408
     * @param string $pattern
409
     * @param bool $optional
410
     * @return string
411
     * @throws InvalidRouteParameterException if the value does not match its regexp or the required parameter is null.
412
     */
413 44
    private function normalizeParameter($value, string $name, string $pattern, bool $optional): string
414
    {
415 44
        if ($value === null) {
416 11
            if ($optional) {
417 3
                return '';
418
            }
419
420 8
            throw InvalidRouteParameterException::forNotPassed($name);
421
        }
422
423 35
        if (!is_scalar($value)) {
424 12
            throw InvalidRouteParameterException::forNotNullOrScalar($value);
425
        }
426
427 27
        $value = (string) $value;
428
429 27
        if (!preg_match('~^' . $pattern . '$~', $value)) {
430 11
            throw InvalidRouteParameterException::forNotMatched($name, $value, $pattern);
431
        }
432
433 20
        return $value;
434
    }
435
436
    /**
437
     * Checks matches the passed host to route host.
438
     *
439
     * @param string $host
440
     * @return bool
441
     */
442 47
    private function isMatchedHost(string $host): bool
443
    {
444 47
        return (!$this->host || preg_match('~^' . str_replace('.', '\\.', $this->host) . '$~i', $host));
445
    }
446
447
    /**
448
     * Checks whether the parameter is optional.
449
     *
450
     * @param string $parameter
451
     * @return bool
452
     */
453 72
    private function isOptionalParameter(string $parameter): bool
454
    {
455 72
        return $parameter[0] === '[';
456
    }
457
458
    /**
459
     * Checks whether the path pattern is root.
460
     *
461
     * @return bool
462
     */
463 43
    private function isRootPath(): bool
464
    {
465 43
        return ($this->pattern === '' || $this->pattern === '/');
466
    }
467
}
468