Passed
Push — master ( 6a5936...88a431 )
by Mihail
12:09
created

Router::match()   A

Complexity

Conditions 5
Paths 6

Size

Total Lines 12
Code Lines 8

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 8
CRAP Score 5

Importance

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