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

Adapt for latest http-client #10

Merged
merged 14 commits into from
Mar 31, 2024
8 changes: 3 additions & 5 deletions .php_cs.dist → .php-cs-fixer.dist.php
Original file line number Diff line number Diff line change
@@ -1,14 +1,12 @@
<?php

$config = new Amp\CodeStyle\Config;

$config->getFinder()
->in(__DIR__ . '/examples')
->in(__DIR__ . '/src')
->in(__DIR__ . '/test');
->in(__DIR__ . "/examples")
->in(__DIR__ . "/src")
->in(__DIR__ . "/test");

$cacheDir = getenv('TRAVIS') ? getenv('HOME') . '/.php-cs-fixer' : __DIR__;

$config->setCacheFile($cacheDir . '/.php_cs.cache');

return $config;
38 changes: 18 additions & 20 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,40 +21,38 @@ Create `Amp\Http\Client\Psr7\PsrAdapter` instance to convert client requests and
```php
<?php

require 'vendor/autoload.php';

use Amp\Http\Client\Psr7\PsrAdapter;
use Amp\Loop;
use Laminas\Diactoros\RequestFactory;
use Laminas\Diactoros\ResponseFactory;

Loop::run(function () {
$psrAdapter = new PsrAdapter();

// PSR-17 request factory
$psrRequestFactory = new RequestFactory();
// PSR-17 response factory
$psrResponseFactory = new ResponseFactory();
// PSR-17 request factory
$psrRequestFactory = new RequestFactory();
// PSR-17 response factory
$psrResponseFactory = new ResponseFactory();

// Convert PSR-7 request to Amp request
$psrRequest = $psrRequestFactory->createRequest('GET', 'https://google.com/');
$ampRequest = yield $psrAdapter->fromPsrRequest($psrRequest);
$psrAdapter = new PsrAdapter($psrRequestFactory, $psrResponseFactory);

// Convert Amp request to PSR-7 request
$psrRequest = yield $psrAdapter->toPsrRequest($psrRequestFactory, $ampRequest);
// Convert PSR-7 request to Amp request
$psrRequest = $psrRequestFactory->createRequest('GET', 'https://google.com/');
$ampRequest = $psrAdapter->fromPsrRequest($psrRequest);

// Convert PSR-7 response to Amp response
$psrResponse = $psrResponseFactory->createResponse();
$ampResponse = yield $psrAdapter->fromPsrResponse($psrResponse, $ampRequest);
// Convert Amp request to PSR-7 request
$psrRequest = $psrAdapter->toPsrRequest($ampRequest);

// Convert Amp response to PSR-7 response
$psrResponse = yield $psrAdapter->toPsrResponse($psrResponseFactory, $ampResponse);
});
// Convert PSR-7 response to Amp response
$psrResponse = $psrResponseFactory->createResponse();
$ampResponse = $psrAdapter->fromPsrResponse($psrResponse, $ampRequest);

// Convert Amp response to PSR-7 response
$psrResponse = $psrAdapter->toPsrResponse($ampResponse);
```

There are few incompatibilities between Amp and PSR-7 implementations that may require special handling:

- PSR-7 requests contain only one protocol version, but Amp requests can contain several versions. In this case the adapter checks if the protocol version list contains a version that is the current PSR-7 implementation default, otherwise it throws an exception. You may also set the protocol version explicitly using the optional argument of the `toPsrRequest()` method.
- Amp responses contain a reference to the `Request` instance, but PSR-7 responses don't; so you need to provide a request instance explicitly.
- Amp responses contain a reference to the `Request` instance, but PSR-7 responses don't; so you need to provide a request instance explicitly.

## Examples

