Passed
Push — master ( c9d9dd...f9840e )
by Divine Niiquaye
02:39
created

SimpleRouteCompiler::computePattern()   B

Complexity

Conditions 8
Paths 5

Size

Total Lines 42
Code Lines 24

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 25
CRAP Score 8

Importance

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