Passed
Push — master ( 8fb003...dae8ba )
by Divine Niiquaye
11:39
created

SimpleRouteCompiler::getVariables()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 2
CRAP Score 1

Importance

Changes 0
Metric Value
cc 1
eloc 1
nc 1
nop 0
dl 0
loc 3
ccs 2
cts 2
cp 1
crap 1
rs 10
c 0
b 0
f 0
1
<?php
2
3
declare(strict_types=1);
4
5
/*
6
 * This file is part of Flight Routing.
7
 *
8
 * PHP version 7.1 and above required
9
 *
10
 * @author    Divine Niiquaye Ibok <[email protected]>
11
 * @copyright 2019 Biurad Group (https://biurad.com/)
12
 * @license   https://opensource.org/licenses/BSD-3-Clause License
13
 *
14
 * For the full copyright and license information, please view the LICENSE
15
 * file that was distributed with this source code.
16
 */
17
18
namespace Flight\Routing\Matchers;
19
20
use Flight\Routing\Exceptions\UriHandlerException;
21
use Flight\Routing\Route;
22
23
/**
24
 * RouteCompiler compiles Route instances to regex.
25
 *
26
 * provides ability to match and generate uris based on given parameters.
27
 *
28
 * @author Divine Niiquaye Ibok <[email protected]>
29
 */
