Test Failed
Pull Request — master (#16)
by Divine Niiquaye
14:00
created

RouteCompiler::filterSegment()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 1
eloc 1
c 1
b 0
f 0
nc 1
nop 1
dl 0
loc 3
rs 10
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
 * @author Divine Niiquaye Ibok <[email protected]>
31
 */
32
class RouteCompiler 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+)(?:\\:([^{}=]+(?:\\{[\\w,^{}]+)?))?(?:\\=((?2)))?\\}~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
     * A helper in reversing route pattern to URI.
76
     */
77
    private const URI_FIXERS = [
78
        '[]' => '',
79
        '[/]' => '',
80
        '[' => '',
81
        ']' => '',
82
        '://' => '://',
83
        '//' => '/',
84
        '/..' => '/%2E%2E',
85
        '/.' => '/%2E',
86
    ];
87
88
    /**
89
     * The maximum supported length of a PCRE subpattern name
90
     * http://pcre.org/current/doc/html/pcre2pattern.html#SEC16.
91
     *
92
     * @internal
93
     */
94
    private const VARIABLE_MAXIMUM_LENGTH = 32;
95
96
    /**
97
     * {@inheritdoc}
98
     */
99
    public function compile(Route $route): array
100
    {
101
        $hostVariables = $hostsRegex = [];
102
        $requirements = $this->getRequirements($route->get('patterns'));
103
        $routePath = \ltrim($route->get('path'), '/');
104
105
        // Strip supported browser prefix of $routePath ...
106
        if (!empty($routePath) && isset(Route::URL_PREFIX_SLASHES[$routePath[-1]])) {
107
            $routePath = \substr($routePath, 0, -1);
108
        }
109
110
        if (!empty($hosts = $route->get('domain'))) {
111
            foreach ($hosts as $host) {
112
                [$hostRegex, $hostVariable] = $this->compilePattern($host, false, $requirements);
113
114
                $hostVariables += $hostVariable;
115
                $hostsRegex[] = $hostRegex;
116
            }
117
        }
118
119
        if (!\str_contains($routePath, '{')) {
120
            return ['/' . $routePath, $hostsRegex, $hostVariables];
121
        }
122
123
        [$pathRegex, $pathVariables] = $this->compilePattern($routePath, false, $requirements);
124
125
        return ['\\/' . $pathRegex, $hostsRegex, empty($hostVariables) ? $pathVariables : $hostVariables += $pathVariables];
126
    }
127
128
    /**
129
     * {@inheritdoc}
130
     */
131
    public function generateUri(Route $route, array $parameters, array $defaults = []): GeneratedUri
