Completed
Push — master ( afe1db...0d8909 )
by ignace nyamagana
01:58
created

UriTemplate::normalizeValue()   B

Complexity

Conditions 9
Paths 7

Size

Total Lines 24
Code Lines 12

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 13
CRAP Score 9

Importance

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