From a4f4223b98e482b84d26b46a1287ba12fea0bd94 Mon Sep 17 00:00:00 2001 From: Zach Borboa Date: Wed, 15 Sep 2021 05:49:25 -0400 Subject: [PATCH] Implement Curl::setStop() for stopping requests early Use Curl::setStop() to stop requests early without downloading the full response. --- README.md | 1 + ...ithout_downloading_full_error_response.php | 39 +++++++++++ src/Curl/Curl.php | 49 +++++++++++++ tests/PHPCurlClass/PHPCurlClassTest.php | 69 +++++++++++++++++++ tests/PHPCurlClass/server.php | 3 + 5 files changed, 161 insertions(+) create mode 100644 examples/get_without_downloading_full_error_response.php diff --git a/README.md b/README.md index 173429e..6ab357d 100644 --- a/README.md +++ b/README.md @@ -290,6 +290,7 @@ Curl::setRange($range) Curl::setReferer($referer) Curl::setReferrer($referrer) Curl::setRetry($mixed) +Curl::setStop($callback) Curl::setTimeout($seconds) Curl::setUrl($url, $mixed_data = '') Curl::setUserAgent($user_agent) diff --git a/examples/get_without_downloading_full_error_response.php b/examples/get_without_downloading_full_error_response.php new file mode 100644 index 0000000..abeeea7 --- /dev/null +++ b/examples/get_without_downloading_full_error_response.php @@ -0,0 +1,39 @@ +setStop(function ($ch, $header) { + // Stop requests returning error responses early without downloading the + // full error response. + // + // Check the header for the status line starting with "HTTP/". + // Status-Line per RFC 2616: + // 6.1 Status-Line: + // Status-Line = HTTP-Version SP Status-Code SP Reason-Phrase CRLF + if (stripos($header, 'HTTP/') === 0) { + $status_line_parts = explode(' ', $header); + if (isset($status_line_parts['1'])) { + $http_status_code = $status_line_parts['1']; + $http_error = in_array((int) floor($http_status_code / 100), [4, 5], true); + if ($http_error) { + // Return true to stop receiving the response. + return true; + } + } + } + + // Return false to continue receiving the response. + return false; +}); + +$curl->get('https://www.example.com/large-500-error'); +if ($curl->error) { + echo 'Error: ' . $curl->errorCode . ': ' . $curl->errorMessage . "\n"; + echo 'Response content-length: ' . $curl->responseHeaders['content-length'] . "\n"; + echo 'Actual response size downloaded: ' . $curl->getInfo(CURLINFO_SIZE_DOWNLOAD) . "\n"; +} else { + echo 'Response content-length: ' . $curl->responseHeaders['content-length'] . "\n"; + echo 'Actual response size downloaded: ' . $curl->getInfo(CURLINFO_SIZE_DOWNLOAD) . "\n"; +} diff --git a/src/Curl/Curl.php b/src/Curl/Curl.php index ae35ae3..af217a7 100644 --- a/src/Curl/Curl.php +++ b/src/Curl/Curl.php @@ -51,6 +51,7 @@ class Curl public $jsonDecoder = null; public $xmlDecoder = null; + private $headerCallbackData; private $cookies = []; private $headers = []; private $options = []; @@ -488,6 +489,8 @@ class Curl $this->responseCookies = $this->headerCallbackData->responseCookies; $this->headerCallbackData->rawResponseHeaders = ''; $this->headerCallbackData->responseCookies = []; + $this->headerCallbackData->stopRequestDecider = null; + $this->headerCallbackData->stopRequest = false; // Include additional error code information in error message when possible. if ($this->curlError && function_exists('curl_strerror')) { @@ -2055,6 +2058,8 @@ class Curl $header_callback_data = new \stdClass(); $header_callback_data->rawResponseHeaders = ''; $header_callback_data->responseCookies = []; + $header_callback_data->stopRequestDecider = null; + $header_callback_data->stopRequest = false; $this->headerCallbackData = $header_callback_data; $this->setOpt(CURLOPT_HEADERFUNCTION, createHeaderCallback($header_callback_data)); @@ -2065,6 +2070,42 @@ class Curl $this->setUrl($base_url); } } + + /** + * Set Stop + * + * Specify a callable decider to stop the request early without waiting for + * the full response to be received. + * + * The callable is passed two parameters. The first is the cURL resource, + * the second is a string with header data. Both parameters match the + * parameters in the CURLOPT_HEADERFUNCTION callback. + * + * The callable must return a truthy value for the request to be stopped + * early. + * + * @access public + * @param $callback callable + */ + public function setStop($callback) + { + $this->headerCallbackData->stopRequestDecider = $callback; + $this->headerCallbackData->stopRequest = false; + + $header_callback_data = $this->headerCallbackData; + $this->progress(function ( + $resource, + $download_size, + $downloaded, + $upload_size, + $uploaded + ) use ( + $header_callback_data + ) { + // Abort the transfer when the stop request flag has been set by returning a non-zero value. + return $header_callback_data->stopRequest ? 1 : 0; + }); + } } /** @@ -2083,6 +2124,14 @@ function createHeaderCallback($header_callback_data) { if (preg_match('/^Set-Cookie:\s*([^=]+)=([^;]+)/mi', $header, $cookie) === 1) { $header_callback_data->responseCookies[$cookie[1]] = trim($cookie[2], " \n\r\t\0\x0B"); } + + if ($header_callback_data->stopRequestDecider !== null) { + $stop_request_decider = $header_callback_data->stopRequestDecider; + if ($stop_request_decider($ch, $header)) { + $header_callback_data->stopRequest = true; + } + } + $header_callback_data->rawResponseHeaders .= $header; return strlen($header); }; diff --git a/tests/PHPCurlClass/PHPCurlClassTest.php b/tests/PHPCurlClass/PHPCurlClassTest.php index 324d089..b6ee554 100644 --- a/tests/PHPCurlClass/PHPCurlClassTest.php +++ b/tests/PHPCurlClass/PHPCurlClassTest.php @@ -4066,4 +4066,73 @@ class CurlTest extends \PHPUnit\Framework\TestCase $this->assertStringContainsString($expected_string, $test_3_output); } } + + public function testStopRequest() { + $response_length_bytes = 1e6; // 1e6 = 1 megabyte + + $stop_request_early = function ($ch, $header) { + // Stop requests returning error responses early without downloading the + // full error response. + // + // Check the header for the status line starting with "HTTP/". + // Status-Line per RFC 2616: + // 6.1 Status-Line: + // Status-Line = HTTP-Version SP Status-Code SP Reason-Phrase CRLF + if (stripos($header, 'HTTP/') === 0) { + $status_line_parts = explode(' ', $header); + if (isset($status_line_parts['1'])) { + $http_status_code = $status_line_parts['1']; + $http_error = in_array((int) floor($http_status_code / 100), [4, 5], true); + if ($http_error) { + // Return true to stop receiving the response. + return true; + } + } + } + + // Return false to continue receiving the response. + return false; + }; + + // Verify that full response is fetched for an error. + $test_1 = new Test(); + $test_1->server('download_file_size', 'GET', [ + 'bytes' => $response_length_bytes, + 'http_response_code' => '500', + ]); + $this->assertEquals($response_length_bytes, $test_1->curl->responseHeaders['Content-Length']); + $this->assertEquals($response_length_bytes, $test_1->curl->getInfo(CURLINFO_SIZE_DOWNLOAD)); + $this->assertEquals($response_length_bytes, strlen($test_1->curl->rawResponse)); + $this->assertTrue($test_1->curl->error); + $this->assertFalse($test_1->curl->curlError); + $this->assertTrue($test_1->curl->httpError); + + // Verify that full response is not fetched for an error. + $test_2 = new Test(); + $test_2->curl->setStop($stop_request_early); + $test_2->server('download_file_size', 'GET', [ + 'bytes' => $response_length_bytes, + 'http_response_code' => '500', + ]); + $this->assertEquals($response_length_bytes, $test_2->curl->responseHeaders['Content-Length']); + $this->assertLessThan($response_length_bytes, $test_2->curl->getInfo(CURLINFO_SIZE_DOWNLOAD)); + $this->assertLessThan($response_length_bytes, strlen($test_2->curl->rawResponse)); + $this->assertTrue($test_2->curl->error); + $this->assertTrue($test_2->curl->curlError); + $this->assertTrue($test_2->curl->httpError); + + // Verify that full response is still fetched for a non-error. + $test_3 = new Test(); + $test_3->curl->setStop($stop_request_early); + $test_3->server('download_file_size', 'GET', [ + 'bytes' => $response_length_bytes, + 'http_response_code' => '200', + ]); + $this->assertEquals($response_length_bytes, $test_3->curl->responseHeaders['Content-Length']); + $this->assertEquals($response_length_bytes, $test_3->curl->getInfo(CURLINFO_SIZE_DOWNLOAD)); + $this->assertEquals($response_length_bytes, strlen($test_3->curl->rawResponse)); + $this->assertFalse($test_3->curl->error); + $this->assertFalse($test_3->curl->curlError); + $this->assertFalse($test_3->curl->httpError); + } } diff --git a/tests/PHPCurlClass/server.php b/tests/PHPCurlClass/server.php index 62a6ad9..593f018 100644 --- a/tests/PHPCurlClass/server.php +++ b/tests/PHPCurlClass/server.php @@ -263,6 +263,9 @@ if ($test === 'http_basic_auth') { readfile($unsafe_file_path); exit; } elseif ($test === 'download_file_size') { + if (isset($_GET['http_response_code'])) { + http_response_code((int) $_GET['http_response_code']); + } $bytes = isset($_GET['bytes']) ? $_GET['bytes'] : 1234; $str = str_repeat('.', (int) $bytes); header('Content-Type: application/octet-stream');