Passed
Pull Request — master (#153)
by ignace nyamagana
02:05
created

UriTemplate::parseExpression()   A

Complexity

Conditions 5
Paths 8

Size

Total Lines 26
Code Lines 18

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 19
CRAP Score 5

Importance

Changes 1
Bugs 0 Features 1
Metric Value
cc 5
eloc 18
c 1
b 0
f 1
nc 8
nop 1
dl 0
loc 26
ccs 19
cts 19
cp 1
crap 5
rs 9.3554
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\UriInterface;
17
use League\Uri\Exceptions\SyntaxError;
18
use function array_filter;
19
use function explode;
20
use function gettype;
21
use function implode;
22
use function is_array;
23
use function is_string;
24
use function method_exists;
25
use function preg_replace_callback;
26
use function rawurlencode;
27
use function sprintf;
28
use function strpos;
29
use function substr;
30
use function trim;
31
32
/**
33
 * Expands URI templates.
34
 *
35
 * @link http://tools.ietf.org/html/rfc6570
36
 *
37
 * Based on GuzzleHttp\UriTemplate class which is removed from Guzzle7.
38
 */
39
final class UriTemplate
40
{
41
    private const REGEXP_EXPAND_PLACEHOLDER = '/\{(?<placeholder>[^\}]+)\}/';
42
43
    private const OPERATOR_HASH_LOOKUP = [
44
        ''  => ['prefix' => '',  'joiner' => ',', 'query' => false],
45
        '+' => ['prefix' => '',  'joiner' => ',', 'query' => false],
46
        '#' => ['prefix' => '#', 'joiner' => ',', 'query' => false],
47
        '.' => ['prefix' => '.', 'joiner' => '.', 'query' => false],
48
        '/' => ['prefix' => '/', 'joiner' => '/', 'query' => false],
49
        ';' => ['prefix' => ';', 'joiner' => ';', 'query' => true],
50
        '?' => ['prefix' => '?', 'joiner' => '&', 'query' => true],
51
        '&' => ['prefix' => '&', 'joiner' => '&', 'query' => true],
52
    ];
53
54
    /**
55
     * @var string
56
     */
57
    private $uriTemplate;
58
59
    /**
60
     * @var array
61
     */
62
    private $defaultVariables;
63
64
    /**
65
     * @var array Variables to use in the template expansion
66
     */
67
    private $variables;
68
69
    /**
70
     * @throws \TypeError if the template is not a Stringable object or a string
71
     */
72 154
    public function __construct($uriTemplate, array $defaultVariables = [])
73
    {
74 154
        if (!is_string($uriTemplate) && !method_exists($uriTemplate, '__toString')) {
75 2
            throw new \TypeError(sprintf('The template must be a string or a stringable object %s given', gettype($uriTemplate)));
76
        }
77
78 152
        $this->uriTemplate = (string) $uriTemplate;
79 152
        $this->defaultVariables = $defaultVariables;
80 152
    }
81
82
    /**
83
     * @throws SyntaxError if the variables contains nested array values
84
     */
85 152
    public function expand(array $variables = []): UriInterface
86
    {
87 152
        if (false === strpos($this->uriTemplate, '{')) {
88 2
            return Uri::createFromString($this->uriTemplate);
89
        }
90
91 150
        $this->variables = array_merge($this->defaultVariables, $variables);
92
93
        /** @var string $uri */
94 150
        $uri = preg_replace_callback(self::REGEXP_EXPAND_PLACEHOLDER, [$this, 'expandMatch'], $this->uriTemplate);
95
96 148
        return Uri::createFromString($uri);
97
    }
98
99
    /**
100
     * Process an expansion.
101
     *
102
     * @throws SyntaxError if the variables contains nested array values
103
     */
104 150
    private function expandMatch(array $matches): string
105
    {
106 150
        $parsed = $this->parseExpression($matches['placeholder']);
107 150
        $joiner = self::OPERATOR_HASH_LOOKUP[$parsed['operator']]['joiner'];
108 150
        $useQuery = self::OPERATOR_HASH_LOOKUP[$parsed['operator']]['query'];
109
110 150
        $parts = [];
111 150
        foreach ($parsed['values'] as $part) {
112 150
            $parts[] = $this->expandPart($part, $parsed['operator'], $joiner, $useQuery);
113
        }
114
115 148
        $matchExpanded = implode($joiner, array_filter($parts));
116 148
        $prefix = self::OPERATOR_HASH_LOOKUP[$parsed['operator']]['prefix'];
117 148
        if ('' !== $matchExpanded && '' !== $prefix) {
118 98
            return $prefix.$matchExpanded;
119
        }
120
121 56
        return $matchExpanded;
122
    }
123
124
    /**
125
     * Parse an expression into parts.
126
     */
127 150
    private function parseExpression(string $expression): array
128
    {
129 150
        $result = [];
130 150
        $result['operator'] = '';
131 150
        if (isset(self::OPERATOR_HASH_LOOKUP[$expression[0]])) {
132 130
            $result['operator'] = $expression[0];
133 130
            $expression = substr($expression, 1);
134
        }
135
136 150
        foreach (explode(',', $expression) as $value) {
137 150
            $value = trim($value);
138 150
            $varSpec = ['value' => $value, 'modifier' => ''];
139 150
            $colonPos = strpos($value, ':');
140 150
            if (false !== $colonPos) {
141 22
                $varSpec['value'] = substr($value, 0, $colonPos);
142 22
                $varSpec['modifier'] = ':';
143 22
                $varSpec['position'] = (int) substr($value, $colonPos + 1);
144 134
            } elseif ('*' === substr($value, -1)) {
145 46
                $varSpec['modifier'] = '*';
146 46
                $varSpec['value'] = substr($value, 0, -1);
147
            }
148
149 150
            $result['values'][] = $varSpec;
150
        }
151
152 150
        return $result;
153
    }
154
155 150
    private function expandPart(array $value, string $operator, string $joiner, bool $useQuery): ?string
156
    {
157 150
        if (!isset($this->variables[$value['value']])) {
158 6
            return null;
159
        }
160
161 146
        $expanded = '';
162 146
        $variable = $this->variables[$value['value']];
163 146
        $actualQuery = $useQuery;
164
165 146
        if (is_scalar($variable)) {
166 78
            $variable = (string) $variable;
167 78
            $expanded = self::expandString($variable, $value, $operator);
0 ignored issues
show
Bug Best Practice introduced by
The method League\Uri\UriTemplate::expandString() is not static, but was called statically. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

167
            /** @scrutinizer ignore-call */ 
168
            $expanded = self::expandString($variable, $value, $operator);
Loading history...
168 78
        } elseif (is_array($variable)) {
169 78
            $expanded = self::expandArray($variable, $value, $operator, $joiner, $actualQuery);
0 ignored issues
show
Bug Best Practice introduced by
The method League\Uri\UriTemplate::expandArray() is not static, but was called statically. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

169
            /** @scrutinizer ignore-call */ 
170
            $expanded = self::expandArray($variable, $value, $operator, $joiner, $actualQuery);
Loading history...
170
        }
171
172 146
        if (!$actualQuery) {
173 108
            return $expanded;
174
        }
175
176 42
        if ('&' !== $joiner && '' === $expanded) {
177 2
            return $value['value'];
178
        }
179
180 42
        return $value['value'].'='.$expanded;
181
    }
182
183 78
    private function expandString(string $variable, array $value, string $operator): string
184
    {
185 78
        if (':' === $value['modifier']) {
186 22
            $variable = substr($variable, 0, $value['position']);
187
        }
188
189 78
        $expanded = rawurlencode($variable);
190 78
        if ('+' === $operator || '#' === $operator) {
191 28
            return $this->decodeReserved($expanded);
192
        }
193
194 54
        return $expanded;
195
    }
196
197 78
    private function expandArray(array $variable, array $value, string $operator, string $joiner, bool &$useQuery): string
198
    {
199 78
        if ([] === $variable) {
200 4
            $useQuery = false;
201
202 4
            return '';
203
        }
204
205 74
        $isAssoc = $this->isAssoc($variable);
206 74
        $pairs = [];
207 74
        foreach ($variable as $key => $var) {
208 74
            if ($isAssoc) {
209 38
                if (is_array($var)) {
210 2
                    throw new SyntaxError(sprintf('The submitted value for `%s` can not be a nested array.', $key));
211
                }
212
213 36
                $key = rawurlencode((string) $key);
214
            }
215
216 72
            $var = rawurlencode((string) $var);
217 72
            if ('+' === $operator || '#' === $operator) {
218 16
                $var = $this->decodeReserved($var);
219
            }
220
221 72
            if ('*' === $value['modifier']) {
222 40
                if ($isAssoc) {
223 20
                    $var = $key.'='.$var;
224 20
                } elseif ($key > 0 && $useQuery) {
225 8
                    $var = $value['value'].'='.$var;
226
                }
227
            }
228
229 72
            $pairs[$key] = $var;
230
        }
231
232 72
        if ('*' === $value['modifier']) {
233 40
            if ($isAssoc) {
234
                // Don't prepend the value name when using the explode
235
                // modifier with an associative array.
236 20
                $useQuery = false;
237
            }
238
239 40
            return implode($joiner, $pairs);
240
        }
241
242 34
        if ($isAssoc) {
243
            // When an associative array is encountered and the
244
            // explode modifier is not set, then the result must be
245
            // a comma separated list of keys followed by their
246
            // respective values.
247 16
            foreach ($pairs as $offset => &$data) {
248 16
                $data = $offset.','.$data;
249
            }
250
251 16
            unset($data);
252
        }
253
254 34
        return implode(',', $pairs);
255
    }
256
257
    /**
258
     * Determines if an array is associative.
259
     *
260
     * This makes the assumption that input arrays are sequences or hashes.
261
     * This assumption is a tradeoff for accuracy in favor of speed, but it
262
     * should work in almost every case where input is supplied for a URI
263
     * template.
264
     */
265 74
    private function isAssoc(array $array): bool
266
    {
267 74
        return [] !== $array && 0 !== array_keys($array)[0];
268
    }
269
270
    /**
271
     * Removes percent encoding on reserved characters (used with + and #
272
     * modifiers).
273
     */
274 44
    private function decodeReserved(string $str): string
275
    {
276 44
        static $delimiters = [
277
            ':', '/', '?', '#', '[', ']', '@', '!', '$',
278
            '&', '\'', '(', ')', '*', '+', ',', ';', '=',
279
        ];
280
281 44
        static $delimiters_encoded = [
282
            '%3A', '%2F', '%3F', '%23', '%5B', '%5D', '%40', '%21', '%24',
283
            '%26', '%27', '%28', '%29', '%2A', '%2B', '%2C', '%3B', '%3D',
284
        ];
285
286 44
        return str_replace($delimiters_encoded, $delimiters, $str);
287
    }
288
}
289