Test Failed
Pull Request — master (#16)
by Divine Niiquaye
02:37
created

RouteCompiler::compile()   A

Complexity

Conditions 6
Paths 12

Size

Total Lines 25
Code Lines 13

Duplication

Lines 0
Ratio 0 %

Importance

Changes 4
Bugs 0 Features 0
Metric Value
cc 6
eloc 13
c 4
b 0
f 0
nc 12
nop 1
dl 0
loc 25
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.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;
19
20
use Flight\Routing\{GeneratedUri, Route};
0 ignored issues
show
Bug introduced by
This use statement conflicts with another class in this namespace, Flight\Routing\GeneratedUri. Consider defining an alias.

Let?s assume that you have a directory layout like this:

.
|-- OtherDir
|   |-- Bar.php
|   `-- Foo.php
`-- SomeDir
    `-- Foo.php

and let?s assume the following content of Bar.php:

// Bar.php
namespace OtherDir;

use SomeDir\Foo; // This now conflicts the class OtherDir\Foo

If both files OtherDir/Foo.php and SomeDir/Foo.php are loaded in the same runtime, you will see a PHP error such as the following:

PHP Fatal error:  Cannot use SomeDir\Foo as Foo because the name is already in use in OtherDir/Foo.php

However, as OtherDir/Foo.php does not necessarily have to be loaded and the error is only triggered if it is loaded before OtherDir/Bar.php, this problem might go unnoticed for a while. In order to prevent this error from surfacing, you must import the namespace with a different alias:

// Bar.php
namespace OtherDir;

use SomeDir\Foo as SomeDirFoo; // There is no conflict anymore.
Loading history...
Bug introduced by
This use statement conflicts with another class in this namespace, Flight\Routing\Route. Consider defining an alias.

Let?s assume that you have a directory layout like this:

.
|-- OtherDir
|   |-- Bar.php
|   `-- Foo.php
`-- SomeDir
    `-- Foo.php

and let?s assume the following content of Bar.php:

// Bar.php
namespace OtherDir;

use SomeDir\Foo; // This now conflicts the class OtherDir\Foo

If both files OtherDir/Foo.php and SomeDir/Foo.php are loaded in the same runtime, you will see a PHP error such as the following:

PHP Fatal error:  Cannot use SomeDir\Foo as Foo because the name is already in use in OtherDir/Foo.php

However, as OtherDir/Foo.php does not necessarily have to be loaded and the error is only triggered if it is loaded before OtherDir/Bar.php, this problem might go unnoticed for a while. In order to prevent this error from surfacing, you must import the namespace with a different alias:

// Bar.php
namespace OtherDir;

use SomeDir\Foo as SomeDirFoo; // There is no conflict anymore.
Loading history...
21
use Flight\Routing\Exceptions\{UriHandlerException, UrlGenerationException};
22
use Flight\Routing\Interfaces\RouteCompilerInterface;
23
24
/**
25
 * RouteCompiler compiles Route instances to regex.
26
 *
27
 * provides ability to match and generate uris based on given parameters.
28
 *
29
 * @final This class is final and recommended not to be extended unless special cases
30
 *
31
 * @author Divine Niiquaye Ibok <[email protected]>
32
 */
33
class RouteCompiler implements RouteCompilerInterface
34
{
35
    private const DEFAULT_SEGMENT = '[^\/]+';
36
37
    /**
38
     * This string defines the characters that are automatically considered separators in front of
39
     * optional placeholders (with default and no static text following). Such a single separator
40
     * can be left out together with the optional placeholder from matching and generating URLs.
41
     */
42
    private const PATTERN_REPLACES = ['/' => '\\/', '/[' => '\/?(?:', '[' => '(?:', ']' => ')?', '.' => '\.'];
43
44
    private const SEGMENT_REPLACES = ['/' => '\\/', '.' => '\.'];
45
46
    /**
47
     * This regex is used to match a certain rule of pattern to be used for routing.
48
     *
49
     * List of string patterns that regex matches:
50
     * - /{var} - A required variable pattern
51
     * - /[{var}] - An optional variable pattern
52
     * - /foo[/{var}] - A path with an optional sub variable pattern
53
     * - /foo[/{var}[.{format}]] - A path with optional nested variables
54
     * - /{var:[a-z]+} - A required variable with lowercase rule
55
     * - /{var=foo} - A required variable with default value
56
     * - /{var}[.{format:(html|php)=html}] - A required variable with an optional variable, a rule & default
57
     */
58
    private const COMPILER_REGEX = '~\\{(\\w+)(?:\\:([^{}=]+(?:\\{[\\w,^{}]+)?))?(?:\\=((?2)))?\\}~i';
59
60
    /**
61
     * A matching requirement helper, to ease matching route pattern when found.
62
     */
63
    private const SEGMENT_TYPES = [
64
        'int' => '\d+',
65
        'lower' => '[a-z]+',
66
        'upper' => '[A-Z]+',
67
        'alpha' => '[A-Za-z]+',
68
        'alnum' => '[A-Za-z0-9]+',
69
        'year' => '[12][0-9]{3}',
70
        'month' => '0[1-9]|1[012]+',
71
        'day' => '0[1-9]|[12][0-9]|3[01]+',
72
        '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}',
73
    ];
74
75
    /**
76
     * A helper in reversing route pattern to URI.
77
     */
78
    private const URI_FIXERS = [
79
        '[]' => '',
80
        '[/]' => '',
81
        '[' => '',
82
        ']' => '',
83
        '://' => '://',
84
        '//' => '/',
85
        '/..' => '/%2E%2E',
86
        '/.' => '/%2E',
87
    ];
88
89
    /**
90
     * The maximum supported length of a PCRE subpattern name
91
     * http://pcre.org/current/doc/html/pcre2pattern.html#SEC16.
92
     *
93
     * @internal
94
     */
95
    private const VARIABLE_MAXIMUM_LENGTH = 32;
96
97
    /**
98
     * {@inheritdoc}
99
     */
100
    public function compile(Route $route): array
101
    {
102
        $hostVariables = $hostsRegex = [];
103
        $requirements = $route->get('patterns');
104
105
        // Strip supported browser prefix of $routePath ...
106
        $routePath = \rtrim($routePath = $route->get('path'), Route::URL_PREFIX_SLASHES[$routePath[-1]] ?? '/');
107
108
        if (!empty($routePath) && '/' === $routePath[0]) {
109
            $routePath = \substr($routePath, 1);
110
        }
111
112
        foreach ($route->get('domain') as $host) {
113
            [$hostRegex, $hostVariable] = self::compilePattern($host, false, $requirements);
114
115
            $hostVariables += $hostVariable;
116
            $hostsRegex[] = $hostRegex;
117
        }
118
119
        if (\str_contains($routePath, '{')) {
120
            [$pathRegex, $pathVariables] = self::compilePattern('/' . $routePath, false, $requirements);
121
            $variables = empty($hostVariables) ? $pathVariables : $hostVariables += $pathVariables;
122
        }
123
124
        return [$pathRegex ?? '/' . $routePath, $hostsRegex, $variables ?? $hostVariables];
125
    }
126
127
    /**
128
     * {@inheritdoc}
129
     */
130
    public function generateUri(Route $route, array $parameters, array $defaults = []): GeneratedUri
131
    {
132
        [$pathRegex, $pathVariables] = self::compilePattern(\ltrim($route->get('path'), '/'), true);
133
134
        $pathRegex = '/' . \stripslashes(\str_replace('?', '', $pathRegex));
135
        $createUri = new GeneratedUri(self::interpolate($pathRegex, $parameters, $defaults, $pathVariables));
136
137
        foreach ($route->get('domain') as $host) {
138
            $compiledHost = self::compilePattern($host, true);
139
140
            if (!empty($compiledHost)) {
141
                [$hostRegex, $hostVariables] = $compiledHost;
142
143
                break;
144
            }
145
        }
146
147
        if (!empty($schemes = $route->get('schemes'))) {
148
            $createUri->withScheme(\in_array('https', $schemes, true) ? 'https' : \end($schemes) ?? 'http');
149
150
            if (!isset($hostRegex)) {
151
                $createUri->withHost($_SERVER['HTTP_HOST'] ?? '');
152
            }
153
        }
154
155
        if (isset($hostRegex)) {
156
            $createUri->withHost(self::interpolate($hostRegex, $parameters, $defaults, $hostVariables));
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable $hostVariables does not seem to be defined for all execution paths leading up to this point.
Loading history...
157
        }
158
159
        return $createUri;
160
    }
161
162
    /**
163
     * Check for mandatory parameters then interpolate $uriRoute with given $parameters.
164
     *
165
     * @param array<int|string,mixed> $parameters
166
     */
167
    private static function interpolate(string $uriRoute, array $parameters, array $defaults, array $allowed): string
168
    {
169
        \preg_match_all('#(?:\[)?\<(\w+).*?\>(?:\])?#', $uriRoute, $paramVars, \PREG_SET_ORDER);
170
171
        foreach ($paramVars as $offset => [$type, $var]) {
172
            if ('[' === $type[0]) {
173
                $defaults[$var] = null;
174
175
                continue;
176
            }
177
178
            if (isset($parameters[$offset])) {
179
                $defaults[$var] = $parameters[$offset];
180
                unset($parameters[$offset]);
181
            }
182
        }
183
184
        // Fetch and merge all possible parameters + route defaults ...
185
        $parameters += $defaults;
186
187
        // all params must be given
188
        if ($diff = \array_diff_key($allowed, $parameters)) {
189
            throw new UrlGenerationException(\sprintf('Some mandatory parameters are missing ("%s") to generate a URL for route path "%s".', \implode('", "', \array_keys($diff)), $uriRoute));
190
        }
191
192
        $replaces = self::URI_FIXERS;
193
194
        foreach ($parameters as $key => $value) {
195
            $replaces["<{$key}>"] = (\is_array($value) || $value instanceof \Closure) ? '' : $value;
196
        }
197
198
        return \strtr($uriRoute, $replaces);
199
    }
200
201
    private static function sanitizeRequirement(string $key, ?string $regex): string
202
    {
203
        if (!empty($regex)) {
204
            if ('^' === $regex[0]) {
205
                $regex = \substr($regex, 1);
206
            } elseif (0 === \strpos($regex, '\\A')) {
207
                $regex = \substr($regex, 2);
208
            }
209
210
            if ('$' === $regex[-1]) {
211
                $regex = \substr($regex, 0, -1);
212
            } elseif (\strlen($regex) - 2 === \strpos($regex, '\\z')) {
213
                $regex = \substr($regex, 0, -2);
214
            }
215
        }
216
217
        if ('' === $regex) {
218
            throw new \InvalidArgumentException(\sprintf('Routing requirement for "%s" cannot be empty.', $key));
219
        }
220
221
        return null !== $regex ? \strtr($regex, self::SEGMENT_REPLACES) : self::DEFAULT_SEGMENT;
222
    }
223
224
    /**
225
     * @param array<string,string|string[]> $requirements
226
     *
227
     * @throws UriHandlerException if a variable name starts with a digit or
228
     *                             if it is too long to be successfully used as a PCRE subpattern or
229
     *                             if a variable is referenced more than once
230
     *
231
     * @return array<string,mixed>
232
     */
233
    private static function compilePattern(string $uriPattern, bool $reversed = false, array $requirements = []): array
234
    {
235
        $variables = $replaces = [];
236
237
        // correct [/ first occurrence]
238
        if (0 === \strpos($uriPattern, '[/')) {
239
            $uriPattern = '[' . \substr($uriPattern, 3);
240
        }
241
242
        // Match all variables enclosed in "{}" and iterate over them...
243
        \preg_match_all(self::COMPILER_REGEX, $uriPattern, $matches, \PREG_SET_ORDER | \PREG_UNMATCHED_AS_NULL);
244
245
        foreach ($matches as [$placeholder, $varName, $segment, $default]) {
246
            // Filter variable name to meet requirement
247
            self::filterVariableName($varName, $uriPattern);
248
249
            if (\array_key_exists($varName, $variables)) {
250
                throw new UriHandlerException(\sprintf('Route pattern "%s" cannot reference variable name "%s" more than once.', $uriPattern, $varName));
251
            }
252
253
            $variables[$varName] = $default;
254
            $replaces[$placeholder] = !$reversed ? '(?P<' . $varName . '>' . (self::SEGMENT_TYPES[$segment] ?? $segment ?? self::prepareSegment($varName, $requirements)) . ')' : "<$varName>";
255
        }
256
257
        return [\strtr($uriPattern, !$reversed ? self::PATTERN_REPLACES + $replaces : $replaces), $variables];
258
    }
259
260
    /**
261
     * Prevent variables with same name used more than once.
262
     */
263
    private static function filterVariableName(string $varName, string $pattern): void
264
    {
265
        // A PCRE subpattern name must start with a non-digit. Also a PHP variable cannot start with a digit so the
266
        // variable would not be usable as a Controller action argument.
267
        if (1 === \preg_match('/^\d/', $varName)) {
268
            throw new UriHandlerException(
269
                \sprintf('Variable name "%s" cannot start with a digit in route pattern "%s". Use a different name.', $varName, $pattern)
270
            );
271
        }
272
273
        if (\strlen($varName) > self::VARIABLE_MAXIMUM_LENGTH) {
274
            throw new UriHandlerException(
275
                \sprintf(
276
                    'Variable name "%s" cannot be longer than %s characters in route pattern "%s".',
277
                    $varName,
278
                    self::VARIABLE_MAXIMUM_LENGTH,
279
                    $pattern
280
                )
281
            );
282
        }
283
    }
284
285
    /**
286
     * Prepares segment pattern with given constrains.
287
     *
288
     * @param array<string,mixed> $requirements
289
     */
290
    private static function prepareSegment(string $name, array $requirements): string
291
    {
292
        $segment = $requirements[$name] ?? null;
293
294
        if (!\is_array($segment)) {
295
            return self::sanitizeRequirement($name, $segment);
296
        }
297
298
        return \implode('|', \array_map([__CLASS__, 'sanitizeRequirement'], $segment));
299
    }
300
}
301