Passed
Push — main ( d79e5f...42d171 )
by Dimitri
12:00 queued 06:40
created

Negotiator::getBestMatch()   C

Complexity

Conditions 13
Paths 13

Size

Total Lines 33
Code Lines 14

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 182

Importance

Changes 0
Metric Value
cc 13
eloc 14
nc 13
nop 4
dl 0
loc 33
ccs 0
cts 7
cp 0
crap 182
rs 6.6166
c 0
b 0
f 0

How to fix   Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

1
<?php
2
3
/**
4
 * This file is part of Blitz PHP framework.
5
 *
6
 * (c) 2022 Dimitri Sitchet Tomkeu <[email protected]>
7
 *
8
 * For the full copyright and license information, please view
9
 * the LICENSE file that was distributed with this source code.
10
 */
11
12
namespace BlitzPHP\Http;
13
14
use BlitzPHP\Exceptions\HttpException;
15
16
class Negotiator
17
{
18
    /**
19
     * Request
20
     *
21
     * @var ServerRequest
22
     */
23
    protected $request;
24
25
    /**
26
     * Constructor
27
     */
28
    public function __construct(?ServerRequest $request = null)
29
    {
30
        if (null !== $request) {
31
            $this->request = $request;
32
        }
33
    }
34
35
    /**
36
     * Stores the request instance to grab the headers from.
37
     */
38
    public function setRequest(ServerRequest $request): self
39
    {
40
        $this->request = $request;
41
42
        return $this;
43
    }
44
45
    /**
46
     * Determines the best content-type to use based on the $supported
47
     * types the application says it supports, and the types requested
48
     * by the client.
49
     *
50
     * If no match is found, the first, highest-ranking client requested
51
     * type is returned.
52
     *
53
     * @param bool $strictMatch If TRUE, will return an empty string when no match found.
54
     *                          If FALSE, will return the first supported element.
55
     */
56
    public function media(array $supported, bool $strictMatch = false): string
57
    {
58
        return $this->getBestMatch($supported, $this->request->getHeaderLine('accept'), true, $strictMatch);
59
    }
60
61
    /**
62
     * Determines the best charset to use based on the $supported
63
     * types the application says it supports, and the types requested
64
     * by the client.
65
     *
66
     * If no match is found, the first, highest-ranking client requested
67
     * type is returned.
68
     */
69
    public function charset(array $supported): string
70
    {
71
        $match = $this->getBestMatch($supported, $this->request->getHeaderLine('accept-charset'), false, true);
72
73
        // If no charset is shown as a match, ignore the directive
74
        // as allowed by the RFC, and tell it a default value.
75
        if ($match === '' || $match === '0') {
76
            return 'utf-8';
77
        }
78
79
        return $match;
80
    }
81
82
    /**
83
     * Determines the best encoding type to use based on the $supported
84
     * types the application says it supports, and the types requested
85
     * by the client.
86
     *
87
     * If no match is found, the first, highest-ranking client requested
88
     * type is returned.
89
     */
90
    public function encoding(array $supported = []): string
91
    {
92
        $supported[] = 'identity';
93
94
        return $this->getBestMatch($supported, $this->request->getHeaderLine('accept-encoding'));
95
    }
96
97
    /**
98
     * Determines the best language to use based on the $supported
99
     * types the application says it supports, and the types requested
100
     * by the client.
101
     *
102
     * If no match is found, the first, highest-ranking client requested
103
     * type is returned.
104
     */
105
    public function language(array $supported): string
106
    {
107
        return $this->getBestMatch($supported, $this->request->getHeaderLine('accept-language'));
108
    }
109
110
    // --------------------------------------------------------------------
111
    // Utility Methods
112
    // --------------------------------------------------------------------
113
114
    /**
115
     * Does the grunt work of comparing any of the app-supported values
116
     * against a given Accept* header string.
117
     *
118
     * Portions of this code base on Aura.Accept library.
119
     *
120
     * @param array  $supported    App-supported values
121
     * @param string $header       header string
122
     * @param bool   $enforceTypes If TRUE, will compare media types and sub-types.
123
     * @param bool   $strictMatch  If TRUE, will return empty string on no match.
124
     *                             If FALSE, will return the first supported element.
125
     *
126
     * @return string Best match
127
     */
128
    protected function getBestMatch(array $supported, ?string $header = null, bool $enforceTypes = false, bool $strictMatch = false): string
129
    {
130
        if ($supported === []) {
131
            throw new HttpException('You must provide an array of supported values to all Negotiations.');
132
        }
133
134
        if ($header === null || $header === '' || $header === '0') {
135
            return $strictMatch ? '' : $supported[0];
136
        }
137
138
        $acceptable = $this->parseHeader($header);
139
140
        foreach ($acceptable as $accept) {
141
            // if acceptable quality is zero, skip it.
142
            if ($accept['q'] === 0.0) {
143
                continue;
144
            }
145
146
            // if acceptable value is "anything", return the first available
147
            if ($accept['value'] === '*' || $accept['value'] === '*/*') {
148
                return $supported[0];
149
            }
150
151
            // If an acceptable value is supported, return it
152
            foreach ($supported as $available) {
153
                if ($this->match($accept, $available, $enforceTypes)) {
154
                    return $available;
155
                }
156
            }
157
        }
158
159
        // No matches? Return the first supported element.
160
        return $strictMatch ? '' : $supported[0];
161
    }
162
163
    /**
164
     * Parses an Accept* header into it's multiple values.
165
     *
166
     * This is based on code from Aura.Accept library.
167
     */
168
    public function parseHeader(string $header): array
169
    {
170
        $results    = [];
171
        $acceptable = explode(',', $header);
172
173
        foreach ($acceptable as $value) {
174
            $pairs = explode(';', $value);
175
176
            $value = $pairs[0];
177
178
            unset($pairs[0]);
179
180
            $parameters = [];
181
182
            foreach ($pairs as $pair) {
183
                $param = [];
184
                preg_match(
185
                    '/^(?P<name>.+?)=(?P<quoted>"|\')?(?P<value>.*?)(?:\k<quoted>)?$/',
186
                    $pair,
187
                    $param
188
                );
189
                $parameters[trim($param['name'])] = trim($param['value']);
190
            }
191
192
            $quality = 1.0;
193
194
            if (array_key_exists('q', $parameters)) {
195
                $quality = $parameters['q'];
196
                unset($parameters['q']);
197
            }
198
199
            $results[] = [
200
                'value'  => trim($value),
201
                'q'      => (float) $quality,
202
                'params' => $parameters,
203
            ];
204
        }
205
206
        // Sort to get the highest results first
207
        usort($results, static function ($a, $b): int {
208
            if ($a['q'] === $b['q']) {
209
                $a_ast = substr_count($a['value'], '*');
210
                $b_ast = substr_count($b['value'], '*');
211
212
                // '*/*' has lower precedence than 'text/*',
213
                // and 'text/*' has lower priority than 'text/plain'
214
                //
215
                // This seems backwards, but needs to be that way
216
                // due to the way PHP7 handles ordering or array
217
                // elements created by reference.
218
                if ($a_ast > $b_ast) {
219
                    return 1;
220
                }
221
222
                // If the counts are the same, but one element
223
                // has more params than another, it has higher precedence.
224
                //
225
                // This seems backwards, but needs to be that way
226
                // due to the way PHP7 handles ordering or array
227
                // elements created by reference.
228
                if ($a_ast === $b_ast) {
229
                    return count($b['params']) - count($a['params']);
230
                }
231
232
                return 0;
233
            }
234
235
            // Still here? Higher q values have precedence.
236
            return ($a['q'] > $b['q']) ? -1 : 1;
237
        });
238
239
        return $results;
240
    }
241
242
    /**
243
     * Match-maker
244
     */
245
    protected function match(array $acceptable, string $supported, bool $enforceTypes = false): bool
246
    {
247
        $supported = $this->parseHeader($supported);
248
        if (count($supported) === 1) {
249
            $supported = $supported[0];
250
        }
251
252
        // Is it an exact match?
253
        if ($acceptable['value'] === $supported['value']) {
254
            return $this->matchParameters($acceptable, $supported);
255
        }
256
257
        // Do we need to compare types/sub-types? Only used
258
        // by negotiateMedia().
259
        if ($enforceTypes) {
260
            return $this->matchTypes($acceptable, $supported);
261
        }
262
263
        return false;
264
    }
265
266
    /**
267
     * Checks two Accept values with matching 'values' to see if their
268
     * 'params' are the same.
269
     */
270
    protected function matchParameters(array $acceptable, array $supported): bool
271
    {
272
        if (count($acceptable['params']) !== count($supported['params'])) {
273
            return false;
274
        }
275
276
        foreach ($supported['params'] as $label => $value) {
277
            if (! isset($acceptable['params'][$label]) || $acceptable['params'][$label] !== $value) {
278
                return false;
279
            }
280
        }
281
282
        return true;
283
    }
284
285
    /**
286
     * Compares the types/subtypes of an acceptable Media type and
287
     * the supported string.
288
     */
289
    public function matchTypes(array $acceptable, array $supported): bool
290
    {
291
        [$aType, $aSubType] = explode('/', $acceptable['value']);
292
        [$sType, $sSubType] = explode('/', $supported['value']);
293
294
        // If the types don't match, we're done.
295
        if ($aType !== $sType) {
296
            return false;
297
        }
298
299
        // If there's an asterisk, we're cool
300
        if ($aSubType === '*') {
301
            return true;
302
        }
303
304
        // Otherwise, subtypes must match also.
305
        return $aSubType === $sSubType;
306
    }
307
}
308