diff --git a/README.md b/README.md index 7b5d681..db24f5a 100644 --- a/README.md +++ b/README.md @@ -57,8 +57,10 @@ mess with most of the low-level details. * [patch()](#patch) * [put()](#put) * [delete()](#delete) + * [request()](#request) + * [requestStreaming()](#requeststreaming) * [submit()](#submit) - * [send()](#send) + * [~~send()~~](#send) * [withOptions()](#withoptions) * [withBase()](#withbase) * [withoutBase()](#withoutbase) @@ -136,7 +138,7 @@ 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 [`send()`](#send) method. +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`. @@ -402,25 +404,23 @@ streaming approach, where only small chunks have to be kept in memory: * 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). -The streaming API uses the same HTTP message API, but does not buffer the response -message body in memory. -It only processes the response body in small chunks as data is received and -forwards this data through [React's Stream API](https://github.com/reactphp/stream). -This works for (any number of) responses of arbitrary sizes. +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 resolves with a normal [`ResponseInterface`](#responseinterface), which -can be used to access the response message parameters as usual. +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 -// turn on streaming responses (does no longer buffer response body) -$streamingBrowser = $browser->withOptions(array('streaming' => true)); - -// issue a normal GET request -$streamingBrowser->get($url)->then(function (Psr\Http\Message\ResponseInterface $response) { +$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) { @@ -485,7 +485,7 @@ use React\Promise\Stream; function download(Browser $browser, string $url): React\Stream\ReadableStreamInterface { return Stream\unwrapReadable( - $browser->withOptions(['streaming' => true])->get($url)->then(function (Psr\Http\Message\ResponseInterface $response) { + $browser->requestStreaming('GET', $url)->then(function (Psr\Http\Message\ResponseInterface $response) { return $response->getBody(); }) ); @@ -497,6 +497,12 @@ $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. @@ -806,6 +812,117 @@ $browser->delete($url)->then(function (Psr\Http\Message\ResponseInterface $respo }); ``` +#### 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() The `submit(string|UriInterface $url, array $fields, array $headers = array(), string $method = 'POST'): PromiseInterface` method can be used to @@ -815,13 +932,16 @@ submit an array of field values similar to submitting a form (`application/x-www $browser->submit($url, array('user' => 'test', 'password' => 'secret')); ``` -#### send() +#### ~~send()~~ + +> Deprecated since v2.9.0, see [`request()`](#request) instead. -The `send(RequestInterface $request): PromiseInterface` method can be used to +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, for example the `get()` method to send an HTTP `GET` 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: @@ -829,6 +949,7 @@ can use this method: ```php $request = new Request('OPTIONS', $url); +// deprecated: see request() instead $browser->send($request)->then(…); ``` @@ -854,7 +975,7 @@ $newBrowser = $browser->withOptions(array( 'followRedirects' => true, 'maxRedirects' => 10, 'obeySuccessCode' => true, - 'streaming' => false, + 'streaming' => false, // deprecated, see requestStreaming() instead )); ``` diff --git a/examples/21-stream-forwarding.php b/examples/21-stream-forwarding.php index dcc7250..0e2815e 100644 --- a/examples/21-stream-forwarding.php +++ b/examples/21-stream-forwarding.php @@ -2,25 +2,32 @@ use Clue\React\Buzz\Browser; use Psr\Http\Message\ResponseInterface; +use React\Stream\ReadableStreamInterface; use React\Stream\WritableResourceStream; use RingCentral\Psr7; -$url = isset($argv[1]) ? $argv[1] : 'http://google.com/'; - require __DIR__ . '/../vendor/autoload.php'; +if (DIRECTORY_SEPARATOR === '\\') { + fwrite(STDERR, 'Non-blocking console I/O not supported on Windows' . PHP_EOL); + exit(1); +} + $loop = React\EventLoop\Factory::create(); $client = new Browser($loop); $out = new WritableResourceStream(STDOUT, $loop); $info = new WritableResourceStream(STDERR, $loop); +$url = isset($argv[1]) ? $argv[1] : 'http://google.com/'; $info->write('Requesting ' . $url . '…' . PHP_EOL); -$client->withOptions(array('streaming' => true))->get($url)->then(function (ResponseInterface $response) use ($info, $out) { +$client->requestStreaming('GET', $url)->then(function (ResponseInterface $response) use ($info, $out) { $info->write('Received' . PHP_EOL . Psr7\str($response)); - $response->getBody()->pipe($out); + $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 index 44ca102..bc71a80 100644 --- a/examples/22-stream-stdin.php +++ b/examples/22-stream-stdin.php @@ -5,15 +5,19 @@ use React\Stream\ReadableResourceStream; use RingCentral\Psr7; -$url = isset($argv[1]) ? $argv[1] : 'https://httpbin.org/post'; - require __DIR__ . '/../vendor/autoload.php'; +if (DIRECTORY_SEPARATOR === '\\') { + fwrite(STDERR, 'Non-blocking console I/O not supported on Windows' . PHP_EOL); + exit(1); +} + $loop = React\EventLoop\Factory::create(); $client = new Browser($loop); $in = new ReadableResourceStream(STDIN, $loop); +$url = isset($argv[1]) ? $argv[1] : 'https://httpbin.org/post'; echo 'Sending STDIN as POST to ' . $url . '…' . PHP_EOL; $client->post($url, array(), $in)->then(function (ResponseInterface $response) { diff --git a/examples/91-benchmark-download.php b/examples/91-benchmark-download.php index 5d75afc..14684e2 100644 --- a/examples/91-benchmark-download.php +++ b/examples/91-benchmark-download.php @@ -10,7 +10,7 @@ // $ php examples/99-benchmark-download.php 8080 // // b2) run HTTP client receiving a 10 GB download: -// $ php examples/92-benchmark-download.php http://localhost:8080/ 10000 +// $ php examples/91-benchmark-download.php http://localhost:8080/10g.bin use Clue\React\Buzz\Browser; use Psr\Http\Message\ResponseInterface; @@ -29,14 +29,12 @@ echo 'Requesting ' . $url . '…' . PHP_EOL; -$client->withOptions(array('streaming' => true))->get($url)->then(function (ResponseInterface $response) use ($loop) { +$client->requestStreaming('GET', $url)->then(function (ResponseInterface $response) use ($loop) { echo 'Headers received' . PHP_EOL; echo RingCentral\Psr7\str($response); $stream = $response->getBody(); - if (!$stream instanceof ReadableStreamInterface) { - throw new UnexpectedValueException(); - } + assert($stream instanceof ReadableStreamInterface); // count number of bytes received $bytes = 0; diff --git a/src/Browser.php b/src/Browser.php index c8d5ea7..e16cc29 100644 --- a/src/Browser.php +++ b/src/Browser.php @@ -245,6 +245,137 @@ 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); + } + /** * Submits an array of field values similar to submitting a form (`application/x-www-form-urlencoded`). * @@ -270,10 +401,11 @@ public function submit($url, array $fields, $headers = array(), $method = 'POST' } /** - * Sends an arbitrary instance implementing the [`RequestInterface`](#requestinterface) (PSR-7). + * [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, for example the `get()` method to send an HTTP `GET` 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: @@ -281,6 +413,7 @@ public function submit($url, array $fields, $headers = array(), $method = 'POST' * ```php * $request = new Request('OPTIONS', $url); * + * // deprecated: see request() instead * $browser->send($request)->then(…); * ``` * @@ -292,6 +425,8 @@ public function submit($url, array $fields, $headers = array(), $method = 'POST' * * @param RequestInterface $request * @return PromiseInterface + * @deprecated 2.9.0 See self::request() instead. + * @see self::request() */ public function send(RequestInterface $request) { @@ -382,7 +517,7 @@ public function withoutBase() * 'followRedirects' => true, * 'maxRedirects' => 10, * 'obeySuccessCode' => true, - * 'streaming' => false, + * 'streaming' => false, // deprecated, see requestStreaming() instead * )); * ``` * diff --git a/tests/BrowserTest.php b/tests/BrowserTest.php index ed3c273..b789f66 100644 --- a/tests/BrowserTest.php +++ b/tests/BrowserTest.php @@ -94,6 +94,32 @@ public function testDeleteSendsDeleteRequest() $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; diff --git a/tests/FunctionalBrowserTest.php b/tests/FunctionalBrowserTest.php index dee28f5..a58dd77 100644 --- a/tests/FunctionalBrowserTest.php +++ b/tests/FunctionalBrowserTest.php @@ -5,6 +5,7 @@ use Clue\React\Block; use Clue\React\Buzz\Browser; use Clue\React\Buzz\Message\ResponseException; +use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use React\EventLoop\Factory; use React\Http\Response; @@ -532,4 +533,35 @@ public function testHeadRequestReceivesResponseWithEmptyBodyButWithContentLength $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()); + } }