Test Failed
Branch master (effa58)
by Divine Niiquaye
02:13
created

RouteCompiler::interpolate()   B

Complexity

Conditions 7
Paths 10

Size

Total Lines 31
Code Lines 15

Duplication

Lines 0
Ratio 0 %

Importance

Changes 2
Bugs 0 Features 0
Metric Value
cc 7
eloc 15
nc 10
nop 3
dl 0
loc 31
rs 8.8333
c 2
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.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\GeneratedUri;
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
    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
     * A matching requirement helper, to ease matching route pattern when found.
68
     */
69
    private const SEGMENT_TYPES = [
70
        'int' => '\d+',
71
        'lower' => '[a-z]+',
72
        'upper' => '[A-Z]+',
73
        'alpha' => '[A-Za-z]+',
74
        'alnum' => '[A-Za-z0-9]+',
75
        'year' => '[12][0-9]{3}',
76
        'month' => '0[1-9]|1[012]+',
77
        'day' => '0[1-9]|[12][0-9]|3[01]+',
78
        '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}',
79
    ];
80
81
    /**
82
     * A helper in reversing route pattern to URI.
83
     */
84
    private const URI_FIXERS = [
85
        '[]' => '',
86
        '[/]' => '',
87
        '[' => '',
88
        ']' => '',
89
        '://' => '://',
90
        '//' => '/',
91
        '/..' => '/%2E%2E',
92
        '/.' => '/%2E',
93
    ];
94
95
    /**
96
     * The maximum supported length of a PCRE subpattern name
97
     * http://pcre.org/current/doc/html/pcre2pattern.html#SEC16.
98
     *
99
     * @internal
100
     */
101
    private const VARIABLE_MAXIMUM_LENGTH = 32;
102
103
    /**
104
     * {@inheritdoc}
105
     */
106
    public function compile(Route $route): array
107
    {
108
        $requirements = $route->get('patterns');
109
        $routePath = \ltrim($route->get('path'), '/');
0 ignored issues
show
Bug introduced by
It seems like $route->get('path') can also be of type array and null; however, parameter $string of ltrim() 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

109
        $routePath = \ltrim(/** @scrutinizer ignore-type */ $route->get('path'), '/');
Loading history...
110
        $variables = []; // The vars found in path and hosts regex.
111
112
        // Strip supported browser prefix of $routePath ...
113
        if (isset(BaseRoute::URL_PREFIX_SLASHES[@$routePath[-1]])) {
114
            $routePath = \substr($routePath, 0, -1);
115
        }
116
117
        // A path containing {} is a Dynamic route.
118
        if (\str_contains($routePath, '{')) {
119
            [$pathRegex, $variables] = self::compilePattern('/' . $routePath, false, $requirements);
0 ignored issues
show
Bug introduced by
It seems like $requirements can also be of type null; however, parameter $requirements of Flight\Routing\RouteCompiler::compilePattern() 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

119
            [$pathRegex, $variables] = self::compilePattern('/' . $routePath, false, /** @scrutinizer ignore-type */ $requirements);
Loading history...
120
        } else {
121
            $pathRegex = \strtr('/' . $routePath, self::SEGMENT_REPLACES);
122
        }
123
124
        if ($route instanceof Routes\DomainRoute) {
125
            $hosts = $route->get('hosts');
126
127
            if (!empty($hosts)) {
128
                $hostsRegex = self::compileHosts($hosts, $requirements, $variables);
129
            }
130
        }
131
132
        return [$pathRegex, $hostsRegex ?? null, $variables];
133
    }
134
135
    /**
136
     * {@inheritdoc}
137
     */
138
    public function generateUri(Route $route, array $parameters): GeneratedUri
