Test Failed
Pull Request — master (#13)
by Divine Niiquaye
14:05
created

SimpleRouteCompiler::serialize()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 2
CRAP Score 1

Importance

Changes 0
Metric Value
cc 1
eloc 1
nc 1
nop 0
dl 0
loc 3
ccs 2
cts 2
cp 1
crap 1
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\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
     * @param bool $host either host or path template
172
     *
173
     * @return string The static regex
174 9
     */
175
    public function getPathTemplate(): string
176 9
    {
177
        return $this->template;
178
    }
179
180
    /**
181
     * The hosts template regex for matching.
182
     *
183
     * @param bool $host either host or path template
184 29
     *
185
     * @return string[] The static regexps
186 29
     */
187
    public function getHostTemplate(): array
188
    {
189
        return $this->hostTemplate;
190
    }
191
192
    /**
193
     * Returns the regex.
194 14
     *
195
     * @return string The regex
196 14
     */
197
    public function getRegex(): string
198
    {
199
        return $this->compiled;
200
    }
201
202
    /**
203
     * Returns the host regex.
204 11
     *
205
     * @return array The hosts regex
206 11
     */
207
    public function getHostsRegex(): array
208
    {
209
        return $this->hostRegex;
210
    }
211
212
    /**
213
     * Returns the variables.
214 71
     *
215
     * @return array<int|string,string> The variables
216 71
     */
217
    public function getVariables(): array
218
    {
219
        return $this->variables;
220
    }
221
222
    /**
223
     * Returns the path variables.
224 55
     *
225
     * @return array<int|string,string> The variables
226 55
     */
227
    public function getPathVariables(): array
228
    {
229
        return $this->pathVariables;
230
    }
231
232
    /**
233
     * Returns the host variables.
234 1
     *
235
     * @return array<int|string,string> The variables
236 1
     */
237
    public function getHostVariables(): array
238
    {
239
        return $this->hostVariables;
240
    }
241
242
    /**
243
     * {@inheritdoc}
244
     *
245
     * @internal
246 1
     */
247
    final public function serialize(): string
248 1
    {
249 1
        return \serialize($this->__serialize());
250
    }
251
252
    /**
253
     * {@inheritdoc}
254
     *
255
     * @param string $serialized the string representation of the object
256
     *
257
     * @internal
258 93
     */
259
    final public function unserialize($serialized): void
260 93
    {
261
        $this->__unserialize(\unserialize($serialized, ['allowed_classes' => false]));
262 93
    }
263 11
264
    /**
265
     * Get the route requirements.
266 89
     *
267
     * @param array<string,string|string[]> $requirements
268
     *
269 8
     * @return array<string,string|string[]>
270
     */
271 8
    protected function getRequirements(array $requirements): array
272 2
    {
273
        $newParameters = [];
274
275 8
        foreach ($requirements as $key => $regex) {
276 2
            $newParameters[$key] = \is_array($regex) ? $regex : $this->sanitizeRequirement($key, $regex);
277
        }
278
279 8
        return $newParameters;
280 4
    }
281
282
    private function sanitizeRequirement(string $key, string $regex): string
283 4
    {
284
        if ('' !== $regex && \strpos($regex, '^') === 0) {
285
            $regex = \substr($regex, 1); // returns false for a single character
286
        }
287
288
        if ('$' === \substr($regex, -1)) {
289
            $regex = \substr($regex, 0, -1);
290
        }
291
292
        if ('' === $regex) {
293
            throw new UriHandlerException(\sprintf('Routing requirement for "%s" cannot be empty.', $key));
294
        }
295
296
        return $regex;
297 93
    }
298
299 93
    /**
300 93
     * @param array<string,string|string[]> $requirements
301
     * @param string                        $uriPattern
302
     * @param bool                          $isHost
303
     *
304 93
     * @throws UriHandlerException if a variable name starts with a digit or
305
     *                             if it is too long to be successfully used as a PCRE subpattern or
306
     *                             if a variable is referenced more than once
307
     *
308
     * @return array<string,mixed>
309 93
     */
310
    private function compilePattern(array $requirements, string $uriPattern, $isHost = false): array
311
    {
312 93
        if (\strlen($uriPattern) > 1) {
313 93
            $uriPattern = \trim($uriPattern, '/');
314
        }
315 93
316
        // correct [/ first occurrence]
317
        if (\strpos($pattern = (!$isHost ? '/' : '') . $uriPattern, '[/') === 0) {
318 86
            $pattern = '[' . \substr($pattern, 2);
319 86
        }
320 86
321
        // Match all variables enclosed in "{}" and iterate over them...
322
        \preg_match_all(self::COMPILER_REGEX, $pattern, $matches);
323
324
        list($variables, $replaces) = $this->computePattern($matches, $pattern, $requirements);
325
326
        // Return only grouped named captures.
327
        $template = (string) \preg_replace(self::COMPILER_REGEX, '<\1>', $pattern);
328
329
        return [
330
            'template'  => \stripslashes(\str_replace('?', '', $template)),
331
            'regex'     => '/^' . ($isHost ? '\/?' : '') . \strtr($template, $replaces) . '$/sD',
332
            'variables' => $variables,
333 93
        ];
334
    }
335 93
336 93
    /**
337 89
     * Compute prepared pattern and return it's replacements and arguments.
338 88
     *
339 88
     * @param array<string,string[]>        $matches
340
     * @param string                        $pattern
341 88
     * @param array<string,string|string[]> $requirements
342
     *
343
     * @return array<int,array<int|string,mixed>>
344 47
     */
345 1
    private function computePattern(array $matches, string $pattern, array $requirements): array
346 1
    {
347 1
        $variables = $replaces = [];
348 1
349 1
        list(, $names, $rules, $defaults) = $matches;
350
351
        foreach ($names as $index => $varName) {
352
            // Filter variable name to meet requirement
353
            $this->filterVariableName($varName, $pattern);
354 46
355 1
            if (\array_key_exists($varName, $variables)) {
356 1
                throw new UriHandlerException(
357 1
                    \sprintf(
358 1
                        'Route pattern "%s" cannot reference variable name "%s" more than once.',
359 1
                        $pattern,
360 1
                        $varName
361
                    )
362
                );
363
            }
364
365
            if (isset($rules[$index])) {
366 45
                $replace = $this->prepareSegment($varName, $rules[$index], $requirements);
367 7
368
                $replaces["<$varName>"] = \sprintf('(?P<%s>(?U)%s)', $varName, $replace);
369
            }
370 45
371 45
            $variables[$varName] = !empty($default = $defaults[$index]) ? $default : null;
372
        }
373
374 86
        return [$variables, $replaces + self::PATTERN_REPLACES];
375
    }
376
377
    /**
378
     * Prevent variables with same name used more than once.
379
     *
380
     * @param int|string $varName
381
     * @param string     $pattern
382
     */
383
    private function filterVariableName($varName, string $pattern): void
384
    {
385 89
        // A PCRE subpattern name must start with a non-digit. Also a PHP variable cannot start with a digit so the
386
        // variable would not be usable as a Controller action argument.
387 89
        if (1 === \preg_match('/^\d/', $varName)) {
388
            throw new UriHandlerException(
389 89
                \sprintf(
390 48
                    'Variable name "%s" cannot start with a digit in route pattern "%s". Use a different name.',
391 1
                    $varName,
392 1
                    $pattern
393 1
                )
394 1
            );
395 1
        }
396
397
        if (\strlen($varName) > self::VARIABLE_MAXIMUM_LENGTH) {
398
            throw new UriHandlerException(
399
                \sprintf(
400 48
                    'Variable name "%s" cannot be longer than %s characters in route pattern "%s".',
401
                    $varName,
402
                    self::VARIABLE_MAXIMUM_LENGTH,
403 88
                    $pattern
404
                )
405
            );
406
        }
407
    }
408
409
    /**
410
     * Prepares segment pattern with given constrains.
411
     *
412
     * @param string              $name
413
     * @param string              $segment
414
     * @param array<string,mixed> $requirements
415 45
     *
416
     * @return string
417 45
     */
418 15
    private function prepareSegment(string $name, string $segment, array $requirements): string
419
    {
420
        if ($segment !== '') {
421 35
            return self::SEGMENT_TYPES[$segment] ?? $segment;
422 31
        }
423
424
        if (!isset($requirements[$name])) {
425 7
            return self::DEFAULT_SEGMENT;
426 3
        }
427
428 3
        if (\is_array($requirements[$name])) {
429
            $values = \array_map([$this, 'filterSegment'], $requirements[$name]);
430
431 4
            return \implode('|', $values);
432
        }
433
434
        return $this->filterSegment((string) $requirements[$name]);
435
    }
436
437
    /**
438
     * @param string $segment
439 7
     *
440
     * @return string
441 7
     */
442
    private function filterSegment(string $segment): string
443
    {
444
        return \strtr($segment, self::SEGMENT_REPLACES);
445
    }
446
}
447