Test Failed
Push — master ( d3660e...c7a4a9 )
by Divine Niiquaye
10:08
created

SimpleRouteCompiler::compilePattern()   A

Complexity

Conditions 4
Paths 4

Size

Total Lines 23
Code Lines 11

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 9
CRAP Score 4.016

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 11
c 1
b 0
f 0
dl 0
loc 23
rs 9.9
ccs 9
cts 10
cp 0.9
cc 4
nc 4
nop 3
crap 4.016
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
    public function __serialize(): array
105
    {
106 2
        return [
107
            'vars'           => $this->variables,
108
            'template_regex' => $this->template,
109 2
            'host_template'  => $this->hostTemplate,
110 2
            'path_regex'     => $this->compiled,
111 2
            'path_vars'      => $this->pathVariables,
112 2
            'host_regex'     => $this->hostRegex,
113 2
            'host_vars'      => $this->hostVariables,
114 2
        ];
115 2
    }
116
117
    /**
118
     * @param array<string,mixed> $data
119
     */
120
    public function __unserialize(array $data): void
121
    {
122 2
        $this->variables     = $data['vars'];
123
        $this->template      = $data['template_regex'];
124 2
        $this->compiled      = $data['path_regex'];
125 2
        $this->pathVariables = $data['path_vars'];
126 2
        $this->hostRegex     = $data['host_regex'];
127 2
        $this->hostTemplate  = $data['host_template'];
128 2
        $this->hostVariables = $data['host_vars'];
129 2
    }
130 2
131 2
    /**
132
     * Match the RouteInterface instance and compiles the current route instance.
133
     */
134
    public function compile(Route $route): self
135
    {
136
        $hostVariables = $hostRegex = $hostTemplate = [];
137
        $requirements  = $this->getRequirements($route->getPatterns());
138
139
        if ([] !== $hosts = $route->getDomain()) {
140 93
            foreach (\array_keys($hosts) as $host) {
141
                $result = $this->compilePattern($requirements, $host, true);
142 93
143 93
                $hostVariables += $result['variables'];
144
145 93
                $hostRegex[]    = $result['regex'] . 'i';
146 16
                $hostTemplate[] = $result['template'];
147
            }
148 16
        }
149 16
150 16
        $result        = $this->compilePattern($requirements, $route->getPath());
151
        $pathVariables = $result['variables'];
152
153 93
        $this->compiled      = $result['regex'] . 'u';
154 86
        $this->template      = $result['template'];
155
        $this->pathVariables = $pathVariables;
156 86
        $this->hostRegex     = $hostRegex;
157 86
        $this->hostTemplate  = $hostTemplate;
158 86
        $this->hostVariables = $hostVariables;
159 86
        $this->variables     = \array_merge($hostVariables, $pathVariables);
160 86
161 86
        return $this;
162 86
    }
163
164 86
    /**
165
     * The path template regex for matching.
166
     */
167
    public function getPathTemplate(): string
168
    {
169
        return $this->template;
170
    }
171
172
    /**
173
     * The hosts template regex for matching.
174 9
     *
175
     * @return string[] The static regexps
176 9
     */
177
    public function getHostTemplate(): array
178
    {
179
        return $this->hostTemplate;
180
    }
181
182
    /**
183
     * Returns the path regex.
184 29
     */
185
    public function getRegex(): string
186 29
    {
187
        return $this->compiled;
188
    }
189
190
    /**
191
     * Returns the hosts regex.
192
     *
193
     * @return string[] The hosts regex
194 14
     */
195
    public function getHostsRegex(): array
196 14
    {
197
        return $this->hostRegex;
198
    }
199
200
    /**
201
     * Returns the variables.
202
     *
203
     * @return array<int|string,string> The variables
204 11
     */
205
    public function getVariables(): array
206 11
    {
207
        return $this->variables;
208
    }
209
210
    /**
211
     * Returns the path variables.
212
     *
213
     * @return array<int|string,string> The variables
214 71
     */
215
    public function getPathVariables(): array
216 71
    {
217
        return $this->pathVariables;
218
    }
219
220
    /**
221
     * Returns the host variables.
222
     *
223
     * @return array<int|string,string> The variables
224 55
     */
225
    public function getHostVariables(): array
226 55
    {
227
        return $this->hostVariables;
228
    }
229
230
    /**
231
     * {@inheritdoc}
232
     *
233
     * @internal
234 1
     */
235
    final public function serialize(): string
236 1
    {
237
        return \serialize($this->__serialize());
238
    }
239
240
    /**
241
     * {@inheritdoc}
242
     *
243
     * @param string $serialized the string representation of the object
244
     *
245
     * @internal
246 1
     */
247
    final public function unserialize($serialized): void
248 1
    {
249 1
        $this->__unserialize(\unserialize($serialized, ['allowed_classes' => false]));
250
    }
251
252
    /**
253
     * Get the route requirements.
254
     *
255
     * @param array<string,string|string[]> $requirements
256
     *
257
     * @return array<string,string|string[]>
258 93
     */
259
    protected function getRequirements(array $requirements): array
260 93
    {
261
        $newParameters = [];
262 93
263 11
        foreach ($requirements as $key => $regex) {
264
            $newParameters[$key] = \is_array($regex) ? $regex : $this->sanitizeRequirement($key, $regex);
265
        }
266 89
267
        return $newParameters;
268
    }
269 8
270
    private function sanitizeRequirement(string $key, string $regex): string
