Passed
Push — master ( 412c5f...ce13c3 )
by Divine Niiquaye
02:22
created

SimpleRouteCompiler::getPathVariables()   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\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
    private $compiled;
89
90
    /** @var null|string */
91
    private $hostRegex;
92
93
    /** @var null|string */
94
    private $hostTemplate;
95
96
    /** @var array<int|string,mixed> */
97
    private $variables;
98
99
    /** @var array<int|string,mixed> */
100
    private $pathVariables;
101
102
    /** @var array<int|string,mixed> */
103
    private $hostVariables;
104
105
    /**
106
     * @return array<string,mixed>
107
     */
108 2
    public function __serialize(): array
109
    {
110
        return [
111 2
            'vars'           => $this->variables,
112 2
            'template_regex' => $this->template,
113 2
            'host_template'  => $this->hostTemplate,
114 2
            'path_regex'     => $this->compiled,
115 2
            'path_vars'      => $this->pathVariables,
116 2
            'host_regex'     => $this->hostRegex,
117 2
            'host_vars'      => $this->hostVariables,
118
        ];
119
    }
120
121
    /**
122
     * @param array<string,mixed> $data
123
     */
124 2
    public function __unserialize(array $data): void
125
    {
126 2
        $this->variables     = $data['vars'];
127 2
        $this->template      = $data['template_regex'];
128 2
        $this->compiled      = $data['path_regex'];
129 2
        $this->pathVariables = $data['path_vars'];
130 2
        $this->hostRegex     = $data['host_regex'];
131 2
        $this->hostTemplate  = $data['host_template'];
132 2
        $this->hostVariables = $data['host_vars'];
133 2
    }
134
135
    /**
136
     * Match the RouteInterface instance and compiles the current route instance.
137
     *
138
     * @param RouteInterface $route
139
     *
140
     * @return SimpleRouteCompiler
141
     */
142 91
    public function compile(RouteInterface $route): self
143
    {
144 91
        $hostVariables = [];
145 91
        $hostRegex     = $hostTemplate = null;
146
147 91
        if ('' !== $host = $route->getDomain()) {
148 15
            $result = $this->compilePattern($route, $host, true);
149
150 15
            $hostVariables = $result['variables'];
151 15
            $hostRegex     = $result['regex'] . 'i';
152 15
            $hostTemplate  = $result['template'];
153
        }
154
155 91
        $result        = $this->compilePattern($route, $route->getPath());
156 84
        $pathVariables = $result['variables'];
157
158 84
        $this->compiled      = $result['regex'] . 'u';
159 84
        $this->template      = $result['template'];
160 84
        $this->pathVariables = $pathVariables;
161 84
        $this->hostRegex     = $hostRegex;
162 84
        $this->hostTemplate  = $hostTemplate;
163 84
        $this->hostVariables = $hostVariables;
164 84
        $this->variables     = \array_merge($hostVariables, $pathVariables);
165
166 84
        return $this;
167
    }
168
169
    /**
170
     * The template regex for matching.
171
     *
172
     * @param bool $host either host or path template
173
     *
174
     * @return string The static regex
175
     */
176 9
    public function getRegexTemplate(bool $host = true): ?string
177
    {
178 9
        return $host ? $this->hostTemplate : $this->template;
179
    }
180
181
    /**
182
     * Returns the regex.
183
     *
184
     * @return string The regex
185
     */
186 28
    public function getRegex(): string
187
    {
188 28
        return $this->compiled;
189
    }
190
191
    /**
192
     * Returns the host regex.
193
     *
194
     * @return null|string The host regex or null
195
     */
196 14
    public function getHostRegex(): ?string
197
    {
198 14
        return $this->hostRegex;
199
    }
200
201
    /**
202
     * Returns the variables.
203
     *
204
     * @return array<int|string,string> The variables
205
     */
206 11
    public function getVariables(): array
207
    {
208 11
        return $this->variables;
209
    }
210
211
    /**
212
     * Returns the path variables.
213
     *
214
     * @return array<int|string,string> The variables
215
     */
216 69
    public function getPathVariables(): array
217
    {
218 69
        return $this->pathVariables;
219
    }
220
221
    /**
222
     * Returns the host variables.
223
     *
224
     * @return array<int|string,string> The variables
225
     */
226 54
    public function getHostVariables(): array
227
    {
228 54
        return $this->hostVariables;
229
    }