139
    {
140
        [$pathRegex, $pathVariables] = self::compilePattern('/' . \ltrim($route->get('path'), '/'), true);
0 ignored issues
show
Bug introduced by
It seems like $route->get('path') can also be of type array and null; however, parameter $string of ltrim() 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

140
        [$pathRegex, $pathVariables] = self::compilePattern('/' . \ltrim(/** @scrutinizer ignore-type */ $route->get('path'), '/'), true);
Loading history...
141
142
        $defaults = $route->get('defaults');
143
        $createUri = new GeneratedUri(self::interpolate($pathRegex, $parameters, $defaults + $pathVariables));
144
145
        foreach ($route->get('hosts') as $host) {
146
            $compiledHost = self::compilePattern($host, true);
147
148
            if (!empty($compiledHost)) {
149
                [$hostRegex, $hostVariables] = $compiledHost;
150
151
                break;
152
            }
153
        }
154
155
        if (!empty($schemes = $route->get('schemes'))) {
156
            $createUri->withScheme(\in_array('https', $schemes, true) ? 'https' : \end($schemes) ?? 'http');
157
158
            if (!isset($hostRegex)) {
159
                $createUri->withHost($_SERVER['HTTP_HOST'] ?? '');
160
            }
161
        }
162
163
        if (isset($hostRegex)) {
164
            $createUri->withHost(self::interpolate($hostRegex, $parameters, $defaults + ($hostVariables ?? [])));
165
        }
166
167
        return $createUri;
168
    }
169
170
    /**
171
     * Check for mandatory parameters then interpolate $uriRoute with given $parameters.
172
     *
173
     * @param array<int|string,mixed> $parameters
174
     * @param array<string,mixed>     $defaults
175
     */
176
    private static function interpolate(string $uriRoute, array $parameters, array $defaults): string
177
    {
178
        $required = []; // Parameters required which are missing.
179
        $replaces = self::URI_FIXERS;
180
181
        // Fetch and merge all possible parameters + route defaults ...
182
        \preg_match_all(self::REVERSED_REGEX, $uriRoute, $matches, \PREG_SET_ORDER | \PREG_UNMATCHED_AS_NULL);
183
184
        foreach ($matches as $matched) {
185
            if (3 === \count($matched) && isset($matched[2])) {
186
                \preg_match_all('#\<(\w+)\>#', $matched[2], $optionalVars, \PREG_SET_ORDER);
187
188
                foreach ($optionalVars as [$type, $var]) {
189
                    $replaces[$type] = $parameters[$var] ?? $defaults[$var] ?? null;
190
                }
191
192
                continue;
193
            }
194
195
            $replaces[$matched[0]] = $parameters[$matched[1]] ?? $defaults[$matched[1]] ?? null;
196
197
            if (null === $replaces[$matched[0]]) {
198
                $required[] = $matched[1];
199
            }
200
        }
201
202
        if (!empty($required)) {
203
            throw new UrlGenerationException(\sprintf('Some mandatory parameters are missing ("%s") to generate a URL for route path "%s".', \implode('", "', $required), $uriRoute));
204
        }
205
206
        return \strtr($uriRoute, $replaces);
207
    }
208
209
    private static function sanitizeRequirement(string $key, string $regex): string
210
    {
211
        if ('' !== $regex) {
212
            if ('^' === $regex[0]) {
213
                $regex = \substr($regex, 1);
214
            } elseif (0 === \strpos($regex, '\\A')) {
215
                $regex = \substr($regex, 2);
216
            }
217
        }
218
219
        if (\str_ends_with($regex, '$')) {
220
            $regex = \substr($regex, 0, -1);
221
        } elseif (\strlen($regex) - 2 === \strpos($regex, '\\z')) {
222
            $regex = \substr($regex, 0, -2);
223
        }
224
225
        if ('' === $regex) {
226
            throw new \InvalidArgumentException(\sprintf('Routing requirement for "%s" cannot be empty.', $key));
227
        }
228
229
        return \strtr($regex, self::SEGMENT_REPLACES);
230
    }
231
232
    /**
233
     * @param array<string,string|string[]> $requirements
234
     *
235
     * @throws UriHandlerException if a variable name starts with a digit or
236
     *                             if it is too long to be successfully used as a PCRE subpattern or
237
     *                             if a variable is referenced more than once
238
     */
