Test Failed
Pull Request — master (#831)
by Maxim
13:01
created

UriHandler::getBasePath()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 2

Importance

Changes 0
Metric Value
eloc 1
dl 0
loc 3
ccs 0
cts 0
cp 0
rs 10
c 0
b 0
f 0
cc 1
nc 1
nop 0
crap 2
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 $basePath = '/';
46
    private ?string $compiled = null;
47
    private ?string $template = null;
48
    private array $options = [];
49 398
50
    public function __construct(
51
        private readonly UriFactoryInterface $uriFactory,
52
        SlugifyInterface $slugify = null
53 398
    ) {
54
        $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...
55
    }
56
57
    public function getPattern(): ?string
58
    {
59
        return $this->pattern;
60
    }
61 343
62
    public function withConstrains(array $constrains, array $defaults = []): self
63 343
    {
64 343
        $uriHandler = clone $this;
65 343
        $uriHandler->compiled = null;
66 343
        $uriHandler->constrains = $constrains;
67
        $uriHandler->defaults = $defaults;
68 343
69
        return $uriHandler;
70
    }
71 1
72
    public function getConstrains(): array
73 1
    {
74
        return $this->constrains;
75
    }
76 325
77
    public function withPrefix(string $prefix): self
78 325
    {
79 325
        $uriHandler = clone $this;
80 325
        $uriHandler->compiled = null;
81
        $uriHandler->prefix = \trim($prefix, '/');
82 325
83
        return $uriHandler;
84
    }
85 1
86
    public function getPrefix(): string
87 1
    {
88
        return $this->prefix;
89
    }
90 355
91
    public function withBasePath(string $basePath): self
92 355
    {
93 355
        if (!\str_ends_with($basePath, '/')) {
94 355
            $basePath .= '/';
95 355
        }
96
97 355
        $uriHandler = clone $this;
98
        $uriHandler->compiled = null;
99
        $uriHandler->basePath = $basePath;
100 101
101
        return $uriHandler;
102 101
    }
103
104
    public function getBasePath(): string
105
    {
106
        return $this->basePath;
107
    }
108
109 89
    public function withPattern(string $pattern): self
110
    {
111 89
        $uriHandler = clone $this;
112 89
        $uriHandler->pattern = $pattern;
113
        $uriHandler->compiled = null;
114
        $uriHandler->matchHost = \str_starts_with($pattern, self::HOST_PREFIX);
115 84
116
        return $uriHandler;
117 84
    }
118 41
119
    public function isCompiled(): bool
120
    {
121 75
        return $this->compiled !== null;
122 75
    }
123 75
124
    /**
125
     * Match given url against compiled template and return matches array or null if pattern does
126 75
     * not match.
127
     */
128
    public function match(UriInterface $uri, array $defaults): ?array
129
    {
130
        if (!$this->isCompiled()) {
131
            $this->compile();
132 12
        }
133
134 12
        $matches = [];
135 12
        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

135
        if (!\preg_match(/** @scrutinizer ignore-type */ $this->compiled, $this->fetchTarget($uri), $matches)) {
Loading history...
136
            return null;
137
        }
138 12
139 12
        $matches = \array_intersect_key(
140
            \array_filter($matches, static fn (string $value) => $value !== ''),
141 12
            $this->options
142
        );
143
144 12
        return \array_merge($this->options, $defaults, $matches);
145 11
    }
146 2
147
    /**
148
     * Generate Uri for a given parameters and default values.
149
     */
150
    public function uri(iterable $parameters = [], array $defaults = []): UriInterface
151 10
    {
152
        if (!$this->isCompiled()) {
153
            $this->compile();
154 10
        }
155
156 10
        $parameters = \array_merge(
157
            $this->options,
158
            $defaults,
159
            $this->fetchOptions($parameters, $query)
160
        );
161
162
        foreach ($this->constrains as $key => $_) {
163
            if (empty($parameters[$key])) {
164 12
                throw new UriHandlerException(\sprintf('Unable to generate Uri, parameter `%s` is missing', $key));
165
            }
166 12
        }
167
168 12
        //Uri without empty blocks (pretty stupid implementation)
169 12
        $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

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