diff --git a/.php_cs.dist b/.php-cs-fixer.dist.php similarity index 70% rename from .php_cs.dist rename to .php-cs-fixer.dist.php index dbdd26d..133572d 100644 --- a/.php_cs.dist +++ b/.php-cs-fixer.dist.php @@ -1,14 +1,12 @@ 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; diff --git a/README.md b/README.md index 872b5e0..abff4e7 100644 --- a/README.md +++ b/README.md @@ -21,40 +21,38 @@ Create `Amp\Http\Client\Psr7\PsrAdapter` instance to convert client requests and ```php 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 diff --git a/composer.json b/composer.json index ffb623c..8a10c46 100644 --- a/composer.json +++ b/composer.json @@ -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": { diff --git a/examples/amp-with-guzzle.php b/examples/amp-with-guzzle.php index e35dc98..dd5648c 100644 --- a/examples/amp-with-guzzle.php +++ b/examples/amp-with-guzzle.php @@ -1,18 +1,18 @@ - 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); diff --git a/examples/psr-with-amp.php b/examples/psr-with-amp.php index dfafbeb..2f46f35 100644 --- a/examples/psr-with-amp.php +++ b/examples/psr-with-amp.php @@ -1,4 +1,4 @@ - - - - - test - - - - - src - - + + + + src + + + + + test + + - diff --git a/psalm-baseline.xml b/psalm-baseline.xml new file mode 100644 index 0000000..8950025 --- /dev/null +++ b/psalm-baseline.xml @@ -0,0 +1,15 @@ + + + + + fromPsrRequest + + + + + getMethod()]]> + getProtocolVersion()]]> + getProtocolVersion()]]]> + + + diff --git a/psalm.xml.dist b/psalm.xml.dist index ee41085..15b8153 100644 --- a/psalm.xml.dist +++ b/psalm.xml.dist @@ -1,6 +1,6 @@ */ + 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 { + 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])) { + $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; + } +} diff --git a/src/Internal/PsrInputStream.php b/src/Internal/PsrInputStream.php index 9a19756..f2927e6 100644 --- a/src/Internal/PsrInputStream.php +++ b/src/Internal/PsrInputStream.php @@ -1,16 +1,20 @@ - */ -final class PsrInputStream implements ReadableStream +final class PsrInputStream implements ReadableStream, \IteratorAggregate { + use ReadableStreamIteratorAggregate; public const DEFAULT_CHUNK_SIZE = 8192; private StreamInterface $stream; diff --git a/src/Internal/PsrMessageStream.php b/src/Internal/PsrMessageStream.php index d11cdb7..270ed8e 100644 --- a/src/Internal/PsrMessageStream.php +++ b/src/Internal/PsrMessageStream.php @@ -1,4 +1,4 @@ -timeout = $timeout; } - public function __toString() + public function __toString(): string { try { return $this->getContents(); diff --git a/src/Internal/PsrStreamBody.php b/src/Internal/PsrStreamBody.php index 68edf77..f325acd 100644 --- a/src/Internal/PsrStreamBody.php +++ b/src/Internal/PsrStreamBody.php @@ -1,15 +1,15 @@ -stream = $stream; } - public function createBodyStream(): ReadableStream + public function getContent(): ReadableStream { return new PsrInputStream($this->stream); } - public function getBodyLength(): ?int + public function getContentLength(): ?int { return $this->stream->getSize() ?? -1; } - public function getHeaders(): array + public function getContentType(): ?string { - return []; + return null; } } diff --git a/src/PsrAdapter.php b/src/PsrAdapter.php index e5866b5..0b55654 100644 --- a/src/PsrAdapter.php +++ b/src/PsrAdapter.php @@ -1,4 +1,4 @@ -getUri(), $source->getMethod()); $target->setHeaders($source->getHeaders()); + /** @psalm-suppress ArgumentTypeCoercion Wrong typehints in PSR */ $target->setProtocolVersions([$source->getProtocolVersion()]); $target->setBody(new PsrStreamBody($source->getBody())); @@ -37,6 +39,7 @@ public function fromPsrRequest(PsrRequest $source): Request public function fromPsrResponse(PsrResponse $source, Request $request, ?Response $previousResponse = null): Response { + /** @psalm-suppress ArgumentTypeCoercion Wrong typehints in PSR */ return new Response( $source->getProtocolVersion(), $source->getStatusCode(), @@ -53,22 +56,17 @@ public function toPsrRequest(Request $source, ?string $protocolVersion = null): { $target = $this->toPsrRequestWithoutBody($source, $protocolVersion); - $this->copyToPsrStream($source->getBody()->createBodyStream(), $target->getBody()); + $this->copyToPsrStream($source->getBody()->getContent(), $target->getBody()); return $target; } - /** - * @param Response $response - * - * @return PsrResponse - */ public function toPsrResponse(Response $response): PsrResponse { $psrResponse = $this->responseFactory->createResponse($response->getStatus(), $response->getReason()) ->withProtocolVersion($response->getProtocolVersion()); - foreach ($response->getRawHeaders() as [$headerName, $headerValue]) { + foreach ($response->getHeaderPairs() as [$headerName, $headerValue]) { $psrResponse = $psrResponse->withAddedHeader($headerName, $headerValue); } @@ -92,7 +90,7 @@ private function toPsrRequestWithoutBody( ): PsrRequest { $target = $this->requestFactory->createRequest($source->getMethod(), $source->getUri()); - foreach ($source->getRawHeaders() as [$headerName, $headerValue]) { + foreach ($source->getHeaderPairs() as [$headerName, $headerValue]) { $target = $target->withAddedHeader($headerName, $headerValue); } diff --git a/src/PsrHttpClient.php b/src/PsrHttpClient.php index f884487..67bcc2f 100644 --- a/src/PsrHttpClient.php +++ b/src/PsrHttpClient.php @@ -1,4 +1,4 @@ -psrAdapter = $psrAdapter; } - /** - * @param PsrRequest $request - * @param Cancellation|null $cancellation - * - * @return PsrResponse - */ public function sendRequest(PsrRequest $request, ?Cancellation $cancellation = null): PsrResponse { $internalRequest = $this->psrAdapter->fromPsrRequest($request); diff --git a/test/GuzzleAdapterTest.php b/test/GuzzleAdapterTest.php new file mode 100644 index 0000000..fd8fecb --- /dev/null +++ b/test/GuzzleAdapterTest.php @@ -0,0 +1,74 @@ + HandlerStack::create(new AmpHandler)]); + $this->assertNotEmpty((string) $client->get('https://example.com/')->getBody()); + } + public function testRequestDelay(): void + { + $client = new Client(['handler' => HandlerStack::create(new AmpHandler)]); + $future = async($client->get(...), 'https://example.com/', ['delay' => 1000]); + $this->assertFalse($future->isComplete()); + delay(1); + $t = \microtime(true); + $this->assertNotEmpty((string) $future->await()->getBody()); + $this->assertTrue(\microtime(true)-$t < 1); + } + public function testRequestProxies(): void + { + $proxy = new LeProxyServer(Loop::get()); + $socket = $proxy->listen('127.0.0.1:0', false); + + $client = new Client(['handler' => HandlerStack::create(new AmpHandler)]); + foreach (['socks5://', 'http://'] as $scheme) { + $uri = \str_replace('tcp://', $scheme, $socket->getAddress()); + + $result = $client->get('https://example.com/', [RequestOptions::PROXY => [ + 'https' => $uri + ]]); + $this->assertStringContainsString('Example Domain', (string) $result->getBody()); + } + } + public function testRequestDelayGuzzleAsync(): void + { + $client = new Client(['handler' => HandlerStack::create(new AmpHandler)]); + $promise = $client->getAsync('https://example.com/', ['delay' => 1000]); + $this->assertEquals($promise->getState(), PromiseInterface::PENDING); + delay(1); + $t = \microtime(true); + $this->assertNotEmpty((string) $promise->wait()->getBody()); + $this->assertTrue(\microtime(true)-$t < 1); + } + public function testRequestCancel(): void + { + $client = new Client(['handler' => HandlerStack::create(new AmpHandler)]); + $promise = $client->getAsync('https://example.com/', ['delay' => 2000]); + $promise->cancel(); + $this->assertEquals($promise->getState(), PromiseInterface::REJECTED); + } + public function testRequest404(): void + { + $this->expectExceptionMessageMatches('/404 Not Found/'); + $client = new Client(['handler' => HandlerStack::create(new AmpHandler)]); + $client->get('https://example.com/test'); + } +} diff --git a/test/Internal/PsrInputStreamTest.php b/test/Internal/PsrInputStreamTest.php index f24b1ad..d04a1f7 100644 --- a/test/Internal/PsrInputStreamTest.php +++ b/test/Internal/PsrInputStreamTest.php @@ -1,4 +1,4 @@ -getBodyLength()); + self::assertSame($expectedSize, $body->getContentLength()); } public function providerBodyLength(): array @@ -38,12 +34,12 @@ public function providerBodyLength(): array ]; } - public function testGetHeadersReturnsEmptyList(): void + public function testContentTypeIsNull(): void { $stream = $this->createMock(StreamInterface::class); $body = new PsrStreamBody($stream); - self::assertSame([], $body->getHeaders()); + self::assertNull($body->getContentType()); } public function testCreateBodyStreamResultReadsFromOriginalStream(): void @@ -51,6 +47,6 @@ public function testCreateBodyStreamResultReadsFromOriginalStream(): void $stream = (new StreamFactory())->createStream('body_content'); $body = new PsrStreamBody($stream); - self::assertSame('body_content', buffer($body->createBodyStream())); + self::assertSame('body_content', buffer($body->getContent())); } } diff --git a/test/PsrAdapterTest.php b/test/PsrAdapterTest.php index 546fa12..0aeab54 100644 --- a/test/PsrAdapterTest.php +++ b/test/PsrAdapterTest.php @@ -1,13 +1,13 @@ -toPsrResponse($source); - self::assertSame(Status::NOT_FOUND, $target->getStatusCode()); + self::assertSame(HttpStatus::NOT_FOUND, $target->getStatusCode()); } public function testToPsrResponseReturnsResponseWithEqualReason(): void @@ -206,7 +202,7 @@ public function testToPsrResponseReturnsResponseWithEqualReason(): void $source = new Response( '1.1', - Status::OK, + HttpStatus::OK, 'a', [], new ReadableBuffer(''), @@ -224,7 +220,7 @@ public function testToPsrResponseReturnsResponseWithEqualHeaders(): void $source = new Response( '1.1', - Status::OK, + HttpStatus::OK, null, ['a' => 'b', 'c' => ['d', 'e']], new ReadableBuffer(''), @@ -242,7 +238,7 @@ public function testToPsrResponseReturnsResponseWithEqualBody(): void $source = new Response( '1.1', - Status::OK, + HttpStatus::OK, null, [], new ReadableBuffer('body_content'), @@ -286,7 +282,7 @@ public function testFromPsrResponseWithPreviousResponseReturnsResponseWithSamePr $previousResponse = new Response( '1.1', - Status::OK, + HttpStatus::OK, null, [], new ReadableBuffer(''), @@ -315,11 +311,11 @@ public function testFromPsrResponseReturnsResultWithEqualStatus(): void { $adapter = new PsrAdapter(new RequestFactory, new ResponseFactory); - $source = (new PsrResponse())->withStatus(Status::NOT_FOUND); + $source = (new PsrResponse())->withStatus(HttpStatus::NOT_FOUND); $target = $adapter->fromPsrResponse($source, new Request('')); - self::assertSame(Status::NOT_FOUND, $target->getStatus()); + self::assertSame(HttpStatus::NOT_FOUND, $target->getStatus()); } public function testFromPsrResponseReturnsResultWithEqualHeaders(): void @@ -328,7 +324,7 @@ public function testFromPsrResponseReturnsResultWithEqualHeaders(): void $source = new PsrResponse( 'php://memory', - Status::OK, + HttpStatus::OK, ['a' => 'b', 'c' => ['d', 'e']] ); @@ -351,9 +347,9 @@ public function testFromPsrResponseReturnsResultWithEqualBody(): void self::assertSame('body_content', $target->getBody()->buffer()); } - private function readBody(RequestBody $body): string + private function readBody(HttpContent $body): string { - $stream = $body->createBodyStream(); + $stream = $body->getContent(); return buffer($stream); }