Code

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