Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
38 / 38
100.00% covered (success)
100.00%
4 / 4
CRAP
100.00% covered (success)
100.00%
1 / 1
Accept
100.00% covered (success)
100.00%
38 / 38
100.00% covered (success)
100.00%
4 / 4
18
100.00% covered (success)
100.00%
1 / 1
 getBestMatch
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 parseHeader
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
6
 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 * @link      https://github.com/php-fast-forward/http-message
12 * @copyright Copyright (c) 2025 Felipe Sayão Lobato Abreu <github@mentordosnerds.com>
13 * @license   https://opensource.org/licenses/MIT MIT License
14 */
15
16namespace FastForward\Http\Message\Header;
17
18/**
19 * Enum Accept.
20 *
21 * Represents common HTTP Accept header values and provides robust helpers for
22 * content negotiation in accordance with RFC 2119 requirement levels.
23 *
24 * This enum MUST be used to ensure that Accept-type comparisons are performed
25 * consistently and predictably. Implementations interacting with this enum
26 * SHOULD rely on its parsing and negotiation logic to determine the most
27 * appropriate response format based on client preferences.
28 */
29enum Accept: string
30{
31    // Common Types
32    case ApplicationJson = 'application/json';
33    case ApplicationXml  = 'application/xml';
34    case TextHtml        = 'text/html';
35    case TextPlain       = 'text/plain';
36
37    /**
38     * Determines the best matching content type from the client's Accept header.
39     *
40     * This method SHALL apply proper HTTP content negotiation rules, including:
41     * - Quality factors (q-values), which MUST be sorted in descending order.
42     * - Specificity preference, where more explicit types MUST be preferred over
43     *   wildcard types when q-values are equal.
44     *
45     * If a match cannot be found in the list of supported server types,
46     * the method MUST return null.
47     *
48     * @param string $acceptHeader   the raw HTTP Accept header value
49     * @param self[] $supportedTypes an array of enum cases the server supports
50     *
51     * @return null|self the best match, or null if no acceptable type exists
52     */
53    public static function getBestMatch(string $acceptHeader, array $supportedTypes): ?self
54    {
55        $clientPreferences = self::parseHeader($acceptHeader);
56
57        foreach ($clientPreferences as $preference) {
58            foreach ($supportedTypes as $supported) {
59                if (self::matches($preference['type'], $supported->value)) {
60                    return $supported;
61                }
62            }
63        }
64
65        return null;
66    }
67
68    /**
69     * Parses the Accept header string into a sorted list of client preferences.
70     *
71     * This method MUST:
72     * - Extract MIME types from the header string.
73     * - Parse quality factors (q-values), defaulting to 1.0 when omitted.
74     * - Calculate specificity, which SHALL be used as a secondary sort criterion.
75     * - Sort results by descending q-value and descending specificity.
76     *
77     * The resulting array MUST represent the client’s explicit and wildcard
78     * preferences in the correct HTTP negotiation order.
79     *
80     * @param string $header the raw Accept header string
81     *
82     * @return array<int, array{type: string, q: float, specificity: int}>
83     */
84    private static function parseHeader(string $header): array
85    {
86        $preferences = [];
87
88        // Captures a type and optional q-value while ignoring other parameters.
89        $pattern = '/(?<type>[^,;]+)(?:;[^,]*q=(?<q>[0-9.]+))?/';
90
91        if (\preg_match_all($pattern, $header, $matches, PREG_SET_ORDER)) {
92            foreach ($matches as $match) {
93                $type = \mb_trim($match['type']);
94                $q    = isset($match['q']) && '' !== $match['q'] ? (float) $match['q'] : 1.0;
95
96                $preferences[] = [
97                    'type'        => $type,
98                    'q'           => $q,
99                    'specificity' => self::calculateSpecificity($type),
100                ];
101            }
102        }
103
104        \usort(
105            $preferences,
106            static function (array $a, array $b): int {
107                if ($a['q'] !== $b['q']) {
108                    return $b['q'] <=> $a['q'];
109                }
110
111                return $b['specificity'] <=> $a['specificity'];
112            }
113        );
114
115        return $preferences;
116    }
117
118    /**
119     * Calculates the specificity of a MIME type.
120     *
121     * Specificity MUST be determined using the following criteria:
122     * - "* /*"    → specificity 0 (least specific)
123     * - "type/*" → specificity 1 (partially specific)
124     * - "type/subtype" → specificity 2 (fully specific)
125     *
126     * This value SHALL be used to sort MIME types when q-values are equal.
127     *
128     * @param string $type the MIME type to evaluate
129     *
130     * @return int the calculated specificity score
131     */
132    private static function calculateSpecificity(string $type): int
133    {
134        if ('*/*' === $type) {
135            return 0;
136        }
137
138        if (\str_ends_with($type, '/*')) {
139            return 1;
140        }
141
142        return 2;
143    }
144
145    /**
146     * Determines whether a client-preferred type matches a server-supported type.
147     *
148     * This method MUST apply wildcard matching rules as defined in HTTP
149     * content negotiation:
150     * - "* /*" MUST match any type.
151     * - A full exact match MUST be treated as the highest priority.
152     * - A "type/*" wildcard MUST match any subtype within the given type.
153     *
154     * @param string $preference the MIME type from the client preference list
155     * @param string $supported  the server-supported MIME type
156     *
157     * @return bool true if the supported type matches the preference
158     */
159    private static function matches(string $preference, string $supported): bool
160    {
161        if ('*/*' === $preference || $preference === $supported) {
162            return true;
163        }
164
165        if (\str_ends_with($preference, '/*')) {
166            $prefix = \strtok($preference, '/');
167            if (\str_starts_with($supported, $prefix . '/')) {
168                return true;
169            }
170        }
171
172        return false;
173    }
174}