Test Failed
Push — master ( d45889...cddc62 )
by Divine Niiquaye
11:28 queued 08:59
created

RouteCompiler::build()   B

Complexity

Conditions 9
Paths 34

Size

Total Lines 44
Code Lines 24

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 25
CRAP Score 9

Importance

Changes 0
Metric Value
cc 9
eloc 24
nc 34
nop 1
dl 0
loc 44
ccs 25
cts 25
cp 1
crap 9
rs 8.0555
c 0
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\Exceptions\{UriHandlerException, UrlGenerationException};
21
use Flight\Routing\RouteUri;
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
final 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
    /**
43
     * Using the strtr function is faster than the preg_quote function.
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+)(?:\:(.*?\}?))?(?:\=(.*?))?\}~i';
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' => '[0-9]+',
71
        'lower' => '[a-z]+',
72
        'upper' => '[A-Z]+',
73
        'alpha' => '[A-Za-z]+',
74
        'hex' => '[[:xdigit:]]+',
75
        'md5' => '[a-f0-9]{32}+',
76
        'sha1' => '[a-f0-9]{40}+',
77
        'year' => '[0-9]{4}',
78
        'month' => '0[1-9]|1[012]+',
79
        'day' => '0[1-9]|[12][0-9]|3[01]+',
80
        'date' => '[0-9]{4}-(?:0[1-9]|1[012])-(?:0[1-9]|[12][0-9]|(?<!02-)3[01])', // YYYY-MM-DD
81
        'slug' => '[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*',
82
        'port' => '[0-9]{1,4}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5]',
83
        'UID_BASE32' => '[0-9A-HJKMNP-TV-Z]{26}',
84
        'UID_BASE58' => '[1-9A-HJ-NP-Za-km-z]{22}',
85
        'UID_RFC4122' => '[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}',
86
        'ULID' => '[0-7][0-9A-HJKMNP-TV-Z]{25}',
87
        'UUID' => '[0-9a-f]{8}-[0-9a-f]{4}-[1-6][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}',
88
        'UUID_V1' => '[0-9a-f]{8}-[0-9a-f]{4}-1[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}',
89
        'UUID_V3' => '[0-9a-f]{8}-[0-9a-f]{4}-3[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}',
90
        'UUID_V4' => '[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}',
91
        'UUID_V5' => '[0-9a-f]{8}-[0-9a-f]{4}-5[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}',
92
        'UUID_V6' => '[0-9a-f]{8}-[0-9a-f]{4}-6[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}',
93
    ];
94
95
    /**
96
     * A helper in reversing route pattern to URI.
97
     */
98
    private const URI_FIXERS = [
99
        '[]' => '',
100
        '[/]' => '',
101
        '[' => '',
102
        ']' => '',
103
        '://' => '://',
104
        '//' => '/',
105
        '/..' => '/%2E%2E',
106
        '/.' => '/%2E',
107
    ];
108
109
    /**
110
     * The maximum supported length of a PCRE subpattern name
111
     * http://pcre.org/current/doc/html/pcre2pattern.html#SEC16.
112
     *
113
     * @internal
114
     */
115
    private const VARIABLE_MAXIMUM_LENGTH = 32;
116
117
    /**
118
     * {@inheritdoc}
119
     */
120
    public function compile(string $route, array $placeholders = [], bool $reversed = false): array
121 13
    {
122
        $variables = $replaces = [];
123 13
124 13
        if (\strpbrk($route, '{')) {
125
            // Match all variables enclosed in "{}" and iterate over them...
126 13
            \preg_match_all(self::COMPILER_REGEX, $route, $matches, \PREG_SET_ORDER | \PREG_UNMATCHED_AS_NULL);
127 13
128 13
            foreach ($matches as [$placeholder, $varName, $segment, $default]) {
129
                if (1 === \preg_match('/\A\d+/', $varName)) {
130 13
                    throw new UriHandlerException(\sprintf('Variable name "%s" cannot start with a digit in route pattern "%s". Use a different name.', $varName, $route));
131 4
                }
132
133
                if (\strlen($varName) > self::VARIABLE_MAXIMUM_LENGTH) {
134 13
                    throw new UriHandlerException(\sprintf('Variable name "%s" cannot be longer than %s characters in route pattern "%s".', $varName, self::VARIABLE_MAXIMUM_LENGTH, $route));
135 9
                }
136
137
                if (\array_key_exists($varName, $variables)) {
138 13
                    throw new UriHandlerException(\sprintf('Route pattern "%s" cannot reference variable name "%s" more than once.', $route, $varName));
139 9
                }
140 2
141
                $segment = self::SEGMENT_TYPES[$segment] ?? $segment ?? self::prepareSegment($varName, $placeholders);
142 9
                [$variables[$varName], $replaces[$placeholder]] = !$reversed ? [$default, '(?P<'.$varName.'>'.$segment.')'] : [[$segment, $default], '<'.$varName.'>'];
143 9
            }
144
        }
145
146 12
        return !$reversed ? [\strtr('{^'.$route.'$}', $replaces + self::PATTERN_REPLACES), $variables] : [\strtr($route, $replaces), $variables];
147 3
    }
148
149
    /**
150 12
     * {@inheritdoc}
151
     */
