Router::route()   A
last analyzed

Complexity

Conditions 3
Paths 3

Size

Total Lines 13
Code Lines 8

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 6
CRAP Score 3.3332

Importance

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