diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 7d664219a..792aa27c6 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -194,4 +194,7 @@ parameters: message: "#^Parameter \\#3 \\$depth of function json_encode expects int\\<1, max\\>, int given\\.$#" count: 1 path: src/Utils.php - + - + message: "#^Class CurlHandle not found\\.$#" + count: 3 + path: src/Handler/EasyHandle.php diff --git a/psalm-baseline.xml b/psalm-baseline.xml index 0693bddd3..326883f3d 100644 --- a/psalm-baseline.xml +++ b/psalm-baseline.xml @@ -118,6 +118,9 @@ void + + CurlHandle + resource|\CurlHandle diff --git a/src/Handler/CurlFactory.php b/src/Handler/CurlFactory.php index 431ed78ba..b9012f17c 100644 --- a/src/Handler/CurlFactory.php +++ b/src/Handler/CurlFactory.php @@ -147,7 +147,7 @@ public static function finish(callable $handler, EasyHandle $easy, CurlFactoryIn self::invokeStats($easy); } - if (!$easy->response || $easy->errno) { + if (!$easy->response || \CURLE_OK !== $easy->errno) { return self::finishError($handler, $easy, $factory); } @@ -192,7 +192,7 @@ private static function finishError(callable $handler, EasyHandle $easy, CurlFac $factory->release($easy); // Retry when nothing is present or when curl failed to rewind. - if (empty($easy->options['_err_message']) && (!$easy->errno || $easy->errno == 65)) { + if (empty($easy->options['_err_message']) && (!$easy->errno || $easy->errno == /* \CURLE_SEND_FAIL_REWIND */ 65)) { return self::retryFailedRewind($handler, $easy, $ctx); } diff --git a/src/Handler/EasyHandle.php b/src/Handler/EasyHandle.php index 1bc39f4b4..47128ae4d 100644 --- a/src/Handler/EasyHandle.php +++ b/src/Handler/EasyHandle.php @@ -11,6 +11,16 @@ /** * Represents a cURL easy handle and the data it populates. * + * @property resource|\CurlHandle $handle resource cURL resource + * @property StreamInterface $sink Where data is being written + * @property array $headers Received HTTP headers so far + * @property ResponseInterface|null $response Received response (if any) + * @property RequestInterface $request Request being sent + * @property array $options Request options + * @property int $errno int cURL error number + * @property \Throwable|null $onHeadersException Exception during on_headers (if any) + * @property \Throwable|null $createResponseException Exception during createResponse (if any) + * * @internal */ final class EasyHandle @@ -18,47 +28,52 @@ final class EasyHandle /** * @var resource|\CurlHandle cURL resource */ - public $handle; + private $handle; /** * @var StreamInterface Where data is being written */ - public $sink; + private $sink; /** - * @var array Received HTTP headers so far + * @var RequestInterface Request being sent */ - public $headers = []; + private $request; /** - * @var ResponseInterface|null Received response (if any) + * @var array Request options */ - public $response; + private $options = []; /** - * @var RequestInterface Request being sent + * @var int cURL error number (if any) */ - public $request; + private $errno = \CURLE_OK; /** - * @var array Request options + * @var array Received HTTP headers so far */ - public $options = []; + private $headers = []; /** - * @var int cURL error number (if any) + * @var ResponseInterface|null Received response (if any) */ - public $errno = 0; + private $response; /** * @var \Throwable|null Exception during on_headers (if any) */ - public $onHeadersException; + private $onHeadersException; + + /** + * @var \Throwable|null Exception during createResponse (if any) + */ + private $createResponseException; /** - * @var \Exception|null Exception during createResponse (if any) + * @var bool Tells if the EasyHandle has been initialized */ - public $createResponseException; + private $initialized = false; /** * Attach a response to the easy handle based on the received headers. @@ -98,15 +113,84 @@ public function createResponse(): void } /** - * @param string $name - * - * @return void + * @return mixed * * @throws \BadMethodCallException */ - public function __get($name) + public function &__get(string $name) { - $msg = $name === 'handle' ? 'The EasyHandle has been released' : 'Invalid property: '.$name; + if (('handle' !== $name && property_exists($this, $name)) || $this->initialized && isset($this->handle)) { + return $this->{$name}; + } + + $msg = $name === 'handle' + ? 'The EasyHandle '.($this->initialized ? 'has been released' : 'is not initialized') + : sprintf('Undefined property: %s::$%s', __CLASS__, $name); + throw new \BadMethodCallException($msg); } + + /** + * @param mixed $value + * + * @throws \UnexpectedValueException|\LogicException|\TypeError + */ + public function __set(string $name, $value): void + { + if ($this->initialized && !isset($this->handle)) { + throw new \UnexpectedValueException('The EasyHandle has been released, please use a new EasyHandle instead.'); + } + + if (in_array($name, ['response', 'initialized'], true)) { + throw new \LogicException(sprintf('Cannot set private property %s::$%s.', __CLASS__, $name)); + } + + if (in_array($name, ['errno', 'handle', 'headers', 'onHeadersException', 'createResponseException'], true)) { + if ('handle' === $name) { + if (isset($this->handle)) { + throw new \UnexpectedValueException(sprintf('Property %s::$%s is already set, please use a new EasyHandle instead.', __CLASS__, $name)); + } + + if (\PHP_VERSION_ID >= 80000) { + if (!$value instanceof \CurlHandle) { + throw new \TypeError(sprintf('Property %s::$%s can only accept an object of type "%s".', __CLASS__, $name, \CurlHandle::class)); + } + } elseif (!is_resource($value) || 'curl' !== get_resource_type($value)) { + throw new \TypeError(sprintf('Property %s::$%s can only accept a resource of type "curl".', __CLASS__, $name)); + } + + $this->initialized = true; + } else { + if (!isset($this->handle) || !$this->handle instanceof \CurlHandle || !is_resource($this->handle) || 'curl' !== get_resource_type($this->handle)) { + throw new \UnexpectedValueException(sprintf('Property %s::$%s could not be set when there isn\'t a valid handle.', __CLASS__, $name)); + } + + if ('errno' === $name) { + if (!is_int($value)) { + throw new \TypeError(sprintf('Property %s::$errno can only accept a value of type int, %s given.', __CLASS__, gettype($value))); + } + + $handleErrno = curl_errno($this->handle); + + if (\CURLE_OK !== $handleErrno && $value !== $handleErrno) { + throw new \UnexpectedValueException(sprintf('Property %s::$errno could not be set with %u since the handle is reporting error %u.', __CLASS__, $value, $handleErrno)); + } + } + } + } + + $this->{$name} = $value; + } + + /** + * @throws \Error + */ + public function __unset(string $name): void + { + if ('handle' !== $name) { + throw new \Error(sprintf('Cannot unset private property %s::$%s.', __CLASS__, $name)); + } + + unset($this->{$name}); + } } diff --git a/tests/Handler/EasyHandleTest.php b/tests/Handler/EasyHandleTest.php index e9ad4e90f..1d195179f 100644 --- a/tests/Handler/EasyHandleTest.php +++ b/tests/Handler/EasyHandleTest.php @@ -3,6 +3,7 @@ namespace GuzzleHttp\Test\Handler; use GuzzleHttp\Handler\EasyHandle; +use GuzzleHttp\Psr7; use PHPUnit\Framework\TestCase; /** @@ -13,10 +14,205 @@ class EasyHandleTest extends TestCase public function testEnsuresHandleExists() { $easy = new EasyHandle(); + + $this->expectException(\BadMethodCallException::class); + $this->expectExceptionMessage('The EasyHandle is not initialized'); + + $easy->handle; + } + + public function testGetReleasedHandle(): void + { + $easy = new EasyHandle(); + $easy->handle = curl_init(); + curl_close($easy->handle); unset($easy->handle); $this->expectException(\BadMethodCallException::class); $this->expectExceptionMessage('The EasyHandle has been released'); + $easy->handle; } + + public function testGetPropertyAfterHandleRelease(): void + { + $easy = new EasyHandle(); + $easy->handle = curl_init(); + curl_close($easy->handle); + unset($easy->handle); + + self::assertSame([], $easy->options); + } + + /** + * @requires PHP < 8 + */ + public function testSettingBadHandlePrePhp8(): void + { + $easy = new EasyHandle(); + + $this->expectException(\TypeError::class); + $this->expectExceptionMessage('Property GuzzleHttp\Handler\EasyHandle::$handle can only accept a resource of type "curl"'); + + $easy->handle = null; + } + + /** + * @requires PHP >= 8 + */ + public function testSettingBadHandle(): void + { + $easy = new EasyHandle(); + + $this->expectException(\TypeError::class); + $this->expectExceptionMessage('Property GuzzleHttp\Handler\EasyHandle::$handle can only accept an object of type "CurlHandle"'); + + $easy->handle = null; + } + + public function testSettingPropertyOnReleasedHandle(): void + { + $easy = new EasyHandle(); + $easy->handle = curl_init(); + curl_close($easy->handle); + unset($easy->handle); + + $this->expectException(\UnexpectedValueException::class); + $this->expectExceptionMessage('The EasyHandle has been released, please use a new EasyHandle instead'); + + $easy->options = []; + } + + public function testSettingHandleTwice(): void + { + $easy = new EasyHandle(); + $easy->handle = curl_init(); + curl_close($easy->handle); + + $this->expectException(\UnexpectedValueException::class); + $this->expectExceptionMessage('Property GuzzleHttp\Handler\EasyHandle::$handle is already set, please use a new EasyHandle instead'); + + $easy->handle = curl_init(); + } + + public function testSettingProperties(): void + { + $handle = curl_init(); + $stream = new Psr7\Stream(fopen('php://temp', 'r')); + $headers = []; + $request = new Psr7\Request('HEAD', '/'); + $options = []; + $easy = new EasyHandle(); + $easy->handle = $handle; + $easy->sink = $stream; + $easy->headers = $headers; + $easy->request = $request; + $easy->options = $options; + + self::assertSame($handle, $easy->handle); + self::assertSame($stream, $easy->sink); + self::assertSame($headers, $easy->headers); + self::assertSame($request, $easy->request); + self::assertSame($options, $easy->options); + curl_close($easy->handle); + unset($handle, $stream, $easy->handle); + } + + public function testSettingHeadersWithoutHandle(): void + { + $easy = new EasyHandle(); + + $this->expectException(\UnexpectedValueException::class); + $this->expectExceptionMessage('Property GuzzleHttp\Handler\EasyHandle::$headers could not be set when there isn\'t a valid handle'); + + $easy->headers = []; + } + + public function testSettingErrnoWithHandle(): void + { + $easy = new EasyHandle(); + $easy->handle = curl_init(); + $easy->errno = \CURLE_OK; + + self::assertSame(\CURLE_OK, $easy->errno); + + curl_close($easy->handle); + unset($easy->handle); + } + + public function testChangingHandleErrno(): void + { + $easy = new EasyHandle(); + $easy->handle = curl_init(); + curl_exec($easy->handle); + + $this->expectException(\UnexpectedValueException::class); + $this->expectExceptionMessage('Property GuzzleHttp\Handler\EasyHandle::$errno could not be set with 0 since the handle is reporting error 3'); + + $easy->errno = \CURLE_OK; + + self::assertSame(\CURLE_OK, $easy->errno); + + curl_close($easy->handle); + + unset($easy->handle); + } + + public function testChangingHandleErrnoWithInvalidType(): void + { + $easy = new EasyHandle(); + $easy->handle = curl_init(); + curl_exec($easy->handle); + + $this->expectException(\TypeError::class); + $this->expectExceptionMessage('Property GuzzleHttp\Handler\EasyHandle::$errno can only accept a value of type int, string given'); + + $easy->errno = 'wrong_type'; + + self::assertSame(\CURLE_OK, $easy->errno); + + curl_close($easy->handle); + + unset($easy->handle); + } + + public function testSettingResponse(): void + { + $easy = new EasyHandle(); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Cannot set private property GuzzleHttp\Handler\EasyHandle::$response'); + + $easy->response = new Psr7\Response(); + } + + public function testSettingErrnoWithoutHandle() + { + $easy = new EasyHandle(); + + $this->expectException(\UnexpectedValueException::class); + $this->expectExceptionMessage('Property GuzzleHttp\Handler\EasyHandle::$errno could not be set when there isn\'t a valid handle'); + + $easy->errno = \CURLE_COULDNT_RESOLVE_HOST; + } + + public function testGetInvalidProperty(): void + { + $easy = new EasyHandle(); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Undefined property: GuzzleHttp\Handler\EasyHandle::$nonexistent'); + + $easy->nonexistent; + } + + public function testPropertyOverload(): void + { + $overloadedValue = 42; + + $easy = new EasyHandle(); + $easy->nonexistent = $overloadedValue; + + self::assertSame($overloadedValue, $easy->nonexistent); + } }