230
231
    /**
232
     * {@inheritdoc}
233
     *
234
     * @internal
235
     */
236 1
    final public function serialize(): string
237
    {
238 1
        return \serialize($this->__serialize());
239
    }
240
241
    /**
242
     * {@inheritdoc}
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 91
    protected function getRequirements(array $requirements): array
259
    {
260 91
        $newParameters = [];
261
262 91
        foreach ($requirements as $key => $regex) {
263 10
            $newParameters[$key] = \is_array($regex) ? $regex : $this->sanitizeRequirement($key, $regex);
264
        }
265
266 87
        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 91
    private function compilePattern(RouteInterface $route, string $uriPattern, $isHost = false): array
298
    {
299 91
        if (\strlen($uriPattern) > 1) {
300 91
            $uriPattern = \trim($uriPattern, '/');
301
        }
302
303
        // correct [/ first occurrence]
304 91
        if (\strpos($pattern = (!$isHost ? '/' : '') . $uriPattern, '[/') === 0) {
305
            $pattern = '[' . \substr($pattern, 2);
306
        }
307
308
        // Match all variables enclosed in "{}" and iterate over them...
309 91
        \preg_match_all(self::COMPILER_REGEX, $pattern, $matches);
310
311
        // Return only grouped named captures.
312 91
        $matches  = \array_filter($matches, 'is_string', \ARRAY_FILTER_USE_KEY);
313 91
        $template = \str_replace(['{', '}'], '', (string) \preg_replace(self::TEMPLATE_REGEX, '<\1>', $pattern));
314
315 91
        list($variables, $replaces) = $this->computePattern($matches, $pattern, $route);
316
317
        return [
318 84
            'template'  => \stripslashes(\str_replace('?', '', $template)),
319 84
            'regex'     => '/^' . ($isHost ? '\/?' : '') . \strtr($template, $replaces) . '$/sD',
320 84
            '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,array<string,string>> $matches
328
     * @param string                             $pattern
329
     * @param RouteInterface                     $route
330
     *
331
     * @return array<int,array<int|string,mixed>>
332
     */
333 91
    private function computePattern(array $matches, string $pattern, RouteInterface $route): array
334
    {
335 91
        $parameters   = $replaces = [];
336 91
        $requirements = $this->getRequirements($route->getPatterns());
337 87
        $varNames     = $this->filterVariableNames($matches['names'], $pattern);
338 86
        $variables    = \array_combine($varNames, $matches['rules']) ?: [];
339 86
        $defaults     = \array_combine($varNames, $matches['defaults']) ?: [];
340
341 86
        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 45
            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 44
            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 43
            if (isset($defaults[$key]) && !empty($default = $defaults[$key])) {
367 6
                $route->setDefaults([$key => $default]);
368
            }
369
370 43
            $replaces["<$key>"] = \sprintf('(?P<%s>(?U)%s)', $key, $this->prepareSegment($key, $segment, $requirements));
371 43
            $parameters[]       = $key;
372
        }
373
374 84
        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 87
    private function filterVariableNames(array $names, string $pattern): array
386
    {
387 87
        $variables = [];
388
389 87
        foreach ($names as $varName) {
390 46
            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 46
            $variables[] = $varName;
401
        }
402
403 86
        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 43
    private function prepareSegment(string $name, string $segment, array $requirements): string
416
    {
417 43
        if ($segment !== '') {
418
            // If PCRE subpattern name starts with a digit. Append the missing symbol "}"
419 14
            if (1 === \preg_match('#\{(\d+)#', $segment)) {
420 7
                $segment = $segment . '}';
421
            }
422
423 14
            return self::SEGMENT_TYPES[$segment] ?? $segment;
424
        }
425
426 33
        if (!isset($requirements[$name])) {
427 29
            return self::DEFAULT_SEGMENT;
428
        }
429
430 6
        if (\is_array($requirements[$name])) {
431 3
            $values = \array_map([$this, 'filterSegment'], $requirements[$name]);
432
433 3
            return \implode('|', $values);
434
        }
435
436 3
        return $this->filterSegment((string) $requirements[$name]);
437
    }
438
439
    /**
440
     * @param string $segment
441
     *
442
     * @return string
443
     */
444 6
    private function filterSegment(string $segment): string
445
    {
446 6
        return \strtr($segment, self::SEGMENT_REPLACES);
447
    }
448
}
449