239
    private static function compilePattern(string $uriPattern, bool $reversed = false, array $requirements = []): array
240
    {
241
        $variables = []; // VarNames mapping to values use by route's handler.
242
        $replaces = $reversed ? ['?' => ''] : self::PATTERN_REPLACES;
243
244
        // correct [/ first occurrence]
245
        if (1 === \strpos($uriPattern, '[/')) {
246
            $uriPattern = '/[' . \substr($uriPattern, 3);
247
        }
248
249
        // Match all variables enclosed in "{}" and iterate over them...
250
        \preg_match_all(self::COMPILER_REGEX, $uriPattern, $matches, \PREG_SET_ORDER | \PREG_UNMATCHED_AS_NULL);
251
252
        foreach ($matches as [$placeholder, $varName, $segment, $default]) {
253
            // Filter variable name to meet requirement
254
            self::filterVariableName($varName, $uriPattern);
255
256
            if (\array_key_exists($varName, $variables)) {
257
                throw new UriHandlerException(\sprintf('Route pattern "%s" cannot reference variable name "%s" more than once.', $uriPattern, $varName));
258
            }
259
260
            $variables[$varName] = $default;
261
            $replaces[$placeholder] = !$reversed ? '(?P<' . $varName . '>' . (self::SEGMENT_TYPES[$segment] ?? $segment ?? self::prepareSegment($varName, $requirements)) . ')' : "<$varName>";
262
        }
263
264
        return [\strtr($uriPattern, $replaces), $variables];
265
    }
266
267
    /**
268
     * @param string[]                      $hosts
269
     * @param array<string,string|string[]> $requirements
270
     */
271
    private static function compileHosts(array $hosts, array $requirements, array &$variables): string
272
    {
273
        $hostsRegex = [];
274
275
        foreach ($hosts as $host) {
276
            [$hostRegex, $hostVars] = self::compilePattern($host, false, $requirements);
277
278
            $variables += $hostVars;
279
            $hostsRegex[] = $hostRegex;
280
        }
281
282
        return 1 === \count($hostsRegex) ? $hostsRegex[0] : '(?|' . \implode('|', $hostsRegex) . ')';
283
    }
284
285
    /**
286
     * Filter variable name to meet requirements.
287
     */
288
    private static function filterVariableName(string $varName, string $pattern): void
289
    {
290
        // A PCRE subpattern name must start with a non-digit. Also a PHP variable cannot start with a digit so the
291
        // variable would not be usable as a Controller action argument.
292
        if (1 === \preg_match('/^\d/', $varName)) {
293
            throw new UriHandlerException(\sprintf('Variable name "%s" cannot start with a digit in route pattern "%s". Use a different name.', $varName, $pattern));
294
        }
295
296
        if (\strlen($varName) > self::VARIABLE_MAXIMUM_LENGTH) {
297
            throw new UriHandlerException(
298
                \sprintf(
299
                    'Variable name "%s" cannot be longer than %s characters in route pattern "%s".',
300
                    $varName,
301
                    self::VARIABLE_MAXIMUM_LENGTH,
302
                    $pattern
303
                )
304
            );
305
        }
306
    }
307
308
    /**
309
     * Prepares segment pattern with given constrains.
310
     *
311
     * @param array<string,mixed> $requirements
312
     */
313
    private static function prepareSegment(string $name, array $requirements): string
314
    {
315
        if (!isset($requirements[$name])) {
316
            return self::DEFAULT_SEGMENT;
317
        }
318
319
        if (!\is_array($segment = $requirements[$name])) {
320
            return self::sanitizeRequirement($name, $segment);
321
        }
322
323
        return \implode('|', \array_map(
324
            static function (string $segment) use ($name): string {
325
                return self::sanitizeRequirement($name, $segment);
326
            },
327
            $segment
328
        ));
329
    }
330
}
331