Passed
Push — master ( a154a4...db1aba )
by Mihail
11:52
created

Router::normalizeParams()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 11
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 6
CRAP Score 2.0625

Importance

Changes 2
Bugs 0 Features 0
Metric Value
cc 2
eloc 7
nc 2
nop 2
dl 0
loc 11
ccs 6
cts 8
cp 0.75
crap 2.0625
rs 10
c 2
b 0
f 0
1
<?php declare(strict_types=1);
2
3
namespace Koded\Framework;
4
5
use Koded\Stdlib\UUID;
6
use Psr\SimpleCache\CacheInterface;
7
use Throwable;
8
use function array_filter;
9
use function assert;
10
use function crc32;
11
use function explode;
12
use function is_object;
13
use function Koded\Stdlib\{json_serialize, json_unserialize};
0 ignored issues
show
Bug introduced by
The type Koded\Stdlib\json_serialize was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
Bug introduced by
The type Koded\Stdlib\json_unserialize was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
14
use function preg_match;
15
use function preg_match_all;
16
use function sprintf;
17
use function str_contains;
18
use function str_replace;
19
20
class Router
21
{
22
    private const INDEX = 'router.index';
23
24
    private bool $cached;
25
    private array $index;
26
    private array $identity = [];
27
    private array $callback = [];
28
29 41
    public function __construct(private CacheInterface $cache)
30
    {
31 41
        $this->index = $cache->get(self::INDEX, []);
32 41
        $this->cached = $cache->has(self::INDEX) && $this->index;
0 ignored issues
show
Bug Best Practice introduced by
The expression $this->index of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
33 41
    }
34
35 39
    public function __destruct()
36
    {
37
        // Saves the routes index in the cache
38 39
        if (false === $this->cached && $this->index) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $this->index of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
39 33
            $this->cached = !$this->cache->set(self::INDEX, $this->index);
40
        }
41 39
    }
42
43 40
    public function route(string $template, object|string $resource): void
44
    {
45 40
        assert('/' === $template[0], 'URI template must begin with "/"');
46 40
        assert(false === str_contains($template, '//'), 'URI template has duplicate slashes');
47
48 40
        $id = $this->id($template);
49 40
        if ($this->index[$id] ?? false) {
50
            if (empty($this->index[$id]['resource'])) {
51
                $this->callback[$id] = ['resource' => $resource] + $this->index[$id];
52
            }
53
            return;
54
        }
55 40
        $this->cache->set($id, $this->compileRoute($template, $resource, $id));
56 35
    }
57
58 32
    public function match(string $path): array
59
    {
60 32
        $id = $this->id($path);
61 32
        if ($route = $this->callback[$id] ?? $this->index[$id] ?? false) {
62 21
            return $route;
63
        }
64 13
        foreach ($this->index as $id => $route) {
65 12
            if (preg_match($route['regex'], $path, $params)) {
66 12
                return $this->normalizeParams($this->callback[$id] ?? $route, $params);
67
            }
68
        }
69 1
        return empty($this->index) ? ['resource' => 'no_app_routes'] : [];
70
    }
71
72 12
    private function normalizeParams(array $route, array $params): array
73
    {
74 12
        if (empty($params)) {
75
            $route['params'] = [];
76
            return $route;
77
        }
78 12
        $route['params'] = json_unserialize(json_serialize(
79 12
            array_filter($params, 'is_string', ARRAY_FILTER_USE_KEY),
80 12
            JSON_NUMERIC_CHECK)
81
        );
82 12
        return $route;
83
    }
84
85 41
    private function id(string $value): string
86
    {
87 41
        return 'r.' . crc32($value);
88
    }
89
90 40
    private function compileRoute(
91
        string $template,
92
        object|string $resource,
93
        string $id): array
94
    {
95 40
        $route = $this->compileTemplate($template) + [
96 35
            'resource' => is_object($resource) ? '' : $resource,
97 35
            'template' => $template,
98
        ];
99 35
        $this->index[$id] = $route;
100 35
        if (empty($route['resource'])) {
101 17
            $this->callback[$id] = ['resource' => $resource] + $route;
102
        }
103 35
        return $route;
104
    }
105
106 40
    private function compileTemplate(string $template): array
