Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
74 / 74
100.00% covered (success)
100.00%
6 / 6
CRAP
100.00% covered (success)
100.00%
1 / 1
Accept
100.00% covered (success)
100.00%
74 / 74
100.00% covered (success)
100.00%
6 / 6
35
100.00% covered (success)
100.00%
1 / 1
 getBestMatch
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
7
 findEffectivePreference
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
5
 parseHeader
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
7
 splitRanges
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
8
 calculateSpecificity
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 matches
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
5
1<?php
2
3declare(strict_types=1);
4
5/**
6 * This file is part of php-fast-forward/http-message.
7 *
8 * This source file is subject to the license bundled
9 * with this source code in the file LICENSE.
10 *
11 * @copyright Copyright (c) 2025-2026 Felipe Sayão Lobato Abreu <github@mentordosnerds.com>
12 * @license   https://opensource.org/licenses/MIT MIT License
13 *
14 * @see       https://github.com/php-fast-forward/http-message
15 * @see       https://github.com/php-fast-forward
16 * @see       https://datatracker.ietf.org/doc/html/rfc2119
17 */
18
19namespace FastForward\Http\Message\Header;
20
21/**
22 * Enum Accept.
23 *
24 * Represents common HTTP Accept header values and provides robust helpers for
25 * content negotiation in accordance with RFC 2119 requirement levels.
26 * This enum MUST be used to ensure that Accept-type comparisons are performed
27 * consistently and predictably. Implementations interacting with this enum
28 * SHOULD rely on its parsing and negotiation logic to determine the most
29 * appropriate response format based on client preferences.
30 */
31enum Accept: string
32{
33    // Common Types
34    case ApplicationJson = 'application/json';
35    case ApplicationXml  = 'application/xml';
36    case TextHtml        = 'text/html';
37    case TextPlain       = 'text/plain';
38
39    /**
40     * Determines the best matching content type from the client's Accept header.
41     *
42     * This method SHALL apply proper HTTP content negotiation rules, including:
43     * - The most specific matching range determines each representation's quality.
44     * - Zero-quality representations are excluded before ranking by quality.
45     * - Specificity preference, where more explicit types MUST be preferred over
46     *   wildcard types when q-values are equal.
47     * If a match cannot be found in the list of supported server types,
48     * the method MUST return null.
49     *
50     * @param string $acceptHeader the raw HTTP Accept header value
51     * @param self[] $supportedTypes an array of enum cases the server supports
52     *
53     * @return self|null the best match, or null if no acceptable type exists
54     */
55    public static function getBestMatch(string $acceptHeader, array $supportedTypes): ?self
56    {
57        $clientPreferences = self::parseHeader($acceptHeader);
58
59        $bestMatch = null;
60        $bestQuality = 0.0;
61        $bestSpecificity = -1;
62
63        foreach ($supportedTypes as $supported) {
64            $effective = self::findEffectivePreference($clientPreferences, $supported->value);
65
66            if (null !== $effective && $effective['q'] > 0.0
67                && ($effective['q'] > $bestQuality
68                    || ($effective['q'] === $bestQuality && $effective['specificity'] > $bestSpecificity))) {
69                $bestMatch = $supported;
70                $bestQuality = $effective['q'];
71                $bestSpecificity = $effective['specificity'];
72            }
73        }
74
75        return $bestMatch;
76    }
77
78    /**
79     * Finds the most specific applicable range before considering its quality.
80     *
81     * @param array<int, array{type: string, q: float, specificity: int}> $preferences
82     * @param string $supported
83     *
84     * @return array{type: string, q: float, specificity: int}|null
85     */
86    private static function findEffectivePreference(array $preferences, string $supported): ?array
87    {
88        $effective = null;
89        foreach ($preferences as $preference) {
90            if (self::matches($preference['type'], $supported)
91                && (null === $effective || $preference['specificity'] > $effective['specificity'])) {
92                $effective = $preference;
93            }
94        }
95
96        return $effective;
97    }
98
99    /**
100     * Parses media ranges in field order. These enum cases represent bare media
101     * types, so ranges requiring media parameters do not match them.
102     *
103     * @param string $header
104     *
105     * @return array<int, array{type: string, q: float, specificity: int}>
106     */
107    private static function parseHeader(string $header): array
108    {
109        $preferences = [];
110        $ranges = self::splitRanges($header);
111
112        foreach ($ranges as $range) {
113            $parts = explode(';', $range);
114            $type = strtolower(trim(array_shift($parts), " \t"));
115            $q = 1.0;
116            $hasMediaParameters = false;
117
118            foreach ($parts as $parameter) {
119                [$name, $value] = array_pad(explode('=', $parameter, 2), 2, '');
120                if ('q' === strtolower(trim($name, " \t"))) {
121                    $value = trim($value, " \t");
122                    if (! preg_match('/^(?:0(?:\.[0-9]{0,3})?|1(?:\.0{0,3})?)$/D', $value)) {
123                        continue 2;
124                    }
125
126                    $q = (float) $value;
127                    break;
128                }
129
130                if ('' !== trim($name, " \t")) {
131                    $hasMediaParameters = true;
132                }
133            }
134
135            if (! $hasMediaParameters) {
136                $preferences[] = [
137                    'type' => $type,
138                    'q' => $q,
139                    'specificity' => self::calculateSpecificity($type),
140                ];
141            }
142        }
143
144        return $preferences;
145    }
146
147    /**
148     * Splits only list commas outside quoted strings, honoring quoted-pair escapes.
149     *
150     * @param string $header
151     *
152     * @return list<string>
153     */
154    private static function splitRanges(string $header): array
155    {
156        $ranges = [];
157        $start = 0;
158        $quoted = false;
159        $escaped = false;
160        $length = \strlen($header);
161
162        for ($index = 0; $index < $length; ++$index) {
163            $character = $header[$index];
164            if ($escaped) {
165                $escaped = false;
166                continue;
167            }
168
169            if ($quoted && '\\' === $character) {
170                $escaped = true;
171            } elseif ('"' === $character) {
172                $quoted = ! $quoted;
173            } elseif (',' === $character && ! $quoted) {
174                $ranges[] = substr($header, $start, $index - $start);
175                $start = $index + 1;
176            }
177        }
178
179        $ranges[] = substr($header, $start);
180
181        return $ranges;
182    }
183
184    /**
185     * Calculates the specificity of a MIME type.
186     *
187     * Specificity MUST be determined using the following criteria:
188     * - "* /*"    → specificity 0 (least specific)
189     * - "type/*" → specificity 1 (partially specific)
190     * - "type/subtype" → specificity 2 (fully specific)
191     * This value SHALL be used to sort MIME types when q-values are equal.
192     *
193     * @param string $type the MIME type to evaluate
194     *
195     * @return int the calculated specificity score
196     */
197    private static function calculateSpecificity(string $type): int
198    {
199        if ('*/*' === $type) {
200            return 0;
201        }
202
203        if (str_ends_with($type, '/*')) {
204            return 1;
205        }
206
207        return 2;
208    }
209
210    /**
211     * Determines whether a client-preferred type matches a server-supported type.
212     *
213     * This method MUST apply wildcard matching rules as defined in HTTP
214     * content negotiation:
215     * - "* /*" MUST match any type.
216     * - A full exact match MUST be treated as the highest priority.
217     * - A "type/*" wildcard MUST match any subtype within the given type.
218     *
219     * @param string $preference the MIME type from the client preference list
220     * @param string $supported the server-supported MIME type
221     *
222     * @return bool true if the supported type matches the preference
223     */
224    private static function matches(string $preference, string $supported): bool
225    {
226        if ('*/*' === $preference || $preference === $supported) {
227            return true;
228        }
229
230        if (str_ends_with($preference, '/*')) {
231            $prefix = strtok($preference, '/');
232            if (str_starts_with($supported, $prefix . '/')) {
233                return true;
234            }
235        }
236
237        return false;
238    }
239}