Skip to content
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