Test Failed
Push — master ( d750d9...412c5f )
by Divine Niiquaye
03:10
created

SimpleRouteCompiler::getPathVariables()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 2

Importance

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