Passed
Pull Request — master (#45)
by Sergei
02:21
created

HeaderValueHelper   A

Complexity

Total Complexity 40

Size/Duplication

Total Lines 285
Duplicated Lines 0 %

Test Coverage

Coverage 98.98%

Importance

Changes 3
Bugs 1 Features 0
Metric Value
wmc 40
eloc 93
c 3
b 1
f 0
dl 0
loc 285
ccs 97
cts 98
cp 0.9898
rs 9.2

4 Methods

Rating   Name   Duplication   Size   Complexity  
B getSortedValueAndParameters() 0 61 11
C getSortedAcceptTypes() 0 64 16
A getValueAndParameters() 0 21 4
B getParameters() 0 51 9

How to fix   Complexity   

Complex Class

Complex classes like HeaderValueHelper often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use HeaderValueHelper, and based on these observations, apply Extract Interface, too.

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