Passed
Push — master ( 4ac306...d2aa89 )
by Mihail
05:30
created

AcceptHeader::matches()   B

Complexity

Conditions 10
Paths 16

Size

Total Lines 49
Code Lines 24

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 25
CRAP Score 10

Importance

Changes 2
Bugs 1 Features 0
Metric Value
cc 10
eloc 24
c 2
b 1
f 0
nc 16
nop 2
dl 0
loc 49
ccs 25
cts 25
cp 1
crap 10
rs 7.6666

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 the Koded package.
5
 *
6
 * (c) Mihail Binev <[email protected]>
7
 *
8
 * Please view the LICENSE distributed with this source code
9
 * for the full copyright and license information.
10
 *
11
 */
12
13
namespace Koded\Http;
14
15
/**
16
 * Content negotiation module.
17
 *
18
 * Supported HTTP/1.1 Accept headers:
19
 *
20
 *  Accept
21
 *  Accept-Language
22
 *  Accept-Charset
23
 *  Accept-Encoding
24
 *
25
 * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Content_negotiation
26
 */
27
28
use Generator;
29
use InvalidArgumentException;
30
use Koded\Http\Interfaces\HttpStatus;
31
32
class AcceptHeaderNegotiator
33
{
34
    /** @var AcceptHeader[] */
35
    private $supports;
36
37 56
    public function __construct(string $supportHeader)
38
    {
39 56
        $this->supports = $supportHeader;
0 ignored issues
show
Documentation Bug introduced by
It seems like $supportHeader of type string is incompatible with the declared type Koded\Http\AcceptHeader[] of property $supports.

Our type inference engine has found an assignment to a property that is incompatible with the declared type of that property.

Either this assignment is in error or the assigned type should be added to the documentation/type hint for that property..

Loading history...
40 56
    }
41
42
43 56
    public function match(string $accepts): AcceptHeader
44
    {
45
        /** @var AcceptHeader $support */
46 56
        foreach ($this->parse($accepts) as $accept) {
47 56
            foreach ($this->parse($this->supports) as $support) {
0 ignored issues
show
Bug introduced by
$this->supports of type Koded\Http\AcceptHeader[] is incompatible with the type string expected by parameter $header of Koded\Http\AcceptHeaderNegotiator::parse(). ( Ignorable by Annotation )

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

47
            foreach ($this->parse(/** @scrutinizer ignore-type */ $this->supports) as $support) {
Loading history...
48 54
                $support->matches($accept, $types);
49
            }
50
        }
51
52
        usort($types, function(AcceptHeader $a, AcceptHeader $b) {
53 30
            return $b->weight() <=> $a->weight();
54 54
        });
55
56 54
        if (empty($types)) {
57
            /* Set "q=0", meaning the header is explicitly rejected.
58
             * The consuming clients should handle this according to
59
             * their internal logic. This is much better then throwing
60
             * exceptions which must be handled in every place where
61
             * match() is called. For example, the client may issue a
62
             * 406 status code and be done with it.
63
             */
64
            $types[] = new class('*;q=0') extends AcceptHeader {};
65
        }
66
67 54
        return $types[0];
68
    }
69
70
    /**
71
     * @param string $header
72
     *
73
     * @return Generator
74
     */
75 56
    private function parse(string $header): Generator
76
    {
77 56
        foreach (explode(',', $header) as $header) {
78
            yield new class($header) extends AcceptHeader {};
79
        }
80 54
    }
81
}
82
83
84
abstract class AcceptHeader
85
{
86
    private $header;
87
    private $separator;
88
    private $type;
89
    private $subtype;
90
    private $quality  = 1.0;
91
    private $weight   = 0.0;
92
    private $catchAll = false;
93
    private $params   = [];
94
95 56
    public function __construct(string $header)
96
    {
97 56
        $this->header = $header;
98
99 56
        $header = preg_replace('/[[:space:]]/', '', $header);
100 56
        $bits   = explode(';', $header);
101 56
        $type   = array_shift($bits);
102
103 56
        if (!empty($type) && !preg_match('~^(\*|[a-z0-9._]+)([/|_-])?(\*|[a-z0-9.\-_+]+)?$~i', $type, $matches)) {
104 1
            throw new InvalidArgumentException(sprintf('"%s" is not a valid Access header', $header),
105 1
                HttpStatus::NOT_ACCEPTABLE);
106
        }
107
108 56
        $this->separator = $matches[2] ?? '/';
109 56
        [$type, $subtype] = explode($this->separator, $type, 2) + [1 => '*'];
110
111 56
        if ('*' === $type && '*' !== $subtype) {
112
            // @see https://tools.ietf.org/html/rfc7231#section-5.3.2
113 1
            throw new InvalidArgumentException(sprintf('"%s" is not a valid Access header', $header),
114 1
                HttpStatus::NOT_ACCEPTABLE);
115
        }
116
117
        // @see https://tools.ietf.org/html/rfc7540#section-8.1.2
118 56
        $this->type = strtolower($type);
119
120
        /* Uses a simple heuristic to check if subtype is part of
121
         * some obscure media type like "vnd.api-v1+json".
122
         *
123
         * NOTE: It is a waste of time to negotiate on the basis
124
         * of obscure parameters while using a meaningless media
125
         * type like "vnd.whatever". But the web world is a big mess
126
         * and this module can handle the Dunning-Kruger effect.
127
         */
128 56
        $this->subtype  = explode('+', $subtype)[1] ?? $subtype;
129 56
        $this->catchAll = '*' === $this->type && '*' === $this->subtype;
130
131 56
        parse_str(join('&', $bits), $this->params);
132 56
        $this->quality = (float)($this->params['q'] ?? 1);
133 56
        unset($this->params['q']);
134 56
    }
135
136
137 6
    public function __toString(): string
138
    {
139 6
        return $this->value();
140
    }
141
142
143 54
    public function value(): string
144
    {
145
        // The header is explicitly rejected
146 54
        if (0.0 === $this->quality) {
147 5
            return '';
148
        }
149
150
        // If language, encoding or charset
151 49
        if ('*' === $this->subtype) {
152 19
            return $this->type;
153
        }
154
155 30
        return $this->type . $this->separator . $this->subtype;
156
    }
157
158
159 47
    public function quality(): float
160
    {
161 47
        return $this->quality;
162
    }
163
164
165 33
    public function weight(): float
166
    {
167 33
        return $this->weight;
168
    }
169
170
    /**
171
     * @internal
172
     *
173
     * @param AcceptHeader   $accept  The accept header part
174
     * @param AcceptHeader[] $matches Matched types
175
     *
176
     * @return bool TRUE if the accept header part is a match
177
     * against the supported (this) header part
178
     *
179
     * This method finds the best match for the Accept header,
180
     * including all the nonsense that may be passed by the
181
     * developers who do not follow RFC standards.
182
     *
183
     */
184 54
    public function matches(AcceptHeader $accept, array &$matches = null): bool
185
    {
186 54
        $matches = (array)$matches;
187 54
        $accept  = clone $accept;
188
189 54
        $typeMatch = $this->type === $accept->type;
190
191 54
        if (1.0 === $accept->quality) {
192 42
            $accept->quality = (float)$this->quality;
193
        }
194
195 54
        if ($accept->catchAll) {
196 19
            $accept->type    = $this->type;
197 19
            $accept->subtype = $this->subtype;
198 19
            $matches[]       = $accept;
199
200 19
            return true;
201
        }
202
203
        // Explicitly denied
204 45
        if (0.0 === $this->quality) {
205 1
            $matches[] = clone $this;
206
207 1
            return true;
208
        }
209
210
        // Explicitly denied
211 44
        if (0.0 === $accept->quality) {
212 1
            $matches[] = $accept;
213
214 1
            return true;
215
        }
216
217
        // Explicit type mismatch (w/o asterisk); bail out
218 43
        if (false === $typeMatch && '*' !== $this->type) {
219 17
            return false;
220
        }
221
222 40
        if ('*' === $accept->subtype) {
223 17
            $accept->subtype = $this->subtype;
224
        }
225
226 40
        if ($accept->subtype !== $this->subtype && '*' !== $this->subtype) {
227 5
            return false;
228
        }
229
230 39
        $matches[] = $this->rank($accept);
231
232 39
        return true;
233
    }
234
235
236 39
    private function rank(AcceptHeader $accept): AcceptHeader
237
    {
238
        // +100 if types are exact match w/o asterisk
239 39
        if ($this->type === $accept->type && '*' !== $accept->type) {
240 25
            $accept->weight += 100;
241
        }
242
243 39
        $accept->weight += $this->catchAll ? 0.0 : $accept->quality;
244
245
        // +1 for each parameter that matches, except "q"
246 39
        foreach ($this->params as $k => $v) {
247 6
            if (isset($accept->params[$k]) && $accept->params[$k] === $v) {
248 3
                $accept->weight += 1;
249
            } else {
250 5
                $accept->weight -= 1;
251
            }
252
        }
253
254
        // Add "q"
255 39
        $accept->weight += $accept->quality;
256
257 39
        return $accept;
258
    }
259
}
260