Passed
Pull Request — master (#45)
by Sergei
12:03
created

HeaderValueHelper   A

Complexity

Total Complexity 38

Size/Duplication

Total Lines 281
Duplicated Lines 0 %

Test Coverage

Coverage 98.96%

Importance

Changes 3
Bugs 1 Features 0
Metric Value
wmc 38
eloc 91
c 3
b 1
f 0
dl 0
loc 281
ccs 95
cts 96
cp 0.9896
rs 9.36

4 Methods

Rating   Name   Duplication   Size   Complexity  
B getSortedValueAndParameters() 0 57 9
C getSortedAcceptTypes() 0 64 16
A getValueAndParameters() 0 21 4
B getParameters() 0 51 9
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Yiisoft\Http;
6
7
use InvalidArgumentException;
8
9
use function array_shift;
10
use function asort;
11
use function count;
12
use function explode;
13
use function implode;
14
use function is_string;
15
use function mb_strtolower;
16
use function mb_strpos;
17
use function mb_substr;
18
use function preg_match;
19
use function preg_split;
20
use function preg_replace;
21
use function preg_replace_callback;
22
use function reset;
23
use function rtrim;
24
use function strtolower;
25
use function strpos;
26
use function substr;
27
use function trim;
28
use function usort;
29
30
/**
31
 * `HeaderValueHelper` parses the header value parameters.
32
 *
33
 * @psalm-type QFactorHeader = array{q: float}&non-empty-array<array-key, string>
34
 */
35
final class HeaderValueHelper
36
{
37
    /**
38
     * @link https://www.rfc-editor.org/rfc/rfc2616.html#section-2.2
39
     * token = 1*<any CHAR except CTLs or separators>
40
     */
41
    private const PATTERN_TOKEN = '(?:(?:[^()<>@,;:\\"\/[\\]?={} \t\x7f]|[\x00-\x1f])+)';
42
43
    /**
44
     * @link https://www.rfc-editor.org/rfc/rfc2616.html#section-3.6
45
     * attribute = token
46
     */
47
    private const PATTERN_ATTRIBUTE = self::PATTERN_TOKEN;
48
49
    /**
50
     * @link https://www.rfc-editor.org/rfc/rfc2616.html#section-2.2
51
     * quoted-string  = ( <"> *(qdtext | quoted-pair ) <"> )
52
     * qdtext         = <any TEXT except <">>
53
     * quoted-pair    = "\" CHAR
54
     */
55
    private const PATTERN_QUOTED_STRING = '(?:"(?:(?:\\\\.)+|[^\\"]+)*")';
56
57
    /**
58
     * @link https://www.rfc-editor.org/rfc/rfc2616.html#section-3.6
59
     * value = token | quoted-string
60
     */
61
    private const PATTERN_VALUE = '(?:' . self::PATTERN_QUOTED_STRING . '|' . self::PATTERN_TOKEN . ')';
62
63
    /**
64
     * Explodes a header value to value and parameters (eg. text/html;q=2;version=6)
65
     *
66
     * @link https://www.rfc-editor.org/rfc/rfc2616.html#section-3.6
67
     * transfer-extension = token *( ";" parameter )
68
     *
69
     * @param string $headerValue Header value.
70
     * @param bool $lowerCaseValue Whether should cast header value to lowercase.
71
     * @param bool $lowerCaseParameter Whether should cast header parameter name to lowercase.
72
     * @param bool $lowerCaseParameterValue Whether should cast header parameter value to lowercase.
73
     *
74
     * @return string[] First element is the value, and key-value are the parameters.
75
     */
76 31
    public static function getValueAndParameters(
77
        string $headerValue,
78
        bool $lowerCaseValue = true,
79
        bool $lowerCaseParameter = true,
80
        bool $lowerCaseParameterValue = true
81
    ): array {
82 31
        $headerValue = trim($headerValue);
83
84 31
        if ($headerValue === '') {
85 1
            return [];
86
        }
87
88 30
        $parts = explode(';', $headerValue, 2);
89 30
        $output = [$lowerCaseValue ? strtolower($parts[0]) : $parts[0]];
90
91 30
        if (count($parts) === 1) {
92 12
            return $output;
93
        }
94
        /** @psalm-var array{0:string,1:string} $parts */
95
96 27
        return $output + self::getParameters($parts[1], $lowerCaseParameter, $lowerCaseParameterValue);
97
    }
98
99
    /**
100
     * Explodes a header value parameters (eg. q=2;version=6)
101
     *
102
     * @link https://tools.ietf.org/html/rfc7230#section-3.2.6
103
     *
104
     * @param string $headerValueParameters Header value parameters.
105
     * @param bool $lowerCaseParameter Whether should cast header parameter name to lowercase.
106
     * @param bool $lowerCaseParameterValue Whether should cast header parameter value to lowercase.
107
     *
108
     * @return string[] Key-value are the parameters.
109
     *
110
     * @psalm-return array<string,string>
111
     */
112 60
    public static function getParameters(
113
        string $headerValueParameters,
114
        bool $lowerCaseParameter = true,
115
        bool $lowerCaseParameterValue = true
116
    ): array {
117 60
        $headerValueParameters = trim($headerValueParameters);
118
119 60
        if ($headerValueParameters === '') {
120
            return [];
121
        }
122
123 60
        if (rtrim($headerValueParameters, ';') !== $headerValueParameters) {
124 1
            throw new InvalidArgumentException('Cannot end with a semicolon.');
125
        }
126
127 59
        $output = [];
128
129
        do {
130
            /** @psalm-suppress InvalidArgument */
131 59
            $headerValueParameters = preg_replace_callback(
132 59
                '/^[ \t]*(?<parameter>' . self::PATTERN_ATTRIBUTE . ')[ \t]*=[ \t]*(?<value>' . self::PATTERN_VALUE . ')[ \t]*(?:;|$)/u',
133 59
                static function (array $matches) use (&$output, $lowerCaseParameter, $lowerCaseParameterValue) {
134 50
                    $value = $matches['value'];
135
136 50
                    if (mb_strpos($matches['value'], '"') === 0) {
137
                        // unescape + remove first and last quote
138 13
                        $value = preg_replace('/\\\\(.)/u', '$1', mb_substr($value, 1, -1));
139
                    }
140
141 50
                    $key = $lowerCaseParameter ? mb_strtolower($matches['parameter']) : $matches['parameter'];
142
143 50
                    if (isset($output[$key])) {
144
                        // The first is the winner.
145 2
                        return;
146
                    }
147
148
                    /** @psalm-suppress MixedArrayAssignment False-positive error */
149 50
                    $output[$key] = $lowerCaseParameterValue ? mb_strtolower($value) : $value;
150 59
                },
151 59
                $headerValueParameters,
152 59
                1,
153 59
                $count
154 59
            );
155
156 59
            if ($count !== 1) {
157 11
                throw new InvalidArgumentException('Invalid input: ' . $headerValueParameters);
158
            }
159 50
        } while ($headerValueParameters !== '');
160
        /** @var array<string,string> $output */
161
162 48
        return $output;
163
    }
164
165
    /**
166
     * Returns a header value as "q" factor sorted list.
167
     *
168
     * @link https://developer.mozilla.org/en-US/docs/Glossary/Quality_values
169
     * @link https://www.ietf.org/rfc/rfc2045.html#section-2
170
     * @see getValueAndParameters
171
     *
172
     * @param string|string[] $values Header value as a comma-separated string or already exploded string array.
173
     * @param bool $lowerCaseValue Whether should cast header value to lowercase.
174
     * @param bool $lowerCaseParameter Whether should cast header parameter name to lowercase.
175
     * @param bool $lowerCaseParameterValue Whether should cast header parameter value to lowercase.
176
     *
177
     * @return array[] The q factor sorted list.
178
     *
179
     * @psalm-return list<QFactorHeader>
180
     * @psalm-suppress MoreSpecificReturnType, LessSpecificReturnStatement Need for Psalm 4.30
181
     */
182 27
    public static function getSortedValueAndParameters(
183
        $values,
184
        bool $lowerCaseValue = true,
185
        bool $lowerCaseParameter = true,
186
        bool $lowerCaseParameterValue = true
187
    ): array {
188 27
        $values = (array) $values;
189
190 27
        $list = [];
191 27
        foreach ($values as $headerValue) {
192
            /** @psalm-suppress DocblockTypeContradiction Don't trust to annotations. */
193 25
            if (!is_string($headerValue)) {
194 2
                throw new InvalidArgumentException('Values must be string or array of strings.');
195
            }
196
197
            /** @psalm-suppress InvalidOperand Presume that `preg_split` never returns false here. */
198 23
            $list = [...$list, ...preg_split('/\s*,\s*/', trim($headerValue), -1, PREG_SPLIT_NO_EMPTY)];
199
        }
200
201
        /** @var string[] $list */
202
203 25
        if (count($list) === 0) {
204 4
            return [];
205
        }
206
207 21
        $output = [];
208
209 21
        foreach ($list as $value) {
210 21
            $parse = self::getValueAndParameters(
211 21
                $value,
212 21
                $lowerCaseValue,
213 21
                $lowerCaseParameter,
214 21
                $lowerCaseParameterValue
215 21
            );
216
            // case-insensitive "q" parameter
217 21
            $q = $parse['q'] ?? $parse['Q'] ?? 1.0;
218
219
            // min 0.000 max 1.000, max 3 digits, without digits allowed
220 21
            if (is_string($q) && preg_match('/^(?:0(?:\.\d{1,3})?|1(?:\.0{1,3})?)$/', $q) === 0) {
221 4
                throw new InvalidArgumentException('Invalid q factor.');
222
            }
223
224 17
            $parse['q'] = (float) $q;
225 17
            unset($parse['Q']);
226 17
            $output[] = $parse;
227
        }
228
229 17
        usort($output, static function (array $a, array $b) {
230 17
            $a = $a['q'];
231 17
            $b = $b['q'];
232 17
            if ($a === $b) {
233 9
                return 0;
234
            }
235 9
            return $a > $b ? -1 : 1;
236 17
        });
237
238 17
        return $output;
239
    }
240
241
    /**
242
     * Returns a list of sorted content types from the accept header values.
243
     *
244
     * @param string|string[] $values Header value as a comma-separated string or already exploded string array.
245
     *
246
     * @return string[] Sorted accept types. Note: According to RFC 7231, special parameters (except the q factor)
247
     * are added to the type, which are always appended by a semicolon and sorted by string.
248
     *
249
     * @link https://tools.ietf.org/html/rfc7231#section-5.3.2
250
     * @link https://www.ietf.org/rfc/rfc2045.html#section-2
251
     */
252 14
    public static function getSortedAcceptTypes($values): array
253
    {
254 14
        $output = self::getSortedValueAndParameters($values);
255
256 14
        usort($output, static function (array $a, array $b) {
257
            /**
258
             * @psalm-var QFactorHeader $a
259
             * @psalm-var QFactorHeader $b
260
             */
261
262 12
            if ($a['q'] !== $b['q']) {
263
                // The higher q value wins
264 5
                return $a['q'] > $b['q'] ? -1 : 1;
265
            }
266
267
            /** @var string $typeA */
268 8
            $typeA = reset($a);
269
270
            /** @var string $typeB */
271 8
            $typeB = reset($b);
272
273 8
            if (strpos($typeA, '*') === false && strpos($typeB, '*') === false) {
274 8
                $countA = count($a);
275 8
                $countB = count($b);
276 8
                if ($countA === $countB) {
277
                    // They are equivalent for the same parameter number
278 6
                    return 0;
279
                }
280
                // No wildcard character, higher parameter number wins
281 2
                return $countA > $countB ? -1 : 1;
282
            }
283
284 1
            $endWildcardA = substr($typeA, -1, 1) === '*';
285 1
            $endWildcardB = substr($typeB, -1, 1) === '*';
286
287 1
            if (($endWildcardA && !$endWildcardB) || (!$endWildcardA && $endWildcardB)) {
288
                // The wildcard ends is the loser.
289 1
                return $endWildcardA ? 1 : -1;
290
            }
291
292
            // The wildcard starts is the loser.
293 1
            return strpos($typeA, '*') === 0 ? 1 : -1;
294 14
        });
295
296 14
        foreach ($output as $key => $value) {
297 12
            $type = array_shift($value);
298 12
            unset($value['q']);
299
300 12
            if (count($value) === 0) {
301 11
                $output[$key] = $type;
302 11
                continue;
303
            }
304
305 4
            foreach ($value as $k => $v) {
306 4
                $value[$k] = $k . '=' . $v;
307
            }
308
309
            // Parameters are sorted for easier use of parameter variations.
310 4
            asort($value, SORT_STRING);
311 4
            $output[$key] = $type . ';' . implode(';', $value);
312
        }
313
        /** @var string[] $output */
314
315 14
        return $output;
316
    }
317
}
318