Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Requests: Add "requests.failed" hook #582

Open
wants to merge 9 commits into
base: develop
Choose a base branch
from
7 changes: 7 additions & 0 deletions docs/hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions src/Exception.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
*
Expand Down
7 changes: 7 additions & 0 deletions src/Exception/InvalidArgument.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
24 changes: 21 additions & 3 deletions src/Requests.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down
18 changes: 18 additions & 0 deletions tests/Fixtures/TransportFailedMock.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?php

namespace WpOrg\Requests\Tests\Fixtures;

use WpOrg\Requests\Exception;
use WpOrg\Requests\Transport;

final class TransportFailedMock implements Transport {
public function request($url, $headers = [], $data = [], $options = []) {
throw new Exception('Transport failed!', 'transporterror');
}
public function request_multiple($requests, $options) {
throw new Exception('Transport failed!', 'transporterror');
}
public static function test($capabilities = []) {
return true;
}
}
18 changes: 18 additions & 0 deletions tests/Fixtures/TransportInvalidArgumentMock.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?php

namespace WpOrg\Requests\Tests\Fixtures;

use WpOrg\Requests\Exception\InvalidArgument;
use WpOrg\Requests\Transport;

final class TransportInvalidArgumentMock implements Transport {
public function request($url, $headers = [], $data = [], $options = []) {
throw InvalidArgument::create(1, '$url', 'string|Stringable', gettype($url));
}
public function request_multiple($requests, $options) {
throw InvalidArgument::create(1, '$requests', 'array|ArrayAccess&Traversable', gettype($requests));
}
public static function test($capabilities = []) {
return true;
}
}
65 changes: 65 additions & 0 deletions tests/Fixtures/TransportRedirectMock.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
<?php

namespace WpOrg\Requests\Tests\Fixtures;

use WpOrg\Requests\Transport;
use WpOrg\Requests\Utility\HttpStatus;

final class TransportRedirectMock implements Transport {
public $code = 302;
public $chunked = false;
public $body = '';
public $raw_headers = '';

private $redirected = [];

public $redirected_transport = null;

public function request($url, $headers = [], $data = [], $options = []) {
if (array_key_exists($url, $this->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;
}
}
152 changes: 152 additions & 0 deletions tests/Requests/RequestsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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
*/
Expand Down Expand Up @@ -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<p>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.
*/
Expand All @@ -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<p>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";
Expand All @@ -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;
Expand All @@ -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);
Expand Down