diff --git a/modules/openapi-generator/src/main/resources/php/HeaderSelector.mustache b/modules/openapi-generator/src/main/resources/php/HeaderSelector.mustache index 75e86d3a00a..1921b061015 100644 --- a/modules/openapi-generator/src/main/resources/php/HeaderSelector.mustache +++ b/modules/openapi-generator/src/main/resources/php/HeaderSelector.mustache @@ -42,10 +42,12 @@ class HeaderSelector if ($accept !== null) { $headers['Accept'] = $accept; } - if(!$isMultipart) { + + if (!$isMultipart) { if($contentType === '') { $contentType = 'application/json'; } + $headers['Content-Type'] = $contentType; } @@ -53,20 +55,182 @@ class HeaderSelector } /** - * Return the header 'Accept' based on an array of Accept provided + * Return the header 'Accept' based on an array of Accept provided. * * @param string[] $accept Array of header * * @return null|string Accept (e.g. application/json) */ - private function selectAcceptHeader($accept) + private function selectAcceptHeader(array $accept): ?string { - if (count($accept) === 0 || (count($accept) === 1 && $accept[0] === '')) { + # filter out empty entries + $accept = array_filter($accept); + + if (count($accept) === 0) { return null; - } elseif ($jsonAccept = preg_grep('~(?i)^(application/json|[^;/ \t]+/[^;/ \t]+[+]json)[ \t]*(;.*)?$~', $accept)) { - return implode(',', $jsonAccept); - } else { + } + + # If there's only one Accept header, just use it + if (count($accept) === 1) { + return reset($accept); + } + + # If none of the available Accept headers is of type "json", then just use all them + $headersWithJson = preg_grep('~(?i)^(application/json|[^;/ \t]+/[^;/ \t]+[+]json)[ \t]*(;.*)?$~', $accept); + if (count($headersWithJson) === 0) { return implode(',', $accept); } + + # If we got here, then we need add quality values (weight), as described in IETF RFC 9110, Items 12.4.2/12.5.1, + # to give the highest priority to json-like headers - recalculating the existing ones, if needed + return $this->getAcceptHeaderWithAdjustedWeight($accept, $headersWithJson); + } + + /** + * Create an Accept header string from the given "Accept" headers array, recalculating all weights + * + * @param string[] $accept Array of Accept Headers + * @param string[] $headersWithJson Array of Accept Headers of type "json" + * + * @return string "Accept" Header (e.g. "application/json, text/html; q=0.9") + */ + private function getAcceptHeaderWithAdjustedWeight(array $accept, array $headersWithJson): string + { + $processedHeaders = [ + 'withApplicationJson' => [], + 'withJson' => [], + 'withoutJson' => [], + ]; + + foreach ($accept as $header) { + + $headerData = $this->getHeaderAndWeight($header); + + if (stripos($headerData['header'], 'application/json') === 0) { + $processedHeaders['withApplicationJson'][] = $headerData; + } elseif (in_array($header, $headersWithJson, true)) { + $processedHeaders['withJson'][] = $headerData; + } else { + $processedHeaders['withoutJson'][] = $headerData; + } + } + + $acceptHeaders = []; + $currentWeight = 1000; + + $hasMoreThan28Headers = count($accept) > 28; + + foreach($processedHeaders as $headers) { + if (count($headers) > 0) { + $acceptHeaders[] = $this->adjustWeight($headers, $currentWeight, $hasMoreThan28Headers); + } + } + + $acceptHeaders = array_merge(...$acceptHeaders); + + return implode(',', $acceptHeaders); + } + + /** + * Given an Accept header, returns an associative array splitting the header and its weight + * + * @param string $header "Accept" Header + * + * @return array with the header and its weight + */ + private function getHeaderAndWeight(string $header): array + { + # matches headers with weight, splitting the header and the weight in $outputArray + if (preg_match('/(.*);\s*q=(1(?:\.0+)?|0\.\d+)$/', $header, $outputArray) === 1) { + $headerData = [ + 'header' => $outputArray[1], + 'weight' => (int)($outputArray[2] * 1000), + ]; + } else { + $headerData = [ + 'header' => trim($header), + 'weight' => 1000, + ]; + } + + return $headerData; + } + + /** + * @param array[] $headers + * @param float $currentWeight + * @param bool $hasMoreThan28Headers + * @return string[] array of adjusted "Accept" headers + */ + private function adjustWeight(array $headers, float &$currentWeight, bool $hasMoreThan28Headers): array + { + usort($headers, function (array $a, array $b) { + return $b['weight'] - $a['weight']; + }); + + $acceptHeaders = []; + foreach ($headers as $index => $header) { + if($index > 0 && $headers[$index - 1]['weight'] > $header['weight']) + { + $currentWeight = $this->getNextWeight($currentWeight, $hasMoreThan28Headers); + } + + $weight = $currentWeight; + + $acceptHeaders[] = $this->buildAcceptHeader($header['header'], $weight); + } + + $currentWeight = $this->getNextWeight($currentWeight, $hasMoreThan28Headers); + + return $acceptHeaders; + } + + /** + * @param string $header + * @param int $weight + * @return string + */ + private function buildAcceptHeader(string $header, int $weight): string + { + if($weight === 1000) { + return $header; + } + + return trim($header, '; ') . ';q=' . rtrim(sprintf('%0.3f', $weight / 1000), '0'); + } + + /** + * Calculate the next weight, based on the current one. + * + * If there are less than 28 "Accept" headers, the weights will be decreased by 1 on its highest significant digit, using the + * following formula: + * + * next weight = current weight - 10 ^ (floor(log(current weight - 1))) + * + * ( current weight minus ( 10 raised to the power of ( floor of (log to the base 10 of ( current weight minus 1 ) ) ) ) ) + * + * Starting from 1000, this generates the following series: + * + * 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 + * + * 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 + * 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 + * decrement rule, which will result in quality codes like "q=0.999", "q=0.998" etc. + * + * @param int $currentWeight varying from 1 to 1000 (will be divided by 1000 to build the quality value) + * @param bool $hasMoreThan28Headers + * @return int + */ + public function getNextWeight(int $currentWeight, bool $hasMoreThan28Headers): int + { + if ($currentWeight <= 1) { + return 1; + } + + if ($hasMoreThan28Headers) { + return $currentWeight - 1; + } + + return $currentWeight - 10 ** floor( log10($currentWeight - 1) ); } } diff --git a/samples/client/petstore/php/OpenAPIClient-php/lib/HeaderSelector.php b/samples/client/petstore/php/OpenAPIClient-php/lib/HeaderSelector.php index 769fdb8d0c1..fb25bf88bcf 100644 --- a/samples/client/petstore/php/OpenAPIClient-php/lib/HeaderSelector.php +++ b/samples/client/petstore/php/OpenAPIClient-php/lib/HeaderSelector.php @@ -51,10 +51,12 @@ class HeaderSelector if ($accept !== null) { $headers['Accept'] = $accept; } - if(!$isMultipart) { + + if (!$isMultipart) { if($contentType === '') { $contentType = 'application/json'; } + $headers['Content-Type'] = $contentType; } @@ -62,20 +64,182 @@ class HeaderSelector } /** - * Return the header 'Accept' based on an array of Accept provided + * Return the header 'Accept' based on an array of Accept provided. * * @param string[] $accept Array of header * * @return null|string Accept (e.g. application/json) */ - private function selectAcceptHeader($accept) + private function selectAcceptHeader(array $accept): ?string { - if (count($accept) === 0 || (count($accept) === 1 && $accept[0] === '')) { + # filter out empty entries + $accept = array_filter($accept); + + if (count($accept) === 0) { return null; - } elseif ($jsonAccept = preg_grep('~(?i)^(application/json|[^;/ \t]+/[^;/ \t]+[+]json)[ \t]*(;.*)?$~', $accept)) { - return implode(',', $jsonAccept); - } else { + } + + # If there's only one Accept header, just use it + if (count($accept) === 1) { + return reset($accept); + } + + # If none of the available Accept headers is of type "json", then just use all them + $headersWithJson = preg_grep('~(?i)^(application/json|[^;/ \t]+/[^;/ \t]+[+]json)[ \t]*(;.*)?$~', $accept); + if (count($headersWithJson) === 0) { return implode(',', $accept); } + + # If we got here, then we need add quality values (weight), as described in IETF RFC 9110, Items 12.4.2/12.5.1, + # to give the highest priority to json-like headers - recalculating the existing ones, if needed + return $this->getAcceptHeaderWithAdjustedWeight($accept, $headersWithJson); + } + + /** + * Create an Accept header string from the given "Accept" headers array, recalculating all weights + * + * @param string[] $accept Array of Accept Headers + * @param string[] $headersWithJson Array of Accept Headers of type "json" + * + * @return string "Accept" Header (e.g. "application/json, text/html; q=0.9") + */ + private function getAcceptHeaderWithAdjustedWeight(array $accept, array $headersWithJson): string + { + $processedHeaders = [ + 'withApplicationJson' => [], + 'withJson' => [], + 'withoutJson' => [], + ]; + + foreach ($accept as $header) { + + $headerData = $this->getHeaderAndWeight($header); + + if (stripos($headerData['header'], 'application/json') === 0) { + $processedHeaders['withApplicationJson'][] = $headerData; + } elseif (in_array($header, $headersWithJson, true)) { + $processedHeaders['withJson'][] = $headerData; + } else { + $processedHeaders['withoutJson'][] = $headerData; + } + } + + $acceptHeaders = []; + $currentWeight = 1000; + + $hasMoreThan28Headers = count($accept) > 28; + + foreach($processedHeaders as $headers) { + if (count($headers) > 0) { + $acceptHeaders[] = $this->adjustWeight($headers, $currentWeight, $hasMoreThan28Headers); + } + } + + $acceptHeaders = array_merge(...$acceptHeaders); + + return implode(',', $acceptHeaders); + } + + /** + * Given an Accept header, returns an associative array splitting the header and its weight + * + * @param string $header "Accept" Header + * + * @return array with the header and its weight + */ + private function getHeaderAndWeight(string $header): array + { + # matches headers with weight, splitting the header and the weight in $outputArray + if (preg_match('/(.*);\s*q=(1(?:\.0+)?|0\.\d+)$/', $header, $outputArray) === 1) { + $headerData = [ + 'header' => $outputArray[1], + 'weight' => (int)($outputArray[2] * 1000), + ]; + } else { + $headerData = [ + 'header' => trim($header), + 'weight' => 1000, + ]; + } + + return $headerData; + } + + /** + * @param array[] $headers + * @param float $currentWeight + * @param bool $hasMoreThan28Headers + * @return string[] array of adjusted "Accept" headers + */ + private function adjustWeight(array $headers, float &$currentWeight, bool $hasMoreThan28Headers): array + { + usort($headers, function (array $a, array $b) { + return $b['weight'] - $a['weight']; + }); + + $acceptHeaders = []; + foreach ($headers as $index => $header) { + if($index > 0 && $headers[$index - 1]['weight'] > $header['weight']) + { + $currentWeight = $this->getNextWeight($currentWeight, $hasMoreThan28Headers); + } + + $weight = $currentWeight; + + $acceptHeaders[] = $this->buildAcceptHeader($header['header'], $weight); + } + + $currentWeight = $this->getNextWeight($currentWeight, $hasMoreThan28Headers); + + return $acceptHeaders; + } + + /** + * @param string $header + * @param int $weight + * @return string + */ + private function buildAcceptHeader(string $header, int $weight): string + { + if($weight === 1000) { + return $header; + } + + return trim($header, '; ') . ';q=' . rtrim(sprintf('%0.3f', $weight / 1000), '0'); + } + + /** + * Calculate the next weight, based on the current one. + * + * If there are less than 28 "Accept" headers, the weights will be decreased by 1 on its highest significant digit, using the + * following formula: + * + * next weight = current weight - 10 ^ (floor(log(current weight - 1))) + * + * ( current weight minus ( 10 raised to the power of ( floor of (log to the base 10 of ( current weight minus 1 ) ) ) ) ) + * + * Starting from 1000, this generates the following series: + * + * 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 + * + * 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 + * 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 + * decrement rule, which will result in quality codes like "q=0.999", "q=0.998" etc. + * + * @param int $currentWeight varying from 1 to 1000 (will be divided by 1000 to build the quality value) + * @param bool $hasMoreThan28Headers + * @return int + */ + public function getNextWeight(int $currentWeight, bool $hasMoreThan28Headers): int + { + if ($currentWeight <= 1) { + return 1; + } + + if ($hasMoreThan28Headers) { + return $currentWeight - 1; + } + + return $currentWeight - 10 ** floor( log10($currentWeight - 1) ); } } diff --git a/samples/client/petstore/php/OpenAPIClient-php/tests/HeaderSelectorTest.php b/samples/client/petstore/php/OpenAPIClient-php/tests/HeaderSelectorTest.php index bbc54c5a78f..f5c804b8e3b 100644 --- a/samples/client/petstore/php/OpenAPIClient-php/tests/HeaderSelectorTest.php +++ b/samples/client/petstore/php/OpenAPIClient-php/tests/HeaderSelectorTest.php @@ -30,41 +30,134 @@ class HeaderSelectorTest extends TestCase */ public function headersProvider(): array { - return [ + $data = [ // array $accept, string $contentType, bool $isMultipart, array $expectedHeaders [ + # No Accept, Content-Type [], 'application/xml', false, ['Content-Type' => 'application/xml'], ], [ + # No Accept, Content-Type, multipart [], 'application/xml', true, [], ], [ + # single Accept, no Content-Type ['application/xml'], '', false, ['Accept' => 'application/xml', 'Content-Type' => 'application/json'], ], [ + # single Accept, no Content-Type, multipart ['application/xml'], '', true, ['Accept' => 'application/xml'], ], [ + # single Accept, Content-Type ['application/xml'], 'application/xml', false, ['Accept' => 'application/xml', 'Content-Type' => 'application/xml'], ], [ + # single Accept, Content-Type, multipart ['application/xml'], 'application/xml', true, ['Accept' => 'application/xml'], ], [ - ['application/xml', 'text/html'], 'application/xml', false, ['Accept' => 'application/xml,text/html', 'Content-Type' => 'application/xml'], - ], - [ - ['application/json', 'text/html'], 'application/xml', false, ['Accept' => 'application/json', 'Content-Type' => 'application/xml'], - ], - [ - ['text/html', 'application/json'], 'application/xml', false, ['Accept' => 'application/json', 'Content-Type' => 'application/xml'], - ], - [ + # single Accept with parameters, Content-Type with parameters ['application/json;format=flowed'], 'text/plain;format=fixed', false, ['Accept' => 'application/json;format=flowed', 'Content-Type' => 'text/plain;format=fixed'], ], + + # Tests for Accept headers - no change on Content-Type or multipart setting [ - ['text/html', 'application/json;format=flowed'], 'text/plain;format=fixed', false, ['Accept' => 'application/json;format=flowed', 'Content-Type' => 'text/plain;format=fixed'], + # Multiple Accept without Json + ['application/xml', 'text/html'], '', true, ['Accept' => 'application/xml,text/html'], ], + [ + # Multiple Accept with application/json + ['application/json', 'text/html'], '', true, ['Accept' => 'application/json,text/html;q=0.9'], + ], + [ + # Multiple Accept with json-like + ['text/html', 'application/xml+json'], '', true, ['Accept' => 'application/xml+json,text/html;q=0.9'], + ], + [ + # Multiple Accept with application/json and json-like + ['text/html', 'application/json', 'application/xml+json'], '', true, ['Accept' => 'application/json,application/xml+json;q=0.9,text/html;q=0.8'], + ], + [ + # Multiple Accept, changing priority to application/json + ['application/xml', 'application/json;q=0.9', 'text/plain;format=flowed;q=0.8'], '', true, ['Accept' => 'application/json,application/xml;q=0.9,text/plain;format=flowed;q=0.8'], + ], + [ + # Multiple Accept, same priority for two headers, one being application/json + ['application/xml', 'application/json', 'text/plain;format=flowed;q=0.9'], '', true, ['Accept' => 'application/json,application/xml;q=0.9,text/plain;format=flowed;q=0.8'], + ], + [ + # Multiple Accept, same priority for two headers, both being application/json + ['application/json', 'application/json;IEEE754Compatible=true', 'text/plain;format=flowed;q=0.9'], '', true, ['Accept' => 'application/json,application/json;IEEE754Compatible=true,text/plain;format=flowed;q=0.9'], + ], + [ + # Multiple Accept, same priority for three headers, two being application/json, with changing priority + ['application/json;q=0.9', 'application/json;IEEE754Compatible=true;q=0.9', 'application/xml', 'text/plain;format=flowed;q=0.9'], '', true, ['Accept' => 'application/json,application/json;IEEE754Compatible=true,application/xml;q=0.9,text/plain;format=flowed;q=0.8'], + ], + ]; + + # More than 28 Accept headers + $data[] = $this->createTestDataForLargeNumberOfAcceptHeaders(30); + + # More than 1000 Accept headers + $data[] = $this->createTestDataForLargeNumberOfAcceptHeaders(1000); + + return $data; + } + + /** + * @param int $numberOfHeaders + * @return array + */ + private function createTestDataForLargeNumberOfAcceptHeaders(int $numberOfHeaders): array + { + $acceptHeaders = ['application/json;q=0.9']; + $expected = ['application/json']; + for ($i=1; $i<$numberOfHeaders; $i++) { + $q = rtrim(sprintf('%0.3f', (1000 - $i) / 1000), '0'); + $acceptHeaders[] = "application/xml;option=$i;q=$q"; + $expected[] = "application/xml;option=$i;q=$q"; + } + + return [ + $acceptHeaders, + '', + true, + ['Accept' => implode(',', $expected)] + ]; + } + + /** + * @dataProvider nextWeightProvider + * @param int $currentWeight + * @param bool $bHasMoreThan28Headers + * @param int $expected + */ + public function testGetNextWeight(int $currentWeight, bool $bHasMoreThan28Headers, int $expected): void + { + $selector = new HeaderSelector(); + + self::assertEquals($expected, $selector->getNextWeight($currentWeight, $bHasMoreThan28Headers)); + } + + public function nextWeightProvider(): array + { + return [ + [1000, true, 999], + [999, true, 998], + [2, true, 1], + [1, true, 1], + [1000, false, 900], + [900, false, 800], + [200, false, 100], + [100, false, 90], + [90, false, 80], + [20, false, 10], + [10, false, 9], + [9, false, 8], + [2, false, 1], + [1, false, 1], + [0, false, 1], ]; } }