Passed
Pull Request — master (#802)
by butschster
17:09
created

UriHandler::isCompiled()   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
namespace Spiral\Router;
6
7
use Cocur\Slugify\Slugify;
8
use Cocur\Slugify\SlugifyInterface;
9
use Psr\Http\Message\UriFactoryInterface;
10
use Psr\Http\Message\UriInterface;
11
use Spiral\Router\Exception\ConstrainException;
12
use Spiral\Router\Exception\UriHandlerException;
13
14
/**
15
 * UriMatcher provides ability to match and generate uris based on given parameters.
16
 */
17
final class UriHandler
18
{
19
    private const HOST_PREFIX      = '//';
20
    private const DEFAULT_SEGMENT  = '[^\/]+';
21
    private const PATTERN_REPLACES = ['/' => '\\/', '[' => '(?:', ']' => ')?', '.' => '\.'];
22
    private const SEGMENT_REPLACES = ['/' => '\\/', '.' => '\.'];
23
    private const SEGMENT_TYPES    = [
24
        'int'     => '\d+',
25
        'integer' => '\d+',
26
        '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}',
27
    ];
28
    private const URI_FIXERS       = [
29
        '[]'  => '',
30
        '[/]' => '',
31
        '['   => '',
32
        ']'   => '',
33
        '://' => '://',
34
        '//'  => '/',
35
    ];
36
37
    private ?string $pattern = null;
38
39
    /** @internal */
40
    private readonly SlugifyInterface $slugify;
41
    private array $constrains = [];
42
    private array $defaults = [];
43
    private bool $matchHost = false;
44
    private string $prefix = '';
45
    private ?string $compiled = null;
46
    private ?string $template = null;
47
    private array $options = [];
48
49 373
    public function __construct(
50
        private readonly UriFactoryInterface $uriFactory,
51
        SlugifyInterface $slugify = null
52
    ) {
53 373
        $this->slugify = $slugify ?? new Slugify();
0 ignored issues
show
Bug introduced by
The property slugify is declared read-only in Spiral\Router\UriHandler.
Loading history...
54
    }
55
56
    public function getPattern(): ?string
57
    {
58
        return $this->pattern;
59
    }
60
61 318
    public function withConstrains(array $constrains, array $defaults = []): self
62
    {
63 318
        $uriHandler = clone $this;
64 318
        $uriHandler->compiled = null;
65 318
        $uriHandler->constrains = $constrains;
66 318
        $uriHandler->defaults = $defaults;
67
68 318
        return $uriHandler;
69
    }
70
71 1
    public function getConstrains(): array
72
    {
73 1
        return $this->constrains;
74
    }
75
76 300
    public function withPrefix(string $prefix): self
77
    {
78 300
        $uriHandler = clone $this;
79 300
        $uriHandler->compiled = null;
80 300
        $uriHandler->prefix = $prefix;
81
82 300
        return $uriHandler;
83
    }
84
85 1
    public function getPrefix(): string
86
    {
87 1
        return $this->prefix;
88
    }
89
90 330
    public function withPattern(string $pattern): self
91
    {
92 330
        $uriHandler = clone $this;
93 330
        $uriHandler->pattern = $pattern;
94 330
        $uriHandler->compiled = null;
95 330
        $uriHandler->matchHost = \str_starts_with($pattern, self::HOST_PREFIX);
96
97 330
        return $uriHandler;
98
    }
99
100 101
    public function isCompiled(): bool
101
    {
102 101
        return $this->compiled !== null;
103
    }
104
105
    /**
106
     * Match given url against compiled template and return matches array or null if pattern does
107
     * not match.
108
     */
109 89
    public function match(UriInterface $uri, array $defaults): ?array
110
    {
111 89
        if (!$this->isCompiled()) {
112 89
            $this->compile();
113
        }
114
115 84
        $matches = [];
116
117 84
        if (!\preg_match($this->compiled, $this->fetchTarget($uri), $matches)) {
0 ignored issues
show
Bug introduced by
It seems like $this->compiled can also be of type null; however, parameter $pattern of preg_match() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

117
        if (!\preg_match(/** @scrutinizer ignore-type */ $this->compiled, $this->fetchTarget($uri), $matches)) {
Loading history...
118 42
            return null;
119
        }
120
121 75
        $matches = \array_intersect_key(
122 75
            \array_filter($matches, static fn (string $value) => $value !== ''),
123 75
            $this->options
124
        );
125
126 75
        return \array_merge($this->options, $defaults, $matches);
127
    }
128
129
    /**
130
     * Generate Uri for a given parameters and default values.
131
     */
132 12
    public function uri(iterable $parameters = [], array $defaults = []): UriInterface
133
    {
134 12
        if (!$this->isCompiled()) {
135 12
            $this->compile();
136
        }
137
138 12
        $parameters = \array_merge(
139 12
            $this->options,
140
            $defaults,
141 12
            $this->fetchOptions($parameters, $query)
142
        );
143
144 12
        foreach ($this->constrains as $key => $_) {
145 11
            if (empty($parameters[$key])) {
146 2
                throw new UriHandlerException(\sprintf('Unable to generate Uri, parameter `%s` is missing', $key));
147
            }
148
        }
149
150
        //Uri without empty blocks (pretty stupid implementation)
151 10
        $path = $this->interpolate($this->template, $parameters);
0 ignored issues
show
Bug introduced by
It seems like $this->template can also be of type null; however, parameter $string of Spiral\Router\UriHandler::interpolate() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

151
        $path = $this->interpolate(/** @scrutinizer ignore-type */ $this->template, $parameters);
Loading history...
152
153
        //Uri with added prefix
154 10
        $uri = $this->uriFactory->createUri(($this->matchHost ? '' : $this->prefix) . trim($path, '/'));
155
156 10
        return empty($query) ? $uri : $uri->withQuery(\http_build_query($query));
157
    }
158
159
    /**
160
     * Fetch uri segments and query parameters.
161
     *
162
     * @param array|null         $query Query parameters.
163
     */
164 12
    private function fetchOptions(iterable $parameters, ?array &$query): array
165
    {
166 12
        $allowed = \array_keys($this->options);
167
168 12
        $result = [];
169 12
        foreach ($parameters as $key => $parameter) {
170 10
            if (\is_int($key) && isset($allowed[$key])) {
171
                // this segment fetched keys from given parameters either by name or by position
172 2
                $key = $allowed[$key];
173 8
            } elseif (!\array_key_exists($key, $this->options) && \is_array($parameters)) {
174
                // all additional parameters given in array form can be glued to query string
175 1
                $query[$key] = $parameter;
176 1
                continue;
177
            }
178
179
            //String must be normalized here
180 10
            if (\is_string($parameter) && !\preg_match('/^[a-z\-_0-9]+$/i', $parameter)) {
181 2
                $result[$key] = $this->slugify->slugify($parameter);
182 2
                continue;
183
            }
184
185 10
            $result[$key] = (string)$parameter;
186
        }
187
188 12
        return $result;
189
    }
190
191
    /**
192
     * Part of uri path which is being matched.
193
     */
194 84
    private function fetchTarget(UriInterface $uri): string
195
    {
196 84
        $path = $uri->getPath();
197
198 84
        if (empty($path) || $path[0] !== '/') {
199 12
            $path = '/' . $path;
200
        }
201
202 84
        if ($this->matchHost) {
203 3
            $uriString = $uri->getHost() . $path;
204
        } else {
205 81
            $uriString = \substr($path, \strlen($this->prefix));
206 81
            if ($uriString === false) {
207
                $uriString = '';
208
            }
209
        }
210
211 84
        return \trim($uriString, '/');
212
    }
213
214
    /**
215
     * Compile route matcher into regexp.
216
     */
217 101
    private function compile(): void
218
    {
219 101
        if ($this->pattern === null) {
220
            throw new UriHandlerException('Unable to compile UriHandler, pattern is not set');
221
        }
222
223 101
        $options = [];
224 101
        $replaces = [];
225 101
        $pattern = \rtrim(\ltrim($this->pattern, ':/'), '/');
226
227
        // correct [/ first occurrence]
228 101
        if (\str_starts_with($pattern, '[/')) {
229 4
            $pattern = '[' . \substr($pattern, 2);
230
        }
231
232 101
        if (\preg_match_all('/<(\w+):?(.*?)?>/', $pattern, $matches)) {
233 85
            $variables = \array_combine($matches[1], $matches[2]);
234
235 85
            foreach ($variables as $key => $segment) {
236 85
                $segment = $this->prepareSegment($key, $segment);
237 85
                $replaces[\sprintf('<%s>', $key)] = \sprintf('(?P<%s>%s)', $key, $segment);
238 85
                $options[] = $key;
239
            }
240
        }
241
242 101
        $template = \preg_replace('/<(\w+):?.*?>/', '<\1>', $pattern);
243 101
        $options = \array_fill_keys($options, null);
244
245 101
        foreach ($this->constrains as $key => $value) {
246 91
            if ($value instanceof Autofill) {
247
                // only forces value replacement, not required to be presented as parameter
248 40
                continue;
249
            }
250
251 74
            if (!\array_key_exists($key, $options) && !isset($this->defaults[$key])) {
252 5
                throw new ConstrainException(
253 5
                    \sprintf(
254
                        'Route `%s` does not define routing parameter `<%s>`.',
255 5
                        $this->pattern,
256
                        $key
257
                    )
258
                );
259
            }
260
        }
261
262 96
        $this->compiled = '/^' . \strtr($template, $replaces + self::PATTERN_REPLACES) . '$/iu';
263 96
        $this->template = \stripslashes(\str_replace('?', '', $template));
264 96
        $this->options = $options;
265
    }
266
267
    /**
268
     * Interpolate string with given values.
269
     */
270 10
    private function interpolate(string $string, array $values): string
271
    {
272 10
        $replaces = [];
273 10
        foreach ($values as $key => $value) {
274 10
            $replaces[\sprintf('<%s>', $key)] = match (true) {
275 10
                $value instanceof \Stringable || \is_scalar($value) => (string) $value,
276 4
                default => '',
277
            };
278
        }
279
280 10
        return \strtr($string, $replaces + self::URI_FIXERS);
281
    }
282
283
    /**
284
     * Prepares segment pattern with given constrains.
285
     */
286 85
    private function prepareSegment(string $name, string $segment): string
287
    {
288
        return match (true) {
289 85
            $segment !== '' => self::SEGMENT_TYPES[$segment] ?? $segment,
290 83
            !isset($this->constrains[$name]) => self::DEFAULT_SEGMENT,
291 23
            \is_array($this->constrains[$name]) => \implode(
292
                '|',
293 22
                \array_map(fn (string $segment): string => $this->filterSegment($segment), $this->constrains[$name])
294
            ),
295 85
            default => $this->filterSegment((string)$this->constrains[$name])
296
        };
297
    }
298
299 23
    private function filterSegment(string $segment): string
300
    {
301 23
        return \strtr($segment, self::SEGMENT_REPLACES);
302
    }
303
}
304