Passed
Push — master ( 67a020...fe0e28 )
by butschster
04:26 queued 17s
created

UriHandler::compile()   B

Complexity

Conditions 9
Paths 17

Size

Total Lines 48
Code Lines 27

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 29
CRAP Score 9.0029

Importance

Changes 0
Metric Value
eloc 27
dl 0
loc 48
ccs 29
cts 30
cp 0.9667
rs 8.0555
c 0
b 0
f 0
cc 9
nc 17
nop 0
crap 9.0029
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
use Spiral\Router\Registry\DefaultPatternRegistry;
14
use Spiral\Router\Registry\RoutePatternRegistryInterface;
15
16
/**
17
 * UriMatcher provides ability to match and generate uris based on given parameters.
18
 *
19
 * @psalm-type Matches = array{controller: non-empty-string, action: non-empty-string, ...}
20
 */
21
final class UriHandler
22
{
23
    private const HOST_PREFIX = '//';
24
    private const DEFAULT_SEGMENT = '[^\/]+';
25
    private const PATTERN_REPLACES = ['/' => '\\/', '[' => '(?:', ']' => ')?', '.' => '\.'];
26
    private const SEGMENT_REPLACES = ['/' => '\\/', '.' => '\.'];
27
    private const URI_FIXERS = [
28
        '[]' => '',
29
        '[/]' => '',
30
        '[' => '',
31
        ']' => '',
32
        '://' => '://',
33
        '//' => '/',
34
    ];
35
36
    private ?string $pattern = null;
37
38
    /** @internal */
39
    private readonly SlugifyInterface $slugify;
40
    private readonly RoutePatternRegistryInterface $patternRegistry;
41
    private array $constrains = [];
42
    private array $defaults = [];
43
    private bool $matchHost = false;
44
    /** @readonly */
45
    private string $prefix = '';
46
    /** @readonly */
47
    private string $basePath = '/';
48
    private ?string $compiled = null;
49
    private ?string $template = null;
50
    private array $options = [];
51
52 435
    public function __construct(
53
        private readonly UriFactoryInterface $uriFactory,
54
        SlugifyInterface $slugify = null,
55
        ?RoutePatternRegistryInterface $patternRegistry = null,
56
    ) {
57 435
        $this->patternRegistry = $patternRegistry ?? new DefaultPatternRegistry();
0 ignored issues
show
Bug introduced by
The property patternRegistry is declared read-only in Spiral\Router\UriHandler.
Loading history...
58 435
        $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...
59
    }
60
61
    public function getPattern(): ?string
62
    {
63
        return $this->pattern;
64
    }
65
66
    /**
67
     * @mutation-free
68
     */
69 372
    public function withConstrains(array $constrains, array $defaults = []): self
70
    {
71 372
        $uriHandler = clone $this;
72 372
        $uriHandler->compiled = null;
73 372
        $uriHandler->constrains = $constrains;
74 372
        $uriHandler->defaults = $defaults;
75
76 372
        return $uriHandler;
77
    }
78
79 1
    public function getConstrains(): array
80
    {
81 1
        return $this->constrains;
82
    }
83
84
    /**
85
     * @mutation-free
86
     */
87 312
    public function withPrefix(string $prefix): self
88
    {
89 312
        $uriHandler = clone $this;
90 312
        $uriHandler->compiled = null;
91 312
        $uriHandler->prefix = \trim($prefix, '/');
92
93 312
        return $uriHandler;
94
    }
95
96 111
    public function getPrefix(): string
97
    {
98 111
        return $this->prefix;
99
    }
100
101
    /**
102
     * @mutation-free
103
     */
104 353
    public function withBasePath(string $basePath): self
105
    {
106 353
        if (!\str_ends_with($basePath, '/')) {
107
            $basePath .= '/';
108
        }
109
110 353
        $uriHandler = clone $this;
111 353
        $uriHandler->compiled = null;
112 353
        $uriHandler->basePath = $basePath;
113
114 353
        return $uriHandler;
115
    }
116
117
    public function getBasePath(): string
118
    {
119
        return $this->basePath;
120
    }
121
122
    /**
123
     * @mutation-free
124
     */
125 390
    public function withPattern(string $pattern): self
126
    {
127 390
        $uriHandler = clone $this;
128 390
        $uriHandler->pattern = $pattern;
129 390
        $uriHandler->compiled = null;
130 390
        $uriHandler->matchHost = \str_starts_with($pattern, self::HOST_PREFIX);
131
132 390
        return $uriHandler;
133
    }
134
135
    /**
136
     * @psalm-assert-if-false null $this->compiled
137
     * @psalm-assert-if-true !null $this->compiled
138
     * @psalm-assert-if-true !null $this->pattern
139
     * @psalm-assert-if-true !null $this->template
140
     */
141 104
    public function isCompiled(): bool
142
    {
143 104
        return $this->compiled !== null;
144
    }
145
146
    /**
147
     * Match given url against compiled template and return matches array or null if pattern does
148
     * not match.
149
     *
150
     * @return Matches|null
0 ignored issues
show
Bug introduced by
The type Spiral\Router\Matches 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...
151
     *
152
     * @psalm-external-mutation-free
153
     */
154 91
    public function match(UriInterface $uri, array $defaults): ?array
155
    {
156 91
        if (!$this->isCompiled()) {
157 91
            $this->compile();
158
        }
159
160 86
        $matches = [];
161 86
        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

161
        if (!\preg_match(/** @scrutinizer ignore-type */ $this->compiled, $this->fetchTarget($uri), $matches)) {
Loading history...
162 41
            return null;
163
        }
164
165 77
        $matches = \array_intersect_key(
166 77
            \array_filter($matches, static fn (string $value) => $value !== ''),
167 77
            $this->options
168 77
        );
169
170 77
        return \array_merge($this->options, $defaults, $matches);
171
    }
172
173
    /**
174
     * Generate Uri for a given parameters and default values.
175
     */
176 13
    public function uri(iterable $parameters = [], array $defaults = []): UriInterface
177
    {
178 13
        if (!$this->isCompiled()) {
179 13
            $this->compile();
180
        }
181
182 13
        $parameters = \array_merge(
183 13
            $this->options,
184 13
            $defaults,
185 13
            $this->fetchOptions($parameters, $query)
186 13
        );
187
188 13
        foreach ($this->constrains as $key => $_) {
189 11
            if (empty($parameters[$key])) {
190 2
                throw new UriHandlerException(\sprintf('Unable to generate Uri, parameter `%s` is missing', $key));
191
            }
192
        }
193
194
        //Uri without empty blocks (pretty stupid implementation)
195 11
        $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

195
        $path = $this->interpolate(/** @scrutinizer ignore-type */ $this->template, $parameters);
Loading history...
196
197
        //Uri with added base path and prefix
198 11
        $uri = $this->uriFactory->createUri(($this->matchHost ? '' : $this->basePath) . \trim($path, '/'));
199
200 11
        return empty($query) ? $uri : $uri->withQuery(\http_build_query($query));
201
    }
202
203
    /**
204
     * Fetch uri segments and query parameters.
205
     *
206
     * @param array|null $query Query parameters.
207
     */
208 13
    private function fetchOptions(iterable $parameters, ?array &$query): array
209
    {
210 13
        $allowed = \array_keys($this->options);
211
212 13
        $result = [];
213 13
        foreach ($parameters as $key => $parameter) {
214 11
            if (\is_int($key) && isset($allowed[$key])) {
215
                // this segment fetched keys from given parameters either by name or by position
216 2
                $key = $allowed[$key];
217 9
            } elseif (!\array_key_exists($key, $this->options) && \is_array($parameters)) {
218
                // all additional parameters given in array form can be glued to query string
219 1
                $query[$key] = $parameter;
220 1
                continue;
221
            }
222
223
            //String must be normalized here
224 11
            if (\is_string($parameter) && !\preg_match('/^[a-z\-_0-9]+$/i', $parameter)) {
225 2
                $result[$key] = $this->slugify->slugify($parameter);
226 2
                continue;
227
            }
228
229 11
            $result[$key] = (string)$parameter;
230
        }
231
232 13
        return $result;
233
    }
234
235
    /**
236
     * Part of uri path which is being matched.
237
     */
238 86
    private function fetchTarget(UriInterface $uri): string
239
    {
240 86
        $path = $uri->getPath();
241
242 86
        if (empty($path) || $path[0] !== '/') {
243 12
            $path = '/' . $path;
244
        }
245
246 86
        if ($this->matchHost) {
247 3
            $uriString = $uri->getHost() . $path;
248
        } else {
249 83
            $uriString = \substr($path, \strlen($this->basePath));
250 83
            if ($uriString === false) {
251
                $uriString = '';
252
            }
253
        }
254
255 86
        return \trim($uriString, '/');
256
    }
257
258
    /**
259
     * Compile route matcher into regexp.
260
     * @psalm-assert !null $this->pattern
261
     * @psalm-assert !null $this->template
262
     * @psalm-assert !null $this->compiled
263
     */
264 104
    private function compile(): void
265
    {
266 104
        if ($this->pattern === null) {
267
            throw new UriHandlerException('Unable to compile UriHandler, pattern is not set');
268
        }
269
270 104
        $options = [];
271 104
        $replaces = [];
272 104
        $pattern = \rtrim(\ltrim($this->getPrefix() . '/' . $this->pattern, ':/'), '/');
273
274
        // correct [/ first occurrence]
275 104
        if (\str_starts_with($pattern, '[/')) {
276 4
            $pattern = '[' . \substr($pattern, 2);
277
        }
278
279 104
        if (\preg_match_all('/<(\w+):?(.*?)?>/', $pattern, $matches)) {
280 87
            $variables = \array_combine($matches[1], $matches[2]);
281
282 87
            foreach ($variables as $key => $segment) {
283 87
                $segment = $this->prepareSegment($key, $segment);
284 87
                $replaces[\sprintf('<%s>', $key)] = \sprintf('(?P<%s>%s)', $key, $segment);
285 87
                $options[] = $key;
286
            }
287
        }
288
289 104
        $template = \preg_replace('/<(\w+):?.*?>/', '<\1>', $pattern);
290 104
        $options = \array_fill_keys($options, null);
291
292 104
        foreach ($this->constrains as $key => $value) {
293 93
            if ($value instanceof Autofill) {
294
                // only forces value replacement, not required to be presented as parameter
295 42
                continue;
296
            }
297
298 74
            if (!\array_key_exists($key, $options) && !isset($this->defaults[$key])) {
299 5
                throw new ConstrainException(
300 5
                    \sprintf(
301 5
                        'Route `%s` does not define routing parameter `<%s>`.',
302 5
                        $this->pattern,
303 5
                        $key
304 5
                    )
305 5
                );
306
            }
307
        }
308
309 99
        $this->compiled = '/^' . \strtr($template, $replaces + self::PATTERN_REPLACES) . '$/iu';
310 99
        $this->template = \stripslashes(\str_replace('?', '', $template));
311 99
        $this->options = $options;
312
    }
313
314
    /**
315
     * Interpolate string with given values.
316
     */
317 11
    private function interpolate(string $string, array $values): string
318
    {
319 11
        $replaces = [];
320 11
        foreach ($values as $key => $value) {
321 11
            $replaces[\sprintf('<%s>', $key)] = match (true) {
322 11
                $value instanceof \Stringable || \is_scalar($value) => (string)$value,
323 11
                default => '',
324 11
            };
325
        }
326
327 11
        return \strtr($string, $replaces + self::URI_FIXERS);
328
    }
329
330
    /**
331
     * Prepares segment pattern with given constrains.
332
     */
333 87
    private function prepareSegment(string $name, string $segment): string
334
    {
335 87
        return match (true) {
336 87
            $segment !== '' => $this->patternRegistry->all()[$segment] ?? $segment,
337 87
            !isset($this->constrains[$name]) => self::DEFAULT_SEGMENT,
338 87
            \is_array($this->constrains[$name]) => \implode(
339 87
                '|',
340 87
                \array_map(fn (string $segment): string => $this->filterSegment($segment), $this->constrains[$name])
341 87
            ),
342 87
            default => $this->filterSegment((string)$this->constrains[$name])
343 87
        };
344
    }
345
346 23
    private function filterSegment(string $segment): string
347
    {
348 23
        return \strtr($segment, self::SEGMENT_REPLACES);
349
    }
350
}
351