diff --git a/CurlHttpClient.php b/CurlHttpClient.php index e74e026..4446a03 100644 --- a/CurlHttpClient.php +++ b/CurlHttpClient.php @@ -51,6 +51,9 @@ final class CurlHttpClient implements HttpClientInterface, LoggerAwareInterface, private ?LoggerInterface $logger = null; + private int $maxHostConnections; + private int $maxPendingPushes; + /** * An internal object to share state between the client and its responses. */ @@ -69,18 +72,22 @@ public function __construct(array $defaultOptions = [], int $maxHostConnections throw new \LogicException('You cannot use the "Symfony\Component\HttpClient\CurlHttpClient" as the "curl" extension is not installed.'); } + $this->maxHostConnections = $maxHostConnections; + $this->maxPendingPushes = $maxPendingPushes; + $this->defaultOptions['buffer'] ??= self::shouldBuffer(...); if ($defaultOptions) { [, $this->defaultOptions] = self::prepareRequest(null, null, $defaultOptions, $this->defaultOptions); } - - $this->multi = new CurlClientState($maxHostConnections, $maxPendingPushes); } public function setLogger(LoggerInterface $logger): void { - $this->logger = $this->multi->logger = $logger; + $this->logger = $logger; + if (isset($this->multi)) { + $this->multi->logger = $logger; + } } /** @@ -88,6 +95,8 @@ public function setLogger(LoggerInterface $logger): void */ public function request(string $method, string $url, array $options = []): ResponseInterface { + $multi = $this->ensureState(); + [$url, $options] = self::prepareRequest($method, $url, $options, $this->defaultOptions); $scheme = $url['scheme']; $authority = $url['authority']; @@ -165,24 +174,24 @@ public function request(string $method, string $url, array $options = []): Respo } // curl's resolve feature varies by host:port but ours varies by host only, let's handle this with our own DNS map - if (isset($this->multi->dnsCache->hostnames[$host])) { - $options['resolve'] += [$host => $this->multi->dnsCache->hostnames[$host]]; + if (isset($multi->dnsCache->hostnames[$host])) { + $options['resolve'] += [$host => $multi->dnsCache->hostnames[$host]]; } - if ($options['resolve'] || $this->multi->dnsCache->evictions) { + if ($options['resolve'] || $multi->dnsCache->evictions) { // First reset any old DNS cache entries then add the new ones - $resolve = $this->multi->dnsCache->evictions; - $this->multi->dnsCache->evictions = []; + $resolve = $multi->dnsCache->evictions; + $multi->dnsCache->evictions = []; if ($resolve && 0x072A00 > CurlClientState::$curlVersion['version_number']) { // DNS cache removals require curl 7.42 or higher - $this->multi->reset(); + $multi->reset(); } foreach ($options['resolve'] as $host => $ip) { $resolve[] = null === $ip ? "-$host:$port" : "$host:$port:$ip"; - $this->multi->dnsCache->hostnames[$host] = $ip; - $this->multi->dnsCache->removals["-$host:$port"] = "-$host:$port"; + $multi->dnsCache->hostnames[$host] = $ip; + $multi->dnsCache->removals["-$host:$port"] = "-$host:$port"; } $curlopts[\CURLOPT_RESOLVE] = $resolve; @@ -285,8 +294,8 @@ public function request(string $method, string $url, array $options = []): Respo $curlopts += $options['extra']['curl']; } - if ($pushedResponse = $this->multi->pushedResponses[$url] ?? null) { - unset($this->multi->pushedResponses[$url]); + if ($pushedResponse = $multi->pushedResponses[$url] ?? null) { + unset($multi->pushedResponses[$url]); if (self::acceptPushForRequest($method, $options, $pushedResponse)) { $this->logger?->debug(sprintf('Accepting pushed response: "%s %s"', $method, $url)); @@ -294,7 +303,7 @@ public function request(string $method, string $url, array $options = []): Respo // Reinitialize the pushed response with request's options $ch = $pushedResponse->handle; $pushedResponse = $pushedResponse->response; - $pushedResponse->__construct($this->multi, $url, $options, $this->logger); + $pushedResponse->__construct($multi, $url, $options, $this->logger); } else { $this->logger?->debug(sprintf('Rejecting pushed response: "%s"', $url)); $pushedResponse = null; @@ -304,7 +313,7 @@ public function request(string $method, string $url, array $options = []): Respo if (!$pushedResponse) { $ch = curl_init(); $this->logger?->info(sprintf('Request: "%s %s"', $method, $url)); - $curlopts += [\CURLOPT_SHARE => $this->multi->share]; + $curlopts += [\CURLOPT_SHARE => $multi->share]; } foreach ($curlopts as $opt => $value) { @@ -314,7 +323,7 @@ public function request(string $method, string $url, array $options = []): Respo } } - return $pushedResponse ?? new CurlResponse($this->multi, $ch, $options, $this->logger, $method, self::createRedirectResolver($options, $host, $port), CurlClientState::$curlVersion['version_number'], $url); + return $pushedResponse ?? new CurlResponse($multi, $ch, $options, $this->logger, $method, self::createRedirectResolver($options, $host, $port), CurlClientState::$curlVersion['version_number'], $url); } public function stream(ResponseInterface|iterable $responses, ?float $timeout = null): ResponseStreamInterface @@ -323,9 +332,11 @@ public function stream(ResponseInterface|iterable $responses, ?float $timeout = $responses = [$responses]; } - if ($this->multi->handle instanceof \CurlMultiHandle) { + $multi = $this->ensureState(); + + if ($multi->handle instanceof \CurlMultiHandle) { $active = 0; - while (\CURLM_CALL_MULTI_PERFORM === curl_multi_exec($this->multi->handle, $active)) { + while (\CURLM_CALL_MULTI_PERFORM === curl_multi_exec($multi->handle, $active)) { } } @@ -334,7 +345,9 @@ public function stream(ResponseInterface|iterable $responses, ?float $timeout = public function reset(): void { - $this->multi->reset(); + if (isset($this->multi)) { + $this->multi->reset(); + } } /** @@ -434,6 +447,16 @@ private static function createRedirectResolver(array $options, string $host, int }; } + private function ensureState(): CurlClientState + { + if (!isset($this->multi)) { + $this->multi = new CurlClientState($this->maxHostConnections, $this->maxPendingPushes); + $this->multi->logger = $this->logger; + } + + return $this->multi; + } + private function findConstantName(int $opt): ?string { $constants = array_filter(get_defined_constants(), static fn ($v, $k) => $v === $opt && 'C' === $k[0] && (str_starts_with($k, 'CURLOPT_') || str_starts_with($k, 'CURLINFO_')), \ARRAY_FILTER_USE_BOTH); diff --git a/Response/JsonMockResponse.php b/Response/JsonMockResponse.php index 66372aa..9372dbe 100644 --- a/Response/JsonMockResponse.php +++ b/Response/JsonMockResponse.php @@ -21,7 +21,7 @@ class JsonMockResponse extends MockResponse public function __construct(mixed $body = [], array $info = []) { try { - $json = json_encode($body, \JSON_THROW_ON_ERROR); + $json = json_encode($body, \JSON_THROW_ON_ERROR | \JSON_PRESERVE_ZERO_FRACTION); } catch (\JsonException $e) { throw new InvalidArgumentException('JSON encoding failed: '.$e->getMessage(), $e->getCode(), $e); } diff --git a/Tests/CurlHttpClientTest.php b/Tests/CurlHttpClientTest.php index 266b539..20cf7ef 100644 --- a/Tests/CurlHttpClientTest.php +++ b/Tests/CurlHttpClientTest.php @@ -58,8 +58,8 @@ public function testHandleIsReinitOnReset() { $httpClient = $this->getHttpClient(__FUNCTION__); - $r = new \ReflectionProperty($httpClient, 'multi'); - $clientState = $r->getValue($httpClient); + $r = new \ReflectionMethod($httpClient, 'ensureState'); + $clientState = $r->invoke($httpClient); $initialShareId = $clientState->share; $httpClient->reset(); self::assertNotSame($initialShareId, $clientState->share); diff --git a/Tests/DataCollector/HttpClientDataCollectorTest.php b/Tests/DataCollector/HttpClientDataCollectorTest.php index a749310..91ec7ea 100644 --- a/Tests/DataCollector/HttpClientDataCollectorTest.php +++ b/Tests/DataCollector/HttpClientDataCollectorTest.php @@ -24,6 +24,11 @@ public static function setUpBeforeClass(): void TestHttpServer::start(); } + public static function tearDownAfterClass(): void + { + TestHttpServer::stop(); + } + public function testItCollectsRequestCount() { $httpClient1 = $this->httpClientThatHasTracedRequests([ diff --git a/Tests/HttpClientTraitTest.php b/Tests/HttpClientTraitTest.php index 613d80c..3e81f42 100644 --- a/Tests/HttpClientTraitTest.php +++ b/Tests/HttpClientTraitTest.php @@ -72,10 +72,8 @@ public function testPrepareRequestWithBodyIsArray() public function testNormalizeBodyMultipart() { $file = fopen('php://memory', 'r+'); - stream_context_set_option($file, ['http' => [ - 'filename' => 'test.txt', - 'content_type' => 'text/plain', - ]]); + stream_context_set_option($file, 'http', 'filename', 'test.txt'); + stream_context_set_option($file, 'http', 'content_type', 'text/plain'); fwrite($file, 'foobarbaz'); rewind($file); diff --git a/Tests/HttplugClientTest.php b/Tests/HttplugClientTest.php index 247588c..182d529 100644 --- a/Tests/HttplugClientTest.php +++ b/Tests/HttplugClientTest.php @@ -32,6 +32,11 @@ public static function setUpBeforeClass(): void TestHttpServer::start(); } + public static function tearDownAfterClass(): void + { + TestHttpServer::stop(); + } + public function testSendRequest() { $client = new HttplugClient(new NativeHttpClient()); diff --git a/Tests/Psr18ClientTest.php b/Tests/Psr18ClientTest.php index d4bae3a..d1f4deb 100644 --- a/Tests/Psr18ClientTest.php +++ b/Tests/Psr18ClientTest.php @@ -28,6 +28,11 @@ public static function setUpBeforeClass(): void TestHttpServer::start(); } + public static function tearDownAfterClass(): void + { + TestHttpServer::stop(); + } + public function testSendRequest() { $factory = new Psr17Factory(); diff --git a/Tests/Response/JsonMockResponseTest.php b/Tests/Response/JsonMockResponseTest.php index b371c08..768353b 100644 --- a/Tests/Response/JsonMockResponseTest.php +++ b/Tests/Response/JsonMockResponseTest.php @@ -59,6 +59,22 @@ public function testJsonEncodeString() $this->assertSame('application/json', $response->getHeaders()['content-type'][0]); } + public function testJsonEncodeFloat() + { + $client = new MockHttpClient(new JsonMockResponse([ + 'foo' => 1.23, + 'ccc' => 1.0, + 'baz' => 10., + ])); + $response = $client->request('GET', 'https://symfony.com'); + + $this->assertSame([ + 'foo' => 1.23, + 'ccc' => 1., + 'baz' => 10., + ], $response->toArray()); + } + /** * @dataProvider responseHeadersProvider */ diff --git a/Tests/RetryableHttpClientTest.php b/Tests/RetryableHttpClientTest.php index ba9504a..a0e39cc 100644 --- a/Tests/RetryableHttpClientTest.php +++ b/Tests/RetryableHttpClientTest.php @@ -27,6 +27,11 @@ class RetryableHttpClientTest extends TestCase { + public static function tearDownAfterClass(): void + { + TestHttpServer::stop(); + } + public function testRetryOnError() { $client = new RetryableHttpClient( diff --git a/Tests/TraceableHttpClientTest.php b/Tests/TraceableHttpClientTest.php index cf437a6..b6a2c03 100644 --- a/Tests/TraceableHttpClientTest.php +++ b/Tests/TraceableHttpClientTest.php @@ -29,6 +29,11 @@ public static function setUpBeforeClass(): void TestHttpServer::start(); } + public static function tearDownAfterClass(): void + { + TestHttpServer::stop(); + } + public function testItTracesRequest() { $httpClient = $this->createMock(HttpClientInterface::class); diff --git a/composer.json b/composer.json index 278a9b2..dd34b41 100644 --- a/composer.json +++ b/composer.json @@ -24,7 +24,7 @@ "require": { "php": ">=8.2", "psr/log": "^1|^2|^3", - "symfony/http-client-contracts": "^3", + "symfony/http-client-contracts": "^3.4.1", "symfony/service-contracts": "^2.5|^3" }, "require-dev": { @@ -32,7 +32,7 @@ "amphp/http-client": "^4.2.1", "amphp/http-tunnel": "^1.0", "amphp/socket": "^1.1", - "guzzlehttp/promises": "^1.4", + "guzzlehttp/promises": "^1.4|^2.0", "nyholm/psr7": "^1.0", "php-http/httplug": "^1.0|^2.0", "psr/http-client": "^1.0",