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

Feature: follow redirects #20

Closed
wants to merge 6 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## Development (not released yet)

* New feature: followRedirects option
* New feature: maxRedirects option
* BC break: `Client` and `Request` take different constructor parameters.

## 0.4.0 (2014-02-02)

* BC break: Drop unused `Response::getBody()`
Expand Down
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,19 +41,23 @@ $dnsResolver = $dnsResolverFactory->createCached('8.8.8.8', $loop);
$factory = new React\HttpClient\Factory();
$client = $factory->create($loop, $dnsResolver);

$request = $client->request('GET', 'https://github.com/');
$request = $client->request('GET', 'https://github.com/', [], [
'followRedirects' => true,
'maxRedirects' => 5
]);
$request->on('response', function ($response) {
$response->on('data', function ($data) {
// ...
});
});
$request->end();
$loop->run();

?>
```

## TODO

* gzip content encoding
* chunked transfer encoding
* keep-alive connections
* following redirections
21 changes: 6 additions & 15 deletions src/Client.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,29 +2,20 @@

namespace React\HttpClient;

use React\SocketClient\ConnectorInterface;

class Client
{
private $connector;
private $secureConnector;
private $connectorPair;

public function __construct(ConnectorInterface $connector, ConnectorInterface $secureConnector)
public function __construct(ConnectorPair $connectorPair)
{
$this->connector = $connector;
$this->secureConnector = $secureConnector;
$this->connectorPair = $connectorPair;
}

public function request($method, $url, array $headers = [])
public function request($method, $url, array $headers = [], array $options = null)
{
$requestData = new RequestData($method, $url, $headers);
$connector = $this->getConnectorForScheme($requestData->getScheme());

return new Request($connector, $requestData);
}
$requestOptions = new RequestOptions($options);

private function getConnectorForScheme($scheme)
{
return ('https' === $scheme) ? $this->secureConnector : $this->connector;
return new Request($this->connectorPair, $requestData, $requestOptions);
}
}
22 changes: 22 additions & 0 deletions src/ConnectorPair.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php

namespace React\HttpClient;

use React\SocketClient\ConnectorInterface;

class ConnectorPair
{
private $connector;
private $secureConnector;

public function __construct(ConnectorInterface $connector, ConnectorInterface $secureConnector)
{
$this->connector = $connector;
$this->secureConnector = $secureConnector;
}

public function getConnectorForScheme($scheme)
{
return ('https' === $scheme) ? $this->secureConnector : $this->connector;
}
}
3 changes: 2 additions & 1 deletion src/Factory.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ public function create(LoopInterface $loop, Resolver $resolver)
{
$connector = new Connector($loop, $resolver);
$secureConnector = new SecureConnector($connector, $loop);
$connectorPair = new ConnectorPair($connector, $secureConnector);

return new Client($connector, $secureConnector);
return new Client($connectorPair);
}
}
88 changes: 77 additions & 11 deletions src/Request.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@

use Evenement\EventEmitterTrait;
use Guzzle\Parser\Message\MessageParser;
use React\SocketClient\ConnectorInterface;
use React\Stream\WritableStreamInterface;

/**
Expand All @@ -24,18 +23,28 @@ class Request implements WritableStreamInterface
const STATE_END = 3;

private $connector;
private $connectorPair;
private $requestData;
private $requestOptions;

private $stream;
private $buffer;
private $responseFactory;
private $response;
private $state = self::STATE_INIT;

public function __construct(ConnectorInterface $connector, RequestData $requestData)
private $redirectCount;
private $redirectLocations;

public function __construct(ConnectorPair $connectorPair, RequestData $requestData, RequestOptions $requestOptions)
{
$this->connector = $connector;
$this->connectorPair = $connectorPair;
$this->requestData = $requestData;
$this->requestOptions = $requestOptions;
$this->connector = $connectorPair->getConnectorForScheme($requestData->getScheme());
$this->redirectCount = 0;
$this->redirectLocations = [];
$this->trackLocation($requestData);
}

public function isWritable()
Expand All @@ -50,12 +59,11 @@ public function writeHead()
}

$this->state = self::STATE_WRITING_HEAD;

$requestData = $this->requestData;
$streamRef = &$this->stream;
$stateRef = &$this->state;

$this
return $this
->connect()
->then(
function ($stream) use ($requestData, &$streamRef, &$stateRef) {
Expand Down Expand Up @@ -89,12 +97,11 @@ public function write($data)
return $this->stream->write($data);
}

$this->on('headers-written', function ($this) use ($data) {
$this->write($data);
});

if (self::STATE_WRITING_HEAD > $this->state) {
$this->writeHead();
$this->writeHead()
->then(function () use ($data) {
$this->stream->write($data);
});
}

return false;
Expand All @@ -108,7 +115,7 @@ public function end($data = null)

if (null !== $data) {
$this->write($data);
} else if (self::STATE_WRITING_HEAD > $this->state) {
} elseif (self::STATE_WRITING_HEAD > $this->state) {
$this->writeHead();
}
}
Expand All @@ -132,6 +139,49 @@ public function handleData($data)
$this->stream->removeListener('end', array($this, 'handleEnd'));
$this->stream->removeListener('error', array($this, 'handleError'));

//Should we respond to any redirects?
if ($this->isRedirectCode($response->getCode())
&& $this->requestOptions->shouldFollowRedirects()) {

//Have we reached our maximum redirects?
if ($this->requestOptions->getMaxRedirects() >= 0
&& $this->redirectCount >= $this->requestOptions->getMaxRedirects()) {
$this->closeError(new \RuntimeException(
sprintf("Too many redirects: %u", $this->redirectCount)
));

return;
}

//Recalibrate to this new location.
$headers = $response->getHeaders();
$this->requestData->redirect($response->getCode(), $headers['Location']);
$this->connector = $this->connectorPair->getConnectorForScheme($this->requestData->getScheme());

//Is the location a cyclic redirect?
if ($this->isKnownLocation($this->requestData->getMethod(), $headers['Location'])) {
$this->closeError(new \RuntimeException(
"Cyclic redirect detected"
));

return;
}

//Store the next location to prevent cyclic redirects.
$this->trackLocation($this->requestData);
$this->redirectCount++;

//Clean up and rewind.
$this->stream->close();
$this->responseFactory = null;
$this->state = self::STATE_INIT;

//Perform the same tricks.
$this->end();

return;
}

$this->response = $response;

$response->on('end', function () {
Expand Down Expand Up @@ -218,6 +268,22 @@ protected function connect()
->create($host, $port);
}

protected function isRedirectCode($code)
{
//Note: 303, 307, 308 status is not supported in HTTP/1.0.
return in_array($code, [301, 302]);
}

protected function trackLocation(RequestData $requestData)
{
$this->redirectLocations[] = $requestData->getMethod().' '.$requestData->getUrl();
}

protected function isKnownLocation($method, $url)
{
return in_array($method.' '.$url, $this->redirectLocations);
}

public function setResponseFactory($factory)
{
$this->responseFactory = $factory;
Expand Down
42 changes: 42 additions & 0 deletions src/RequestData.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

namespace React\HttpClient;

use InvalidArgumentException;

class RequestData
{
private $method;
Expand Down Expand Up @@ -32,6 +34,16 @@ private function mergeDefaultheaders(array $headers)
);
}

public function getMethod()
{
return strtoupper($this->method);
}

public function getUrl()
{
return $this->url;
}

public function getScheme()
{
return parse_url($this->url, PHP_URL_SCHEME);
Expand Down Expand Up @@ -78,4 +90,34 @@ public function __toString()

return $data;
}

/**
* Processes a redirect request, updating internal values to represent the new request data.
*
* @param integer $code HTTP status code for the redirect.
* @param string $location The Location header received.
*/
public function redirect($code, $location)
{
switch ($code) {

//These cases require that we switch to the GET method.
//@see https://github.com/bagder/curl/blob/cc28bc472ec421cec2ba26d653e53892998a248d/lib/transfer.c#L1736
case 301:
case 302:
$this->method = 'GET';

//Note: 303, 307, 308 status is not supported in HTTP/1.0.
// case 303:
// case 307:
// case 308:

//Of course switch the location.
$this->url = $location;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

to refer relativ redirect locations we need a test and url rebuild.

//Of course switch the location.
if (is_null(parse_url($location, PHP_URL_HOST))) {
    $path = parse_url($location, PHP_URL_PATH) ?: '/';
    $queryString = parse_url($location, PHP_URL_QUERY);
    $location = $this->getScheme() . '://' . $this->getHost() . $path . ($queryString ? "?$queryString" : '');
}
$this->url = $location;

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another way to do that is to use PSR7 and resolve two URI's with https://github.com/guzzle/psr7/blob/master/src/UriResolver.php

break;

default:
throw new InvalidArgumentException(sprintf("Redirect code %u is not supported", $code));
}
}
}
Loading