Test Failed
Pull Request — master (#16)
by Divine Niiquaye
13:18
created

SimpleRouteCompiler::sanitizeRequirement()   A

Complexity

Conditions 4
Paths 8

Size

Total Lines 15
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 10
CRAP Score 4

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 4
eloc 7
nc 8
nop 2
dl 0
loc 15
ccs 10
cts 10
cp 1
crap 4
rs 10
c 1
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\CompiledRoute;
21
use Flight\Routing\Exceptions\UriHandlerException;
22
use Flight\Routing\Interfaces\RouteCompilerInterface;
23
use Flight\Routing\Route;
24
25
/**
26
 * RouteCompiler compiles Route instances to regex.
27
 *
28
 * provides ability to match and generate uris based on given parameters.
29
 *
30
 * @author Divine Niiquaye Ibok <[email protected]>
31
 */
32
class SimpleRouteCompiler implements RouteCompilerInterface
33
{
34
    private const DEFAULT_SEGMENT = '[^\/]+';
35
36
    /**
37
     * This string defines the characters that are automatically considered separators in front of
38
     * optional placeholders (with default and no static text following). Such a single separator
39
     * can be left out together with the optional placeholder from matching and generating URLs.
40
     */
41
    private const PATTERN_REPLACES = ['/' => '\\/', '/[' => '\/?(?:', '[' => '(?:', ']' => ')?', '.' => '\.'];
42
43
    private const SEGMENT_REPLACES = ['/' => '\\/', '.' => '\.'];
44
45
    /**
46
     * This regex is used to match a certain rule of pattern to be used for routing.
47
     *
48
     * List of string patterns that regex matches:
49
     * - /{var} - A required variable pattern
50
     * - /[{var}] - An optional variable pattern
51
     * - /foo[/{var}] - A path with an optional sub variable pattern
52
     * - /foo[/{var}[.{format}]] - A path with optional nested variables
53
     * - /{var:[a-z]+} - A required variable with lowercase rule
54
     * - /{var=foo} - A required variable with default value
55
     * - /{var}[.{format:(html|php)=html}] - A required variable with an optional variable, a rule & default
56
     */
57
    private const COMPILER_REGEX = '#\{(\w+)(?:\:((?U).*\}|.*))?(?:\=(\w+))?\}#i';
58
59
    /**
60
     * A matching requirement helper, to ease matching route pattern when found.
61
     */
62
    private const SEGMENT_TYPES = [
63
        'int' => '\d+',
64
        'lower' => '[a-z]+',
65
        'upper' => '[A-Z]+',
66
        'alpha' => '[A-Za-z]+',
67
        'alnum' => '[A-Za-z0-9]+',
68
        'year' => '[12][0-9]{3}',
69
        'month' => '0[1-9]|1[012]+',
70
        'day' => '0[1-9]|[12][0-9]|3[01]+',
71
        '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}',
72
    ];
73
74
    /**
75
     * The maximum supported length of a PCRE subpattern name
76
     * http://pcre.org/current/doc/html/pcre2pattern.html#SEC16.
77
     *
78
     * @internal
79
     */
80
    private const VARIABLE_MAXIMUM_LENGTH = 32;
81
82
    /**
83
     * {@inheritdoc}
84
     */
85
    public function compile(Route $route, bool $reversed = false): CompiledRoute
86
    {
87
        $hostVariables = $hostRegexs = [];
88
        $requirements = $this->getRequirements($route->get('patterns'));
89
90
        if (!empty($hosts = $route->get('domain'))) {
91
            foreach ($hosts as $host) {
92
                $result = $this->compilePattern($requirements, $host, $reversed, true);
93
94
                $hostVariables += $result['variables'];
95
                $hostRegexs[] = $result['regex'];
96
            }
97
        }
98
99
        $result = $this->compilePattern($requirements, \ltrim($route->get('path'), '/'), $reversed);
100
101
        return new CompiledRoute($result['regex'], $hostRegexs, $hostVariables += $result['variables']);
102
    }
103
104 1
    /**
105
     * Get the route requirements.
106
     *
107 1
     * @param array<string,string|string[]> $requirements
108 1
     *
109 1
     * @return array<string,string|string[]>
110 1
     */
111 1
    protected function getRequirements(array $requirements): array
112 1
    {
113 1
        $newParameters = [];
114
115
        foreach ($requirements as $key => $regex) {
116
            $newParameters[$key] = \is_array($regex) ? $regex : $this->sanitizeRequirement($key, $regex);
117
        }
118
119
        return $newParameters;
120 1
    }
121
122 1
    private function sanitizeRequirement(string $key, string $regex): string
123 1
    {
124 1
        if ('^' === @$regex[0]) {
125 1
            $regex = \substr($regex, 1); // returns false for a single character
126 1
        }
127 1
128 1
        if ('$' === @$regex[-1]) {
129 1
            $regex = \substr($regex, 0, -1);
130
        }
131
132
        if (empty($regex)) {
133
            throw new UriHandlerException(\sprintf('Routing requirement for "%s" cannot be empty.', $key));
134 99
        }
135
136 99
        return $regex;
137 99
    }
138
139 95
    /**
140 14
     * @param array<string,string|string[]> $requirements
141 14
     *
142
     * @throws UriHandlerException if a variable name starts with a digit or
143 14
     *                             if it is too long to be successfully used as a PCRE subpattern or
144
     *                             if a variable is referenced more than once
145 14
     *
146 14
     * @return array<string,mixed>
147
     */
148
    private function compilePattern(array $requirements, string $uriPattern, bool $reversed, bool $isHost = false): array
149
    {
150 95
        if ('' === $uriPattern) {
151 92
            return ['regex' => $reversed ? '/' : '/^\/$/sDu', 'variables' => []];
152
        }
153 92
154 92
        // correct [/ first occurrence]
155 92
        if (0 === \strpos($uriPattern, '[/')) {
156 92
            $uriPattern = '[' . \substr($uriPattern, 2);
157 92
        }
158 92
159 92
        if (!$isHost) {
160
            $uriPattern = '/' . \substr($uriPattern, 0, isset(Route::URL_PREFIX_SLASHES[$uriPattern[-1]]) ? -1 : null);
161 92
        }
162
163
        // Match all variables enclosed in "{}" and iterate over them...
164
        \preg_match_all(self::COMPILER_REGEX, $uriPattern, $matches, \PREG_UNMATCHED_AS_NULL);
165
166
        [$variables, $replaces] = $this->computePattern($matches, $uriPattern, $reversed, $requirements);
167 10
168
        if (!$reversed) {
169 10
            $template = '/^' . \strtr($uriPattern, self::PATTERN_REPLACES + $replaces) . '$/sD' . ($isHost ? 'i' : 'u');
170
        }
171
172
        return ['regex' => $template ?? \stripslashes(\strtr($uriPattern, $replaces + ['?' => ''])), 'variables' => $variables];
173
    }
174
175
    /**
176
     * Compute prepared pattern and return it's replacements and arguments.
177 10
     *
178
     * @param array<string,string[]>        $matches
179 10
     * @param array<string,string|string[]> $requirements
180
     *
181
     * @return array<int,array<int|string,mixed>>
182
     */
183
    private function computePattern(array $matches, string $pattern, bool $isReversed, array $requirements): array
184
    {
185 42
        $variables = $replaces = [];
186
        [$placeholders, $names, $rules, $defaults] = $matches;
187 42
188
        $count = \count($names);
189
190
        foreach ($names as $index => $varName) {
191
            // Filter variable name to meet requirement
192
            $this->filterVariableName($varName, $pattern);
193
194
            if (\array_key_exists($varName, $variables)) {
195 9
                throw new UriHandlerException(
196
                    \sprintf(
197 9
                        'Route pattern "%s" cannot reference variable name "%s" more than once.',
198
                        $pattern,
199
                        $varName
200
                    )
201
                );
202
            }
203
204
            if (!$isReversed) {
205 12
                $replace = self::SEGMENT_TYPES[$rules[$index]] ?? $rules[$index] ?? $this->prepareSegment($varName, $requirements);
206
207 12
                // optimize the regex with a possessive quantifier.
208
                if (1 === $count && ('/' === $pattern[0] && '+' === @$replace[-1])) {
209
                    // This optimization cannot be applied when the next char is no real separator.
210
                    \preg_match('#\{.*\}(.+?)#', $pattern, $nextSeperator);
211
212
                    $replace .= !(isset($nextSeperator[1]) && (1 === \count($names) || '{' === $nextSeperator[1])) ? '+' : '';
213
                }
214
215 85
                $replace = \sprintf('(?P<%s>%s)', $varName, $replace);
216
            }
217 85
218
            $replaces[$placeholders[$index]] = $replace ?? "<$varName>";
219
            $variables[$varName] = $defaults[$index] ?? null;
220
221
            --$count;
222
        }
223
224
        return [$variables, $replaces];
225 70
    }
226
227 70
    /**
228
     * Prevent variables with same name used more than once.
229
     */
230
    private function filterVariableName(string $varName, string $pattern): void
231
    {
232
        // A PCRE subpattern name must start with a non-digit. Also a PHP variable cannot start with a digit so the
233
        // variable would not be usable as a Controller action argument.
234
        if (1 === \preg_match('/^\d/', $varName)) {
235 1
            throw new UriHandlerException(
236
                \sprintf(
237 1
                    'Variable name "%s" cannot start with a digit in route pattern "%s". Use a different name.',
238
                    $varName,
239
                    $pattern
240
                )
241
            );
242
        }
243
244
        if (\strlen($varName) > self::VARIABLE_MAXIMUM_LENGTH) {
245
            throw new UriHandlerException(
246
                \sprintf(
247 1
                    'Variable name "%s" cannot be longer than %s characters in route pattern "%s".',
248
                    $varName,
249 1
                    self::VARIABLE_MAXIMUM_LENGTH,
250 1
                    $pattern
251
                )
252
            );
253
        }
254
    }
255
256
    /**
257
     * Prepares segment pattern with given constrains.
258
     *
259 99
     * @param array<string,mixed> $requirements
260
     */
261 99
    private function prepareSegment(string $name, array $requirements): string
262
    {
263 99
        if (!isset($requirements[$name])) {
264 12
            return self::DEFAULT_SEGMENT;
265
        }
266
267 95
        if (\is_array($requirements[$name])) {
268
            $values = \array_map([$this, 'filterSegment'], $requirements[$name]);
269
270 9
            return \implode('|', $values);
271
        }
272 9
273 2
        return $this->filterSegment((string) $requirements[$name]);
274
    }
275
276 9
    private function filterSegment(string $segment): string
277 2
    {
278
        return \strtr($segment, self::SEGMENT_REPLACES);
279
    }
280
}
281