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

RouteCompiler::filterSegment()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 1
c 0
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\{CompiledRoute, GeneratedUri, Route};
0 ignored issues
show
Bug introduced by
This use statement conflicts with another class in this namespace, Flight\Routing\CompiledRoute. 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\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
 * @author Divine Niiquaye Ibok <[email protected]>
30
 */
31
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
    private const SEGMENT_REPLACES = ['/' => '\\/', '.' => '\.'];
43
44
    /**
45
     * This regex is used to match a certain rule of pattern to be used for routing.
46
     *
47
     * List of string patterns that regex matches:
48
     * - /{var} - A required variable pattern
49
     * - /[{var}] - An optional variable pattern
50
     * - /foo[/{var}] - A path with an optional sub variable pattern
51
     * - /foo[/{var}[.{format}]] - A path with optional nested variables
52
     * - /{var:[a-z]+} - A required variable with lowercase rule
53
     * - /{var=foo} - A required variable with default value
54
     * - /{var}[.{format:(html|php)=html}] - A required variable with an optional variable, a rule & default
55
     */
56
    private const COMPILER_REGEX = '~\{(\w+)(?:\:([^{}]+(?:\{[\w,^{}]+)?))?(?:\=((?2)))?\}~i';
57
58
    /**
59
     * A matching requirement helper, to ease matching route pattern when found.
60
     */
61
    private const SEGMENT_TYPES = [
62
        'int' => '\d+',
63
        'lower' => '[a-z]+',
64
        'upper' => '[A-Z]+',
65
        'alpha' => '[A-Za-z]+',
66
        'alnum' => '[A-Za-z0-9]+',
67
        'year' => '[12][0-9]{3}',
68
        'month' => '0[1-9]|1[012]+',
69
        'day' => '0[1-9]|[12][0-9]|3[01]+',
70
        '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}',
71
    ];
72
73
    /**
74
     * A helper in reversing route pattern to URI.
75
     */
76
    private const URI_FIXERS = [
77
        '[]' => '',
78
        '[/]' => '',
79
        '[' => '',
80
        ']' => '',
81
        '://' => '://',
82
        '//' => '/',
83
        '/..' => '/%2E%2E',
84
        '/.' => '/%2E',
85
    ];
86
87
    /**
88
     * The maximum supported length of a PCRE subpattern name
89
     * http://pcre.org/current/doc/html/pcre2pattern.html#SEC16.
90
     *
91
     * @internal
92
     */
93
    private const VARIABLE_MAXIMUM_LENGTH = 32;
94
95
    /**
96
     * {@inheritdoc}
97
     */
98
    public function compile(Route $route, bool $reversed = false): CompiledRoute
99
    {
100
        $requirements = !$reversed ? $this->getRequirements($route->get('patterns')) : [];
101
        $routePath = \ltrim($route->get('path'), '/');
102
103
        // Strip supported browser prefix of $routePath ...
104
        if (!empty($routePath) && isset(Route::URL_PREFIX_SLASHES[$routePath[-1]])) {
105
            $routePath = \substr($routePath, 0, -1);
106
        }
107
108
        if (!empty($hosts = $route->get('domain'))) {
109
            $hostVariables = $hostsRegex = [];
110
111
            foreach ($hosts as $host) {
112
                [$hostRegex, $hostVariable] = $this->compilePattern($requirements, $host, $reversed);
113
114
                $hostVariables += $hostVariable;
115
                $hostsRegex[] = $hostRegex;
116
            }
117
        }
118
119
        if (!\str_contains($routePath, '{')) {
120
            return new CompiledRoute('/' . $routePath, $hostsRegex ?? [], $hostVariables ?? []);
121
        }
122
123
        [$pathRegex, $pathVariable] = $this->compilePattern($requirements, $routePath, $reversed);
124
125
        // Resolves $pathRegex and host and pattern variables.
126
        $pathRegex = !$reversed ? '\\/' . $pathRegex : \stripslashes('/' . \str_replace('?', '', $pathRegex));
127
        $variables = isset($hostVariables) ? $hostVariables += $pathVariable : $pathVariable;
128
129
        return new CompiledRoute($pathRegex, $hostsRegex ?? [], $variables);
130
    }
131
132
    /**
133
     * {@inheritdoc}
134
     */
135
    public function generateUri(Route $route, array $parameters, array $defaults = []): GeneratedUri
136
    {
137
        $compiledRoute = $this->compile($route, true);
138
        $pathRegex = $compiledRoute->getPathRegex();
139
        $variables = $compiledRoute->getVariables();
140
141
        if (!empty($hostRegex = $compiledRoute->getHostsRegex())) {
142
            $hostRegex = \end($hostRegex);
143
        }
144
145
        $createUri = new GeneratedUri($this->interpolate($pathRegex, $this->fetchOptions($pathRegex, $parameters, $defaults, $variables)));
146
147
        if (!empty($schemes = $route->get('schemes'))) {
148
            $createUri->withScheme(isset($schemes['https']) ? 'https' : \key($schemes) ?? 'http');
149
150
            if (empty($hostRegex)) {
151
                $createUri->withHost($_SERVER['HTTP_HOST'] ?? '');
152
            }
153
        }
154
155
        if (!empty($hostRegex)) {
156
            $createUri->withHost($this->interpolate($hostRegex, $this->fetchOptions($hostRegex, $parameters, $defaults, $variables)));
157
        }
158
159
        return $createUri;
160
    }