Expand Down
14 changes: 9 additions & 5 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,15 +31,19 @@
"psr/http-message": "^1",
"psr/http-factory": "^1",
"psr/http-client": "^1",
"revolt/event-loop": "^0.2"
"revolt/event-loop": "^1",
"amphp/http-tunnel": "^2.0@beta"
},
"require-dev": {
"amphp/phpunit-util": "^1.4",
"amphp/php-cs-fixer-config": "dev-master",
"amphp/phpunit-util": "^3",
"amphp/php-cs-fixer-config": "^2",
"phpunit/phpunit": "^9",
"friendsofphp/php-cs-fixer": "^2.3",
"laminas/laminas-diactoros": "^2.3",
"guzzlehttp/guzzle": "^7"
"guzzlehttp/guzzle": "^7",
"psalm/phar": "^5",
"leproxy/leproxy": "^0.2.2",
"revolt/event-loop-adapter-react": "^1",
"amphp/file": "^3.0"
},
"autoload": {
"psr-4": {
Expand Down
20 changes: 10 additions & 10 deletions examples/amp-with-guzzle.php
Original file line number Diff line number Diff line change
@@ -1,18 +1,18 @@
<?php
<?php declare(strict_types=1);

use Amp\Http\Client\Psr7\PsrAdapter;
use Amp\Http\Client\Request;
use Amp\Http\Client\Psr7\AmpHandler;
use GuzzleHttp\Client;
use Laminas\Diactoros\RequestFactory;
use Laminas\Diactoros\ResponseFactory;
use GuzzleHttp\HandlerStack;

use function Amp\async;
use function Amp\ByteStream\getStdout;

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

$psrAdapter = new PsrAdapter(new RequestFactory, new ResponseFactory);
$client = new Client(['handler' => HandlerStack::create(new AmpHandler)]);

$request = new Request('https://api.github.com/');
$future = async($client->get(...), 'https://api.github.com/', ['delay' => 1000]);

$psrResponse = (new Client)->send($psrAdapter->toPsrRequest($request));
$response = $psrAdapter->fromPsrResponse($psrResponse, $request);
getStdout()->write("First output: ".$client->get('https://api.github.com/')->getBody().PHP_EOL);

print $response->getBody()->buffer();
getStdout()->write("Deferred output: ".$future->await()->getBody().PHP_EOL);
2 changes: 1 addition & 1 deletion examples/psr-with-amp.php
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<?php
<?php declare(strict_types=1);

use Amp\Http\Client\HttpClientBuilder;
use Amp\Http\Client\Psr7\PsrAdapter;
Expand Down
28 changes: 11 additions & 17 deletions phpunit.xml.dist
Original file line number Diff line number Diff line change
@@ -1,19 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/9.0/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true"
>
<testsuites>
<testsuite name="Main">
<directory>test</directory>
</testsuite>
</testsuites>
<filter>
<whitelist>
<directory suffix=".php">src</directory>
</whitelist>
</filter>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/9.3/phpunit.xsd" bootstrap="vendor/autoload.php" colors="true">
<coverage>
<include>
<directory suffix=".php">src</directory>
</include>
</coverage>
<testsuites>
<testsuite name="Main">
<directory>test</directory>
</testsuite>
</testsuites>
</phpunit>

15 changes: 15 additions & 0 deletions psalm-baseline.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<files psalm-version="5.14.1@b9d355e0829c397b9b3b47d0c0ed042a8a70284d">
<file src="src/AmpHandler.php">
<PossiblyNullReference>
<code>fromPsrRequest</code>
</PossiblyNullReference>
</file>
<file src="src/PsrAdapter.php">
<ArgumentTypeCoercion>
<code><![CDATA[$source->getMethod()]]></code>
<code><![CDATA[$source->getProtocolVersion()]]></code>
<code><![CDATA[[$source->getProtocolVersion()]]]></code>
</ArgumentTypeCoercion>
</file>
</files>
2 changes: 1 addition & 1 deletion psalm.xml.dist
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<?xml version="1.0"?>
<psalm
phpVersion="7.2"
phpVersion="8.1"
resolveFromConfigFile="true"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns="https://getpsalm.org/schema/config"
Expand Down
207 changes: 207 additions & 0 deletions src/AmpHandler.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
<?php declare(strict_types=1);

namespace Amp\Http\Client\Psr7;

use Amp\CancelledException;
use Amp\DeferredCancellation;
use Amp\Dns\DnsRecord;
use Amp\File\File;
use Amp\Http\Client\Connection\DefaultConnectionFactory;
use Amp\Http\Client\Connection\UnlimitedConnectionPool;
use Amp\Http\Client\HttpClient;
use Amp\Http\Client\HttpClientBuilder;
use Amp\Http\Tunnel\Http1TunnelConnector;
use Amp\Http\Tunnel\Https1TunnelConnector;
use Amp\Http\Tunnel\Socks5TunnelConnector;
use Amp\Socket\Certificate;
use Amp\Socket\ClientTlsContext;
use Amp\Socket\ConnectContext;
use AssertionError;
use GuzzleHttp\Promise\Promise;
use GuzzleHttp\Promise\PromiseInterface;
use GuzzleHttp\Psr7\Request;
use GuzzleHttp\Psr7\Response;
use GuzzleHttp\Psr7\Uri;
use GuzzleHttp\RequestOptions;
use GuzzleHttp\Utils;
use Psr\Http\Message\RequestFactoryInterface;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseFactoryInterface;
use Psr\Http\Message\ResponseInterface;
use Throwable;

use function Amp\async;
use function Amp\ByteStream\pipe;
use function Amp\delay;
use function Amp\File\openFile;

/**
* Handler for guzzle which uses amphp/http-client.
*/
final class AmpHandler
{
private static ?PsrAdapter $psrAdapter;
private readonly HttpClient $client;
/** @var array<string, HttpClient> */
private array $cachedClients = [];
public function __construct(?HttpClient $client = null)
{
if (!\interface_exists(PromiseInterface::class)) {
throw new AssertionError("Please require guzzle to use the guzzle AmpHandler!");
}
$this->client = $client ?? (
(new HttpClientBuilder)
->followRedirects(0)
->build()
);
self::$psrAdapter ??= new PsrAdapter(new class implements RequestFactoryInterface {

Choose a reason for hiding this comment

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

this is the line that hardcodes guzzle's PSR-7

public function createRequest(string $method, $uri): RequestInterface
{
return new Request($method, $uri);
}
}, new class implements ResponseFactoryInterface {
public function createResponse(int $code = 200, string $reasonPhrase = ''): ResponseInterface
{
return new Response($code, reason: $reasonPhrase);
}
});
}

public function __invoke(RequestInterface $request, array $options): PromiseInterface
{
if (isset($options['curl'])) {
//throw new AssertionError("Cannot provide curl options when using AMP backend!");
}
$deferred = new DeferredCancellation;
$cancellation = $deferred->getCancellation();
$future = async(function () use ($request, $options, $cancellation) {
if (isset($options['delay'])) {
delay($options['delay'] / 1000.0, cancellation: $cancellation);
}
/** @psalm-suppress PossiblyNullReference Initialized in the constructor */
$request = self::$psrAdapter->fromPsrRequest($request);
if (isset($options[RequestOptions::TIMEOUT])) {

Choose a reason for hiding this comment

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

also this: this is not portable

$request->setTransferTimeout((float) $options[RequestOptions::TIMEOUT]);
$request->setInactivityTimeout((float) $options[RequestOptions::TIMEOUT]);
}
if (isset($options[RequestOptions::CONNECT_TIMEOUT])) {
$request->setTcpConnectTimeout((float) $options[RequestOptions::CONNECT_TIMEOUT]);
}

$client = $this->client;
if (isset($options[RequestOptions::CERT]) ||
isset($options[RequestOptions::PROXY]) || (
isset($options[RequestOptions::VERIFY])
&& $options[RequestOptions::VERIFY] !== true
) ||
isset($options[RequestOptions::FORCE_IP_RESOLVE])
) {
$cacheKey = [];
foreach ([RequestOptions::FORCE_IP_RESOLVE, RequestOptions::VERIFY, RequestOptions::PROXY, RequestOptions::CERT] as $k) {
$cacheKey[$k] = $options[$k] ?? null;
}
$cacheKey = json_encode($cacheKey);
if (isset($this->cachedClients[$cacheKey])) {
$client = $this->cachedClients[$cacheKey];
} else {
$tlsContext = null;
if (isset($options[RequestOptions::CERT])) {
$tlsContext ??= new ClientTlsContext();
if (\is_string($options[RequestOptions::CERT])) {
$tlsContext = $tlsContext->withCertificate(new Certificate(
$options[RequestOptions::CERT],
$options[RequestOptions::SSL_KEY] ?? null,
));
} else {
$tlsContext = $tlsContext->withCertificate(new Certificate(
$options[RequestOptions::CERT][0],
$options[RequestOptions::SSL_KEY] ?? null,
$options[RequestOptions::CERT][1]
));
}
}
if (isset($options[RequestOptions::VERIFY])) {
$tlsContext ??= new ClientTlsContext();
if ($options[RequestOptions::VERIFY] === false) {
$tlsContext = $tlsContext->withoutPeerVerification();
} elseif (\is_string($options[RequestOptions::VERIFY])) {
$tlsContext = $tlsContext->withCaFile($options[RequestOptions::VERIFY]);
}
}

$connector = null;
if (isset($options[RequestOptions::PROXY])) {
if (!\is_array($options['proxy'])) {
$connector = $options['proxy'];
} else {
$scheme = $request->getUri()->getScheme();
if (isset($options['proxy'][$scheme])) {
$host = $request->getUri()->getHost();
if (!isset($options['proxy']['no']) || !Utils::isHostInNoProxy($host, $options['proxy']['no'])) {
$connector = $options['proxy'][$scheme];
}
}
}

if ($connector !== null) {
$connector = new Uri($connector);
$connector = match ($connector->getScheme()) {
'http' => new Http1TunnelConnector($connector->getHost().':'.$connector->getPort()),
'https' => new Https1TunnelConnector($connector->getHost().':'.$connector->getPort(), new ClientTlsContext($connector->getHost())),
'socks5' => new Socks5TunnelConnector($connector->getHost().':'.$connector->getPort())
};
}
}

$connectContext = new ConnectContext;
if ($tlsContext) {
$connectContext = $connectContext->withTlsContext($tlsContext);
}
if (isset($options[RequestOptions::FORCE_IP_RESOLVE])) {
$connectContext->withDnsTypeRestriction(match ($options[RequestOptions::FORCE_IP_RESOLVE]) {
'v4' => DnsRecord::A,
'v6' => DnsRecord::AAAA,
});
}

$client = (new HttpClientBuilder)
->usingPool(new UnlimitedConnectionPool(new DefaultConnectionFactory(connector: $connector, connectContext: $connectContext)))
->build();

$this->cachedClients[$cacheKey] = $client;
}
}
if (isset($options['amp']['protocols'])) {
$request->setProtocolVersions($options['amp']['protocols']);
}
$response = $client->request(
$request,
$cancellation
);
if (isset($options[RequestOptions::SINK])) {
if (!\is_string($options[RequestOptions::SINK])) {
throw new AssertionError("Only a file name can be provided as sink!");
}
if (!\interface_exists(File::class)) {
throw new AssertionError("Please require amphp/file to use the sink option!");
}
$f = openFile($options[RequestOptions::SINK], 'w');
pipe($response->getBody(), $f, $cancellation);
}
return self::$psrAdapter->toPsrResponse($response);
});
$future->ignore();
$promise = new Promise(function () use ($future, $cancellation, &$promise) {
try {
$promise->resolve($future->await());
} catch (CancelledException $e) {
if (!$cancellation->isRequested()) {
$promise->reject($e);
}
} catch (Throwable $e) {
$promise->reject($e);
}
}, $deferred->cancel(...));
return $promise;
}
}
Loading