30
class SimpleRouteCompiler implements \Serializable
31
{
32
    private const DEFAULT_SEGMENT = '[^\/]+';
33
34
    /**
35
     * This string defines the characters that are automatically considered separators in front of
36
     * optional placeholders (with default and no static text following). Such a single separator
37
     * can be left out together with the optional placeholder from matching and generating URLs.
38
     */
39
    private const PATTERN_REPLACES = ['/' => '\\/', '/[' => '\/?(?:', '[' => '(?:', ']' => ')?', '.' => '\.'];
40
41
    private const SEGMENT_REPLACES = ['/' => '\\/', '.' => '\.'];
42
43
    /**
44
     * This regex is used to match a certain rule of pattern to be used for routing.
45
     *
46
     * List of string patterns that regex matches:
47
     * - /{var} - A required variable pattern
48
     * - /[{var}] - An optional variable pattern
49
     * - /foo[/{var}] - A path with an optional sub variable pattern
50
     * - /foo[/{var}[.{format}]] - A path with optional nested variables
51
     * - /{var:[a-z]+} - A required variable with lowercase rule
52
     * - /{var=<foo>} - A required variable with default value
53
     * - /{var}[.{format:(html|php)=<html>}] - A required variable with an optional variable, a rule & default
54
     */
55
    private const COMPILER_REGEX = '#\{(\w+)?(?:\:([^{}=]*(?:\{(?-1)\}[^{}]?)*))?(?:\=\<([^>]+)\>)?\}#xi';
56
57
    /**
58
     * A matching requirement helper, to ease matching route pattern when found.
59
     */
60
    private const SEGMENT_TYPES = [
61
        'int'     => '\d+',
62
        'lower'   => '[a-z]+',
63
        'upper'   => '[A-Z]+',
64
        'alpha'   => '[A-Za-z]+',
65
        'alnum'   => '[A-Za-z0-9]+',
66
        'year'    => '[12][0-9]{3}',
67
        'month'   => '0[1-9]|1[012]+',
68
        'day'     => '0[1-9]|[12][0-9]|3[01]+',
69
        '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}',
70
    ];
71
72
    /**
73
     * The maximum supported length of a PCRE subpattern name
74
     * http://pcre.org/current/doc/html/pcre2pattern.html#SEC16.
75
     *
76
     * @internal
77
     */
78
    private const VARIABLE_MAXIMUM_LENGTH = 32;
79
80
    /** @var string */
81
    private $template;
82
83
    /** @var string */
84
    private $compiled;
85
86
    /** @var string[] */
87
    private $hostRegex;
88
89
    /** @var string[] */
90
    private $hostTemplate;
91
92
    /** @var array<int|string,mixed> */
93
    private $variables;
94
95
    /** @var array<int|string,mixed> */
96
    private $pathVariables;
97
98
    /** @var array<int|string,mixed> */
99
    private $hostVariables;
100
101
    /**
102
     * @return array<string,mixed>
103
     */
104 1
    public function __serialize(): array
105
    {
106
        return [
107 1
            'vars'           => $this->variables,
108 1
            'template_regex' => $this->template,
109 1
            'host_template'  => $this->hostTemplate,
110 1
            'path_regex'     => $this->compiled,
111 1
            'path_vars'      => $this->pathVariables,
112 1
            'host_regex'     => $this->hostRegex,
113 1
            'host_vars'      => $this->hostVariables,
114
        ];
115
    }
116
117
    /**
118
     * @param array<string,mixed> $data
119
     */
120 1
    public function __unserialize(array $data): void
121
    {
122 1
        $this->variables     = $data['vars'];
123 1
        $this->template      = $data['template_regex'];
124 1
        $this->compiled      = $data['path_regex'];
125 1
        $this->pathVariables = $data['path_vars'];
126 1
        $this->hostRegex     = $data['host_regex'];
127 1
        $this->hostTemplate  = $data['host_template'];
128 1
        $this->hostVariables = $data['host_vars'];
129 1
    }
130
131
    /**
132
     * Match the RouteInterface instance and compiles the current route instance.
133
     */
134 99
    public function compile(Route $route): self
135
    {
136 99
        $hostVariables = $hostRegex = $hostTemplate = [];
137 99
        $requirements  = $this->getRequirements($route->get('patterns'));
0 ignored issues
show
Bug introduced by
It seems like $route->get('patterns') can also be of type null; however, parameter $requirements of Flight\Routing\Matchers\...iler::getRequirements() does only seem to accept array, 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

137
        $requirements  = $this->getRequirements(/** @scrutinizer ignore-type */ $route->get('patterns'));
Loading history...
138
139 95
        if ([] !== $hosts = $route->get('domain')) {
140 18
            foreach ($hosts as $host => $has) {
141 18
                $result = $this->compilePattern($requirements, $host, true);
142
143 18
                $hostVariables += $result['variables'];
144
145 18
                $hostRegex[]    = $result['regex'] . 'i';
146 18
                $hostTemplate[] = $result['template'];
147
            }
148
        }
149
150 95
        $result        = $this->compilePattern($requirements, $route->get('path'));
0 ignored issues
show
Bug introduced by
It seems like $route->get('path') can also be of type null; however, parameter $uriPattern of Flight\Routing\Matchers\...piler::compilePattern() 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

150
        $result        = $this->compilePattern($requirements, /** @scrutinizer ignore-type */ $route->get('path'));
Loading history...
151 92
        $pathVariables = $result['variables'];
152
153 92
        $this->compiled      = $result['regex'] . 'u';
154 92
        $this->template      = $result['template'];
155 92
        $this->pathVariables = $pathVariables;
156 92
        $this->hostRegex     = $hostRegex;
157 92
        $this->hostTemplate  = $hostTemplate;
158 92
        $this->hostVariables = $hostVariables;
159 92
        $this->variables     = \array_merge($hostVariables, $pathVariables);
160
161 92
        return $this;
162
    }
163
164
    /**
165
     * The path template regex for matching.
166
     */
167 10
    public function getPathTemplate(): string
168
    {
169 10
        return $this->template;
170
    }
171
172
    /**
173
     * The hosts template regex for matching.
174
     *
175
     * @return string[] The static regexps
176
     */
177 10
    public function getHostTemplate(): array
178
    {
179 10
        return $this->hostTemplate;
180
    }
181
182
    /**
183
     * Returns the path regex.
184
     */
185 42
    public function getRegex(): string
186
    {
187 42
        return $this->compiled;
188
    }
189
190
    /**
191
     * Returns the hosts regex.
192
     *
193
     * @return string[] The hosts regex
194
     */
195 9
    public function getHostsRegex(): array
196
    {
197 9
        return $this->hostRegex;
198
    }
199
200
    /**
201
     * Returns the variables.
202
     *
203
     * @return array<int|string,string> The variables
204
     */
205 12
    public function getVariables(): array
206
    {
207 12
        return $this->variables;
208
    }
209
210
    /**
211
     * Returns the path variables.
212
     *
213
     * @return array<int|string,string> The variables
214
     */
215 85
    public function getPathVariables(): array
216
    {
217 85
        return $this->pathVariables;
218
    }
219
220
    /**
221
     * Returns the host variables.
222
     *
223
     * @return array<int|string,string> The variables
224
     */
225 70
    public function getHostVariables(): array
226
    {
227 70
        return $this->hostVariables;
228
    }
229
230
    /**
231
     * {@inheritdoc}
232
     *
233
     * @internal
234
     */
235 1
    final public function serialize(): string
236
    {
237 1
        return \serialize($this->__serialize());
238
    }
239
240
    /**
241
     * {@inheritdoc}
242
     *
243
     * @param string $serialized the string representation of the object
244
     *
245
     * @internal
246
     */
247 1
    final public function unserialize($serialized): void
248
    {
249 1
        $this->__unserialize(\unserialize($serialized, ['allowed_classes' => false]));
250 1
    }
251
252
    /**
253
     * Get the route requirements.
254
     *
255
     * @param array<string,string|string[]> $requirements
256
     *
257
     * @return array<string,string|string[]>
258
     */
259 99
    protected function getRequirements(array $requirements): array
260
    {
261 99
        $newParameters = [];
262
263 99
        foreach ($requirements as $key => $regex) {
264 12
            $newParameters[$key] = \is_array($regex) ? $regex : $this->sanitizeRequirement($key, $regex);
265
        }
266
267 95
        return $newParameters;
268
    }
269
270 9
    private function sanitizeRequirement(string $key, string $regex): string
271
    {
272 9
        if ('' !== $regex && \strpos($regex, '^') === 0) {
273 2
            $regex = \substr($regex, 1); // returns false for a single character
274
        }
275
276 9
        if ('$' === \substr($regex, -1)) {
277 2
            $regex = \substr($regex, 0, -1);
278
        }
279
280 9
        if ('' === $regex) {
281 4
            throw new UriHandlerException(\sprintf('Routing requirement for "%s" cannot be empty.', $key));
282
        }
283
284 5
        return $regex;
285
    }
286
287
    /**
288
     * @param array<string,string|string[]> $requirements
289
     *
290
     * @throws UriHandlerException if a variable name starts with a digit or
291
     *                             if it is too long to be successfully used as a PCRE subpattern or
292
     *                             if a variable is referenced more than once
293
     *
294
     * @return array<string,mixed>
295
     */
296 95
    private function compilePattern(array $requirements, string $uriPattern, bool $isHost = false): array
297
    {
298 95
        if (\strlen($uriPattern) > 1) {
299 95
            $uriPattern = \trim($uriPattern, '/');
300
        }
301
302
        // correct [/ first occurrence]
303 95
        if (\strpos($uriPattern, '[/') === 0) {
304 1
            $uriPattern = '[' . \substr($uriPattern, 2);
305
        }
306
307
        // Match all variables enclosed in "{}" and iterate over them...
308 95
        \preg_match_all(self::COMPILER_REGEX, $pattern = (!$isHost ? '/' : '') . $uriPattern, $matches);
309
310 95
        [$variables, $replaces] = $this->computePattern($matches, $pattern, $requirements);
311
312
        // Return only grouped named captures.
313 92
        $template = (string) \preg_replace(self::COMPILER_REGEX, '<\1>', $pattern);
314
315
        return [
316 92
            'template'  => \stripslashes(\str_replace('?', '', $template)),
317 92
            'regex'     => '/^' . \strtr($template, $replaces) . '$/sD',
318 92
            'variables' => $variables,
319
        ];
320
    }
321
322
    /**
323
     * Compute prepared pattern and return it's replacements and arguments.
324
     *
325
     * @param array<string,string[]>        $matches
326
     * @param array<string,string|string[]> $requirements
327
     *
328
     * @return array<int,array<int|string,mixed>>
329
     */
330 95
    private function computePattern(array $matches, string $pattern, array $requirements): array
331
    {
332 95
        $variables = [];
333 95
        $replaces  = self::PATTERN_REPLACES;
334 95
        [, $names, $rules, $defaults] = $matches;
335
336 95
        $count = \count($names);
337
338 95
        foreach ($names as $index => $varName) {
339
            // Filter variable name to meet requirement
340 51
            $this->filterVariableName($varName, $pattern);
341
342 49
            if (\array_key_exists($varName, $variables)) {
343 1
                throw new UriHandlerException(
344 1
                    \sprintf(
345 1
                        'Route pattern "%s" cannot reference variable name "%s" more than once.',
346 1
                        $pattern,
347 1
                        $varName
348
                    )
349
                );
350
            }
351
352 49
            if (isset($rules[$index])) {
353 49
                $replace = $this->prepareSegment($varName, $rules[$index], $requirements);
354
355
                // optimize the regex with a possessive quantifier.
356 49
                if ($count === 1 && ('/' === $pattern[0] && '+' === $replace[-1])) {
357
                    // This optimization cannot be applied when the next char is no real separator.
358 35
                    preg_match('#\{.*\}(.+?)#', $pattern, $nextSeperator);
359
360 35
                    $replace .= !(isset($nextSeperator[1]) && (\count($names) === 1 || '{' === $nextSeperator[1])) ? '+' : '';
361
                }
362
363 49
                $replaces["<$varName>"] = \sprintf('(?P<%s>%s)', $varName, $replace);
364
            }
365
366 49
            $variables[$varName] = !empty($defaults[$index]) ? $defaults[$index] : null;
367
368 49
            $count--;
369
        }
370
371 92
        return [$variables, $replaces];
372
    }
373
374
    /**
375
     * Prevent variables with same name used more than once.
376
     */
377 51
    private function filterVariableName(string $varName, string $pattern): void
378
    {
379
        // A PCRE subpattern name must start with a non-digit. Also a PHP variable cannot start with a digit so the
380
        // variable would not be usable as a Controller action argument.
381 51
        if (1 === \preg_match('/^\d/', $varName)) {
382 1
            throw new UriHandlerException(
383 1
                \sprintf(
384 1
                    'Variable name "%s" cannot start with a digit in route pattern "%s". Use a different name.',
385 1
                    $varName,
386 1
                    $pattern
387
                )
388
            );
389
        }
390
391 50
        if (\strlen($varName) > self::VARIABLE_MAXIMUM_LENGTH) {
392 1
            throw new UriHandlerException(
393 1
                \sprintf(
394 1
                    'Variable name "%s" cannot be longer than %s characters in route pattern "%s".',
395 1
                    $varName,
396 1
                    self::VARIABLE_MAXIMUM_LENGTH,
397 1
                    $pattern
398
                )
399
            );
400
        }
401 49
    }
402
403
    /**
404
     * Prepares segment pattern with given constrains.
405
     *
406
     * @param array<string,mixed> $requirements
407
     *
408
     * @return string
409
     */
410 49
    private function prepareSegment(string $name, string $segment, array $requirements): string
411
    {
412 49
        if ($segment !== '') {
413 16
            return self::SEGMENT_TYPES[$segment] ?? $segment;
414
        }
415
416 38
        if (!isset($requirements[$name])) {
417 34
            return self::DEFAULT_SEGMENT;
418
        }
419
420 8
        if (\is_array($requirements[$name])) {
421 3
            $values = \array_map([$this, 'filterSegment'], $requirements[$name]);
422
423 3
            return \implode('|', $values);
424
        }
425
426 5
        return $this->filterSegment((string) $requirements[$name]);
427
    }
428
429 8
    private function filterSegment(string $segment): string
430
    {
431 8
        return \strtr($segment, self::SEGMENT_REPLACES);
432
    }
433
}
434