Test Failed
Push — master ( 08de7a...d45f24 )
by Divine Niiquaye
11:57
created

RouteCompiler::build()   A

Complexity

Conditions 6
Paths 6

Size

Total Lines 24
Code Lines 13

Duplication

Lines 0
Ratio 0 %

Importance

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