Passed
Push — master ( c867a9...cb28d6 )
by ignace nyamagana
02:04
created

UriTemplate::withTemplate()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 12
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 8
CRAP Score 2

Importance

Changes 0
Metric Value
cc 2
eloc 7
nc 2
nop 1
dl 0
loc 12
ccs 8
cts 8
cp 1
crap 2
rs 10
c 0
b 0
f 0
1
<?php
2
3
/**
4
 * League.Uri (https://uri.thephpleague.com)
5
 *
6
 * (c) Ignace Nyamagana Butera <[email protected]>
7
 *
8
 * For the full copyright and license information, please view the LICENSE
9
 * file that was distributed with this source code.
10
 */
11
12
declare(strict_types=1);
13
14
namespace League\Uri;
15
16
use League\Uri\Contracts\UriException;
17
use League\Uri\Contracts\UriInterface;
18
use League\Uri\Exceptions\SyntaxError;
19
use League\Uri\Exceptions\TemplateCanNotBeExpanded;
20
use function array_filter;
21
use function array_keys;
22
use function explode;
23
use function gettype;
24
use function implode;
25
use function in_array;
26
use function is_array;
27
use function is_bool;
28
use function is_scalar;
29
use function is_string;
30
use function method_exists;
31
use function preg_match;
32
use function preg_match_all;
33
use function preg_replace;
34
use function preg_replace_callback;
35
use function rawurlencode;
36
use function sprintf;
37
use function strpos;
38
use function substr;
39
use const ARRAY_FILTER_USE_KEY;
40
use const PREG_SET_ORDER;
41
42
/**
43
 * Expands URI templates.
44
 *
45
 * @link http://tools.ietf.org/html/rfc6570
46
 *
47
 * Based on GuzzleHttp\UriTemplate class which is removed from Guzzle7.
48
 * @see https://github.com/guzzle/guzzle/blob/6.5/src/UriTemplate.php
49
 */