152
    public function generateUri(array $route, array $parameters, int $referenceType = RouteUri::ABSOLUTE_PATH): RouteUri
153 13
    {
154 9
        [$pathRegex, $pathVars] = $this->compile($route['path'], reversed: true);
155 9
156
        $defaults = $route['defaults'] ?? [];
157 9
        $createUri = new RouteUri(self::interpolate($pathRegex, $pathVars, $parameters + $defaults), $referenceType);
158 9
159
        foreach (($route['hosts'] ?? []) as $host => $exists) {
160
            [$hostRegex, $hostVars] = $this->compile($host, reversed: true);
161 9
            $createUri->withHost(self::interpolate($hostRegex, $hostVars, $parameters + $defaults));
162
            break;
163
        }
164 13
165
        if (!empty($schemes = $route['schemes'] ?? [])) {
166
            $createUri->withScheme(isset($schemes['https']) ? 'https' : \array_key_last($schemes) ?? 'http');
167
        }
168
169
        return $createUri;
170 128
    }
171
172 128
    /**
173
     * Check for mandatory parameters then interpolate $uriRoute with given $parameters.
174 118
     *
175 16
     * @param array<string,array<int,string>> $uriVars
176
     * @param array<int|string,string>        $parameters
177 16
     */
178 16
    private static function interpolate(string $uriRoute, array $uriVars, array $parameters): string
179 16
    {
180 16
        $required = []; // Parameters required which are missing.
181
        $replaces = self::URI_FIXERS;
182
183 16
        // Fetch and merge all possible parameters + route defaults ...
184
        \preg_match_all(self::REVERSED_REGEX, $uriRoute, $matches, \PREG_SET_ORDER | \PREG_UNMATCHED_AS_NULL);
185
186 118
        if (isset($uriVars['*'])) {
187 103
            [$defaultPath, $required, $optional] = $uriVars['*'];
188
            $replaces = [];
189
        }
190 118
191
        foreach ($matches as $i => [$matched, $varName]) {
192
            if ('[' !== $matched[0]) {
193
                [$segment, $default] = $uriVars[$varName];
194
                $value = $parameters[$varName] ?? (isset($optional) ? $default : ($parameters[$i] ?? $default));
195
196 18
                if (!empty($value)) {
197
                    if (1 !== \preg_match("~^{$segment}\$~", (string) $value)) {
198 18
                        throw new UriHandlerException(
199
                            \sprintf('Expected route path "%s" placeholder "%s" value "%s" to match "%s".', $uriRoute, $varName, $value, $segment)
200 18
                        );
201 18
                    }
202
                    $optional = isset($optional) ? false : null;
203 17
                    $replaces[$matched] = $value;
204 6
                } elseif (isset($optional) && $optional) {
205
                    $replaces[$matched] = '';
206 6
                } else {
207
                    $required[] = $varName;
208
                }
209 17
                continue;
210 4
            }
211
            $replaces[$matched] = self::interpolate($varName, $uriVars + ['*' => [$uriRoute, $required, true]], $parameters);
212 4
        }
213 1
214
        if (!empty($required)) {
215
            throw new UrlGenerationException(\sprintf(
216
                'Some mandatory parameters are missing ("%s") to generate a URL for route path "%s".',
217 17
                \implode('", "', $required),
0 ignored issues
show
Bug introduced by
It seems like $required can also be of type string; however, parameter $pieces of implode() 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

217
                \implode('", "', /** @scrutinizer ignore-type */ $required),
Loading history...
218 6
                $defaultPath ?? $uriRoute
219
            ));
220
        }
221 17
222
        return !empty(\array_filter($replaces)) ? \strtr($uriRoute, $replaces) : '';
223
    }
224
225
    private static function sanitizeRequirement(string $key, string $regex): string
226
    {
227
        if ('' !== $regex) {
228
            if ('^' === $regex[0]) {
229
                $regex = \substr($regex, 1);
230 18
            } elseif (\str_starts_with($regex, '\\A')) {
231
                $regex = \substr($regex, 2);
232 18
            }
233 18
234
            if (\str_ends_with($regex, '$')) {
235
                $regex = \substr($regex, 0, -1);
236 18
            } elseif (\strlen($regex) - 2 === \strpos($regex, '\\z')) {
237
                $regex = \substr($regex, 0, -2);
238 18
            }
239 11
        }
240 3
241
        if ('' === $regex) {
242 3
            throw new UriHandlerException(\sprintf('Routing requirement for "%s" cannot be empty.', $key));
243 3
        }
244
245
        return \strtr($regex, self::SEGMENT_REPLACES);
246 3
    }
247
248
    /**
249 11
     * Prepares segment pattern with given constrains.
250
     *
251 11
     * @param array<string,mixed> $requirements
252 1
     */
253
    private static function prepareSegment(string $name, array $requirements): string
254
    {
255
        if (!\array_key_exists($name, $requirements)) {
256 18
            return self::DEFAULT_SEGMENT;
257 1
        }
258
259
        if (!\is_array($segment = $requirements[$name])) {
260 17
            return self::sanitizeRequirement($name, $segment);
261
        }
262
263 10
        return \implode('|', $segment);
264
    }
265
}
266