src/Fapi/HttpClient/CurlHttpClient.php
<?php declare(strict_types = 1);
namespace Fapi\HttpClient;
use Composer\CaBundle\CaBundle;
use CurlHandle;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
use function assert;
use function curl_close;
use function curl_errno;
use function curl_error;
use function curl_exec;
use function curl_getinfo;
use function curl_init;
use function curl_setopt;
use function curl_setopt_array;
use function explode;
use function extension_loaded;
use function is_array;
use function is_dir;
use function is_link;
use function is_string;
use function preg_match;
use function readlink;
use function substr;
use function trim;
use const CURLE_OPERATION_TIMEOUTED;
use const CURLINFO_HEADER_SIZE;
use const CURLINFO_HTTP_CODE;
use const CURLOPT_CAINFO;
use const CURLOPT_CAPATH;
use const CURLOPT_CONNECTTIMEOUT;
use const CURLOPT_CUSTOMREQUEST;
use const CURLOPT_ENCODING;
use const CURLOPT_HEADER;
use const CURLOPT_HTTPHEADER;
use const CURLOPT_POSTFIELDS;
use const CURLOPT_RETURNTRANSFER;
use const CURLOPT_SSL_VERIFYHOST;
use const CURLOPT_SSL_VERIFYPEER;
use const CURLOPT_SSLCERT;
use const CURLOPT_SSLCERTPASSWD;
use const CURLOPT_SSLKEY;
use const CURLOPT_SSLKEYPASSWD;
use const CURLOPT_TIMEOUT;
use const CURLOPT_URL;
class CurlHttpClient implements IHttpClient
{
public function __construct()
{
if (!extension_loaded('curl')) {
throw new NotSupportedException('cURL extension must be installed.');
}
}
public function sendRequest(RequestInterface $request): ResponseInterface
{
$handle = $this->initializeCurl($request);
$this->processOptions($request, $handle);
$request = $this->processHeaders($request, $handle);
if ($request->getBody()->getSize() > 0) {
curl_setopt($handle, CURLOPT_POSTFIELDS, (string) $request->getBody());
}
$result = curl_exec($handle);
assert(is_string($result) || $result === false);
if ($result === false) {
$error = curl_error($handle);
$errno = curl_errno($handle);
curl_close($handle);
if ($errno === CURLE_OPERATION_TIMEOUTED) {
throw new TimeLimitExceededException($error, $errno);
}
throw new HttpClientException($error, $errno);
}
$headerSize = curl_getinfo($handle, CURLINFO_HEADER_SIZE);
$header = substr($result, 0, $headerSize);
$headers = $this->parseHeaders($header);
$body = substr($result, $headerSize);
$statusCode = curl_getinfo($handle, CURLINFO_HTTP_CODE);
$httpResponse = new HttpResponse($statusCode, $headers, $body);
curl_close($handle);
return $httpResponse;
}
private function initializeCurl(RequestInterface $httpRequest): CurlHandle
{
$handle = curl_init();
curl_setopt_array($handle, [
CURLOPT_URL => (string) $httpRequest->getUri(),
CURLOPT_CUSTOMREQUEST => $httpRequest->getMethod(),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HEADER => true,
CURLOPT_ENCODING => 'gzip',
]);
return $handle;
}
private function processOptions(RequestInterface $request, CurlHandle $handle): void
{
if ($request->hasHeader('timeout')) {
curl_setopt($handle, CURLOPT_TIMEOUT, (int) $request->getHeaderLine('timeout'));
}
if ($request->hasHeader('connect_timeout')) {
curl_setopt($handle, CURLOPT_CONNECTTIMEOUT, (int) $request->getHeaderLine('connect_timeout'));
}
if ($request->hasHeader('cert')) {
curl_setopt($handle, CURLOPT_SSLCERT, $request->getHeader('cert')[0]);
if ((bool) ($request->getHeader('cert')[1] ?? false)) {
curl_setopt($handle, CURLOPT_SSLCERTPASSWD, $request->getHeader('cert')[1]);
}
}
if ($request->hasHeader('ssl_key')) {
curl_setopt($handle, CURLOPT_SSLKEY, $request->getHeader('ssl_key')[0]);
if ((bool) ($request->getHeader('ssl_key')[1] ?? false)) {
curl_setopt($handle, CURLOPT_SSLKEYPASSWD, $request->getHeader('ssl_key')[1]);
}
}
$this->processVerifyOption($request->getHeaderLine('verify'), $handle);
}
private function processVerifyOption(string $verify, CurlHandle $handle): void
{
if ((bool) $verify) {
$caPathOrFile = CaBundle::getSystemCaRootBundlePath();
if (is_dir($caPathOrFile) || (is_link($caPathOrFile) && is_dir((string) readlink($caPathOrFile)))) {
curl_setopt($handle, CURLOPT_CAPATH, $caPathOrFile);
} else {
curl_setopt($handle, CURLOPT_CAINFO, $caPathOrFile);
}
return;
}
curl_setopt($handle, CURLOPT_SSL_VERIFYHOST, 0);
curl_setopt($handle, CURLOPT_SSL_VERIFYPEER, false);
}
private function processHeaders(RequestInterface $request, CurlHandle $handle): RequestInterface
{
$request = $request->withoutHeader('timeout')
->withoutHeader('connect_timeout')
->withoutHeader('verify')
->withoutHeader('cert')
->withoutHeader('ssl_key');
curl_setopt($handle, CURLOPT_HTTPHEADER, $this->formatHeaders($request->getHeaders()));
return $request;
}
/**
* @param array<mixed> $headers
* @return array<mixed>
*/
private function formatHeaders(array $headers): array
{
$result = [];
foreach ($headers as $key => $values) {
$values = is_array($values)
? $values
: [$values];
foreach ($values as $value) {
$result[] = $key . ': ' . $value;
}
}
return $result;
}
/**
* @return array<mixed>
*/
private function parseHeaders(string $header): array
{
$headers = [];
foreach (explode("\n", $header) as $line) {
$line = trim($line);
preg_match('#^([A-Za-z\-]+): (.*)\z#', $line, $match);
if (!(bool) $match) {
continue;
}
$headers[$match[1]][] = $match[2];
}
return $headers;
}
}