Skip to content

Commit

Permalink
First implementation of the GET handler via cURL.
Browse files Browse the repository at this point in the history
  • Loading branch information
lucasnetau committed Nov 25, 2022
1 parent a0101cc commit 3e9eb82
Show file tree
Hide file tree
Showing 8 changed files with 550 additions and 1 deletion.
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
/vendor/
composer.phar
/.idea/
.DS_Store
.~lock.*
/composer.lock
44 changes: 43 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,44 @@
# reactphp-http-browser-curl
Implementation of an Async HTTP client using CURL and Fibers
Implementation of an Async HTTP client using CURL and Fibers.

*** NOTE *** This is a work in progress, GET requests work however other HTTP verbs have not yet been implemented.

## Why not use react/http Browser?
Using cURL allows for HTTP/2, and the extraction of timing data for the requests. This functionality is not available though the ReactPHP Browser implementation

## Requirements

The package is compatible with PHP 8.0+ and requires the cURL extension and [react/event-loop](https://github.com/reactphp/http) library.

## Installation

You can add the library as project dependency using [Composer](https://getcomposer.org/):

```sh
composer require edgetelemetrics/reactphp-http-browser-curl
```

## Examples
See [/examples](/examples) directory

## Timing
Request timing values are returned in the PSR7 Response object headers under the key [Server-Timing](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Server-Timing)

## Configuration
The Browser can be configured with standard CURLOPT_* parameters given via the constructor.

```php
$browser = new Browser([
CURLOPT_TIMEOUT => 20,
CURLOPT_DOH_URL, 'https://1.1.1.1/dns-query',
CURLOPT_DNS_SERVERS => '1.1.1.1',
]);
```

## License

MIT, see [LICENSE file](LICENSE).

### Contributing

Bug reports (and small patches) can be submitted via the [issue tracker](https://github.com/lucasnetau/reactphp-http-browser-curl/issues). Forking the repository and submitting a Pull Request is preferred for substantial patches.
35 changes: 35 additions & 0 deletions composer.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
{
"name": "edgetelemetrics/reactphp-http-browser-curl",
"description": "An async http client using Curl and Fibers",
"minimum-stability": "stable",
"license": "MIT",
"type": "library",
"authors": [
{
"name": "James Lucas",
"email": "james@lucas.net.au"
}
],
"require": {
"php": "^8.1",
"react/event-loop": "^1.2",
"ext-curl": "*",
"psr/http-message":"^1.0.1"
},
"require-dev": {
"phpunit/phpunit": "^9.5",
"react/http": "^1.6",
"react/dns": "*",
"ringcentral/psr7": "^1.2"
},
"autoload": {
"psr-4": {
"EdgeTelemetrics\\React\\Http\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"EdgeTelemetrics\\React\\Http\\Tests\\": "tests/"
}
}
}
20 changes: 20 additions & 0 deletions examples/simple.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?php declare(strict_types=1);

use EdgeTelemetrics\React\Http\Browser;

include __DIR__ . '/../vendor/autoload.php';

$browser = new Browser([
CURLOPT_TIMEOUT => 20,
//CURLOPT_DOH_URL, 'https://1.1.1.1/dns-query',
//CURLOPT_DNS_SERVERS => '1.1.1.1',
]);

$browser->get("https://raw.githubusercontent.com/lucasnetau/reactphp-http-browser-curl/main/LICENSE")->then(function($response) use($browser) {
/** @var \Psr\Http\Message\ResponseInterface $response */
echo $response->getStatusCode() . " " . $response->getReasonPhrase() . PHP_EOL;
print_r($response->getHeaders());
print_r((string)$response->getBody());
}, function($ex) {
echo 'Download failed: ' . $ex->getMessage() . PHP_EOL;
});
20 changes: 20 additions & 0 deletions phpunit.xml.dist
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>

<!-- PHPUnit configuration file with new format for PHPUnit 9.3+ -->
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/9.3/phpunit.xsd"
bootstrap="vendor/autoload.php"
cacheResult="false"
colors="true"
convertDeprecationsToExceptions="true">
<testsuites>
<testsuite name="Library Test Suite">
<directory>tests/</directory>
</testsuite>
</testsuites>
<coverage>
<include>
<directory>src/</directory>
</include>
</coverage>
</phpunit>
215 changes: 215 additions & 0 deletions src/Browser.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
<?php declare(strict_types=1);

namespace EdgeTelemetrics\React\Http;

use CurlHandle;
use CurlMultiHandle;
use Fiber;
use Psr\Http\Message\ResponseInterface;
use React\EventLoop;
use React\Promise\Deferred;
use React\Promise\PromiseInterface;
use RingCentral\Psr7\Response as Psr7Response;

use function count;
use function curl_close;
use function curl_getinfo;
use function curl_init;
use function curl_multi_add_handle;
use function curl_multi_close;
use function curl_multi_exec;
use function curl_multi_info_read;
use function curl_multi_init;
use function curl_multi_remove_handle;
use function curl_multi_strerror;
use function curl_setopt;
use function fopen;
use function preg_split;
use function rewind;
use function stream_get_contents;
use function stream_set_blocking;

class Browser {
protected bool $disableCurlCache = false;

/** @var array Options to disable as much of cURL cache as possible */
const NO_CACHE_OPTIONS = [
CURLOPT_FRESH_CONNECT => true,
CURLOPT_FORBID_REUSE => true,
CURLOPT_DNS_CACHE_TIMEOUT => 1,
];

const DEFAULT_CURL_OPTIONS = [
CURLOPT_HEADER => false, //We will write headers out to a separate file
CURLOPT_CONNECTTIMEOUT => 30,
CURLOPT_TIMEOUT => 120,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_CERTINFO => true,
CURLOPT_TCP_NODELAY => true,
];

private \SplObjectStorage $inprogress;

/**
* @param EventLoop\LoopInterface|null $loop
* @param array $options
*/
public function __construct(protected array $options = [], protected ?EventLoop\LoopInterface $loop = null) {
if (!isset($this->loop)) {
$this->loop = EventLoop\Loop::get();
}

$this->inprogress = new \SplObjectStorage();
}

public function get(string $url, array $headers = []) : PromiseInterface {
$curl = $this->initCurl();
curl_setopt($curl, CURLOPT_URL, $url);

if (!empty($headers)) {
curl_setopt($curl, CURLOPT_HTTPHEADER, $headers);
}

$deferred = new Deferred();
$fileHandle = fopen('php://temp', 'w+');
if ($fileHandle === false) {
throw new \RuntimeException('Unable to create temporary file for response body');
}
$headerHandle = fopen('php://temp', 'w+');
if ($headerHandle === false) {
throw new \RuntimeException('Unable to create temporary file for response headers');
}
$fiber = $this->initFiber($curl);
$this->inprogress[$fiber] = [
'deferred' => $deferred,
'file' => $fileHandle,
'headers' => $headerHandle,
];
curl_setopt($curl, CURLOPT_FILE, $fileHandle);
curl_setopt($curl, CURLOPT_WRITEHEADER, $headerHandle);

//Kickstart the handler any time we initiate a new request and no requests are currently in the queue
if (count($this->inprogress) === 1) {
$this->loop->futureTick($this->curlTick(...));
}

return $deferred->promise();
}

/**
* Don't apply any additional configuration changes which remove or limit cURL connection caches / reuse
* @return void
*/
public function enableConnectionCaches() : void {
$this->disableCurlCache = false;
}

/**
* Disable as much of cURLs internal caches (DNS resolution) and connection reuse. Useful when performing a health check
* @return void
*/
public function disableConnectionCaches() : void {
$this->disableCurlCache = true;
}

private function initCurl() : CurlHandle {
$curl = curl_init();

if ($curl === false) {
throw new \RuntimeException('Unable to init curl');
}

//@TODO remove any options that will conflict with out internal working. Eg CURLOPT_FILE, CURLOPT_WRITEHEADER, etc.
$options = $this->options + self::DEFAULT_CURL_OPTIONS;

if ($this->disableCurlCache) {
$options = $options + static::NO_CACHE_OPTIONS;
}

curl_setopt_array($curl, $options);

return $curl;
}

private function initFiber(CurlHandle $curl) : Fiber {
$multi = curl_multi_init();
$return = curl_multi_add_handle($multi, $curl);

if ($return !== 0) {
curl_multi_close($multi);
throw new \RuntimeException('Unable to add curl to multi handle, Error:' . $return . ", Msg: " . curl_multi_strerror($return));
}

$fiber = new Fiber(function (CurlMultiHandle $mh) use(&$fiber) {
$still_running = null;
do {
curl_multi_exec($mh, $still_running);
if ($still_running) {
Fiber::suspend();
}
} while ($still_running);
$info = curl_multi_info_read($mh);
$curl = $info["handle"];
curl_multi_remove_handle($mh, $curl);
curl_multi_close($mh);

$deferred = $this->inprogress[$fiber]['deferred'];
if ($info['result'] === CURLE_OK) {
$responseBodyHandle = $this->inprogress[$fiber]['file'];
stream_set_blocking($responseBodyHandle, false);
rewind($responseBodyHandle);
$responseHeaderHandle = $this->inprogress[$fiber]['headers'];
rewind($responseHeaderHandle);
$headers = stream_get_contents($responseHeaderHandle);
$deferred->resolve($this->constructResponseFromCurl($curl, $headers, $responseBodyHandle)); //@TODO implement ReactPHP Browser withRejectErrorResponse support
} else {
$deferred->reject(new ConnectionException($curl));
}
curl_close($curl);
});

$fiber->start($multi);
return $fiber;
}

private function curlTick(): void
{
foreach($this->inprogress as $fiber) {
if ($fiber->isTerminated()) {
unset($this->inprogress[$fiber]);
} else {
$fiber->resume();
}
}

if (count($this->inprogress)) {
$this->loop->addTimer(0.01, $this->curlTick(...)); //use a timer instead of futureTick so that we don't lock the CPU at 100%
}
}

private function constructResponseFromCurl(CurlHandle $curl, string $rawHeaders, $body) : ResponseInterface {
$headers = [];
$lines = preg_split('/(\\r?\\n)/', trim($rawHeaders), -1);
array_shift($lines);
foreach($lines as $headerLine) {
$parts = explode(':', $headerLine, 2);
$key = trim($parts[0]);
$value = isset($parts[1]) ? trim($parts[1]) : '';
$headers[$key][] = $value;
}

$info = curl_getinfo($curl);
$timing = [];
foreach(['total_time',"namelookup_time", "connect_time", "pretransfer_time", "starttransfer_time", "redirect_time"] as $timingKey) {
$timing[] = "$timingKey;dur=". $info[$timingKey];
}
$headers['ServerTiming'] = $timing;

return new Psr7Response(
curl_getinfo($curl, CURLINFO_RESPONSE_CODE),
$headers,
\RingCentral\Psr7\stream_for($body),
curl_getinfo($curl, CURLINFO_HTTP_VERSION),
);
}
}
29 changes: 29 additions & 0 deletions src/ConnectionException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?php declare(strict_types=1);

namespace EdgeTelemetrics\React\Http;

use CurlHandle;
use RuntimeException;
use function curl_errno;
use function curl_error;

/**
* The `EdgeTelemetrics\React\Http\ConnectionException` is an `Exception` sub-class that will be used to reject
* a request promise if the cURL exec returns an error (Connection, Resolution, Timeout)
*
* The `getCode(): int` method can be used to
* return the cURL error code which can be used with curl_strerror().
*/
final class ConnectionException extends RuntimeException
{
public function __construct(CurlHandle $curl, $message = null, $code = null, $previous = null)
{
if ($message === null) {
$message = curl_error($curl) . ' (' . curl_errno($curl) . ')';
}
if ($code === null) {
$code = curl_errno($curl);
}
parent::__construct($message, $code, $previous);
}
}
Loading

0 comments on commit 3e9eb82

Please sign in to comment.