Completed
Push — v3 ( 82e8cc...c94160 )
by Mihail
10:10
created

AcceptHeaderNegotiator.php$0 ➔ matches()   A

Complexity

Conditions 4

Size

Total Lines 20

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
c 0
b 0
f 0
dl 0
loc 20
cc 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
    }
38
39
    public function match(string $accepts): AcceptHeader
40
    {
41
        return $this->matches($accepts)[0];
42
    }
43
44
    public function matches(string $accepts): array
45
    {
46
        /** @var AcceptHeader $support */
47
        foreach ($this->parse($accepts) as $accept) {
48
            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
        if (empty($matches)) {
54
            /* Set "q=0", meaning the header is explicitly rejected.
55
             * The consuming clients should handle this according to
56
             * 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
     * @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
        }
76
    }
77
}
78
79
80
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
        $header = preg_replace('/[[:space:]]/', '', $header);
96
        $bits   = explode(';', $header);
97
        $type   = array_shift($bits);
98
        if (!empty($type) && !preg_match('~^(\*|[a-z0-9._]+)([/|_\-])?(\*|[a-z0-9.\-_+]+)?$~i', $type, $matches)) {
99
            throw new \InvalidArgumentException(sprintf('"%s" is not a valid Access header', $header),
100
                HttpStatus::NOT_ACCEPTABLE);
101
        }
102
        $this->separator = $matches[2] ?? '/';
103
        [$type, $subtype] = explode($this->separator, $type, 2) + [1 => '*'];
104
        if ('*' === $type && '*' !== $subtype) {
105
            // @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
        }
109
        // @see https://tools.ietf.org/html/rfc7540#section-8.1.2
110
        $this->type = trim(strtolower($type));
111
        /*
112
         * Uses a simple heuristic to check if subtype is part of
113
         * some convoluted media type like "vnd.api-v1+json".
114
         *
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
         * 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
    {
129
        return $this->value();
130
    }
131
132
    public function value(): string
133
    {
134
        // 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
            return '';
138
        }
139
        // If language, encoding or charset
140
        if ('*' === $this->subtype) {
141
            return $this->type;
142
        }
143
        return $this->type . $this->separator . $this->subtype;
144
    }
145
146
    public function quality(): float
147
    {
148
        return $this->quality;
149
    }
150
151
    public function weight(): float
152
    {
153
        return $this->weight;
154
    }
155
156
    public function is(string $type): bool
157
    {
158
        return ($type === $this->subtype) && ($this->subtype !== '*');
159
    }
160
161
    /**
162
     * @param AcceptHeader   $accept  The accept header part
163
     * @param AcceptHeader[] $matches Matched types
164
     *
165
     * @return bool TRUE if the accept header part is a match
166
     * against the supported (this) header part
167
     *
168
     * This method finds the best match for the Accept header,
169
     * including lots of nonsense that may be passed by the
170
     * developers who do not follow RFC standards.
171
     *
172
     * @internal
173
     */
174
    public function matches(AcceptHeader $accept, array &$matches = null): bool
175
    {
176
        $matches   = (array)$matches;
177
        $accept    = clone $accept;
178
        $typeMatch = ($this->type === $accept->type);
179
        if (1.0 === $accept->quality) {
0 ignored issues
show
introduced by
The condition 1.0 === $accept->quality is always false.
Loading history...
180
            $accept->quality = (float)$this->quality;
181
        }
182
        if ($accept->catchAll) {
183
            $accept->type    = $this->type;
184
            $accept->subtype = $this->subtype;
185
            $matches[]       = $accept;
186
            return true;
187
        }
188
        // Explicitly denied
189
        if (0.0 === $this->quality) {
0 ignored issues
show
introduced by
The condition 0.0 === $this->quality is always false.
Loading history...
190
            $matches[] = clone $this;
191
            return true;
192
        }
193
        // Explicitly denied
194
        if (0.0 === $accept->quality) {
0 ignored issues
show
introduced by
The condition 0.0 === $accept->quality is always false.
Loading history...
195
            $matches[] = $accept;
196
            return true;
197
        }
198
        // Explicit type mismatch (w/o asterisk); bail out
199
        if ((false === $typeMatch) && ('*' !== $this->type)) {
200
            return false;
201
        }
202
        if ('*' === $accept->subtype) {
203
            $accept->subtype = $this->subtype;
204
        }
205
        if (($accept->subtype !== $this->subtype) && ('*' !== $this->subtype)) {
206
            return false;
207
        }
208
        $matches[] = $this->rank($accept);
209
        return true;
210
    }
211
212
213
    private function rank(AcceptHeader $accept): AcceptHeader
214
    {
215
        // +100 if types are exact match w/o asterisk
216
        if (($this->type === $accept->type) &&
217
            ($this->subtype === $accept->subtype) &&
218
            ('*' !== $accept->subtype)) {
219
            $accept->weight += 100;
220
        }
221
        $accept->weight += ($this->catchAll ? 0.0 : $accept->quality);
222
        // +1 for each parameter that matches, except "q"
223
        foreach ($this->params as $k => $v) {
224
            if (isset($accept->params[$k]) && ($accept->params[$k] === $v)) {
225
                $accept->weight += 1;
226
            } else {
227
                $accept->weight -= 1;
228
            }
229
        }
230
        // Add "q"
231
        $accept->weight += $accept->quality;
232
        return $accept;
233
    }
234
}
235