Passed
Push — master ( 8d099e...79397a )
by Divine Niiquaye
02:48
created

RouteCompiler::build()   B

Complexity

Conditions 10
Paths 22

Size

Total Lines 39
Code Lines 20

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 21
CRAP Score 10

Importance

Changes 0
Metric Value
cc 10
eloc 20
c 0
b 0
f 0
nc 22
nop 1
dl 0
loc 39
ccs 21
cts 21
cp 1
crap 10
rs 7.6666

How to fix   Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

1
<?php
2
3
declare(strict_types=1);
4
5
/*
6
 * This file is part of Flight Routing.
7
 *
8
 * PHP version 7.4 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;
19
20
use Flight\Routing\Exceptions\{UriHandlerException, UrlGenerationException};
21
use Flight\Routing\Generator\{GeneratedRoute, GeneratedUri, RegexGenerator};
22
use Flight\Routing\Interfaces\{RouteCompilerInterface, RouteGeneratorInterface};
23
24
/**
25
 * RouteCompiler compiles Route instances to regex.
26
 *
27
 * provides ability to match and generate uris based on given parameters.
28
 *
29
 * @author Divine Niiquaye Ibok <[email protected]>
30
 */
31
final class RouteCompiler implements RouteCompilerInterface
32
{
33
    private const DEFAULT_SEGMENT = '[^\/]+';
34
35
    /**
36
     * This string defines the characters that are automatically considered separators in front of
37
     * optional placeholders (with default and no static text following). Such a single separator
38
     * can be left out together with the optional placeholder from matching and generating URLs.
39
     */
40
    private const PATTERN_REPLACES = ['/' => '\\/', '/[' => '\/?(?:', '[' => '(?:', ']' => ')?', '.' => '\.'];
41
42
    /**
43
     * Using the strtr function is faster than the preg_quote function.
44
     */
45
    private const SEGMENT_REPLACES = ['/' => '\\/', '.' => '\.'];
46
47
    /**
48
     * This regex is used to match a certain rule of pattern to be used for routing.
49
     *
50
     * List of string patterns that regex matches:
51
     * - /{var} - A required variable pattern
52
     * - /[{var}] - An optional variable pattern
53
     * - /foo[/{var}] - A path with an optional sub variable pattern
54
     * - /foo[/{var}[.{format}]] - A path with optional nested variables
55
     * - /{var:[a-z]+} - A required variable with lowercase rule
56
     * - /{var=foo} - A required variable with default value
57
     * - /{var}[.{format:(html|php)=html}] - A required variable with an optional variable, a rule & default
58
     */
59
    private const COMPILER_REGEX = '~\{(\w+)(?:\:(.*?\}?))?(?:\=(\w+))?\}~iu';
60
61
    /**
62
     * This regex is used to reverse a pattern path, matching required and options vars.
63
     */
64
    private const REVERSED_REGEX = '#(?|\<(\w+)\>|(\[(.*)]))#';
65
66
    /**
67
     * This regex is used to strip off a name attached to a group in a regex pattern.
68
     */
69
    private const STRIP_REGEX = '#\?(?|P<\w+>|<\w+>|\'\w+\')#';
70
71
    /**
72
     * A matching requirement helper, to ease matching route pattern when found.
73
     */
74
    private const SEGMENT_TYPES = [
75
        'int' => '\d+',
76
        'lower' => '[a-z]+',
77
        'upper' => '[A-Z]+',
78
        'alpha' => '[A-Za-z]+',
79
        'alnum' => '[A-Za-z0-9]+',
80
        'year' => '[12][0-9]{3}',
81
        'month' => '0[1-9]|1[012]+',
82
        'day' => '0[1-9]|[12][0-9]|3[01]+',
83
        '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}',
84
    ];
85
86
    /**
87
     * A helper in reversing route pattern to URI.
88
     */
89
    private const URI_FIXERS = [
90
        '[]' => '',
91
        '[/]' => '',
92
        '[' => '',
93
        ']' => '',
94
        '://' => '://',
95
        '//' => '/',
96
        '/..' => '/%2E%2E',
97
        '/.' => '/%2E',
98
    ];
99
100
    /**
101
     * The maximum supported length of a PCRE subpattern name
102
     * http://pcre.org/current/doc/html/pcre2pattern.html#SEC16.
103
     *
104
     * @internal
105
     */
106
    private const VARIABLE_MAXIMUM_LENGTH = 32;
107
108
    /**
109
     * {@inheritdoc}
110
     */
111 12
    public function build(RouteCollection $routes): ?RouteGeneratorInterface
112
    {
113 12
        $tree = new RegexGenerator();
114 12
        $variables = $staticRegex = $hasSlashes = [];
115
116 12
        foreach ($routes->getRoutes() as $i => $route) {
117 12
            [$pathRegex, $hostsRegex, $compiledVars] = $this->compile($route);
118 12
            $pathRegex = self::resolvePathRegex($pathRegex);
119
120 12
            foreach ($route->getMethods() as $method) {
121 12
                $variables[$method][$i][$hostsRegex ?: 0] = $compiledVars;
122
            }
123
124 12
            if ('?' === $pos = $pathRegex[-1]) {
125 8
                if (!isset(Route::URL_PREFIX_SLASHES[$pathRegex[-2]])) {
126 7
                    $pathRegex = \substr($pathRegex, 0, -1);
127
                }
128
129 8
                $tree->addRoute($pathRegex, [$pathRegex, $i]);
130
131 8
                continue;
132
            }
133
134 12
            if (isset(Route::URL_PREFIX_SLASHES[$pos])) {
135 3
                $hasSlashes[$pathRegex][] = $i;
136
137 3
                if (\array_key_exists($pathRegex = \substr($pathRegex, 0, -1), $staticRegex)) {
138 1
                    continue;
139
                }
140
            }
141
142 12
            $staticRegex[$pathRegex][] = $i;
143
        }
144
145 12
        if (!empty($compiledRegex = $tree->compile(0))) {
146 8
            $compiledRegex = '~^' . \substr($compiledRegex, 1) . '$~sDu';
147
        }
148
149 12
        return new GeneratedRoute($staticRegex + ['*' => $hasSlashes], $compiledRegex ?: null, $variables);
150
    }
151
152
    /**
153
     * {@inheritdoc}
154
     */
155 127
    public function compile(Route $route): array
156
    {
157 127
        [$pathRegex, $variables] = self::compilePattern($route->getPath(), false, $route->getPatterns());
158
159 117
        if ($hosts = $route->getHosts()) {
160 16
            $hostsRegex = [];
161
162 16
            foreach ($hosts as $host) {
163 16
                [$hostRegex, $hostVars] = self::compilePattern($host, false, $route->getPatterns());
164 16
                $variables += $hostVars;
165 16
                $hostsRegex[] = $hostRegex;
166
            }
167
168 16
            $hostsRegex = '{^' . \implode('|', $hostsRegex) . '$}ui';
169
        }
170
171 117
        if ('?' !== $pathRegex[-1]) {
172 102
            $pathRegex .= '?';
173
        }
174
175 117
        return ['{^' . $pathRegex . '$}u', $hostsRegex ?? null, $variables];
176
    }
177
178
    /**
179
     * {@inheritdoc}
180
     */
181 14
    public function generateUri(Route $route, array $parameters): GeneratedUri
182
    {
183 14
        [$pathRegex, $pathVariables] = self::compilePattern($route->getPath(), true);
184
185 14
        $defaults = $route->getDefaults();
186 14
        $createUri = new GeneratedUri(self::interpolate($pathRegex, $parameters, $defaults + $pathVariables));
187
188 13
        foreach ($route->getHosts() as $host) {
189 2
            [$hostRegex, $hostVariables] = self::compilePattern($host, true);
190
191 2
            break;
192
        }
193
194 13
        if (!empty($schemes = $route->getSchemes())) {
195 3
            $createUri->withScheme(\in_array('https', $schemes, true) ? 'https' : \end($schemes) ?? 'http');
196
197 3
            if (!isset($hostRegex)) {
198 1
                $createUri->withHost($_SERVER['HTTP_HOST'] ?? '');
199
            }
200
        }
201
202 13
        if (isset($hostRegex)) {
203 2
            $createUri->withHost(self::interpolate($hostRegex, $parameters, $defaults + ($hostVariables ?? [])));
204
        }
205
206 13
        return $createUri;
207
    }
208
209
    /**
210
     * Check for mandatory parameters then interpolate $uriRoute with given $parameters.
211
     *
212
     * @param array<int|string,mixed> $parameters
213
     * @param array<string,mixed>     $defaults
214
     */
215 14
    private static function interpolate(string $uriRoute, array $parameters, array $defaults): string
216
    {
217 14
        $required = []; // Parameters required which are missing.
218 14
        $replaces = self::URI_FIXERS;
219
220
        // Fetch and merge all possible parameters + route defaults ...
221 14
        \preg_match_all(self::REVERSED_REGEX, $uriRoute, $matches, \PREG_SET_ORDER | \PREG_UNMATCHED_AS_NULL);
222
223 14
        foreach ($matches as $matched) {
224 11
            if (3 === \count($matched) && isset($matched[2])) {
225 3
                \preg_match_all('#\<(\w+)\>#', $matched[2], $optionalVars, \PREG_SET_ORDER);
226
227 3
                foreach ($optionalVars as [$type, $var]) {
228 3
                    $replaces[$type] = $parameters[$var] ?? $defaults[$var] ?? null;
229
                }
230
231 3
                continue;
232
            }
233
234 11
            $replaces[$matched[0]] = $parameters[$matched[1]] ?? $defaults[$matched[1]] ?? null;
235
236 11
            if (null === $replaces[$matched[0]]) {
237 1
                $required[] = $matched[1];
238
            }
239
        }
240
241 14
        if (!empty($required)) {
242 1
            throw new UrlGenerationException(\sprintf('Some mandatory parameters are missing ("%s") to generate a URL for route path "%s".', \implode('", "', $required), $uriRoute));
243
        }
244
245 13
        return \strtr($uriRoute, $replaces);
246
    }
247
248 10
    private static function sanitizeRequirement(string $key, string $regex): string
249
    {
250 10
        if ('' !== $regex) {
251 9
            if ('^' === $regex[0]) {
252 2
                $regex = \substr($regex, 1);
253 7
            } elseif (0 === \strpos($regex, '\\A')) {
254 2
                $regex = \substr($regex, 2);
255
            }
256
        }
257
258 10
        if (\str_ends_with($regex, '$')) {
259 2
            $regex = \substr($regex, 0, -1);
260 8
        } elseif (\strlen($regex) - 2 === \strpos($regex, '\\z')) {
261 2
            $regex = \substr($regex, 0, -2);
262
        }
263
264 10
        if ('' === $regex) {
265 7
            throw new \InvalidArgumentException(\sprintf('Routing requirement for "%s" cannot be empty.', $key));
266
        }
267
268 3
        return \strtr($regex, self::SEGMENT_REPLACES);
269
    }
270
271
    /**
272
     * @param array<string,string|string[]> $requirements
273
     *
274
     * @throws UriHandlerException if a variable name starts with a digit or
275
     *                             if it is too long to be successfully used as a PCRE subpattern or
276
     *                             if a variable is referenced more than once
277
     */
278 139
    private static function compilePattern(string $uriPattern, bool $reversed = false, array $requirements = []): array
279
    {
280
        // A path which doesn't contain {}, should be ignored.
281 139
        if (!\str_contains($uriPattern, '{')) {
282 79
            return [\strtr($uriPattern, $reversed ? ['?' => ''] : self::SEGMENT_REPLACES), []];
283
        }
284
285 77
        $variables = []; // VarNames mapping to values use by route's handler.
286 77
        $replaces = $reversed ? ['?' => ''] : self::PATTERN_REPLACES;
287
288
        // correct [/ first occurrence]
289 77
        if (1 === \strpos($uriPattern, '[/')) {
290 3
            $uriPattern = '/[' . \substr($uriPattern, 3);
291
        }
292
293
        // Match all variables enclosed in "{}" and iterate over them...
294 77
        \preg_match_all(self::COMPILER_REGEX, $uriPattern, $matches, \PREG_SET_ORDER | \PREG_UNMATCHED_AS_NULL);
295
296 77
        foreach ($matches as [$placeholder, $varName, $segment, $default]) {
297
            // Filter variable name to meet requirement
298 77
            self::filterVariableName($varName, $uriPattern);
299
300 75
            if (\array_key_exists($varName, $variables)) {
301 1
                throw new UriHandlerException(\sprintf('Route pattern "%s" cannot reference variable name "%s" more than once.', $uriPattern, $varName));
302
            }
303
304 75
            $variables[$varName] = $default;
305 75
            $replaces[$placeholder] = !$reversed ? '(?P<' . $varName . '>' . (self::SEGMENT_TYPES[$segment] ?? $segment ?? self::prepareSegment($varName, $requirements)) . ')' : "<$varName>";
306
        }
307
308 67
        return [\strtr($uriPattern, $replaces), $variables];
309
    }
310
311
    /**
312
     * Filter variable name to meet requirements.
313
     */
314 77
    private static function filterVariableName(string $varName, string $pattern): void
315
    {
316
        // A PCRE subpattern name must start with a non-digit. Also a PHP variable cannot start with a digit so the
317
        // variable would not be usable as a Controller action argument.
318 77
        if (1 === \preg_match('/\d/A', $varName)) {
319 1
            throw new UriHandlerException(\sprintf('Variable name "%s" cannot start with a digit in route pattern "%s". Use a different name.', $varName, $pattern));
320
        }
321
322 76
        if (\strlen($varName) > self::VARIABLE_MAXIMUM_LENGTH) {
323 1
            throw new UriHandlerException(
324 1
                \sprintf(
325
                    'Variable name "%s" cannot be longer than %s characters in route pattern "%s".',
326
                    $varName,
327
                    self::VARIABLE_MAXIMUM_LENGTH,
328
                    $pattern
329
                )
330
            );
331
        }
332
    }
333
334
    /**
335
     * Prepares segment pattern with given constrains.
336
     *
337
     * @param array<string,mixed> $requirements
338
     */
339 54
    private static function prepareSegment(string $name, array $requirements): string
340
    {
341 54
        if (!\array_key_exists($name, $requirements)) {
342 43
            return self::DEFAULT_SEGMENT;
343
        }
344
345 13
        if (!\is_array($segment = $requirements[$name])) {
346 10
            return self::sanitizeRequirement($name, $segment);
347
        }
348
349 3
        return \implode('|', $segment);
350
    }
351
352
    /**
353
     * Strips starting and ending modifiers from a path regex.
354
     */
355 12
    private static function resolvePathRegex(string $pathRegex): string
356
    {
357 12
        $pos = (int) \strrpos($pathRegex, '$');
358 12
        $pathRegex = \substr($pathRegex, 1 + \strpos($pathRegex, '^'), -(\strlen($pathRegex) - $pos));
359
360 12
        if (\preg_match('/\\(\\?P\\<\w+\\>.*\\)/', $pathRegex)) {
361 8
            return \preg_replace(self::STRIP_REGEX, '', $pathRegex);
362
        }
363
364 12
        return \str_replace(['\\', '?'], '', $pathRegex);
365
    }
366
}
367