@@ -42,31 +42,195 @@ class HeaderSelector
4242 if ($accept !== null) {
4343 $headers [' Accept' ] = $accept ;
4444 }
45- if(!$isMultipart) {
45+
46+ if (!$isMultipart) {
4647 if ($contentType === ' ' ) {
4748 $contentType = ' application/json' ;
4849 }
50+
4951 $headers['Content-Type'] = $contentType;
5052 }
5153
5254 return $headers;
5355 }
5456
5557 /**
56- * Return the header 'Accept' based on an array of Accept provided
58+ * Return the header 'Accept' based on an array of Accept provided.
5759 *
5860 * @param string[] $accept Array of header
5961 *
6062 * @return null|string Accept (e.g. application/json)
6163 */
62- private function selectAcceptHeader($accept)
64+ private function selectAcceptHeader(array $accept): ?string
6365 {
64- if (count($accept ) === 0 || (count($accept ) === 1 && $accept [0] === ' ' )) {
66+ # filter out empty entries
67+ $accept = array_filter($accept );
68+
69+ if (count($accept ) === 0) {
6570 return null;
66- } elseif ($jsonAccept = preg_grep('~(?i)^(application/json|[^;/ \t]+/[^;/ \t]+[+]json)[ \t]*(;.*)?$~', $accept)) {
67- return implode(' ,' , $jsonAccept );
68- } else {
71+ }
72+
73+ # If there's only one Accept header, just use it
74+ if (count($accept) === 1) {
75+ return reset($accept );
76+ }
77+
78+ # If none of the available Accept headers is of type "json", then just use all them
79+ $headersWithJson = preg_grep('~(?i)^(application/json|[^;/ \t]+/[^;/ \t]+[+]json)[ \t]*(;.*)?$~', $accept);
80+ if (count($headersWithJson) === 0) {
6981 return implode(' ,' , $accept );
7082 }
83+
84+ # If we got here, then we need add quality values (weight), as described in IETF RFC 9110, Items 12.4.2/12.5.1,
85+ # to give the highest priority to json-like headers - recalculating the existing ones, if needed
86+ return $this->getAcceptHeaderWithAdjustedWeight($accept, $headersWithJson);
87+ }
88+
89+ /**
90+ * Create an Accept header string from the given "Accept" headers array, recalculating all weights
91+ *
92+ * @param string[] $accept Array of Accept Headers
93+ * @param string[] $headersWithJson Array of Accept Headers of type "json"
94+ *
95+ * @return string "Accept" Header (e.g. "application/json, text/html; q=0.9")
96+ */
97+ private function getAcceptHeaderWithAdjustedWeight(array $accept, array $headersWithJson): string
98+ {
99+ $processedHeaders = [
100+ ' withApplicationJson' => [],
101+ ' withJson' => [],
102+ ' withoutJson' => [],
103+ ];
104+
105+ foreach ($accept as $header ) {
106+
107+ $headerData = $this -> getHeaderAndWeight ($header );
108+
109+ if (stripos($headerData [' header' ], ' application/json' ) === 0) {
110+ $processedHeaders [' withApplicationJson' ][] = $headerData ;
111+ } elseif (in_array($header, $headersWithJson, true)) {
112+ $processedHeaders [' withJson' ][] = $headerData ;
113+ } else {
114+ $processedHeaders [' withoutJson' ][] = $headerData ;
115+ }
116+ }
117+
118+ $acceptHeaders = [];
119+ $currentWeight = 1000;
120+
121+ $hasMoreThan28Headers = count($accept) > 28;
122+
123+ foreach($processedHeaders as $headers) {
124+ if (count($headers ) > 0) {
125+ $acceptHeaders [] = $this -> adjustWeight ($headers , $currentWeight , $hasMoreThan28Headers );
126+ }
127+ }
128+
129+ $acceptHeaders = array_merge(...$acceptHeaders);
130+
131+ return implode(',', $acceptHeaders);
132+ }
133+
134+ /**
135+ * Given an Accept header, returns an associative array splitting the header and its weight
136+ *
137+ * @param string $header "Accept" Header
138+ *
139+ * @return array with the header and its weight
140+ */
141+ private function getHeaderAndWeight(string $header): array
142+ {
143+ # matches headers with weight, splitting the header and the weight in $outputArray
144+ if (preg_match(' /(.*);\s *q=(1(?:\. 0+)?|0\.\d +)$/' , $header , $outputArray ) === 1) {
145+ $headerData = [
146+ ' header' => $outputArray [1],
147+ ' weight' => (int)($outputArray [2] * 1000),
148+ ];
149+ } else {
150+ $headerData = [
151+ ' header' => trim($header ),
152+ ' weight' => 1000,
153+ ];
154+ }
155+
156+ return $headerData;
157+ }
158+
159+ /**
160+ * @param array[] $headers
161+ * @param float $currentWeight
162+ * @param bool $hasMoreThan28Headers
163+ * @return string[] array of adjusted "Accept" headers
164+ */
165+ private function adjustWeight(array $headers, float & $currentWeight, bool $hasMoreThan28Headers): array
166+ {
167+ usort($headers , function (array $a , array $b ) {
168+ return $b [' weight' ] - $a [' weight' ];
169+ } );
170+
171+ $acceptHeaders = [];
172+ foreach ($headers as $index => $header) {
173+ if ($index > 0 && $headers [$index - 1][' weight' ] > $header [' weight' ])
174+ {
175+ $currentWeight = $this -> getNextWeight ($currentWeight , $hasMoreThan28Headers );
176+ }
177+
178+ $weight = $currentWeight;
179+
180+ $acceptHeaders[] = $this->buildAcceptHeader($header['header'], $weight);
181+ }
182+
183+ $currentWeight = $this->getNextWeight($currentWeight, $hasMoreThan28Headers);
184+
185+ return $acceptHeaders;
186+ }
187+
188+ /**
189+ * @param string $header
190+ * @param int $weight
191+ * @return string
192+ */
193+ private function buildAcceptHeader(string $header, int $weight): string
194+ {
195+ if ($weight === 1000) {
196+ return $header ;
197+ }
198+
199+ return trim($header, '; ') . ';q=' . rtrim(sprintf('%0.3f', $weight / 1000), '0');
200+ }
201+
202+ /**
203+ * Calculate the next weight, based on the current one.
204+ *
205+ * If there are less than 28 "Accept" headers, the weights will be decreased by 1 on its highest significant digit, using the
206+ * following formula:
207+ *
208+ * next weight = current weight - 10 ^ (floor(log(current weight - 1)))
209+ *
210+ * ( current weight minus ( 10 raised to the power of ( floor of (log to the base 10 of ( current weight minus 1 ) ) ) ) )
211+ *
212+ * Starting from 1000, this generates the following series:
213+ *
214+ * 1000, 900, 800, 700, 600, 500, 400, 300, 200, 100, 90, 80, 70, 60, 50, 40, 30, 20, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1
215+ *
216+ * The resulting quality codes are closer to the average "normal" usage of them (like "q=0.9", "q=0.8" and so on), but it only works
217+ * if there is a maximum of 28 "Accept" headers. If we have more than that (which is extremely unlikely), then we fall back to a 1-by-1
218+ * decrement rule, which will result in quality codes like "q=0.999", "q=0.998" etc.
219+ *
220+ * @param int $currentWeight varying from 1 to 1000 (will be divided by 1000 to build the quality value)
221+ * @param bool $hasMoreThan28Headers
222+ * @return int
223+ */
224+ public function getNextWeight(int $currentWeight, bool $hasMoreThan28Headers): int
225+ {
226+ if ($currentWeight <= 1) {
227+ return 1;
228+ }
229+
230+ if ($hasMoreThan28Headers) {
231+ return $currentWeight - 1;
232+ }
233+
234+ return $currentWeight - 10 ** floor( log10($currentWeight - 1) );
71235 }
72236}
0 commit comments