-
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
First implementation of the GET handler via cURL.
- Loading branch information
1 parent
a0101cc
commit 3e9eb82
Showing
8 changed files
with
550 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,6 @@ | ||
/vendor/ | ||
composer.phar | ||
/.idea/ | ||
.DS_Store | ||
.~lock.* | ||
/composer.lock |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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/" | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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), | ||
); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} | ||
} |
Oops, something went wrong.