• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

codeigniter4 / CodeIgniter4 / 12597439528

03 Jan 2025 12:07PM UTC coverage: 84.446%. First build
12597439528

Pull #9360

github

web-flow
Merge 29472c600 into 046967af0
Pull Request #9360: feat: Strict locale negotiate

18 of 24 new or added lines in 1 file covered. (75.0%)

20680 of 24489 relevant lines covered (84.45%)

190.58 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

95.0
/system/HTTP/Negotiate.php
1
<?php
2

3
declare(strict_types=1);
4

5
/**
6
 * This file is part of CodeIgniter 4 framework.
7
 *
8
 * (c) CodeIgniter Foundation <admin@codeigniter.com>
9
 *
10
 * For the full copyright and license information, please view
11
 * the LICENSE file that was distributed with this source code.
12
 */
13

14
namespace CodeIgniter\HTTP;
15

16
use CodeIgniter\HTTP\Exceptions\HTTPException;
17
use Config\Feature;
18

19
/**
20
 * Class Negotiate
21
 *
22
 * Provides methods to negotiate with the HTTP headers to determine the best
23
 * type match between what the application supports and what the requesting
24
 * server wants.
25
 *
26
 * @see http://tools.ietf.org/html/rfc7231#section-5.3
27
 * @see \CodeIgniter\HTTP\NegotiateTest
28
 */