107
    {
108
        // Test for direct URI
109 40
        if (false === str_contains($template, '{') &&
110 40
            false === str_contains($template, '<')) {
111 23
            $this->identity[$template] = $template;
112
            return [
113 23
                'regex' => "~^$template\$~ui",
114 23
                'identity' => $template
115
            ];
116
        }
117 19
        $options = '';
118 19
        $regex = $identity = $template;
119 19
        $types = [
120 19
            'str' => '.+?', // non-greedy (stop at first match)
121 19
            'int' => '\-?\d+',
122 19
            'float' => '(\-?\d*\.\d+)',
123 19
            'path' => '.+', // greedy
124 19
            'regex' => '',
125 19
            'uuid' => UUID::PATTERN,
126
        ];
127
128
        // https://regex101.com/r/xeuMU3/2
129 19
        preg_match_all('~{((?:[^{}]*|(?R))*)}~mx',
130 19
                       $template,
131 19
                       $parameters,
132 19
                       PREG_SET_ORDER);
133
134 19
        foreach ($parameters as [$parameter, $param]) {
135 19
            [$name, $type, $filter] = explode(':', $param, 3) + [1 => 'str', 2 => ''];
136 19
            $this->assertSupportedType($template, $types, $type, $filter);
137 16
            $regex = str_replace($parameter, '(?P<' . $name .'>' . ($filter ?: $types[$type]) . ')', $regex);
138 16
            $identity = str_replace($parameter, $types[$type] ? ":$type" : $filter, $identity);
139 16
            ('str' === $type || 'path' === $type) && $options = 'ui';
140
        }
141
        /*
142
         * [NOTE]: Replace :path with :str. The concept of "path" is irrelevant
143
         *  because the single parameters are matched as non-greedy (first occurrence)
144
         *  and the path is greedy matched (as many as possible). The implementation
145
         *  cannot distinguish between both types, therefore limit the types to :str
146
         *  and disallow routes with multiple :path types.
147
         */
148 16
        $identity = str_replace(':path', ':str', $identity, $paths);
149 16
        $this->assertIdentityAndPaths($template, $identity, $paths);
150 15
        $this->identity[$identity] = $template;
151
152
        try {
153 15
            $regex = "~^$regex\$~$options";
154
            // TODO: Quick test for duplicate subpattern names
155 15
            preg_match($regex, '/');
156
            return [
157 14
                'regex' => $regex,
158 14
                'identity' => $identity
159
            ];
160 1
        } catch (Throwable $ex) {
161 1
            throw new HTTPConflict(
162 1
                title: 'PCRE compilation error. ' . $ex->getMessage(),
163 1
                detail: $ex->getMessage(),
164
                instance: $template,
165
            );
166
        }
167
    }
168
169 19
    private function assertSupportedType(
170
        string $template,
171
        array $types,
172
        string $type,
173
        string $filter): void
174
    {
175 19
        ('regex' === $type and empty($filter)) and throw new HTTPConflict(
176 2
            title: 'Invalid route. No regular expression provided',
177 2
            detail: 'Provide a proper PCRE regular expression',
178
            instance: $template,
179
        );
180 17
        isset($types[$type]) or throw (new HTTPConflict(
181 1
            title: "Invalid route parameter type $type",
182 1
            detail: 'Use one of the supported parameter types',
183
            instance: $template,
184 1
        ))->setMember('supported-types', \array_keys($types));
185 16
    }
186
187 16
    private function assertIdentityAndPaths(
188
        string $template,
189
        string $identity,
190
        int $paths): void
191
    {
192 16
        isset($this->identity[$identity]) and throw (new HTTPConflict(
193 2
            instance: $template,
194 2
            title: 'Duplicate route',
195 2
            detail: sprintf(
196
                'Detected a multiple route definitions. The URI template ' .
197 2
                'for "%s" conflicts with an already registered route "%s".',
198 2
                $template, $this->identity[$identity])
199 2
        ))->setMember('conflict-route', [$template => $this->identity[$identity]]);
200
201 16
        $paths > 1 and throw new HTTPConflict(
202 1
            title: 'Invalid route. Multiple path parameters in the route template detected',
203 1
            detail: 'Only one "path" type is allowed as URI parameter',
204
            instance: $template,
205
        );
206 15
    }
207
}
208