132
    {
133
        [$pathRegex, $pathVariables] = $this->compilePattern(\ltrim($route->get('path'), '/'), true);
134
135
        $pathRegex = '/' . \stripslashes(\str_replace('?', '', $pathRegex));
136
        $createUri = new GeneratedUri($this->interpolate($pathRegex, $parameters, $defaults, $pathVariables));
137
138
        foreach ($route->get('domain') as $host) {
139
            $compiledHost = $this->compilePattern($host, true);
140
141
            if (!empty($compiledHost)) {
142
                [$hostRegex, $hostVariables] = $compiledHost;
143
144
                break;
145
            }
146
        }
147
148
        if (!empty($schemes = $route->get('schemes'))) {
149
            $createUri->withScheme(isset($schemes['https']) ? 'https' : \key($schemes) ?? 'http');
150
151
            if (!isset($hostRegex)) {
152
                $createUri->withHost($_SERVER['HTTP_HOST'] ?? '');
153
            }
154
        }
155
156
        if (isset($hostRegex)) {
157
            $createUri->withHost($this->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...
158
        }
159
160
        return $createUri;
161
    }
162
163
    /**
164
     * Check for mandatory parameters then interpolate $uriRoute with given $parameters.
165
     *
166
     * @param array<int|string,mixed> $parameters
167
     */
168
    private function interpolate(string $uriRoute, array $parameters, array $defaults, array $allowed): string
169
    {
170
        \preg_match_all('#\[\<(\w+).*?\>\]#', $uriRoute, $optionalVars, \PREG_UNMATCHED_AS_NULL);
171
172
        if (isset($optionalVars[1])) {
173
            foreach ($optionalVars[1] as $optional) {
174
                $defaults[$optional] = null;
175
            }
176
        }
177
178
        // Fetch and merge all possible parameters + route defaults ...
179
        $parameters += $defaults;
180
181
        // all params must be given
182
        if ($diff = \array_diff_key($allowed, $parameters)) {
183
            throw new UrlGenerationException(\sprintf('Some mandatory parameters are missing ("%s") to generate a URL for route path "%s".', \implode('", "', \array_keys($diff)), $uriRoute));
184
        }
185
186
        $replaces = self::URI_FIXERS;
187
188
        foreach ($parameters as $key => $value) {
189
            $replaces["<{$key}>"] = (\is_array($value) || $value instanceof \Closure) ? '' : $value;
190
        }
191
192
        return \strtr($uriRoute, $replaces);
193
    }
194
195
    /**
196
     * Get the route requirements.
197
     *
198
     * @param array<string,string|string[]> $requirements
199
     *
200
     * @return array<string,string|string[]>
201
     */
202
    private function getRequirements(array $requirements): array
203
    {
204
        $newParameters = [];
205
206
        foreach ($requirements as $key => $regex) {
207
            $newParameters[$key] = \is_array($regex) ? $regex : $this->sanitizeRequirement($key, $regex);
208
        }
209
210
        return $newParameters;
211
    }
212
213
    private function sanitizeRequirement(string $key, string $regex): string
214
    {
215
        if ('' !== $regex) {
216
            if ('^' === $regex[0]) {
217
                $regex = \substr($regex, 1);
218
            } elseif (0 === \strpos($regex, '\\A')) {
219
                $regex = \substr($regex, 2);
220
            }
221
222
            if ('$' === $regex[-1]) {
223
                $regex = \substr($regex, 0, -1);
224
            } elseif (\strlen($regex) - 2 === \strpos($regex, '\\z')) {
225
                $regex = \substr($regex, 0, -2);
226
            }
227
        }
228
229
        if ('' === $regex) {
230
            throw new \InvalidArgumentException(\sprintf('Routing requirement for "%s" cannot be empty.', $key));
231
        }
232
233
        return $regex;
234
    }
235
236
    /**
237
     * @param array<string,string|string[]> $requirements
238
     *
239
     * @throws UriHandlerException if a variable name starts with a digit or
240
     *                             if it is too long to be successfully used as a PCRE subpattern or
241
     *                             if a variable is referenced more than once
242
     *
243
     * @return array<string,mixed>
244
     */
245
    private function compilePattern(string $uriPattern, bool $reversed = false, array $requirements = []): array
246
    {
247
        $variables = $replaces = [];
248
249
        // correct [/ first occurrence]
250
        if (0 === \strpos($uriPattern, '[/')) {
251
            $uriPattern = '[' . \substr($uriPattern, 3);
252
        }
253
254
        // Match all variables enclosed in "{}" and iterate over them...
255
        \preg_match_all(self::COMPILER_REGEX, $uriPattern, $matches, \PREG_SET_ORDER | \PREG_UNMATCHED_AS_NULL);
256
257
        foreach ($matches as [$placeholder, $varName, $segment, $default]) {
258
            // Filter variable name to meet requirement
259
            $this->filterVariableName($varName, $uriPattern);
260
261
            if (\array_key_exists($varName, $variables)) {
262
                throw new UriHandlerException(\sprintf('Route pattern "%s" cannot reference variable name "%s" more than once.', $uriPattern, $varName));
263
            }
264
265
            $variables[$varName] = $default;
266
            $replaces[$placeholder] = !$reversed ? '(?P<' . $varName . '>' . (self::SEGMENT_TYPES[$segment] ?? $segment ?? $this->prepareSegment($varName, $requirements)) . ')' : "<$varName>";
267
        }
268
269
        return [\strtr($uriPattern, !$reversed ? self::PATTERN_REPLACES + $replaces : $replaces), $variables];
270
    }
271
272
    /**
273
     * Prevent variables with same name used more than once.
274
     */
275
    private function filterVariableName(string $varName, string $pattern): void
276
    {
277
        // A PCRE subpattern name must start with a non-digit. Also a PHP variable cannot start with a digit so the
278
        // variable would not be usable as a Controller action argument.
279
        if (1 === \preg_match('/^\d/', $varName)) {
280
            throw new UriHandlerException(
281
                \sprintf('Variable name "%s" cannot start with a digit in route pattern "%s". Use a different name.', $varName, $pattern)
282
            );
283
        }
284
285
        if (\strlen($varName) > self::VARIABLE_MAXIMUM_LENGTH) {
286
            throw new UriHandlerException(
287
                \sprintf(
288
                    'Variable name "%s" cannot be longer than %s characters in route pattern "%s".',
289
                    $varName,
290
                    self::VARIABLE_MAXIMUM_LENGTH,
291
                    $pattern
292
                )
293
            );
294
        }
295
    }
296
297
    /**
298
     * Prepares segment pattern with given constrains.
299
     *
300
     * @param array<string,mixed> $requirements
301
     */
302
    private function prepareSegment(string $name, array $requirements): string
303
    {
304
        if (null === $segment = $requirements[$name] ?? null) {
305
            return self::DEFAULT_SEGMENT;
306
        }
307
308
        return \is_array($segment) ? \implode('|', \array_map([$this, 'filterSegment'], $segment)) : $this->filterSegment((string) $segment);
309
    }
310
311
    private function filterSegment(string $segment): string
312
    {
313
        return \strtr($segment, self::SEGMENT_REPLACES);
314
    }
315
}
316