Passed
Pull Request — master (#1141)
by Abdul Malik
15:30 queued 03:41
created

UriHandler::withPathSegmentEncoder()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 6
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 4
CRAP Score 1

Importance

Changes 0
Metric Value
eloc 3
c 0
b 0
f 0
dl 0
loc 6
ccs 4
cts 4
cp 1
rs 10
cc 1
nc 1
nop 1
crap 1
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
    private array $constrains = [];
38
    private array $defaults = [];
39
    private bool $matchHost = false;
40
    /** @readonly */
41
    private string $prefix = '';
42
    /** @readonly */
43
    private string $basePath = '/';
44
    private ?string $compiled = null;
45
    private ?string $template = null;
46
    private array $options = [];
47
48
    private \Closure $pathSegmentEncoder;
49
50
    /**
51
     * Note: SlugifyInterface will be removed in next major release.
52
     * @see UriHandler::withPathSegmentEncoder() for more details.
53
     */
54 515
    public function __construct(
55
        private readonly UriFactoryInterface $uriFactory,
56
        SlugifyInterface $slugify = null,
57
        private readonly RoutePatternRegistryInterface $patternRegistry = new DefaultPatternRegistry(),
58
    ) {
59 515
        $slugify ??= new Slugify();
60 515
        $this->pathSegmentEncoder = static fn (string $segment): string => $slugify->slugify($segment);
61
    }
62
63
    /**
64
     * Set custom path segment encoder.
65
     *
66
     * @param \Closure(non-empty-string): non-empty-string $callable Callable must accept string and return string.
67
     */
68 4
    public function withPathSegmentEncoder(\Closure $callable): self
69
    {
70 4
        $uriHandler = clone $this;
71 4
        $uriHandler->pathSegmentEncoder = $callable;
72
73 4
        return $uriHandler;
74
    }
75
76
    public function getPattern(): ?string
77
    {
78
        return $this->pattern;
79
    }
80
81
    /**
82
     * @mutation-free
83
     */
84 450
    public function withConstrains(array $constrains, array $defaults = []): self
85
    {
86 450
        $uriHandler = clone $this;
87 450
        $uriHandler->compiled = null;
88 450
        $uriHandler->constrains = $constrains;
89 450
        $uriHandler->defaults = $defaults;
90
91 450
        return $uriHandler;
92
    }
93
94 1
    public function getConstrains(): array
95
    {
96 1
        return $this->constrains;
97
    }
98
99
    /**
100
     * @mutation-free
101
     */
102 387
    public function withPrefix(string $prefix): self
103
    {
104 387
        $uriHandler = clone $this;
105 387
        $uriHandler->compiled = null;
106 387
        $uriHandler->prefix = \trim($prefix, '/');
107
108 387
        return $uriHandler;
109
    }
110
111 120
    public function getPrefix(): string
112
    {
113 120
        return $this->prefix;
114
    }
115
116
    /**
117
     * @mutation-free
118
     */
119 432
    public function withBasePath(string $basePath): self
120
    {
121 432
        if (!\str_ends_with($basePath, '/')) {
122 1
            $basePath .= '/';
123
        }
124
125 432
        $uriHandler = clone $this;
126 432
        $uriHandler->compiled = null;
127 432
        $uriHandler->basePath = $basePath;
128
129 432
        return $uriHandler;
130
    }
131
132
    public function getBasePath(): string
133
    {
134
        return $this->basePath;
135
    }
136
137
    /**
138
     * @mutation-free
139
     */
140 469
    public function withPattern(string $pattern): self
141
    {
142 469
        $uriHandler = clone $this;
143 469
        $uriHandler->pattern = $pattern;
144 469
        $uriHandler->compiled = null;
145 469
        $uriHandler->matchHost = \str_starts_with($pattern, self::HOST_PREFIX);
146
147 469
        return $uriHandler;
148
    }
149
150
    /**
151
     * @psalm-assert-if-false null $this->compiled
152
     * @psalm-assert-if-true !null $this->compiled
153
     * @psalm-assert-if-true !null $this->pattern
154
     * @psalm-assert-if-true !null $this->template
155
     */
156 113
    public function isCompiled(): bool
157
    {
158 113
        return $this->compiled !== null;
159
    }
160
161
    /**
162
     * Match given url against compiled template and return matches array or null if pattern does
163
     * not match.
164
     *
165
     * @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...
166
     *
167
     * @psalm-external-mutation-free
168
     */
169 95
    public function match(UriInterface $uri, array $defaults): ?array
170
    {
171 95
        if (!$this->isCompiled()) {
172 95
            $this->compile();
173
        }
174
175 90
        $matches = [];
176 90
        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

176
        if (!\preg_match(/** @scrutinizer ignore-type */ $this->compiled, $this->fetchTarget($uri), $matches)) {
Loading history...
177 36
            return null;
178
        }
179
180 81
        $matches = \array_intersect_key(
181 81
            \array_filter($matches, static fn (string $value) => $value !== ''),
182 81
            $this->options
183 81
        );
184
185 81
        return \array_merge($this->options, $defaults, $matches);
186
    }
187
188
    /**
189
     * Generate Uri for a given parameters and default values.
190
     */
191 27
    public function uri(iterable $parameters = [], array $defaults = []): UriInterface
192
    {
193 27
        if (!$this->isCompiled()) {
194 18
            $this->compile();
195
        }
196
197 27
        $parameters = \array_merge(
198 27
            $this->options,
199 27
            $defaults,
200 27
            $this->fetchOptions($parameters, $query)
201 27
        );
202
203 27
        foreach ($this->constrains as $key => $_) {
204 24
            if (empty($parameters[$key])) {
205 2
                throw new UriHandlerException(\sprintf('Unable to generate Uri, parameter `%s` is missing', $key));
206
            }
207
        }
208
209
        //Uri without empty blocks (pretty stupid implementation)
210 25
        $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

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