Route::isAllowedMethod()   A
last analyzed

Complexity

Conditions 2
Paths 2

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 2
CRAP Score 2

Importance

Changes 2
Bugs 0 Features 0
Metric Value
cc 2
eloc 1
c 2
b 0
f 0
nc 2
nop 1
dl 0
loc 3
ccs 2
cts 2
cp 1
crap 2
rs 10
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 145
    public function __construct(string $name, string $pattern, $handler, array $methods = [])
88
    {
89 145
        $this->name = $name;
90 145
        $this->pattern = $pattern;
91 145
        $this->handler = $handler;
92
93 145
        foreach ($methods as $method) {
94 57
            if (!is_string($method)) {
95 9
                throw InvalidRouteParameterException::forMethods($method);
96
            }
97
98 48
            $this->methods[] = strtoupper($method);
99
        }
100
    }
101
102
    /**
103
     * Gets the unique route name.
104
     *
105
     * @return string
106
     */
107 90
    public function getName(): string
108
    {
109 90
        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 17
    public function isAllowedMethod(string $method): bool
192
    {
193 17
        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 87
    public function tokens(array $tokens): self
205
    {
206 87
        foreach ($tokens as $key => $token) {
207 82
            if ($token === null) {
208 4
                $this->tokens[$key] = null;
209 4
                continue;
210
            }
211
212 82
            if (!is_string($token) || $token === '') {
213 9
                throw InvalidRouteParameterException::forTokens($token);
214
            }
215
216 73
            $this->tokens[$key] = $token;
217
        }
218
219 78
        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 15
    public function host(string $host): self
250
    {
251 15
        $this->host = trim($host, '/');
252 15
        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
     */
264 45
    public function match(ServerRequestInterface $request): bool
265
    {
266 45
        $this->matchedParameters = [];
267
268 45
        if (!$this->isMatchedHost($request->getUri()->getHost())) {
269 1
            return false;
270
        }
271
272 44
        $pattern = !$this->isRootPath() ? preg_replace_callback(self::PLACEHOLDER, function (array $matches): string {
273 29
            $parameter = $matches[1];
274
275 29
            return ($this->isOptionalParameter($parameter))
276 17
                ? $this->getPatternOptionalParametersReplacement($parameter)
277 29
                : $this->getPatternParameterReplacement($parameter)
278 29
            ;
279 44
        }, $this->pattern) : self::ROOT_PATH_PATTERN;
280
281 44
        if (preg_match('~^' . $pattern . '$~i', rawurldecode($request->getUri()->getPath()), $matches)) {
282 36
            foreach ($matches as $key => $parameter) {
283 36
                if (is_string($key)) {
284 22
                    $this->matchedParameters[$key] = $parameter;
285
                }
286
            }
287
288 36
            return true;
289
        }
290
291 10
        return false;
292
    }
293
294
    /**
295
     * Generates the URL path from the route parameters.
296
     *
297
     * @param array $parameters parameter-value set.
298
     * @return string URL path generated.
299
     * @throws InvalidRouteParameterException if the value does not match its regexp or the required parameter is null.
300
     * @psalm-suppress MixedAssignment, RiskyTruthyFalsyComparison
301
     */
302 46
    public function path(array $parameters = []): string
303
    {
304 46
        $path = preg_replace_callback(self::PLACEHOLDER, function (array $matches) use ($parameters): string {
305 44
            $parameter = $matches[1];
306
307 44
            if (!$this->isOptionalParameter($parameter)) {
308 41
                $pattern = $this->tokens[$parameter] ?? self::DEFAULT_TOKEN;
309 41
                $value = $parameters[$parameter] ?? $this->defaults[$parameter] ?? null;
310 41
                return $this->normalizeParameter($value, $parameter, $pattern, false);
311
            }
312
313 13
            $params = '';
314
315 13
            foreach ($this->parsePatternOptionalParameters($parameter) as $param) {
316 13
                $pattern = $this->tokens[$param] ?? self::DEFAULT_TOKEN;
317 13
                $value = array_key_exists($param, $parameters) ? $parameters[$param] : $this->defaults[$param] ?? null;
318
319 13
                if (($normalizeParameter = $this->normalizeParameter($value, $param, $pattern, true)) !== '') {
320 3
                    $params .= '/' . $normalizeParameter;
321
                }
322
            }
323
324 4
            return $params;
325 46
        }, $this->pattern);
326
327 15
        return ($path && $path[0] !== '/') ? '/' . $path : $path;
328
    }
329
330
    /**
331
     * Generates the URL from the route parameters.
332
     *
333
     * @param array $parameters parameter-value set.
334
     * @param string|null $host host component of the URI.
335
     * @param bool|null $secure if `true`, then `https`. If `false`, then `http`. If `null`, then without the protocol.
336
     * @return string URL generated.
337
     * @throws InvalidRouteParameterException If the host or the parameter value does not match its regexp.
338
     * @psalm-suppress PossiblyNullArgument, RiskyTruthyFalsyComparison
339
     */
340 18
    public function url(array $parameters = [], ?string $host = null, ?bool $secure = null): string
341
    {
342 18
        $path = $this->path($parameters);
343 4
        $host = $host ? trim($host, '/') : null;
344
345 4
        if (!$host) {
346 4
            return $path;
347
        }
348
349 4
        if (!$this->isMatchedHost($host)) {
350 2
            throw InvalidRouteParameterException::forNotHostMatched($host, $this->host);
351
        }
352
353 4
        if ($secure === null) {
354 4
            return '//' . $host . ($path === '/' ? '' : $path);
355
        }
356
357 4
        return ($secure ? 'https' : 'http') . '://' . $host . ($path === '/' ? '' : $path);
358
    }
359
360
    /**
361
     * Gets the replacement for required parameter in the regexp.
362
     *
363
     * @param string $parameter
364
     * @return string
365
     */
366 29
    private function getPatternParameterReplacement(string $parameter): string
367
    {
368 29
        return '(?P<' . $parameter . '>' . ($this->tokens[$parameter] ?? self::DEFAULT_TOKEN) . ')';
369
    }
370
371
    /**
372
     * Gets the replacement for optional parameters in the regexp.
373
     *
374
     * @param string $parameters
375
     * @return string
376
     */
377 17
    private function getPatternOptionalParametersReplacement(string $parameters): string
378
    {
379 17
        $head = $tail = '';
380
381 17
        foreach ($this->parsePatternOptionalParameters($parameters) as $parameter) {
382 17
            $head .= '(?:/' . $this->getPatternParameterReplacement($parameter);
383 17
            $tail .= ')?';
384
        }
385
386 17
        return $head . $tail;
387
    }
388
389
    /**
390
     * Parses the optional parameters pattern.
391
     *
392
     * @param string $parameters
393
     * @return string[]
394
     */
395 30
    private function parsePatternOptionalParameters(string $parameters): array
396
    {
397 30
        return array_filter(explode('/', trim($parameters, '[]')));
398
    }
399
400
    /**
401
     * Validates, normalizes and gets the parameter value.
402
     *
403
     * @param mixed $value
404
     * @param string $name
405
     * @param string $pattern
406
     * @param bool $optional
407
     * @return string
408
     * @throws InvalidRouteParameterException if the value does not match its regexp or the required parameter is null.
409
     */
410 44
    private function normalizeParameter($value, string $name, string $pattern, bool $optional): string
411
    {
412 44
        if ($value === null) {
413 11
            if ($optional) {
414 3
                return '';
415
            }
416
417 8
            throw InvalidRouteParameterException::forNotPassed($name);
418
        }
419
420 35
        if (!is_scalar($value)) {
421 12
            throw InvalidRouteParameterException::forNotNullOrScalar($value);
422
        }
423
424 27
        $value = (string) $value;
425
426 27
        if (!preg_match('~^' . $pattern . '$~', $value)) {
427 11
            throw InvalidRouteParameterException::forNotMatched($name, $value, $pattern);
428
        }
429
430 20
        return $value;
431
    }
432
433
    /**
434
     * Checks matches the passed host to route host.
435
     *
436
     * @param string $host
437
     * @return bool
438
     * @psalm-suppress RiskyTruthyFalsyComparison
439
     */
440 49
    private function isMatchedHost(string $host): bool
441
    {
442 49
        return (!$this->host || preg_match('~^' . str_replace('.', '\\.', $this->host) . '$~i', $host));
443
    }
444
445
    /**
446
     * Checks whether the parameter is optional.
447
     *
448
     * @param string $parameter
449
     * @return bool
450
     */
451 73
    private function isOptionalParameter(string $parameter): bool
452
    {
453 73
        return $parameter[0] === '[';
454
    }
455
456
    /**
457
     * Checks whether the path pattern is root.
458
     *
459
     * @return bool
460
     */
461 44
    private function isRootPath(): bool
462
    {
463 44
        return ($this->pattern === '' || $this->pattern === '/');
464
    }
465
}
466