50
final class UriTemplate
51
{
52
    private const REGEXP_EXPRESSION = '/\{
53
        (?<expression>
54
            (?<operator>[\.\/;\?&\=,\!@\|\+#])?
55
            (?<variables>[^\}]*)
56
        )
57
    \}/x';
58
59
    private const REGEXP_VARSPEC = '/^
60
        (?<name>(?:[A-z0-9_\.]|%[0-9a-fA-F]{2})+)
61
        (?<modifier>\:(?<position>\d+)|\*)?
62
    $/x';
63
64
    private const RESERVED_OPERATOR = '=,!@|';
65
66
    private const OPERATOR_HASH_LOOKUP = [
67
        ''  => ['prefix' => '',  'joiner' => ',', 'query' => false],
68
        '+' => ['prefix' => '',  'joiner' => ',', 'query' => false],
69
        '#' => ['prefix' => '#', 'joiner' => ',', 'query' => false],
70
        '.' => ['prefix' => '.', 'joiner' => '.', 'query' => false],
71
        '/' => ['prefix' => '/', 'joiner' => '/', 'query' => false],
72
        ';' => ['prefix' => ';', 'joiner' => ';', 'query' => true],
73
        '?' => ['prefix' => '?', 'joiner' => '&', 'query' => true],
74
        '&' => ['prefix' => '&', 'joiner' => '&', 'query' => true],
75
    ];
76
77
    /**
78
     * @var string
79
     */
80
    private $template;
81
82
    /**
83
     * @var array<string,string|array>
84
     */
85
    private $defaultVariables;
86
87
    /**
88
     * @var string[]
89
     */
90
    private $variableNames;
91
92
    /**
93
     * @var array<string, array{
0 ignored issues
show
Documentation Bug introduced by
The doc comment array<string, array{ at position 6 could not be parsed: the token is null at position 6.
Loading history...
94
     *                    pattern:string,
95
     *                    operator:string,
96
     *                    variables:array<array{
97
     *                    name: string,
98
     *                    modifier: string,
99
     *                    position: string
100
     *                    }>,
101
     *                    joiner:string,
102
     *                    prefix:string,
103
     *                    query:bool
104
     *                    }>
105
     */
106
    private $expressions;
107
108
    /**
109
     * @var array{noExpression:UriInterface|null, noVariables:UriInterface|null}
110
     */
111
    private $cache;
112
113
    /**
114
     * @var array<string,string|array>
115
     */
116
    private $variables;
117
118
    /**
119
     * @param object|string $template a string or an object with the __toString method
120
     *
121
     * @throws \TypeError               if the template is not a string or an object with the __toString method
122
     * @throws TemplateCanNotBeExpanded if the template syntax is invalid
123
     */
124 6
    public function __construct($template, array $defaultVariables = [])
125
    {
126 6
        $this->template = $this->filterTemplate($template);
127
128 4
        $this->parseExpressions();
129
130 4
        $this->defaultVariables = $this->filterVariables($defaultVariables);
131 4
    }
132
133
    /**
134
     * {@inheritDoc}
135
     */
136 2
    public static function __set_state(array $properties): self
137
    {
138 2
        return new self($properties['template'], $properties['defaultVariables']);
139
    }
140
141
    /**
142
     * @param object|string $template a string or an object with the __toString method
143
     *
144
     * @throws \TypeError if the template is not a string or an object with the __toString method
145
     */
146 4
    private function filterTemplate($template): string
147
    {
148 4
        if (!is_string($template) && !method_exists($template, '__toString')) {
149 2
            throw new \TypeError(sprintf('The template must be a string or a stringable object %s given.', gettype($template)));
150
        }
151
152 2
        return (string) $template;
153
    }
154
155
    /**
156
     * Parses the template expressions.
157
     *
158
     * @throws SyntaxError if the template syntax is invalid
159
     */
160 70
    private function parseExpressions(): void
161
    {
162 70
        $this->cache = ['noExpression' => null, 'noVariables' => null];
163
        /** @var string $remainder */
164 70
        $remainder = preg_replace(self::REGEXP_EXPRESSION, '', $this->template);
165 70
        if (false !== strpos($remainder, '{') || false !== strpos($remainder, '}')) {
166 6
            throw new SyntaxError('The submitted template "'.$this->template.'" contains invalid expressions.');
167
        }
168
169 64
        preg_match_all(self::REGEXP_EXPRESSION, $this->template, $expressions, PREG_SET_ORDER);
170 64
        $this->expressions = [];
171 64
        $foundVariables = [];
172 64
        foreach ($expressions as $expression) {
173 62
            if (isset($this->expressions[$expression['expression']])) {
174 2
                continue;
175
            }
176
177
            /** @var array{expression:string, operator:string, variables:string, pattern:string} $expression */
178 62
            $expression = $expression + ['operator' => '', 'pattern' => '{'.$expression['expression'].'}'];
179 62
            [$parsedVariables, $foundVariables] = $this->parseVariableSpecification($expression, $foundVariables);
180 12
            $this->expressions[$expression['expression']] = ['variables' => $parsedVariables]
181 12
                + $expression
182 12
                + self::OPERATOR_HASH_LOOKUP[$expression['operator']];
183
        }
184
185 10
        $this->variableNames = array_keys($foundVariables);
186 10
    }
187
188
    /**
189
     * Parses a variable specification in conformance to RFC6570.
190
     *
191
     * @param array{expression:string, operator:string, variables:string, pattern:string} $expression
192
     * @param array<string,int>                                                           $foundVariables
193
     *
194
     * @throws SyntaxError if the expression does not conform to RFC6570
195
     *
196
     * @return array{0:array<array{name:string, modifier:string, position:string}>, 1:array<string,int>}
197
     */
198 60
    private function parseVariableSpecification(array $expression, array $foundVariables): array
199
    {
200 60
        $parsedVariableSpecification = [];
201 60
        if ('' !== $expression['operator'] && false !== strpos(self::RESERVED_OPERATOR, $expression['operator'])) {
202 6
            throw new SyntaxError('The operator used in the expression "'.$expression['pattern'].'" is reserved.');
203
        }
204
205 54
        foreach (explode(',', $expression['variables']) as $varSpec) {
206 54
            if ('' === $varSpec) {
207 2
                throw new SyntaxError('No variable specification was included in the expression "'.$expression['pattern'].'".');
208
            }
209
210 52
            if (1 !== preg_match(self::REGEXP_VARSPEC, $varSpec, $parsed)) {
211 46
                throw new SyntaxError('The variable specification "'.$varSpec.'" included in the expression "'.$expression['pattern'].'" is invalid.');
212
            }
213
214 14
            $parsed += ['modifier' => '', 'position' => ''];
215 14
            if ('' !== $parsed['position']) {
216 2
                $parsed['position'] = (int) $parsed['position'];
217 2
                $parsed['modifier'] = ':';
218
            }
219
220 14
            $foundVariables[$parsed['name']] = 1;
221 14
            $parsedVariableSpecification[] = $parsed;
222
        }
223
224
        /** @var array{0:array<array{name:string, modifier:string, position:string}>, 1:array<string,int>} $result */
225 10
        $result = [$parsedVariableSpecification, $foundVariables];
226
227 10
        return $result;
228
    }
229
230
    /**
231
     * Filter out the value whose key is not a valid variable name for the given template.
232
     */
233 8
    private function filterVariables(array $variables): array
234
    {
235
        $filter = function ($key): bool {
236 8
            return in_array($key, $this->variableNames, true);
237 8
        };
238
239 8
        return array_filter($variables, $filter, ARRAY_FILTER_USE_KEY);
240
    }
241
242
    /**
243
     * The template string.
244
     */
245 2
    public function getTemplate(): string
246
    {
247 2
        return $this->template;
248
    }
249
250
    /**
251
     * Returns the names of the variables in the template, in order.
252
     *
253
     * @return string[]
254
     */
255 8
    public function getVariableNames(): array
256
    {
257 8
        return $this->variableNames;
258
    }
259
260
    /**
261
     * Returns the default values used to expand the template.
262
     *
263
     * The returned list only contains variables whose name is part of the current template.
264
     *
265
     * @return array<string,string|array>
266
     */
267 2
    public function getDefaultVariables(): array
268
    {
269 2
        return $this->defaultVariables;
270
    }
271
272
    /**
273
     * Returns a new instance with the updated default variables.
274
     *
275
     * This method MUST retain the state of the current instance, and return
276
     * an instance that contains the modified default variables.
277
     *
278
     * If present, variables whose name is not part of the current template
279
     * possible variable names are removed.
280
     *
281
     */
282 2
    public function withDefaultVariables(array $defaultDefaultVariables): self
283
    {
284 2
        $defaultDefaultVariables = $this->filterVariables($defaultDefaultVariables);
285 2
        if ($defaultDefaultVariables === $this->defaultVariables) {
286 2
            return $this;
287
        }
288
289 2
        $clone = clone $this;
290 2
        $clone->defaultVariables = $defaultDefaultVariables;
291
292 2
        return $clone;
293
    }
294
295
    /**
296
     * @throws TemplateCanNotBeExpanded if the variable contains nested array values
297
     * @throws UriException             if the resulting expansion can not be converted to a UriInterface instance
298
     */
299 154
    public function expand(array $variables = []): UriInterface
300
    {
301 154
        if ([] === $this->expressions) {
302 2
            $this->cache['noExpression'] = $this->cache['noExpression'] ?? Uri::createFromString($this->template);
303
304 2
            return $this->cache['noExpression'];
305
        }
306
307 152
        $this->variables = $this->filterVariables($variables) + $this->defaultVariables;
308 152
        if ([] === $this->variables) {
309 2
            $this->cache['noVariables'] = $this->cache['noVariables'] ?? Uri::createFromString(
310 2
                preg_replace(self::REGEXP_EXPRESSION, '', $this->template)
311
            );
312
313 2
            return $this->cache['noVariables'];
314
        }
315
316
        /** @var string $uri */
317 150
        $uri = preg_replace_callback(self::REGEXP_EXPRESSION, [$this, 'expandExpression'], $this->template);
318
319 150
        return Uri::createFromString($uri);
320
    }
321
322
    /**
323
     * Expands a single expression.
324
     *
325
     * @param array{expression:string, operator: string, variables:string} $foundExpression
326
     *
327
     * @throws TemplateCanNotBeExpanded if the variables is an array and a ":" modifier needs to be applied
328
     * @throws TemplateCanNotBeExpanded if the variables contains nested array values
329
     */
330 146
    private function expandExpression(array $foundExpression): string
331
    {
332 146
        $expression = $this->expressions[$foundExpression['expression']];
333 146
        $joiner = $expression['joiner'];
334 146
        $useQuery = $expression['query'];
335
336 146
        $parts = [];
337
        /** @var array{name:string, modifier:string, position:string} $variable */
338 146
        foreach ($expression['variables'] as $variable) {
339 146
            $parts[] = $this->expandVariable($variable, $expression['operator'], $joiner, $useQuery);
340
        }
341
342
        $nullFilter = static function ($value): bool {
343 146
            return null !== $value && '' !== $value;
344 146
        };
345
346 146
        $expanded = implode($joiner, array_filter($parts, $nullFilter));
347 146
        $prefix = $expression['prefix'];
348 146
        if ('' !== $expanded && '' !== $prefix) {
349 96
            return $prefix.$expanded;
350
        }
351
352 54
        return $expanded;
353
    }
354
355
    /**
356
     * Expands an expression.
357
     *
358
     * @param array{name:string, modifier:string, position:string} $variable
359
     *
360
     * @throws TemplateCanNotBeExpanded if the variables is an array and a ":" modifier needs to be applied
361
     * @throws TemplateCanNotBeExpanded if the variables contains nested array values
362
     */
363 146
    private function expandVariable(array $variable, string $operator, string $joiner, bool $useQuery): string
364
    {
365 146
        $expanded = '';
366 146
        if (!isset($this->variables[$variable['name']])) {
367 4
            return $expanded;
368
        }
369
370 144
        $value = $this->normalizeValue($this->variables[$variable['name']]);
371 144
        $arguments = [$value, $variable, $operator];
372 144
        $method = 'expandString';
373 144
        $actualQuery = $useQuery;
374 144
        if (is_array($value)) {
375 74
            $arguments[] = $joiner;
376 74
            $arguments[] = $useQuery;
377 74
            $method = 'expandList';
378
        }
379
380 144
        $expanded = $this->$method(...$arguments);
381 144
        if (is_array($expanded)) {
382 74
            [$expanded, $actualQuery] = $expanded;
383
        }
384
385 144
        if (!$actualQuery) {
386 108
            return $expanded;
387
        }
388
389 38
        if ('&' !== $joiner && '' === $expanded) {
390 2
            return $variable['name'];
391
        }
392
393 38
        return $variable['name'].'='.$expanded;
394
    }
395
396
    /**
397
     * @param mixed $value the value to be expanded
398
     *
399
     * @throws \TypeError if the type is not supported
400
     *
401
     * @return string|array
402
     */
403 8
    private function normalizeValue($value)
404
    {
405 8
        if (is_array($value)) {
406 4
            return $value;
407
        }
408
409 8
        if (is_bool($value)) {
410 2
            return true === $value ? '1' : '0';
411
        }
412
413 6
        if (is_scalar($value) || method_exists($value, '__toString')) {
414 4
            return (string) $value;
415
        }
416
417 2
        throw new \TypeError(sprintf('The variables must be a scalar or a stringable object `%s` given', gettype($value)));
418
    }
419
420
    /**
421
     * Expands an expression using a string value.
422
     */
423 76
    private function expandString(string $value, array $variable, string $operator): string
424
    {
425 76
        if (':' === $variable['modifier']) {
426 22
            $value = substr($value, 0, $variable['position']);
427
        }
428
429 76
        $expanded = rawurlencode($value);
430 76
        if ('+' === $operator || '#' === $operator) {
431 26
            return $this->decodeReserved($expanded);
432
        }
433
434 52
        return $expanded;
435
    }
436
437
    /**
438
     * Expands an expression using a list of values.
439
     *
440
     * @throws TemplateCanNotBeExpanded if the variables is an array and a ":" modifier needs to be applied
441
     * @throws TemplateCanNotBeExpanded if the variables contains nested array values
442
     *
443
     * @return array{0:string, 1:bool}
444
     */
445 82
    private function expandList(array $value, array $variable, string $operator, string $joiner, bool $useQuery): array
446
    {
447 82
        if ([] === $value) {
448 4
            return ['', false];
449
        }
450
451 78
        $isAssoc = $this->isAssoc($value);
452 78
        $pairs = [];
453 78
        if (':' === $variable['modifier']) {
454 4
            throw TemplateCanNotBeExpanded::dueToUnableToProcessValueListWithPrefix($variable['name']);
455
        }
456
457
        /** @var string $key */
458 74
        foreach ($value as $key => $var) {
459 74
            if ($isAssoc) {
460 38
                if (is_array($var)) {
461 2
                    throw TemplateCanNotBeExpanded::dueToNestedListOfValue($key);
462
                }
463
464 36
                $key = rawurlencode((string) $key);
465
            }
466
467 72
            $var = rawurlencode((string) $var);
468 72
            if ('+' === $operator || '#' === $operator) {
469 16
                $var = $this->decodeReserved($var);
470
            }
471
472 72
            if ('*' === $variable['modifier']) {
473 40
                if ($isAssoc) {
474 20
                    $var = $key.'='.$var;
475 20
                } elseif ($key > 0 && $useQuery) {
476 8
                    $var = $variable['name'].'='.$var;
477
                }
478
            }
479
480 72
            $pairs[$key] = $var;
481
        }
482
483 72
        if ('*' === $variable['modifier']) {
484 40
            if ($isAssoc) {
485
                // Don't prepend the value name when using the explode
486
                // modifier with an associative array.
487 20
                $useQuery = false;
488
            }
489
490 40
            return [implode($joiner, $pairs), $useQuery];
491
        }
492
493 34
        if ($isAssoc) {
494
            // When an associative array is encountered and the
495
            // explode modifier is not set, then the result must be
496
            // a comma separated list of keys followed by their
497
            // respective values.
498 16
            foreach ($pairs as $offset => &$data) {
499 16
                $data = $offset.','.$data;
500
            }
501
502 16
            unset($data);
503
        }
504
505 34
        return [implode(',', $pairs), $useQuery];
506
    }
507
508
    /**
509
     * Determines if an array is associative.
510
     *
511
     * This makes the assumption that input arrays are sequences or hashes.
512
     * This assumption is a tradeoff for accuracy in favor of speed, but it
513
     * should work in almost every case where input is supplied for a URI
514
     * template.
515
     */
516 70
    private function isAssoc(array $array): bool
517
    {
518 70
        return [] !== $array && 0 !== array_keys($array)[0];
519
    }
520
521
    /**
522
     * Removes percent encoding on reserved characters (used with + and # modifiers).
523
     */
524 42
    private function decodeReserved(string $str): string
525
    {
526 42
        static $delimiters = [
527
            ':', '/', '?', '#', '[', ']', '@', '!', '$',
528
            '&', '\'', '(', ')', '*', '+', ',', ';', '=',
529
        ];
530
531 42
        static $delimitersEncoded = [
532
            '%3A', '%2F', '%3F', '%23', '%5B', '%5D', '%40', '%21', '%24',
533
            '%26', '%27', '%28', '%29', '%2A', '%2B', '%2C', '%3B', '%3D',
534
        ];
535
536 42
        return str_replace($delimitersEncoded, $delimiters, $str);
537
    }
538
}
539