Completed
Push — master ( d2aa89...bbfa5f )
by Mihail
34:12 queued 24:23
created

AcceptHeaderNegotiator.php$0 ➔ matches()   A

Complexity

Conditions 4

Size

Total Lines 20

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 6
CRAP Score 4

Importance

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