Test Failed
Pull Request — master (#13)
by Divine Niiquaye
02:21
created

SimpleRouteCompiler::computePattern()   A

Complexity

Conditions 5
Paths 6

Size

Total Lines 30
Code Lines 15

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 17
CRAP Score 5

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 15
c 1
b 0
f 0
dl 0
loc 30
rs 9.4555
ccs 17
cts 17
cp 1
cc 5
nc 6
nop 3
crap 5
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\Matchers;
19
20
use Flight\Routing\Exceptions\UriHandlerException;
21
use Flight\Routing\Route;
22
23
/**
24
 * RouteCompiler compiles Route instances to regex.
25
 *
26
 * provides ability to match and generate uris based on given parameters.
27
 *
28
 * @author Divine Niiquaye Ibok <[email protected]>
29
 */
30
class SimpleRouteCompiler implements \Serializable
31
{
32
    private const DEFAULT_SEGMENT = '[^\/]+';
33
34
    /**
35
     * This string defines the characters that are automatically considered separators in front of
36
     * optional placeholders (with default and no static text following). Such a single separator
37
     * can be left out together with the optional placeholder from matching and generating URLs.
38
     */
39
    private const PATTERN_REPLACES = ['/' => '\\/', '/[' => '\/?(?:', '[' => '(?:', ']' => ')?', '.' => '\.'];
40
41
    private const SEGMENT_REPLACES = ['/' => '\\/', '.' => '\.'];
42
43
    /**
44
     * This regex is used to match a certain rule of pattern to be used for routing.
45
     *
46
     * List of string patterns that regex matches:
47
     * - /{var} - A required variable pattern
48
     * - /[{var}] - An optional variable pattern
49
     * - /foo[/{var}] - A path with an optional sub variable pattern
50
     * - /foo[/{var}[.{format}]] - A path with optional nested variables
51
     * - /{var:[a-z]+} - A required variable with lowercase rule
52
     * - /{var=<foo>} - A required variable with default value
53
     * - /{var}[.{format:(html|php)=<html>}] - A required variable with an optional variable, a rule & default
54
     */
55
    private const COMPILER_REGEX = '#\{(\w+)?(?:\:([^{}=]*(?:\{(?-1)\}[^{}]?)*))?(?:\=\<([^>]+)\>)?\}#xi';
56
57
    /**
58
     * A matching requirement helper, to ease matching route pattern when found.
59
     */
60
    private const SEGMENT_TYPES = [
61
        'int'     => '\d+',
62
        'lower'   => '[a-z]+',
63
        'upper'   => '[A-Z]+',
64
        'alpha'   => '[A-Za-z]+',
65
        'alnum'   => '[A-Za-z0-9]+',
66
        'year'    => '[12][0-9]{3}',
67
        'month'   => '0[1-9]|1[012]',
68
        'day'     => '0[1-9]|[12][0-9]|3[01]',
69
        '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}',
70
    ];
71
72
    /**
73
     * The maximum supported length of a PCRE subpattern name
74
     * http://pcre.org/current/doc/html/pcre2pattern.html#SEC16.
75
     *
76
     * @internal
77
     */
78
    private const VARIABLE_MAXIMUM_LENGTH = 32;
79
80
    /** @var string */
81
    private $template;
82
83
    /** @var string */
84
    private $compiled;
85
86
    /** @var string[] */
87
    private $hostRegex;
88
89
    /** @var string[] */
90
    private $hostTemplate;
91
92
    /** @var array<int|string,mixed> */
93
    private $variables;
94
95
    /** @var array<int|string,mixed> */
96
    private $pathVariables;
97
98
    /** @var array<int|string,mixed> */
99
    private $hostVariables;
100
101
    /**
102
     * @return array<string,mixed>
103
     */
104
    public function __serialize(): array
105
    {
106 2
        return [
107
            'vars'           => $this->variables,
108
            'template_regex' => $this->template,
109 2
            'host_template'  => $this->hostTemplate,
110 2
            'path_regex'     => $this->compiled,
111 2
            'path_vars'      => $this->pathVariables,
112 2
            'host_regex'     => $this->hostRegex,
113 2
            'host_vars'      => $this->hostVariables,
114 2
        ];
115 2
    }
116
117
    /**
118
     * @param array<string,mixed> $data
119
     */
120
    public function __unserialize(array $data): void
121
    {
122 2
        $this->variables     = $data['vars'];
123
        $this->template      = $data['template_regex'];
124 2
        $this->compiled      = $data['path_regex'];
125 2
        $this->pathVariables = $data['path_vars'];
126 2
        $this->hostRegex     = $data['host_regex'];
127 2
        $this->hostTemplate  = $data['host_template'];
128 2
        $this->hostVariables = $data['host_vars'];
129 2
    }
130 2
131 2
    /**
132
     * Match the RouteInterface instance and compiles the current route instance.
133
     *
134
     * @param Route $route
135
     *
136
     * @return SimpleRouteCompiler
137
     */
138
    public function compile(Route $route): self
139
    {
140 93
        $hostVariables = $hostRegex = $hostTemplate = [];
141
        $requirements  = $this->getRequirements($route->getPatterns());
142 93
143 93
        if ([] !== $hosts = $route->getDomain()) {
144
            foreach (\array_keys($hosts) as $host) {
145 93
                $result = $this->compilePattern($requirements, $host, true);
146 16
147
                $hostVariables += $result['variables'];
148 16
149 16
                $hostRegex[]    = $result['regex'] . 'i';
150 16
                $hostTemplate[] = $result['template'];
151
            }
152
        }
153 93
154 86
        $result        = $this->compilePattern($requirements, $route->getPath());
155
        $pathVariables = $result['variables'];
156 86
157 86
        $this->compiled      = $result['regex'] . 'u';
158 86
        $this->template      = $result['template'];
159 86
        $this->pathVariables = $pathVariables;
160 86
        $this->hostRegex     = $hostRegex;
161 86
        $this->hostTemplate  = $hostTemplate;
162 86
        $this->hostVariables = $hostVariables;
163
        $this->variables     = \array_merge($hostVariables, $pathVariables);
164 86
165
        return $this;
166
    }
167
168
    /**
169
     * The path template regex for matching.
170
     *
171
     *
172
     * @return string The static regex
173
     */
174 9
    public function getPathTemplate(): string
175
    {
176 9
        return $this->template;
177
    }
178
179
    /**
180
     * The hosts template regex for matching.
181
     *
182
     *
183
     * @return string[] The static regexps
184 29
     */
185
    public function getHostTemplate(): array
186 29
    {
187
        return $this->hostTemplate;
188
    }
189
190
    /**
191
     * Returns the regex.
192
     *
193
     * @return string The regex
194 14
     */
195
    public function getRegex(): string
196 14
    {
197
        return $this->compiled;
198
    }
199
200
    /**
201
     * Returns the host regex.
202
     *
203
     * @return array The hosts regex
204 11
     */
205
    public function getHostsRegex(): array
206 11
    {
207
        return $this->hostRegex;
208
    }
209
210
    /**
211
     * Returns the variables.
212
     *
213
     * @return array<int|string,string> The variables
214 71
     */
215
    public function getVariables(): array
216 71
    {
217
        return $this->variables;
218
    }
219
220
    /**
221
     * Returns the path variables.
222
     *
223
     * @return array<int|string,string> The variables
224 55
     */
225
    public function getPathVariables(): array
226 55
    {
227
        return $this->pathVariables;
228
    }
229
230
    /**
231
     * Returns the host variables.
232
     *
233
     * @return array<int|string,string> The variables
234 1
     */
235
    public function getHostVariables(): array
236 1
    {
237
        return $this->hostVariables;
238
    }
239
240
    /**
241
     * {@inheritdoc}
242
     *
243
     * @internal
244
     */
245
    final public function serialize(): string
246 1
    {
247
        return \serialize($this->__serialize());
248 1
    }
249 1
250
    /**
251
     * {@inheritdoc}
252
     *
253
     * @param string $serialized the string representation of the object
254
     *
255
     * @internal
256
     */
257
    final public function unserialize($serialized): void
258 93
    {
259
        $this->__unserialize(\unserialize($serialized, ['allowed_classes' => false]));
260 93
    }
261
262 93
    /**
263 11
     * Get the route requirements.
264
     *
265
     * @param array<string,string|string[]> $requirements
266 89
     *
267
     * @return array<string,string|string[]>
268
     */
269 8
    protected function getRequirements(array $requirements): array
270
    {
271 8
        $newParameters = [];
272 2
273
        foreach ($requirements as $key => $regex) {
274
            $newParameters[$key] = \is_array($regex) ? $regex : $this->sanitizeRequirement($key, $regex);
275 8
        }
276 2
277
        return $newParameters;
278
    }
279 8
280 4
    private function sanitizeRequirement(string $key, string $regex): string
281
    {
282
        if ('' !== $regex && \strpos($regex, '^') === 0) {
283 4
            $regex = \substr($regex, 1); // returns false for a single character
284
        }
285
286
        if ('$' === \substr($regex, -1)) {
287
            $regex = \substr($regex, 0, -1);
288
        }
289
290
        if ('' === $regex) {
291
            throw new UriHandlerException(\sprintf('Routing requirement for "%s" cannot be empty.', $key));
292
        }
293
294
        return $regex;
295
    }
296
297 93
    /**
298
     * @param array<string,string|string[]> $requirements
299 93
     * @param string                        $uriPattern
300 93
     * @param bool                          $isHost
301
     *
302
     * @throws UriHandlerException if a variable name starts with a digit or
303
     *                             if it is too long to be successfully used as a PCRE subpattern or
304 93
     *                             if a variable is referenced more than once
305
     *
306
     * @return array<string,mixed>
307
     */
308
    private function compilePattern(array $requirements, string $uriPattern, $isHost = false): array
309 93
    {
310
        if (\strlen($uriPattern) > 1) {
311
            $uriPattern = \trim($uriPattern, '/');
312 93
        }
313 93
314
        // correct [/ first occurrence]
315 93
        if (\strpos($uriPattern, '[/') === 0) {
316
            $uriPattern = '[' . \substr($uriPattern, 2);
317
        }
318 86
319 86
        // Match all variables enclosed in "{}" and iterate over them...
320 86
        \preg_match_all(self::COMPILER_REGEX, $pattern = (!$isHost ? '/' : '') . $uriPattern, $matches);
321
322
        list($variables, $replaces) = $this->computePattern($matches, $pattern, $requirements);
323
324
        // Return only grouped named captures.
325
        $template = (string) \preg_replace(self::COMPILER_REGEX, '<\1>', $pattern);
326
327
        return [
328
            'template'  => \stripslashes(\str_replace('?', '', $template)),
329
            'regex'     => '/^' . ($isHost ? '\/?' : '') . \strtr($template, $replaces) . '$/sD',
330
            'variables' => $variables,
331
        ];
332
    }
333 93
334
    /**
335 93
     * Compute prepared pattern and return it's replacements and arguments.
336 93
     *
337 89
     * @param array<string,string[]>        $matches
338 88
     * @param string                        $pattern
339 88
     * @param array<string,string|string[]> $requirements
340
     *
341 88
     * @return array<int,array<int|string,mixed>>
342
     */
343
    private function computePattern(array $matches, string $pattern, array $requirements): array
344 47
    {
345 1
        $variables = $replaces = [];
346 1
347 1
        list(, $names, $rules, $defaults) = $matches;
348 1
349 1
        foreach ($names as $index => $varName) {
350
            // Filter variable name to meet requirement
351
            $this->filterVariableName($varName, $pattern);
352
353
            if (\array_key_exists($varName, $variables)) {
354 46
                throw new UriHandlerException(
355 1
                    \sprintf(
356 1
                        'Route pattern "%s" cannot reference variable name "%s" more than once.',
357 1
                        $pattern,
358 1
                        $varName
359 1
                    )
360 1
                );
361
            }
362
363
            if (isset($rules[$index])) {
364
                $replace = $this->prepareSegment($varName, $rules[$index], $requirements);
365
366 45
                $replaces["<$varName>"] = \sprintf('(?P<%s>(?U)%s)', $varName, $replace);
367 7
            }
368
369
            $variables[$varName] = !empty($default = $defaults[$index]) ? $default : null;
370 45
        }
371 45
372
        return [$variables, $replaces + self::PATTERN_REPLACES];
373
    }
374 86
375
    /**
376
     * Prevent variables with same name used more than once.
377
     *
378
     * @param string $varName
379
     * @param string $pattern
380
     */
381
    private function filterVariableName($varName, string $pattern): void
382
    {
383
        // A PCRE subpattern name must start with a non-digit. Also a PHP variable cannot start with a digit so the
384
        // variable would not be usable as a Controller action argument.
385 89
        if (1 === \preg_match('/^\d/', $varName)) {
386
            throw new UriHandlerException(
387 89
                \sprintf(
388
                    'Variable name "%s" cannot start with a digit in route pattern "%s". Use a different name.',
389 89
                    $varName,
390 48
                    $pattern
391 1
                )
392 1
            );
393 1
        }
394 1
395 1
        if (\strlen($varName) > self::VARIABLE_MAXIMUM_LENGTH) {
396
            throw new UriHandlerException(
397
                \sprintf(
398
                    'Variable name "%s" cannot be longer than %s characters in route pattern "%s".',
399
                    $varName,
400 48
                    self::VARIABLE_MAXIMUM_LENGTH,
401
                    $pattern
402
                )
403 88
            );
404
        }
405
    }
406
407
    /**
408
     * Prepares segment pattern with given constrains.
409
     *
410
     * @param string              $name
411
     * @param string              $segment
412
     * @param array<string,mixed> $requirements
413
     *
414
     * @return string
415 45
     */
416
    private function prepareSegment(string $name, string $segment, array $requirements): string
417 45
    {
418 15
        if ($segment !== '') {
419
            return self::SEGMENT_TYPES[$segment] ?? $segment;
420
        }
421 35
422 31
        if (!isset($requirements[$name])) {
423
            return self::DEFAULT_SEGMENT;
424
        }
425 7
426 3
        if (\is_array($requirements[$name])) {
427
            $values = \array_map([$this, 'filterSegment'], $requirements[$name]);
428 3
429
            return \implode('|', $values);
430
        }
431 4
432
        return $this->filterSegment((string) $requirements[$name]);
433
    }
434
435
    /**
436
     * @param string $segment
437
     *
438
     * @return string
439 7
     */
440
    private function filterSegment(string $segment): string
441 7
    {
442
        return \strtr($segment, self::SEGMENT_REPLACES);
443
    }
444
}
445