Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.44% covered (success)
97.44%
38 / 39
80.00% covered (success)
80.00%
4 / 5
CRAP
0.00% covered (danger)
0.00%
0 / 1
AcceptHeader
97.44% covered (success)
97.44%
38 / 39
80.00% covered (success)
80.00%
4 / 5
22
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 matches
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
12
 accepts
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 getPreferredType
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 splitMediaType
80.00% covered (success)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
2.03
1<?php
2declare(strict_types=1);
3/**
4 * Pop PHP Framework (https://www.popphp.org/)
5 *
6 * @link       https://github.com/popphp/popphp-framework
7 * @author     Nick Sagona, III <nick@popphp.org>
8 * @copyright  Copyright (c) 2009-2026 Nick Sagona, III
9 * @license    https://www.popphp.org/license     New BSD License
10 */
11
12/**
13 * @namespace
14 */
15namespace Pop\Http\Server;
16
17/**
18 * HTTP accept header class
19 *
20 * RFC 7231 Section 5.3.2 compliant Accept header parser/negotiator. Built on QualityValue's
21 * generic 'value;q=N' parsing, adding media-type wildcard matching (*\/*, type/*) and
22 * specificity-based precedence (exact match beats type/* beats *\/*). Media-range parameters
23 * other than 'q' (e.g. ';level=1') are ignored for matching purposes.
24 *
25 * @category   Pop
26 * @package    Pop\Http
27 * @author     Nick Sagona, III <nick@popphp.org>
28 * @copyright  Copyright (c) 2009-2026 Nick Sagona, III
29 * @license    https://www.popphp.org/license     New BSD License
30 * @version    6.0.0
31 */
32class AcceptHeader
33{
34
35    /**
36     * Parsed, quality-sorted entries
37     * @var QualityValue[]
38     */
39    protected array $entries;
40
41    /**
42     * Constructor
43     *
44     * A null or empty header is treated as '*\/*' - per RFC 7231, a request with no Accept
45     * header implies the client will accept any media type in response.
46     *
47     * @param  ?string $header
48     */
49    public function __construct(?string $header = null)
50    {
51        $header        = trim((string)$header);
52        $header        = ($header !== '') ? $header : '*/*';
53        $this->entries = QualityValue::parseList($header);
54    }
55
56    /**
57     * Get the effective quality for a concrete media type, resolved by specificity:
58     * exact type/subtype match > type/* > *\/*. Among entries tied at the same specificity,
59     * the highest quality wins. Returns 0.0 if nothing matches, if the highest-specificity
60     * match is explicitly excluded via q=0, or if every matching entry falls below the
61     * given $specificity threshold.
62     *
63     * @param  string            $mediaType
64     * @param  AcceptSpecificity $specificity
65     * @return float
66     */
67    public function matches(string $mediaType, AcceptSpecificity $specificity = AcceptSpecificity::Any): float
68    {
69        [$type, $subtype] = self::splitMediaType($mediaType);
70
71        $bestSpecificity = -1;
72        $bestQuality     = 0.0;
73
74        foreach ($this->entries as $entry) {
75            [$entryType, $entrySubtype] = self::splitMediaType($entry->getValue());
76
77            if (($entryType === $type) && ($entrySubtype === $subtype)) {
78                $entrySpecificity = 2;
79            } else if (($entryType === $type) && ($entrySubtype === '*')) {
80                $entrySpecificity = 1;
81            } else if (($entryType === '*') && ($entrySubtype === '*')) {
82                $entrySpecificity = 0;
83            } else {
84                continue;
85            }
86
87            if ($entrySpecificity < $specificity->value) {
88                continue;
89            }
90
91            if (($entrySpecificity > $bestSpecificity) ||
92                (($entrySpecificity === $bestSpecificity) && ($entry->getQuality() > $bestQuality))) {
93                $bestSpecificity = $entrySpecificity;
94                $bestQuality     = $entry->getQuality();
95            }
96        }
97
98        return $bestQuality;
99    }
100
101    /**
102     * Whether any of the given media type(s) is acceptable
103     *
104     * @param  string|array      $types
105     * @param  AcceptSpecificity $specificity
106     * @return bool
107     */
108    public function accepts(string|array $types, AcceptSpecificity $specificity = AcceptSpecificity::Any): bool
109    {
110        foreach ((array)$types as $type) {
111            if ($this->matches($type, $specificity) > 0) {
112                return true;
113            }
114        }
115
116        return false;
117    }
118
119    /**
120     * Given the media types this server can actually respond with, return the client's
121     * best match. Ties (equal matches() score) are broken by $available's own order - the
122     * server's stated preference wins, since HTTP doesn't mandate an order for equal-quality
123     * client preferences. Returns null if every candidate scores 0.
124     *
125     * @param  string[]          $available
126     * @param  AcceptSpecificity $specificity
127     * @return string|null
128     */
129    public function getPreferredType(array $available, AcceptSpecificity $specificity = AcceptSpecificity::Any): ?string
130    {
131        $best      = null;
132        $bestScore = 0.0;
133
134        foreach ($available as $type) {
135            $score = $this->matches($type, $specificity);
136            if ($score > $bestScore) {
137                $bestScore = $score;
138                $best      = $type;
139            }
140        }
141
142        return $best;
143    }
144
145    /**
146     * Split a media type into [type, subtype]. A value with no '/' is treated as [$value, '*']
147     * so malformed entries degrade gracefully instead of raising a warning.
148     *
149     * @param  string $mediaType
150     * @return array
151     */
152    protected static function splitMediaType(string $mediaType): array
153    {
154        $mediaType = strtolower(trim($mediaType));
155        if (!str_contains($mediaType, '/')) {
156            return [$mediaType, '*'];
157        }
158
159        [$type, $subtype] = explode('/', $mediaType, 2);
160        return [trim($type), trim($subtype)];
161    }
162
163}