Completed
Push — master ( 3dd273...357556 )
by ignace nyamagana
02:06
created

UriTemplate::expandVariable()   B

Complexity

Conditions 7
Paths 13

Size

Total Lines 30
Code Lines 18

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 19
CRAP Score 7

Importance

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