271 8
    {
272 2
        if ('' !== $regex && \strpos($regex, '^') === 0) {
273
            $regex = \substr($regex, 1); // returns false for a single character
274
        }
275 8
276 2
        if ('$' === \substr($regex, -1)) {
277
            $regex = \substr($regex, 0, -1);
278
        }
279 8
280 4
        if ('' === $regex) {
281
            throw new UriHandlerException(\sprintf('Routing requirement for "%s" cannot be empty.', $key));
282
        }
283 4
284
        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
    private function compilePattern(array $requirements, string $uriPattern, bool $isHost = false): array
297 93
    {
298
        if (\strlen($uriPattern) > 1) {
299 93
            $uriPattern = \trim($uriPattern, '/');
300 93
        }
301
302
        // correct [/ first occurrence]
303
        if (\strpos($uriPattern, '[/') === 0) {
304 93
            $uriPattern = '[' . \substr($uriPattern, 2);
305
        }
306
307
        // Match all variables enclosed in "{}" and iterate over them...
308
        \preg_match_all(self::COMPILER_REGEX, $pattern = (!$isHost ? '/' : '') . $uriPattern, $matches);
309 93
310
        [$variables, $replaces] = $this->computePattern($matches, $pattern, $requirements);
311
312 93
        // Return only grouped named captures.
313 93
        $template = (string) \preg_replace(self::COMPILER_REGEX, '<\1>', $pattern);
314
315 93
        return [
316
            'template'  => \stripslashes(\str_replace('?', '', $template)),
317
            'regex'     => '/^' . \strtr($template, $replaces) . '$/sD',
318 86
            'variables' => $variables,
319 86
        ];
320 86
    }
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
    private function computePattern(array $matches, string $pattern, array $requirements): array
331
    {
332
        $variables = $replaces = [];
333 93
        [, $names, $rules, $defaults] = $matches;
334
335 93
        $count = \count($names);
336 93
337 89
        foreach ($names as $index => $varName) {
338 88
            // Filter variable name to meet requirement
339 88
            $this->filterVariableName($varName, $pattern);
340
341 88
            if (\array_key_exists($varName, $variables)) {
342
                throw new UriHandlerException(
343
                    \sprintf(
344 47
                        'Route pattern "%s" cannot reference variable name "%s" more than once.',
345 1
                        $pattern,
346 1
                        $varName
347 1
                    )
348 1
                );
349 1
            }
350
351
            if (isset($rules[$index])) {
352
                $replace = $this->prepareSegment($varName, $rules[$index], $requirements);
353
354 46
                // optimize the regex with a possessive quantifier.
355 1
                if ($count === 1 && ('/' === $pattern[0] && '+' === $replace[-1])) {
356 1
                    // This optimization cannot be applied when the next char is no real separator.
357 1
                    preg_match('#\{.*\}(.+?)#', $pattern, $nextSeperator);
358 1
359 1
                    $replace .= !(isset($nextSeperator[1]) && (\count($names) === 1 || '{' === $nextSeperator[1])) ? '+' : '';
360 1
                }
361
362
                $replaces["<$varName>"] = \sprintf('(?P<%s>%s)', $varName, $replace);
363
            }
364
365
            $variables[$varName] = !empty($defaults[$index]) ? $defaults[$index] : null;
366 45
367 7
            $count--;
368
        }
369
370 45
        return [$variables, $replaces + self::PATTERN_REPLACES];
371 45
    }
372
373
    /**
374 86
     * Prevent variables with same name used more than once.
375
     */
376
    private function filterVariableName(string $varName, string $pattern): void
377
    {
378
        // A PCRE subpattern name must start with a non-digit. Also a PHP variable cannot start with a digit so the
379
        // variable would not be usable as a Controller action argument.
380
        if (1 === \preg_match('/^\d/', $varName)) {
381
            throw new UriHandlerException(
382
                \sprintf(
383
                    'Variable name "%s" cannot start with a digit in route pattern "%s". Use a different name.',
384
                    $varName,
385 89
                    $pattern
386
                )
387 89
            );
388
        }
389 89
390 48
        if (\strlen($varName) > self::VARIABLE_MAXIMUM_LENGTH) {
391 1
            throw new UriHandlerException(
392 1
                \sprintf(
393 1
                    'Variable name "%s" cannot be longer than %s characters in route pattern "%s".',
394 1
                    $varName,
395 1
                    self::VARIABLE_MAXIMUM_LENGTH,
396
                    $pattern
397
                )
398
            );
399
        }
400 48
    }
401
402
    /**
403 88
     * Prepares segment pattern with given constrains.
404
     *
405
     * @param array<string,mixed> $requirements
406
     *
407
     * @return string
408
     */
409
    private function prepareSegment(string $name, string $segment, array $requirements): string
410
    {
411
        if ($segment !== '') {
412
            return self::SEGMENT_TYPES[$segment] ?? $segment;
413
        }
414
415 45
        if (!isset($requirements[$name])) {
416
            return self::DEFAULT_SEGMENT;
417 45
        }
418 15
419
        if (\is_array($requirements[$name])) {
420
            $values = \array_map([$this, 'filterSegment'], $requirements[$name]);
421 35
422 31
            return \implode('|', $values);
423
        }
424
425 7
        return $this->filterSegment((string) $requirements[$name]);
426 3
    }
427
428 3
    private function filterSegment(string $segment): string
429
    {
430
        return \strtr($segment, self::SEGMENT_REPLACES);
431 4
    }
432
}
433