29
class Negotiate
30
{
31
    /**
32
     * Request
33
     *
34
     * @var IncomingRequest
35
     */
36
    protected $request;
37

38
    /**
39
     * Constructor
40
     */
41
    public function __construct(?RequestInterface $request = null)
42
    {
43
        if ($request instanceof RequestInterface) {
22✔
44
            assert($request instanceof IncomingRequest);
45

46
            $this->request = $request;
22✔
47
        }
48
    }
49

50
    /**
51
     * Stores the request instance to grab the headers from.
52
     *
53
     * @return $this
54
     */
55
    public function setRequest(RequestInterface $request)
56
    {
57
        assert($request instanceof IncomingRequest);
58

59
        $this->request = $request;
1✔
60

61
        return $this;
1✔
62
    }
63

64
    /**
65
     * Determines the best content-type to use based on the $supported
66
     * types the application says it supports, and the types requested
67
     * by the client.
68
     *
69
     * If no match is found, the first, highest-ranking client requested
70
     * type is returned.
71
     *
72
     * @param bool $strictMatch If TRUE, will return an empty string when no match found.
73
     *                          If FALSE, will return the first supported element.
74
     */
75
    public function media(array $supported, bool $strictMatch = false): string
76
    {
77
        return $this->getBestMatch($supported, $this->request->getHeaderLine('accept'), true, $strictMatch);
13✔
78
    }
79

80
    /**
81
     * Determines the best charset to use based on the $supported
82
     * types the application says it supports, and the types requested
83
     * by the client.
84
     *
85
     * If no match is found, the first, highest-ranking client requested
86
     * type is returned.
87
     */
88
    public function charset(array $supported): string
89
    {
90
        $match = $this->getBestMatch(
2✔
91
            $supported,
2✔
92
            $this->request->getHeaderLine('accept-charset'),
2✔
93
            false,
2✔
94
            true
2✔
95
        );
2✔
96

97
        // If no charset is shown as a match, ignore the directive
98
        // as allowed by the RFC, and tell it a default value.
99
        if ($match === '') {
2✔
100
            return 'utf-8';
2✔
101
        }
102

103
        return $match;
1✔
104
    }
105

106
    /**
107
     * Determines the best encoding type to use based on the $supported
108
     * types the application says it supports, and the types requested
109
     * by the client.
110
     *
111
     * If no match is found, the first, highest-ranking client requested
112
     * type is returned.
113
     */
114
    public function encoding(array $supported = []): string
115
    {
116
        $supported[] = 'identity';
3✔
117

118
        return $this->getBestMatch($supported, $this->request->getHeaderLine('accept-encoding'));
3✔
119
    }
120

121
    /**
122
     * Determines the best language to use based on the $supported
123
     * types the application says it supports, and the types requested
124
     * by the client.
125
     *
126
     * If loose locale negotiation is enabled and no match is found, the first, highest-ranking client requested
127
     * type is returned.
128
     */
129
    public function language(array $supported): string
130
    {
131
        if (config(Feature::class)->looseLocaleNegotiation) {
5✔
132
            return $this->getBestMatch($supported, $this->request->getHeaderLine('accept-language'), false, false, true);
5✔
133
        }
134

135
        return $this->getBestLocaleMatch($supported, $this->request->getHeaderLine('accept-language'));
2✔
136
    }
137

138
    // --------------------------------------------------------------------
139
    // Utility Methods
140
    // --------------------------------------------------------------------
141

142
    /**
143
     * Does the grunt work of comparing any of the app-supported values
144
     * against a given Accept* header string.
145
     *
146
     * Portions of this code base on Aura.Accept library.
147
     *
148
     * @param array  $supported    App-supported values
149
     * @param string $header       header string
150
     * @param bool   $enforceTypes If TRUE, will compare media types and sub-types.
151
     * @param bool   $strictMatch  If TRUE, will return empty string on no match.
152
     *                             If FALSE, will return the first supported element.
153
     * @param bool   $matchLocales If TRUE, will match locale sub-types to a broad type (fr-FR = fr)
154
     *
155
     * @return string Best match
156
     */
157
    protected function getBestMatch(
158
        array $supported,
159
        ?string $header = null,
160
        bool $enforceTypes = false,
161
        bool $strictMatch = false,
162
        bool $matchLocales = false
163
    ): string {
164
        if ($supported === []) {
23✔
165
            throw HTTPException::forEmptySupportedNegotiations();
1✔
166
        }
167

168
        if ($header === null || $header === '') {
22✔
169
            return $strictMatch ? '' : $supported[0];
6✔
170
        }
171

172
        $acceptable = $this->parseHeader($header);
16✔
173

174
        foreach ($acceptable as $accept) {
16✔
175
            // if acceptable quality is zero, skip it.
176
            if ($accept['q'] === 0.0) {
16✔
177
                continue;
1✔
178
            }
179

180
            // if acceptable value is "anything", return the first available
181
            if ($accept['value'] === '*' || $accept['value'] === '*/*') {
15✔
182
                return $supported[0];
1✔
183
            }
184

185
            // If an acceptable value is supported, return it
186
            foreach ($supported as $available) {
15✔
187
                if ($this->match($accept, $available, $enforceTypes, $matchLocales)) {
15✔
188
                    return $available;
11✔
189
                }
190
            }
191
        }
192

193
        // No matches? Return the first supported element.
194
        return $strictMatch ? '' : $supported[0];
6✔
195
    }
196

197
    /**
198
     * Strict locale search, including territories (en-*)
199
     *
200
     * @param list<string> $supported App-supported values
201
     * @param ?string      $header    Compatible 'Accept-Language' header string
202
     */
203
    protected function getBestLocaleMatch(array $supported, ?string $header): string
204
    {
205
        if ($supported === []) {
2✔
NEW
206
            throw HTTPException::forEmptySupportedNegotiations();
×
207
        }
208

209
        if ($header === null || $header === '') {
2✔
NEW
210
            return $supported[0];
×
211
        }
212

213
        $acceptable      = $this->parseHeader($header);
2✔
214
        $fallbackLocales = [];
2✔
215

216
        foreach ($acceptable as $accept) {
2✔
217
            // if acceptable quality is zero, skip it.
218
            if ($accept['q'] === 0.0) {
2✔
NEW
219
                continue;
×
220
            }
221

222
            // if acceptable value is "anything", return the first available
223
            if ($accept['value'] === '*' || $accept['value'] === '*/*') {
2✔
NEW
224
                return $supported[0];
×
225
            }
226

227
            // look for exact match
228
            if (in_array($accept['value'], $supported, true)) {
2✔
229
                return $accept['value'];
2✔
230
            }
231

232
            // set a fallback locale
233
            $fallbackLocales[] = strtok($accept['value'], '-');
2✔
234
        }
235

236
        foreach ($fallbackLocales as $fallbackLocale) {
1✔
237
            // look for exact match
238
            if (in_array($fallbackLocale, $supported, true)) {
1✔
NEW
239
                return $fallbackLocale;
×
240
            }
241

242
            // look for locale variants match
243
            foreach ($supported as $locale) {
1✔
244
                if (str_starts_with($locale, $fallbackLocale . '-')) {
1✔
245
                    return $locale;
1✔
246
                }
247
            }
248
        }
249

NEW
250
        return $supported[0];
×
251
    }
252

253
    /**
254
     * Parses an Accept* header into it's multiple values.
255
     *
256
     * This is based on code from Aura.Accept library.
257
     */
258
    public function parseHeader(string $header): array
259
    {
260
        $results    = [];
17✔
261
        $acceptable = explode(',', $header);
17✔
262

263
        foreach ($acceptable as $value) {
17✔
264
            $pairs = explode(';', $value);
17✔
265

266
            $value = $pairs[0];
17✔
267

268
            unset($pairs[0]);
17✔
269

270
            $parameters = [];
17✔
271

272
            foreach ($pairs as $pair) {
17✔
273
                if (preg_match(
12✔
274
                    '/^(?P<name>.+?)=(?P<quoted>"|\')?(?P<value>.*?)(?:\k<quoted>)?$/',
12✔
275
                    $pair,
12✔
276
                    $param
12✔
277
                )) {
12✔
278
                    $parameters[trim($param['name'])] = trim($param['value']);
12✔
279
                }
280
            }
281

282
            $quality = 1.0;
17✔
283

284
            if (array_key_exists('q', $parameters)) {
17✔
285
                $quality = $parameters['q'];
10✔
286
                unset($parameters['q']);
10✔
287
            }
288

289
            $results[] = [
17✔
290
                'value'  => trim($value),
17✔
291
                'q'      => (float) $quality,
17✔
292
                'params' => $parameters,
17✔
293
            ];
17✔
294
        }
295

296
        // Sort to get the highest results first
297
        usort($results, static function ($a, $b): int {
17✔
298
            if ($a['q'] === $b['q']) {
14✔
299
                $aAst = substr_count($a['value'], '*');
8✔
300
                $bAst = substr_count($b['value'], '*');
8✔
301

302
                // '*/*' has lower precedence than 'text/*',
303
                // and 'text/*' has lower priority than 'text/plain'
304
                //
305
                // This seems backwards, but needs to be that way
306
                // due to the way PHP7 handles ordering or array
307
                // elements created by reference.
308
                if ($aAst > $bAst) {
8✔
309
                    return 1;
3✔
310
                }
311

312
                // If the counts are the same, but one element
313
                // has more params than another, it has higher precedence.
314
                //
315
                // This seems backwards, but needs to be that way
316
                // due to the way PHP7 handles ordering or array
317
                // elements created by reference.
318
                if ($aAst === $bAst) {
6✔
319
                    return count($b['params']) - count($a['params']);
5✔
320
                }
321

322
                return 0;
2✔
323
            }
324

325
            // Still here? Higher q values have precedence.
326
            return ($a['q'] > $b['q']) ? -1 : 1;
9✔
327
        });
17✔
328

329
        return $results;
17✔
330
    }
331

332
    /**
333
     * Match-maker
334
     *
335
     * @param bool $matchLocales
336
     */
337
    protected function match(array $acceptable, string $supported, bool $enforceTypes = false, $matchLocales = false): bool
338
    {
339
        $supported = $this->parseHeader($supported);
15✔
340
        if (count($supported) === 1) {
15✔
341
            $supported = $supported[0];
15✔
342
        }
343

344
        // Is it an exact match?
345
        if ($acceptable['value'] === $supported['value']) {
15✔
346
            return $this->matchParameters($acceptable, $supported);
8✔
347
        }
348

349
        // Do we need to compare types/sub-types? Only used
350
        // by negotiateMedia().
351
        if ($enforceTypes) {
14✔
352
            return $this->matchTypes($acceptable, $supported);
7✔
353
        }
354

355
        // Do we need to match locales against broader locales?
356
        if ($matchLocales) {
7✔
357
            return $this->matchLocales($acceptable, $supported);
5✔
358
        }
359

360
        return false;
2✔
361
    }
362

363
    /**
364
     * Checks two Accept values with matching 'values' to see if their
365
     * 'params' are the same.
366
     */
367
    protected function matchParameters(array $acceptable, array $supported): bool
368
    {
369
        if (count($acceptable['params']) !== count($supported['params'])) {
8✔
370
            return false;
1✔
371
        }
372

373
        foreach ($supported['params'] as $label => $value) {
8✔
374
            if (! isset($acceptable['params'][$label])
1✔
375
                || $acceptable['params'][$label] !== $value
1✔
376
            ) {
377
                return false;
1✔
378
            }
379
        }
380

381
        return true;
7✔
382
    }
383

384
    /**
385
     * Compares the types/subtypes of an acceptable Media type and
386
     * the supported string.
387
     */
388
    public function matchTypes(array $acceptable, array $supported): bool
389
    {
390
        // PHPDocumentor v2 cannot parse yet the shorter list syntax,
391
        // causing no API generation for the file.
392
        [$aType, $aSubType] = explode('/', $acceptable['value']);
7✔
393
        [$sType, $sSubType] = explode('/', $supported['value']);
7✔
394

395
        // If the types don't match, we're done.
396
        if ($aType !== $sType) {
7✔
397
            return false;
6✔
398
        }
399

400
        // If there's an asterisk, we're cool
401
        if ($aSubType === '*') {
4✔
402
            return true;
2✔
403
        }
404

405
        // Otherwise, subtypes must match also.
406
        return $aSubType === $sSubType;
2✔
407
    }
408

409
    /**
410
     * Will match locales against their broader pairs, so that fr-FR would
411
     * match a supported localed of fr
412
     */
413
    public function matchLocales(array $acceptable, array $supported): bool
414
    {
415
        $aBroad = mb_strpos($acceptable['value'], '-') > 0
5✔
416
            ? mb_substr($acceptable['value'], 0, mb_strpos($acceptable['value'], '-'))
5✔
417
            : $acceptable['value'];
2✔
418
        $sBroad = mb_strpos($supported['value'], '-') > 0
5✔
419
            ? mb_substr($supported['value'], 0, mb_strpos($supported['value'], '-'))
2✔
420
            : $supported['value'];
5✔
421

422
        return strtolower($aBroad) === strtolower($sBroad);
5✔
423
    }
424
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc