Passed
Push — master ( d7c804...5e483c )
by butschster
09:23 queued 13s
created

UriHandler::withPattern()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 8
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 6
CRAP Score 1

Importance

Changes 0
Metric Value
cc 1
eloc 5
nc 1
nop 1
dl 0
loc 8
ccs 6
cts 6
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
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 457
    public function __construct(
53
        private readonly UriFactoryInterface $uriFactory,
54
        SlugifyInterface $slugify = null,
55
        ?RoutePatternRegistryInterface $patternRegistry = null,
56
    ) {
57 457
        $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 457
        $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 394
    public function withConstrains(array $constrains, array $defaults = []): self
70
    {
71 394
        $uriHandler = clone $this;
72 394
        $uriHandler->compiled = null;
73 394
        $uriHandler->constrains = $constrains;
74 394
        $uriHandler->defaults = $defaults;
75
76 394
        return $uriHandler;
77
    }
78
79 1
    public function getConstrains(): array
80
    {
81 1
        return $this->constrains;
82
    }
83
84
    /**
85
     * @mutation-free
86
     */
87 334
    public function withPrefix(string $prefix): self
88
    {
89 334
        $uriHandler = clone $this;
90 334
        $uriHandler->compiled = null;
91 334
        $uriHandler->prefix = \trim($prefix, '/');
92
93 334
        return $uriHandler;
94
    }
95
96 120
    public function getPrefix(): string
97
    {
98 120
        return $this->prefix;
99
    }
100
101
    /**
102
     * @mutation-free
103
     */
104 375
    public function withBasePath(string $basePath): self
105
    {
106 375
        if (!\str_ends_with($basePath, '/')) {
107
            $basePath .= '/';
108
        }
109
110 375
        $uriHandler = clone $this;
111 375
        $uriHandler->compiled = null;
112 375
        $uriHandler->basePath = $basePath;
113
114 375
        return $uriHandler;
115
    }
116
117
    public function getBasePath(): string
118
    {
119
        return $this->basePath;
120
    }
121
122
    /**
123
     * @mutation-free
124
     */
125 412
    public function withPattern(string $pattern): self
126
    {
127 412
        $uriHandler = clone $this;
128 412
        $uriHandler->pattern = $pattern;
129 412
        $uriHandler->compiled = null;
130 412
        $uriHandler->matchHost = \str_starts_with($pattern, self::HOST_PREFIX);
131
132 412
        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 113
    public function isCompiled(): bool
142
    {
143 113
        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 100
    public function match(UriInterface $uri, array $defaults): ?array
155
    {
156 100
        if (!$this->isCompiled()) {
157 100
            $this->compile();
158
        }
159
160 95
        $matches = [];
161 95
        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 42
            return null;
163
        }
164
165 86
        $matches = \array_intersect_key(
166 86
            \array_filter($matches, static fn (string $value) => $value !== ''),
167 86
            $this->options
168 86
        );
169
170 86
        return \array_merge($this->options, $defaults, $matches);
171
    }
172
173
    /**
174
     * Generate Uri for a given parameters and default values.
175
     */
176 22
    public function uri(iterable $parameters = [], array $defaults = []): UriInterface
177
    {
178 22
        if (!$this->isCompiled()) {
179 13
            $this->compile();
180
        }
181
182 22
        $parameters = \array_merge(
183 22
            $this->options,
184 22
            $defaults,
185 22
            $this->fetchOptions($parameters, $query)
186 22
        );
187
188 22
        foreach ($this->constrains as $key => $_) {
189 20
            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 20
        $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 20
        $uri = $this->uriFactory->createUri(($this->matchHost ? '' : $this->basePath) . \trim($path, '/'));
199
200 20
        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 22
    private function fetchOptions(iterable $parameters, ?array &$query): array
209
    {
210 22
        $allowed = \array_keys($this->options);
211
212 22
        $result = [];
213 22
        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 22
        return $result;
233
    }
234
235
    /**
236
     * Part of uri path which is being matched.
237
     */
238 95
    private function fetchTarget(UriInterface $uri): string
239
    {
240 95
        $path = $uri->getPath();
241
242 95
        if (empty($path) || $path[0] !== '/') {
243 12
            $path = '/' . $path;
244
        }
245
246 95
        if ($this->matchHost) {
247 3
            $uriString = $uri->getHost() . $path;
248
        } else {
249 92
            $uriString = \substr($path, \strlen($this->basePath));
250 92
            if ($uriString === false) {
251
                $uriString = '';
252
            }
253
        }
254
255 95
        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 113
    private function compile(): void
265
    {
266 113
        if ($this->pattern === null) {
267
            throw new UriHandlerException('Unable to compile UriHandler, pattern is not set');
268
        }
269
270 113
        $options = [];
271 113
        $replaces = [];
272
273 113
        $prefix = \rtrim($this->getPrefix(), '/ ');
274 113
        $pattern = \ltrim($this->pattern, '/ ');
275 113
        $pattern = $prefix . '/' . $pattern;
276 113
        $pattern = \rtrim(\ltrim($pattern, ':/'), '/');
277
278
        // correct [/ first occurrence]
279 113
        if (\str_starts_with($pattern, '[/')) {
280 4
            $pattern = '[' . \substr($pattern, 2);
281
        }
282
283 113
        if (\preg_match_all('/<(\w+):?(.*?)?>/', $pattern, $matches)) {
284 89
            $variables = \array_combine($matches[1], $matches[2]);
285
286 89
            foreach ($variables as $key => $segment) {
287 89
                $segment = $this->prepareSegment($key, $segment);
288 89
                $replaces[\sprintf('<%s>', $key)] = \sprintf('(?P<%s>%s)', $key, $segment);
289 89
                $options[] = $key;
290
            }
291
        }
292
293 113
        $template = \preg_replace('/<(\w+):?.*?>/', '<\1>', $pattern);
294 113
        $options = \array_fill_keys($options, null);
295
296 113
        foreach ($this->constrains as $key => $value) {
297 102
            if ($value instanceof Autofill) {
298
                // only forces value replacement, not required to be presented as parameter
299 51
                continue;
300
            }
301
302 74
            if (!\array_key_exists($key, $options) && !isset($this->defaults[$key])) {
303 5
                throw new ConstrainException(
304 5
                    \sprintf(
305 5
                        'Route `%s` does not define routing parameter `<%s>`.',
306 5
                        $this->pattern,
307 5
                        $key
308 5
                    )
309 5
                );
310
            }
311
        }
312
313 108
        $this->compiled = '/^' . \strtr($template, $replaces + self::PATTERN_REPLACES) . '$/iu';
314 108
        $this->template = \stripslashes(\str_replace('?', '', $template));
315 108
        $this->options = $options;
316
    }
317
318
    /**
319
     * Interpolate string with given values.
320
     */
321 20
    private function interpolate(string $string, array $values): string
322
    {
323 20
        $replaces = [];
324 20
        foreach ($values as $key => $value) {
325 20
            $replaces[\sprintf('<%s>', $key)] = match (true) {
326 20
                $value instanceof \Stringable || \is_scalar($value) => (string)$value,
327 20
                default => '',
328 20
            };
329
        }
330
331 20
        return \strtr($string, $replaces + self::URI_FIXERS);
332
    }
333
334
    /**
335
     * Prepares segment pattern with given constrains.
336
     */
337 89
    private function prepareSegment(string $name, string $segment): string
338
    {
339 89
        return match (true) {
340 89
            $segment !== '' => $this->patternRegistry->all()[$segment] ?? $segment,
341 89
            !isset($this->constrains[$name]) => self::DEFAULT_SEGMENT,
342 89
            \is_array($this->constrains[$name]) => \implode(
343 89
                '|',
344 89
                \array_map(fn (string $segment): string => $this->filterSegment($segment), $this->constrains[$name])
345 89
            ),
346 89
            default => $this->filterSegment((string)$this->constrains[$name])
347 89
        };
348
    }
349
350 23
    private function filterSegment(string $segment): string
351
    {
352 23
        return \strtr($segment, self::SEGMENT_REPLACES);
353
    }
354
}
355