Passed
Pull Request — master (#831)
by Maxim
07:40
created

UriHandler::withBasePath()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 11
Code Lines 6

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 6
CRAP Score 2.0116

Importance

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

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

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