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

UriTemplate::expandString()   A

Complexity

Conditions 4
Paths 4

Size

Total Lines 12
Code Lines 6

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 7
CRAP Score 4

Importance

Changes 1
Bugs 0 Features 1
Metric Value
cc 4
eloc 6
c 1
b 0
f 1
nc 4
nop 3
dl 0
loc 12
ccs 7
cts 7
cp 1
crap 4
rs 10
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
     * @var UriInterface|null
71
     */
72
    private $uri;
73
74
    /**
75
     * @throws \TypeError if the template is not a Stringable object or a string
76
     */
77 162
    public function __construct($uriTemplate, array $defaultVariables = [])
78
    {
79 162
        if (!is_string($uriTemplate) && !method_exists($uriTemplate, '__toString')) {
80 2
            throw new \TypeError(sprintf('The template must be a string or a stringable object %s given', gettype($uriTemplate)));
81
        }
82
83 160
        $this->uriTemplate = (string) $uriTemplate;
84 160
        $this->defaultVariables = $defaultVariables;
85 160
        if (false === strpos($this->uriTemplate, '{')) {
86 2
            $this->uri = Uri::createFromString($this->uriTemplate);
87
        }
88 160
    }
89
90 2
    public function getUriTemplate(): string
91
    {
92 2
        return $this->uriTemplate;
93
    }
94
95 2
    public function getDefaultVariables(): array
96
    {
97 2
        return $this->defaultVariables;
98
    }
99
100
    /**
101
     * @throws SyntaxError if the variables contains nested array values
102
     */
103 156
    public function expand(array $variables = []): UriInterface
104
    {
105 156
        if (null !== $this->uri) {
106 2
            return $this->uri;
107
        }
108
109 154
        $this->variables = $variables + $this->defaultVariables;
110
111
        /** @var string $uri */
112 154
        $uri = preg_replace_callback(self::REGEXP_EXPAND_PLACEHOLDER, [$this, 'expandMatch'], $this->uriTemplate);
113
114 152
        return Uri::createFromString($uri);
115
    }
116
117
    /**
118
     * Process an expansion.
119
     *
120
     * @throws SyntaxError if the variables contains nested array values
121
     */
122 154
    private function expandMatch(array $matches): string
123
    {
124 154
        $parsed = $this->parseExpression($matches['placeholder']);
125 154
        $joiner = self::OPERATOR_HASH_LOOKUP[$parsed['operator']]['joiner'];
126 154
        $useQuery = self::OPERATOR_HASH_LOOKUP[$parsed['operator']]['query'];
127
128 154
        $parts = [];
129 154
        foreach ($parsed['values'] as $part) {
130 154
            $parts[] = $this->expandPart($part, $parsed['operator'], $joiner, $useQuery);
131
        }
132
133 152
        $matchExpanded = implode($joiner, array_filter($parts));
134 152
        $prefix = self::OPERATOR_HASH_LOOKUP[$parsed['operator']]['prefix'];
135 152
        if ('' !== $matchExpanded && '' !== $prefix) {
136 102
            return $prefix.$matchExpanded;
137
        }
138
139 60
        return $matchExpanded;
140
    }
141
142
    /**
143
     * Parse an expression into parts.
144
     */
145 154
    private function parseExpression(string $expression): array
146
    {
147 154
        $result = [];
148 154
        $result['operator'] = '';
149 154
        if (isset(self::OPERATOR_HASH_LOOKUP[$expression[0]])) {
150 134
            $result['operator'] = $expression[0];
151 134
            $expression = substr($expression, 1);
152
        }
153
154 154
        foreach (explode(',', $expression) as $value) {
155 154
            $value = trim($value);
156 154
            $varSpec = ['value' => $value, 'modifier' => ''];
157 154
            $colonPos = strpos($value, ':');
158 154
            if (false !== $colonPos) {
159 22
                $varSpec['value'] = substr($value, 0, $colonPos);
160 22
                $varSpec['modifier'] = ':';
161 22
                $varSpec['position'] = (int) substr($value, $colonPos + 1);
162 138
            } elseif ('*' === substr($value, -1)) {
163 50
                $varSpec['modifier'] = '*';
164 50
                $varSpec['value'] = substr($value, 0, -1);
165
            }
166
167 154
            $result['values'][] = $varSpec;
168
        }
169
170 154
        return $result;
171
    }
172
173 154
    private function expandPart(array $value, string $operator, string $joiner, bool $useQuery): ?string
174
    {
175 154
        if (!isset($this->variables[$value['value']])) {
176 6
            return null;
177
        }
178
179 150
        $expanded = '';
180 150
        $variable = $this->variables[$value['value']];
181 150
        $actualQuery = $useQuery;
182
183 150
        if (is_scalar($variable)) {
184 82
            $variable = (string) $variable;
185 82
            $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

185
            /** @scrutinizer ignore-call */ 
186
            $expanded = self::expandString($variable, $value, $operator);
Loading history...
186 82
        } elseif (is_array($variable)) {
187 82
            $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

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