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 * @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     * - Quality factors (q-values), which MUST be sorted in descending order.
44     * - Specificity preference, where more explicit types MUST be preferred over
45     *   wildcard types when q-values are equal.
46     * If a match cannot be found in the list of supported server types,
47     * the method MUST return null.
48     *
49     * @param string $acceptHeader the raw HTTP Accept header value
50     * @param self[] $supportedTypes an array of enum cases the server supports
51     *
52     * @return self|null the best match, or null if no acceptable type exists
53     */
54    public static function getBestMatch(string $acceptHeader, array $supportedTypes): ?self
55    {
56        $clientPreferences = self::parseHeader($acceptHeader);
57
58        foreach ($clientPreferences as $preference) {
59            foreach ($supportedTypes as $supported) {
60                if (self::matches($preference['type'], $supported->value)) {
61                    return $supported;
62                }
63            }
64        }
65
66        return null;
67    }
68
69    /**
70     * Parses the Accept header string into a sorted list of client preferences.
71     *
72     * This method MUST:
73     * - Extract MIME types from the header string.
74     * - Parse quality factors (q-values), defaulting to 1.0 when omitted.
75     * - Calculate specificity, which SHALL be used as a secondary sort criterion.
76     * - Sort results by descending q-value and descending specificity.
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     * This value SHALL be used to sort MIME types when q-values are equal.
126     *
127     * @param string $type the MIME type to evaluate
128     *
129     * @return int the calculated specificity score
130     */
131    private static function calculateSpecificity(string $type): int
132    {
133        if ('*/*' === $type) {
134            return 0;
135        }
136
137        if (str_ends_with($type, '/*')) {
138            return 1;
139        }
140
141        return 2;
142    }
143
144    /**
145     * Determines whether a client-preferred type matches a server-supported type.
146     *
147     * This method MUST apply wildcard matching rules as defined in HTTP
148     * content negotiation:
149     * - "* /*" MUST match any type.
150     * - A full exact match MUST be treated as the highest priority.
151     * - A "type/*" wildcard MUST match any subtype within the given type.
152     *
153     * @param string $preference the MIME type from the client preference list
154     * @param string $supported the server-supported MIME type
155     *
156     * @return bool true if the supported type matches the preference
157     */
158    private static function matches(string $preference, string $supported): bool
159    {
160        if ('*/*' === $preference || $preference === $supported) {
161            return true;
162        }
163
164        if (str_ends_with($preference, '/*')) {
165            $prefix = strtok($preference, '/');
166            if (str_starts_with($supported, $prefix . '/')) {
167                return true;
168            }
169        }
170
171        return false;
172    }
173}