Passed
Push — master ( 035a69...ef3bdc )
by Kirill
03:22
created

UriHandler::match()   A

Complexity

Conditions 3
Paths 4

Size

Total Lines 14
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 7
c 1
b 0
f 0
dl 0
loc 14
rs 10
cc 3
nc 4
nop 2
1
<?php
2
3
/**
4
 * Spiral Framework.
5
 *
6
 * @license   MIT
7
 * @author    Anton Titov (Wolfy-J)
8
 */
9
10
declare(strict_types=1);
11
12
namespace Spiral\Router;
13
14
use Cocur\Slugify\Slugify;
15
use Cocur\Slugify\SlugifyInterface;
16
use Psr\Http\Message\UriFactoryInterface;
17
use Psr\Http\Message\UriInterface;
18
use Spiral\Router\Exception\ConstrainException;
19
use Spiral\Router\Exception\UriHandlerException;
20
21
/**
22
 * UriMatcher provides ability to match and generate uris based on given parameters.
23
 */
24
final class UriHandler
25
{
26
    private const HOST_PREFIX      = '//';
27
    private const DEFAULT_SEGMENT  = '[^\/]+';
28
    private const PATTERN_REPLACES = ['/' => '\\/', '[' => '(?:', ']' => ')?', '.' => '\.'];
29
    private const SEGMENT_REPLACES = ['/' => '\\/', '.' => '\.'];
30
    private const SEGMENT_TYPES    = [
31
        'int'     => '\d+',
32
        'integer' => '\d+',
33
        '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}'
34
    ];
35
    private const URI_FIXERS       = [
36
        '[]'  => '',
37
        '[/]' => '',
38
        '['   => '',
39
        ']'   => '',
40
        '://' => '://',
41
        '//'  => '/'
42
    ];
43
44
    /** @var UriFactoryInterface */
45
    private $uriFactory;
46
47
    /** @var string */
48
    private $pattern;
49
50
    /** @var SlugifyInterface @internal */
51
    private $slugify;
52
53
    /** @var array */
54
    private $constrains = [];
55
56
    /** @var array */
57
    private $defaults = [];
58
59
    /** @var bool */
60
    private $matchHost = false;
61
62
    /** @var string */
63
    private $prefix = '';
64
65
    /** @var string|null */
66
    private $compiled;
67
68
    /** @var string|null */
69
    private $template;
70
71
    /** @var array */
72
    private $options = [];
73
74
    /**
75
     * @param UriFactoryInterface $uriFactory
76
     * @param SlugifyInterface    $slugify
77
     */
78
    public function __construct(
79
        UriFactoryInterface $uriFactory,
80
        SlugifyInterface $slugify = null
81
    ) {
82
        $this->uriFactory = $uriFactory;
83
        $this->slugify = $slugify ?? new Slugify();
84
    }
85
86
    /**
87
     * @return string
88
     */
89
    public function getPattern(): string
90
    {
91
        return $this->pattern;
92
    }
93
94
    /**
95
     * @param array $constrains
96
     * @param array $defaults
97
     * @return UriHandler
98
     */
99
    public function withConstrains(array $constrains, array $defaults = []): self
100
    {
101
        $uriHandler = clone $this;
102
        $uriHandler->compiled = null;
103
        $uriHandler->constrains = $constrains;
104
        $uriHandler->defaults = $defaults;
105
106
        return $uriHandler;
107
    }
108
109
    /**
110
     * @return array
111
     */
112
    public function getConstrains(): array
113
    {
114
        return $this->constrains;
115
    }
116
117
    /**
118
     * @param string $prefix
119
     * @return UriHandler
120
     */
121
    public function withPrefix($prefix): self
122
    {
123
        $uriHandler = clone $this;
124
        $uriHandler->compiled = null;
125
        $uriHandler->prefix = $prefix;
126
127
        return $uriHandler;
128
    }
129
130
    /**
131
     * @return string
132
     */
133
    public function getPrefix(): string
134
    {
135
        return $this->prefix;
136
    }
137
138
    /**
139
     * @param string $pattern
140
     * @return UriHandler
141
     */
142
    public function withPattern(string $pattern): self
143
    {
144
        $uriHandler = clone $this;
145
        $uriHandler->pattern = $pattern;
146
        $uriHandler->compiled = null;
147
        $uriHandler->matchHost = strpos($pattern, self::HOST_PREFIX) === 0;
148
149
        return $uriHandler;
150
    }
151
152
    /**
153
     * @return bool
154
     */
155
    public function isCompiled(): bool
156
    {
157
        return $this->compiled !== null;
158
    }
159
160
    /**
161
     * Match given url against compiled template and return matches array or null if pattern does
162
     * not match.
163
     *
164
     * @param UriInterface $uri
165
     * @param array        $defaults
166
     * @return array|null
167
     */
168
    public function match(UriInterface $uri, array $defaults): ?array
169
    {
170
        if (!$this->isCompiled()) {
171
            $this->compile();
172
        }
173
174
        $matches = [];
175
        if (!preg_match($this->compiled, $this->fetchTarget($uri), $matches)) {
176
            return null;
177
        }
178
179
        $matches = array_intersect_key($matches, $this->options);
180
181
        return array_merge($this->options, $defaults, $matches);
182
    }
183
184
    /**
185
     * Generate Uri for a given parameters and default values.
186
     *
187
     * @param array|\Traversable $parameters
188
     * @param array              $defaults
189
     * @return UriInterface
190
     */
191
    public function uri($parameters = [], array $defaults = []): UriInterface
192
    {
193
        if (!$this->isCompiled()) {
194
            $this->compile();
195
        }
196
197
        $parameters = array_merge(
198
            $this->options,
199
            $defaults,
200
            $this->fetchOptions($parameters, $query)
201
        );
202
203
        foreach ($this->constrains as $key => $values) {
204
            if (empty($parameters[$key])) {
205
                throw new UriHandlerException("Unable to generate Uri, parameter `{$key}` is missing");
206
            }
207
        }
208
209
        //Uri without empty blocks (pretty stupid implementation)
210
        $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 prefix
213
        $uri = $this->uriFactory->createUri(($this->matchHost ? '' : $this->prefix) . trim($path, '/'));
214
215
        return empty($query) ? $uri : $uri->withQuery(http_build_query($query));
216
    }
217
218
    /**
219
     * Fetch uri segments and query parameters.
220
     *
221
     * @param \Traversable|array $parameters
222
     * @param array|null         $query Query parameters.
223
     * @return array
224
     */
225
    private function fetchOptions($parameters, &$query): array
226
    {
227
        $allowed = array_keys($this->options);
228
229
        $result = [];
230
        foreach ($parameters as $key => $parameter) {
231
            if (is_numeric($key) && isset($allowed[$key])) {
232
                // this segment fetched keys from given parameters either by name or by position
233
                $key = $allowed[$key];
234
            } elseif (!array_key_exists($key, $this->options) && is_array($parameters)) {
235
                // all additional parameters given in array form can be glued to query string
236
                $query[$key] = $parameter;
237
                continue;
238
            }
239
240
            //String must be normalized here
241
            if (is_string($parameter) && !preg_match('/^[a-z\-_0-9]+$/i', $parameter)) {
242
                $result[$key] = $this->slugify->slugify($parameter);
243
                continue;
244
            }
245
246
            $result[$key] = (string)$parameter;
247
        }
248
249
        return $result;
250
    }
251
252
    /**
253
     * Part of uri path which is being matched.
254
     *
255
     * @param UriInterface $uri
256
     * @return string
257
     */
258
    private function fetchTarget(UriInterface $uri): string
259
    {
260
        $path = $uri->getPath();
261
262
        if (empty($path) || $path[0] !== '/') {
263
            $path = '/' . $path;
264
        }
265
266
        if ($this->matchHost) {
267
            $uriString = $uri->getHost() . $path;
268
        } else {
269
            $uriString = substr($path, strlen($this->prefix));
270
            if ($uriString === false) {
271
                $uriString = '';
272
            }
273
        }
274
275
        return trim($uriString, '/');
276
    }
277
278
    /**
279
     * Compile route matcher into regexp.
280
     */
281
    private function compile(): void
282
    {
283
        if ($this->pattern === null) {
284
            throw new UriHandlerException('Unable to compile UriHandler, pattern is not set');
285
        }
286
287
        $options = $replaces = [];
288
        $pattern = rtrim(ltrim($this->pattern, ':/'), '/');
289
290
        // correct [/ first occurrence]
291
        if (strpos($pattern, '[/') === 0) {
292
            $pattern = '[' . substr($pattern, 2);
293
        }
294
295
        if (preg_match_all('/<(\w+):?(.*?)?>/', $pattern, $matches)) {
296
            $variables = array_combine($matches[1], $matches[2]);
297
298
            foreach ($variables as $key => $segment) {
299
                $segment = $this->prepareSegment($key, $segment);
300
                $replaces["<$key>"] = "(?P<$key>$segment)";
301
                $options[] = $key;
302
            }
303
        }
304
305
        $template = preg_replace('/<(\w+):?.*?>/', '<\1>', $pattern);
306
        $options = array_fill_keys($options, null);
307
308
        foreach ($this->constrains as $key => $value) {
309
            if ($value instanceof Autofill) {
310
                // only forces value replacement, not required to be presented as parameter
311
                continue;
312
            }
313
314
            if (!array_key_exists($key, $options) && !isset($this->defaults[$key])) {
315
                throw new ConstrainException(
316
                    sprintf(
317
                        'Route `%s` does not define routing parameter `<%s>`.',
318
                        $this->pattern,
319
                        $key
320
                    )
321
                );
322
            }
323
        }
324
325
        $this->compiled = '/^' . strtr($template, $replaces + self::PATTERN_REPLACES) . '$/iu';
326
        $this->template = stripslashes(str_replace('?', '', $template));
327
        $this->options = $options;
328
    }
329
330
    /**
331
     * Interpolate string with given values.
332
     *
333
     * @param string $string
334
     * @param array  $values
335
     * @return string
336
     */
337
    private function interpolate(string $string, array $values): string
338
    {
339
        $replaces = [];
340
        foreach ($values as $key => $value) {
341
            $value = (is_array($value) || $value instanceof \Closure) ? '' : $value;
342
            $replaces["<{$key}>"] = is_object($value) ? (string)$value : $value;
343
        }
344
345
        return strtr($string, $replaces + self::URI_FIXERS);
346
    }
347
348
    /**
349
     * Prepares segment pattern with given constrains.
350
     *
351
     * @param string $name
352
     * @param string $segment
353
     * @return string
354
     */
355
    private function prepareSegment(string $name, string $segment): string
356
    {
357
        if ($segment !== '') {
358
            return self::SEGMENT_TYPES[$segment] ?? $segment;
359
        }
360
361
        if (!isset($this->constrains[$name])) {
362
            return self::DEFAULT_SEGMENT;
363
        }
364
365
        if (is_array($this->constrains[$name])) {
366
            $values = array_map([$this, 'filterSegment'], $this->constrains[$name]);
367
368
            return implode('|', $values);
369
        }
370
371
        return $this->filterSegment((string)$this->constrains[$name]);
372
    }
373
374
    /**
375
     * @param string $segment
376
     * @return string
377
     */
378
    private function filterSegment(string $segment): string
379
    {
380
        return strtr($segment, self::SEGMENT_REPLACES);
381
    }
382
}
383