From b39f484e79106b1331be092dcb11da80bac8de24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Tue, 7 Jul 2020 09:04:20 +0200 Subject: [PATCH 1/5] Prepare TOC to avoid name collisions with HTTP client --- README.md | 12 ++++++------ src/Server.php | 8 ++++---- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index d53e1540..84af8325 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Event-driven, streaming plaintext HTTP and secure HTTPS server for [ReactPHP](ht * [Usage](#usage) * [Server](#server) * [listen()](#listen) - * [Request](#request) + * [Server Request](#server-request) * [Request parameters](#request-parameters) * [Query parameters](#query-parameters) * [Request body](#request-body) @@ -71,7 +71,7 @@ processing each incoming HTTP request. When a complete HTTP request has been received, it will invoke the given request handler function. This request handler function needs to be passed to -the constructor and will be invoked with the respective [request](#request) +the constructor and will be invoked with the respective [request](#server-request) object and expects a [response](#response) object in return: ```php @@ -88,7 +88,7 @@ $server = new React\Http\Server(function (Psr\Http\Message\ServerRequestInterfac Each incoming HTTP request message is always represented by the [PSR-7 `ServerRequestInterface`](https://www.php-fig.org/psr/psr-7/#321-psrhttpmessageserverrequestinterface), -see also following [request](#request) chapter for more details. +see also following [request](#server-request) chapter for more details. Each outgoing HTTP response message is always represented by the [PSR-7 `ResponseInterface`](https://www.php-fig.org/psr/psr-7/#33-psrhttpmessageresponseinterface), @@ -115,7 +115,7 @@ for more details. By default, the `Server` buffers and parses the complete incoming HTTP request in memory. It will invoke the given request handler function when the complete request headers and request body has been received. This means the -[request](#request) object passed to your request handler function will be +[request](#server-request) object passed to your request handler function will be fully compatible with PSR-7 (http-message). This provides sane defaults for 80% of the use cases and is the recommended way to use this library unless you're sure you know what you're doing. @@ -190,7 +190,7 @@ $server = new React\Http\Server(array( In this case, it will invoke the request handler function once the HTTP request headers have been received, i.e. before receiving the potentially -much larger HTTP request body. This means the [request](#request) passed to +much larger HTTP request body. This means the [request](#server-request) passed to your request handler function may not be fully compatible with PSR-7. This is specifically designed to help with more advanced use cases where you want to have full control over consuming the incoming HTTP request body and @@ -246,7 +246,7 @@ $server->listen($socket); See also [example #11](examples) for more details. -### Request +### Server Request As seen above, the [`Server`](#server) class is responsible for handling incoming connections and then processing each incoming HTTP request. diff --git a/src/Server.php b/src/Server.php index 7c4724c5..3fe942c9 100644 --- a/src/Server.php +++ b/src/Server.php @@ -17,7 +17,7 @@ * * When a complete HTTP request has been received, it will invoke the given * request handler function. This request handler function needs to be passed to - * the constructor and will be invoked with the respective [request](#request) + * the constructor and will be invoked with the respective [request](#server-request) * object and expects a [response](#response) object in return: * * ```php @@ -34,7 +34,7 @@ * * Each incoming HTTP request message is always represented by the * [PSR-7 `ServerRequestInterface`](https://www.php-fig.org/psr/psr-7/#321-psrhttpmessageserverrequestinterface), - * see also following [request](#request) chapter for more details. + * see also following [request](#server-request) chapter for more details. * * Each outgoing HTTP response message is always represented by the * [PSR-7 `ResponseInterface`](https://www.php-fig.org/psr/psr-7/#33-psrhttpmessageresponseinterface), @@ -61,7 +61,7 @@ * By default, the `Server` buffers and parses the complete incoming HTTP * request in memory. It will invoke the given request handler function when the * complete request headers and request body has been received. This means the - * [request](#request) object passed to your request handler function will be + * [request](#server-request) object passed to your request handler function will be * fully compatible with PSR-7 (http-message). This provides sane defaults for * 80% of the use cases and is the recommended way to use this library unless * you're sure you know what you're doing. @@ -136,7 +136,7 @@ * * In this case, it will invoke the request handler function once the HTTP * request headers have been received, i.e. before receiving the potentially - * much larger HTTP request body. This means the [request](#request) passed to + * much larger HTTP request body. This means the [request](#server-request) passed to * your request handler function may not be fully compatible with PSR-7. This is * specifically designed to help with more advanced use cases where you want to * have full control over consuming the incoming HTTP request body and From 2538e61eead1b438d73f4362dc2d819d1318cfb2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Fri, 3 Jul 2020 22:02:52 +0200 Subject: [PATCH 2/5] Import clue/reactphp-buzz v2.9.0 Change namespace from `Clue\React\Buzz` to `React\Http` and update all tests with merged namespaces. See https://github.com/clue/reactphp-buzz for original repo. --- LICENSE | 3 + README.md | 1328 +++++++++++++++++++++- composer.json | 16 +- examples/01-google.php | 15 + examples/02-concurrent.php | 23 + examples/03-any.php | 32 + examples/04-post-json.php | 29 + examples/05-put-xml.php | 26 + examples/11-http-proxy.php | 29 + examples/12-socks-proxy.php | 29 + examples/13-ssh-proxy.php | 29 + examples/14-unix-domain-sockets.php | 27 + examples/21-stream-forwarding.php | 33 + examples/22-stream-stdin.php | 27 + examples/91-benchmark-download.php | 61 + examples/92-benchmark-upload.php | 125 ++ src/Browser.php | 867 ++++++++++++++ src/Io/ChunkedEncoder.php | 30 +- src/Io/Sender.php | 161 +++ src/Io/Transaction.php | 305 +++++ src/Message/MessageFactory.php | 139 +++ src/Message/ReadableBodyStream.php | 153 +++ src/Message/ResponseException.php | 43 + tests/BrowserTest.php | 414 +++++++ tests/FunctionalBrowserTest.php | 645 +++++++++++ tests/Io/ChunkedEncoderTest.php | 4 +- tests/Io/SenderTest.php | 393 +++++++ tests/Io/TransactionTest.php | 861 ++++++++++++++ tests/Message/MessageFactoryTest.php | 197 ++++ tests/Message/ReadableBodyStreamTest.php | 255 +++++ tests/Message/ResponseExceptionTest.php | 23 + tests/TestCase.php | 14 +- 32 files changed, 6291 insertions(+), 45 deletions(-) create mode 100644 examples/01-google.php create mode 100644 examples/02-concurrent.php create mode 100644 examples/03-any.php create mode 100644 examples/04-post-json.php create mode 100644 examples/05-put-xml.php create mode 100644 examples/11-http-proxy.php create mode 100644 examples/12-socks-proxy.php create mode 100644 examples/13-ssh-proxy.php create mode 100644 examples/14-unix-domain-sockets.php create mode 100644 examples/21-stream-forwarding.php create mode 100644 examples/22-stream-stdin.php create mode 100644 examples/91-benchmark-download.php create mode 100644 examples/92-benchmark-upload.php create mode 100644 src/Browser.php create mode 100644 src/Io/Sender.php create mode 100644 src/Io/Transaction.php create mode 100644 src/Message/MessageFactory.php create mode 100644 src/Message/ReadableBodyStream.php create mode 100644 src/Message/ResponseException.php create mode 100644 tests/BrowserTest.php create mode 100644 tests/FunctionalBrowserTest.php create mode 100644 tests/Io/SenderTest.php create mode 100644 tests/Io/TransactionTest.php create mode 100644 tests/Message/MessageFactoryTest.php create mode 100644 tests/Message/ReadableBodyStreamTest.php create mode 100644 tests/Message/ResponseExceptionTest.php diff --git a/LICENSE b/LICENSE index a808108c..0ca9208a 100644 --- a/LICENSE +++ b/LICENSE @@ -1,3 +1,6 @@ +The MIT License (MIT) + +Copyright (c) 2013 Christian Lück Copyright (c) 2012 Igor Wiedler, Chris Boden Permission is hereby granted, free of charge, to any person obtaining a copy diff --git a/README.md b/README.md index 84af8325..af2fada9 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,22 @@ Event-driven, streaming plaintext HTTP and secure HTTPS server for [ReactPHP](ht **Table of contents** * [Quickstart example](#quickstart-example) -* [Usage](#usage) +* [Client Usage](#client-usage) + * [Request methods](#request-methods) + * [Promises](#promises) + * [Cancellation](#cancellation) + * [Timeouts](#timeouts) + * [Authentication](#authentication) + * [Redirects](#redirects) + * [Blocking](#blocking) + * [Concurrency](#concurrency) + * [Streaming response](#streaming-response) + * [Streaming request](#streaming-request) + * [HTTP proxy](#http-proxy) + * [SOCKS proxy](#socks-proxy) + * [SSH proxy](#ssh-proxy) + * [Unix domain sockets](#unix-domain-sockets) +* [Server Usage](#server-usage) * [Server](#server) * [listen()](#listen) * [Server Request](#server-request) @@ -28,17 +43,54 @@ Event-driven, streaming plaintext HTTP and secure HTTPS server for [ReactPHP](ht * [Custom middleware](#custom-middleware) * [Third-Party Middleware](#third-party-middleware) * [API](#api) + * [Browser](#browser) + * [get()](#get) + * [post()](#post) + * [head()](#head) + * [patch()](#patch) + * [put()](#put) + * [delete()](#delete) + * [request()](#request) + * [requestStreaming()](#requeststreaming) + * [~~submit()~~](#submit) + * [~~send()~~](#send) + * [withTimeout()](#withtimeout) + * [withFollowRedirects()](#withfollowredirects) + * [withRejectErrorResponse()](#withrejecterrorresponse) + * [withBase()](#withbase) + * [withProtocolVersion()](#withprotocolversion) + * [withResponseBuffer()](#withresponsebuffer) + * [~~withOptions()~~](#withoptions) + * [~~withoutBase()~~](#withoutbase) * [React\Http\Middleware](#reacthttpmiddleware) * [StreamingRequestMiddleware](#streamingrequestmiddleware) * [LimitConcurrentRequestsMiddleware](#limitconcurrentrequestsmiddleware) * [RequestBodyBufferMiddleware](#requestbodybuffermiddleware) * [RequestBodyParserMiddleware](#requestbodyparsermiddleware) + * [ResponseInterface](#responseinterface) + * [RequestInterface](#requestinterface) + * [UriInterface](#uriinterface) + * [ResponseException](#responseexception) * [Install](#install) * [Tests](#tests) * [License](#license) ## Quickstart example +Once [installed](#install), you can use the following code to access a +HTTP webserver and send some simple HTTP GET requests: + +```php +$loop = React\EventLoop\Factory::create(); +$client = new React\Http\Browser($loop); + +$client->get('http://www.google.com/')->then(function (Psr\Http\Message\ResponseInterface $response) { + var_dump($response->getHeaders(), (string)$response->getBody()); +}); + +$loop->run(); +``` + This is an HTTP server which responds with `Hello World!` to every request. ```php @@ -62,7 +114,597 @@ $loop->run(); See also the [examples](examples). -## Usage +## Client Usage + +### Request methods + + + +Most importantly, this project provides a [`Browser`](#browser) object that +offers several methods that resemble the HTTP protocol methods: + +```php +$browser->get($url, array $headers = array()); +$browser->head($url, array $headers = array()); +$browser->post($url, array $headers = array(), string|ReadableStreamInterface $contents = ''); +$browser->delete($url, array $headers = array(), string|ReadableStreamInterface $contents = ''); +$browser->put($url, array $headers = array(), string|ReadableStreamInterface $contents = ''); +$browser->patch($url, array $headers = array(), string|ReadableStreamInterface $contents = ''); +``` + +Each of these methods requires a `$url` and some optional parameters to send an +HTTP request. Each of these method names matches the respective HTTP request +method, for example the [`get()`](#get) method sends an HTTP `GET` request. + +You can optionally pass an associative array of additional `$headers` that will be +sent with this HTTP request. Additionally, each method will automatically add a +matching `Content-Length` request header if an outgoing request body is given and its +size is known and non-empty. For an empty request body, if will only include a +`Content-Length: 0` request header if the request method usually expects a request +body (only applies to `POST`, `PUT` and `PATCH` HTTP request methods). + +If you're using a [streaming request body](#streaming-request), it will default +to using `Transfer-Encoding: chunked` unless you explicitly pass in a matching `Content-Length` +request header. See also [streaming request](#streaming-request) for more details. + +By default, all of the above methods default to sending requests using the +HTTP/1.1 protocol version. If you want to explicitly use the legacy HTTP/1.0 +protocol version, you can use the [`withProtocolVersion()`](#withprotocolversion) +method. If you want to use any other or even custom HTTP request method, you can +use the [`request()`](#request) method. + +Each of the above methods supports async operation and either *fulfills* with a +[`ResponseInterface`](#responseinterface) or *rejects* with an `Exception`. +Please see the following chapter about [promises](#promises) for more details. + +### Promises + +Sending requests is async (non-blocking), so you can actually send multiple +requests in parallel. +The `Browser` will respond to each request with a [`ResponseInterface`](#responseinterface) +message, the order is not guaranteed. +Sending requests uses a [Promise](https://github.com/reactphp/promise)-based +interface that makes it easy to react to when an HTTP request is completed +(i.e. either successfully fulfilled or rejected with an error): + +```php +$browser->get($url)->then( + function (Psr\Http\Message\ResponseInterface $response) { + var_dump('Response received', $response); + }, + function (Exception $error) { + var_dump('There was an error', $error->getMessage()); + } +); +``` + +If this looks strange to you, you can also use the more traditional [blocking API](#blocking). + +Keep in mind that resolving the Promise with the full response message means the +whole response body has to be kept in memory. +This is easy to get started and works reasonably well for smaller responses +(such as common HTML pages or RESTful or JSON API requests). + +You may also want to look into the [streaming API](#streaming-response): + +* If you're dealing with lots of concurrent requests (100+) or +* If you want to process individual data chunks as they happen (without having to wait for the full response body) or +* If you're expecting a big response body size (1 MiB or more, for example when downloading binary files) or +* If you're unsure about the response body size (better be safe than sorry when accessing arbitrary remote HTTP endpoints and the response body size is unknown in advance). + +### Cancellation + +The returned Promise is implemented in such a way that it can be cancelled +when it is still pending. +Cancelling a pending promise will reject its value with an Exception and +clean up any underlying resources. + +```php +$promise = $browser->get($url); + +$loop->addTimer(2.0, function () use ($promise) { + $promise->cancel(); +}); +``` + +### Timeouts + +This library uses a very efficient HTTP implementation, so most HTTP requests +should usually be completed in mere milliseconds. However, when sending HTTP +requests over an unreliable network (the internet), there are a number of things +that can go wrong and may cause the request to fail after a time. As such, this +library respects PHP's `default_socket_timeout` setting (default 60s) as a timeout +for sending the outgoing HTTP request and waiting for a successful response and +will otherwise cancel the pending request and reject its value with an Exception. + +Note that this timeout value covers creating the underlying transport connection, +sending the HTTP request, receiving the HTTP response headers and its full +response body and following any eventual [redirects](#redirects). See also +[redirects](#redirects) below to configure the number of redirects to follow (or +disable following redirects altogether) and also [streaming](#streaming-response) +below to not take receiving large response bodies into account for this timeout. + +You can use the [`withTimeout()` method](#withtimeout) to pass a custom timeout +value in seconds like this: + +```php +$browser = $browser->withTimeout(10.0); + +$browser->get($url)->then(function (Psr\Http\Message\ResponseInterface $response) { + // response received within 10 seconds maximum + var_dump($response->getHeaders()); +}); +``` + +Similarly, you can use a bool `false` to not apply a timeout at all +or use a bool `true` value to restore the default handling. +See [`withTimeout()`](#withtimeout) for more details. + +If you're using a [streaming response body](#streaming-response), the time it +takes to receive the response body stream will not be included in the timeout. +This allows you to keep this incoming stream open for a longer time, such as +when downloading a very large stream or when streaming data over a long-lived +connection. + +If you're using a [streaming request body](#streaming-request), the time it +takes to send the request body stream will not be included in the timeout. This +allows you to keep this outgoing stream open for a longer time, such as when +uploading a very large stream. + +Note that this timeout handling applies to the higher-level HTTP layer. Lower +layers such as socket and DNS may also apply (different) timeout values. In +particular, the underlying socket connection uses the same `default_socket_timeout` +setting to establish the underlying transport connection. To control this +connection timeout behavior, you can [inject a custom `Connector`](#browser) +like this: + +```php +$browser = new React\Http\Browser( + $loop, + new React\Socket\Connector( + $loop, + array( + 'timeout' => 5 + ) + ) +); +``` + +### Authentication + +This library supports [HTTP Basic Authentication](https://en.wikipedia.org/wiki/Basic_access_authentication) +using the `Authorization: Basic …` request header or allows you to set an explicit +`Authorization` request header. + +By default, this library does not include an outgoing `Authorization` request +header. If the server requires authentication, if may return a `401` (Unauthorized) +status code which will reject the request by default (see also the +[`withRejectErrorResponse()` method](#withrejecterrorresponse) below). + +In order to pass authentication details, you can simple pass the username and +password as part of the request URL like this: + +```php +$promise = $browser->get('https://user:pass@example.com/api'); +``` + +Note that special characters in the authentication details have to be +percent-encoded, see also [`rawurlencode()`](https://www.php.net/manual/en/function.rawurlencode.php). +This example will automatically pass the base64-encoded authentication details +using the outgoing `Authorization: Basic …` request header. If the HTTP endpoint +you're talking to requires any other authentication scheme, you can also pass +this header explicitly. This is common when using (RESTful) HTTP APIs that use +OAuth access tokens or JSON Web Tokens (JWT): + +```php +$token = 'abc123'; + +$promise = $browser->get( + 'https://example.com/api', + array( + 'Authorization' => 'Bearer ' . $token + ) +); +``` + +When following redirects, the `Authorization` request header will never be sent +to any remote hosts by default. When following a redirect where the `Location` +response header contains authentication details, these details will be sent for +following requests. See also [redirects](#redirects) below. + +### Redirects + +By default, this library follows any redirects and obeys `3xx` (Redirection) +status codes using the `Location` response header from the remote server. +The promise will be fulfilled with the last response from the chain of redirects. + +```php +$browser->get($url, $headers)->then(function (Psr\Http\Message\ResponseInterface $response) { + // the final response will end up here + var_dump($response->getHeaders()); +}); +``` + +Any redirected requests will follow the semantics of the original request and +will include the same request headers as the original request except for those +listed below. +If the original request contained a request body, this request body will never +be passed to the redirected request. Accordingly, each redirected request will +remove any `Content-Length` and `Content-Type` request headers. + +If the original request used HTTP authentication with an `Authorization` request +header, this request header will only be passed as part of the redirected +request if the redirected URL is using the same host. In other words, the +`Authorizaton` request header will not be forwarded to other foreign hosts due to +possible privacy/security concerns. When following a redirect where the `Location` +response header contains authentication details, these details will be sent for +following requests. + +You can use the [`withFollowRedirects()`](#withfollowredirects) method to +control the maximum number of redirects to follow or to return any redirect +responses as-is and apply custom redirection logic like this: + +```php +$browser = $browser->withFollowRedirects(false); + +$browser->get($url)->then(function (Psr\Http\Message\ResponseInterface $response) { + // any redirects will now end up here + var_dump($response->getHeaders()); +}); +``` + +See also [`withFollowRedirects()`](#withfollowredirects) for more details. + +### Blocking + +As stated above, this library provides you a powerful, async API by default. + +If, however, you want to integrate this into your traditional, blocking environment, +you should look into also using [clue/reactphp-block](https://github.com/clue/reactphp-block). + +The resulting blocking code could look something like this: + +```php +use Clue\React\Block; + +$loop = React\EventLoop\Factory::create(); +$browser = new React\Http\Browser($loop); + +$promise = $browser->get('http://example.com/'); + +try { + $response = Block\await($promise, $loop); + // response successfully received +} catch (Exception $e) { + // an error occured while performing the request +} +``` + +Similarly, you can also process multiple requests concurrently and await an array of `Response` objects: + +```php +$promises = array( + $browser->get('http://example.com/'), + $browser->get('http://www.example.org/'), +); + +$responses = Block\awaitAll($promises, $loop); +``` + +Please refer to [clue/reactphp-block](https://github.com/clue/reactphp-block#readme) for more details. + +Keep in mind the above remark about buffering the whole response message in memory. +As an alternative, you may also see one of the following chapters for the +[streaming API](#streaming-response). + +### Concurrency + +As stated above, this library provides you a powerful, async API. Being able to +send a large number of requests at once is one of the core features of this +project. For instance, you can easily send 100 requests concurrently while +processing SQL queries at the same time. + +Remember, with great power comes great responsibility. Sending an excessive +number of requests may either take up all resources on your side or it may even +get you banned by the remote side if it sees an unreasonable number of requests +from your side. + +```php +// watch out if array contains many elements +foreach ($urls as $url) { + $browser->get($url)->then(function (Psr\Http\Message\ResponseInterface $response) { + var_dump($response->getHeaders()); + }); +} +``` + +As a consequence, it's usually recommended to limit concurrency on the sending +side to a reasonable value. It's common to use a rather small limit, as doing +more than a dozen of things at once may easily overwhelm the receiving side. You +can use [clue/reactphp-mq](https://github.com/clue/reactphp-mq) as a lightweight +in-memory queue to concurrently do many (but not too many) things at once: + +```php +// wraps Browser in a Queue object that executes no more than 10 operations at once +$q = new Clue\React\Mq\Queue(10, null, function ($url) use ($browser) { + return $browser->get($url); +}); + +foreach ($urls as $url) { + $q($url)->then(function (Psr\Http\Message\ResponseInterface $response) { + var_dump($response->getHeaders()); + }); +} +``` + +Additional requests that exceed the concurrency limit will automatically be +enqueued until one of the pending requests completes. This integrates nicely +with the existing [Promise-based API](#promises). Please refer to +[clue/reactphp-mq](https://github.com/clue/reactphp-mq) for more details. + +This in-memory approach works reasonably well for some thousand outstanding +requests. If you're processing a very large input list (think millions of rows +in a CSV or NDJSON file), you may want to look into using a streaming approach +instead. See [clue/reactphp-flux](https://github.com/clue/reactphp-flux) for +more details. + +### Streaming response + + + +All of the above examples assume you want to store the whole response body in memory. +This is easy to get started and works reasonably well for smaller responses. + +However, there are several situations where it's usually a better idea to use a +streaming approach, where only small chunks have to be kept in memory: + +* If you're dealing with lots of concurrent requests (100+) or +* If you want to process individual data chunks as they happen (without having to wait for the full response body) or +* If you're expecting a big response body size (1 MiB or more, for example when downloading binary files) or +* If you're unsure about the response body size (better be safe than sorry when accessing arbitrary remote HTTP endpoints and the response body size is unknown in advance). + +You can use the [`requestStreaming()`](#requeststreaming) method to send an +arbitrary HTTP request and receive a streaming response. It uses the same HTTP +message API, but does not buffer the response body in memory. It only processes +the response body in small chunks as data is received and forwards this data +through [ReactPHP's Stream API](https://github.com/reactphp/stream). This works +for (any number of) responses of arbitrary sizes. + +This means it resolves with a normal [`ResponseInterface`](#responseinterface), +which can be used to access the response message parameters as usual. +You can access the message body as usual, however it now also +implements ReactPHP's [`ReadableStreamInterface`](https://github.com/reactphp/stream#readablestreaminterface) +as well as parts of the PSR-7's [`StreamInterface`](https://www.php-fig.org/psr/psr-7/#3-4-psr-http-message-streaminterface). + +```php +$browser->requestStreaming('GET', $url)->then(function (Psr\Http\Message\ResponseInterface $response) { + $body = $response->getBody(); + assert($body instanceof Psr\Http\Message\StreamInterface); + assert($body instanceof React\Stream\ReadableStreamInterface); + + $body->on('data', function ($chunk) { + echo $chunk; + }); + + $body->on('error', function (Exception $error) { + echo 'Error: ' . $error->getMessage() . PHP_EOL; + }); + + $body->on('close', function () { + echo '[DONE]' . PHP_EOL; + }); +}); +``` + +See also the [stream download example](examples/91-benchmark-download.php) and +the [stream forwarding example](examples/21-stream-forwarding.php). + +You can invoke the following methods on the message body: + +```php +$body->on($event, $callback); +$body->eof(); +$body->isReadable(); +$body->pipe(React\Stream\WritableStreamInterface $dest, array $options = array()); +$body->close(); +$body->pause(); +$body->resume(); +``` + +Because the message body is in a streaming state, invoking the following methods +doesn't make much sense: + +```php +$body->__toString(); // '' +$body->detach(); // throws BadMethodCallException +$body->getSize(); // null +$body->tell(); // throws BadMethodCallException +$body->isSeekable(); // false +$body->seek(); // throws BadMethodCallException +$body->rewind(); // throws BadMethodCallException +$body->isWritable(); // false +$body->write(); // throws BadMethodCallException +$body->read(); // throws BadMethodCallException +$body->getContents(); // throws BadMethodCallException +``` + +Note how [timeouts](#timeouts) apply slightly differently when using streaming. +In streaming mode, the timeout value covers creating the underlying transport +connection, sending the HTTP request, receiving the HTTP response headers and +following any eventual [redirects](#redirects). In particular, the timeout value +does not take receiving (possibly large) response bodies into account. + +If you want to integrate the streaming response into a higher level API, then +working with Promise objects that resolve with Stream objects is often inconvenient. +Consider looking into also using [react/promise-stream](https://github.com/reactphp/promise-stream). +The resulting streaming code could look something like this: + +```php +use React\Promise\Stream; + +function download(Browser $browser, string $url): React\Stream\ReadableStreamInterface { + return Stream\unwrapReadable( + $browser->requestStreaming('GET', $url)->then(function (Psr\Http\Message\ResponseInterface $response) { + return $response->getBody(); + }) + ); +} + +$stream = download($browser, $url); +$stream->on('data', function ($data) { + echo $data; +}); +``` + +See also the [`requestStreaming()`](#requeststreaming) method for more details. + +> Legacy info: Legacy versions prior to v2.9.0 used the legacy + [`streaming` option](#withoptions). This option is now deprecated but otherwise + continues to show the exact same behavior. + +### Streaming request + +Besides streaming the response body, you can also stream the request body. +This can be useful if you want to send big POST requests (uploading files etc.) +or process many outgoing streams at once. +Instead of passing the body as a string, you can simply pass an instance +implementing ReactPHP's [`ReadableStreamInterface`](https://github.com/reactphp/stream#readablestreaminterface) +to the [request methods](#request-methods) like this: + +```php +$browser->post($url, array(), $stream)->then(function (Psr\Http\Message\ResponseInterface $response) { + echo 'Successfully sent.'; +}); +``` + +If you're using a streaming request body (`React\Stream\ReadableStreamInterface`), it will +default to using `Transfer-Encoding: chunked` or you have to explicitly pass in a +matching `Content-Length` request header like so: + +```php +$body = new React\Stream\ThroughStream(); +$loop->addTimer(1.0, function () use ($body) { + $body->end("hello world"); +}); + +$browser->post($url, array('Content-Length' => '11'), $body); +``` + +If the streaming request body emits an `error` event or is explicitly closed +without emitting a successful `end` event first, the request will automatically +be closed and rejected. + +### HTTP proxy + +You can also establish your outgoing connections through an HTTP CONNECT proxy server +by adding a dependency to [clue/reactphp-http-proxy](https://github.com/clue/reactphp-http-proxy). + +HTTP CONNECT proxy servers (also commonly known as "HTTPS proxy" or "SSL proxy") +are commonly used to tunnel HTTPS traffic through an intermediary ("proxy"), to +conceal the origin address (anonymity) or to circumvent address blocking +(geoblocking). While many (public) HTTP CONNECT proxy servers often limit this +to HTTPS port`443` only, this can technically be used to tunnel any TCP/IP-based +protocol, such as plain HTTP and TLS-encrypted HTTPS. + +```php +$proxy = new Clue\React\HttpProxy\ProxyConnector( + 'http://127.0.0.1:8080', + new React\Socket\Connector($loop) +); + +$connector = new React\Socket\Connector($loop, array( + 'tcp' => $proxy, + 'dns' => false +)); + +$browser = new React\Http\Browser($loop, $connector); +``` + +See also the [HTTP CONNECT proxy example](examples/11-http-proxy.php). + +### SOCKS proxy + +You can also establish your outgoing connections through a SOCKS proxy server +by adding a dependency to [clue/reactphp-socks](https://github.com/clue/reactphp-socks). + +The SOCKS proxy protocol family (SOCKS5, SOCKS4 and SOCKS4a) is commonly used to +tunnel HTTP(S) traffic through an intermediary ("proxy"), to conceal the origin +address (anonymity) or to circumvent address blocking (geoblocking). While many +(public) SOCKS proxy servers often limit this to HTTP(S) port `80` and `443` +only, this can technically be used to tunnel any TCP/IP-based protocol. + +```php +$proxy = new Clue\React\Socks\Client( + 'socks://127.0.0.1:1080', + new React\Socket\Connector($loop) +); + +$connector = new React\Socket\Connector($loop, array( + 'tcp' => $proxy, + 'dns' => false +)); + +$browser = new React\Http\Browser($loop, $connector); +``` + +See also the [SOCKS proxy example](examples/12-socks-proxy.php). + +### SSH proxy + +You can also establish your outgoing connections through an SSH server +by adding a dependency to [clue/reactphp-ssh-proxy](https://github.com/clue/reactphp-ssh-proxy). + +[Secure Shell (SSH)](https://en.wikipedia.org/wiki/Secure_Shell) is a secure +network protocol that is most commonly used to access a login shell on a remote +server. Its architecture allows it to use multiple secure channels over a single +connection. Among others, this can also be used to create an "SSH tunnel", which +is commonly used to tunnel HTTP(S) traffic through an intermediary ("proxy"), to +conceal the origin address (anonymity) or to circumvent address blocking +(geoblocking). This can be used to tunnel any TCP/IP-based protocol (HTTP, SMTP, +IMAP etc.), allows you to access local services that are otherwise not accessible +from the outside (database behind firewall) and as such can also be used for +plain HTTP and TLS-encrypted HTTPS. + +```php +$proxy = new Clue\React\SshProxy\SshSocksConnector('me@localhost:22', $loop); + +$connector = new React\Socket\Connector($loop, array( + 'tcp' => $proxy, + 'dns' => false +)); + +$browser = new React\Http\Browser($loop, $connector); +``` + +See also the [SSH proxy example](examples/13-ssh-proxy.php). + +### Unix domain sockets + +By default, this library supports transport over plaintext TCP/IP and secure +TLS connections for the `http://` and `https://` URL schemes respectively. +This library also supports Unix domain sockets (UDS) when explicitly configured. + +In order to use a UDS path, you have to explicitly configure the connector to +override the destination URL so that the hostname given in the request URL will +no longer be used to establish the connection: + +```php +$connector = new React\Socket\FixedUriConnector( + 'unix:///var/run/docker.sock', + new React\Socket\UnixConnector($loop) +); + +$browser = new Browser($loop, $connector); + +$client->get('http://localhost/info')->then(function (Psr\Http\Message\ResponseInterface $response) { + var_dump($response->getHeaders(), (string)$response->getBody()); +}); +``` + +See also the [Unix Domain Sockets (UDS) example](examples/14-unix-domain-sockets.php). + + +## Server Usage ### Server @@ -1184,6 +1826,643 @@ feel free to add it to this list. ## API +### Browser + +The `React\Http\Browser` is responsible for sending HTTP requests to your HTTP server +and keeps track of pending incoming HTTP responses. +It also registers everything with the main [`EventLoop`](https://github.com/reactphp/event-loop#usage). + +```php +$loop = React\EventLoop\Factory::create(); + +$browser = new React\Http\Browser($loop); +``` + +If you need custom connector settings (DNS resolution, TLS parameters, timeouts, +proxy servers etc.), you can explicitly pass a custom instance of the +[`ConnectorInterface`](https://github.com/reactphp/socket#connectorinterface): + +```php +$connector = new React\Socket\Connector($loop, array( + 'dns' => '127.0.0.1', + 'tcp' => array( + 'bindto' => '192.168.10.1:0' + ), + 'tls' => array( + 'verify_peer' => false, + 'verify_peer_name' => false + ) +)); + +$browser = new React\Http\Browser($loop, $connector); +``` + +#### get() + +The `get(string|UriInterface $url, array $headers = array()): PromiseInterface` method can be used to +send an HTTP GET request. + +```php +$browser->get($url)->then(function (Psr\Http\Message\ResponseInterface $response) { + var_dump((string)$response->getBody()); +}); +``` + +See also [example 01](examples/01-google.php). + +> For BC reasons, this method accepts the `$url` as either a `string` + value or as an `UriInterface`. It's recommended to explicitly cast any + objects implementing `UriInterface` to `string`. + +#### post() + +The `post(string|UriInterface $url, array $headers = array(), string|ReadableStreamInterface $contents = ''): PromiseInterface` method can be used to +send an HTTP POST request. + +```php +$browser->post( + $url, + [ + 'Content-Type' => 'application/json' + ], + json_encode($data) +)->then(function (Psr\Http\Message\ResponseInterface $response) { + var_dump(json_decode((string)$response->getBody())); +}); +``` + +See also [example 04](examples/04-post-json.php). + +This method is also commonly used to submit HTML form data: + +```php +$data = [ + 'user' => 'Alice', + 'password' => 'secret' +]; + +$browser->post( + $url, + [ + 'Content-Type' => 'application/x-www-form-urlencoded' + ], + http_build_query($data) +); +``` + +This method will automatically add a matching `Content-Length` request +header if the outgoing request body is a `string`. If you're using a +streaming request body (`ReadableStreamInterface`), it will default to +using `Transfer-Encoding: chunked` or you have to explicitly pass in a +matching `Content-Length` request header like so: + +```php +$body = new React\Stream\ThroughStream(); +$loop->addTimer(1.0, function () use ($body) { + $body->end("hello world"); +}); + +$browser->post($url, array('Content-Length' => '11'), $body); +``` + +> For BC reasons, this method accepts the `$url` as either a `string` + value or as an `UriInterface`. It's recommended to explicitly cast any + objects implementing `UriInterface` to `string`. + +#### head() + +The `head(string|UriInterface $url, array $headers = array()): PromiseInterface` method can be used to +send an HTTP HEAD request. + +```php +$browser->head($url)->then(function (Psr\Http\Message\ResponseInterface $response) { + var_dump($response->getHeaders()); +}); +``` + +> For BC reasons, this method accepts the `$url` as either a `string` + value or as an `UriInterface`. It's recommended to explicitly cast any + objects implementing `UriInterface` to `string`. + +#### patch() + +The `patch(string|UriInterface $url, array $headers = array(), string|ReadableStreamInterface $contents = ''): PromiseInterface` method can be used to +send an HTTP PATCH request. + +```php +$browser->patch( + $url, + [ + 'Content-Type' => 'application/json' + ], + json_encode($data) +)->then(function (Psr\Http\Message\ResponseInterface $response) { + var_dump(json_decode((string)$response->getBody())); +}); +``` + +This method will automatically add a matching `Content-Length` request +header if the outgoing request body is a `string`. If you're using a +streaming request body (`ReadableStreamInterface`), it will default to +using `Transfer-Encoding: chunked` or you have to explicitly pass in a +matching `Content-Length` request header like so: + +```php +$body = new React\Stream\ThroughStream(); +$loop->addTimer(1.0, function () use ($body) { + $body->end("hello world"); +}); + +$browser->patch($url, array('Content-Length' => '11'), $body); +``` + +> For BC reasons, this method accepts the `$url` as either a `string` + value or as an `UriInterface`. It's recommended to explicitly cast any + objects implementing `UriInterface` to `string`. + +#### put() + +The `put(string|UriInterface $url, array $headers = array()): PromiseInterface` method can be used to +send an HTTP PUT request. + +```php +$browser->put( + $url, + [ + 'Content-Type' => 'text/xml' + ], + $xml->asXML() +)->then(function (Psr\Http\Message\ResponseInterface $response) { + var_dump((string)$response->getBody()); +}); +``` + +See also [example 05](examples/05-put-xml.php). + +This method will automatically add a matching `Content-Length` request +header if the outgoing request body is a `string`. If you're using a +streaming request body (`ReadableStreamInterface`), it will default to +using `Transfer-Encoding: chunked` or you have to explicitly pass in a +matching `Content-Length` request header like so: + +```php +$body = new React\Stream\ThroughStream(); +$loop->addTimer(1.0, function () use ($body) { + $body->end("hello world"); +}); + +$browser->put($url, array('Content-Length' => '11'), $body); +``` + +> For BC reasons, this method accepts the `$url` as either a `string` + value or as an `UriInterface`. It's recommended to explicitly cast any + objects implementing `UriInterface` to `string`. + +#### delete() + +The `delete(string|UriInterface $url, array $headers = array()): PromiseInterface` method can be used to +send an HTTP DELETE request. + +```php +$browser->delete($url)->then(function (Psr\Http\Message\ResponseInterface $response) { + var_dump((string)$response->getBody()); +}); +``` + +> For BC reasons, this method accepts the `$url` as either a `string` + value or as an `UriInterface`. It's recommended to explicitly cast any + objects implementing `UriInterface` to `string`. + +#### request() + +The `request(string $method, string $url, array $headers = array(), string|ReadableStreamInterface $body = ''): PromiseInterface` method can be used to +send an arbitrary HTTP request. + +The preferred way to send an HTTP request is by using the above +[request methods](#request-methods), for example the [`get()`](#get) +method to send an HTTP `GET` request. + +As an alternative, if you want to use a custom HTTP request method, you +can use this method: + +```php +$browser->request('OPTIONS', $url)->then(function (Psr\Http\Message\ResponseInterface $response) { + var_dump((string)$response->getBody()); +}); +``` + +This method will automatically add a matching `Content-Length` request +header if the size of the outgoing request body is known and non-empty. +For an empty request body, if will only include a `Content-Length: 0` +request header if the request method usually expects a request body (only +applies to `POST`, `PUT` and `PATCH`). + +If you're using a streaming request body (`ReadableStreamInterface`), it +will default to using `Transfer-Encoding: chunked` or you have to +explicitly pass in a matching `Content-Length` request header like so: + +```php +$body = new React\Stream\ThroughStream(); +$loop->addTimer(1.0, function () use ($body) { + $body->end("hello world"); +}); + +$browser->request('POST', $url, array('Content-Length' => '11'), $body); +``` + +> Note that this method is available as of v2.9.0 and always buffers the + response body before resolving. + It does not respect the deprecated [`streaming` option](#withoptions). + If you want to stream the response body, you can use the + [`requestStreaming()`](#requeststreaming) method instead. + +#### requestStreaming() + +The `requestStreaming(string $method, string $url, array $headers = array(), string|ReadableStreamInterface $body = ''): PromiseInterface` method can be used to +send an arbitrary HTTP request and receive a streaming response without buffering the response body. + +The preferred way to send an HTTP request is by using the above +[request methods](#request-methods), for example the [`get()`](#get) +method to send an HTTP `GET` request. Each of these methods will buffer +the whole response body in memory by default. This is easy to get started +and works reasonably well for smaller responses. + +In some situations, it's a better idea to use a streaming approach, where +only small chunks have to be kept in memory. You can use this method to +send an arbitrary HTTP request and receive a streaming response. It uses +the same HTTP message API, but does not buffer the response body in +memory. It only processes the response body in small chunks as data is +received and forwards this data through [ReactPHP's Stream API](https://github.com/reactphp/stream). +This works for (any number of) responses of arbitrary sizes. + +```php +$browser->requestStreaming('GET', $url)->then(function (Psr\Http\Message\ResponseInterface $response) { + $body = $response->getBody(); + assert($body instanceof Psr\Http\Message\StreamInterface); + assert($body instanceof React\Stream\ReadableStreamInterface); + + $body->on('data', function ($chunk) { + echo $chunk; + }); + + $body->on('error', function (Exception $error) { + echo 'Error: ' . $error->getMessage() . PHP_EOL; + }); + + $body->on('close', function () { + echo '[DONE]' . PHP_EOL; + }); +}); +``` + +See also [`ReadableStreamInterface`](https://github.com/reactphp/stream#readablestreaminterface) +and the [streaming response](#streaming-response) for more details, +examples and possible use-cases. + +This method will automatically add a matching `Content-Length` request +header if the size of the outgoing request body is known and non-empty. +For an empty request body, if will only include a `Content-Length: 0` +request header if the request method usually expects a request body (only +applies to `POST`, `PUT` and `PATCH`). + +If you're using a streaming request body (`ReadableStreamInterface`), it +will default to using `Transfer-Encoding: chunked` or you have to +explicitly pass in a matching `Content-Length` request header like so: + +```php +$body = new React\Stream\ThroughStream(); +$loop->addTimer(1.0, function () use ($body) { + $body->end("hello world"); +}); + +$browser->requestStreaming('POST', $url, array('Content-Length' => '11'), $body); +``` + +> Note that this method is available as of v2.9.0 and always resolves the + response without buffering the response body. + It does not respect the deprecated [`streaming` option](#withoptions). + If you want to buffer the response body, use can use the + [`request()`](#request) method instead. + +#### ~~submit()~~ + +> Deprecated since v2.9.0, see [`post()`](#post) instead. + +The deprecated `submit(string|UriInterface $url, array $fields, array $headers = array(), string $method = 'POST'): PromiseInterface` method can be used to +submit an array of field values similar to submitting a form (`application/x-www-form-urlencoded`). + +```php +// deprecated: see post() instead +$browser->submit($url, array('user' => 'test', 'password' => 'secret')); +``` + +> For BC reasons, this method accepts the `$url` as either a `string` + value or as an `UriInterface`. It's recommended to explicitly cast any + objects implementing `UriInterface` to `string`. + +#### ~~send()~~ + +> Deprecated since v2.9.0, see [`request()`](#request) instead. + +The deprecated `send(RequestInterface $request): PromiseInterface` method can be used to +send an arbitrary instance implementing the [`RequestInterface`](#requestinterface) (PSR-7). + +The preferred way to send an HTTP request is by using the above +[request methods](#request-methods), for example the [`get()`](#get) +method to send an HTTP `GET` request. + +As an alternative, if you want to use a custom HTTP request method, you +can use this method: + +```php +$request = new Request('OPTIONS', $url); + +// deprecated: see request() instead +$browser->send($request)->then(…); +``` + +This method will automatically add a matching `Content-Length` request +header if the size of the outgoing request body is known and non-empty. +For an empty request body, if will only include a `Content-Length: 0` +request header if the request method usually expects a request body (only +applies to `POST`, `PUT` and `PATCH`). + +#### withTimeout() + +The `withTimeout(bool|number $timeout): Browser` method can be used to +change the maximum timeout used for waiting for pending requests. + +You can pass in the number of seconds to use as a new timeout value: + +```php +$browser = $browser->withTimeout(10.0); +``` + +You can pass in a bool `false` to disable any timeouts. In this case, +requests can stay pending forever: + +```php +$browser = $browser->withTimeout(false); +``` + +You can pass in a bool `true` to re-enable default timeout handling. This +will respects PHP's `default_socket_timeout` setting (default 60s): + +```php +$browser = $browser->withTimeout(true); +``` + +See also [timeouts](#timeouts) for more details about timeout handling. + +Notice that the [`Browser`](#browser) is an immutable object, i.e. this +method actually returns a *new* [`Browser`](#browser) instance with the +given timeout value applied. + +#### withFollowRedirects() + +The `withTimeout(bool|int $$followRedirects): Browser` method can be used to +change how HTTP redirects will be followed. + +You can pass in the maximum number of redirects to follow: + +```php +$new = $browser->withFollowRedirects(5); +``` + +The request will automatically be rejected when the number of redirects +is exceeded. You can pass in a `0` to reject the request for any +redirects encountered: + +```php +$browser = $browser->withFollowRedirects(0); + +$browser->get($url)->then(function (Psr\Http\Message\ResponseInterface $response) { + // only non-redirected responses will now end up here + var_dump($response->getHeaders()); +}); +``` + +You can pass in a bool `false` to disable following any redirects. In +this case, requests will resolve with the redirection response instead +of following the `Location` response header: + +```php +$browser = $browser->withFollowRedirects(false); + +$browser->get($url)->then(function (Psr\Http\Message\ResponseInterface $response) { + // any redirects will now end up here + var_dump($response->getHeaderLine('Location')); +}); +``` + +You can pass in a bool `true` to re-enable default redirect handling. +This defaults to following a maximum of 10 redirects: + +```php +$browser = $browser->withFollowRedirects(true); +``` + +See also [redirects](#redirects) for more details about redirect handling. + +Notice that the [`Browser`](#browser) is an immutable object, i.e. this +method actually returns a *new* [`Browser`](#browser) instance with the +given redirect setting applied. + +#### withRejectErrorResponse() + +The `withRejectErrorResponse(bool $obeySuccessCode): Browser` method can be used to +change whether non-successful HTTP response status codes (4xx and 5xx) will be rejected. + +You can pass in a bool `false` to disable rejecting incoming responses +that use a 4xx or 5xx response status code. In this case, requests will +resolve with the response message indicating an error condition: + +```php +$browser = $browser->withRejectErrorResponse(false); + +$browser->get($url)->then(function (Psr\Http\Message\ResponseInterface $response) { + // any HTTP response will now end up here + var_dump($response->getStatusCode(), $response->getReasonPhrase()); +}); +``` + +You can pass in a bool `true` to re-enable default status code handling. +This defaults to rejecting any response status codes in the 4xx or 5xx +range with a [`ResponseException`](#responseexception): + +```php +$browser = $browser->withRejectErrorResponse(true); + +$browser->get($url)->then(function (Psr\Http\Message\ResponseInterface $response) { + // any successful HTTP response will now end up here + var_dump($response->getStatusCode(), $response->getReasonPhrase()); +}, function (Exception $e) { + if ($e instanceof React\Http\Message\ResponseException) { + // any HTTP response error message will now end up here + $response = $e->getResponse(); + var_dump($response->getStatusCode(), $response->getReasonPhrase()); + } else { + var_dump($e->getMessage()); + } +}); +``` + +Notice that the [`Browser`](#browser) is an immutable object, i.e. this +method actually returns a *new* [`Browser`](#browser) instance with the +given setting applied. + +#### withBase() + +The `withBase(string|null|UriInterface $baseUrl): Browser` method can be used to +change the base URL used to resolve relative URLs to. + +If you configure a base URL, any requests to relative URLs will be +processed by first prepending this absolute base URL. Note that this +merely prepends the base URL and does *not* resolve any relative path +references (like `../` etc.). This is mostly useful for (RESTful) API +calls where all endpoints (URLs) are located under a common base URL. + +```php +$browser = $browser->withBase('http://api.example.com/v3'); + +// will request http://api.example.com/v3/example +$browser->get('/example')->then(…); +``` + +You can pass in a `null` base URL to return a new instance that does not +use a base URL: + +```php +$browser = $browser->withBase(null); +``` + +Accordingly, any requests using relative URLs to a browser that does not +use a base URL can not be completed and will be rejected without sending +a request. + +This method will throw an `InvalidArgumentException` if the given +`$baseUrl` argument is not a valid URL. + +Notice that the [`Browser`](#browser) is an immutable object, i.e. the `withBase()` method +actually returns a *new* [`Browser`](#browser) instance with the given base URL applied. + +> For BC reasons, this method accepts the `$baseUrl` as either a `string` + value or as an `UriInterface`. It's recommended to explicitly cast any + objects implementing `UriInterface` to `string`. + +> Changelog: As of v2.9.0 this method accepts a `null` value to reset the + base URL. Earlier versions had to use the deprecated `withoutBase()` + method to reset the base URL. + +#### withProtocolVersion() + +The `withProtocolVersion(string $protocolVersion): Browser` method can be used to +change the HTTP protocol version that will be used for all subsequent requests. + +All the above [request methods](#request-methods) default to sending +requests as HTTP/1.1. This is the preferred HTTP protocol version which +also provides decent backwards-compatibility with legacy HTTP/1.0 +servers. As such, there should rarely be a need to explicitly change this +protocol version. + +If you want to explicitly use the legacy HTTP/1.0 protocol version, you +can use this method: + +```php +$newBrowser = $browser->withProtocolVersion('1.0'); + +$newBrowser->get($url)->then(…); +``` + +Notice that the [`Browser`](#browser) is an immutable object, i.e. this +method actually returns a *new* [`Browser`](#browser) instance with the +new protocol version applied. + +#### withResponseBuffer() + +The `withRespomseBuffer(int $maximumSize): Browser` method can be used to +change the maximum size for buffering a response body. + +The preferred way to send an HTTP request is by using the above +[request methods](#request-methods), for example the [`get()`](#get) +method to send an HTTP `GET` request. Each of these methods will buffer +the whole response body in memory by default. This is easy to get started +and works reasonably well for smaller responses. + +By default, the response body buffer will be limited to 16 MiB. If the +response body exceeds this maximum size, the request will be rejected. + +You can pass in the maximum number of bytes to buffer: + +```php +$browser = $browser->withResponseBuffer(1024 * 1024); + +$browser->get($url)->then(function (Psr\Http\Message\ResponseInterface $response) { + // response body will not exceed 1 MiB + var_dump($response->getHeaders(), (string) $response->getBody()); +}); +``` + +Note that the response body buffer has to be kept in memory for each +pending request until its transfer is completed and it will only be freed +after a pending request is fulfilled. As such, increasing this maximum +buffer size to allow larger response bodies is usually not recommended. +Instead, you can use the [`requestStreaming()` method](#requeststreaming) +to receive responses with arbitrary sizes without buffering. Accordingly, +this maximum buffer size setting has no effect on streaming responses. + +Notice that the [`Browser`](#browser) is an immutable object, i.e. this +method actually returns a *new* [`Browser`](#browser) instance with the +given setting applied. + +#### ~~withOptions()~~ + +> Deprecated since v2.9.0, see [`withTimeout()`](#withtimeout), [`withFollowRedirects()`](#withfollowredirects) + and [`withRejectErrorResponse()`](#withrejecterrorresponse) instead. + +The deprecated `withOptions(array $options): Browser` method can be used to +change the options to use: + +The [`Browser`](#browser) class exposes several options for the handling of +HTTP transactions. These options resemble some of PHP's +[HTTP context options](https://www.php.net/manual/en/context.http.php) and +can be controlled via the following API (and their defaults): + +```php +// deprecated +$newBrowser = $browser->withOptions(array( + 'timeout' => null, // see withTimeout() instead + 'followRedirects' => true, // see withFollowRedirects() instead + 'maxRedirects' => 10, // see withFollowRedirects() instead + 'obeySuccessCode' => true, // see withRejectErrorResponse() instead + 'streaming' => false, // deprecated, see requestStreaming() instead +)); +``` + +See also [timeouts](#timeouts), [redirects](#redirects) and +[streaming](#streaming-response) for more details. + +Notice that the [`Browser`](#browser) is an immutable object, i.e. this +method actually returns a *new* [`Browser`](#browser) instance with the +options applied. + +#### ~~withoutBase()~~ + +> Deprecated since v2.9.0, see [`withBase()`](#withbase) instead. + +The deprecated `withoutBase(): Browser` method can be used to +remove the base URL. + +```php +// deprecated: see withBase() instead +$newBrowser = $browser->withoutBase(); +``` + +Notice that the [`Browser`](#browser) is an immutable object, i.e. the `withoutBase()` method +actually returns a *new* [`Browser`](#browser) instance without any base URL applied. + +See also [`withBase()`](#withbase). + ### React\Http\Middleware #### StreamingRequestMiddleware @@ -1470,6 +2749,51 @@ new RequestBodyParserMiddleware(10 * 1024, 100); // 100 files with 10 KiB each If you want to respect this setting, you have to check its value and effectively avoid using this middleware entirely. +### ResponseInterface + +The `Psr\Http\Message\ResponseInterface` represents the incoming response received from the [`Browser`](#browser). + +This is a standard interface defined in +[PSR-7: HTTP message interfaces](https://www.php-fig.org/psr/psr-7/), see its +[`ResponseInterface` definition](https://www.php-fig.org/psr/psr-7/#3-3-psr-http-message-responseinterface) +which in turn extends the +[`MessageInterface` definition](https://www.php-fig.org/psr/psr-7/#3-1-psr-http-message-messageinterface). + +### RequestInterface + +The `Psr\Http\Message\RequestInterface` represents the outgoing request to be sent via the [`Browser`](#browser). + +This is a standard interface defined in +[PSR-7: HTTP message interfaces](https://www.php-fig.org/psr/psr-7/), see its +[`RequestInterface` definition](https://www.php-fig.org/psr/psr-7/#3-2-psr-http-message-requestinterface) +which in turn extends the +[`MessageInterface` definition](https://www.php-fig.org/psr/psr-7/#3-1-psr-http-message-messageinterface). + +### UriInterface + +The `Psr\Http\Message\UriInterface` represents an absolute or relative URI (aka URL). + +This is a standard interface defined in +[PSR-7: HTTP message interfaces](https://www.php-fig.org/psr/psr-7/), see its +[`UriInterface` definition](https://www.php-fig.org/psr/psr-7/#3-5-psr-http-message-uriinterface). + +> For BC reasons, the request methods accept the URL as either a `string` + value or as an `UriInterface`. It's recommended to explicitly cast any + objects implementing `UriInterface` to `string`. + +### ResponseException + +The `ResponseException` is an `Exception` sub-class that will be used to reject +a request promise if the remote server returns a non-success status code +(anything but 2xx or 3xx). +You can control this behavior via the [`withRejectErrorResponse()` method](#withrejecterrorresponse). + +The `getCode(): int` method can be used to +return the HTTP response status code. + +The `getResponse(): ResponseInterface` method can be used to +access its underlying [`ResponseInterface`](#responseinterface) object. + ## Install The recommended way to install this library is [through Composer](https://getcomposer.org). diff --git a/composer.json b/composer.json index d750445a..755e5d82 100644 --- a/composer.json +++ b/composer.json @@ -5,15 +5,21 @@ "license": "MIT", "require": { "php": ">=5.3.0", - "ringcentral/psr7": "^1.2", - "react/socket": "^1.0 || ^0.8.3", - "react/stream": "^1.0 || ^0.7.1", - "react/promise": "^2.3 || ^1.2.1", "evenement/evenement": "^3.0 || ^2.0 || ^1.0", - "react/promise-stream": "^1.1" + "psr/http-message": "^1.0", + "react/event-loop": "^1.0 || ^0.5", + "react/http-client": "^0.5.10", + "react/promise": "^2.3 || ^1.2.1", + "react/promise-stream": "^1.1", + "react/socket": "^1.1", + "react/stream": "^1.0 || ^0.7.5", + "ringcentral/psr7": "^1.2" }, "require-dev": { "clue/block-react": "^1.1", + "clue/http-proxy-react": "^1.3", + "clue/reactphp-ssh-proxy": "^1.0", + "clue/socks-react": "^1.0", "phpunit/phpunit": "^9.0 || ^5.7 || ^4.8.35" }, "autoload": { diff --git a/examples/01-google.php b/examples/01-google.php new file mode 100644 index 00000000..31a82606 --- /dev/null +++ b/examples/01-google.php @@ -0,0 +1,15 @@ +get('http://google.com/')->then(function (ResponseInterface $response) { + var_dump($response->getHeaders(), (string)$response->getBody()); +}); + +$loop->run(); diff --git a/examples/02-concurrent.php b/examples/02-concurrent.php new file mode 100644 index 00000000..5a9e4258 --- /dev/null +++ b/examples/02-concurrent.php @@ -0,0 +1,23 @@ +head('http://www.github.com/clue/http-react')->then(function (ResponseInterface $response) { + var_dump($response->getHeaders(), (string)$response->getBody()); +}); + +$client->get('http://google.com/')->then(function (ResponseInterface $response) { + var_dump($response->getHeaders(), (string)$response->getBody()); +}); + +$client->get('http://www.lueck.tv/psocksd')->then(function (ResponseInterface $response) { + var_dump($response->getHeaders(), (string)$response->getBody()); +}); + +$loop->run(); diff --git a/examples/03-any.php b/examples/03-any.php new file mode 100644 index 00000000..881dabfc --- /dev/null +++ b/examples/03-any.php @@ -0,0 +1,32 @@ +head('http://www.github.com/clue/http-react'), + $client->get('https://httpbin.org/'), + $client->get('https://google.com'), + $client->get('http://www.lueck.tv/psocksd'), + $client->get('http://www.httpbin.org/absolute-redirect/5') +); + +React\Promise\any($promises)->then(function (ResponseInterface $response) use ($promises) { + // first response arrived => cancel all other pending requests + foreach ($promises as $promise) { + $promise->cancel(); + } + + var_dump($response->getHeaders()); + echo PHP_EOL . $response->getBody(); +}); + +$loop->run(); diff --git a/examples/04-post-json.php b/examples/04-post-json.php new file mode 100644 index 00000000..818dc9bc --- /dev/null +++ b/examples/04-post-json.php @@ -0,0 +1,29 @@ + array( + 'first' => 'Alice', + 'name' => 'Smith' + ), + 'email' => 'alice@example.com' +); + +$client->post( + 'https://httpbin.org/post', + array( + 'Content-Type' => 'application/json' + ), + json_encode($data) +)->then(function (ResponseInterface $response) { + echo (string)$response->getBody(); +}, 'printf'); + +$loop->run(); diff --git a/examples/05-put-xml.php b/examples/05-put-xml.php new file mode 100644 index 00000000..7c23182d --- /dev/null +++ b/examples/05-put-xml.php @@ -0,0 +1,26 @@ +'); +$child = $xml->addChild('user'); +$child->alias = 'clue'; +$child->name = 'Christian Lück'; + +$client->put( + 'https://httpbin.org/put', + array( + 'Content-Type' => 'text/xml' + ), + $xml->asXML() +)->then(function (ResponseInterface $response) { + echo (string)$response->getBody(); +}, 'printf'); + +$loop->run(); diff --git a/examples/11-http-proxy.php b/examples/11-http-proxy.php new file mode 100644 index 00000000..d1ad9cf5 --- /dev/null +++ b/examples/11-http-proxy.php @@ -0,0 +1,29 @@ + $proxy, + 'dns' => false +)); +$browser = new Browser($loop, $connector); + +// demo fetching HTTP headers (or bail out otherwise) +$browser->get('https://www.google.com/')->then(function (ResponseInterface $response) { + echo RingCentral\Psr7\str($response); +}, 'printf'); + +$loop->run(); diff --git a/examples/12-socks-proxy.php b/examples/12-socks-proxy.php new file mode 100644 index 00000000..3b694804 --- /dev/null +++ b/examples/12-socks-proxy.php @@ -0,0 +1,29 @@ + $proxy, + 'dns' => false +)); +$browser = new Browser($loop, $connector); + +// demo fetching HTTP headers (or bail out otherwise) +$browser->get('https://www.google.com/')->then(function (ResponseInterface $response) { + echo RingCentral\Psr7\str($response); +}, 'printf'); + +$loop->run(); diff --git a/examples/13-ssh-proxy.php b/examples/13-ssh-proxy.php new file mode 100644 index 00000000..d0424fea --- /dev/null +++ b/examples/13-ssh-proxy.php @@ -0,0 +1,29 @@ + $proxy, + 'dns' => false +)); +$browser = new Browser($loop, $connector); + +// demo fetching HTTP headers (or bail out otherwise) +$browser->get('https://www.google.com/')->then(function (ResponseInterface $response) { + echo RingCentral\Psr7\str($response); +}, 'printf'); + +$loop->run(); diff --git a/examples/14-unix-domain-sockets.php b/examples/14-unix-domain-sockets.php new file mode 100644 index 00000000..8881321e --- /dev/null +++ b/examples/14-unix-domain-sockets.php @@ -0,0 +1,27 @@ +get('http://localhost/info')->then(function (ResponseInterface $response) { + echo Psr7\str($response); +}, 'printf'); + +$loop->run(); diff --git a/examples/21-stream-forwarding.php b/examples/21-stream-forwarding.php new file mode 100644 index 00000000..b7873775 --- /dev/null +++ b/examples/21-stream-forwarding.php @@ -0,0 +1,33 @@ +write('Requesting ' . $url . '…' . PHP_EOL); + +$client->requestStreaming('GET', $url)->then(function (ResponseInterface $response) use ($info, $out) { + $info->write('Received' . PHP_EOL . Psr7\str($response)); + + $body = $response->getBody(); + assert($body instanceof ReadableStreamInterface); + $body->pipe($out); +}, 'printf'); + +$loop->run(); diff --git a/examples/22-stream-stdin.php b/examples/22-stream-stdin.php new file mode 100644 index 00000000..4a36df91 --- /dev/null +++ b/examples/22-stream-stdin.php @@ -0,0 +1,27 @@ +post($url, array(), $in)->then(function (ResponseInterface $response) { + echo 'Received' . PHP_EOL . Psr7\str($response); +}, 'printf'); + +$loop->run(); diff --git a/examples/91-benchmark-download.php b/examples/91-benchmark-download.php new file mode 100644 index 00000000..10ad8e00 --- /dev/null +++ b/examples/91-benchmark-download.php @@ -0,0 +1,61 @@ +requestStreaming('GET', $url)->then(function (ResponseInterface $response) use ($loop) { + echo 'Headers received' . PHP_EOL; + echo RingCentral\Psr7\str($response); + + $stream = $response->getBody(); + assert($stream instanceof ReadableStreamInterface); + + // count number of bytes received + $bytes = 0; + $stream->on('data', function ($chunk) use (&$bytes) { + $bytes += strlen($chunk); + }); + + // report progress every 0.1s + $timer = $loop->addPeriodicTimer(0.1, function () use (&$bytes) { + echo "\rDownloaded " . $bytes . " bytes…"; + }); + + // report results once the stream closes + $time = microtime(true); + $stream->on('close', function() use (&$bytes, $timer, $loop, $time) { + $loop->cancelTimer($timer); + + $time = microtime(true) - $time; + + echo "\r" . 'Downloaded ' . $bytes . ' bytes in ' . round($time, 3) . 's => ' . round($bytes / $time / 1000000, 1) . ' MB/s' . PHP_EOL; + }); +}, 'printf'); + +$loop->run(); diff --git a/examples/92-benchmark-upload.php b/examples/92-benchmark-upload.php new file mode 100644 index 00000000..2b4e7ed6 --- /dev/null +++ b/examples/92-benchmark-upload.php @@ -0,0 +1,125 @@ +chunk = $chunk; + $this->count = $count; + } + + public function pause() + { + $this->paused = true; + } + + public function resume() + { + if (!$this->paused || $this->closed) { + return; + } + + // keep emitting until stream is paused + $this->paused = false; + while ($this->position < $this->count && !$this->paused) { + ++$this->position; + $this->emit('data', array($this->chunk)); + } + + // end once the last chunk has been written + if ($this->position >= $this->count) { + $this->emit('end'); + $this->close(); + } + } + + public function pipe(WritableStreamInterface $dest, array $options = array()) + { + return Util::pipe($this, $dest, $options); + } + + public function isReadable() + { + return !$this->closed; + } + + public function close() + { + if ($this->closed) { + return; + } + + $this->closed = true; + $this->count = 0; + $this->paused = true; + $this->emit('close'); + } + + public function getPosition() + { + return $this->position * strlen($this->chunk); + } +} + +$loop = React\EventLoop\Factory::create(); +$client = new Browser($loop); + +$url = isset($argv[1]) ? $argv[1] : 'http://httpbin.org/post'; +$n = isset($argv[2]) ? $argv[2] : 10; +$source = new ChunkRepeater(str_repeat('x', 1000000), $n); +$loop->futureTick(function () use ($source) { + $source->resume(); +}); + +echo 'POSTing ' . $n . ' MB to ' . $url . PHP_EOL; + +$start = microtime(true); +$report = $loop->addPeriodicTimer(0.05, function () use ($source, $start) { + printf("\r%d bytes in %0.3fs...", $source->getPosition(), microtime(true) - $start); +}); + +$client->post($url, array('Content-Length' => $n * 1000000), $source)->then(function (ResponseInterface $response) use ($source, $report, $loop, $start) { + $now = microtime(true); + $loop->cancelTimer($report); + + printf("\r%d bytes in %0.3fs => %.1f MB/s\n", $source->getPosition(), $now - $start, $source->getPosition() / ($now - $start) / 1000000); + + echo rtrim(preg_replace('/x{5,}/','x…', (string) $response->getBody()), PHP_EOL) . PHP_EOL; +}, function ($e) use ($loop, $report) { + $loop->cancelTimer($report); + echo 'Error: ' . $e->getMessage() . PHP_EOL; +}); + +$loop->run(); diff --git a/src/Browser.php b/src/Browser.php new file mode 100644 index 00000000..70e875a2 --- /dev/null +++ b/src/Browser.php @@ -0,0 +1,867 @@ + '127.0.0.1', + * 'tcp' => array( + * 'bindto' => '192.168.10.1:0' + * ), + * 'tls' => array( + * 'verify_peer' => false, + * 'verify_peer_name' => false + * ) + * )); + * + * $browser = new React\Http\Browser($loop, $connector); + * ``` + * + * @param LoopInterface $loop + * @param ConnectorInterface|null $connector [optional] Connector to use. + * Should be `null` in order to use default Connector. + */ + public function __construct(LoopInterface $loop, ConnectorInterface $connector = null) + { + $this->messageFactory = new MessageFactory(); + $this->transaction = new Transaction( + Sender::createFromLoop($loop, $connector, $this->messageFactory), + $this->messageFactory, + $loop + ); + } + + /** + * Sends an HTTP GET request + * + * ```php + * $browser->get($url)->then(function (Psr\Http\Message\ResponseInterface $response) { + * var_dump((string)$response->getBody()); + * }); + * ``` + * + * See also [example 01](../examples/01-google.php). + * + * > For BC reasons, this method accepts the `$url` as either a `string` + * value or as an `UriInterface`. It's recommended to explicitly cast any + * objects implementing `UriInterface` to `string`. + * + * @param string|UriInterface $url URL for the request. + * @param array $headers + * @return PromiseInterface + */ + public function get($url, array $headers = array()) + { + return $this->requestMayBeStreaming('GET', $url, $headers); + } + + /** + * Sends an HTTP POST request + * + * ```php + * $browser->post( + * $url, + * [ + * 'Content-Type' => 'application/json' + * ], + * json_encode($data) + * )->then(function (Psr\Http\Message\ResponseInterface $response) { + * var_dump(json_decode((string)$response->getBody())); + * }); + * ``` + * + * See also [example 04](../examples/04-post-json.php). + * + * This method is also commonly used to submit HTML form data: + * + * ```php + * $data = [ + * 'user' => 'Alice', + * 'password' => 'secret' + * ]; + * + * $browser->post( + * $url, + * [ + * 'Content-Type' => 'application/x-www-form-urlencoded' + * ], + * http_build_query($data) + * ); + * ``` + * + * This method will automatically add a matching `Content-Length` request + * header if the outgoing request body is a `string`. If you're using a + * streaming request body (`ReadableStreamInterface`), it will default to + * using `Transfer-Encoding: chunked` or you have to explicitly pass in a + * matching `Content-Length` request header like so: + * + * ```php + * $body = new React\Stream\ThroughStream(); + * $loop->addTimer(1.0, function () use ($body) { + * $body->end("hello world"); + * }); + * + * $browser->post($url, array('Content-Length' => '11'), $body); + * ``` + * + * > For BC reasons, this method accepts the `$url` as either a `string` + * value or as an `UriInterface`. It's recommended to explicitly cast any + * objects implementing `UriInterface` to `string`. + * + * @param string|UriInterface $url URL for the request. + * @param array $headers + * @param string|ReadableStreamInterface $contents + * @return PromiseInterface + */ + public function post($url, array $headers = array(), $contents = '') + { + return $this->requestMayBeStreaming('POST', $url, $headers, $contents); + } + + /** + * Sends an HTTP HEAD request + * + * ```php + * $browser->head($url)->then(function (Psr\Http\Message\ResponseInterface $response) { + * var_dump($response->getHeaders()); + * }); + * ``` + * + * > For BC reasons, this method accepts the `$url` as either a `string` + * value or as an `UriInterface`. It's recommended to explicitly cast any + * objects implementing `UriInterface` to `string`. + * + * @param string|UriInterface $url URL for the request. + * @param array $headers + * @return PromiseInterface + */ + public function head($url, array $headers = array()) + { + return $this->requestMayBeStreaming('HEAD', $url, $headers); + } + + /** + * Sends an HTTP PATCH request + * + * ```php + * $browser->patch( + * $url, + * [ + * 'Content-Type' => 'application/json' + * ], + * json_encode($data) + * )->then(function (Psr\Http\Message\ResponseInterface $response) { + * var_dump(json_decode((string)$response->getBody())); + * }); + * ``` + * + * This method will automatically add a matching `Content-Length` request + * header if the outgoing request body is a `string`. If you're using a + * streaming request body (`ReadableStreamInterface`), it will default to + * using `Transfer-Encoding: chunked` or you have to explicitly pass in a + * matching `Content-Length` request header like so: + * + * ```php + * $body = new React\Stream\ThroughStream(); + * $loop->addTimer(1.0, function () use ($body) { + * $body->end("hello world"); + * }); + * + * $browser->patch($url, array('Content-Length' => '11'), $body); + * ``` + * + * > For BC reasons, this method accepts the `$url` as either a `string` + * value or as an `UriInterface`. It's recommended to explicitly cast any + * objects implementing `UriInterface` to `string`. + * + * @param string|UriInterface $url URL for the request. + * @param array $headers + * @param string|ReadableStreamInterface $contents + * @return PromiseInterface + */ + public function patch($url, array $headers = array(), $contents = '') + { + return $this->requestMayBeStreaming('PATCH', $url , $headers, $contents); + } + + /** + * Sends an HTTP PUT request + * + * ```php + * $browser->put( + * $url, + * [ + * 'Content-Type' => 'text/xml' + * ], + * $xml->asXML() + * )->then(function (Psr\Http\Message\ResponseInterface $response) { + * var_dump((string)$response->getBody()); + * }); + * ``` + * + * See also [example 05](../examples/05-put-xml.php). + * + * This method will automatically add a matching `Content-Length` request + * header if the outgoing request body is a `string`. If you're using a + * streaming request body (`ReadableStreamInterface`), it will default to + * using `Transfer-Encoding: chunked` or you have to explicitly pass in a + * matching `Content-Length` request header like so: + * + * ```php + * $body = new React\Stream\ThroughStream(); + * $loop->addTimer(1.0, function () use ($body) { + * $body->end("hello world"); + * }); + * + * $browser->put($url, array('Content-Length' => '11'), $body); + * ``` + * + * > For BC reasons, this method accepts the `$url` as either a `string` + * value or as an `UriInterface`. It's recommended to explicitly cast any + * objects implementing `UriInterface` to `string`. + * + * @param string|UriInterface $url URL for the request. + * @param array $headers + * @param string|ReadableStreamInterface $contents + * @return PromiseInterface + */ + public function put($url, array $headers = array(), $contents = '') + { + return $this->requestMayBeStreaming('PUT', $url, $headers, $contents); + } + + /** + * Sends an HTTP DELETE request + * + * ```php + * $browser->delete($url)->then(function (Psr\Http\Message\ResponseInterface $response) { + * var_dump((string)$response->getBody()); + * }); + * ``` + * + * > For BC reasons, this method accepts the `$url` as either a `string` + * value or as an `UriInterface`. It's recommended to explicitly cast any + * objects implementing `UriInterface` to `string`. + * + * @param string|UriInterface $url URL for the request. + * @param array $headers + * @param string|ReadableStreamInterface $contents + * @return PromiseInterface + */ + public function delete($url, array $headers = array(), $contents = '') + { + return $this->requestMayBeStreaming('DELETE', $url, $headers, $contents); + } + + /** + * Sends an arbitrary HTTP request. + * + * The preferred way to send an HTTP request is by using the above + * [request methods](#request-methods), for example the [`get()`](#get) + * method to send an HTTP `GET` request. + * + * As an alternative, if you want to use a custom HTTP request method, you + * can use this method: + * + * ```php + * $browser->request('OPTIONS', $url)->then(function (Psr\Http\Message\ResponseInterface $response) { + * var_dump((string)$response->getBody()); + * }); + * ``` + * + * This method will automatically add a matching `Content-Length` request + * header if the size of the outgoing request body is known and non-empty. + * For an empty request body, if will only include a `Content-Length: 0` + * request header if the request method usually expects a request body (only + * applies to `POST`, `PUT` and `PATCH`). + * + * If you're using a streaming request body (`ReadableStreamInterface`), it + * will default to using `Transfer-Encoding: chunked` or you have to + * explicitly pass in a matching `Content-Length` request header like so: + * + * ```php + * $body = new React\Stream\ThroughStream(); + * $loop->addTimer(1.0, function () use ($body) { + * $body->end("hello world"); + * }); + * + * $browser->request('POST', $url, array('Content-Length' => '11'), $body); + * ``` + * + * > Note that this method is available as of v2.9.0 and always buffers the + * response body before resolving. + * It does not respect the deprecated [`streaming` option](#withoptions). + * If you want to stream the response body, you can use the + * [`requestStreaming()`](#requeststreaming) method instead. + * + * @param string $method HTTP request method, e.g. GET/HEAD/POST etc. + * @param string $url URL for the request + * @param array $headers Additional request headers + * @param string|ReadableStreamInterface $body HTTP request body contents + * @return PromiseInterface + * @since 2.9.0 + */ + public function request($method, $url, array $headers = array(), $body = '') + { + return $this->withOptions(array('streaming' => false))->requestMayBeStreaming($method, $url, $headers, $body); + } + + /** + * Sends an arbitrary HTTP request and receives a streaming response without buffering the response body. + * + * The preferred way to send an HTTP request is by using the above + * [request methods](#request-methods), for example the [`get()`](#get) + * method to send an HTTP `GET` request. Each of these methods will buffer + * the whole response body in memory by default. This is easy to get started + * and works reasonably well for smaller responses. + * + * In some situations, it's a better idea to use a streaming approach, where + * only small chunks have to be kept in memory. You can use this method to + * send an arbitrary HTTP request and receive a streaming response. It uses + * the same HTTP message API, but does not buffer the response body in + * memory. It only processes the response body in small chunks as data is + * received and forwards this data through [ReactPHP's Stream API](https://github.com/reactphp/stream). + * This works for (any number of) responses of arbitrary sizes. + * + * ```php + * $browser->requestStreaming('GET', $url)->then(function (Psr\Http\Message\ResponseInterface $response) { + * $body = $response->getBody(); + * assert($body instanceof Psr\Http\Message\StreamInterface); + * assert($body instanceof React\Stream\ReadableStreamInterface); + * + * $body->on('data', function ($chunk) { + * echo $chunk; + * }); + * + * $body->on('error', function (Exception $error) { + * echo 'Error: ' . $error->getMessage() . PHP_EOL; + * }); + * + * $body->on('close', function () { + * echo '[DONE]' . PHP_EOL; + * }); + * }); + * ``` + * + * See also [`ReadableStreamInterface`](https://github.com/reactphp/stream#readablestreaminterface) + * and the [streaming response](#streaming-response) for more details, + * examples and possible use-cases. + * + * This method will automatically add a matching `Content-Length` request + * header if the size of the outgoing request body is known and non-empty. + * For an empty request body, if will only include a `Content-Length: 0` + * request header if the request method usually expects a request body (only + * applies to `POST`, `PUT` and `PATCH`). + * + * If you're using a streaming request body (`ReadableStreamInterface`), it + * will default to using `Transfer-Encoding: chunked` or you have to + * explicitly pass in a matching `Content-Length` request header like so: + * + * ```php + * $body = new React\Stream\ThroughStream(); + * $loop->addTimer(1.0, function () use ($body) { + * $body->end("hello world"); + * }); + * + * $browser->requestStreaming('POST', $url, array('Content-Length' => '11'), $body); + * ``` + * + * > Note that this method is available as of v2.9.0 and always resolves the + * response without buffering the response body. + * It does not respect the deprecated [`streaming` option](#withoptions). + * If you want to buffer the response body, use can use the + * [`request()`](#request) method instead. + * + * @param string $method HTTP request method, e.g. GET/HEAD/POST etc. + * @param string $url URL for the request + * @param array $headers Additional request headers + * @param string|ReadableStreamInterface $body HTTP request body contents + * @return PromiseInterface + * @since 2.9.0 + */ + public function requestStreaming($method, $url, $headers = array(), $contents = '') + { + return $this->withOptions(array('streaming' => true))->requestMayBeStreaming($method, $url, $headers, $contents); + } + + /** + * [Deprecated] Submits an array of field values similar to submitting a form (`application/x-www-form-urlencoded`). + * + * ```php + * // deprecated: see post() instead + * $browser->submit($url, array('user' => 'test', 'password' => 'secret')); + * ``` + * + * This method will automatically add a matching `Content-Length` request + * header for the encoded length of the given `$fields`. + * + * > For BC reasons, this method accepts the `$url` as either a `string` + * value or as an `UriInterface`. It's recommended to explicitly cast any + * objects implementing `UriInterface` to `string`. + * + * @param string|UriInterface $url URL for the request. + * @param array $fields + * @param array $headers + * @param string $method + * @return PromiseInterface + * @deprecated 2.9.0 See self::post() instead. + * @see self::post() + */ + public function submit($url, array $fields, $headers = array(), $method = 'POST') + { + $headers['Content-Type'] = 'application/x-www-form-urlencoded'; + $contents = http_build_query($fields); + + return $this->requestMayBeStreaming($method, $url, $headers, $contents); + } + + /** + * [Deprecated] Sends an arbitrary instance implementing the [`RequestInterface`](#requestinterface) (PSR-7). + * + * The preferred way to send an HTTP request is by using the above + * [request methods](#request-methods), for example the [`get()`](#get) + * method to send an HTTP `GET` request. + * + * As an alternative, if you want to use a custom HTTP request method, you + * can use this method: + * + * ```php + * $request = new Request('OPTIONS', $url); + * + * // deprecated: see request() instead + * $browser->send($request)->then(…); + * ``` + * + * This method will automatically add a matching `Content-Length` request + * header if the size of the outgoing request body is known and non-empty. + * For an empty request body, if will only include a `Content-Length: 0` + * request header if the request method usually expects a request body (only + * applies to `POST`, `PUT` and `PATCH`). + * + * @param RequestInterface $request + * @return PromiseInterface + * @deprecated 2.9.0 See self::request() instead. + * @see self::request() + */ + public function send(RequestInterface $request) + { + if ($this->baseUrl !== null) { + // ensure we're actually below the base URL + $request = $request->withUri($this->messageFactory->expandBase($request->getUri(), $this->baseUrl)); + } + + return $this->transaction->send($request); + } + + /** + * Changes the maximum timeout used for waiting for pending requests. + * + * You can pass in the number of seconds to use as a new timeout value: + * + * ```php + * $browser = $browser->withTimeout(10.0); + * ``` + * + * You can pass in a bool `false` to disable any timeouts. In this case, + * requests can stay pending forever: + * + * ```php + * $browser = $browser->withTimeout(false); + * ``` + * + * You can pass in a bool `true` to re-enable default timeout handling. This + * will respects PHP's `default_socket_timeout` setting (default 60s): + * + * ```php + * $browser = $browser->withTimeout(true); + * ``` + * + * See also [timeouts](#timeouts) for more details about timeout handling. + * + * Notice that the [`Browser`](#browser) is an immutable object, i.e. this + * method actually returns a *new* [`Browser`](#browser) instance with the + * given timeout value applied. + * + * @param bool|number $timeout + * @return self + */ + public function withTimeout($timeout) + { + if ($timeout === true) { + $timeout = null; + } elseif ($timeout === false) { + $timeout = -1; + } elseif ($timeout < 0) { + $timeout = 0; + } + + return $this->withOptions(array( + 'timeout' => $timeout, + )); + } + + /** + * Changes how HTTP redirects will be followed. + * + * You can pass in the maximum number of redirects to follow: + * + * ```php + * $new = $browser->withFollowRedirects(5); + * ``` + * + * The request will automatically be rejected when the number of redirects + * is exceeded. You can pass in a `0` to reject the request for any + * redirects encountered: + * + * ```php + * $browser = $browser->withFollowRedirects(0); + * + * $browser->get($url)->then(function (Psr\Http\Message\ResponseInterface $response) { + * // only non-redirected responses will now end up here + * var_dump($response->getHeaders()); + * }); + * ``` + * + * You can pass in a bool `false` to disable following any redirects. In + * this case, requests will resolve with the redirection response instead + * of following the `Location` response header: + * + * ```php + * $browser = $browser->withFollowRedirects(false); + * + * $browser->get($url)->then(function (Psr\Http\Message\ResponseInterface $response) { + * // any redirects will now end up here + * var_dump($response->getHeaderLine('Location')); + * }); + * ``` + * + * You can pass in a bool `true` to re-enable default redirect handling. + * This defaults to following a maximum of 10 redirects: + * + * ```php + * $browser = $browser->withFollowRedirects(true); + * ``` + * + * See also [redirects](#redirects) for more details about redirect handling. + * + * Notice that the [`Browser`](#browser) is an immutable object, i.e. this + * method actually returns a *new* [`Browser`](#browser) instance with the + * given redirect setting applied. + * + * @param bool|int $followRedirects + * @return self + */ + public function withFollowRedirects($followRedirects) + { + return $this->withOptions(array( + 'followRedirects' => $followRedirects !== false, + 'maxRedirects' => \is_bool($followRedirects) ? null : $followRedirects + )); + } + + /** + * Changes whether non-successful HTTP response status codes (4xx and 5xx) will be rejected. + * + * You can pass in a bool `false` to disable rejecting incoming responses + * that use a 4xx or 5xx response status code. In this case, requests will + * resolve with the response message indicating an error condition: + * + * ```php + * $browser = $browser->withRejectErrorResponse(false); + * + * $browser->get($url)->then(function (Psr\Http\Message\ResponseInterface $response) { + * // any HTTP response will now end up here + * var_dump($response->getStatusCode(), $response->getReasonPhrase()); + * }); + * ``` + * + * You can pass in a bool `true` to re-enable default status code handling. + * This defaults to rejecting any response status codes in the 4xx or 5xx + * range: + * + * ```php + * $browser = $browser->withRejectErrorResponse(true); + * + * $browser->get($url)->then(function (Psr\Http\Message\ResponseInterface $response) { + * // any successful HTTP response will now end up here + * var_dump($response->getStatusCode(), $response->getReasonPhrase()); + * }, function (Exception $e) { + * if ($e instanceof React\Http\Message\ResponseException) { + * // any HTTP response error message will now end up here + * $response = $e->getResponse(); + * var_dump($response->getStatusCode(), $response->getReasonPhrase()); + * } else { + * var_dump($e->getMessage()); + * } + * }); + * ``` + * + * Notice that the [`Browser`](#browser) is an immutable object, i.e. this + * method actually returns a *new* [`Browser`](#browser) instance with the + * given setting applied. + * + * @param bool $obeySuccessCode + * @return self + */ + public function withRejectErrorResponse($obeySuccessCode) + { + return $this->withOptions(array( + 'obeySuccessCode' => $obeySuccessCode, + )); + } + + /** + * Changes the base URL used to resolve relative URLs to. + * + * If you configure a base URL, any requests to relative URLs will be + * processed by first prepending this absolute base URL. Note that this + * merely prepends the base URL and does *not* resolve any relative path + * references (like `../` etc.). This is mostly useful for (RESTful) API + * calls where all endpoints (URLs) are located under a common base URL. + * + * ```php + * $browser = $browser->withBase('http://api.example.com/v3'); + * + * // will request http://api.example.com/v3/example + * $browser->get('/example')->then(…); + * ``` + * + * You can pass in a `null` base URL to return a new instance that does not + * use a base URL: + * + * ```php + * $browser = $browser->withBase(null); + * ``` + * + * Accordingly, any requests using relative URLs to a browser that does not + * use a base URL can not be completed and will be rejected without sending + * a request. + * + * This method will throw an `InvalidArgumentException` if the given + * `$baseUrl` argument is not a valid URL. + * + * Notice that the [`Browser`](#browser) is an immutable object, i.e. the `withBase()` method + * actually returns a *new* [`Browser`](#browser) instance with the given base URL applied. + * + * > For BC reasons, this method accepts the `$baseUrl` as either a `string` + * value or as an `UriInterface`. It's recommended to explicitly cast any + * objects implementing `UriInterface` to `string`. + * + * > Changelog: As of v2.9.0 this method accepts a `null` value to reset the + * base URL. Earlier versions had to use the deprecated `withoutBase()` + * method to reset the base URL. + * + * @param string|null|UriInterface $baseUrl absolute base URL + * @return self + * @throws InvalidArgumentException if the given $baseUrl is not a valid absolute URL + * @see self::withoutBase() + */ + public function withBase($baseUrl) + { + $browser = clone $this; + if ($baseUrl === null) { + $browser->baseUrl = null; + return $browser; + } + + $browser->baseUrl = $this->messageFactory->uri($baseUrl); + if (!\in_array($browser->baseUrl->getScheme(), array('http', 'https')) || $browser->baseUrl->getHost() === '') { + throw new \InvalidArgumentException('Base URL must be absolute'); + } + + return $browser; + } + + /** + * Changes the HTTP protocol version that will be used for all subsequent requests. + * + * All the above [request methods](#request-methods) default to sending + * requests as HTTP/1.1. This is the preferred HTTP protocol version which + * also provides decent backwards-compatibility with legacy HTTP/1.0 + * servers. As such, there should rarely be a need to explicitly change this + * protocol version. + * + * If you want to explicitly use the legacy HTTP/1.0 protocol version, you + * can use this method: + * + * ```php + * $newBrowser = $browser->withProtocolVersion('1.0'); + * + * $newBrowser->get($url)->then(…); + * ``` + * + * Notice that the [`Browser`](#browser) is an immutable object, i.e. this + * method actually returns a *new* [`Browser`](#browser) instance with the + * new protocol version applied. + * + * @param string $protocolVersion HTTP protocol version to use, must be one of "1.1" or "1.0" + * @return self + * @throws InvalidArgumentException + * @since 2.8.0 + */ + public function withProtocolVersion($protocolVersion) + { + if (!\in_array($protocolVersion, array('1.0', '1.1'), true)) { + throw new InvalidArgumentException('Invalid HTTP protocol version, must be one of "1.1" or "1.0"'); + } + + $browser = clone $this; + $browser->protocolVersion = (string) $protocolVersion; + + return $browser; + } + + /** + * Changes the maximum size for buffering a response body. + * + * The preferred way to send an HTTP request is by using the above + * [request methods](#request-methods), for example the [`get()`](#get) + * method to send an HTTP `GET` request. Each of these methods will buffer + * the whole response body in memory by default. This is easy to get started + * and works reasonably well for smaller responses. + * + * By default, the response body buffer will be limited to 16 MiB. If the + * response body exceeds this maximum size, the request will be rejected. + * + * You can pass in the maximum number of bytes to buffer: + * + * ```php + * $browser = $browser->withResponseBuffer(1024 * 1024); + * + * $browser->get($url)->then(function (Psr\Http\Message\ResponseInterface $response) { + * // response body will not exceed 1 MiB + * var_dump($response->getHeaders(), (string) $response->getBody()); + * }); + * ``` + * + * Note that the response body buffer has to be kept in memory for each + * pending request until its transfer is completed and it will only be freed + * after a pending request is fulfilled. As such, increasing this maximum + * buffer size to allow larger response bodies is usually not recommended. + * Instead, you can use the [`requestStreaming()` method](#requeststreaming) + * to receive responses with arbitrary sizes without buffering. Accordingly, + * this maximum buffer size setting has no effect on streaming responses. + * + * Notice that the [`Browser`](#browser) is an immutable object, i.e. this + * method actually returns a *new* [`Browser`](#browser) instance with the + * given setting applied. + * + * @param int $maximumSize + * @return self + * @see self::requestStreaming() + */ + public function withResponseBuffer($maximumSize) + { + return $this->withOptions(array( + 'maximumSize' => $maximumSize + )); + } + + /** + * [Deprecated] Changes the [options](#options) to use: + * + * The [`Browser`](#browser) class exposes several options for the handling of + * HTTP transactions. These options resemble some of PHP's + * [HTTP context options](http://php.net/manual/en/context.http.php) and + * can be controlled via the following API (and their defaults): + * + * ```php + * // deprecated + * $newBrowser = $browser->withOptions(array( + * 'timeout' => null, // see withTimeout() instead + * 'followRedirects' => true, // see withFollowRedirects() instead + * 'maxRedirects' => 10, // see withFollowRedirects() instead + * 'obeySuccessCode' => true, // see withRejectErrorResponse() instead + * 'streaming' => false, // deprecated, see requestStreaming() instead + * )); + * ``` + * + * See also [timeouts](#timeouts), [redirects](#redirects) and + * [streaming](#streaming) for more details. + * + * Notice that the [`Browser`](#browser) is an immutable object, i.e. this + * method actually returns a *new* [`Browser`](#browser) instance with the + * options applied. + * + * @param array $options + * @return self + * @deprecated 2.9.0 See self::withTimeout(), self::withFollowRedirects() and self::withRejectErrorResponse() instead. + * @see self::withTimeout() + * @see self::withFollowRedirects() + * @see self::withRejectErrorResponse() + */ + public function withOptions(array $options) + { + $browser = clone $this; + $browser->transaction = $this->transaction->withOptions($options); + + return $browser; + } + + /** + * [Deprecated] Removes the base URL. + * + * ```php + * // deprecated: see withBase() instead + * $newBrowser = $browser->withoutBase(); + * ``` + * + * Notice that the [`Browser`](#browser) is an immutable object, i.e. the `withoutBase()` method + * actually returns a *new* [`Browser`](#browser) instance without any base URL applied. + * + * See also [`withBase()`](#withbase). + * + * @return self + * @deprecated 2.9.0 See self::withBase() instead. + * @see self::withBase() + */ + public function withoutBase() + { + return $this->withBase(null); + } + + /** + * @param string $method + * @param string|UriInterface $url + * @param array $headers + * @param string|ReadableStreamInterface $contents + * @return PromiseInterface + */ + private function requestMayBeStreaming($method, $url, array $headers = array(), $contents = '') + { + return $this->send($this->messageFactory->request($method, $url, $headers, $contents, $this->protocolVersion)); + } +} diff --git a/src/Io/ChunkedEncoder.php b/src/Io/ChunkedEncoder.php index d4e53b91..c84ef54f 100644 --- a/src/Io/ChunkedEncoder.php +++ b/src/Io/ChunkedEncoder.php @@ -17,7 +17,7 @@ class ChunkedEncoder extends EventEmitter implements ReadableStreamInterface { private $input; - private $closed; + private $closed = false; public function __construct(ReadableStreamInterface $input) { @@ -46,9 +46,7 @@ public function resume() public function pipe(WritableStreamInterface $dest, array $options = array()) { - Util::pipe($this, $dest, $options); - - return $dest; + return Util::pipe($this, $dest, $options); } public function close() @@ -67,13 +65,11 @@ public function close() /** @internal */ public function handleData($data) { - if ($data === '') { - return; + if ($data !== '') { + $this->emit('data', array( + \dechex(\strlen($data)) . "\r\n" . $data . "\r\n" + )); } - - $completeChunk = $this->createChunk($data); - - $this->emit('data', array($completeChunk)); } /** @internal */ @@ -93,18 +89,4 @@ public function handleEnd() $this->close(); } } - - /** - * @param string $data - string to be transformed in an valid - * HTTP encoded chunk string - * @return string - */ - private function createChunk($data) - { - $byteSize = \dechex(\strlen($data)); - $chunkBeginning = $byteSize . "\r\n"; - - return $chunkBeginning . $data . "\r\n"; - } - } diff --git a/src/Io/Sender.php b/src/Io/Sender.php new file mode 100644 index 00000000..e9c0a600 --- /dev/null +++ b/src/Io/Sender.php @@ -0,0 +1,161 @@ +http = $http; + $this->messageFactory = $messageFactory; + } + + /** + * + * @internal + * @param RequestInterface $request + * @return PromiseInterface Promise + */ + public function send(RequestInterface $request) + { + $body = $request->getBody(); + $size = $body->getSize(); + + if ($size !== null && $size !== 0) { + // automatically assign a "Content-Length" request header if the body size is known and non-empty + $request = $request->withHeader('Content-Length', (string)$size); + } elseif ($size === 0 && \in_array($request->getMethod(), array('POST', 'PUT', 'PATCH'))) { + // only assign a "Content-Length: 0" request header if the body is expected for certain methods + $request = $request->withHeader('Content-Length', '0'); + } elseif ($body instanceof ReadableStreamInterface && $body->isReadable() && !$request->hasHeader('Content-Length')) { + // use "Transfer-Encoding: chunked" when this is a streaming body and body size is unknown + $request = $request->withHeader('Transfer-Encoding', 'chunked'); + } else { + // do not use chunked encoding if size is known or if this is an empty request body + $size = 0; + } + + $headers = array(); + foreach ($request->getHeaders() as $name => $values) { + $headers[$name] = implode(', ', $values); + } + + $requestStream = $this->http->request($request->getMethod(), (string)$request->getUri(), $headers, $request->getProtocolVersion()); + + $deferred = new Deferred(function ($_, $reject) use ($requestStream) { + // close request stream if request is cancelled + $reject(new \RuntimeException('Request cancelled')); + $requestStream->close(); + }); + + $requestStream->on('error', function($error) use ($deferred) { + $deferred->reject($error); + }); + + $messageFactory = $this->messageFactory; + $requestStream->on('response', function (ResponseStream $responseStream) use ($deferred, $messageFactory, $request) { + // apply response header values from response stream + $deferred->resolve($messageFactory->response( + $responseStream->getVersion(), + $responseStream->getCode(), + $responseStream->getReasonPhrase(), + $responseStream->getHeaders(), + $responseStream, + $request->getMethod() + )); + }); + + if ($body instanceof ReadableStreamInterface) { + if ($body->isReadable()) { + // length unknown => apply chunked transfer-encoding + if ($size === null) { + $body = new ChunkedEncoder($body); + } + + // pipe body into request stream + // add dummy write to immediately start request even if body does not emit any data yet + $body->pipe($requestStream); + $requestStream->write(''); + + $body->on('close', $close = function () use ($deferred, $requestStream) { + $deferred->reject(new \RuntimeException('Request failed because request body closed unexpectedly')); + $requestStream->close(); + }); + $body->on('error', function ($e) use ($deferred, $requestStream, $close, $body) { + $body->removeListener('close', $close); + $deferred->reject(new \RuntimeException('Request failed because request body reported an error', 0, $e)); + $requestStream->close(); + }); + $body->on('end', function () use ($close, $body) { + $body->removeListener('close', $close); + }); + } else { + // stream is not readable => end request without body + $requestStream->end(); + } + } else { + // body is fully buffered => write as one chunk + $requestStream->end((string)$body); + } + + return $deferred->promise(); + } +} diff --git a/src/Io/Transaction.php b/src/Io/Transaction.php new file mode 100644 index 00000000..4b8cd390 --- /dev/null +++ b/src/Io/Transaction.php @@ -0,0 +1,305 @@ +sender = $sender; + $this->messageFactory = $messageFactory; + $this->loop = $loop; + } + + /** + * @param array $options + * @return self returns new instance, without modifying existing instance + */ + public function withOptions(array $options) + { + $transaction = clone $this; + foreach ($options as $name => $value) { + if (property_exists($transaction, $name)) { + // restore default value if null is given + if ($value === null) { + $default = new self($this->sender, $this->messageFactory, $this->loop); + $value = $default->$name; + } + + $transaction->$name = $value; + } + } + + return $transaction; + } + + public function send(RequestInterface $request) + { + $deferred = new Deferred(function () use (&$deferred) { + if (isset($deferred->pending)) { + $deferred->pending->cancel(); + unset($deferred->pending); + } + }); + + $deferred->numRequests = 0; + + // use timeout from options or default to PHP's default_socket_timeout (60) + $timeout = (float)($this->timeout !== null ? $this->timeout : ini_get("default_socket_timeout")); + + $loop = $this->loop; + $this->next($request, $deferred)->then( + function (ResponseInterface $response) use ($deferred, $loop, &$timeout) { + if (isset($deferred->timeout)) { + $loop->cancelTimer($deferred->timeout); + unset($deferred->timeout); + } + $timeout = -1; + $deferred->resolve($response); + }, + function ($e) use ($deferred, $loop, &$timeout) { + if (isset($deferred->timeout)) { + $loop->cancelTimer($deferred->timeout); + unset($deferred->timeout); + } + $timeout = -1; + $deferred->reject($e); + } + ); + + if ($timeout < 0) { + return $deferred->promise(); + } + + $body = $request->getBody(); + if ($body instanceof ReadableStreamInterface && $body->isReadable()) { + $that = $this; + $body->on('close', function () use ($that, $deferred, &$timeout) { + if ($timeout >= 0) { + $that->applyTimeout($deferred, $timeout); + } + }); + } else { + $this->applyTimeout($deferred, $timeout); + } + + return $deferred->promise(); + } + + /** + * @internal + * @param Deferred $deferred + * @param number $timeout + * @return void + */ + public function applyTimeout(Deferred $deferred, $timeout) + { + $deferred->timeout = $this->loop->addTimer($timeout, function () use ($timeout, $deferred) { + $deferred->reject(new \RuntimeException( + 'Request timed out after ' . $timeout . ' seconds' + )); + if (isset($deferred->pending)) { + $deferred->pending->cancel(); + unset($deferred->pending); + } + }); + } + + private function next(RequestInterface $request, Deferred $deferred) + { + $this->progress('request', array($request)); + + $that = $this; + ++$deferred->numRequests; + + $promise = $this->sender->send($request); + + if (!$this->streaming) { + $promise = $promise->then(function ($response) use ($deferred, $that) { + return $that->bufferResponse($response, $deferred); + }); + } + + $deferred->pending = $promise; + + return $promise->then( + function (ResponseInterface $response) use ($request, $that, $deferred) { + return $that->onResponse($response, $request, $deferred); + } + ); + } + + /** + * @internal + * @param ResponseInterface $response + * @return PromiseInterface Promise + */ + public function bufferResponse(ResponseInterface $response, $deferred) + { + $stream = $response->getBody(); + + $size = $stream->getSize(); + if ($size !== null && $size > $this->maximumSize) { + $stream->close(); + return \React\Promise\reject(new \OverflowException( + 'Response body size of ' . $size . ' bytes exceeds maximum of ' . $this->maximumSize . ' bytes', + \defined('SOCKET_EMSGSIZE') ? \SOCKET_EMSGSIZE : 0 + )); + } + + // body is not streaming => already buffered + if (!$stream instanceof ReadableStreamInterface) { + return \React\Promise\resolve($response); + } + + // buffer stream and resolve with buffered body + $messageFactory = $this->messageFactory; + $maximumSize = $this->maximumSize; + $promise = \React\Promise\Stream\buffer($stream, $maximumSize)->then( + function ($body) use ($response, $messageFactory) { + return $response->withBody($messageFactory->body($body)); + }, + function ($e) use ($stream, $maximumSize) { + // try to close stream if buffering fails (or is cancelled) + $stream->close(); + + if ($e instanceof \OverflowException) { + $e = new \OverflowException( + 'Response body size exceeds maximum of ' . $maximumSize . ' bytes', + \defined('SOCKET_EMSGSIZE') ? \SOCKET_EMSGSIZE : 0 + ); + } + + throw $e; + } + ); + + $deferred->pending = $promise; + + return $promise; + } + + /** + * @internal + * @param ResponseInterface $response + * @param RequestInterface $request + * @throws ResponseException + * @return ResponseInterface|PromiseInterface + */ + public function onResponse(ResponseInterface $response, RequestInterface $request, $deferred) + { + $this->progress('response', array($response, $request)); + + // follow 3xx (Redirection) response status codes if Location header is present and not explicitly disabled + // @link https://tools.ietf.org/html/rfc7231#section-6.4 + if ($this->followRedirects && ($response->getStatusCode() >= 300 && $response->getStatusCode() < 400) && $response->hasHeader('Location')) { + return $this->onResponseRedirect($response, $request, $deferred); + } + + // only status codes 200-399 are considered to be valid, reject otherwise + if ($this->obeySuccessCode && ($response->getStatusCode() < 200 || $response->getStatusCode() >= 400)) { + throw new ResponseException($response); + } + + // resolve our initial promise + return $response; + } + + /** + * @param ResponseInterface $response + * @param RequestInterface $request + * @return PromiseInterface + * @throws \RuntimeException + */ + private function onResponseRedirect(ResponseInterface $response, RequestInterface $request, Deferred $deferred) + { + // resolve location relative to last request URI + $location = $this->messageFactory->uriRelative($request->getUri(), $response->getHeaderLine('Location')); + + $request = $this->makeRedirectRequest($request, $location); + $this->progress('redirect', array($request)); + + if ($deferred->numRequests >= $this->maxRedirects) { + throw new \RuntimeException('Maximum number of redirects (' . $this->maxRedirects . ') exceeded'); + } + + return $this->next($request, $deferred); + } + + /** + * @param RequestInterface $request + * @param UriInterface $location + * @return RequestInterface + */ + private function makeRedirectRequest(RequestInterface $request, UriInterface $location) + { + $originalHost = $request->getUri()->getHost(); + $request = $request + ->withoutHeader('Host') + ->withoutHeader('Content-Type') + ->withoutHeader('Content-Length'); + + // Remove authorization if changing hostnames (but not if just changing ports or protocols). + if ($location->getHost() !== $originalHost) { + $request = $request->withoutHeader('Authorization'); + } + + // naïve approach.. + $method = ($request->getMethod() === 'HEAD') ? 'HEAD' : 'GET'; + + return $this->messageFactory->request($method, $location, $request->getHeaders()); + } + + private function progress($name, array $args = array()) + { + return; + + echo $name; + + foreach ($args as $arg) { + echo ' '; + if ($arg instanceof ResponseInterface) { + echo 'HTTP/' . $arg->getProtocolVersion() . ' ' . $arg->getStatusCode() . ' ' . $arg->getReasonPhrase(); + } elseif ($arg instanceof RequestInterface) { + echo $arg->getMethod() . ' ' . $arg->getRequestTarget() . ' HTTP/' . $arg->getProtocolVersion(); + } else { + echo $arg; + } + } + + echo PHP_EOL; + } +} diff --git a/src/Message/MessageFactory.php b/src/Message/MessageFactory.php new file mode 100644 index 00000000..eaa144cd --- /dev/null +++ b/src/Message/MessageFactory.php @@ -0,0 +1,139 @@ +body($content), $protocolVersion); + } + + /** + * Creates a new instance of ResponseInterface for the given response parameters + * + * @param string $protocolVersion + * @param int $status + * @param string $reason + * @param array $headers + * @param ReadableStreamInterface|string $body + * @param ?string $requestMethod + * @return Response + * @uses self::body() + */ + public function response($protocolVersion, $status, $reason, $headers = array(), $body = '', $requestMethod = null) + { + $response = new Response($status, $headers, $body instanceof ReadableStreamInterface ? null : $body, $protocolVersion, $reason); + + if ($body instanceof ReadableStreamInterface) { + $length = null; + $code = $response->getStatusCode(); + if ($requestMethod === 'HEAD' || ($code >= 100 && $code < 200) || $code == 204 || $code == 304) { + $length = 0; + } elseif (\strtolower($response->getHeaderLine('Transfer-Encoding')) === 'chunked') { + $length = null; + } elseif ($response->hasHeader('Content-Length')) { + $length = (int)$response->getHeaderLine('Content-Length'); + } + + $response = $response->withBody(new ReadableBodyStream($body, $length)); + } + + return $response; + } + + /** + * Creates a new instance of StreamInterface for the given body contents + * + * @param ReadableStreamInterface|string $body + * @return StreamInterface + */ + public function body($body) + { + if ($body instanceof ReadableStreamInterface) { + return new ReadableBodyStream($body); + } + + return \RingCentral\Psr7\stream_for($body); + } + + /** + * Creates a new instance of UriInterface for the given URI string + * + * @param string $uri + * @return UriInterface + */ + public function uri($uri) + { + return new Uri($uri); + } + + /** + * Creates a new instance of UriInterface for the given URI string relative to the given base URI + * + * @param UriInterface $base + * @param string $uri + * @return UriInterface + */ + public function uriRelative(UriInterface $base, $uri) + { + return Uri::resolve($base, $uri); + } + + /** + * Resolves the given relative or absolute $uri by appending it behind $this base URI + * + * The given $uri parameter can be either a relative or absolute URI and + * as such can not contain any URI template placeholders. + * + * As such, the outcome of this method represents a valid, absolute URI + * which will be returned as an instance implementing `UriInterface`. + * + * If the given $uri is a relative URI, it will simply be appended behind $base URI. + * + * If the given $uri is an absolute URI, it will simply be returned as-is. + * + * @param UriInterface $uri + * @param UriInterface $base + * @return UriInterface + */ + public function expandBase(UriInterface $uri, UriInterface $base) + { + if ($uri->getScheme() !== '') { + return $uri; + } + + $uri = (string)$uri; + $base = (string)$base; + + if ($uri !== '' && substr($base, -1) !== '/' && substr($uri, 0, 1) !== '?') { + $base .= '/'; + } + + if (isset($uri[0]) && $uri[0] === '/') { + $uri = substr($uri, 1); + } + + return $this->uri($base . $uri); + } +} diff --git a/src/Message/ReadableBodyStream.php b/src/Message/ReadableBodyStream.php new file mode 100644 index 00000000..bb0064e0 --- /dev/null +++ b/src/Message/ReadableBodyStream.php @@ -0,0 +1,153 @@ +input = $input; + $this->size = $size; + + $that = $this; + $pos =& $this->position; + $input->on('data', function ($data) use ($that, &$pos, $size) { + $that->emit('data', array($data)); + + $pos += \strlen($data); + if ($size !== null && $pos >= $size) { + $that->handleEnd(); + } + }); + $input->on('error', function ($error) use ($that) { + $that->emit('error', array($error)); + $that->close(); + }); + $input->on('end', array($that, 'handleEnd')); + $input->on('close', array($that, 'close')); + } + + public function close() + { + if (!$this->closed) { + $this->closed = true; + $this->input->close(); + + $this->emit('close'); + $this->removeAllListeners(); + } + } + + public function isReadable() + { + return $this->input->isReadable(); + } + + public function pause() + { + $this->input->pause(); + } + + public function resume() + { + $this->input->resume(); + } + + public function pipe(WritableStreamInterface $dest, array $options = array()) + { + Util::pipe($this, $dest, $options); + + return $dest; + } + + public function eof() + { + return !$this->isReadable(); + } + + public function __toString() + { + return ''; + } + + public function detach() + { + throw new \BadMethodCallException(); + } + + public function getSize() + { + return $this->size; + } + + public function tell() + { + throw new \BadMethodCallException(); + } + + public function isSeekable() + { + return false; + } + + public function seek($offset, $whence = SEEK_SET) + { + throw new \BadMethodCallException(); + } + + public function rewind() + { + throw new \BadMethodCallException(); + } + + public function isWritable() + { + return false; + } + + public function write($string) + { + throw new \BadMethodCallException(); + } + + public function read($length) + { + throw new \BadMethodCallException(); + } + + public function getContents() + { + throw new \BadMethodCallException(); + } + + public function getMetadata($key = null) + { + return ($key === null) ? array() : null; + } + + /** @internal */ + public function handleEnd() + { + if ($this->position !== $this->size && $this->size !== null) { + $this->emit('error', array(new \UnderflowException('Unexpected end of response body after ' . $this->position . '/' . $this->size . ' bytes'))); + } else { + $this->emit('end'); + } + + $this->close(); + } +} diff --git a/src/Message/ResponseException.php b/src/Message/ResponseException.php new file mode 100644 index 00000000..88272242 --- /dev/null +++ b/src/Message/ResponseException.php @@ -0,0 +1,43 @@ +getStatusCode() . ' (' . $response->getReasonPhrase() . ')'; + } + if ($code === null) { + $code = $response->getStatusCode(); + } + parent::__construct($message, $code, $previous); + + $this->response = $response; + } + + /** + * Access its underlying [`ResponseInterface`](#responseinterface) object. + * + * @return ResponseInterface + */ + public function getResponse() + { + return $this->response; + } +} diff --git a/tests/BrowserTest.php b/tests/BrowserTest.php new file mode 100644 index 00000000..56a28303 --- /dev/null +++ b/tests/BrowserTest.php @@ -0,0 +1,414 @@ +loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + $this->sender = $this->getMockBuilder('React\Http\Io\Transaction')->disableOriginalConstructor()->getMock(); + $this->browser = new Browser($this->loop); + + $ref = new \ReflectionProperty($this->browser, 'transaction'); + $ref->setAccessible(true); + $ref->setValue($this->browser, $this->sender); + } + + public function testGetSendsGetRequest() + { + $that = $this; + $this->sender->expects($this->once())->method('send')->with($this->callback(function (RequestInterface $request) use ($that) { + $that->assertEquals('GET', $request->getMethod()); + return true; + }))->willReturn(new Promise(function () { })); + + $this->browser->get('http://example.com/'); + } + + public function testPostSendsPostRequest() + { + $that = $this; + $this->sender->expects($this->once())->method('send')->with($this->callback(function (RequestInterface $request) use ($that) { + $that->assertEquals('POST', $request->getMethod()); + return true; + }))->willReturn(new Promise(function () { })); + + $this->browser->post('http://example.com/'); + } + + public function testHeadSendsHeadRequest() + { + $that = $this; + $this->sender->expects($this->once())->method('send')->with($this->callback(function (RequestInterface $request) use ($that) { + $that->assertEquals('HEAD', $request->getMethod()); + return true; + }))->willReturn(new Promise(function () { })); + + $this->browser->head('http://example.com/'); + } + + public function testPatchSendsPatchRequest() + { + $that = $this; + $this->sender->expects($this->once())->method('send')->with($this->callback(function (RequestInterface $request) use ($that) { + $that->assertEquals('PATCH', $request->getMethod()); + return true; + }))->willReturn(new Promise(function () { })); + + $this->browser->patch('http://example.com/'); + } + + public function testPutSendsPutRequest() + { + $that = $this; + $this->sender->expects($this->once())->method('send')->with($this->callback(function (RequestInterface $request) use ($that) { + $that->assertEquals('PUT', $request->getMethod()); + return true; + }))->willReturn(new Promise(function () { })); + + $this->browser->put('http://example.com/'); + } + + public function testDeleteSendsDeleteRequest() + { + $that = $this; + $this->sender->expects($this->once())->method('send')->with($this->callback(function (RequestInterface $request) use ($that) { + $that->assertEquals('DELETE', $request->getMethod()); + return true; + }))->willReturn(new Promise(function () { })); + + $this->browser->delete('http://example.com/'); + } + + public function testRequestOptionsSendsPutRequestWithStreamingExplicitlyDisabled() + { + $this->sender->expects($this->once())->method('withOptions')->with(array('streaming' => false))->willReturnSelf(); + + $that = $this; + $this->sender->expects($this->once())->method('send')->with($this->callback(function (RequestInterface $request) use ($that) { + $that->assertEquals('OPTIONS', $request->getMethod()); + return true; + }))->willReturn(new Promise(function () { })); + + $this->browser->request('OPTIONS', 'http://example.com/'); + } + + public function testRequestStreamingGetSendsGetRequestWithStreamingExplicitlyEnabled() + { + $this->sender->expects($this->once())->method('withOptions')->with(array('streaming' => true))->willReturnSelf(); + + $that = $this; + $this->sender->expects($this->once())->method('send')->with($this->callback(function (RequestInterface $request) use ($that) { + $that->assertEquals('GET', $request->getMethod()); + return true; + }))->willReturn(new Promise(function () { })); + + $this->browser->requestStreaming('GET', 'http://example.com/'); + } + + public function testSubmitSendsPostRequest() + { + $that = $this; + $this->sender->expects($this->once())->method('send')->with($this->callback(function (RequestInterface $request) use ($that) { + $that->assertEquals('POST', $request->getMethod()); + $that->assertEquals('application/x-www-form-urlencoded', $request->getHeaderLine('Content-Type')); + $that->assertEquals('', (string)$request->getBody()); + return true; + }))->willReturn(new Promise(function () { })); + + $this->browser->submit('http://example.com/', array()); + } + + public function testWithTimeoutTrueSetsDefaultTimeoutOption() + { + $this->sender->expects($this->once())->method('withOptions')->with(array('timeout' => null))->willReturnSelf(); + + $this->browser->withTimeout(true); + } + + public function testWithTimeoutFalseSetsNegativeTimeoutOption() + { + $this->sender->expects($this->once())->method('withOptions')->with(array('timeout' => -1))->willReturnSelf(); + + $this->browser->withTimeout(false); + } + + public function testWithTimeout10SetsTimeoutOption() + { + $this->sender->expects($this->once())->method('withOptions')->with(array('timeout' => 10))->willReturnSelf(); + + $this->browser->withTimeout(10); + } + + public function testWithTimeoutNegativeSetsZeroTimeoutOption() + { + $this->sender->expects($this->once())->method('withOptions')->with(array('timeout' => null))->willReturnSelf(); + + $this->browser->withTimeout(-10); + } + + public function testWithFollowRedirectsTrueSetsSenderOption() + { + $this->sender->expects($this->once())->method('withOptions')->with(array('followRedirects' => true, 'maxRedirects' => null))->willReturnSelf(); + + $this->browser->withFollowRedirects(true); + } + + public function testWithFollowRedirectsFalseSetsSenderOption() + { + $this->sender->expects($this->once())->method('withOptions')->with(array('followRedirects' => false, 'maxRedirects' => null))->willReturnSelf(); + + $this->browser->withFollowRedirects(false); + } + + public function testWithFollowRedirectsTenSetsSenderOption() + { + $this->sender->expects($this->once())->method('withOptions')->with(array('followRedirects' => true, 'maxRedirects' => 10))->willReturnSelf(); + + $this->browser->withFollowRedirects(10); + } + + public function testWithFollowRedirectsZeroSetsSenderOption() + { + $this->sender->expects($this->once())->method('withOptions')->with(array('followRedirects' => true, 'maxRedirects' => 0))->willReturnSelf(); + + $this->browser->withFollowRedirects(0); + } + + public function testWithRejectErrorResponseTrueSetsSenderOption() + { + $this->sender->expects($this->once())->method('withOptions')->with(array('obeySuccessCode' => true))->willReturnSelf(); + + $this->browser->withRejectErrorResponse(true); + } + + public function testWithRejectErrorResponseFalseSetsSenderOption() + { + $this->sender->expects($this->once())->method('withOptions')->with(array('obeySuccessCode' => false))->willReturnSelf(); + + $this->browser->withRejectErrorResponse(false); + } + + public function testWithResponseBufferThousandSetsSenderOption() + { + $this->sender->expects($this->once())->method('withOptions')->with(array('maximumSize' => 1000))->willReturnSelf(); + + $this->browser->withResponseBuffer(1000); + } + + public function testWithBase() + { + $browser = $this->browser->withBase('http://example.com/root'); + + $this->assertInstanceOf('React\Http\Browser', $browser); + $this->assertNotSame($this->browser, $browser); + } + + public function provideOtherUris() + { + return array( + 'empty returns base' => array( + 'http://example.com/base', + '', + 'http://example.com/base', + ), + 'absolute same as base returns base' => array( + 'http://example.com/base', + 'http://example.com/base', + 'http://example.com/base', + ), + 'absolute below base returns absolute' => array( + 'http://example.com/base', + 'http://example.com/base/another', + 'http://example.com/base/another', + ), + 'slash returns added slash' => array( + 'http://example.com/base', + '/', + 'http://example.com/base/', + ), + 'slash does not add duplicate slash if base already ends with slash' => array( + 'http://example.com/base/', + '/', + 'http://example.com/base/', + ), + 'relative is added behind base' => array( + 'http://example.com/base/', + 'test', + 'http://example.com/base/test', + ), + 'relative with slash is added behind base without duplicate slashes' => array( + 'http://example.com/base/', + '/test', + 'http://example.com/base/test', + ), + 'relative is added behind base with automatic slash inbetween' => array( + 'http://example.com/base', + 'test', + 'http://example.com/base/test', + ), + 'relative with slash is added behind base' => array( + 'http://example.com/base', + '/test', + 'http://example.com/base/test', + ), + 'query string is added behind base' => array( + 'http://example.com/base', + '?key=value', + 'http://example.com/base?key=value', + ), + 'query string is added behind base with slash' => array( + 'http://example.com/base/', + '?key=value', + 'http://example.com/base/?key=value', + ), + 'query string with slash is added behind base' => array( + 'http://example.com/base', + '/?key=value', + 'http://example.com/base/?key=value', + ), + 'absolute with query string below base is returned as-is' => array( + 'http://example.com/base', + 'http://example.com/base?test', + 'http://example.com/base?test', + ), + 'urlencoded special chars will stay as-is' => array( + 'http://example.com/%7Bversion%7D/', + '', + 'http://example.com/%7Bversion%7D/' + ), + 'special chars will be urlencoded' => array( + 'http://example.com/{version}/', + '', + 'http://example.com/%7Bversion%7D/' + ), + 'other domain' => array( + 'http://example.com/base/', + 'http://example.org/base/', + 'http://example.org/base/' + ), + 'other scheme' => array( + 'http://example.com/base/', + 'https://example.com/base/', + 'https://example.com/base/' + ), + 'other port' => array( + 'http://example.com/base/', + 'http://example.com:81/base/', + 'http://example.com:81/base/' + ), + 'other path' => array( + 'http://example.com/base/', + 'http://example.com/other/', + 'http://example.com/other/' + ), + 'other path due to missing slash' => array( + 'http://example.com/base/', + 'http://example.com/other', + 'http://example.com/other' + ), + ); + } + + /** + * @dataProvider provideOtherUris + * @param string $uri + * @param string $expected + */ + public function testResolveUriWithBaseEndsWithoutSlash($base, $uri, $expectedAbsolute) + { + $browser = $this->browser->withBase($base); + + $that = $this; + $this->sender->expects($this->once())->method('send')->with($this->callback(function (RequestInterface $request) use ($expectedAbsolute, $that) { + $that->assertEquals($expectedAbsolute, $request->getUri()); + return true; + }))->willReturn(new Promise(function () { })); + + $browser->get($uri); + } + + public function testWithBaseUrlNotAbsoluteFails() + { + $this->setExpectedException('InvalidArgumentException'); + $this->browser->withBase('hello'); + } + + public function testWithBaseUrlInvalidSchemeFails() + { + $this->setExpectedException('InvalidArgumentException'); + $this->browser->withBase('ftp://example.com'); + } + + public function testWithoutBaseFollowedByGetRequestTriesToSendIncompleteRequestUrl() + { + $this->browser = $this->browser->withBase('http://example.com')->withoutBase(); + + $that = $this; + $this->sender->expects($this->once())->method('send')->with($this->callback(function (RequestInterface $request) use ($that) { + $that->assertEquals('path', $request->getUri()); + return true; + }))->willReturn(new Promise(function () { })); + + $this->browser->get('path'); + } + + public function testWithProtocolVersionFollowedByGetRequestSendsRequestWithProtocolVersion() + { + $this->browser = $this->browser->withProtocolVersion('1.0'); + + $that = $this; + $this->sender->expects($this->once())->method('send')->with($this->callback(function (RequestInterface $request) use ($that) { + $that->assertEquals('1.0', $request->getProtocolVersion()); + return true; + }))->willReturn(new Promise(function () { })); + + $this->browser->get('http://example.com/'); + } + + public function testWithProtocolVersionFollowedBySubmitRequestSendsRequestWithProtocolVersion() + { + $this->browser = $this->browser->withProtocolVersion('1.0'); + + $that = $this; + $this->sender->expects($this->once())->method('send')->with($this->callback(function (RequestInterface $request) use ($that) { + $that->assertEquals('1.0', $request->getProtocolVersion()); + return true; + }))->willReturn(new Promise(function () { })); + + $this->browser->submit('http://example.com/', array()); + } + + public function testWithProtocolVersionInvalidThrows() + { + $this->setExpectedException('InvalidArgumentException'); + $this->browser->withProtocolVersion('1.2'); + } + + public function testCancelGetRequestShouldCancelUnderlyingSocketConnection() + { + $pending = new Promise(function () { }, $this->expectCallableOnce()); + + $connector = $this->getMockBuilder('React\Socket\ConnectorInterface')->getMock(); + $connector->expects($this->once())->method('connect')->with('example.com:80')->willReturn($pending); + + $this->browser = new Browser($this->loop, $connector); + + $promise = $this->browser->get('http://example.com/'); + $promise->cancel(); + } +} diff --git a/tests/FunctionalBrowserTest.php b/tests/FunctionalBrowserTest.php new file mode 100644 index 00000000..16293fbb --- /dev/null +++ b/tests/FunctionalBrowserTest.php @@ -0,0 +1,645 @@ +loop = $loop = Factory::create(); + $this->browser = new Browser($this->loop); + + $server = new Server(array(new StreamingRequestMiddleware(), function (ServerRequestInterface $request) use ($loop) { + $path = $request->getUri()->getPath(); + + $headers = array(); + foreach ($request->getHeaders() as $name => $values) { + $headers[$name] = implode(', ', $values); + } + + if ($path === '/get') { + return new Response( + 200, + array(), + 'hello' + ); + } + + if ($path === '/redirect-to') { + $params = $request->getQueryParams(); + return new Response( + 302, + array('Location' => $params['url']) + ); + } + + if ($path === '/basic-auth/user/pass') { + return new Response( + $request->getHeaderLine('Authorization') === 'Basic dXNlcjpwYXNz' ? 200 : 401, + array(), + '' + ); + } + + if ($path === '/status/300') { + return new Response( + 300, + array(), + '' + ); + } + + if ($path === '/status/404') { + return new Response( + 404, + array(), + '' + ); + } + + if ($path === '/delay/10') { + return new Promise(function ($resolve) use ($loop) { + $loop->addTimer(10, function () use ($resolve) { + $resolve(new Response( + 200, + array(), + 'hello' + )); + }); + }); + } + + if ($path === '/post') { + return new Promise(function ($resolve) use ($request, $headers) { + $body = $request->getBody(); + assert($body instanceof ReadableStreamInterface); + + $buffer = ''; + $body->on('data', function ($data) use (&$buffer) { + $buffer .= $data; + }); + + $body->on('close', function () use (&$buffer, $resolve, $headers) { + $resolve(new Response( + 200, + array(), + json_encode(array( + 'data' => $buffer, + 'headers' => $headers + )) + )); + }); + }); + } + + if ($path === '/stream/1') { + $stream = new ThroughStream(); + + $loop->futureTick(function () use ($stream, $headers) { + $stream->end(json_encode(array( + 'headers' => $headers + ))); + }); + + return new Response( + 200, + array(), + $stream + ); + } + + var_dump($path); + })); + $socket = new \React\Socket\Server(0, $this->loop); + $server->listen($socket); + + $this->base = str_replace('tcp:', 'http:', $socket->getAddress()) . '/'; + } + + /** + * @doesNotPerformAssertions + */ + public function testSimpleRequest() + { + Block\await($this->browser->get($this->base . 'get'), $this->loop); + } + + public function testGetRequestWithRelativeAddressRejects() + { + $promise = $this->browser->get('delay'); + + $this->setExpectedException('InvalidArgumentException', 'Invalid request URL given'); + Block\await($promise, $this->loop); + } + + /** + * @doesNotPerformAssertions + */ + public function testGetRequestWithBaseAndRelativeAddressResolves() + { + Block\await($this->browser->withBase($this->base)->get('get'), $this->loop); + } + + /** + * @doesNotPerformAssertions + */ + public function testGetRequestWithBaseAndFullAddressResolves() + { + Block\await($this->browser->withBase('http://example.com/')->get($this->base . 'get'), $this->loop); + } + + public function testCancelGetRequestWillRejectRequest() + { + $promise = $this->browser->get($this->base . 'get'); + $promise->cancel(); + + $this->setExpectedException('RuntimeException'); + Block\await($promise, $this->loop); + } + + public function testCancelSendWithPromiseFollowerWillRejectRequest() + { + $promise = $this->browser->send(new Request('GET', $this->base . 'get'))->then(function () { + var_dump('noop'); + }); + $promise->cancel(); + + $this->setExpectedException('RuntimeException'); + Block\await($promise, $this->loop); + } + + public function testRequestWithoutAuthenticationFails() + { + $this->setExpectedException('RuntimeException'); + Block\await($this->browser->get($this->base . 'basic-auth/user/pass'), $this->loop); + } + + /** + * @doesNotPerformAssertions + */ + public function testRequestWithAuthenticationSucceeds() + { + $base = str_replace('://', '://user:pass@', $this->base); + + Block\await($this->browser->get($base . 'basic-auth/user/pass'), $this->loop); + } + + /** + * ```bash + * $ curl -vL "http://httpbin.org/redirect-to?url=http://user:pass@httpbin.org/basic-auth/user/pass" + * ``` + * + * @doesNotPerformAssertions + */ + public function testRedirectToPageWithAuthenticationSendsAuthenticationFromLocationHeader() + { + $target = str_replace('://', '://user:pass@', $this->base) . 'basic-auth/user/pass'; + + Block\await($this->browser->get($this->base . 'redirect-to?url=' . urlencode($target)), $this->loop); + } + + /** + * ```bash + * $ curl -vL "http://unknown:invalid@httpbin.org/redirect-to?url=http://user:pass@httpbin.org/basic-auth/user/pass" + * ``` + * + * @doesNotPerformAssertions + */ + public function testRedirectFromPageWithInvalidAuthToPageWithCorrectAuthenticationSucceeds() + { + $base = str_replace('://', '://unknown:invalid@', $this->base); + $target = str_replace('://', '://user:pass@', $this->base) . 'basic-auth/user/pass'; + + Block\await($this->browser->get($base . 'redirect-to?url=' . urlencode($target)), $this->loop); + } + + public function testCancelRedirectedRequestShouldReject() + { + $promise = $this->browser->get($this->base . 'redirect-to?url=delay%2F10'); + + $this->loop->addTimer(0.1, function () use ($promise) { + $promise->cancel(); + }); + + $this->setExpectedException('RuntimeException', 'Request cancelled'); + Block\await($promise, $this->loop); + } + + public function testTimeoutDelayedResponseShouldReject() + { + $promise = $this->browser->withTimeout(0.1)->get($this->base . 'delay/10'); + + $this->setExpectedException('RuntimeException', 'Request timed out after 0.1 seconds'); + Block\await($promise, $this->loop); + } + + public function testTimeoutDelayedResponseAfterStreamingRequestShouldReject() + { + $stream = new ThroughStream(); + $promise = $this->browser->withTimeout(0.1)->post($this->base . 'delay/10', array(), $stream); + $stream->end(); + + $this->setExpectedException('RuntimeException', 'Request timed out after 0.1 seconds'); + Block\await($promise, $this->loop); + } + + /** + * @doesNotPerformAssertions + */ + public function testTimeoutFalseShouldResolveSuccessfully() + { + Block\await($this->browser->withTimeout(false)->get($this->base . 'get'), $this->loop); + } + + /** + * @doesNotPerformAssertions + */ + public function testRedirectRequestRelative() + { + Block\await($this->browser->get($this->base . 'redirect-to?url=get'), $this->loop); + } + + /** + * @doesNotPerformAssertions + */ + public function testRedirectRequestAbsolute() + { + Block\await($this->browser->get($this->base . 'redirect-to?url=' . urlencode($this->base . 'get')), $this->loop); + } + + /** + * @doesNotPerformAssertions + */ + public function testFollowingRedirectsFalseResolvesWithRedirectResult() + { + $browser = $this->browser->withFollowRedirects(false); + + Block\await($browser->get($this->base . 'redirect-to?url=get'), $this->loop); + } + + public function testFollowRedirectsZeroRejectsOnRedirect() + { + $browser = $this->browser->withFollowRedirects(0); + + $this->setExpectedException('RuntimeException'); + Block\await($browser->get($this->base . 'redirect-to?url=get'), $this->loop); + } + + /** + * @doesNotPerformAssertions + */ + public function testResponseStatus300WithoutLocationShouldResolveWithoutFollowingRedirect() + { + Block\await($this->browser->get($this->base . 'status/300'), $this->loop); + } + + /** + * @doesNotPerformAssertions + */ + public function testGetRequestWithResponseBufferMatchedExactlyResolves() + { + $promise = $this->browser->withResponseBuffer(5)->get($this->base . 'get'); + + Block\await($promise, $this->loop); + } + + public function testGetRequestWithResponseBufferExceededRejects() + { + $promise = $this->browser->withResponseBuffer(4)->get($this->base . 'get'); + + $this->setExpectedException( + 'OverflowException', + 'Response body size of 5 bytes exceeds maximum of 4 bytes', + defined('SOCKET_EMSGSIZE') ? SOCKET_EMSGSIZE : 0 + ); + Block\await($promise, $this->loop); + } + + public function testGetRequestWithResponseBufferExceededDuringStreamingRejects() + { + $promise = $this->browser->withResponseBuffer(4)->get($this->base . 'stream/1'); + + $this->setExpectedException( + 'OverflowException', + 'Response body size exceeds maximum of 4 bytes', + defined('SOCKET_EMSGSIZE') ? SOCKET_EMSGSIZE : 0 + ); + Block\await($promise, $this->loop); + } + + /** + * @group online + * @doesNotPerformAssertions + */ + public function testCanAccessHttps() + { + if (!function_exists('stream_socket_enable_crypto')) { + $this->markTestSkipped('Not supported on your platform (outdated HHVM?)'); + } + + Block\await($this->browser->get('https://www.google.com/'), $this->loop); + } + + /** + * @group online + */ + public function testVerifyPeerEnabledForBadSslRejects() + { + if (!function_exists('stream_socket_enable_crypto')) { + $this->markTestSkipped('Not supported on your platform (outdated HHVM?)'); + } + + $connector = new Connector($this->loop, array( + 'tls' => array( + 'verify_peer' => true + ) + )); + + $browser = new Browser($this->loop, $connector); + + $this->setExpectedException('RuntimeException'); + Block\await($browser->get('https://self-signed.badssl.com/'), $this->loop); + } + + /** + * @group online + * @doesNotPerformAssertions + */ + public function testVerifyPeerDisabledForBadSslResolves() + { + if (!function_exists('stream_socket_enable_crypto')) { + $this->markTestSkipped('Not supported on your platform (outdated HHVM?)'); + } + + $connector = new Connector($this->loop, array( + 'tls' => array( + 'verify_peer' => false + ) + )); + + $browser = new Browser($this->loop, $connector); + + Block\await($browser->get('https://self-signed.badssl.com/'), $this->loop); + } + + /** + * @group online + */ + public function testInvalidPort() + { + $this->setExpectedException('RuntimeException'); + Block\await($this->browser->get('http://www.google.com:443/'), $this->loop); + } + + public function testErrorStatusCodeRejectsWithResponseException() + { + try { + Block\await($this->browser->get($this->base . 'status/404'), $this->loop); + $this->fail(); + } catch (ResponseException $e) { + $this->assertEquals(404, $e->getCode()); + + $this->assertInstanceOf('Psr\Http\Message\ResponseInterface', $e->getResponse()); + $this->assertEquals(404, $e->getResponse()->getStatusCode()); + } + } + + public function testErrorStatusCodeDoesNotRejectWithRejectErrorResponseFalse() + { + $response = Block\await($this->browser->withRejectErrorResponse(false)->get($this->base . 'status/404'), $this->loop); + + $this->assertEquals(404, $response->getStatusCode()); + } + + public function testPostString() + { + $response = Block\await($this->browser->post($this->base . 'post', array(), 'hello world'), $this->loop); + $data = json_decode((string)$response->getBody(), true); + + $this->assertEquals('hello world', $data['data']); + } + + public function testReceiveStreamUntilConnectionsEndsForHttp10() + { + $response = Block\await($this->browser->withProtocolVersion('1.0')->get($this->base . 'stream/1'), $this->loop); + + $this->assertEquals('1.0', $response->getProtocolVersion()); + $this->assertFalse($response->hasHeader('Transfer-Encoding')); + + $this->assertStringStartsWith('{', (string) $response->getBody()); + $this->assertStringEndsWith('}', (string) $response->getBody()); + } + + public function testReceiveStreamChunkedForHttp11() + { + $response = Block\await($this->browser->send(new Request('GET', $this->base . 'stream/1', array(), null, '1.1')), $this->loop); + + $this->assertEquals('1.1', $response->getProtocolVersion()); + + // underlying http-client automatically decodes and doesn't expose header + // @link https://github.com/reactphp/http-client/pull/58 + // $this->assertEquals('chunked', $response->getHeaderLine('Transfer-Encoding')); + $this->assertFalse($response->hasHeader('Transfer-Encoding')); + + $this->assertStringStartsWith('{', (string) $response->getBody()); + $this->assertStringEndsWith('}', (string) $response->getBody()); + } + + public function testReceiveStreamAndExplicitlyCloseConnectionEvenWhenServerKeepsConnectionOpen() + { + $closed = new \React\Promise\Deferred(); + $socket = new \React\Socket\Server(0, $this->loop); + $socket->on('connection', function (\React\Socket\ConnectionInterface $connection) use ($closed) { + $connection->on('data', function () use ($connection) { + $connection->write("HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nhello"); + }); + $connection->on('close', function () use ($closed) { + $closed->resolve(true); + }); + }); + + $this->base = str_replace('tcp:', 'http:', $socket->getAddress()) . '/'; + + $response = Block\await($this->browser->get($this->base . 'get', array()), $this->loop); + $this->assertEquals('hello', (string)$response->getBody()); + + $ret = Block\await($closed->promise(), $this->loop, 0.1); + $this->assertTrue($ret); + + $socket->close(); + } + + public function testPostStreamChunked() + { + $stream = new ThroughStream(); + + $this->loop->addTimer(0.001, function () use ($stream) { + $stream->end('hello world'); + }); + + $response = Block\await($this->browser->post($this->base . 'post', array(), $stream), $this->loop); + $data = json_decode((string)$response->getBody(), true); + + $this->assertEquals('hello world', $data['data']); + $this->assertFalse(isset($data['headers']['Content-Length'])); + $this->assertEquals('chunked', $data['headers']['Transfer-Encoding']); + } + + public function testPostStreamKnownLength() + { + $stream = new ThroughStream(); + + $this->loop->addTimer(0.001, function () use ($stream) { + $stream->end('hello world'); + }); + + $response = Block\await($this->browser->post($this->base . 'post', array('Content-Length' => 11), $stream), $this->loop); + $data = json_decode((string)$response->getBody(), true); + + $this->assertEquals('hello world', $data['data']); + } + + /** + * @doesNotPerformAssertions + */ + public function testPostStreamWillStartSendingRequestEvenWhenBodyDoesNotEmitData() + { + $server = new Server(array(new StreamingRequestMiddleware(), function (ServerRequestInterface $request) { + return new Response(200); + })); + $socket = new \React\Socket\Server(0, $this->loop); + $server->listen($socket); + + $this->base = str_replace('tcp:', 'http:', $socket->getAddress()) . '/'; + + $stream = new ThroughStream(); + Block\await($this->browser->post($this->base . 'post', array(), $stream), $this->loop); + + $socket->close(); + } + + public function testPostStreamClosed() + { + $stream = new ThroughStream(); + $stream->close(); + + $response = Block\await($this->browser->post($this->base . 'post', array(), $stream), $this->loop); + $data = json_decode((string)$response->getBody(), true); + + $this->assertEquals('', $data['data']); + } + + public function testSendsHttp11ByDefault() + { + $server = new Server(function (ServerRequestInterface $request) { + return new Response( + 200, + array(), + $request->getProtocolVersion() + ); + }); + $socket = new \React\Socket\Server(0, $this->loop); + $server->listen($socket); + + $this->base = str_replace('tcp:', 'http:', $socket->getAddress()) . '/'; + + $response = Block\await($this->browser->get($this->base), $this->loop); + $this->assertEquals('1.1', (string)$response->getBody()); + + $socket->close(); + } + + public function testSendsExplicitHttp10Request() + { + $server = new Server(function (ServerRequestInterface $request) { + return new Response( + 200, + array(), + $request->getProtocolVersion() + ); + }); + $socket = new \React\Socket\Server(0, $this->loop); + $server->listen($socket); + + $this->base = str_replace('tcp:', 'http:', $socket->getAddress()) . '/'; + + $response = Block\await($this->browser->withProtocolVersion('1.0')->get($this->base), $this->loop); + $this->assertEquals('1.0', (string)$response->getBody()); + + $socket->close(); + } + + public function testHeadRequestReceivesResponseWithEmptyBodyButWithContentLengthResponseHeader() + { + $response = Block\await($this->browser->head($this->base . 'get'), $this->loop); + $this->assertEquals('', (string)$response->getBody()); + $this->assertEquals(0, $response->getBody()->getSize()); + $this->assertEquals('5', $response->getHeaderLine('Content-Length')); + } + + public function testRequestGetReceivesBufferedResponseEvenWhenStreamingOptionHasBeenTurnedOn() + { + $response = Block\await( + $this->browser->withOptions(array('streaming' => true))->request('GET', $this->base . 'get'), + $this->loop + ); + $this->assertEquals('hello', (string)$response->getBody()); + } + + public function testRequestStreamingGetReceivesStreamingResponseBody() + { + $buffer = Block\await( + $this->browser->requestStreaming('GET', $this->base . 'get')->then(function (ResponseInterface $response) { + return Stream\buffer($response->getBody()); + }), + $this->loop + ); + + $this->assertEquals('hello', $buffer); + } + + public function testRequestStreamingGetReceivesStreamingResponseEvenWhenStreamingOptionHasBeenTurnedOff() + { + $response = Block\await( + $this->browser->withOptions(array('streaming' => false))->requestStreaming('GET', $this->base . 'get'), + $this->loop + ); + $this->assertInstanceOf('React\Stream\ReadableStreamInterface', $response->getBody()); + $this->assertEquals('', (string)$response->getBody()); + } + + public function testRequestStreamingGetReceivesStreamingResponseBodyEvenWhenResponseBufferExceeded() + { + $buffer = Block\await( + $this->browser->withResponseBuffer(4)->requestStreaming('GET', $this->base . 'get')->then(function (ResponseInterface $response) { + return Stream\buffer($response->getBody()); + }), + $this->loop + ); + + $this->assertEquals('hello', $buffer); + } +} diff --git a/tests/Io/ChunkedEncoderTest.php b/tests/Io/ChunkedEncoderTest.php index 75d43d4a..87ce44c4 100644 --- a/tests/Io/ChunkedEncoderTest.php +++ b/tests/Io/ChunkedEncoderTest.php @@ -22,7 +22,7 @@ public function setUpChunkedStream() public function testChunked() { - $this->chunkedStream->on('data', $this->expectCallableOnce(array("5\r\nhello\r\n"))); + $this->chunkedStream->on('data', $this->expectCallableOnceWith("5\r\nhello\r\n")); $this->input->emit('data', array('hello')); } @@ -34,7 +34,7 @@ public function testEmptyString() public function testBiggerStringToCheckHexValue() { - $this->chunkedStream->on('data', $this->expectCallableOnce(array("1a\r\nabcdefghijklmnopqrstuvwxyz\r\n"))); + $this->chunkedStream->on('data', $this->expectCallableOnceWith("1a\r\nabcdefghijklmnopqrstuvwxyz\r\n")); $this->input->emit('data', array('abcdefghijklmnopqrstuvwxyz')); } diff --git a/tests/Io/SenderTest.php b/tests/Io/SenderTest.php new file mode 100644 index 00000000..aaf93ce1 --- /dev/null +++ b/tests/Io/SenderTest.php @@ -0,0 +1,393 @@ +loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + } + + public function testCreateFromLoop() + { + $sender = Sender::createFromLoop($this->loop, null, $this->getMockBuilder('React\Http\Message\MessageFactory')->getMock()); + + $this->assertInstanceOf('React\Http\Io\Sender', $sender); + } + + public function testSenderRejectsInvalidUri() + { + $connector = $this->getMockBuilder('React\Socket\ConnectorInterface')->getMock(); + $connector->expects($this->never())->method('connect'); + + $sender = new Sender(new HttpClient($this->loop, $connector), $this->getMockBuilder('React\Http\Message\MessageFactory')->getMock()); + + $request = new Request('GET', 'www.google.com'); + + $promise = $sender->send($request); + + $this->setExpectedException('InvalidArgumentException'); + Block\await($promise, $this->loop); + } + + public function testSenderConnectorRejection() + { + $connector = $this->getMockBuilder('React\Socket\ConnectorInterface')->getMock(); + $connector->expects($this->once())->method('connect')->willReturn(Promise\reject(new \RuntimeException('Rejected'))); + + $sender = new Sender(new HttpClient($this->loop, $connector), $this->getMockBuilder('React\Http\Message\MessageFactory')->getMock()); + + $request = new Request('GET', 'http://www.google.com/'); + + $promise = $sender->send($request); + + $this->setExpectedException('RuntimeException'); + Block\await($promise, $this->loop); + } + + public function testSendPostWillAutomaticallySendContentLengthHeader() + { + $client = $this->getMockBuilder('React\HttpClient\Client')->disableOriginalConstructor()->getMock(); + $client->expects($this->once())->method('request')->with( + 'POST', + 'http://www.google.com/', + array('Host' => 'www.google.com', 'Content-Length' => '5'), + '1.1' + )->willReturn($this->getMockBuilder('React\HttpClient\Request')->disableOriginalConstructor()->getMock()); + + $sender = new Sender($client, $this->getMockBuilder('React\Http\Message\MessageFactory')->getMock()); + + $request = new Request('POST', 'http://www.google.com/', array(), 'hello'); + $sender->send($request); + } + + public function testSendPostWillAutomaticallySendContentLengthZeroHeaderForEmptyRequestBody() + { + $client = $this->getMockBuilder('React\HttpClient\Client')->disableOriginalConstructor()->getMock(); + $client->expects($this->once())->method('request')->with( + 'POST', + 'http://www.google.com/', + array('Host' => 'www.google.com', 'Content-Length' => '0'), + '1.1' + )->willReturn($this->getMockBuilder('React\HttpClient\Request')->disableOriginalConstructor()->getMock()); + + $sender = new Sender($client, $this->getMockBuilder('React\Http\Message\MessageFactory')->getMock()); + + $request = new Request('POST', 'http://www.google.com/', array(), ''); + $sender->send($request); + } + + public function testSendPostStreamWillAutomaticallySendTransferEncodingChunked() + { + $outgoing = $this->getMockBuilder('React\HttpClient\Request')->disableOriginalConstructor()->getMock(); + $outgoing->expects($this->once())->method('write')->with(""); + + $client = $this->getMockBuilder('React\HttpClient\Client')->disableOriginalConstructor()->getMock(); + $client->expects($this->once())->method('request')->with( + 'POST', + 'http://www.google.com/', + array('Host' => 'www.google.com', 'Transfer-Encoding' => 'chunked'), + '1.1' + )->willReturn($outgoing); + + $sender = new Sender($client, $this->getMockBuilder('React\Http\Message\MessageFactory')->getMock()); + + $stream = new ThroughStream(); + $request = new Request('POST', 'http://www.google.com/', array(), new ReadableBodyStream($stream)); + $sender->send($request); + } + + public function testSendPostStreamWillAutomaticallyPipeChunkEncodeBodyForWriteAndRespectRequestThrottling() + { + $outgoing = $this->getMockBuilder('React\HttpClient\Request')->disableOriginalConstructor()->getMock(); + $outgoing->expects($this->once())->method('isWritable')->willReturn(true); + $outgoing->expects($this->exactly(2))->method('write')->withConsecutive(array(""), array("5\r\nhello\r\n"))->willReturn(false); + + $client = $this->getMockBuilder('React\HttpClient\Client')->disableOriginalConstructor()->getMock(); + $client->expects($this->once())->method('request')->willReturn($outgoing); + + $sender = new Sender($client, $this->getMockBuilder('React\Http\Message\MessageFactory')->getMock()); + + $stream = new ThroughStream(); + $request = new Request('POST', 'http://www.google.com/', array(), new ReadableBodyStream($stream)); + $sender->send($request); + + $ret = $stream->write('hello'); + $this->assertFalse($ret); + } + + public function testSendPostStreamWillAutomaticallyPipeChunkEncodeBodyForEnd() + { + $outgoing = $this->getMockBuilder('React\HttpClient\Request')->disableOriginalConstructor()->getMock(); + $outgoing->expects($this->once())->method('isWritable')->willReturn(true); + $outgoing->expects($this->exactly(2))->method('write')->withConsecutive(array(""), array("0\r\n\r\n"))->willReturn(false); + $outgoing->expects($this->once())->method('end')->with(null); + + $client = $this->getMockBuilder('React\HttpClient\Client')->disableOriginalConstructor()->getMock(); + $client->expects($this->once())->method('request')->willReturn($outgoing); + + $sender = new Sender($client, $this->getMockBuilder('React\Http\Message\MessageFactory')->getMock()); + + $stream = new ThroughStream(); + $request = new Request('POST', 'http://www.google.com/', array(), new ReadableBodyStream($stream)); + $sender->send($request); + + $stream->end(); + } + + public function testSendPostStreamWillRejectWhenRequestBodyEmitsErrorEvent() + { + $outgoing = $this->getMockBuilder('React\HttpClient\Request')->disableOriginalConstructor()->getMock(); + $outgoing->expects($this->once())->method('isWritable')->willReturn(true); + $outgoing->expects($this->once())->method('write')->with("")->willReturn(false); + $outgoing->expects($this->never())->method('end'); + $outgoing->expects($this->once())->method('close'); + + $client = $this->getMockBuilder('React\HttpClient\Client')->disableOriginalConstructor()->getMock(); + $client->expects($this->once())->method('request')->willReturn($outgoing); + + $sender = new Sender($client, $this->getMockBuilder('React\Http\Message\MessageFactory')->getMock()); + + $expected = new \RuntimeException(); + $stream = new ThroughStream(); + $request = new Request('POST', 'http://www.google.com/', array(), new ReadableBodyStream($stream)); + $promise = $sender->send($request); + + $stream->emit('error', array($expected)); + + $exception = null; + $promise->then(null, function ($e) use (&$exception) { + $exception = $e; + }); + + $this->assertInstanceOf('RuntimeException', $exception); + $this->assertEquals('Request failed because request body reported an error', $exception->getMessage()); + $this->assertSame($expected, $exception->getPrevious()); + } + + public function testSendPostStreamWillRejectWhenRequestBodyClosesWithoutEnd() + { + $outgoing = $this->getMockBuilder('React\HttpClient\Request')->disableOriginalConstructor()->getMock(); + $outgoing->expects($this->once())->method('isWritable')->willReturn(true); + $outgoing->expects($this->once())->method('write')->with("")->willReturn(false); + $outgoing->expects($this->never())->method('end'); + $outgoing->expects($this->once())->method('close'); + + $client = $this->getMockBuilder('React\HttpClient\Client')->disableOriginalConstructor()->getMock(); + $client->expects($this->once())->method('request')->willReturn($outgoing); + + $sender = new Sender($client, $this->getMockBuilder('React\Http\Message\MessageFactory')->getMock()); + + $stream = new ThroughStream(); + $request = new Request('POST', 'http://www.google.com/', array(), new ReadableBodyStream($stream)); + $promise = $sender->send($request); + + $stream->close(); + + $exception = null; + $promise->then(null, function ($e) use (&$exception) { + $exception = $e; + }); + + $this->assertInstanceOf('RuntimeException', $exception); + $this->assertEquals('Request failed because request body closed unexpectedly', $exception->getMessage()); + } + + public function testSendPostStreamWillNotRejectWhenRequestBodyClosesAfterEnd() + { + $outgoing = $this->getMockBuilder('React\HttpClient\Request')->disableOriginalConstructor()->getMock(); + $outgoing->expects($this->once())->method('isWritable')->willReturn(true); + $outgoing->expects($this->exactly(2))->method('write')->withConsecutive(array(""), array("0\r\n\r\n"))->willReturn(false); + $outgoing->expects($this->once())->method('end'); + $outgoing->expects($this->never())->method('close'); + + $client = $this->getMockBuilder('React\HttpClient\Client')->disableOriginalConstructor()->getMock(); + $client->expects($this->once())->method('request')->willReturn($outgoing); + + $sender = new Sender($client, $this->getMockBuilder('React\Http\Message\MessageFactory')->getMock()); + + $stream = new ThroughStream(); + $request = new Request('POST', 'http://www.google.com/', array(), new ReadableBodyStream($stream)); + $promise = $sender->send($request); + + $stream->end(); + $stream->close(); + + $exception = null; + $promise->then(null, function ($e) use (&$exception) { + $exception = $e; + }); + + $this->assertNull($exception); + } + + public function testSendPostStreamWithExplicitContentLengthWillSendHeaderAsIs() + { + $client = $this->getMockBuilder('React\HttpClient\Client')->disableOriginalConstructor()->getMock(); + $client->expects($this->once())->method('request')->with( + 'POST', + 'http://www.google.com/', + array('Host' => 'www.google.com', 'Content-Length' => '100'), + '1.1' + )->willReturn($this->getMockBuilder('React\HttpClient\Request')->disableOriginalConstructor()->getMock()); + + $sender = new Sender($client, $this->getMockBuilder('React\Http\Message\MessageFactory')->getMock()); + + $stream = new ThroughStream(); + $request = new Request('POST', 'http://www.google.com/', array('Content-Length' => '100'), new ReadableBodyStream($stream)); + $sender->send($request); + } + + public function testSendGetWillNotPassContentLengthHeaderForEmptyRequestBody() + { + $client = $this->getMockBuilder('React\HttpClient\Client')->disableOriginalConstructor()->getMock(); + $client->expects($this->once())->method('request')->with( + 'GET', + 'http://www.google.com/', + array('Host' => 'www.google.com'), + '1.1' + )->willReturn($this->getMockBuilder('React\HttpClient\Request')->disableOriginalConstructor()->getMock()); + + $sender = new Sender($client, $this->getMockBuilder('React\Http\Message\MessageFactory')->getMock()); + + $request = new Request('GET', 'http://www.google.com/'); + $sender->send($request); + } + + public function testSendCustomMethodWillNotPassContentLengthHeaderForEmptyRequestBody() + { + $client = $this->getMockBuilder('React\HttpClient\Client')->disableOriginalConstructor()->getMock(); + $client->expects($this->once())->method('request')->with( + 'CUSTOM', + 'http://www.google.com/', + array('Host' => 'www.google.com'), + '1.1' + )->willReturn($this->getMockBuilder('React\HttpClient\Request')->disableOriginalConstructor()->getMock()); + + $sender = new Sender($client, $this->getMockBuilder('React\Http\Message\MessageFactory')->getMock()); + + $request = new Request('CUSTOM', 'http://www.google.com/'); + $sender->send($request); + } + + public function testSendCustomMethodWithExplicitContentLengthZeroWillBePassedAsIs() + { + $client = $this->getMockBuilder('React\HttpClient\Client')->disableOriginalConstructor()->getMock(); + $client->expects($this->once())->method('request')->with( + 'CUSTOM', + 'http://www.google.com/', + array('Host' => 'www.google.com', 'Content-Length' => '0'), + '1.1' + )->willReturn($this->getMockBuilder('React\HttpClient\Request')->disableOriginalConstructor()->getMock()); + + $sender = new Sender($client, $this->getMockBuilder('React\Http\Message\MessageFactory')->getMock()); + + $request = new Request('CUSTOM', 'http://www.google.com/', array('Content-Length' => '0')); + $sender->send($request); + } + + public function testCancelRequestWillCancelConnector() + { + $promise = new \React\Promise\Promise(function () { }, function () { + throw new \RuntimeException(); + }); + + $connector = $this->getMockBuilder('React\Socket\ConnectorInterface')->getMock(); + $connector->expects($this->once())->method('connect')->willReturn($promise); + + $sender = new Sender(new HttpClient($this->loop, $connector), $this->getMockBuilder('React\Http\Message\MessageFactory')->getMock()); + + $request = new Request('GET', 'http://www.google.com/'); + + $promise = $sender->send($request); + $promise->cancel(); + + $this->setExpectedException('RuntimeException'); + Block\await($promise, $this->loop); + } + + public function testCancelRequestWillCloseConnection() + { + $connection = $this->getMockBuilder('React\Socket\ConnectionInterface')->getMock(); + $connection->expects($this->once())->method('close'); + + $connector = $this->getMockBuilder('React\Socket\ConnectorInterface')->getMock(); + $connector->expects($this->once())->method('connect')->willReturn(Promise\resolve($connection)); + + $sender = new Sender(new HttpClient($this->loop, $connector), $this->getMockBuilder('React\Http\Message\MessageFactory')->getMock()); + + $request = new Request('GET', 'http://www.google.com/'); + + $promise = $sender->send($request); + $promise->cancel(); + + $this->setExpectedException('RuntimeException'); + Block\await($promise, $this->loop); + } + + public function provideRequestProtocolVersion() + { + return array( + array( + new Request('GET', 'http://www.google.com/'), + 'GET', + 'http://www.google.com/', + array( + 'Host' => 'www.google.com', + ), + '1.1', + ), + array( + new Request('GET', 'http://www.google.com/', array(), '', '1.0'), + 'GET', + 'http://www.google.com/', + array( + 'Host' => 'www.google.com', + ), + '1.0', + ), + ); + } + + /** + * @dataProvider provideRequestProtocolVersion + */ + public function testRequestProtocolVersion(Request $Request, $method, $uri, $headers, $protocolVersion) + { + $http = $this->getMockBuilder('React\HttpClient\Client') + ->setMethods(array( + 'request', + )) + ->setConstructorArgs(array( + $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(), + ))->getMock(); + + $request = $this->getMockBuilder('React\HttpClient\Request') + ->setMethods(array()) + ->setConstructorArgs(array( + $this->getMockBuilder('React\Socket\ConnectorInterface')->getMock(), + new RequestData($method, $uri, $headers, $protocolVersion), + ))->getMock(); + + $http->expects($this->once())->method('request')->with($method, $uri, $headers, $protocolVersion)->willReturn($request); + + $sender = new Sender($http, $this->getMockBuilder('React\Http\Message\MessageFactory')->getMock()); + $sender->send($Request); + } +} diff --git a/tests/Io/TransactionTest.php b/tests/Io/TransactionTest.php new file mode 100644 index 00000000..882b1860 --- /dev/null +++ b/tests/Io/TransactionTest.php @@ -0,0 +1,861 @@ +makeSenderMock(); + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + $transaction = new Transaction($sender, new MessageFactory(), $loop); + + $new = $transaction->withOptions(array('followRedirects' => false)); + + $this->assertInstanceOf('React\Http\Io\Transaction', $new); + $this->assertNotSame($transaction, $new); + + $ref = new \ReflectionProperty($new, 'followRedirects'); + $ref->setAccessible(true); + + $this->assertFalse($ref->getValue($new)); + } + + public function testWithOptionsDoesNotChangeOriginalInstance() + { + $sender = $this->makeSenderMock(); + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + $transaction = new Transaction($sender, new MessageFactory(), $loop); + + $transaction->withOptions(array('followRedirects' => false)); + + $ref = new \ReflectionProperty($transaction, 'followRedirects'); + $ref->setAccessible(true); + + $this->assertTrue($ref->getValue($transaction)); + } + + public function testWithOptionsNullValueReturnsNewInstanceWithDefaultOption() + { + $sender = $this->makeSenderMock(); + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + $transaction = new Transaction($sender, new MessageFactory(), $loop); + + $transaction = $transaction->withOptions(array('followRedirects' => false)); + $transaction = $transaction->withOptions(array('followRedirects' => null)); + + $ref = new \ReflectionProperty($transaction, 'followRedirects'); + $ref->setAccessible(true); + + $this->assertTrue($ref->getValue($transaction)); + } + + public function testTimeoutExplicitOptionWillStartTimeoutTimer() + { + $messageFactory = new MessageFactory(); + + $timer = $this->getMockBuilder('React\EventLoop\TimerInterface')->getMock(); + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + $loop->expects($this->once())->method('addTimer')->with(2, $this->anything())->willReturn($timer); + $loop->expects($this->never())->method('cancelTimer'); + + $request = $this->getMockBuilder('Psr\Http\Message\RequestInterface')->getMock(); + + $sender = $this->getMockBuilder('React\Http\Io\Sender')->disableOriginalConstructor()->getMock(); + $sender->expects($this->once())->method('send')->with($this->equalTo($request))->willReturn(new \React\Promise\Promise(function () { })); + + $transaction = new Transaction($sender, $messageFactory, $loop); + $transaction = $transaction->withOptions(array('timeout' => 2)); + $promise = $transaction->send($request); + + $this->assertInstanceOf('React\Promise\PromiseInterface', $promise); + } + + public function testTimeoutImplicitFromIniWillStartTimeoutTimer() + { + $messageFactory = new MessageFactory(); + + $timer = $this->getMockBuilder('React\EventLoop\TimerInterface')->getMock(); + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + $loop->expects($this->once())->method('addTimer')->with(2, $this->anything())->willReturn($timer); + $loop->expects($this->never())->method('cancelTimer'); + + $request = $this->getMockBuilder('Psr\Http\Message\RequestInterface')->getMock(); + + $sender = $this->getMockBuilder('React\Http\Io\Sender')->disableOriginalConstructor()->getMock(); + $sender->expects($this->once())->method('send')->with($this->equalTo($request))->willReturn(new \React\Promise\Promise(function () { })); + + $transaction = new Transaction($sender, $messageFactory, $loop); + + $old = ini_get('default_socket_timeout'); + ini_set('default_socket_timeout', '2'); + $promise = $transaction->send($request); + ini_set('default_socket_timeout', $old); + + $this->assertInstanceOf('React\Promise\PromiseInterface', $promise); + } + + public function testTimeoutExplicitOptionWillRejectWhenTimerFires() + { + $messageFactory = new MessageFactory(); + + $timeout = null; + $timer = $this->getMockBuilder('React\EventLoop\TimerInterface')->getMock(); + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + $loop->expects($this->once())->method('addTimer')->with(2, $this->callback(function ($cb) use (&$timeout) { + $timeout = $cb; + return true; + }))->willReturn($timer); + $loop->expects($this->never())->method('cancelTimer'); + + $request = $this->getMockBuilder('Psr\Http\Message\RequestInterface')->getMock(); + + $sender = $this->getMockBuilder('React\Http\Io\Sender')->disableOriginalConstructor()->getMock(); + $sender->expects($this->once())->method('send')->with($this->equalTo($request))->willReturn(new \React\Promise\Promise(function () { })); + + $transaction = new Transaction($sender, $messageFactory, $loop); + $transaction = $transaction->withOptions(array('timeout' => 2)); + $promise = $transaction->send($request); + + $this->assertNotNull($timeout); + $timeout(); + + $exception = null; + $promise->then(null, function ($e) use (&$exception) { + $exception = $e; + }); + + $this->assertInstanceOf('RuntimeException', $exception); + $this->assertEquals('Request timed out after 2 seconds', $exception->getMessage()); + } + + public function testTimeoutExplicitOptionWillNotStartTimeoutWhenSenderResolvesImmediately() + { + $messageFactory = new MessageFactory(); + + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + $loop->expects($this->never())->method('addTimer'); + + $request = $this->getMockBuilder('Psr\Http\Message\RequestInterface')->getMock(); + $response = $messageFactory->response(1.0, 200, 'OK', array(), ''); + + $sender = $this->getMockBuilder('React\Http\Io\Sender')->disableOriginalConstructor()->getMock(); + $sender->expects($this->once())->method('send')->with($this->equalTo($request))->willReturn(Promise\resolve($response)); + + $transaction = new Transaction($sender, $messageFactory, $loop); + $transaction = $transaction->withOptions(array('timeout' => 0.001)); + $promise = $transaction->send($request); + + $this->assertInstanceOf('React\Promise\PromiseInterface', $promise); + $promise->then($this->expectCallableOnceWith($response)); + } + + public function testTimeoutExplicitOptionWillCancelTimeoutTimerWhenSenderResolvesLaterOn() + { + $messageFactory = new MessageFactory(); + + $timer = $this->getMockBuilder('React\EventLoop\TimerInterface')->getMock(); + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + $loop->expects($this->once())->method('addTimer')->willReturn($timer); + $loop->expects($this->once())->method('cancelTimer')->with($timer); + + $request = $this->getMockBuilder('Psr\Http\Message\RequestInterface')->getMock(); + $response = $messageFactory->response(1.0, 200, 'OK', array(), ''); + + $deferred = new Deferred(); + $sender = $this->getMockBuilder('React\Http\Io\Sender')->disableOriginalConstructor()->getMock(); + $sender->expects($this->once())->method('send')->with($this->equalTo($request))->willReturn($deferred->promise()); + + $transaction = new Transaction($sender, $messageFactory, $loop); + $transaction = $transaction->withOptions(array('timeout' => 0.001)); + $promise = $transaction->send($request); + + $deferred->resolve($response); + + $this->assertInstanceOf('React\Promise\PromiseInterface', $promise); + $promise->then($this->expectCallableOnceWith($response)); + } + + public function testTimeoutExplicitOptionWillNotStartTimeoutWhenSenderRejectsImmediately() + { + $messageFactory = new MessageFactory(); + + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + $loop->expects($this->never())->method('addTimer'); + + $request = $this->getMockBuilder('Psr\Http\Message\RequestInterface')->getMock(); + $exception = new \RuntimeException(); + + $sender = $this->getMockBuilder('React\Http\Io\Sender')->disableOriginalConstructor()->getMock(); + $sender->expects($this->once())->method('send')->with($this->equalTo($request))->willReturn(Promise\reject($exception)); + + $transaction = new Transaction($sender, $messageFactory, $loop); + $transaction = $transaction->withOptions(array('timeout' => 0.001)); + $promise = $transaction->send($request); + + $this->assertInstanceOf('React\Promise\PromiseInterface', $promise); + $promise->then(null, $this->expectCallableOnceWith($exception)); + } + + public function testTimeoutExplicitOptionWillCancelTimeoutTimerWhenSenderRejectsLaterOn() + { + $messageFactory = new MessageFactory(); + + $timer = $this->getMockBuilder('React\EventLoop\TimerInterface')->getMock(); + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + $loop->expects($this->once())->method('addTimer')->willReturn($timer); + $loop->expects($this->once())->method('cancelTimer')->with($timer); + + $request = $this->getMockBuilder('Psr\Http\Message\RequestInterface')->getMock(); + + $deferred = new Deferred(); + $sender = $this->getMockBuilder('React\Http\Io\Sender')->disableOriginalConstructor()->getMock(); + $sender->expects($this->once())->method('send')->with($this->equalTo($request))->willReturn($deferred->promise()); + + $transaction = new Transaction($sender, $messageFactory, $loop); + $transaction = $transaction->withOptions(array('timeout' => 0.001)); + $promise = $transaction->send($request); + + $exception = new \RuntimeException(); + $deferred->reject($exception); + + $this->assertInstanceOf('React\Promise\PromiseInterface', $promise); + $promise->then(null, $this->expectCallableOnceWith($exception)); + } + + public function testTimeoutExplicitNegativeWillNotStartTimeoutTimer() + { + $messageFactory = new MessageFactory(); + + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + $loop->expects($this->never())->method('addTimer'); + + $request = $this->getMockBuilder('Psr\Http\Message\RequestInterface')->getMock(); + + $sender = $this->getMockBuilder('React\Http\Io\Sender')->disableOriginalConstructor()->getMock(); + $sender->expects($this->once())->method('send')->with($this->equalTo($request))->willReturn(new \React\Promise\Promise(function () { })); + + $transaction = new Transaction($sender, $messageFactory, $loop); + $transaction = $transaction->withOptions(array('timeout' => -1)); + $promise = $transaction->send($request); + + $this->assertInstanceOf('React\Promise\PromiseInterface', $promise); + } + + public function testTimeoutExplicitOptionWillNotStartTimeoutTimerWhenRequestBodyIsStreaming() + { + $messageFactory = new MessageFactory(); + + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + $loop->expects($this->never())->method('addTimer'); + + $stream = new ThroughStream(); + $request = $messageFactory->request('POST', 'http://example.com', array(), $stream); + + $sender = $this->getMockBuilder('React\Http\Io\Sender')->disableOriginalConstructor()->getMock(); + $sender->expects($this->once())->method('send')->with($this->equalTo($request))->willReturn(new \React\Promise\Promise(function () { })); + + $transaction = new Transaction($sender, $messageFactory, $loop); + $transaction = $transaction->withOptions(array('timeout' => 2)); + $promise = $transaction->send($request); + + $this->assertInstanceOf('React\Promise\PromiseInterface', $promise); + } + + public function testTimeoutExplicitOptionWillStartTimeoutTimerWhenStreamingRequestBodyIsAlreadyClosed() + { + $messageFactory = new MessageFactory(); + + $timer = $this->getMockBuilder('React\EventLoop\TimerInterface')->getMock(); + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + $loop->expects($this->once())->method('addTimer')->with(2, $this->anything())->willReturn($timer); + $loop->expects($this->never())->method('cancelTimer'); + + $stream = new ThroughStream(); + $stream->close(); + $request = $messageFactory->request('POST', 'http://example.com', array(), $stream); + + $sender = $this->getMockBuilder('React\Http\Io\Sender')->disableOriginalConstructor()->getMock(); + $sender->expects($this->once())->method('send')->with($this->equalTo($request))->willReturn(new \React\Promise\Promise(function () { })); + + $transaction = new Transaction($sender, $messageFactory, $loop); + $transaction = $transaction->withOptions(array('timeout' => 2)); + $promise = $transaction->send($request); + + $this->assertInstanceOf('React\Promise\PromiseInterface', $promise); + } + + public function testTimeoutExplicitOptionWillStartTimeoutTimerWhenStreamingRequestBodyClosesWhileSenderIsStillPending() + { + $messageFactory = new MessageFactory(); + + $timer = $this->getMockBuilder('React\EventLoop\TimerInterface')->getMock(); + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + $loop->expects($this->once())->method('addTimer')->with(2, $this->anything())->willReturn($timer); + $loop->expects($this->never())->method('cancelTimer'); + + $stream = new ThroughStream(); + $request = $messageFactory->request('POST', 'http://example.com', array(), $stream); + + $sender = $this->getMockBuilder('React\Http\Io\Sender')->disableOriginalConstructor()->getMock(); + $sender->expects($this->once())->method('send')->with($this->equalTo($request))->willReturn(new \React\Promise\Promise(function () { })); + + $transaction = new Transaction($sender, $messageFactory, $loop); + $transaction = $transaction->withOptions(array('timeout' => 2)); + $promise = $transaction->send($request); + + $stream->close(); + + $this->assertInstanceOf('React\Promise\PromiseInterface', $promise); + } + + public function testTimeoutExplicitOptionWillNotStartTimeoutTimerWhenStreamingRequestBodyClosesAfterSenderRejects() + { + $messageFactory = new MessageFactory(); + + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + $loop->expects($this->never())->method('addTimer'); + + $stream = new ThroughStream(); + $request = $messageFactory->request('POST', 'http://example.com', array(), $stream); + + $deferred = new Deferred(); + $sender = $this->getMockBuilder('React\Http\Io\Sender')->disableOriginalConstructor()->getMock(); + $sender->expects($this->once())->method('send')->with($this->equalTo($request))->willReturn($deferred->promise()); + + $transaction = new Transaction($sender, $messageFactory, $loop); + $transaction = $transaction->withOptions(array('timeout' => 2)); + $promise = $transaction->send($request); + + $deferred->reject(new \RuntimeException('Request failed')); + $stream->close(); + + $this->assertInstanceOf('React\Promise\PromiseInterface', $promise); + } + + public function testTimeoutExplicitOptionWillRejectWhenTimerFiresAfterStreamingRequestBodyCloses() + { + $messageFactory = new MessageFactory(); + + $timeout = null; + $timer = $this->getMockBuilder('React\EventLoop\TimerInterface')->getMock(); + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + $loop->expects($this->once())->method('addTimer')->with(2, $this->callback(function ($cb) use (&$timeout) { + $timeout = $cb; + return true; + }))->willReturn($timer); + $loop->expects($this->never())->method('cancelTimer'); + + $stream = new ThroughStream(); + $request = $messageFactory->request('POST', 'http://example.com', array(), $stream); + + $sender = $this->getMockBuilder('React\Http\Io\Sender')->disableOriginalConstructor()->getMock(); + $sender->expects($this->once())->method('send')->with($this->equalTo($request))->willReturn(new \React\Promise\Promise(function () { })); + + $transaction = new Transaction($sender, $messageFactory, $loop); + $transaction = $transaction->withOptions(array('timeout' => 2)); + $promise = $transaction->send($request); + + $stream->close(); + + $this->assertNotNull($timeout); + $timeout(); + + $exception = null; + $promise->then(null, function ($e) use (&$exception) { + $exception = $e; + }); + + $this->assertInstanceOf('RuntimeException', $exception); + $this->assertEquals('Request timed out after 2 seconds', $exception->getMessage()); + } + + public function testReceivingErrorResponseWillRejectWithResponseException() + { + $request = $this->getMockBuilder('Psr\Http\Message\RequestInterface')->getMock(); + $response = new Response(404); + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + + // mock sender to resolve promise with the given $response in response to the given $request + $sender = $this->makeSenderMock(); + $sender->expects($this->once())->method('send')->with($this->equalTo($request))->willReturn(Promise\resolve($response)); + + $transaction = new Transaction($sender, new MessageFactory(), $loop); + $transaction = $transaction->withOptions(array('timeout' => -1)); + $promise = $transaction->send($request); + + try { + Block\await($promise, $loop); + $this->fail(); + } catch (ResponseException $exception) { + $this->assertEquals(404, $exception->getCode()); + $this->assertSame($response, $exception->getResponse()); + } + } + + public function testReceivingStreamingBodyWillResolveWithBufferedResponseByDefault() + { + $messageFactory = new MessageFactory(); + $loop = Factory::create(); + + $stream = new ThroughStream(); + $loop->addTimer(0.001, function () use ($stream) { + $stream->emit('data', array('hello world')); + $stream->close(); + }); + + $request = $this->getMockBuilder('Psr\Http\Message\RequestInterface')->getMock(); + $response = $messageFactory->response(1.0, 200, 'OK', array(), $stream); + + // mock sender to resolve promise with the given $response in response to the given $request + $sender = $this->makeSenderMock(); + $sender->expects($this->once())->method('send')->with($this->equalTo($request))->willReturn(Promise\resolve($response)); + + $transaction = new Transaction($sender, $messageFactory, $loop); + $promise = $transaction->send($request); + + $response = Block\await($promise, $loop); + + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals('hello world', (string)$response->getBody()); + } + + public function testReceivingStreamingBodyWithSizeExceedingMaximumResponseBufferWillRejectAndCloseResponseStream() + { + $messageFactory = new MessageFactory(); + $loop = Factory::create(); + + $stream = new ThroughStream(); + $stream->on('close', $this->expectCallableOnce()); + + $request = $this->getMockBuilder('Psr\Http\Message\RequestInterface')->getMock(); + + $response = $messageFactory->response(1.0, 200, 'OK', array('Content-Length' => '100000000'), $stream); + + // mock sender to resolve promise with the given $response in response to the given $request + $sender = $this->makeSenderMock(); + $sender->expects($this->once())->method('send')->with($this->equalTo($request))->willReturn(Promise\resolve($response)); + + $transaction = new Transaction($sender, $messageFactory, $loop); + $promise = $transaction->send($request); + + $this->setExpectedException('OverflowException'); + Block\await($promise, $loop, 0.001); + } + + public function testCancelBufferingResponseWillCloseStreamAndReject() + { + $messageFactory = new MessageFactory(); + $loop = Factory::create(); + + $stream = $this->getMockBuilder('React\Stream\ReadableStreamInterface')->getMock(); + $stream->expects($this->any())->method('isReadable')->willReturn(true); + $stream->expects($this->once())->method('close'); + + $request = $this->getMockBuilder('Psr\Http\Message\RequestInterface')->getMock(); + $response = $messageFactory->response(1.0, 200, 'OK', array(), $stream); + + // mock sender to resolve promise with the given $response in response to the given $request + $sender = $this->makeSenderMock(); + $sender->expects($this->once())->method('send')->with($this->equalTo($request))->willReturn(Promise\resolve($response)); + + $transaction = new Transaction($sender, $messageFactory, $loop); + $promise = $transaction->send($request); + $promise->cancel(); + + $this->setExpectedException('RuntimeException'); + Block\await($promise, $loop, 0.001); + } + + public function testReceivingStreamingBodyWillResolveWithStreamingResponseIfStreamingIsEnabled() + { + $messageFactory = new MessageFactory(); + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + + $request = $this->getMockBuilder('Psr\Http\Message\RequestInterface')->getMock(); + $response = $messageFactory->response(1.0, 200, 'OK', array(), $this->getMockBuilder('React\Stream\ReadableStreamInterface')->getMock()); + + // mock sender to resolve promise with the given $response in response to the given $request + $sender = $this->makeSenderMock(); + $sender->expects($this->once())->method('send')->with($this->equalTo($request))->willReturn(Promise\resolve($response)); + + $transaction = new Transaction($sender, $messageFactory, $loop); + $transaction = $transaction->withOptions(array('streaming' => true, 'timeout' => -1)); + $promise = $transaction->send($request); + + $response = Block\await($promise, $loop); + + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals('', (string)$response->getBody()); + } + + public function testResponseCode304WithoutLocationWillResolveWithResponseAsIs() + { + $messageFactory = new MessageFactory(); + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + + // conditional GET request will respond with 304 (Not Modified + $request = $messageFactory->request('GET', 'http://example.com', array('If-None-Match' => '"abc"')); + $response = $messageFactory->response(1.0, 304, null, array('ETag' => '"abc"')); + $sender = $this->makeSenderMock(); + $sender->expects($this->once())->method('send')->with($request)->willReturn(Promise\resolve($response)); + + $transaction = new Transaction($sender, $messageFactory, $loop); + $transaction = $transaction->withOptions(array('timeout' => -1)); + $promise = $transaction->send($request); + + $promise->then($this->expectCallableOnceWith($response)); + } + + public function testCustomRedirectResponseCode333WillFollowLocationHeaderAndSendRedirectedRequest() + { + $messageFactory = new MessageFactory(); + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + + // original GET request will respond with custom 333 redirect status code and follow location header + $requestOriginal = $messageFactory->request('GET', 'http://example.com'); + $response = $messageFactory->response(1.0, 333, null, array('Location' => 'foo')); + $sender = $this->makeSenderMock(); + $sender->expects($this->exactly(2))->method('send')->withConsecutive( + array($requestOriginal), + array($this->callback(function (RequestInterface $request) { + return $request->getMethod() === 'GET' && (string)$request->getUri() === 'http://example.com/foo'; + })) + )->willReturnOnConsecutiveCalls( + Promise\resolve($response), + new \React\Promise\Promise(function () { }) + ); + + $transaction = new Transaction($sender, $messageFactory, $loop); + $transaction->send($requestOriginal); + } + + public function testFollowingRedirectWithSpecifiedHeaders() + { + $messageFactory = new MessageFactory(); + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + + $customHeaders = array('User-Agent' => 'Chrome'); + $requestWithUserAgent = $messageFactory->request('GET', 'http://example.com', $customHeaders); + $sender = $this->makeSenderMock(); + + // mock sender to resolve promise with the given $redirectResponse in + // response to the given $requestWithUserAgent + $redirectResponse = $messageFactory->response(1.0, 301, null, array('Location' => 'http://redirect.com')); + $sender->expects($this->at(0))->method('send')->willReturn(Promise\resolve($redirectResponse)); + + // mock sender to resolve promise with the given $okResponse in + // response to the given $requestWithUserAgent + $okResponse = $messageFactory->response(1.0, 200, 'OK'); + $that = $this; + $sender->expects($this->at(1)) + ->method('send') + ->with($this->callback(function (RequestInterface $request) use ($that) { + $that->assertEquals(array('Chrome'), $request->getHeader('User-Agent')); + return true; + }))->willReturn(Promise\resolve($okResponse)); + + $transaction = new Transaction($sender, $messageFactory, $loop); + $transaction->send($requestWithUserAgent); + } + + public function testRemovingAuthorizationHeaderWhenChangingHostnamesDuringRedirect() + { + $messageFactory = new MessageFactory(); + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + + $customHeaders = array('Authorization' => 'secret'); + $requestWithAuthorization = $messageFactory->request('GET', 'http://example.com', $customHeaders); + $sender = $this->makeSenderMock(); + + // mock sender to resolve promise with the given $redirectResponse in + // response to the given $requestWithAuthorization + $redirectResponse = $messageFactory->response(1.0, 301, null, array('Location' => 'http://redirect.com')); + $sender->expects($this->at(0))->method('send')->willReturn(Promise\resolve($redirectResponse)); + + // mock sender to resolve promise with the given $okResponse in + // response to the given $requestWithAuthorization + $okResponse = $messageFactory->response(1.0, 200, 'OK'); + $that = $this; + $sender->expects($this->at(1)) + ->method('send') + ->with($this->callback(function (RequestInterface $request) use ($that) { + $that->assertFalse($request->hasHeader('Authorization')); + return true; + }))->willReturn(Promise\resolve($okResponse)); + + $transaction = new Transaction($sender, $messageFactory, $loop); + $transaction->send($requestWithAuthorization); + } + + public function testAuthorizationHeaderIsForwardedWhenRedirectingToSameDomain() + { + $messageFactory = new MessageFactory(); + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + + $customHeaders = array('Authorization' => 'secret'); + $requestWithAuthorization = $messageFactory->request('GET', 'http://example.com', $customHeaders); + $sender = $this->makeSenderMock(); + + // mock sender to resolve promise with the given $redirectResponse in + // response to the given $requestWithAuthorization + $redirectResponse = $messageFactory->response(1.0, 301, null, array('Location' => 'http://example.com/new')); + $sender->expects($this->at(0))->method('send')->willReturn(Promise\resolve($redirectResponse)); + + // mock sender to resolve promise with the given $okResponse in + // response to the given $requestWithAuthorization + $okResponse = $messageFactory->response(1.0, 200, 'OK'); + $that = $this; + $sender->expects($this->at(1)) + ->method('send') + ->with($this->callback(function (RequestInterface $request) use ($that) { + $that->assertEquals(array('secret'), $request->getHeader('Authorization')); + return true; + }))->willReturn(Promise\resolve($okResponse)); + + $transaction = new Transaction($sender, $messageFactory, $loop); + $transaction->send($requestWithAuthorization); + } + + public function testAuthorizationHeaderIsForwardedWhenLocationContainsAuthentication() + { + $messageFactory = new MessageFactory(); + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + + $request = $messageFactory->request('GET', 'http://example.com'); + $sender = $this->makeSenderMock(); + + // mock sender to resolve promise with the given $redirectResponse in + // response to the given $requestWithAuthorization + $redirectResponse = $messageFactory->response(1.0, 301, null, array('Location' => 'http://user:pass@example.com/new')); + $sender->expects($this->at(0))->method('send')->willReturn(Promise\resolve($redirectResponse)); + + // mock sender to resolve promise with the given $okResponse in + // response to the given $requestWithAuthorization + $okResponse = $messageFactory->response(1.0, 200, 'OK'); + $that = $this; + $sender->expects($this->at(1)) + ->method('send') + ->with($this->callback(function (RequestInterface $request) use ($that) { + $that->assertEquals('user:pass', $request->getUri()->getUserInfo()); + $that->assertFalse($request->hasHeader('Authorization')); + return true; + }))->willReturn(Promise\resolve($okResponse)); + + $transaction = new Transaction($sender, $messageFactory, $loop); + $transaction->send($request); + } + + public function testSomeRequestHeadersShouldBeRemovedWhenRedirecting() + { + $messageFactory = new MessageFactory(); + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + + $customHeaders = array( + 'Content-Type' => 'text/html; charset=utf-8', + 'Content-Length' => '111', + ); + + $requestWithCustomHeaders = $messageFactory->request('GET', 'http://example.com', $customHeaders); + $sender = $this->makeSenderMock(); + + // mock sender to resolve promise with the given $redirectResponse in + // response to the given $requestWithCustomHeaders + $redirectResponse = $messageFactory->response(1.0, 301, null, array('Location' => 'http://example.com/new')); + $sender->expects($this->at(0))->method('send')->willReturn(Promise\resolve($redirectResponse)); + + // mock sender to resolve promise with the given $okResponse in + // response to the given $requestWithCustomHeaders + $okResponse = $messageFactory->response(1.0, 200, 'OK'); + $that = $this; + $sender->expects($this->at(1)) + ->method('send') + ->with($this->callback(function (RequestInterface $request) use ($that) { + $that->assertFalse($request->hasHeader('Content-Type')); + $that->assertFalse($request->hasHeader('Content-Length')); + return true; + }))->willReturn(Promise\resolve($okResponse)); + + $transaction = new Transaction($sender, $messageFactory, $loop); + $transaction->send($requestWithCustomHeaders); + } + + public function testCancelTransactionWillCancelRequest() + { + $messageFactory = new MessageFactory(); + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + + $request = $messageFactory->request('GET', 'http://example.com'); + $sender = $this->makeSenderMock(); + + $pending = new \React\Promise\Promise(function () { }, $this->expectCallableOnce()); + + // mock sender to return pending promise which should be cancelled when cancelling result + $sender->expects($this->once())->method('send')->willReturn($pending); + + $transaction = new Transaction($sender, $messageFactory, $loop); + $promise = $transaction->send($request); + + $promise->cancel(); + } + + public function testCancelTransactionWillCancelTimeoutTimer() + { + $messageFactory = new MessageFactory(); + + $timer = $this->getMockBuilder('React\EventLoop\TimerInterface')->getMock(); + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + $loop->expects($this->once())->method('addTimer')->willReturn($timer); + $loop->expects($this->once())->method('cancelTimer')->with($timer); + + $request = $messageFactory->request('GET', 'http://example.com'); + $sender = $this->makeSenderMock(); + + $pending = new \React\Promise\Promise(function () { }, function () { throw new \RuntimeException(); }); + + // mock sender to return pending promise which should be cancelled when cancelling result + $sender->expects($this->once())->method('send')->willReturn($pending); + + $transaction = new Transaction($sender, $messageFactory, $loop); + $transaction = $transaction->withOptions(array('timeout' => 2)); + $promise = $transaction->send($request); + + $promise->cancel(); + } + + public function testCancelTransactionWillCancelRedirectedRequest() + { + $messageFactory = new MessageFactory(); + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + + $request = $messageFactory->request('GET', 'http://example.com'); + $sender = $this->makeSenderMock(); + + // mock sender to resolve promise with the given $redirectResponse in + $redirectResponse = $messageFactory->response(1.0, 301, null, array('Location' => 'http://example.com/new')); + $sender->expects($this->at(0))->method('send')->willReturn(Promise\resolve($redirectResponse)); + + $pending = new \React\Promise\Promise(function () { }, $this->expectCallableOnce()); + + // mock sender to return pending promise which should be cancelled when cancelling result + $sender->expects($this->at(1))->method('send')->willReturn($pending); + + $transaction = new Transaction($sender, $messageFactory, $loop); + $promise = $transaction->send($request); + + $promise->cancel(); + } + + public function testCancelTransactionWillCancelRedirectedRequestAgain() + { + $messageFactory = new MessageFactory(); + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + + $request = $messageFactory->request('GET', 'http://example.com'); + $sender = $this->makeSenderMock(); + + // mock sender to resolve promise with the given $redirectResponse in + $first = new Deferred(); + $sender->expects($this->at(0))->method('send')->willReturn($first->promise()); + + $second = new \React\Promise\Promise(function () { }, $this->expectCallableOnce()); + + // mock sender to return pending promise which should be cancelled when cancelling result + $sender->expects($this->at(1))->method('send')->willReturn($second); + + $transaction = new Transaction($sender, $messageFactory, $loop); + $promise = $transaction->send($request); + + // mock sender to resolve promise with the given $redirectResponse in + $first->resolve($messageFactory->response(1.0, 301, null, array('Location' => 'http://example.com/new'))); + + $promise->cancel(); + } + + public function testCancelTransactionWillCloseBufferingStream() + { + $messageFactory = new MessageFactory(); + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + + $request = $messageFactory->request('GET', 'http://example.com'); + $sender = $this->makeSenderMock(); + + $body = new ThroughStream(); + $body->on('close', $this->expectCallableOnce()); + + // mock sender to resolve promise with the given $redirectResponse in + $redirectResponse = $messageFactory->response(1.0, 301, null, array('Location' => 'http://example.com/new'), $body); + $sender->expects($this->once())->method('send')->willReturn(Promise\resolve($redirectResponse)); + + $transaction = new Transaction($sender, $messageFactory, $loop); + $promise = $transaction->send($request); + + $promise->cancel(); + } + + public function testCancelTransactionWillCloseBufferingStreamAgain() + { + $messageFactory = new MessageFactory(); + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + + $request = $messageFactory->request('GET', 'http://example.com'); + $sender = $this->makeSenderMock(); + + $first = new Deferred(); + $sender->expects($this->once())->method('send')->willReturn($first->promise()); + + $transaction = new Transaction($sender, $messageFactory, $loop); + $promise = $transaction->send($request); + + $body = new ThroughStream(); + $body->on('close', $this->expectCallableOnce()); + + // mock sender to resolve promise with the given $redirectResponse in + $first->resolve($messageFactory->response(1.0, 301, null, array('Location' => 'http://example.com/new'), $body)); + $promise->cancel(); + } + + public function testCancelTransactionShouldCancelSendingPromise() + { + $messageFactory = new MessageFactory(); + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + + $request = $messageFactory->request('GET', 'http://example.com'); + $sender = $this->makeSenderMock(); + + // mock sender to resolve promise with the given $redirectResponse in + $redirectResponse = $messageFactory->response(1.0, 301, null, array('Location' => 'http://example.com/new')); + $sender->expects($this->at(0))->method('send')->willReturn(Promise\resolve($redirectResponse)); + + $pending = new \React\Promise\Promise(function () { }, $this->expectCallableOnce()); + + // mock sender to return pending promise which should be cancelled when cancelling result + $sender->expects($this->at(1))->method('send')->willReturn($pending); + + $transaction = new Transaction($sender, $messageFactory, $loop); + $promise = $transaction->send($request); + + $promise->cancel(); + } + + /** + * @return MockObject + */ + private function makeSenderMock() + { + return $this->getMockBuilder('React\Http\Io\Sender')->disableOriginalConstructor()->getMock(); + } +} diff --git a/tests/Message/MessageFactoryTest.php b/tests/Message/MessageFactoryTest.php new file mode 100644 index 00000000..c06418bf --- /dev/null +++ b/tests/Message/MessageFactoryTest.php @@ -0,0 +1,197 @@ +messageFactory = new MessageFactory(); + } + + public function testUriSimple() + { + $uri = $this->messageFactory->uri('http://www.lueck.tv/'); + + $this->assertEquals('http', $uri->getScheme()); + $this->assertEquals('www.lueck.tv', $uri->getHost()); + $this->assertEquals('/', $uri->getPath()); + + $this->assertEquals(null, $uri->getPort()); + $this->assertEquals('', $uri->getQuery()); + } + + public function testUriComplete() + { + $uri = $this->messageFactory->uri('https://example.com:8080/?just=testing'); + + $this->assertEquals('https', $uri->getScheme()); + $this->assertEquals('example.com', $uri->getHost()); + $this->assertEquals(8080, $uri->getPort()); + $this->assertEquals('/', $uri->getPath()); + $this->assertEquals('just=testing', $uri->getQuery()); + } + + public function testPlaceholdersInUriWillBeEscaped() + { + $uri = $this->messageFactory->uri('http://example.com/{version}'); + + $this->assertEquals('/%7Bversion%7D', $uri->getPath()); + } + + public function testEscapedPlaceholdersInUriWillStayEscaped() + { + $uri = $this->messageFactory->uri('http://example.com/%7Bversion%7D'); + + $this->assertEquals('/%7Bversion%7D', $uri->getPath()); + } + + public function testResolveRelative() + { + $base = $this->messageFactory->uri('http://example.com/base/'); + + $this->assertEquals('http://example.com/base/', $this->messageFactory->uriRelative($base, '')); + $this->assertEquals('http://example.com/', $this->messageFactory->uriRelative($base, '/')); + + $this->assertEquals('http://example.com/base/a', $this->messageFactory->uriRelative($base, 'a')); + $this->assertEquals('http://example.com/a', $this->messageFactory->uriRelative($base, '../a')); + } + + public function testResolveAbsolute() + { + $base = $this->messageFactory->uri('http://example.org/'); + + $this->assertEquals('http://www.example.com/', $this->messageFactory->uriRelative($base, 'http://www.example.com/')); + } + + public function testResolveUri() + { + $base = $this->messageFactory->uri('http://example.org/'); + + $this->assertEquals('http://www.example.com/', $this->messageFactory->uriRelative($base, $this->messageFactory->uri('http://www.example.com/'))); + } + + public function testBodyString() + { + $body = $this->messageFactory->body('hi'); + + $this->assertInstanceOf('Psr\Http\Message\StreamInterface', $body); + $this->assertNotInstanceOf('React\Stream\ReadableStreamInterface', $body); + $this->assertEquals(2, $body->getSize()); + $this->assertEquals('hi', (string)$body); + } + + public function testBodyReadableStream() + { + $stream = $this->getMockBuilder('React\Stream\ReadableStreamInterface')->getMock(); + $body = $this->messageFactory->body($stream); + + $this->assertInstanceOf('Psr\Http\Message\StreamInterface', $body); + $this->assertInstanceOf('React\Stream\ReadableStreamInterface', $body); + $this->assertEquals(null, $body->getSize()); + $this->assertEquals('', (string)$body); + } + + public function testResponseWithBodyString() + { + $response = $this->messageFactory->response('1.1', 200, 'OK', array(), 'hi'); + + $body = $response->getBody(); + $this->assertInstanceOf('Psr\Http\Message\StreamInterface', $body); + $this->assertNotInstanceOf('React\Stream\ReadableStreamInterface', $body); + $this->assertEquals(2, $body->getSize()); + $this->assertEquals('hi', (string)$body); + } + + public function testResponseWithStreamingBodyHasUnknownSizeByDefault() + { + $stream = $this->getMockBuilder('React\Stream\ReadableStreamInterface')->getMock(); + $response = $this->messageFactory->response('1.1', 200, 'OK', array(), $stream); + + $body = $response->getBody(); + $this->assertInstanceOf('Psr\Http\Message\StreamInterface', $body); + $this->assertInstanceOf('React\Stream\ReadableStreamInterface', $body); + $this->assertNull($body->getSize()); + $this->assertEquals('', (string)$body); + } + + public function testResponseWithStreamingBodyHasSizeFromContentLengthHeader() + { + $stream = $this->getMockBuilder('React\Stream\ReadableStreamInterface')->getMock(); + $response = $this->messageFactory->response('1.1', 200, 'OK', array('Content-Length' => '100'), $stream); + + $body = $response->getBody(); + $this->assertInstanceOf('Psr\Http\Message\StreamInterface', $body); + $this->assertInstanceOf('React\Stream\ReadableStreamInterface', $body); + $this->assertEquals(100, $body->getSize()); + $this->assertEquals('', (string)$body); + } + + public function testResponseWithStreamingBodyHasUnknownSizeWithTransferEncodingChunkedHeader() + { + $stream = $this->getMockBuilder('React\Stream\ReadableStreamInterface')->getMock(); + $response = $this->messageFactory->response('1.1', 200, 'OK', array('Transfer-Encoding' => 'chunked'), $stream); + + $body = $response->getBody(); + $this->assertInstanceOf('Psr\Http\Message\StreamInterface', $body); + $this->assertInstanceOf('React\Stream\ReadableStreamInterface', $body); + $this->assertNull($body->getSize()); + $this->assertEquals('', (string)$body); + } + + public function testResponseWithStreamingBodyHasZeroSizeForInformationalResponse() + { + $stream = $this->getMockBuilder('React\Stream\ReadableStreamInterface')->getMock(); + $response = $this->messageFactory->response('1.1', 101, 'OK', array('Content-Length' => '100'), $stream); + + $body = $response->getBody(); + $this->assertInstanceOf('Psr\Http\Message\StreamInterface', $body); + $this->assertInstanceOf('React\Stream\ReadableStreamInterface', $body); + $this->assertEquals(0, $body->getSize()); + $this->assertEquals('', (string)$body); + } + + public function testResponseWithStreamingBodyHasZeroSizeForNoContentResponse() + { + $stream = $this->getMockBuilder('React\Stream\ReadableStreamInterface')->getMock(); + $response = $this->messageFactory->response('1.1', 204, 'OK', array('Content-Length' => '100'), $stream); + + $body = $response->getBody(); + $this->assertInstanceOf('Psr\Http\Message\StreamInterface', $body); + $this->assertInstanceOf('React\Stream\ReadableStreamInterface', $body); + $this->assertEquals(0, $body->getSize()); + $this->assertEquals('', (string)$body); + } + + public function testResponseWithStreamingBodyHasZeroSizeForNotModifiedResponse() + { + $stream = $this->getMockBuilder('React\Stream\ReadableStreamInterface')->getMock(); + $response = $this->messageFactory->response('1.1', 304, 'OK', array('Content-Length' => '100'), $stream); + + $body = $response->getBody(); + $this->assertInstanceOf('Psr\Http\Message\StreamInterface', $body); + $this->assertInstanceOf('React\Stream\ReadableStreamInterface', $body); + $this->assertEquals(0, $body->getSize()); + $this->assertEquals('', (string)$body); + } + + public function testResponseWithStreamingBodyHasZeroSizeForHeadRequestMethod() + { + $stream = $this->getMockBuilder('React\Stream\ReadableStreamInterface')->getMock(); + $response = $this->messageFactory->response('1.1', 200, 'OK', array('Content-Length' => '100'), $stream, 'HEAD'); + + $body = $response->getBody(); + $this->assertInstanceOf('Psr\Http\Message\StreamInterface', $body); + $this->assertInstanceOf('React\Stream\ReadableStreamInterface', $body); + $this->assertEquals(0, $body->getSize()); + $this->assertEquals('', (string)$body); + } +} diff --git a/tests/Message/ReadableBodyStreamTest.php b/tests/Message/ReadableBodyStreamTest.php new file mode 100644 index 00000000..b540b888 --- /dev/null +++ b/tests/Message/ReadableBodyStreamTest.php @@ -0,0 +1,255 @@ +input = $this->getMockBuilder('React\Stream\ReadableStreamInterface')->getMock(); + $this->stream = new ReadableBodyStream($this->input); + } + + public function testIsReadableIfInputIsReadable() + { + $this->input->expects($this->once())->method('isReadable')->willReturn(true); + + $this->assertTrue($this->stream->isReadable()); + } + + public function testIsEofIfInputIsNotReadable() + { + $this->input->expects($this->once())->method('isReadable')->willReturn(false); + + $this->assertTrue($this->stream->eof()); + } + + public function testCloseWillCloseInputStream() + { + $this->input->expects($this->once())->method('close'); + + $this->stream->close(); + } + + public function testCloseWillEmitCloseEvent() + { + $this->input = new ThroughStream(); + $this->stream = new ReadableBodyStream($this->input); + + $called = 0; + $this->stream->on('close', function () use (&$called) { + ++$called; + }); + + $this->stream->close(); + $this->stream->close(); + + $this->assertEquals(1, $called); + } + + public function testCloseInputWillEmitCloseEvent() + { + $this->input = new ThroughStream(); + $this->stream = new ReadableBodyStream($this->input); + + $called = 0; + $this->stream->on('close', function () use (&$called) { + ++$called; + }); + + $this->input->close(); + $this->input->close(); + + $this->assertEquals(1, $called); + } + + public function testEndInputWillEmitCloseEvent() + { + $this->input = new ThroughStream(); + $this->stream = new ReadableBodyStream($this->input); + + $called = 0; + $this->stream->on('close', function () use (&$called) { + ++$called; + }); + + $this->input->end(); + $this->input->end(); + + $this->assertEquals(1, $called); + } + + public function testEndInputWillEmitErrorEventWhenDataDoesNotReachExpectedLength() + { + $this->input = new ThroughStream(); + $this->stream = new ReadableBodyStream($this->input, 5); + + $called = null; + $this->stream->on('error', function ($e) use (&$called) { + $called = $e; + }); + + $this->input->write('hi'); + $this->input->end(); + + $this->assertInstanceOf('UnderflowException', $called); + $this->assertSame('Unexpected end of response body after 2/5 bytes', $called->getMessage()); + } + + public function testDataEventOnInputWillEmitDataEvent() + { + $this->input = new ThroughStream(); + $this->stream = new ReadableBodyStream($this->input); + + $called = null; + $this->stream->on('data', function ($data) use (&$called) { + $called = $data; + }); + + $this->input->write('hello'); + + $this->assertEquals('hello', $called); + } + + public function testDataEventOnInputWillEmitEndWhenDataReachesExpectedLength() + { + $this->input = new ThroughStream(); + $this->stream = new ReadableBodyStream($this->input, 5); + + $called = null; + $this->stream->on('end', function () use (&$called) { + ++$called; + }); + + $this->input->write('hello'); + + $this->assertEquals(1, $called); + } + + public function testEndEventOnInputWillEmitEndOnlyOnceWhenDataAlreadyReachedExpectedLength() + { + $this->input = new ThroughStream(); + $this->stream = new ReadableBodyStream($this->input, 5); + + $called = null; + $this->stream->on('end', function () use (&$called) { + ++$called; + }); + + $this->input->write('hello'); + $this->input->end(); + + $this->assertEquals(1, $called); + } + + public function testDataEventOnInputWillNotEmitEndWhenDataDoesNotReachExpectedLength() + { + $this->input = new ThroughStream(); + $this->stream = new ReadableBodyStream($this->input, 5); + + $called = null; + $this->stream->on('end', function () use (&$called) { + ++$called; + }); + + $this->input->write('hi'); + + $this->assertNull($called); + } + + public function testPauseWillPauseInputStream() + { + $this->input->expects($this->once())->method('pause'); + + $this->stream->pause(); + } + + public function testResumeWillResumeInputStream() + { + $this->input->expects($this->once())->method('resume'); + + $this->stream->resume(); + } + + public function testPointlessTostringReturnsEmptyString() + { + $this->assertEquals('', (string)$this->stream); + } + + public function testPointlessDetachThrows() + { + $this->setExpectedException('BadMethodCallException'); + $this->stream->detach(); + } + + public function testPointlessGetSizeReturnsNull() + { + $this->assertEquals(null, $this->stream->getSize()); + } + + public function testPointlessTellThrows() + { + $this->setExpectedException('BadMethodCallException'); + $this->stream->tell(); + } + + public function testPointlessIsSeekableReturnsFalse() + { + $this->assertEquals(false, $this->stream->isSeekable()); + } + + public function testPointlessSeekThrows() + { + $this->setExpectedException('BadMethodCallException'); + $this->stream->seek(0); + } + + public function testPointlessRewindThrows() + { + $this->setExpectedException('BadMethodCallException'); + $this->stream->rewind(); + } + + public function testPointlessIsWritableReturnsFalse() + { + $this->assertEquals(false, $this->stream->isWritable()); + } + + public function testPointlessWriteThrows() + { + $this->setExpectedException('BadMethodCallException'); + $this->stream->write(''); + } + + public function testPointlessReadThrows() + { + $this->setExpectedException('BadMethodCallException'); + $this->stream->read(8192); + } + + public function testPointlessGetContentsThrows() + { + $this->setExpectedException('BadMethodCallException'); + $this->stream->getContents(); + } + + public function testPointlessGetMetadataReturnsNullWhenKeyIsGiven() + { + $this->assertEquals(null, $this->stream->getMetadata('unknown')); + } + + public function testPointlessGetMetadataReturnsEmptyArrayWhenNoKeyIsGiven() + { + $this->assertEquals(array(), $this->stream->getMetadata()); + } +} diff --git a/tests/Message/ResponseExceptionTest.php b/tests/Message/ResponseExceptionTest.php new file mode 100644 index 00000000..33eeea9e --- /dev/null +++ b/tests/Message/ResponseExceptionTest.php @@ -0,0 +1,23 @@ +withStatus(404, 'File not found'); + + $e = new ResponseException($response); + + $this->assertEquals(404, $e->getCode()); + $this->assertEquals('HTTP status code 404 (File not found)', $e->getMessage()); + + $this->assertSame($response, $e->getResponse()); + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php index 6295e871..575ac274 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -6,16 +6,6 @@ class TestCase extends BaseTestCase { - protected function expectCallableExactly($amount) - { - $mock = $this->createCallableMock(); - $mock - ->expects($this->exactly($amount)) - ->method('__invoke'); - - return $mock; - } - protected function expectCallableOnce() { $mock = $this->createCallableMock(); @@ -64,10 +54,10 @@ protected function expectCallableConsecutive($numberOfCalls, array $with) protected function createCallableMock() { if (method_exists('PHPUnit\Framework\MockObject\MockBuilder', 'addMethods')) { - // PHPUnit 10+ + // PHPUnit 9+ return $this->getMockBuilder('stdClass')->addMethods(array('__invoke'))->getMock(); } else { - // legacy PHPUnit 4 - PHPUnit 9 + // legacy PHPUnit 4 - PHPUnit 8 return $this->getMockBuilder('stdClass')->setMethods(array('__invoke'))->getMock(); } } From a71880c803028ca9669f0197d2f0f138ea343d6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Fri, 3 Jul 2020 22:08:20 +0200 Subject: [PATCH 3/5] Remove deprecated APIs and legacy references --- README.md | 178 ++------------------------------ src/Browser.php | 169 ++++-------------------------- tests/BrowserTest.php | 28 +---- tests/FunctionalBrowserTest.php | 25 +---- 4 files changed, 29 insertions(+), 371 deletions(-) diff --git a/README.md b/README.md index af2fada9..92877f5a 100644 --- a/README.md +++ b/README.md @@ -52,16 +52,12 @@ Event-driven, streaming plaintext HTTP and secure HTTPS server for [ReactPHP](ht * [delete()](#delete) * [request()](#request) * [requestStreaming()](#requeststreaming) - * [~~submit()~~](#submit) - * [~~send()~~](#send) * [withTimeout()](#withtimeout) * [withFollowRedirects()](#withfollowredirects) * [withRejectErrorResponse()](#withrejecterrorresponse) * [withBase()](#withbase) * [withProtocolVersion()](#withprotocolversion) * [withResponseBuffer()](#withresponsebuffer) - * [~~withOptions()~~](#withoptions) - * [~~withoutBase()~~](#withoutbase) * [React\Http\Middleware](#reacthttpmiddleware) * [StreamingRequestMiddleware](#streamingrequestmiddleware) * [LimitConcurrentRequestsMiddleware](#limitconcurrentrequestsmiddleware) @@ -69,7 +65,6 @@ Event-driven, streaming plaintext HTTP and secure HTTPS server for [ReactPHP](ht * [RequestBodyParserMiddleware](#requestbodyparsermiddleware) * [ResponseInterface](#responseinterface) * [RequestInterface](#requestinterface) - * [UriInterface](#uriinterface) * [ResponseException](#responseexception) * [Install](#install) * [Tests](#tests) @@ -118,8 +113,6 @@ See also the [examples](examples). ### Request methods - - Most importantly, this project provides a [`Browser`](#browser) object that offers several methods that resemble the HTTP protocol methods: @@ -450,8 +443,6 @@ more details. ### Streaming response - - All of the above examples assume you want to store the whole response body in memory. This is easy to get started and works reasonably well for smaller responses. @@ -558,10 +549,6 @@ $stream->on('data', function ($data) { See also the [`requestStreaming()`](#requeststreaming) method for more details. -> Legacy info: Legacy versions prior to v2.9.0 used the legacy - [`streaming` option](#withoptions). This option is now deprecated but otherwise - continues to show the exact same behavior. - ### Streaming request Besides streaming the response body, you can also stream the request body. @@ -1105,8 +1092,6 @@ header or when using `Transfer-Encoding: chunked` for HTTP/1.1 requests. #### Streaming incoming request - - If you're using the advanced [`StreamingRequestMiddleware`](#streamingrequestmiddleware), the request object will be processed once the request headers have been received. This means that this happens irrespective of (i.e. *before*) receiving the @@ -1413,8 +1398,6 @@ If a promise is resolved after the client closes, it will simply be ignored. #### Streaming outgoing response - - The `Response` class in this project supports to add an instance which implements the [ReactPHP ReadableStreamInterface](https://github.com/reactphp/stream#readablestreaminterface) for the response body. @@ -1859,7 +1842,7 @@ $browser = new React\Http\Browser($loop, $connector); #### get() -The `get(string|UriInterface $url, array $headers = array()): PromiseInterface` method can be used to +The `get(string $url, array $headers = array()): PromiseInterface` method can be used to send an HTTP GET request. ```php @@ -1870,13 +1853,9 @@ $browser->get($url)->then(function (Psr\Http\Message\ResponseInterface $response See also [example 01](examples/01-google.php). -> For BC reasons, this method accepts the `$url` as either a `string` - value or as an `UriInterface`. It's recommended to explicitly cast any - objects implementing `UriInterface` to `string`. - #### post() -The `post(string|UriInterface $url, array $headers = array(), string|ReadableStreamInterface $contents = ''): PromiseInterface` method can be used to +The `post(string $url, array $headers = array(), string|ReadableStreamInterface $contents = ''): PromiseInterface` method can be used to send an HTTP POST request. ```php @@ -1925,13 +1904,9 @@ $loop->addTimer(1.0, function () use ($body) { $browser->post($url, array('Content-Length' => '11'), $body); ``` -> For BC reasons, this method accepts the `$url` as either a `string` - value or as an `UriInterface`. It's recommended to explicitly cast any - objects implementing `UriInterface` to `string`. - #### head() -The `head(string|UriInterface $url, array $headers = array()): PromiseInterface` method can be used to +The `head(string $url, array $headers = array()): PromiseInterface` method can be used to send an HTTP HEAD request. ```php @@ -1940,13 +1915,9 @@ $browser->head($url)->then(function (Psr\Http\Message\ResponseInterface $respons }); ``` -> For BC reasons, this method accepts the `$url` as either a `string` - value or as an `UriInterface`. It's recommended to explicitly cast any - objects implementing `UriInterface` to `string`. - #### patch() -The `patch(string|UriInterface $url, array $headers = array(), string|ReadableStreamInterface $contents = ''): PromiseInterface` method can be used to +The `patch(string $url, array $headers = array(), string|ReadableStreamInterface $contents = ''): PromiseInterface` method can be used to send an HTTP PATCH request. ```php @@ -1976,13 +1947,9 @@ $loop->addTimer(1.0, function () use ($body) { $browser->patch($url, array('Content-Length' => '11'), $body); ``` -> For BC reasons, this method accepts the `$url` as either a `string` - value or as an `UriInterface`. It's recommended to explicitly cast any - objects implementing `UriInterface` to `string`. - #### put() -The `put(string|UriInterface $url, array $headers = array()): PromiseInterface` method can be used to +The `put(string $url, array $headers = array()): PromiseInterface` method can be used to send an HTTP PUT request. ```php @@ -2014,13 +1981,9 @@ $loop->addTimer(1.0, function () use ($body) { $browser->put($url, array('Content-Length' => '11'), $body); ``` -> For BC reasons, this method accepts the `$url` as either a `string` - value or as an `UriInterface`. It's recommended to explicitly cast any - objects implementing `UriInterface` to `string`. - #### delete() -The `delete(string|UriInterface $url, array $headers = array()): PromiseInterface` method can be used to +The `delete(string $url, array $headers = array()): PromiseInterface` method can be used to send an HTTP DELETE request. ```php @@ -2029,10 +1992,6 @@ $browser->delete($url)->then(function (Psr\Http\Message\ResponseInterface $respo }); ``` -> For BC reasons, this method accepts the `$url` as either a `string` - value or as an `UriInterface`. It's recommended to explicitly cast any - objects implementing `UriInterface` to `string`. - #### request() The `request(string $method, string $url, array $headers = array(), string|ReadableStreamInterface $body = ''): PromiseInterface` method can be used to @@ -2070,12 +2029,6 @@ $loop->addTimer(1.0, function () use ($body) { $browser->request('POST', $url, array('Content-Length' => '11'), $body); ``` -> Note that this method is available as of v2.9.0 and always buffers the - response body before resolving. - It does not respect the deprecated [`streaming` option](#withoptions). - If you want to stream the response body, you can use the - [`requestStreaming()`](#requeststreaming) method instead. - #### requestStreaming() The `requestStreaming(string $method, string $url, array $headers = array(), string|ReadableStreamInterface $body = ''): PromiseInterface` method can be used to @@ -2138,55 +2091,6 @@ $loop->addTimer(1.0, function () use ($body) { $browser->requestStreaming('POST', $url, array('Content-Length' => '11'), $body); ``` -> Note that this method is available as of v2.9.0 and always resolves the - response without buffering the response body. - It does not respect the deprecated [`streaming` option](#withoptions). - If you want to buffer the response body, use can use the - [`request()`](#request) method instead. - -#### ~~submit()~~ - -> Deprecated since v2.9.0, see [`post()`](#post) instead. - -The deprecated `submit(string|UriInterface $url, array $fields, array $headers = array(), string $method = 'POST'): PromiseInterface` method can be used to -submit an array of field values similar to submitting a form (`application/x-www-form-urlencoded`). - -```php -// deprecated: see post() instead -$browser->submit($url, array('user' => 'test', 'password' => 'secret')); -``` - -> For BC reasons, this method accepts the `$url` as either a `string` - value or as an `UriInterface`. It's recommended to explicitly cast any - objects implementing `UriInterface` to `string`. - -#### ~~send()~~ - -> Deprecated since v2.9.0, see [`request()`](#request) instead. - -The deprecated `send(RequestInterface $request): PromiseInterface` method can be used to -send an arbitrary instance implementing the [`RequestInterface`](#requestinterface) (PSR-7). - -The preferred way to send an HTTP request is by using the above -[request methods](#request-methods), for example the [`get()`](#get) -method to send an HTTP `GET` request. - -As an alternative, if you want to use a custom HTTP request method, you -can use this method: - -```php -$request = new Request('OPTIONS', $url); - -// deprecated: see request() instead -$browser->send($request)->then(…); -``` - -This method will automatically add a matching `Content-Length` request -header if the size of the outgoing request body is known and non-empty. -For an empty request body, if will only include a `Content-Length: 0` -request header if the request method usually expects a request body (only -applies to `POST`, `PUT` and `PATCH`). - #### withTimeout() The `withTimeout(bool|number $timeout): Browser` method can be used to @@ -2313,7 +2217,7 @@ given setting applied. #### withBase() -The `withBase(string|null|UriInterface $baseUrl): Browser` method can be used to +The `withBase(string|null $baseUrl): Browser` method can be used to change the base URL used to resolve relative URLs to. If you configure a base URL, any requests to relative URLs will be @@ -2346,14 +2250,6 @@ This method will throw an `InvalidArgumentException` if the given Notice that the [`Browser`](#browser) is an immutable object, i.e. the `withBase()` method actually returns a *new* [`Browser`](#browser) instance with the given base URL applied. -> For BC reasons, this method accepts the `$baseUrl` as either a `string` - value or as an `UriInterface`. It's recommended to explicitly cast any - objects implementing `UriInterface` to `string`. - -> Changelog: As of v2.9.0 this method accepts a `null` value to reset the - base URL. Earlier versions had to use the deprecated `withoutBase()` - method to reset the base URL. - #### withProtocolVersion() The `withProtocolVersion(string $protocolVersion): Browser` method can be used to @@ -2415,54 +2311,6 @@ Notice that the [`Browser`](#browser) is an immutable object, i.e. this method actually returns a *new* [`Browser`](#browser) instance with the given setting applied. -#### ~~withOptions()~~ - -> Deprecated since v2.9.0, see [`withTimeout()`](#withtimeout), [`withFollowRedirects()`](#withfollowredirects) - and [`withRejectErrorResponse()`](#withrejecterrorresponse) instead. - -The deprecated `withOptions(array $options): Browser` method can be used to -change the options to use: - -The [`Browser`](#browser) class exposes several options for the handling of -HTTP transactions. These options resemble some of PHP's -[HTTP context options](https://www.php.net/manual/en/context.http.php) and -can be controlled via the following API (and their defaults): - -```php -// deprecated -$newBrowser = $browser->withOptions(array( - 'timeout' => null, // see withTimeout() instead - 'followRedirects' => true, // see withFollowRedirects() instead - 'maxRedirects' => 10, // see withFollowRedirects() instead - 'obeySuccessCode' => true, // see withRejectErrorResponse() instead - 'streaming' => false, // deprecated, see requestStreaming() instead -)); -``` - -See also [timeouts](#timeouts), [redirects](#redirects) and -[streaming](#streaming-response) for more details. - -Notice that the [`Browser`](#browser) is an immutable object, i.e. this -method actually returns a *new* [`Browser`](#browser) instance with the -options applied. - -#### ~~withoutBase()~~ - -> Deprecated since v2.9.0, see [`withBase()`](#withbase) instead. - -The deprecated `withoutBase(): Browser` method can be used to -remove the base URL. - -```php -// deprecated: see withBase() instead -$newBrowser = $browser->withoutBase(); -``` - -Notice that the [`Browser`](#browser) is an immutable object, i.e. the `withoutBase()` method -actually returns a *new* [`Browser`](#browser) instance without any base URL applied. - -See also [`withBase()`](#withbase). - ### React\Http\Middleware #### StreamingRequestMiddleware @@ -2769,18 +2617,6 @@ This is a standard interface defined in which in turn extends the [`MessageInterface` definition](https://www.php-fig.org/psr/psr-7/#3-1-psr-http-message-messageinterface). -### UriInterface - -The `Psr\Http\Message\UriInterface` represents an absolute or relative URI (aka URL). - -This is a standard interface defined in -[PSR-7: HTTP message interfaces](https://www.php-fig.org/psr/psr-7/), see its -[`UriInterface` definition](https://www.php-fig.org/psr/psr-7/#3-5-psr-http-message-uriinterface). - -> For BC reasons, the request methods accept the URL as either a `string` - value or as an `UriInterface`. It's recommended to explicitly cast any - objects implementing `UriInterface` to `string`. - ### ResponseException The `ResponseException` is an `Exception` sub-class that will be used to reject diff --git a/src/Browser.php b/src/Browser.php index 70e875a2..38479c86 100644 --- a/src/Browser.php +++ b/src/Browser.php @@ -75,12 +75,8 @@ public function __construct(LoopInterface $loop, ConnectorInterface $connector = * * See also [example 01](../examples/01-google.php). * - * > For BC reasons, this method accepts the `$url` as either a `string` - * value or as an `UriInterface`. It's recommended to explicitly cast any - * objects implementing `UriInterface` to `string`. - * - * @param string|UriInterface $url URL for the request. - * @param array $headers + * @param string $url URL for the request. + * @param array $headers * @return PromiseInterface */ public function get($url, array $headers = array()) @@ -137,11 +133,7 @@ public function get($url, array $headers = array()) * $browser->post($url, array('Content-Length' => '11'), $body); * ``` * - * > For BC reasons, this method accepts the `$url` as either a `string` - * value or as an `UriInterface`. It's recommended to explicitly cast any - * objects implementing `UriInterface` to `string`. - * - * @param string|UriInterface $url URL for the request. + * @param string $url URL for the request. * @param array $headers * @param string|ReadableStreamInterface $contents * @return PromiseInterface @@ -160,12 +152,8 @@ public function post($url, array $headers = array(), $contents = '') * }); * ``` * - * > For BC reasons, this method accepts the `$url` as either a `string` - * value or as an `UriInterface`. It's recommended to explicitly cast any - * objects implementing `UriInterface` to `string`. - * - * @param string|UriInterface $url URL for the request. - * @param array $headers + * @param string $url URL for the request. + * @param array $headers * @return PromiseInterface */ public function head($url, array $headers = array()) @@ -203,11 +191,7 @@ public function head($url, array $headers = array()) * $browser->patch($url, array('Content-Length' => '11'), $body); * ``` * - * > For BC reasons, this method accepts the `$url` as either a `string` - * value or as an `UriInterface`. It's recommended to explicitly cast any - * objects implementing `UriInterface` to `string`. - * - * @param string|UriInterface $url URL for the request. + * @param string $url URL for the request. * @param array $headers * @param string|ReadableStreamInterface $contents * @return PromiseInterface @@ -249,11 +233,7 @@ public function patch($url, array $headers = array(), $contents = '') * $browser->put($url, array('Content-Length' => '11'), $body); * ``` * - * > For BC reasons, this method accepts the `$url` as either a `string` - * value or as an `UriInterface`. It's recommended to explicitly cast any - * objects implementing `UriInterface` to `string`. - * - * @param string|UriInterface $url URL for the request. + * @param string $url URL for the request. * @param array $headers * @param string|ReadableStreamInterface $contents * @return PromiseInterface @@ -272,11 +252,7 @@ public function put($url, array $headers = array(), $contents = '') * }); * ``` * - * > For BC reasons, this method accepts the `$url` as either a `string` - * value or as an `UriInterface`. It's recommended to explicitly cast any - * objects implementing `UriInterface` to `string`. - * - * @param string|UriInterface $url URL for the request. + * @param string $url URL for the request. * @param array $headers * @param string|ReadableStreamInterface $contents * @return PromiseInterface @@ -321,18 +297,11 @@ public function delete($url, array $headers = array(), $contents = '') * $browser->request('POST', $url, array('Content-Length' => '11'), $body); * ``` * - * > Note that this method is available as of v2.9.0 and always buffers the - * response body before resolving. - * It does not respect the deprecated [`streaming` option](#withoptions). - * If you want to stream the response body, you can use the - * [`requestStreaming()`](#requeststreaming) method instead. - * * @param string $method HTTP request method, e.g. GET/HEAD/POST etc. * @param string $url URL for the request * @param array $headers Additional request headers * @param string|ReadableStreamInterface $body HTTP request body contents * @return PromiseInterface - * @since 2.9.0 */ public function request($method, $url, array $headers = array(), $body = '') { @@ -399,93 +368,17 @@ public function request($method, $url, array $headers = array(), $body = '') * $browser->requestStreaming('POST', $url, array('Content-Length' => '11'), $body); * ``` * - * > Note that this method is available as of v2.9.0 and always resolves the - * response without buffering the response body. - * It does not respect the deprecated [`streaming` option](#withoptions). - * If you want to buffer the response body, use can use the - * [`request()`](#request) method instead. - * * @param string $method HTTP request method, e.g. GET/HEAD/POST etc. * @param string $url URL for the request * @param array $headers Additional request headers * @param string|ReadableStreamInterface $body HTTP request body contents * @return PromiseInterface - * @since 2.9.0 */ public function requestStreaming($method, $url, $headers = array(), $contents = '') { return $this->withOptions(array('streaming' => true))->requestMayBeStreaming($method, $url, $headers, $contents); } - /** - * [Deprecated] Submits an array of field values similar to submitting a form (`application/x-www-form-urlencoded`). - * - * ```php - * // deprecated: see post() instead - * $browser->submit($url, array('user' => 'test', 'password' => 'secret')); - * ``` - * - * This method will automatically add a matching `Content-Length` request - * header for the encoded length of the given `$fields`. - * - * > For BC reasons, this method accepts the `$url` as either a `string` - * value or as an `UriInterface`. It's recommended to explicitly cast any - * objects implementing `UriInterface` to `string`. - * - * @param string|UriInterface $url URL for the request. - * @param array $fields - * @param array $headers - * @param string $method - * @return PromiseInterface - * @deprecated 2.9.0 See self::post() instead. - * @see self::post() - */ - public function submit($url, array $fields, $headers = array(), $method = 'POST') - { - $headers['Content-Type'] = 'application/x-www-form-urlencoded'; - $contents = http_build_query($fields); - - return $this->requestMayBeStreaming($method, $url, $headers, $contents); - } - - /** - * [Deprecated] Sends an arbitrary instance implementing the [`RequestInterface`](#requestinterface) (PSR-7). - * - * The preferred way to send an HTTP request is by using the above - * [request methods](#request-methods), for example the [`get()`](#get) - * method to send an HTTP `GET` request. - * - * As an alternative, if you want to use a custom HTTP request method, you - * can use this method: - * - * ```php - * $request = new Request('OPTIONS', $url); - * - * // deprecated: see request() instead - * $browser->send($request)->then(…); - * ``` - * - * This method will automatically add a matching `Content-Length` request - * header if the size of the outgoing request body is known and non-empty. - * For an empty request body, if will only include a `Content-Length: 0` - * request header if the request method usually expects a request body (only - * applies to `POST`, `PUT` and `PATCH`). - * - * @param RequestInterface $request - * @return PromiseInterface - * @deprecated 2.9.0 See self::request() instead. - * @see self::request() - */ - public function send(RequestInterface $request) - { - if ($this->baseUrl !== null) { - // ensure we're actually below the base URL - $request = $request->withUri($this->messageFactory->expandBase($request->getUri(), $this->baseUrl)); - } - - return $this->transaction->send($request); - } - /** * Changes the maximum timeout used for waiting for pending requests. * @@ -676,15 +569,7 @@ public function withRejectErrorResponse($obeySuccessCode) * Notice that the [`Browser`](#browser) is an immutable object, i.e. the `withBase()` method * actually returns a *new* [`Browser`](#browser) instance with the given base URL applied. * - * > For BC reasons, this method accepts the `$baseUrl` as either a `string` - * value or as an `UriInterface`. It's recommended to explicitly cast any - * objects implementing `UriInterface` to `string`. - * - * > Changelog: As of v2.9.0 this method accepts a `null` value to reset the - * base URL. Earlier versions had to use the deprecated `withoutBase()` - * method to reset the base URL. - * - * @param string|null|UriInterface $baseUrl absolute base URL + * @param string|null $baseUrl absolute base URL * @return self * @throws InvalidArgumentException if the given $baseUrl is not a valid absolute URL * @see self::withoutBase() @@ -730,7 +615,6 @@ public function withBase($baseUrl) * @param string $protocolVersion HTTP protocol version to use, must be one of "1.1" or "1.0" * @return self * @throws InvalidArgumentException - * @since 2.8.0 */ public function withProtocolVersion($protocolVersion) { @@ -791,7 +675,7 @@ public function withResponseBuffer($maximumSize) } /** - * [Deprecated] Changes the [options](#options) to use: + * Changes the [options](#options) to use: * * The [`Browser`](#browser) class exposes several options for the handling of * HTTP transactions. These options resemble some of PHP's @@ -818,12 +702,11 @@ public function withResponseBuffer($maximumSize) * * @param array $options * @return self - * @deprecated 2.9.0 See self::withTimeout(), self::withFollowRedirects() and self::withRejectErrorResponse() instead. * @see self::withTimeout() * @see self::withFollowRedirects() * @see self::withRejectErrorResponse() */ - public function withOptions(array $options) + private function withOptions(array $options) { $browser = clone $this; $browser->transaction = $this->transaction->withOptions($options); @@ -831,28 +714,6 @@ public function withOptions(array $options) return $browser; } - /** - * [Deprecated] Removes the base URL. - * - * ```php - * // deprecated: see withBase() instead - * $newBrowser = $browser->withoutBase(); - * ``` - * - * Notice that the [`Browser`](#browser) is an immutable object, i.e. the `withoutBase()` method - * actually returns a *new* [`Browser`](#browser) instance without any base URL applied. - * - * See also [`withBase()`](#withbase). - * - * @return self - * @deprecated 2.9.0 See self::withBase() instead. - * @see self::withBase() - */ - public function withoutBase() - { - return $this->withBase(null); - } - /** * @param string $method * @param string|UriInterface $url @@ -862,6 +723,12 @@ public function withoutBase() */ private function requestMayBeStreaming($method, $url, array $headers = array(), $contents = '') { - return $this->send($this->messageFactory->request($method, $url, $headers, $contents, $this->protocolVersion)); + $request = $this->messageFactory->request($method, $url, $headers, $contents, $this->protocolVersion); + if ($this->baseUrl !== null) { + // ensure we're actually below the base URL + $request = $request->withUri($this->messageFactory->expandBase($request->getUri(), $this->baseUrl)); + } + + return $this->transaction->send($request); } } diff --git a/tests/BrowserTest.php b/tests/BrowserTest.php index 56a28303..88ef107e 100644 --- a/tests/BrowserTest.php +++ b/tests/BrowserTest.php @@ -120,19 +120,6 @@ public function testRequestStreamingGetSendsGetRequestWithStreamingExplicitlyEna $this->browser->requestStreaming('GET', 'http://example.com/'); } - public function testSubmitSendsPostRequest() - { - $that = $this; - $this->sender->expects($this->once())->method('send')->with($this->callback(function (RequestInterface $request) use ($that) { - $that->assertEquals('POST', $request->getMethod()); - $that->assertEquals('application/x-www-form-urlencoded', $request->getHeaderLine('Content-Type')); - $that->assertEquals('', (string)$request->getBody()); - return true; - }))->willReturn(new Promise(function () { })); - - $this->browser->submit('http://example.com/', array()); - } - public function testWithTimeoutTrueSetsDefaultTimeoutOption() { $this->sender->expects($this->once())->method('withOptions')->with(array('timeout' => null))->willReturnSelf(); @@ -356,7 +343,7 @@ public function testWithBaseUrlInvalidSchemeFails() public function testWithoutBaseFollowedByGetRequestTriesToSendIncompleteRequestUrl() { - $this->browser = $this->browser->withBase('http://example.com')->withoutBase(); + $this->browser = $this->browser->withBase('http://example.com')->withBase(null); $that = $this; $this->sender->expects($this->once())->method('send')->with($this->callback(function (RequestInterface $request) use ($that) { @@ -380,19 +367,6 @@ public function testWithProtocolVersionFollowedByGetRequestSendsRequestWithProto $this->browser->get('http://example.com/'); } - public function testWithProtocolVersionFollowedBySubmitRequestSendsRequestWithProtocolVersion() - { - $this->browser = $this->browser->withProtocolVersion('1.0'); - - $that = $this; - $this->sender->expects($this->once())->method('send')->with($this->callback(function (RequestInterface $request) use ($that) { - $that->assertEquals('1.0', $request->getProtocolVersion()); - return true; - }))->willReturn(new Promise(function () { })); - - $this->browser->submit('http://example.com/', array()); - } - public function testWithProtocolVersionInvalidThrows() { $this->setExpectedException('InvalidArgumentException'); diff --git a/tests/FunctionalBrowserTest.php b/tests/FunctionalBrowserTest.php index 16293fbb..c4bbe523 100644 --- a/tests/FunctionalBrowserTest.php +++ b/tests/FunctionalBrowserTest.php @@ -180,9 +180,9 @@ public function testCancelGetRequestWillRejectRequest() Block\await($promise, $this->loop); } - public function testCancelSendWithPromiseFollowerWillRejectRequest() + public function testCancelRequestWithPromiseFollowerWillRejectRequest() { - $promise = $this->browser->send(new Request('GET', $this->base . 'get'))->then(function () { + $promise = $this->browser->request('GET', $this->base . 'get')->then(function () { var_dump('noop'); }); $promise->cancel(); @@ -455,7 +455,7 @@ public function testReceiveStreamUntilConnectionsEndsForHttp10() public function testReceiveStreamChunkedForHttp11() { - $response = Block\await($this->browser->send(new Request('GET', $this->base . 'stream/1', array(), null, '1.1')), $this->loop); + $response = Block\await($this->browser->request('GET', $this->base . 'stream/1'), $this->loop); $this->assertEquals('1.1', $response->getProtocolVersion()); @@ -600,15 +600,6 @@ public function testHeadRequestReceivesResponseWithEmptyBodyButWithContentLength $this->assertEquals('5', $response->getHeaderLine('Content-Length')); } - public function testRequestGetReceivesBufferedResponseEvenWhenStreamingOptionHasBeenTurnedOn() - { - $response = Block\await( - $this->browser->withOptions(array('streaming' => true))->request('GET', $this->base . 'get'), - $this->loop - ); - $this->assertEquals('hello', (string)$response->getBody()); - } - public function testRequestStreamingGetReceivesStreamingResponseBody() { $buffer = Block\await( @@ -621,16 +612,6 @@ public function testRequestStreamingGetReceivesStreamingResponseBody() $this->assertEquals('hello', $buffer); } - public function testRequestStreamingGetReceivesStreamingResponseEvenWhenStreamingOptionHasBeenTurnedOff() - { - $response = Block\await( - $this->browser->withOptions(array('streaming' => false))->requestStreaming('GET', $this->base . 'get'), - $this->loop - ); - $this->assertInstanceOf('React\Stream\ReadableStreamInterface', $response->getBody()); - $this->assertEquals('', (string)$response->getBody()); - } - public function testRequestStreamingGetReceivesStreamingResponseBodyEvenWhenResponseBufferExceeded() { $buffer = Block\await( From a7c1585306e16cc6826c11c7bf896946d3bab05f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Fri, 3 Jul 2020 22:52:24 +0200 Subject: [PATCH 4/5] Import react/http-client v0.5.10 Change namespace from `React\HttpClient` to `React\Http\Client` and mark all classes as internal only. See https://github.com/reactphp/http-client for original repo. --- composer.json | 1 - src/Client/ChunkedStreamDecoder.php | 207 ++++++ src/Client/Client.php | 31 + src/Client/Request.php | 295 +++++++++ src/Client/RequestData.php | 128 ++++ src/Client/Response.php | 175 +++++ src/Io/Sender.php | 4 +- tests/Client/DecodeChunkedStreamTest.php | 227 +++++++ tests/Client/FunctionalIntegrationTest.php | 170 +++++ tests/Client/RequestDataTest.php | 154 +++++ tests/Client/RequestTest.php | 714 +++++++++++++++++++++ tests/Client/ResponseTest.php | 168 +++++ tests/Io/SenderTest.php | 58 +- 13 files changed, 2300 insertions(+), 32 deletions(-) create mode 100644 src/Client/ChunkedStreamDecoder.php create mode 100644 src/Client/Client.php create mode 100644 src/Client/Request.php create mode 100644 src/Client/RequestData.php create mode 100644 src/Client/Response.php create mode 100644 tests/Client/DecodeChunkedStreamTest.php create mode 100644 tests/Client/FunctionalIntegrationTest.php create mode 100644 tests/Client/RequestDataTest.php create mode 100644 tests/Client/RequestTest.php create mode 100644 tests/Client/ResponseTest.php diff --git a/composer.json b/composer.json index 755e5d82..50afc4db 100644 --- a/composer.json +++ b/composer.json @@ -8,7 +8,6 @@ "evenement/evenement": "^3.0 || ^2.0 || ^1.0", "psr/http-message": "^1.0", "react/event-loop": "^1.0 || ^0.5", - "react/http-client": "^0.5.10", "react/promise": "^2.3 || ^1.2.1", "react/promise-stream": "^1.1", "react/socket": "^1.1", diff --git a/src/Client/ChunkedStreamDecoder.php b/src/Client/ChunkedStreamDecoder.php new file mode 100644 index 00000000..02cab52a --- /dev/null +++ b/src/Client/ChunkedStreamDecoder.php @@ -0,0 +1,207 @@ +stream = $stream; + $this->stream->on('data', array($this, 'handleData')); + $this->stream->on('end', array($this, 'handleEnd')); + Util::forwardEvents($this->stream, $this, array( + 'error', + )); + } + + /** @internal */ + public function handleData($data) + { + $this->buffer .= $data; + + do { + $bufferLength = strlen($this->buffer); + $continue = $this->iterateBuffer(); + $iteratedBufferLength = strlen($this->buffer); + } while ( + $continue && + $bufferLength !== $iteratedBufferLength && + $iteratedBufferLength > 0 + ); + + if ($this->buffer === false) { + $this->buffer = ''; + } + } + + protected function iterateBuffer() + { + if (strlen($this->buffer) <= 1) { + return false; + } + + if ($this->nextChunkIsLength) { + $crlfPosition = strpos($this->buffer, static::CRLF); + if ($crlfPosition === false && strlen($this->buffer) > 1024) { + $this->emit('error', array( + new Exception('Chunk length header longer then 1024 bytes'), + )); + $this->close(); + return false; + } + if ($crlfPosition === false) { + return false; // Chunk header hasn't completely come in yet + } + $lengthChunk = substr($this->buffer, 0, $crlfPosition); + if (strpos($lengthChunk, ';') !== false) { + list($lengthChunk) = explode(';', $lengthChunk, 2); + } + if ($lengthChunk !== '') { + $lengthChunk = ltrim(trim($lengthChunk), "0"); + if ($lengthChunk === '') { + // We've reached the end of the stream + $this->reachedEnd = true; + $this->emit('end'); + $this->close(); + return false; + } + } + $this->nextChunkIsLength = false; + if (dechex(@hexdec($lengthChunk)) !== strtolower($lengthChunk)) { + $this->emit('error', array( + new Exception('Unable to validate "' . $lengthChunk . '" as chunk length header'), + )); + $this->close(); + return false; + } + $this->remainingLength = hexdec($lengthChunk); + $this->buffer = substr($this->buffer, $crlfPosition + 2); + return true; + } + + if ($this->remainingLength > 0) { + $chunkLength = $this->getChunkLength(); + if ($chunkLength === 0) { + return true; + } + $this->emit('data', array( + substr($this->buffer, 0, $chunkLength), + $this + )); + $this->remainingLength -= $chunkLength; + $this->buffer = substr($this->buffer, $chunkLength); + return true; + } + + $this->nextChunkIsLength = true; + $this->buffer = substr($this->buffer, 2); + return true; + } + + protected function getChunkLength() + { + $bufferLength = strlen($this->buffer); + + if ($bufferLength >= $this->remainingLength) { + return $this->remainingLength; + } + + return $bufferLength; + } + + public function pause() + { + $this->stream->pause(); + } + + public function resume() + { + $this->stream->resume(); + } + + public function isReadable() + { + return $this->stream->isReadable(); + } + + public function pipe(WritableStreamInterface $dest, array $options = array()) + { + Util::pipe($this, $dest, $options); + + return $dest; + } + + public function close() + { + $this->closed = true; + return $this->stream->close(); + } + + /** @internal */ + public function handleEnd() + { + $this->handleData(''); + + if ($this->closed) { + return; + } + + if ($this->buffer === '' && $this->reachedEnd) { + $this->emit('end'); + $this->close(); + return; + } + + $this->emit( + 'error', + array( + new Exception('Stream ended with incomplete control code') + ) + ); + $this->close(); + } +} diff --git a/src/Client/Client.php b/src/Client/Client.php new file mode 100644 index 00000000..f28ec289 --- /dev/null +++ b/src/Client/Client.php @@ -0,0 +1,31 @@ +connector = $connector; + } + + public function request($method, $url, array $headers = array(), $protocolVersion = '1.0') + { + $requestData = new RequestData($method, $url, $headers, $protocolVersion); + + return new Request($this->connector, $requestData); + } +} diff --git a/src/Client/Request.php b/src/Client/Request.php new file mode 100644 index 00000000..7ebb627f --- /dev/null +++ b/src/Client/Request.php @@ -0,0 +1,295 @@ +connector = $connector; + $this->requestData = $requestData; + } + + public function isWritable() + { + return self::STATE_END > $this->state && !$this->ended; + } + + private function writeHead() + { + $this->state = self::STATE_WRITING_HEAD; + + $requestData = $this->requestData; + $streamRef = &$this->stream; + $stateRef = &$this->state; + $pendingWrites = &$this->pendingWrites; + $that = $this; + + $promise = $this->connect(); + $promise->then( + function (ConnectionInterface $stream) use ($requestData, &$streamRef, &$stateRef, &$pendingWrites, $that) { + $streamRef = $stream; + + $stream->on('drain', array($that, 'handleDrain')); + $stream->on('data', array($that, 'handleData')); + $stream->on('end', array($that, 'handleEnd')); + $stream->on('error', array($that, 'handleError')); + $stream->on('close', array($that, 'handleClose')); + + $headers = (string) $requestData; + + $more = $stream->write($headers . $pendingWrites); + + $stateRef = Request::STATE_HEAD_WRITTEN; + + // clear pending writes if non-empty + if ($pendingWrites !== '') { + $pendingWrites = ''; + + if ($more) { + $that->emit('drain'); + } + } + }, + array($this, 'closeError') + ); + + $this->on('close', function() use ($promise) { + $promise->cancel(); + }); + } + + public function write($data) + { + if (!$this->isWritable()) { + return false; + } + + // write directly to connection stream if already available + if (self::STATE_HEAD_WRITTEN <= $this->state) { + return $this->stream->write($data); + } + + // otherwise buffer and try to establish connection + $this->pendingWrites .= $data; + if (self::STATE_WRITING_HEAD > $this->state) { + $this->writeHead(); + } + + return false; + } + + public function end($data = null) + { + if (!$this->isWritable()) { + return; + } + + if (null !== $data) { + $this->write($data); + } else if (self::STATE_WRITING_HEAD > $this->state) { + $this->writeHead(); + } + + $this->ended = true; + } + + /** @internal */ + public function handleDrain() + { + $this->emit('drain'); + } + + /** @internal */ + public function handleData($data) + { + $this->buffer .= $data; + + // buffer until double CRLF (or double LF for compatibility with legacy servers) + if (false !== strpos($this->buffer, "\r\n\r\n") || false !== strpos($this->buffer, "\n\n")) { + try { + list($response, $bodyChunk) = $this->parseResponse($this->buffer); + } catch (\InvalidArgumentException $exception) { + $this->emit('error', array($exception)); + } + + $this->buffer = null; + + $this->stream->removeListener('drain', array($this, 'handleDrain')); + $this->stream->removeListener('data', array($this, 'handleData')); + $this->stream->removeListener('end', array($this, 'handleEnd')); + $this->stream->removeListener('error', array($this, 'handleError')); + $this->stream->removeListener('close', array($this, 'handleClose')); + + if (!isset($response)) { + return; + } + + $response->on('close', array($this, 'close')); + $that = $this; + $response->on('error', function (\Exception $error) use ($that) { + $that->closeError(new \RuntimeException( + "An error occured in the response", + 0, + $error + )); + }); + + $this->emit('response', array($response, $this)); + + $this->stream->emit('data', array($bodyChunk)); + } + } + + /** @internal */ + public function handleEnd() + { + $this->closeError(new \RuntimeException( + "Connection ended before receiving response" + )); + } + + /** @internal */ + public function handleError(\Exception $error) + { + $this->closeError(new \RuntimeException( + "An error occurred in the underlying stream", + 0, + $error + )); + } + + /** @internal */ + public function handleClose() + { + $this->close(); + } + + /** @internal */ + public function closeError(\Exception $error) + { + if (self::STATE_END <= $this->state) { + return; + } + $this->emit('error', array($error)); + $this->close(); + } + + public function close() + { + if (self::STATE_END <= $this->state) { + return; + } + + $this->state = self::STATE_END; + $this->pendingWrites = ''; + + if ($this->stream) { + $this->stream->close(); + } + + $this->emit('close'); + $this->removeAllListeners(); + } + + protected function parseResponse($data) + { + $psrResponse = gPsr\parse_response($data); + $headers = array_map(function($val) { + if (1 === count($val)) { + $val = $val[0]; + } + + return $val; + }, $psrResponse->getHeaders()); + + $factory = $this->getResponseFactory(); + + $response = $factory( + 'HTTP', + $psrResponse->getProtocolVersion(), + $psrResponse->getStatusCode(), + $psrResponse->getReasonPhrase(), + $headers + ); + + return array($response, (string)($psrResponse->getBody())); + } + + protected function connect() + { + $scheme = $this->requestData->getScheme(); + if ($scheme !== 'https' && $scheme !== 'http') { + return Promise\reject( + new \InvalidArgumentException('Invalid request URL given') + ); + } + + $host = $this->requestData->getHost(); + $port = $this->requestData->getPort(); + + if ($scheme === 'https') { + $host = 'tls://' . $host; + } + + return $this->connector + ->connect($host . ':' . $port); + } + + public function setResponseFactory($factory) + { + $this->responseFactory = $factory; + } + + public function getResponseFactory() + { + if (null === $factory = $this->responseFactory) { + $stream = $this->stream; + + $factory = function ($protocol, $version, $code, $reasonPhrase, $headers) use ($stream) { + return new Response( + $stream, + $protocol, + $version, + $code, + $reasonPhrase, + $headers + ); + }; + + $this->responseFactory = $factory; + } + + return $factory; + } +} diff --git a/src/Client/RequestData.php b/src/Client/RequestData.php new file mode 100644 index 00000000..55efaa9b --- /dev/null +++ b/src/Client/RequestData.php @@ -0,0 +1,128 @@ +method = $method; + $this->url = $url; + $this->headers = $headers; + $this->protocolVersion = $protocolVersion; + } + + private function mergeDefaultheaders(array $headers) + { + $port = ($this->getDefaultPort() === $this->getPort()) ? '' : ":{$this->getPort()}"; + $connectionHeaders = ('1.1' === $this->protocolVersion) ? array('Connection' => 'close') : array(); + $authHeaders = $this->getAuthHeaders(); + + $defaults = array_merge( + array( + 'Host' => $this->getHost().$port, + 'User-Agent' => 'React/alpha', + ), + $connectionHeaders, + $authHeaders + ); + + // remove all defaults that already exist in $headers + $lower = array_change_key_case($headers, CASE_LOWER); + foreach ($defaults as $key => $_) { + if (isset($lower[strtolower($key)])) { + unset($defaults[$key]); + } + } + + return array_merge($defaults, $headers); + } + + public function getScheme() + { + return parse_url($this->url, PHP_URL_SCHEME); + } + + public function getHost() + { + return parse_url($this->url, PHP_URL_HOST); + } + + public function getPort() + { + return (int) parse_url($this->url, PHP_URL_PORT) ?: $this->getDefaultPort(); + } + + public function getDefaultPort() + { + return ('https' === $this->getScheme()) ? 443 : 80; + } + + public function getPath() + { + $path = parse_url($this->url, PHP_URL_PATH); + $queryString = parse_url($this->url, PHP_URL_QUERY); + + // assume "/" path by default, but allow "OPTIONS *" + if ($path === null) { + $path = ($this->method === 'OPTIONS' && $queryString === null) ? '*': '/'; + } + if ($queryString !== null) { + $path .= '?' . $queryString; + } + + return $path; + } + + public function setProtocolVersion($version) + { + $this->protocolVersion = $version; + } + + public function __toString() + { + $headers = $this->mergeDefaultheaders($this->headers); + + $data = ''; + $data .= "{$this->method} {$this->getPath()} HTTP/{$this->protocolVersion}\r\n"; + foreach ($headers as $name => $values) { + foreach ((array)$values as $value) { + $data .= "$name: $value\r\n"; + } + } + $data .= "\r\n"; + + return $data; + } + + private function getUrlUserPass() + { + $components = parse_url($this->url); + + if (isset($components['user'])) { + return array( + 'user' => $components['user'], + 'pass' => isset($components['pass']) ? $components['pass'] : null, + ); + } + } + + private function getAuthHeaders() + { + if (null !== $auth = $this->getUrlUserPass()) { + return array( + 'Authorization' => 'Basic ' . base64_encode($auth['user'].':'.$auth['pass']), + ); + } + + return array(); + } +} diff --git a/src/Client/Response.php b/src/Client/Response.php new file mode 100644 index 00000000..be19eb4c --- /dev/null +++ b/src/Client/Response.php @@ -0,0 +1,175 @@ +stream = $stream; + $this->protocol = $protocol; + $this->version = $version; + $this->code = $code; + $this->reasonPhrase = $reasonPhrase; + $this->headers = $headers; + + if (strtolower($this->getHeaderLine('Transfer-Encoding')) === 'chunked') { + $this->stream = new ChunkedStreamDecoder($stream); + $this->removeHeader('Transfer-Encoding'); + } + + $this->stream->on('data', array($this, 'handleData')); + $this->stream->on('error', array($this, 'handleError')); + $this->stream->on('end', array($this, 'handleEnd')); + $this->stream->on('close', array($this, 'handleClose')); + } + + public function getProtocol() + { + return $this->protocol; + } + + public function getVersion() + { + return $this->version; + } + + public function getCode() + { + return $this->code; + } + + public function getReasonPhrase() + { + return $this->reasonPhrase; + } + + public function getHeaders() + { + return $this->headers; + } + + private function removeHeader($name) + { + foreach ($this->headers as $key => $value) { + if (strcasecmp($name, $key) === 0) { + unset($this->headers[$key]); + break; + } + } + } + + private function getHeader($name) + { + $name = strtolower($name); + $normalized = array_change_key_case($this->headers, CASE_LOWER); + + return isset($normalized[$name]) ? (array)$normalized[$name] : array(); + } + + private function getHeaderLine($name) + { + return implode(', ' , $this->getHeader($name)); + } + + /** @internal */ + public function handleData($data) + { + if ($this->readable) { + $this->emit('data', array($data)); + } + } + + /** @internal */ + public function handleEnd() + { + if (!$this->readable) { + return; + } + $this->emit('end'); + $this->close(); + } + + /** @internal */ + public function handleError(\Exception $error) + { + if (!$this->readable) { + return; + } + $this->emit('error', array(new \RuntimeException( + "An error occurred in the underlying stream", + 0, + $error + ))); + + $this->close(); + } + + /** @internal */ + public function handleClose() + { + $this->close(); + } + + public function close() + { + if (!$this->readable) { + return; + } + + $this->readable = false; + $this->stream->close(); + + $this->emit('close'); + $this->removeAllListeners(); + } + + public function isReadable() + { + return $this->readable; + } + + public function pause() + { + if (!$this->readable) { + return; + } + + $this->stream->pause(); + } + + public function resume() + { + if (!$this->readable) { + return; + } + + $this->stream->resume(); + } + + public function pipe(WritableStreamInterface $dest, array $options = array()) + { + Util::pipe($this, $dest, $options); + + return $dest; + } +} diff --git a/src/Io/Sender.php b/src/Io/Sender.php index e9c0a600..d16b09d0 100644 --- a/src/Io/Sender.php +++ b/src/Io/Sender.php @@ -5,8 +5,8 @@ use React\Http\Message\MessageFactory; use Psr\Http\Message\RequestInterface; use React\EventLoop\LoopInterface; -use React\HttpClient\Client as HttpClient; -use React\HttpClient\Response as ResponseStream; +use React\Http\Client\Client as HttpClient; +use React\Http\Client\Response as ResponseStream; use React\Promise\PromiseInterface; use React\Promise\Deferred; use React\Socket\ConnectorInterface; diff --git a/tests/Client/DecodeChunkedStreamTest.php b/tests/Client/DecodeChunkedStreamTest.php new file mode 100644 index 00000000..f238fb6b --- /dev/null +++ b/tests/Client/DecodeChunkedStreamTest.php @@ -0,0 +1,227 @@ + array( + array("4\r\nWiki\r\n5\r\npedia\r\ne\r\n in\r\n\r\nchunks.\r\n0\r\n\r\n"), + ), + 'data-set-2' => array( + array("4\r\nWiki\r\n", "5\r\npedia\r\ne\r\n in\r\n\r\nchunks.\r\n0\r\n\r\n"), + ), + 'data-set-3' => array( + array("4\r\nWiki\r\n", "5\r\n", "pedia\r\ne\r\n in\r\n\r\nchunks.\r\n0\r\n\r\n"), + ), + 'data-set-4' => array( + array("4\r\nWiki\r\n", "5\r\n", "pedia\r\ne\r\n in\r\n", "\r\nchunks.\r\n0\r\n\r\n"), + ), + 'data-set-5' => array( + array("4\r\n", "Wiki\r\n", "5\r\n", "pedia\r\ne\r\n in\r\n", "\r\nchunks.\r\n0\r\n\r\n"), + ), + 'data-set-6' => array( + array("4\r\n", "Wiki\r\n", "5\r\n", "pedia\r\ne; foo=[bar,beer,pool,cue,win,won]\r\n", " in\r\n", "\r\nchunks.\r\n0\r\n\r\n"), + ), + 'header-fields' => array( + array("4; foo=bar\r\n", "Wiki\r\n", "5\r\n", "pedia\r\ne\r\n", " in\r\n", "\r\nchunks.\r\n", "0\r\n\r\n"), + ), + 'character-for-charactrr' => array( + str_split("4\r\nWiki\r\n5\r\npedia\r\ne\r\n in\r\n\r\nchunks.\r\n0\r\n\r\n"), + ), + 'extra-newline-in-wiki-character-for-chatacter' => array( + str_split("6\r\nWi\r\nki\r\n5\r\npedia\r\ne\r\n in\r\n\r\nchunks.\r\n0\r\n\r\n"), + "Wi\r\nkipedia in\r\n\r\nchunks." + ), + 'extra-newline-in-wiki' => array( + array("6\r\nWi\r\n", "ki\r\n5\r\npedia\r\ne\r\n in\r\n\r\nchunks.\r\n0\r\n\r\n"), + "Wi\r\nkipedia in\r\n\r\nchunks." + ), + 'varnish-type-response-1' => array( + array("0017\r\nWikipedia in\r\n\r\nchunks.\r\n0\r\n\r\n") + ), + 'varnish-type-response-2' => array( + array("000017\r\nWikipedia in\r\n\r\nchunks.\r\n0\r\n\r\n") + ), + 'varnish-type-response-3' => array( + array("017\r\nWikipedia in\r\n\r\nchunks.\r\n0\r\n\r\n") + ), + 'varnish-type-response-4' => array( + array("004\r\nWiki\r\n005\r\npedia\r\n00e\r\n in\r\n\r\nchunks.\r\n0\r\n\r\n") + ), + 'varnish-type-response-5' => array( + array("000004\r\nWiki\r\n00005\r\npedia\r\n000e\r\n in\r\n\r\nchunks.\r\n0\r\n\r\n") + ), + 'varnish-type-response-extra-line' => array( + array("006\r\nWi\r\nki\r\n005\r\npedia\r\n00e\r\n in\r\n\r\nchunks.\r\n0\r\n\r\n"), + "Wi\r\nkipedia in\r\n\r\nchunks." + ), + 'varnish-type-response-random' => array( + array(str_repeat("0", rand(0, 10)), "4\r\nWiki\r\n", str_repeat("0", rand(0, 10)), "5\r\npedia\r\n", str_repeat("0", rand(0, 10)), "e\r\n in\r\n\r\nchunks.\r\n0\r\n\r\n") + ), + 'end-chunk-zero-check-1' => array( + array("4\r\nWiki\r\n5\r\npedia\r\ne\r\n in\r\n\r\nchunks.\r\n00\r\n\r\n") + ), + 'end-chunk-zero-check-2' => array( + array("4\r\nWiki\r\n5\r\npedia\r\ne\r\n in\r\n\r\nchunks.\r\n000\r\n\r\n") + ), + 'end-chunk-zero-check-3' => array( + array("00004\r\nWiki\r\n005\r\npedia\r\ne\r\n in\r\n\r\nchunks.\r\n0000\r\n\r\n") + ), + 'uppercase-chunk' => array( + array("4\r\nWiki\r\n5\r\npedia\r\nE\r\n in\r\n\r\nchunks.\r\n0\r\n\r\n"), + ), + 'extra-space-in-length-chunk' => array( + array(" 04 \r\nWiki\r\n5\r\npedia\r\nE\r\n in\r\n\r\nchunks.\r\n0\r\n\r\n"), + ), + 'only-whitespace-is-final-chunk' => array( + array(" \r\n\r\n"), + "" + ) + ); + } + + /** + * @test + * @dataProvider provideChunkedEncoding + */ + public function testChunkedEncoding(array $strings, $expected = "Wikipedia in\r\n\r\nchunks.") + { + $stream = new ThroughStream(); + $response = new ChunkedStreamDecoder($stream); + $buffer = ''; + $response->on('data', function ($data) use (&$buffer) { + $buffer .= $data; + }); + $response->on('error', function ($error) { + $this->fail((string)$error); + }); + foreach ($strings as $string) { + $stream->write($string); + } + $this->assertSame($expected, $buffer); + } + + public function provideInvalidChunkedEncoding() + { + return array( + 'chunk-body-longer-than-header-suggests' => array( + array("4\r\nWiwot40n98w3498tw3049nyn039409t34\r\n", "ki\r\n5\r\npedia\r\ne\r\n in\r\n\r\nchunks.\r\n0\r\n\r\n"), + ), + 'invalid-header-charactrrs' => array( + str_split("xyz\r\nWi\r\nki\r\n5\r\npedia\r\ne\r\n in\r\n\r\nchunks.\r\n0\r\n\r\n") + ), + 'header-chunk-to-long' => array( + str_split(str_repeat('a', 2015) . "\r\nWi\r\nki\r\n5\r\npedia\r\ne\r\n in\r\n\r\nchunks.\r\n0\r\n\r\n") + ) + ); + } + + /** + * @test + * @dataProvider provideInvalidChunkedEncoding + */ + public function testInvalidChunkedEncoding(array $strings) + { + $stream = new ThroughStream(); + $response = new ChunkedStreamDecoder($stream); + $response->on('error', function (Exception $exception) { + throw $exception; + }); + + $this->setExpectedException('Exception'); + foreach ($strings as $string) { + $stream->write($string); + } + } + + public function provideZeroChunk() + { + return array( + array('1-zero' => "0\r\n\r\n"), + array('random-zero' => str_repeat("0", rand(2, 10))."\r\n\r\n") + ); + } + + /** + * @test + * @dataProvider provideZeroChunk + */ + public function testHandleEnd($zeroChunk) + { + $ended = false; + $stream = new ThroughStream(); + $response = new ChunkedStreamDecoder($stream); + $response->on('error', function ($error) { + $this->fail((string)$error); + }); + $response->on('end', function () use (&$ended) { + $ended = true; + }); + + $stream->write("4\r\nWiki\r\n".$zeroChunk); + + $this->assertTrue($ended); + } + + public function testHandleEndIncomplete() + { + $exception = null; + $stream = new ThroughStream(); + $response = new ChunkedStreamDecoder($stream); + $response->on('error', function ($e) use (&$exception) { + $exception = $e; + }); + + $stream->end("4\r\nWiki"); + + $this->assertInstanceOf('Exception', $exception); + } + + public function testHandleEndTrailers() + { + $ended = false; + $stream = new ThroughStream(); + $response = new ChunkedStreamDecoder($stream); + $response->on('error', function ($error) { + $this->fail((string)$error); + }); + $response->on('end', function () use (&$ended) { + $ended = true; + }); + + $stream->write("4\r\nWiki\r\n0\r\nabc: def\r\nghi: klm\r\n\r\n"); + + $this->assertTrue($ended); + } + + /** + * @test + * @dataProvider provideZeroChunk + */ + public function testHandleEndEnsureNoError($zeroChunk) + { + $ended = false; + $stream = new ThroughStream(); + $response = new ChunkedStreamDecoder($stream); + $response->on('error', function ($error) { + $this->fail((string)$error); + }); + $response->on('end', function () use (&$ended) { + $ended = true; + }); + + $stream->write("4\r\nWiki\r\n"); + $stream->write($zeroChunk); + $stream->end(); + + $this->assertTrue($ended); + } +} diff --git a/tests/Client/FunctionalIntegrationTest.php b/tests/Client/FunctionalIntegrationTest.php new file mode 100644 index 00000000..d6cc4b0f --- /dev/null +++ b/tests/Client/FunctionalIntegrationTest.php @@ -0,0 +1,170 @@ +on('connection', $this->expectCallableOnce()); + $server->on('connection', function (ConnectionInterface $conn) use ($server) { + $conn->end("HTTP/1.1 200 OK\r\n\r\nOk"); + $server->close(); + }); + $port = parse_url($server->getAddress(), PHP_URL_PORT); + + $client = new Client($loop); + $request = $client->request('GET', 'http://localhost:' . $port); + + $promise = Stream\first($request, 'close'); + $request->end(); + + Block\await($promise, $loop, self::TIMEOUT_LOCAL); + } + + public function testRequestLegacyHttpServerWithOnlyLineFeedReturnsSuccessfulResponse() + { + $loop = Factory::create(); + + $server = new Server(0, $loop); + $server->on('connection', function (ConnectionInterface $conn) use ($server) { + $conn->end("HTTP/1.0 200 OK\n\nbody"); + $server->close(); + }); + + $client = new Client($loop); + $request = $client->request('GET', str_replace('tcp:', 'http:', $server->getAddress())); + + $once = $this->expectCallableOnceWith('body'); + $request->on('response', function (Response $response) use ($once) { + $response->on('data', $once); + }); + + $promise = Stream\first($request, 'close'); + $request->end(); + + Block\await($promise, $loop, self::TIMEOUT_LOCAL); + } + + /** @group internet */ + public function testSuccessfulResponseEmitsEnd() + { + $loop = Factory::create(); + $client = new Client($loop); + + $request = $client->request('GET', 'http://www.google.com/'); + + $once = $this->expectCallableOnce(); + $request->on('response', function (Response $response) use ($once) { + $response->on('end', $once); + }); + + $promise = Stream\first($request, 'close'); + $request->end(); + + Block\await($promise, $loop, self::TIMEOUT_REMOTE); + } + + /** @group internet */ + public function testPostDataReturnsData() + { + $loop = Factory::create(); + $client = new Client($loop); + + $data = str_repeat('.', 33000); + $request = $client->request('POST', 'https://' . (mt_rand(0, 1) === 0 ? 'eu.' : '') . 'httpbin.org/post', array('Content-Length' => strlen($data))); + + $deferred = new Deferred(); + $request->on('response', function (Response $response) use ($deferred) { + $deferred->resolve(Stream\buffer($response)); + }); + + $request->on('error', 'printf'); + $request->on('error', $this->expectCallableNever()); + + $request->end($data); + + $buffer = Block\await($deferred->promise(), $loop, self::TIMEOUT_REMOTE); + + $this->assertNotEquals('', $buffer); + + $parsed = json_decode($buffer, true); + $this->assertTrue(is_array($parsed) && isset($parsed['data'])); + $this->assertEquals(strlen($data), strlen($parsed['data'])); + $this->assertEquals($data, $parsed['data']); + } + + /** @group internet */ + public function testPostJsonReturnsData() + { + $loop = Factory::create(); + $client = new Client($loop); + + $data = json_encode(array('numbers' => range(1, 50))); + $request = $client->request('POST', 'https://httpbin.org/post', array('Content-Length' => strlen($data), 'Content-Type' => 'application/json')); + + $deferred = new Deferred(); + $request->on('response', function (Response $response) use ($deferred) { + $deferred->resolve(Stream\buffer($response)); + }); + + $request->on('error', 'printf'); + $request->on('error', $this->expectCallableNever()); + + $request->end($data); + + $buffer = Block\await($deferred->promise(), $loop, self::TIMEOUT_REMOTE); + + $this->assertNotEquals('', $buffer); + + $parsed = json_decode($buffer, true); + $this->assertTrue(is_array($parsed) && isset($parsed['json'])); + $this->assertEquals(json_decode($data, true), $parsed['json']); + } + + /** @group internet */ + public function testCancelPendingConnectionEmitsClose() + { + $loop = Factory::create(); + $client = new Client($loop); + + $request = $client->request('GET', 'http://www.google.com/'); + $request->on('error', $this->expectCallableNever()); + $request->on('close', $this->expectCallableOnce()); + $request->end(); + $request->close(); + } +} diff --git a/tests/Client/RequestDataTest.php b/tests/Client/RequestDataTest.php new file mode 100644 index 00000000..313e140f --- /dev/null +++ b/tests/Client/RequestDataTest.php @@ -0,0 +1,154 @@ +assertSame($expected, $requestData->__toString()); + } + + /** @test */ + public function toStringReturnsHTTPRequestMessageWithEmptyQueryString() + { + $requestData = new RequestData('GET', 'http://www.example.com/path?hello=world'); + + $expected = "GET /path?hello=world HTTP/1.0\r\n" . + "Host: www.example.com\r\n" . + "User-Agent: React/alpha\r\n" . + "\r\n"; + + $this->assertSame($expected, $requestData->__toString()); + } + + /** @test */ + public function toStringReturnsHTTPRequestMessageWithZeroQueryStringAndRootPath() + { + $requestData = new RequestData('GET', 'http://www.example.com?0'); + + $expected = "GET /?0 HTTP/1.0\r\n" . + "Host: www.example.com\r\n" . + "User-Agent: React/alpha\r\n" . + "\r\n"; + + $this->assertSame($expected, $requestData->__toString()); + } + + /** @test */ + public function toStringReturnsHTTPRequestMessageWithOptionsAbsoluteRequestForm() + { + $requestData = new RequestData('OPTIONS', 'http://www.example.com/'); + + $expected = "OPTIONS / HTTP/1.0\r\n" . + "Host: www.example.com\r\n" . + "User-Agent: React/alpha\r\n" . + "\r\n"; + + $this->assertSame($expected, $requestData->__toString()); + } + + /** @test */ + public function toStringReturnsHTTPRequestMessageWithOptionsAsteriskRequestForm() + { + $requestData = new RequestData('OPTIONS', 'http://www.example.com'); + + $expected = "OPTIONS * HTTP/1.0\r\n" . + "Host: www.example.com\r\n" . + "User-Agent: React/alpha\r\n" . + "\r\n"; + + $this->assertSame($expected, $requestData->__toString()); + } + + /** @test */ + public function toStringReturnsHTTPRequestMessageWithProtocolVersion() + { + $requestData = new RequestData('GET', 'http://www.example.com'); + $requestData->setProtocolVersion('1.1'); + + $expected = "GET / HTTP/1.1\r\n" . + "Host: www.example.com\r\n" . + "User-Agent: React/alpha\r\n" . + "Connection: close\r\n" . + "\r\n"; + + $this->assertSame($expected, $requestData->__toString()); + } + + /** @test */ + public function toStringReturnsHTTPRequestMessageWithHeaders() + { + $requestData = new RequestData('GET', 'http://www.example.com', array( + 'User-Agent' => array(), + 'Via' => array( + 'first', + 'second' + ) + )); + + $expected = "GET / HTTP/1.0\r\n" . + "Host: www.example.com\r\n" . + "Via: first\r\n" . + "Via: second\r\n" . + "\r\n"; + + $this->assertSame($expected, $requestData->__toString()); + } + + /** @test */ + public function toStringReturnsHTTPRequestMessageWithHeadersInCustomCase() + { + $requestData = new RequestData('GET', 'http://www.example.com', array( + 'user-agent' => 'Hello', + 'LAST' => 'World' + )); + + $expected = "GET / HTTP/1.0\r\n" . + "Host: www.example.com\r\n" . + "user-agent: Hello\r\n" . + "LAST: World\r\n" . + "\r\n"; + + $this->assertSame($expected, $requestData->__toString()); + } + + /** @test */ + public function toStringReturnsHTTPRequestMessageWithProtocolVersionThroughConstructor() + { + $requestData = new RequestData('GET', 'http://www.example.com', array(), '1.1'); + + $expected = "GET / HTTP/1.1\r\n" . + "Host: www.example.com\r\n" . + "User-Agent: React/alpha\r\n" . + "Connection: close\r\n" . + "\r\n"; + + $this->assertSame($expected, $requestData->__toString()); + } + + /** @test */ + public function toStringUsesUserPassFromURL() + { + $requestData = new RequestData('GET', 'http://john:dummy@www.example.com'); + + $expected = "GET / HTTP/1.0\r\n" . + "Host: www.example.com\r\n" . + "User-Agent: React/alpha\r\n" . + "Authorization: Basic am9objpkdW1teQ==\r\n" . + "\r\n"; + + $this->assertSame($expected, $requestData->__toString()); + } +} diff --git a/tests/Client/RequestTest.php b/tests/Client/RequestTest.php new file mode 100644 index 00000000..e702d315 --- /dev/null +++ b/tests/Client/RequestTest.php @@ -0,0 +1,714 @@ +stream = $this->getMockBuilder('React\Socket\ConnectionInterface') + ->disableOriginalConstructor() + ->getMock(); + + $this->connector = $this->getMockBuilder('React\Socket\ConnectorInterface') + ->getMock(); + + $this->response = $this->getMockBuilder('React\Http\Client\Response') + ->disableOriginalConstructor() + ->getMock(); + } + + /** @test */ + public function requestShouldBindToStreamEventsAndUseconnector() + { + $requestData = new RequestData('GET', 'http://www.example.com'); + $request = new Request($this->connector, $requestData); + + $this->successfulConnectionMock(); + + $this->stream + ->expects($this->at(0)) + ->method('on') + ->with('drain', $this->identicalTo(array($request, 'handleDrain'))); + $this->stream + ->expects($this->at(1)) + ->method('on') + ->with('data', $this->identicalTo(array($request, 'handleData'))); + $this->stream + ->expects($this->at(2)) + ->method('on') + ->with('end', $this->identicalTo(array($request, 'handleEnd'))); + $this->stream + ->expects($this->at(3)) + ->method('on') + ->with('error', $this->identicalTo(array($request, 'handleError'))); + $this->stream + ->expects($this->at(4)) + ->method('on') + ->with('close', $this->identicalTo(array($request, 'handleClose'))); + $this->stream + ->expects($this->at(6)) + ->method('removeListener') + ->with('drain', $this->identicalTo(array($request, 'handleDrain'))); + $this->stream + ->expects($this->at(7)) + ->method('removeListener') + ->with('data', $this->identicalTo(array($request, 'handleData'))); + $this->stream + ->expects($this->at(8)) + ->method('removeListener') + ->with('end', $this->identicalTo(array($request, 'handleEnd'))); + $this->stream + ->expects($this->at(9)) + ->method('removeListener') + ->with('error', $this->identicalTo(array($request, 'handleError'))); + $this->stream + ->expects($this->at(10)) + ->method('removeListener') + ->with('close', $this->identicalTo(array($request, 'handleClose'))); + + $response = $this->response; + + $this->stream->expects($this->once()) + ->method('emit') + ->with('data', $this->identicalTo(array('body'))); + + $response->expects($this->at(0)) + ->method('on') + ->with('close', $this->anything()) + ->will($this->returnCallback(function ($event, $cb) use (&$endCallback) { + $endCallback = $cb; + })); + + $factory = $this->createCallableMock(); + $factory->expects($this->once()) + ->method('__invoke') + ->with('HTTP', '1.0', '200', 'OK', array('Content-Type' => 'text/plain')) + ->will($this->returnValue($response)); + + $request->setResponseFactory($factory); + + $handler = $this->createCallableMock(); + $handler->expects($this->once()) + ->method('__invoke') + ->with($response); + + $request->on('response', $handler); + $request->on('end', $this->expectCallableNever()); + + $handler = $this->createCallableMock(); + $handler->expects($this->once()) + ->method('__invoke'); + + $request->on('close', $handler); + $request->end(); + + $request->handleData("HTTP/1.0 200 OK\r\n"); + $request->handleData("Content-Type: text/plain\r\n"); + $request->handleData("\r\nbody"); + + $this->assertNotNull($endCallback); + call_user_func($endCallback); + } + + /** @test */ + public function requestShouldEmitErrorIfConnectionFails() + { + $requestData = new RequestData('GET', 'http://www.example.com'); + $request = new Request($this->connector, $requestData); + + $this->rejectedConnectionMock(); + + $handler = $this->createCallableMock(); + $handler->expects($this->once()) + ->method('__invoke') + ->with( + $this->isInstanceOf('RuntimeException') + ); + + $request->on('error', $handler); + + $handler = $this->createCallableMock(); + $handler->expects($this->once()) + ->method('__invoke'); + + $request->on('close', $handler); + $request->on('end', $this->expectCallableNever()); + + $request->end(); + } + + /** @test */ + public function requestShouldEmitErrorIfConnectionClosesBeforeResponseIsParsed() + { + $requestData = new RequestData('GET', 'http://www.example.com'); + $request = new Request($this->connector, $requestData); + + $this->successfulConnectionMock(); + + $handler = $this->createCallableMock(); + $handler->expects($this->once()) + ->method('__invoke') + ->with( + $this->isInstanceOf('RuntimeException') + ); + + $request->on('error', $handler); + + $handler = $this->createCallableMock(); + $handler->expects($this->once()) + ->method('__invoke'); + + $request->on('close', $handler); + $request->on('end', $this->expectCallableNever()); + + $request->end(); + $request->handleEnd(); + } + + /** @test */ + public function requestShouldEmitErrorIfConnectionEmitsError() + { + $requestData = new RequestData('GET', 'http://www.example.com'); + $request = new Request($this->connector, $requestData); + + $this->successfulConnectionMock(); + + $handler = $this->createCallableMock(); + $handler->expects($this->once()) + ->method('__invoke') + ->with( + $this->isInstanceOf('Exception') + ); + + $request->on('error', $handler); + + $handler = $this->createCallableMock(); + $handler->expects($this->once()) + ->method('__invoke'); + + $request->on('close', $handler); + $request->on('end', $this->expectCallableNever()); + + $request->end(); + $request->handleError(new \Exception('test')); + } + + /** @test */ + public function requestShouldEmitErrorIfGuzzleParseThrowsException() + { + $requestData = new RequestData('GET', 'http://www.example.com'); + $request = new Request($this->connector, $requestData); + + $this->successfulConnectionMock(); + + $handler = $this->createCallableMock(); + $handler->expects($this->once()) + ->method('__invoke') + ->with( + $this->isInstanceOf('\InvalidArgumentException') + ); + + $request->on('error', $handler); + + $request->end(); + $request->handleData("\r\n\r\n"); + } + + /** + * @test + */ + public function requestShouldEmitErrorIfUrlIsInvalid() + { + $requestData = new RequestData('GET', 'ftp://www.example.com'); + $request = new Request($this->connector, $requestData); + + $handler = $this->createCallableMock(); + $handler->expects($this->once()) + ->method('__invoke') + ->with( + $this->isInstanceOf('\InvalidArgumentException') + ); + + $request->on('error', $handler); + + $this->connector->expects($this->never()) + ->method('connect'); + + $request->end(); + } + + /** + * @test + */ + public function requestShouldEmitErrorIfUrlHasNoScheme() + { + $requestData = new RequestData('GET', 'www.example.com'); + $request = new Request($this->connector, $requestData); + + $handler = $this->createCallableMock(); + $handler->expects($this->once()) + ->method('__invoke') + ->with( + $this->isInstanceOf('\InvalidArgumentException') + ); + + $request->on('error', $handler); + + $this->connector->expects($this->never()) + ->method('connect'); + + $request->end(); + } + + /** @test */ + public function postRequestShouldSendAPostRequest() + { + $requestData = new RequestData('POST', 'http://www.example.com'); + $request = new Request($this->connector, $requestData); + + $this->successfulConnectionMock(); + + $this->stream + ->expects($this->once()) + ->method('write') + ->with($this->matchesRegularExpression("#^POST / HTTP/1\.0\r\nHost: www.example.com\r\nUser-Agent:.*\r\n\r\nsome post data$#")); + + $factory = $this->createCallableMock(); + $factory->expects($this->once()) + ->method('__invoke') + ->will($this->returnValue($this->response)); + + $request->setResponseFactory($factory); + $request->end('some post data'); + + $request->handleData("HTTP/1.0 200 OK\r\n"); + $request->handleData("Content-Type: text/plain\r\n"); + $request->handleData("\r\nbody"); + } + + /** @test */ + public function writeWithAPostRequestShouldSendToTheStream() + { + $requestData = new RequestData('POST', 'http://www.example.com'); + $request = new Request($this->connector, $requestData); + + $this->successfulConnectionMock(); + + $this->stream + ->expects($this->at(5)) + ->method('write') + ->with($this->matchesRegularExpression("#^POST / HTTP/1\.0\r\nHost: www.example.com\r\nUser-Agent:.*\r\n\r\nsome$#")); + $this->stream + ->expects($this->at(6)) + ->method('write') + ->with($this->identicalTo("post")); + $this->stream + ->expects($this->at(7)) + ->method('write') + ->with($this->identicalTo("data")); + + $factory = $this->createCallableMock(); + $factory->expects($this->once()) + ->method('__invoke') + ->will($this->returnValue($this->response)); + + $request->setResponseFactory($factory); + + $request->write("some"); + $request->write("post"); + $request->end("data"); + + $request->handleData("HTTP/1.0 200 OK\r\n"); + $request->handleData("Content-Type: text/plain\r\n"); + $request->handleData("\r\nbody"); + } + + /** @test */ + public function writeWithAPostRequestShouldSendBodyAfterHeadersAndEmitDrainEvent() + { + $requestData = new RequestData('POST', 'http://www.example.com'); + $request = new Request($this->connector, $requestData); + + $resolveConnection = $this->successfulAsyncConnectionMock(); + + $this->stream + ->expects($this->at(5)) + ->method('write') + ->with($this->matchesRegularExpression("#^POST / HTTP/1\.0\r\nHost: www.example.com\r\nUser-Agent:.*\r\n\r\nsomepost$#")) + ->willReturn(true); + $this->stream + ->expects($this->at(6)) + ->method('write') + ->with($this->identicalTo("data")); + + $factory = $this->createCallableMock(); + $factory->expects($this->once()) + ->method('__invoke') + ->will($this->returnValue($this->response)); + + $request->setResponseFactory($factory); + + $this->assertFalse($request->write("some")); + $this->assertFalse($request->write("post")); + + $request->on('drain', $this->expectCallableOnce()); + $request->once('drain', function () use ($request) { + $request->write("data"); + $request->end(); + }); + + $resolveConnection(); + + $request->handleData("HTTP/1.0 200 OK\r\n"); + $request->handleData("Content-Type: text/plain\r\n"); + $request->handleData("\r\nbody"); + } + + /** @test */ + public function writeWithAPostRequestShouldForwardDrainEventIfFirstChunkExceedsBuffer() + { + $requestData = new RequestData('POST', 'http://www.example.com'); + $request = new Request($this->connector, $requestData); + + $this->stream = $this->getMockBuilder('React\Socket\Connection') + ->disableOriginalConstructor() + ->setMethods(array('write')) + ->getMock(); + + $resolveConnection = $this->successfulAsyncConnectionMock(); + + $this->stream + ->expects($this->at(0)) + ->method('write') + ->with($this->matchesRegularExpression("#^POST / HTTP/1\.0\r\nHost: www.example.com\r\nUser-Agent:.*\r\n\r\nsomepost$#")) + ->willReturn(false); + $this->stream + ->expects($this->at(1)) + ->method('write') + ->with($this->identicalTo("data")); + + $factory = $this->createCallableMock(); + $factory->expects($this->once()) + ->method('__invoke') + ->will($this->returnValue($this->response)); + + $request->setResponseFactory($factory); + + $this->assertFalse($request->write("some")); + $this->assertFalse($request->write("post")); + + $request->on('drain', $this->expectCallableOnce()); + $request->once('drain', function () use ($request) { + $request->write("data"); + $request->end(); + }); + + $resolveConnection(); + $this->stream->emit('drain'); + + $request->handleData("HTTP/1.0 200 OK\r\n"); + $request->handleData("Content-Type: text/plain\r\n"); + $request->handleData("\r\nbody"); + } + + /** @test */ + public function pipeShouldPipeDataIntoTheRequestBody() + { + $requestData = new RequestData('POST', 'http://www.example.com'); + $request = new Request($this->connector, $requestData); + + $this->successfulConnectionMock(); + + $this->stream + ->expects($this->at(5)) + ->method('write') + ->with($this->matchesRegularExpression("#^POST / HTTP/1\.0\r\nHost: www.example.com\r\nUser-Agent:.*\r\n\r\nsome$#")); + $this->stream + ->expects($this->at(6)) + ->method('write') + ->with($this->identicalTo("post")); + $this->stream + ->expects($this->at(7)) + ->method('write') + ->with($this->identicalTo("data")); + + $factory = $this->createCallableMock(); + $factory->expects($this->once()) + ->method('__invoke') + ->will($this->returnValue($this->response)); + + $loop = $this + ->getMockBuilder('React\EventLoop\LoopInterface') + ->getMock(); + + $request->setResponseFactory($factory); + + $stream = fopen('php://memory', 'r+'); + $stream = new DuplexResourceStream($stream, $loop); + + $stream->pipe($request); + $stream->emit('data', array('some')); + $stream->emit('data', array('post')); + $stream->emit('data', array('data')); + + $request->handleData("HTTP/1.0 200 OK\r\n"); + $request->handleData("Content-Type: text/plain\r\n"); + $request->handleData("\r\nbody"); + } + + /** + * @test + */ + public function writeShouldStartConnecting() + { + $requestData = new RequestData('POST', 'http://www.example.com'); + $request = new Request($this->connector, $requestData); + + $this->connector->expects($this->once()) + ->method('connect') + ->with('www.example.com:80') + ->willReturn(new Promise(function () { })); + + $request->write('test'); + } + + /** + * @test + */ + public function endShouldStartConnectingAndChangeStreamIntoNonWritableMode() + { + $requestData = new RequestData('POST', 'http://www.example.com'); + $request = new Request($this->connector, $requestData); + + $this->connector->expects($this->once()) + ->method('connect') + ->with('www.example.com:80') + ->willReturn(new Promise(function () { })); + + $request->end(); + + $this->assertFalse($request->isWritable()); + } + + /** + * @test + */ + public function closeShouldEmitCloseEvent() + { + $requestData = new RequestData('POST', 'http://www.example.com'); + $request = new Request($this->connector, $requestData); + + $request->on('close', $this->expectCallableOnce()); + $request->close(); + } + + /** + * @test + */ + public function writeAfterCloseReturnsFalse() + { + $requestData = new RequestData('POST', 'http://www.example.com'); + $request = new Request($this->connector, $requestData); + + $request->close(); + + $this->assertFalse($request->isWritable()); + $this->assertFalse($request->write('nope')); + } + + /** + * @test + */ + public function endAfterCloseIsNoOp() + { + $requestData = new RequestData('POST', 'http://www.example.com'); + $request = new Request($this->connector, $requestData); + + $this->connector->expects($this->never()) + ->method('connect'); + + $request->close(); + $request->end(); + } + + /** + * @test + */ + public function closeShouldCancelPendingConnectionAttempt() + { + $requestData = new RequestData('POST', 'http://www.example.com'); + $request = new Request($this->connector, $requestData); + + $promise = new Promise(function () {}, function () { + throw new \RuntimeException(); + }); + + $this->connector->expects($this->once()) + ->method('connect') + ->with('www.example.com:80') + ->willReturn($promise); + + $request->end(); + + $request->on('error', $this->expectCallableNever()); + $request->on('close', $this->expectCallableOnce()); + + $request->close(); + $request->close(); + } + + /** @test */ + public function requestShouldRelayErrorEventsFromResponse() + { + $requestData = new RequestData('GET', 'http://www.example.com'); + $request = new Request($this->connector, $requestData); + + $this->successfulConnectionMock(); + + $response = $this->response; + + $response->expects($this->at(0)) + ->method('on') + ->with('close', $this->anything()); + $response->expects($this->at(1)) + ->method('on') + ->with('error', $this->anything()) + ->will($this->returnCallback(function ($event, $cb) use (&$errorCallback) { + $errorCallback = $cb; + })); + + $factory = $this->createCallableMock(); + $factory->expects($this->once()) + ->method('__invoke') + ->with('HTTP', '1.0', '200', 'OK', array('Content-Type' => 'text/plain')) + ->will($this->returnValue($response)); + + $request->setResponseFactory($factory); + $request->end(); + + $request->handleData("HTTP/1.0 200 OK\r\n"); + $request->handleData("Content-Type: text/plain\r\n"); + $request->handleData("\r\nbody"); + + $this->assertNotNull($errorCallback); + call_user_func($errorCallback, new \Exception('test')); + } + + /** @test */ + public function requestShouldRemoveAllListenerAfterClosed() + { + $requestData = new RequestData('GET', 'http://www.example.com'); + $request = new Request($this->connector, $requestData); + + $request->on('close', function () {}); + $this->assertCount(1, $request->listeners('close')); + + $request->close(); + $this->assertCount(0, $request->listeners('close')); + } + + private function successfulConnectionMock() + { + call_user_func($this->successfulAsyncConnectionMock()); + } + + private function successfulAsyncConnectionMock() + { + $deferred = new Deferred(); + + $this->connector + ->expects($this->once()) + ->method('connect') + ->with('www.example.com:80') + ->will($this->returnValue($deferred->promise())); + + $stream = $this->stream; + return function () use ($deferred, $stream) { + $deferred->resolve($stream); + }; + } + + private function rejectedConnectionMock() + { + $this->connector + ->expects($this->once()) + ->method('connect') + ->with('www.example.com:80') + ->will($this->returnValue(new RejectedPromise(new \RuntimeException()))); + } + + /** @test */ + public function multivalueHeader() + { + $requestData = new RequestData('GET', 'http://www.example.com'); + $request = new Request($this->connector, $requestData); + + $this->successfulConnectionMock(); + + $response = $this->response; + + $response->expects($this->at(0)) + ->method('on') + ->with('close', $this->anything()); + $response->expects($this->at(1)) + ->method('on') + ->with('error', $this->anything()) + ->will($this->returnCallback(function ($event, $cb) use (&$errorCallback) { + $errorCallback = $cb; + })); + + $factory = $this->createCallableMock(); + $factory->expects($this->once()) + ->method('__invoke') + ->with('HTTP', '1.0', '200', 'OK', array('Content-Type' => 'text/plain', 'X-Xss-Protection' => '1; mode=block', 'Cache-Control' => 'public, must-revalidate, max-age=0')) + ->will($this->returnValue($response)); + + $request->setResponseFactory($factory); + $request->end(); + + $request->handleData("HTTP/1.0 200 OK\r\n"); + $request->handleData("Content-Type: text/plain\r\n"); + $request->handleData("X-Xss-Protection:1; mode=block\r\n"); + $request->handleData("Cache-Control:public, must-revalidate, max-age=0\r\n"); + $request->handleData("\r\nbody"); + + $this->assertNotNull($errorCallback); + call_user_func($errorCallback, new \Exception('test')); + } + + /** @test */ + public function chunkedStreamDecoder() + { + $requestData = new RequestData('GET', 'http://www.example.com'); + $request = new Request($this->connector, $requestData); + + $this->successfulConnectionMock(); + + $request->end(); + + $this->stream->expects($this->once()) + ->method('emit') + ->with('data', array("1\r\nb\r")); + + $request->handleData("HTTP/1.0 200 OK\r\n"); + $request->handleData("Transfer-Encoding: chunked\r\n"); + $request->handleData("\r\n1\r\nb\r"); + $request->handleData("\n3\t\nody\r\n0\t\n\r\n"); + + } +} diff --git a/tests/Client/ResponseTest.php b/tests/Client/ResponseTest.php new file mode 100644 index 00000000..14467239 --- /dev/null +++ b/tests/Client/ResponseTest.php @@ -0,0 +1,168 @@ +stream = $this->getMockBuilder('React\Stream\DuplexStreamInterface') + ->getMock(); + } + + /** @test */ + public function responseShouldEmitEndEventOnEnd() + { + $this->stream + ->expects($this->at(0)) + ->method('on') + ->with('data', $this->anything()); + $this->stream + ->expects($this->at(1)) + ->method('on') + ->with('error', $this->anything()); + $this->stream + ->expects($this->at(2)) + ->method('on') + ->with('end', $this->anything()); + $this->stream + ->expects($this->at(3)) + ->method('on') + ->with('close', $this->anything()); + + $response = new Response($this->stream, 'HTTP', '1.0', '200', 'OK', array('Content-Type' => 'text/plain')); + + $handler = $this->createCallableMock(); + $handler->expects($this->once()) + ->method('__invoke') + ->with('some data'); + + $response->on('data', $handler); + + $handler = $this->createCallableMock(); + $handler->expects($this->once()) + ->method('__invoke'); + + $response->on('end', $handler); + + $handler = $this->createCallableMock(); + $handler->expects($this->once()) + ->method('__invoke'); + + $response->on('close', $handler); + + $this->stream + ->expects($this->at(0)) + ->method('close'); + + $response->handleData('some data'); + $response->handleEnd(); + + $this->assertSame( + array( + 'Content-Type' => 'text/plain' + ), + $response->getHeaders() + ); + } + + /** @test */ + public function closedResponseShouldNotBeResumedOrPaused() + { + $response = new Response($this->stream, 'http', '1.0', '200', 'ok', array('content-type' => 'text/plain')); + + $this->stream + ->expects($this->never()) + ->method('pause'); + $this->stream + ->expects($this->never()) + ->method('resume'); + + $response->handleEnd(); + + $response->resume(); + $response->pause(); + + $this->assertSame( + array( + 'content-type' => 'text/plain', + ), + $response->getHeaders() + ); + } + + /** @test */ + public function chunkedEncodingResponse() + { + $stream = new ThroughStream(); + $response = new Response( + $stream, + 'http', + '1.0', + '200', + 'ok', + array( + 'content-type' => 'text/plain', + 'transfer-encoding' => 'chunked', + ) + ); + + $buffer = ''; + $response->on('data', function ($data) use (&$buffer) { + $buffer.= $data; + }); + $this->assertSame('', $buffer); + $stream->write("4; abc=def\r\n"); + $this->assertSame('', $buffer); + $stream->write("Wiki\r\n"); + $this->assertSame('Wiki', $buffer); + + $this->assertSame( + array( + 'content-type' => 'text/plain', + ), + $response->getHeaders() + ); + } + + /** @test */ + public function doubleChunkedEncodingResponseWillBePassedAsIs() + { + $stream = new ThroughStream(); + $response = new Response( + $stream, + 'http', + '1.0', + '200', + 'ok', + array( + 'content-type' => 'text/plain', + 'transfer-encoding' => array( + 'chunked', + 'chunked' + ) + ) + ); + + $this->assertSame( + array( + 'content-type' => 'text/plain', + 'transfer-encoding' => array( + 'chunked', + 'chunked' + ) + ), + $response->getHeaders() + ); + } +} + diff --git a/tests/Io/SenderTest.php b/tests/Io/SenderTest.php index aaf93ce1..8a04d1f3 100644 --- a/tests/Io/SenderTest.php +++ b/tests/Io/SenderTest.php @@ -3,13 +3,13 @@ namespace React\Tests\Http\Io; use Clue\React\Block; +use React\Http\Client\Client as HttpClient; +use React\Http\Client\RequestData; use React\Http\Io\Sender; use React\Http\Message\ReadableBodyStream; -use React\Tests\Http\TestCase; -use React\HttpClient\Client as HttpClient; -use React\HttpClient\RequestData; use React\Promise; use React\Stream\ThroughStream; +use React\Tests\Http\TestCase; use RingCentral\Psr7\Request; class SenderTest extends TestCase @@ -63,13 +63,13 @@ public function testSenderConnectorRejection() public function testSendPostWillAutomaticallySendContentLengthHeader() { - $client = $this->getMockBuilder('React\HttpClient\Client')->disableOriginalConstructor()->getMock(); + $client = $this->getMockBuilder('React\Http\Client\Client')->disableOriginalConstructor()->getMock(); $client->expects($this->once())->method('request')->with( 'POST', 'http://www.google.com/', array('Host' => 'www.google.com', 'Content-Length' => '5'), '1.1' - )->willReturn($this->getMockBuilder('React\HttpClient\Request')->disableOriginalConstructor()->getMock()); + )->willReturn($this->getMockBuilder('React\Http\Client\Request')->disableOriginalConstructor()->getMock()); $sender = new Sender($client, $this->getMockBuilder('React\Http\Message\MessageFactory')->getMock()); @@ -79,13 +79,13 @@ public function testSendPostWillAutomaticallySendContentLengthHeader() public function testSendPostWillAutomaticallySendContentLengthZeroHeaderForEmptyRequestBody() { - $client = $this->getMockBuilder('React\HttpClient\Client')->disableOriginalConstructor()->getMock(); + $client = $this->getMockBuilder('React\Http\Client\Client')->disableOriginalConstructor()->getMock(); $client->expects($this->once())->method('request')->with( 'POST', 'http://www.google.com/', array('Host' => 'www.google.com', 'Content-Length' => '0'), '1.1' - )->willReturn($this->getMockBuilder('React\HttpClient\Request')->disableOriginalConstructor()->getMock()); + )->willReturn($this->getMockBuilder('React\Http\Client\Request')->disableOriginalConstructor()->getMock()); $sender = new Sender($client, $this->getMockBuilder('React\Http\Message\MessageFactory')->getMock()); @@ -95,10 +95,10 @@ public function testSendPostWillAutomaticallySendContentLengthZeroHeaderForEmpty public function testSendPostStreamWillAutomaticallySendTransferEncodingChunked() { - $outgoing = $this->getMockBuilder('React\HttpClient\Request')->disableOriginalConstructor()->getMock(); + $outgoing = $this->getMockBuilder('React\Http\Client\Request')->disableOriginalConstructor()->getMock(); $outgoing->expects($this->once())->method('write')->with(""); - $client = $this->getMockBuilder('React\HttpClient\Client')->disableOriginalConstructor()->getMock(); + $client = $this->getMockBuilder('React\Http\Client\Client')->disableOriginalConstructor()->getMock(); $client->expects($this->once())->method('request')->with( 'POST', 'http://www.google.com/', @@ -115,11 +115,11 @@ public function testSendPostStreamWillAutomaticallySendTransferEncodingChunked() public function testSendPostStreamWillAutomaticallyPipeChunkEncodeBodyForWriteAndRespectRequestThrottling() { - $outgoing = $this->getMockBuilder('React\HttpClient\Request')->disableOriginalConstructor()->getMock(); + $outgoing = $this->getMockBuilder('React\Http\Client\Request')->disableOriginalConstructor()->getMock(); $outgoing->expects($this->once())->method('isWritable')->willReturn(true); $outgoing->expects($this->exactly(2))->method('write')->withConsecutive(array(""), array("5\r\nhello\r\n"))->willReturn(false); - $client = $this->getMockBuilder('React\HttpClient\Client')->disableOriginalConstructor()->getMock(); + $client = $this->getMockBuilder('React\Http\Client\Client')->disableOriginalConstructor()->getMock(); $client->expects($this->once())->method('request')->willReturn($outgoing); $sender = new Sender($client, $this->getMockBuilder('React\Http\Message\MessageFactory')->getMock()); @@ -134,12 +134,12 @@ public function testSendPostStreamWillAutomaticallyPipeChunkEncodeBodyForWriteAn public function testSendPostStreamWillAutomaticallyPipeChunkEncodeBodyForEnd() { - $outgoing = $this->getMockBuilder('React\HttpClient\Request')->disableOriginalConstructor()->getMock(); + $outgoing = $this->getMockBuilder('React\Http\Client\Request')->disableOriginalConstructor()->getMock(); $outgoing->expects($this->once())->method('isWritable')->willReturn(true); $outgoing->expects($this->exactly(2))->method('write')->withConsecutive(array(""), array("0\r\n\r\n"))->willReturn(false); $outgoing->expects($this->once())->method('end')->with(null); - $client = $this->getMockBuilder('React\HttpClient\Client')->disableOriginalConstructor()->getMock(); + $client = $this->getMockBuilder('React\Http\Client\Client')->disableOriginalConstructor()->getMock(); $client->expects($this->once())->method('request')->willReturn($outgoing); $sender = new Sender($client, $this->getMockBuilder('React\Http\Message\MessageFactory')->getMock()); @@ -153,13 +153,13 @@ public function testSendPostStreamWillAutomaticallyPipeChunkEncodeBodyForEnd() public function testSendPostStreamWillRejectWhenRequestBodyEmitsErrorEvent() { - $outgoing = $this->getMockBuilder('React\HttpClient\Request')->disableOriginalConstructor()->getMock(); + $outgoing = $this->getMockBuilder('React\Http\Client\Request')->disableOriginalConstructor()->getMock(); $outgoing->expects($this->once())->method('isWritable')->willReturn(true); $outgoing->expects($this->once())->method('write')->with("")->willReturn(false); $outgoing->expects($this->never())->method('end'); $outgoing->expects($this->once())->method('close'); - $client = $this->getMockBuilder('React\HttpClient\Client')->disableOriginalConstructor()->getMock(); + $client = $this->getMockBuilder('React\Http\Client\Client')->disableOriginalConstructor()->getMock(); $client->expects($this->once())->method('request')->willReturn($outgoing); $sender = new Sender($client, $this->getMockBuilder('React\Http\Message\MessageFactory')->getMock()); @@ -183,13 +183,13 @@ public function testSendPostStreamWillRejectWhenRequestBodyEmitsErrorEvent() public function testSendPostStreamWillRejectWhenRequestBodyClosesWithoutEnd() { - $outgoing = $this->getMockBuilder('React\HttpClient\Request')->disableOriginalConstructor()->getMock(); + $outgoing = $this->getMockBuilder('React\Http\Client\Request')->disableOriginalConstructor()->getMock(); $outgoing->expects($this->once())->method('isWritable')->willReturn(true); $outgoing->expects($this->once())->method('write')->with("")->willReturn(false); $outgoing->expects($this->never())->method('end'); $outgoing->expects($this->once())->method('close'); - $client = $this->getMockBuilder('React\HttpClient\Client')->disableOriginalConstructor()->getMock(); + $client = $this->getMockBuilder('React\Http\Client\Client')->disableOriginalConstructor()->getMock(); $client->expects($this->once())->method('request')->willReturn($outgoing); $sender = new Sender($client, $this->getMockBuilder('React\Http\Message\MessageFactory')->getMock()); @@ -211,13 +211,13 @@ public function testSendPostStreamWillRejectWhenRequestBodyClosesWithoutEnd() public function testSendPostStreamWillNotRejectWhenRequestBodyClosesAfterEnd() { - $outgoing = $this->getMockBuilder('React\HttpClient\Request')->disableOriginalConstructor()->getMock(); + $outgoing = $this->getMockBuilder('React\Http\Client\Request')->disableOriginalConstructor()->getMock(); $outgoing->expects($this->once())->method('isWritable')->willReturn(true); $outgoing->expects($this->exactly(2))->method('write')->withConsecutive(array(""), array("0\r\n\r\n"))->willReturn(false); $outgoing->expects($this->once())->method('end'); $outgoing->expects($this->never())->method('close'); - $client = $this->getMockBuilder('React\HttpClient\Client')->disableOriginalConstructor()->getMock(); + $client = $this->getMockBuilder('React\Http\Client\Client')->disableOriginalConstructor()->getMock(); $client->expects($this->once())->method('request')->willReturn($outgoing); $sender = new Sender($client, $this->getMockBuilder('React\Http\Message\MessageFactory')->getMock()); @@ -239,13 +239,13 @@ public function testSendPostStreamWillNotRejectWhenRequestBodyClosesAfterEnd() public function testSendPostStreamWithExplicitContentLengthWillSendHeaderAsIs() { - $client = $this->getMockBuilder('React\HttpClient\Client')->disableOriginalConstructor()->getMock(); + $client = $this->getMockBuilder('React\Http\Client\Client')->disableOriginalConstructor()->getMock(); $client->expects($this->once())->method('request')->with( 'POST', 'http://www.google.com/', array('Host' => 'www.google.com', 'Content-Length' => '100'), '1.1' - )->willReturn($this->getMockBuilder('React\HttpClient\Request')->disableOriginalConstructor()->getMock()); + )->willReturn($this->getMockBuilder('React\Http\Client\Request')->disableOriginalConstructor()->getMock()); $sender = new Sender($client, $this->getMockBuilder('React\Http\Message\MessageFactory')->getMock()); @@ -256,13 +256,13 @@ public function testSendPostStreamWithExplicitContentLengthWillSendHeaderAsIs() public function testSendGetWillNotPassContentLengthHeaderForEmptyRequestBody() { - $client = $this->getMockBuilder('React\HttpClient\Client')->disableOriginalConstructor()->getMock(); + $client = $this->getMockBuilder('React\Http\Client\Client')->disableOriginalConstructor()->getMock(); $client->expects($this->once())->method('request')->with( 'GET', 'http://www.google.com/', array('Host' => 'www.google.com'), '1.1' - )->willReturn($this->getMockBuilder('React\HttpClient\Request')->disableOriginalConstructor()->getMock()); + )->willReturn($this->getMockBuilder('React\Http\Client\Request')->disableOriginalConstructor()->getMock()); $sender = new Sender($client, $this->getMockBuilder('React\Http\Message\MessageFactory')->getMock()); @@ -272,13 +272,13 @@ public function testSendGetWillNotPassContentLengthHeaderForEmptyRequestBody() public function testSendCustomMethodWillNotPassContentLengthHeaderForEmptyRequestBody() { - $client = $this->getMockBuilder('React\HttpClient\Client')->disableOriginalConstructor()->getMock(); + $client = $this->getMockBuilder('React\Http\Client\Client')->disableOriginalConstructor()->getMock(); $client->expects($this->once())->method('request')->with( 'CUSTOM', 'http://www.google.com/', array('Host' => 'www.google.com'), '1.1' - )->willReturn($this->getMockBuilder('React\HttpClient\Request')->disableOriginalConstructor()->getMock()); + )->willReturn($this->getMockBuilder('React\Http\Client\Request')->disableOriginalConstructor()->getMock()); $sender = new Sender($client, $this->getMockBuilder('React\Http\Message\MessageFactory')->getMock()); @@ -288,13 +288,13 @@ public function testSendCustomMethodWillNotPassContentLengthHeaderForEmptyReques public function testSendCustomMethodWithExplicitContentLengthZeroWillBePassedAsIs() { - $client = $this->getMockBuilder('React\HttpClient\Client')->disableOriginalConstructor()->getMock(); + $client = $this->getMockBuilder('React\Http\Client\Client')->disableOriginalConstructor()->getMock(); $client->expects($this->once())->method('request')->with( 'CUSTOM', 'http://www.google.com/', array('Host' => 'www.google.com', 'Content-Length' => '0'), '1.1' - )->willReturn($this->getMockBuilder('React\HttpClient\Request')->disableOriginalConstructor()->getMock()); + )->willReturn($this->getMockBuilder('React\Http\Client\Request')->disableOriginalConstructor()->getMock()); $sender = new Sender($client, $this->getMockBuilder('React\Http\Message\MessageFactory')->getMock()); @@ -370,7 +370,7 @@ public function provideRequestProtocolVersion() */ public function testRequestProtocolVersion(Request $Request, $method, $uri, $headers, $protocolVersion) { - $http = $this->getMockBuilder('React\HttpClient\Client') + $http = $this->getMockBuilder('React\Http\Client\Client') ->setMethods(array( 'request', )) @@ -378,7 +378,7 @@ public function testRequestProtocolVersion(Request $Request, $method, $uri, $hea $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(), ))->getMock(); - $request = $this->getMockBuilder('React\HttpClient\Request') + $request = $this->getMockBuilder('React\Http\Client\Request') ->setMethods(array()) ->setConstructorArgs(array( $this->getMockBuilder('React\Socket\ConnectorInterface')->getMock(), From 785ded0cce0b8ad6fa641ba9239e9ff43e6d52bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Tue, 7 Jul 2020 15:17:00 +0200 Subject: [PATCH 5/5] Organize and rename client and server examples --- README.md | 61 +++++++++++-------- composer.json | 4 +- ...1-google.php => 01-client-get-request.php} | 0 ....php => 02-client-concurrent-requests.php} | 0 .../{03-any.php => 03-client-request-any.php} | 0 ...-post-json.php => 04-client-post-json.php} | 0 .../{05-put-xml.php => 05-client-put-xml.php} | 0 ...y.php => 11-client-http-connect-proxy.php} | 7 ++- ...ks-proxy.php => 12-client-socks-proxy.php} | 4 +- ...-ssh-proxy.php => 13-client-ssh-proxy.php} | 0 ....php => 14-client-unix-domain-sockets.php} | 0 ...21-client-request-streaming-to-stdout.php} | 0 ...=> 22-client-stream-upload-from-stdin.php} | 0 ...lo-world.php => 51-server-hello-world.php} | 0 ...itors.php => 52-server-count-visitors.php} | 0 ...-client-ip.php => 53-server-whatsmyip.php} | 0 ...eter.php => 54-server-query-parameter.php} | 0 ...ling.php => 55-server-cookie-handling.php} | 0 .../{06-sleep.php => 56-server-sleep.php} | 0 ...dling.php => 57-server-error-handling.php} | 0 ...onse.php => 58-server-stream-response.php} | 0 ...09-json-api.php => 59-server-json-api.php} | 2 +- ...ps.php => 61-server-hello-world-https.php} | 0 ...2-upload.php => 62-server-form-upload.php} | 2 +- ...st.php => 63-server-streaming-request.php} | 0 ...ttp-proxy.php => 71-server-http-proxy.php} | 3 + ...y.php => 72-server-http-connect-proxy.php} | 3 + ...de-echo.php => 81-server-upgrade-echo.php} | 0 ...de-chat.php => 82-server-upgrade-chat.php} | 0 ...d.php => 91-client-benchmark-download.php} | 9 ++- ...oad.php => 92-client-benchmark-upload.php} | 9 ++- ...d.php => 99-server-benchmark-download.php} | 7 ++- src/Browser.php | 6 +- src/Server.php | 9 ++- 34 files changed, 77 insertions(+), 49 deletions(-) rename examples/{01-google.php => 01-client-get-request.php} (100%) rename examples/{02-concurrent.php => 02-client-concurrent-requests.php} (100%) rename examples/{03-any.php => 03-client-request-any.php} (100%) rename examples/{04-post-json.php => 04-client-post-json.php} (100%) rename examples/{05-put-xml.php => 05-client-put-xml.php} (100%) rename examples/{11-http-proxy.php => 11-client-http-connect-proxy.php} (82%) rename examples/{12-socks-proxy.php => 12-client-socks-proxy.php} (89%) rename examples/{13-ssh-proxy.php => 13-client-ssh-proxy.php} (100%) rename examples/{14-unix-domain-sockets.php => 14-client-unix-domain-sockets.php} (100%) rename examples/{21-stream-forwarding.php => 21-client-request-streaming-to-stdout.php} (100%) rename examples/{22-stream-stdin.php => 22-client-stream-upload-from-stdin.php} (100%) rename examples/{01-hello-world.php => 51-server-hello-world.php} (100%) rename examples/{02-count-visitors.php => 52-server-count-visitors.php} (100%) rename examples/{03-client-ip.php => 53-server-whatsmyip.php} (100%) rename examples/{04-query-parameter.php => 54-server-query-parameter.php} (100%) rename examples/{05-cookie-handling.php => 55-server-cookie-handling.php} (100%) rename examples/{06-sleep.php => 56-server-sleep.php} (100%) rename examples/{07-error-handling.php => 57-server-error-handling.php} (100%) rename examples/{08-stream-response.php => 58-server-stream-response.php} (100%) rename examples/{09-json-api.php => 59-server-json-api.php} (97%) rename examples/{11-hello-world-https.php => 61-server-hello-world-https.php} (100%) rename examples/{12-upload.php => 62-server-form-upload.php} (98%) rename examples/{13-stream-request.php => 63-server-streaming-request.php} (100%) rename examples/{21-http-proxy.php => 71-server-http-proxy.php} (94%) rename examples/{22-connect-proxy.php => 72-server-http-connect-proxy.php} (93%) rename examples/{31-upgrade-echo.php => 81-server-upgrade-echo.php} (100%) rename examples/{32-upgrade-chat.php => 82-server-upgrade-chat.php} (100%) rename examples/{91-benchmark-download.php => 91-client-benchmark-download.php} (85%) rename examples/{92-benchmark-upload.php => 92-client-benchmark-upload.php} (92%) rename examples/{99-benchmark-download.php => 99-server-benchmark-download.php} (91%) diff --git a/README.md b/README.md index 92877f5a..e4dc0e81 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,18 @@ -# Http +# HTTP [![Build Status](https://travis-ci.org/reactphp/http.svg?branch=master)](https://travis-ci.org/reactphp/http) -Event-driven, streaming plaintext HTTP and secure HTTPS server for [ReactPHP](https://reactphp.org/). +Event-driven, streaming HTTP client and server implementation for [ReactPHP](https://reactphp.org/). + +This HTTP library provides re-usable implementations for an HTTP client and +server based on ReactPHP's [`Socket`](https://github.com/reactphp/socket) and +[`EventLoop`](https://github.com/reactphp/event-loop) components. +Its client component allows you to send any number of async HTTP/HTTPS requests +concurrently. +Its server component allows you to build plaintext HTTP and secure HTTPS servers +that accept incoming HTTP requests from HTTP clients (such as web browsers). +This library provides async, streaming means for all of this, so you can handle +multiple concurrent HTTP requests without blocking. **Table of contents** @@ -91,8 +101,8 @@ This is an HTTP server which responds with `Hello World!` to every request. ```php $loop = React\EventLoop\Factory::create(); -$server = new Server(function (ServerRequestInterface $request) { - return new Response( +$server = new React\Http\Server(function (Psr\Http\Message\ServerRequestInterface $request) { + return new React\Http\Response( 200, array( 'Content-Type' => 'text/plain' @@ -107,7 +117,7 @@ $server->listen($socket); $loop->run(); ``` -See also the [examples](examples). +See also the [examples](examples/). ## Client Usage @@ -487,8 +497,8 @@ $browser->requestStreaming('GET', $url)->then(function (Psr\Http\Message\Respons }); ``` -See also the [stream download example](examples/91-benchmark-download.php) and -the [stream forwarding example](examples/21-stream-forwarding.php). +See also the [stream download benchmark example](examples/91-client-benchmark-download.php) and +the [stream forwarding example](examples/21-client-request-streaming-to-stdout.php). You can invoke the following methods on the message body: @@ -607,7 +617,7 @@ $connector = new React\Socket\Connector($loop, array( $browser = new React\Http\Browser($loop, $connector); ``` -See also the [HTTP CONNECT proxy example](examples/11-http-proxy.php). +See also the [HTTP CONNECT proxy example](examples/11-http-connect-proxy.php). ### SOCKS proxy @@ -738,7 +748,8 @@ $socket = new React\Socket\Server('0.0.0.0:8080', $loop); $server->listen($socket); ``` -See also the [`listen()`](#listen) method and the [first example](../examples/) +See also the [`listen()`](#listen) method and the +[hello world server example](examples/51-server-hello-world.php) for more details. By default, the `Server` buffers and parses the complete incoming HTTP @@ -846,7 +857,8 @@ $socket = new React\Socket\Server('0.0.0.0:8080', $loop); $server->listen($socket); ``` -See also [example #1](examples) for more details. +See also [hello world server example](examples/51-server-hello-world.php) +for more details. This example will start listening for HTTP requests on the alternative HTTP port `8080` on all interfaces (publicly). As an alternative, it is @@ -873,7 +885,8 @@ $socket = new React\Socket\Server('tls://0.0.0.0:8443', $loop, array( $server->listen($socket); ``` -See also [example #11](examples) for more details. +See also [hello world HTTPS example](examples/61-server-hello-world-https.php) +for more details. ### Server Request @@ -945,7 +958,7 @@ $server = new Server(function (ServerRequestInterface $request) { }); ``` -See also [example #3](examples). +See also [whatsmyip server example](examples/53-server-whatsmyip.php). > Advanced: Note that address parameters will not be set if you're listening on a Unix domain socket (UDS) path as this protocol lacks the concept of @@ -983,7 +996,7 @@ Use [`htmlentities`](https://www.php.net/manual/en/function.htmlentities.php) like in this example to prevent [Cross-Site Scripting (abbreviated as XSS)](https://en.wikipedia.org/wiki/Cross-site_scripting). -See also [example #4](examples). +See also [server query parameters example](examples/54-server-query-parameter.php). #### Request body @@ -1022,7 +1035,7 @@ $server = new Server(function (ServerRequestInterface $request) { }); ``` -See also [example #12](examples) for more details. +See also [form upload example](examples/62-server-form-upload.php) for more details. The `getBody(): StreamInterface` method can be used to get the raw data from this request body, similar to @@ -1047,7 +1060,7 @@ $server = new Server(function (ServerRequestInterface $request) { }); ``` -See also [example #9](examples) for more details. +See also [JSON API server example](examples/59-server-json-api.php) for more details. The `getUploadedFiles(): array` method can be used to get the uploaded files in this request, similar to @@ -1070,7 +1083,7 @@ $server = new Server(function (ServerRequestInterface $request) { }); ``` -See also [example #12](examples) for more details. +See also [form upload server example](examples/62-server-form-upload.php) for more details. The `getSize(): ?int` method can be used to get the size of the request body, similar to PHP's `$_SERVER['CONTENT_LENGTH']` variable. @@ -1169,7 +1182,7 @@ $server = new React\Http\Server(array( The above example simply counts the number of bytes received in the request body. This can be used as a skeleton for buffering or processing the request body. -See also [example #13](examples) for more details. +See also [streaming request server example](examples/63-server-streaming-request.php) for more details. The `data` event will be emitted whenever new data is available on the request body stream. @@ -1307,7 +1320,7 @@ non-alphanumeric characters. This encoding is also used internally when decoding the name and value of cookies (which is in line with other implementations, such as PHP's cookie functions). -See also [example #5](examples) for more details. +See also [cookie server example](examples/55-server-cookie-handling.php) for more details. #### Invalid request @@ -1467,7 +1480,7 @@ in this case (if applicable). to look into using [Ratchet](http://socketo.me/) instead. If you want to handle a custom protocol, you will likely want to look into the [HTTP specs](https://tools.ietf.org/html/rfc7230#section-6.7) and also see - [examples #31 and #32](examples) for more details. + [examples #81 and #82](examples/) for more details. In particular, the `101` (Switching Protocols) response code MUST NOT be used unless you send an `Upgrade` response header value that is also present in the corresponding HTTP/1.1 `Upgrade` request header value. @@ -1488,7 +1501,7 @@ in this case (if applicable). requests, one may still be present. Normal request body processing applies here and the connection will only turn to "tunneling mode" after the request body has been processed (which should be empty in most cases). - See also [example #22](examples) for more details. + See also [HTTP CONNECT server example](examples/72-server-http-connect-proxy.php) for more details. #### Response length @@ -1851,7 +1864,7 @@ $browser->get($url)->then(function (Psr\Http\Message\ResponseInterface $response }); ``` -See also [example 01](examples/01-google.php). +See also [GET request client example](examples/01-client-get-request.php). #### post() @@ -1870,7 +1883,7 @@ $browser->post( }); ``` -See also [example 04](examples/04-post-json.php). +See also [POST JSON client example](examples/04-client-post-json.php). This method is also commonly used to submit HTML form data: @@ -1964,7 +1977,7 @@ $browser->put( }); ``` -See also [example 05](examples/05-put-xml.php). +See also [PUT XML client example](examples/05-client-put-xml.php). This method will automatically add a matching `Content-Length` request header if the outgoing request body is a `string`. If you're using a @@ -2538,7 +2551,7 @@ $server = new Server(array( )); ``` -See also [example #12](examples) for more details. +See also [form upload server example](examples/62-server-form-upload.php) for more details. By default, this middleware respects the [`upload_max_filesize`](https://www.php.net/manual/en/ini.core.php#ini.upload-max-filesize) diff --git a/composer.json b/composer.json index 50afc4db..711d3156 100644 --- a/composer.json +++ b/composer.json @@ -1,7 +1,7 @@ { "name": "react/http", - "description": "Event-driven, streaming plaintext HTTP and secure HTTPS server for ReactPHP", - "keywords": ["event-driven", "streaming", "HTTP", "HTTPS", "server", "ReactPHP"], + "description": "Event-driven, streaming HTTP client and server implementation for ReactPHP", + "keywords": ["HTTP client", "HTTP server", "HTTP", "HTTPS", "event-driven", "streaming", "client", "server", "PSR-7", "async", "ReactPHP"], "license": "MIT", "require": { "php": ">=5.3.0", diff --git a/examples/01-google.php b/examples/01-client-get-request.php similarity index 100% rename from examples/01-google.php rename to examples/01-client-get-request.php diff --git a/examples/02-concurrent.php b/examples/02-client-concurrent-requests.php similarity index 100% rename from examples/02-concurrent.php rename to examples/02-client-concurrent-requests.php diff --git a/examples/03-any.php b/examples/03-client-request-any.php similarity index 100% rename from examples/03-any.php rename to examples/03-client-request-any.php diff --git a/examples/04-post-json.php b/examples/04-client-post-json.php similarity index 100% rename from examples/04-post-json.php rename to examples/04-client-post-json.php diff --git a/examples/05-put-xml.php b/examples/05-client-put-xml.php similarity index 100% rename from examples/05-put-xml.php rename to examples/05-client-put-xml.php diff --git a/examples/11-http-proxy.php b/examples/11-client-http-connect-proxy.php similarity index 82% rename from examples/11-http-proxy.php rename to examples/11-client-http-connect-proxy.php index d1ad9cf5..53d2e91a 100644 --- a/examples/11-http-proxy.php +++ b/examples/11-client-http-connect-proxy.php @@ -1,5 +1,11 @@ /dev/null // $ wget http://localhost:8080/10g.bin -O /dev/null // $ ab -n10 -c10 http://localhost:8080/1g.bin -// $ docker run -it --rm --net=host jordi/ab ab -n10 -c10 http://localhost:8080/1g.bin +// $ docker run -it --rm --net=host jordi/ab -n100000 -c10 http://localhost:8080/ +// $ docker run -it --rm --net=host jordi/ab -n10 -c10 http://localhost:8080/1g.bin use Evenement\EventEmitter; use Psr\Http\Message\ServerRequestInterface; @@ -118,7 +119,7 @@ public function getSize() ); }); -$socket = new \React\Socket\Server(isset($argv[1]) ? $argv[1] : '0.0.0.0:0', $loop); +$socket = new \React\Socket\Server(isset($argv[1]) ? $argv[1] : '0.0.0.0:0', $loop, array('tcp' => array('backlog' => 511))); $server->listen($socket); echo 'Listening on ' . str_replace('tcp:', 'http:', $socket->getAddress()) . PHP_EOL; diff --git a/src/Browser.php b/src/Browser.php index 38479c86..28f90f87 100644 --- a/src/Browser.php +++ b/src/Browser.php @@ -73,7 +73,7 @@ public function __construct(LoopInterface $loop, ConnectorInterface $connector = * }); * ``` * - * See also [example 01](../examples/01-google.php). + * See also [GET request client example](../examples/01-client-get-request.php). * * @param string $url URL for the request. * @param array $headers @@ -99,7 +99,7 @@ public function get($url, array $headers = array()) * }); * ``` * - * See also [example 04](../examples/04-post-json.php). + * See also [POST JSON client example](../examples/04-client-post-json.php). * * This method is also commonly used to submit HTML form data: * @@ -216,7 +216,7 @@ public function patch($url, array $headers = array(), $contents = '') * }); * ``` * - * See also [example 05](../examples/05-put-xml.php). + * See also [PUT XML client example](../examples/05-client-put-xml.php). * * This method will automatically add a matching `Content-Length` request * header if the outgoing request body is a `string`. If you're using a diff --git a/src/Server.php b/src/Server.php index 3fe942c9..81b6bd0a 100644 --- a/src/Server.php +++ b/src/Server.php @@ -55,7 +55,8 @@ * $server->listen($socket); * ``` * - * See also the [`listen()`](#listen) method and the [first example](../examples/) + * See also the [`listen()`](#listen) method and + * [hello world server example](../examples/51-server-hello-world.php) * for more details. * * By default, the `Server` buffers and parses the complete incoming HTTP @@ -229,7 +230,8 @@ public function __construct($requestHandler) * $server->listen($socket); * ``` * - * See also [example #1](examples) for more details. + * See also [hello world server example](../examples/51-server-hello-world.php) + * for more details. * * This example will start listening for HTTP requests on the alternative * HTTP port `8080` on all interfaces (publicly). As an alternative, it is @@ -256,7 +258,8 @@ public function __construct($requestHandler) * $server->listen($socket); * ``` * - * See also [example #11](examples) for more details. + * See also [hello world HTTPS example](../examples/61-server-hello-world-https.php) + * for more details. * * @param ServerInterface $socket */