161
162
    /**
163
     * Fetch uri segments and query parameters.
164
     *
165
     * @param array<int|string,mixed> $parameters
166
     *
167
     * @return array<int|string,mixed>
168
     */
169
    private function fetchOptions(string $uriRoute, array $parameters, array $defaults, array $allowed): array
170
    {
171
        \preg_match_all('#\[\<(\w+).*?\>\]#', $uriRoute, $optionalVars, \PREG_UNMATCHED_AS_NULL);
172
173
        if (isset($optionalVars[1])) {
174
            foreach ($optionalVars[1] as $optional) {
175
                $defaults[$optional] = null;
176
            }
177
        }
178
179
        // Fetch and merge all possible parameters + route defaults ...
180
        $parameters += $defaults;
181
182
        // all params must be given
183
        if ($diff = \array_diff_key($allowed, $parameters)) {
184
            throw new UrlGenerationException(\sprintf('Some mandatory parameters are missing ("%s") to generate a URL for route path "%s".', \implode('", "', \array_keys($diff)), $uriRoute));
185
        }
186
187
        return $parameters;
188
    }
189
190
    /**
191
     * Get the route requirements.
192
     *
193
     * @param array<string,string|string[]> $requirements
194
     *
195
     * @return array<string,string|string[]>
196
     */
197
    protected function getRequirements(array $requirements): array
198
    {
199
        $newParameters = [];
200
201
        foreach ($requirements as $key => $regex) {
202
            $newParameters[$key] = \is_array($regex) ? $regex : $this->sanitizeRequirement($key, $regex);
203
        }
204
205
        return $newParameters;
206
    }
207
208
    private function sanitizeRequirement(string $key, string $regex): string
209
    {
210
        if ('' !== $regex) {
211
            if ('^' === $regex[0]) {
212
                $regex = \substr($regex, 1);
213
            } elseif (0 === \strpos($regex, '\\A')) {
214
                $regex = \substr($regex, 2);
215
            }
216
217
            if ('$' === $regex[-1]) {
218
                $regex = \substr($regex, 0, -1);
219
            } elseif (\strlen($regex) - 2 === \strpos($regex, '\\z')) {
220
                $regex = \substr($regex, 0, -2);
221
            }
222
        }
223
224
        if ('' === $regex) {
225
            throw new \InvalidArgumentException(\sprintf('Routing requirement for "%s" cannot be empty.', $key));
226
        }
227
228
        return $regex;
229
    }
230
231
    /**
232
     * @param array<string,string|string[]> $requirements
233
     *
234
     * @throws UriHandlerException if a variable name starts with a digit or
235
     *                             if it is too long to be successfully used as a PCRE subpattern or
236
     *                             if a variable is referenced more than once
237
     *
238
     * @return array<string,mixed>
239
     */
240
    private function compilePattern(array $requirements, string $uriPattern, bool $reversed): array
241
    {
242
        $variables = $replaces = [];
243
244
        // correct [/ first occurrence]
245
        if (0 === \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
            $this->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 ?? $this->prepareSegment($varName, $requirements)) . ')' : "<$varName>";
262
        }
263
264
        return [\strtr($uriPattern, !$reversed ? self::PATTERN_REPLACES + $replaces : $replaces), $variables];
265
    }
266
267
    /**
268
     * Prevent variables with same name used more than once.
269
     */
270
    private function filterVariableName(string $varName, string $pattern): void
271
    {
272
        // A PCRE subpattern name must start with a non-digit. Also a PHP variable cannot start with a digit so the
273
        // variable would not be usable as a Controller action argument.
274
        if (1 === \preg_match('/^\d/', $varName)) {
275
            throw new UriHandlerException(
276
                \sprintf('Variable name "%s" cannot start with a digit in route pattern "%s". Use a different name.', $varName, $pattern)
277
            );
278
        }
279
280
        if (\strlen($varName) > self::VARIABLE_MAXIMUM_LENGTH) {
281
            throw new UriHandlerException(
282
                \sprintf(
283
                    'Variable name "%s" cannot be longer than %s characters in route pattern "%s".',
284
                    $varName,
285
                    self::VARIABLE_MAXIMUM_LENGTH,
286
                    $pattern
287
                )
288
            );
289
        }
290
    }
291
292
    /**
293
     * Prepares segment pattern with given constrains.
294
     *
295
     * @param array<string,mixed> $requirements
296
     */
297
    private function prepareSegment(string $name, array $requirements): string
298
    {
299
        if (null === $segment = $requirements[$name] ?? null) {
300
            return self::DEFAULT_SEGMENT;
301
        }
302
303
        return \is_array($segment) ? \implode('|', \array_map([$this, 'filterSegment'], $segment)) : $this->filterSegment((string) $segment);
304
    }
305
306
    private function filterSegment(string $segment): string
307
    {
308
        return \strtr($segment, self::SEGMENT_REPLACES);
309
    }
310
311
    /**
312
     * Interpolate string with given values.
313
     *
314
     * @param array<int|string,mixed> $values
315
     */
316
    private function interpolate(string $string, array $values): string
317
    {
318
        $replaces = self::URI_FIXERS;
319
320
        foreach ($values as $key => $value) {
321
            $replaces["<{$key}>"] = (\is_array($value) || $value instanceof \Closure) ? '' : $value;
322
        }
323
324
        return \strtr($string, $replaces);
325
    }
326
}
327