diff --git a/docs/hooks.md b/docs/hooks.md index 10f04745b..3718c9b6d 100644 --- a/docs/hooks.md +++ b/docs/hooks.md @@ -63,6 +63,13 @@ Available Hooks To use this hook, pass a callback via `$options['complete']` when calling `WpOrg\Requests\Requests\request_multiple()`. +* **`requests.failed`** + + Alter/Inspect transport or response parsing exception before it is returned to the user. + + Parameters: `WpOrg\Requests\Exception|WpOrg\Requests\Exception\InvalidArgument &$exception`, `string $url`, `array $headers`, `array|string $data`, + `string $type`, `array $options` + * **`curl.before_request`** Set cURL options before the transport sets any (note that Requests may diff --git a/src/Exception.php b/src/Exception.php index ef8d0ff43..8e7de2fde 100644 --- a/src/Exception.php +++ b/src/Exception.php @@ -31,6 +31,13 @@ class Exception extends PHPException { */ protected $data; + /** + * Whether the exception was already passed to the requests.failed hook or not + * + * @var boolean + */ + public $failed_hook_handled = false; + /** * Create a new exception * diff --git a/src/Exception/InvalidArgument.php b/src/Exception/InvalidArgument.php index 0d6d88095..99cce5216 100644 --- a/src/Exception/InvalidArgument.php +++ b/src/Exception/InvalidArgument.php @@ -19,6 +19,13 @@ */ final class InvalidArgument extends InvalidArgumentException { + /** + * Whether the exception was already passed to the requests.failed hook or not + * + * @var boolean + */ + public $failed_hook_handled = false; + /** * Create a new invalid argument exception with a standardized text. * diff --git a/src/Requests.php b/src/Requests.php index 9390800b6..5937b90cf 100644 --- a/src/Requests.php +++ b/src/Requests.php @@ -465,11 +465,29 @@ public static function request($url, $headers = [], $data = [], $type = self::GE $transport = self::get_transport($capabilities); } - $response = $transport->request($url, $headers, $data, $options); + try { + $response = $transport->request($url, $headers, $data, $options); + + $options['hooks']->dispatch('requests.before_parse', [&$response, $url, $headers, $data, $type, $options]); + + $parsed_response = self::parse_response($response, $url, $headers, $data, $options); + } catch (Exception $e) { + if ($e->failed_hook_handled === false) { + $options['hooks']->dispatch('requests.failed', [&$e, $url, $headers, $data, $type, $options]); + $e->failed_hook_handled = true; + } + + throw $e; + } catch (InvalidArgument $e) { + if ($e->failed_hook_handled === false) { + $options['hooks']->dispatch('requests.failed', [&$e, $url, $headers, $data, $type, $options]); + $e->failed_hook_handled = true; + } - $options['hooks']->dispatch('requests.before_parse', [&$response, $url, $headers, $data, $type, $options]); + throw $e; + } - return self::parse_response($response, $url, $headers, $data, $options); + return $parsed_response; } /** diff --git a/tests/Fixtures/TransportFailedMock.php b/tests/Fixtures/TransportFailedMock.php new file mode 100644 index 000000000..ec22c557a --- /dev/null +++ b/tests/Fixtures/TransportFailedMock.php @@ -0,0 +1,18 @@ +redirected)) { + return $this->redirected_transport->request($url, $headers, $data, $options); + } + + $redirect_url = 'https://example.com/redirected?url=' . urlencode($url); + + $text = HttpStatus::is_valid_code($this->code) ? HttpStatus::get_text($this->code) : 'unknown'; + $response = "HTTP/1.0 {$this->code} $text\r\n"; + $response .= "Content-Type: text/plain\r\n"; + if ($this->chunked) { + $response .= "Transfer-Encoding: chunked\r\n"; + } + + $response .= "Location: $redirect_url\r\n"; + $response .= $this->raw_headers; + $response .= "Connection: close\r\n\r\n"; + $response .= $this->body; + + $this->redirected[$url] = true; + $this->redirected[$redirect_url] = true; + + return $response; + } + + public function request_multiple($requests, $options) { + $responses = []; + foreach ($requests as $id => $request) { + $handler = new self(); + $handler->code = $request['options']['mock.code']; + $handler->chunked = $request['options']['mock.chunked']; + $handler->body = $request['options']['mock.body']; + $handler->raw_headers = $request['options']['mock.raw_headers']; + $responses[$id] = $handler->request($request['url'], $request['headers'], $request['data'], $request['options']); + + if (!empty($options['mock.parse'])) { + $request['options']['hooks']->dispatch('transport.internal.parse_response', [&$responses[$id], $request]); + $request['options']['hooks']->dispatch('multiple.request.complete', [&$responses[$id], $id]); + } + } + + return $responses; + } + + public static function test($capabilities = []) { + return true; + } +} diff --git a/tests/Requests/RequestsTest.php b/tests/Requests/RequestsTest.php index e229ace17..f1f9dfdee 100644 --- a/tests/Requests/RequestsTest.php +++ b/tests/Requests/RequestsTest.php @@ -4,11 +4,15 @@ use WpOrg\Requests\Exception; use WpOrg\Requests\Exception\InvalidArgument; +use WpOrg\Requests\Hooks; use WpOrg\Requests\Iri; use WpOrg\Requests\Requests; use WpOrg\Requests\Response\Headers; use WpOrg\Requests\Tests\Fixtures\RawTransportMock; +use WpOrg\Requests\Tests\Fixtures\TransportFailedMock; +use WpOrg\Requests\Tests\Fixtures\TransportInvalidArgumentMock; use WpOrg\Requests\Tests\Fixtures\TransportMock; +use WpOrg\Requests\Tests\Fixtures\TransportRedirectMock; use WpOrg\Requests\Tests\TestCase; use WpOrg\Requests\Tests\TypeProviderHelper; @@ -152,6 +156,42 @@ public function testDefaultTransport() { $this->assertSame(200, $request->status_code); } + public function testTransportFailedTriggersRequestsFailedCallback() { + $mock = $this->getMockedStdClassWithMethods(['failed']); + $mock->expects($this->once())->method('failed'); + $hooks = new Hooks(); + $hooks->register('requests.failed', [$mock, 'failed']); + + $transport = new TransportFailedMock(); + + $options = [ + 'hooks' => $hooks, + 'transport' => $transport, + ]; + + $this->expectException(Exception::class); + $this->expectExceptionMessage('Transport failed!'); + Requests::get('http://example.com/', [], $options); + } + + public function testTransportInvalidArgumentTriggersRequestsFailedCallback() { + $mock = $this->getMockedStdClassWithMethods(['failed']); + $mock->expects($this->once())->method('failed'); + $hooks = new Hooks(); + $hooks->register('requests.failed', [$mock, 'failed']); + + $transport = new TransportInvalidArgumentMock(); + + $options = [ + 'hooks' => $hooks, + 'transport' => $transport, + ]; + + $this->expectException(InvalidArgument::class); + $this->expectExceptionMessage('Argument #1 ($url) must be of type string|Stringable'); + Requests::get('http://example.com/', [], $options); + } + /** * Standard response header parsing */ @@ -252,6 +292,31 @@ public function testInvalidProtocolVersion() { Requests::get('http://example.com/', [], $options); } + /** + * Check that invalid protocols are not accepted + * + * We do not support HTTP/0.9. If this is really an issue for you, file a + * new issue, and update your server/proxy to support a proper protocol. + */ + public function testInvalidProtocolVersionTriggersRequestsFailedCallback() { + $mock = $this->getMockedStdClassWithMethods(['failed']); + $mock->expects($this->once())->method('failed'); + $hooks = new Hooks(); + $hooks->register('requests.failed', [$mock, 'failed']); + + $transport = new RawTransportMock(); + $transport->data = "HTTP/0.9 200 OK\r\n\r\n

Test"; + + $options = [ + 'hooks' => $hooks, + 'transport' => $transport, + ]; + + $this->expectException(Exception::class); + $this->expectExceptionMessage('Response could not be parsed'); + Requests::get('http://example.com/', [], $options); + } + /** * HTTP/0.9 also appears to use a single CRLF instead of two. */ @@ -268,6 +333,28 @@ public function testSingleCRLFSeparator() { Requests::get('http://example.com/', [], $options); } + /** + * HTTP/0.9 also appears to use a single CRLF instead of two. + */ + public function testSingleCRLFSeparatorTriggersRequestsFailedCallback() { + $mock = $this->getMockedStdClassWithMethods(['failed']); + $mock->expects($this->once())->method('failed'); + $hooks = new Hooks(); + $hooks->register('requests.failed', [$mock, 'failed']); + + $transport = new RawTransportMock(); + $transport->data = "HTTP/0.9 200 OK\r\n

Test"; + + $options = [ + 'hooks' => $hooks, + 'transport' => $transport, + ]; + + $this->expectException(Exception::class); + $this->expectExceptionMessage('Missing header/body separator'); + Requests::get('http://example.com/', [], $options); + } + public function testInvalidStatus() { $transport = new RawTransportMock(); $transport->data = "HTTP/1.1 OK\r\nTest: value\nAnother-Test: value\r\n\r\nTest"; @@ -281,6 +368,25 @@ public function testInvalidStatus() { Requests::get('http://example.com/', [], $options); } + public function testInvalidStatusTriggersRequestsFailedCallback() { + $mock = $this->getMockedStdClassWithMethods(['failed']); + $mock->expects($this->once())->method('failed'); + $hooks = new Hooks(); + $hooks->register('requests.failed', [$mock, 'failed']); + + $transport = new RawTransportMock(); + $transport->data = "HTTP/1.1 OK\r\nTest: value\nAnother-Test: value\r\n\r\nTest"; + + $options = [ + 'hooks' => $hooks, + 'transport' => $transport, + ]; + + $this->expectException(Exception::class); + $this->expectExceptionMessage('Response could not be parsed'); + Requests::get('http://example.com/', [], $options); + } + public function test30xWithoutLocation() { $transport = new TransportMock(); $transport->code = 302; @@ -293,6 +399,52 @@ public function test30xWithoutLocation() { $this->assertSame(0, $response->redirects); } + public function testRedirectToExceptionTriggersRequestsFailedCallbackOnce() { + $mock = $this->getMockedStdClassWithMethods(['failed']); + $mock->expects($this->once())->method('failed'); + $hooks = new Hooks(); + $hooks->register('requests.failed', [$mock, 'failed']); + + $transport = new TransportRedirectMock(); + $transport->redirected_transport = new TransportFailedMock(); + + $options = [ + 'hooks' => $hooks, + 'transport' => $transport, + ]; + + $this->expectException(Exception::class); + $this->expectExceptionMessage('Transport failed!'); + + $response = Requests::get('http://example.com/', [], $options); + + $this->assertSame(302, $response->status_code); + $this->assertSame(1, $response->redirects); + } + + public function testRedirectToInvalidArgumentTriggersRequestsFailedCallbackOnce() { + $mock = $this->getMockedStdClassWithMethods(['failed']); + $mock->expects($this->once())->method('failed'); + $hooks = new Hooks(); + $hooks->register('requests.failed', [$mock, 'failed']); + + $transport = new TransportRedirectMock(); + $transport->redirected_transport = new TransportInvalidArgumentMock(); + + $options = [ + 'hooks' => $hooks, + 'transport' => $transport, + ]; + + $this->expectException(InvalidArgument::class); + $this->expectExceptionMessage('Argument #1 ($url) must be of type string|Stringable'); + + $response = Requests::get('http://example.com/', [], $options); + + $this->assertSame(302, $response->status_code); + $this->assertSame(1, $response->redirects); + } + public function testTimeoutException() { $options = ['timeout' => 0.5]; $this->expectException(Exception::class);