Completed
Pull Request — master (#153)
by ignace nyamagana
02:22
created

UriTemplate::expandVariable()   B

Complexity

Conditions 7
Paths 13

Size

Total Lines 31
Code Lines 19

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 20
CRAP Score 7

Importance

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