diff --git a/.travis.yml b/.travis.yml
index f5449647..fcd3a2d6 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -1,21 +1,27 @@
language: php
php:
+ - 5.3
- 5.4
- 5.5
- 5.6
- 7
- hhvm
- - hhvm-nightly
matrix:
+ include:
+ - php: 5.3
+ env:
+ - DEPENDENCIES=lowest
+ - php: 7.0
+ env:
+ - DEPENDENCIES=lowest
allow_failures:
- - php: 7
- php: hhvm
- - php: hhvm-nightly
-before_script:
- - composer install --dev --prefer-source
+install:
+ - composer install --no-interaction
+ - if [ "$DEPENDENCIES" = "lowest" ]; then composer update --prefer-lowest -n; fi
script:
- - phpunit --coverage-text
+ - ./vendor/bin/phpunit --coverage-text
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c66b2ad6..1a9f09f0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,6 +1,299 @@
# Changelog
-## 0.4.1 (2014-05-21)
+## 0.7.0 (2017-05-29)
+
+* Feature / BC break: Use PSR-7 (http-message) standard and
+ `Request-In-Response-Out`-style request handler callback.
+ Pass standard PSR-7 `ServerRequestInterface` and expect any standard
+ PSR-7 `ResponseInterface` in return for the request handler callback.
+ (#146 and #152 and #170 by @legionth)
+
+ ```php
+ // old
+ $app = function (Request $request, Response $response) {
+ $response->writeHead(200, array('Content-Type' => 'text/plain'));
+ $response->end("Hello world!\n");
+ };
+
+ // new
+ $app = function (ServerRequestInterface $request) {
+ return new Response(
+ 200,
+ array('Content-Type' => 'text/plain'),
+ "Hello world!\n"
+ );
+ };
+ ```
+
+ A `Content-Length` header will automatically be included if the size can be
+ determined from the response body.
+ (#164 by @maciejmrozinski)
+
+ The request handler callback will automatically make sure that responses to
+ HEAD requests and certain status codes, such as `204` (No Content), never
+ contain a response body.
+ (#156 by @clue)
+
+ The intermediary `100 Continue` response will automatically be sent if
+ demanded by a HTTP/1.1 client.
+ (#144 by @legionth)
+
+ The request handler callback can now return a standard `Promise` if
+ processing the request needs some time, such as when querying a database.
+ Similarly, the request handler may return a streaming response if the
+ response body comes from a `ReadableStreamInterface` or its size is
+ unknown in advance.
+
+ ```php
+ // old
+ $app = function (Request $request, Response $response) use ($db) {
+ $db->query()->then(function ($result) use ($response) {
+ $response->writeHead(200, array('Content-Type' => 'text/plain'));
+ $response->end($result);
+ });
+ };
+
+ // new
+ $app = function (ServerRequestInterface $request) use ($db) {
+ return $db->query()->then(function ($result) {
+ return new Response(
+ 200,
+ array('Content-Type' => 'text/plain'),
+ $result
+ );
+ });
+ };
+ ```
+
+ Pending promies and response streams will automatically be canceled once the
+ client connection closes.
+ (#187 and #188 by @clue)
+
+ The `ServerRequestInterface` contains the full effective request URI,
+ server-side parameters, query parameters and parsed cookies values as
+ defined in PSR-7.
+ (#167 by @clue and #174, #175 and #180 by @legionth)
+
+ ```php
+ $app = function (ServerRequestInterface $request) {
+ return new Response(
+ 200,
+ array('Content-Type' => 'text/plain'),
+ $request->getUri()->getScheme()
+ );
+ };
+ ```
+
+ Advanced: Support duplex stream response for `Upgrade` requests such as
+ `Upgrade: WebSocket` or custom protocols and `CONNECT` requests
+ (#189 and #190 by @clue)
+
+ > Note that the request body will currently not be buffered and parsed by
+ default, which depending on your particilar use-case, may limit
+ interoperability with the PSR-7 (http-message) ecosystem.
+ The provided streaming request body interfaces allow you to perform
+ buffering and parsing as needed in the request handler callback.
+ See also the README and examples for more details.
+
+* Feature / BC break: Replace `request` listener with callback function and
+ use `listen()` method to support multiple listening sockets
+ (#97 by @legionth and #193 by @clue)
+
+ ```php
+ // old
+ $server = new Server($socket);
+ $server->on('request', $app);
+
+ // new
+ $server = new Server($app);
+ $server->listen($socket);
+ ```
+
+* Feature: Support the more advanced HTTP requests, such as
+ `OPTIONS * HTTP/1.1` (`OPTIONS` method in asterisk-form),
+ `GET http://example.com/path HTTP/1.1` (plain proxy requests in absolute-form),
+ `CONNECT example.com:443 HTTP/1.1` (`CONNECT` proxy requests in authority-form)
+ and sanitize `Host` header value across all requests.
+ (#157, #158, #161, #165, #169 and #173 by @clue)
+
+* Feature: Forward compatibility with Socket v1.0, v0.8, v0.7 and v0.6 and
+ forward compatibility with Stream v1.0 and v0.7
+ (#154, #163, #183, #184 and #191 by @clue)
+
+* Feature: Simplify examples to ease getting started and
+ add benchmarking example
+ (#151 and #162 by @clue)
+
+* Improve test suite by adding tests for case insensitive chunked transfer
+ encoding and ignoring HHVM test failures until Travis tests work again.
+ (#150 by @legionth and #185 by @clue)
+
+## 0.6.0 (2017-03-09)
+
+* Feature / BC break: The `Request` and `Response` objects now follow strict
+ stream semantics and their respective methods and events.
+ (#116, #129, #133, #135, #136, #137, #138, #140, #141 by @legionth
+ and #122, #123, #130, #131, #132, #142 by @clue)
+
+ This implies that the `Server` now supports proper detection of the request
+ message body stream, such as supporting decoding chunked transfer encoding,
+ delimiting requests with an explicit `Content-Length` header
+ and those with an empty request message body.
+
+ These streaming semantics are compatible with previous Stream v0.5, future
+ compatible with v0.5 and upcoming v0.6 versions and can be used like this:
+
+ ```php
+ $http->on('request', function (Request $request, Response $response) {
+ $contentLength = 0;
+ $request->on('data', function ($data) use (&$contentLength) {
+ $contentLength += strlen($data);
+ });
+
+ $request->on('end', function () use ($response, &$contentLength){
+ $response->writeHead(200, array('Content-Type' => 'text/plain'));
+ $response->end("The length of the submitted request body is: " . $contentLength);
+ });
+
+ // an error occured
+ // e.g. on invalid chunked encoded data or an unexpected 'end' event
+ $request->on('error', function (\Exception $exception) use ($response, &$contentLength) {
+ $response->writeHead(400, array('Content-Type' => 'text/plain'));
+ $response->end("An error occured while reading at length: " . $contentLength);
+ });
+ });
+ ```
+
+ Similarly, the `Request` and `Response` now strictly follow the
+ `close()` method and `close` event semantics.
+ Closing the `Request` does not interrupt the underlying TCP/IP in
+ order to allow still sending back a valid response message.
+ Closing the `Response` does terminate the underlying TCP/IP
+ connection in order to clean up resources.
+
+ You should make sure to always attach a `request` event listener
+ like above. The `Server` will not respond to an incoming HTTP
+ request otherwise and keep the TCP/IP connection pending until the
+ other side chooses to close the connection.
+
+* Feature: Support `HTTP/1.1` and `HTTP/1.0` for `Request` and `Response`.
+ (#124, #125, #126, #127, #128 by @clue and #139 by @legionth)
+
+ The outgoing `Response` will automatically use the same HTTP version as the
+ incoming `Request` message and will only apply `HTTP/1.1` semantics if
+ applicable. This includes that the `Response` will automatically attach a
+ `Date` and `Connection: close` header if applicable.
+
+ This implies that the `Server` now automatically responds with HTTP error
+ messages for invalid requests (status 400) and those exceeding internal
+ request header limits (status 431).
+
+## 0.5.0 (2017-02-16)
+
+* Feature / BC break: Change `Request` methods to be in line with PSR-7
+ (#117 by @clue)
+ * Rename `getQuery()` to `getQueryParams()`
+ * Rename `getHttpVersion()` to `getProtocolVersion()`
+ * Change `getHeaders()` to always return an array of string values
+ for each header
+
+* Feature / BC break: Update Socket component to v0.5 and
+ add secure HTTPS server support
+ (#90 and #119 by @clue)
+
+ ```php
+ // old plaintext HTTP server
+ $socket = new React\Socket\Server($loop);
+ $socket->listen(8080, '127.0.0.1');
+ $http = new React\Http\Server($socket);
+
+ // new plaintext HTTP server
+ $socket = new React\Socket\Server('127.0.0.1:8080', $loop);
+ $http = new React\Http\Server($socket);
+
+ // new secure HTTPS server
+ $socket = new React\Socket\Server('127.0.0.1:8080', $loop);
+ $socket = new React\Socket\SecureServer($socket, $loop, array(
+ 'local_cert' => __DIR__ . '/localhost.pem'
+ ));
+ $http = new React\Http\Server($socket);
+ ```
+
+* BC break: Mark internal APIs as internal or private and
+ remove unneeded `ServerInterface`
+ (#118 by @clue, #95 by @legionth)
+
+## 0.4.4 (2017-02-13)
+
+* Feature: Add request header accessors (à la PSR-7)
+ (#103 by @clue)
+
+ ```php
+ // get value of host header
+ $host = $request->getHeaderLine('Host');
+
+ // get list of all cookie headers
+ $cookies = $request->getHeader('Cookie');
+ ```
+
+* Feature: Forward `pause()` and `resume()` from `Request` to underlying connection
+ (#110 by @clue)
+
+ ```php
+ // support back-pressure when piping request into slower destination
+ $request->pipe($dest);
+
+ // manually pause/resume request
+ $request->pause();
+ $request->resume();
+ ```
+
+* Fix: Fix `100-continue` to be handled case-insensitive and ignore it for HTTP/1.0.
+ Similarly, outgoing response headers are now handled case-insensitive, e.g
+ we no longer apply chunked transfer encoding with mixed-case `Content-Length`.
+ (#107 by @clue)
+
+ ```php
+ // now handled case-insensitive
+ $request->expectsContinue();
+
+ // now works just like properly-cased header
+ $response->writeHead($status, array('content-length' => 0));
+ ```
+
+* Fix: Do not emit empty `data` events and ignore empty writes in order to
+ not mess up chunked transfer encoding
+ (#108 and #112 by @clue)
+
+* Lock and test minimum required dependency versions and support PHPUnit v5
+ (#113, #115 and #114 by @andig)
+
+## 0.4.3 (2017-02-10)
+
+* Fix: Do not take start of body into account when checking maximum header size
+ (#88 by @nopolabs)
+
+* Fix: Remove `data` listener if `HeaderParser` emits an error
+ (#83 by @nick4fake)
+
+* First class support for PHP 5.3 through PHP 7 and HHVM
+ (#101 and #102 by @clue, #66 by @WyriHaximus)
+
+* Improve test suite by adding PHPUnit to require-dev,
+ improving forward compatibility with newer PHPUnit versions
+ and replacing unneeded test stubs
+ (#92 and #93 by @nopolabs, #100 by @legionth)
+
+## 0.4.2 (2016-11-09)
+
+* Remove all listeners after emitting error in RequestHeaderParser #68 @WyriHaximus
+* Catch Guzzle parse request errors #65 @WyriHaximus
+* Remove branch-alias definition as per reactphp/react#343 #58 @WyriHaximus
+* Add functional example to ease getting started #64 by @clue
+* Naming, immutable array manipulation #37 @cboden
+
+## 0.4.1 (2015-05-21)
+
* Replaced guzzle/parser with guzzlehttp/psr7 by @cboden
* FIX Continue Header by @iannsp
* Missing type hint by @marenzo
diff --git a/README.md b/README.md
index 9bad8ad4..f09371d8 100644
--- a/README.md
+++ b/README.md
@@ -2,19 +2,18 @@
[](http://travis-ci.org/reactphp/http) [](https://codeclimate.com/github/reactphp/http)
-Library for building an evented http server.
+Event-driven, streaming plaintext HTTP and secure HTTPS server for [ReactPHP](https://reactphp.org/)
-This component builds on top of the `Socket` component to implement HTTP. Here
-are the main concepts:
-
-* **Server**: Attaches itself to an instance of
- `React\Socket\ServerInterface`, parses any incoming data as HTTP, emits a
- `request` event for each request.
-* **Request**: A `ReadableStream` which streams the request body and contains
- meta data which was parsed from the request header.
-* **Response** A `WritableStream` which streams the response body. You can set
- the status code and response headers via the `writeHead()` method.
+**Table of Contents**
+* [Quickstart example](#quickstart-example)
+* [Usage](#usage)
+ * [Server](#server)
+ * [Request](#request)
+ * [Response](#response)
+* [Install](#install)
+* [Tests](#tests)
+* [License](#license)
## Quickstart example
@@ -22,16 +21,664 @@ This is an HTTP server which responds with `Hello World` to every request.
```php
$loop = React\EventLoop\Factory::create();
-$socket = new React\Socket\Server($loop);
-$http = new React\Http\Server($socket);
-$http->on('request', function ($request, $response) {
- $response->writeHead(200, array('Content-Type' => 'text/plain'));
- $response->end("Hello World!\n");
+$server = new Server(function (ServerRequestInterface $request) {
+ return new Response(
+ 200,
+ array('Content-Type' => 'text/plain'),
+ "Hello World!\n"
+ );
});
-$socket->listen(1337);
+$socket = new React\Socket\Server(8080, $loop);
+$server->listen($socket);
+
$loop->run();
```
See also the [examples](examples).
+
+## Usage
+
+### Server
+
+The `Server` class is responsible for handling incoming connections and then
+processing each incoming HTTP request.
+
+For each request, it executes the callback function passed to the
+constructor with the respective [request](#request) object and expects
+a respective [response](#response) object in return.
+
+```php
+$server = new Server(function (ServerRequestInterface $request) {
+ return new Response(
+ 200,
+ array('Content-Type' => 'text/plain'),
+ "Hello World!\n"
+ );
+});
+```
+
+In order to process any connections, the server needs to be attached to an
+instance of `React\Socket\ServerInterface` which emits underlying streaming
+connections in order to then parse incoming data as HTTP.
+
+You can attach this to a
+[`React\Socket\Server`](https://github.com/reactphp/socket#server)
+in order to start a plaintext HTTP server like this:
+
+```php
+$server = new Server($handler);
+
+$socket = new React\Socket\Server(8080, $loop);
+$server->listen($socket);
+```
+
+See also the `listen()` method and the [first example](examples) for more details.
+
+Similarly, you can also attach this to a
+[`React\Socket\SecureServer`](https://github.com/reactphp/socket#secureserver)
+in order to start a secure HTTPS server like this:
+
+```php
+$server = new Server($handler);
+
+$socket = new React\Socket\Server(8080, $loop);
+$socket = new React\Socket\SecureServer($socket, $loop, array(
+ 'local_cert' => __DIR__ . '/localhost.pem'
+));
+
+$server->listen($socket);
+```
+
+See also [example #11](examples) for more details.
+
+When HTTP/1.1 clients want to send a bigger request body, they MAY send only
+the request headers with an additional `Expect: 100-continue` header and
+wait before sending the actual (large) message body.
+In this case the server will automatically send an intermediary
+`HTTP/1.1 100 Continue` response to the client.
+This ensures you will receive the request body without a delay as expected.
+The [Response](#response) still needs to be created as described in the
+examples above.
+
+See also [request](#request) and [response](#response)
+for more details (e.g. the request data body).
+
+The `Server` supports both HTTP/1.1 and HTTP/1.0 request messages.
+If a client sends an invalid request message, uses an invalid HTTP protocol
+version or sends an invalid `Transfer-Encoding` in the request header, it will
+emit an `error` event, send an HTTP error response to the client and
+close the connection:
+
+```php
+$server->on('error', function (Exception $e) {
+ echo 'Error: ' . $e->getMessage() . PHP_EOL;
+});
+```
+
+The server will also emit an `error` event if you return an invalid
+type in the callback function or have a unhandled `Exception`.
+If your callback function throws an exception,
+the `Server` will emit a `RuntimeException` and add the thrown exception
+as previous:
+
+```php
+$server->on('error', function (Exception $e) {
+ echo 'Error: ' . $e->getMessage() . PHP_EOL;
+ if ($e->getPrevious() !== null) {
+ $previousException = $e->getPrevious();
+ echo $previousException->getMessage() . PHP_EOL;
+ }
+});
+```
+
+Note that the request object can also emit an error.
+Check out [request](#request) for more details.
+
+### Request
+
+An seen above, the `Server` class is responsible for handling incoming
+connections and then processing each incoming HTTP request.
+
+The request object will be processed once the request headers have
+been received by the client.
+This request object implements the
+[PSR-7 ServerRequestInterface](https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-7-http-message.md#321-psrhttpmessageserverrequestinterface)
+which in turn extends the
+[PSR-7 RequestInterface](https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-7-http-message.md#32-psrhttpmessagerequestinterface)
+and will be passed to the callback function like this.
+
+ ```php
+$server = new Server(function (ServerRequestInterface $request) {
+ $body = "The method of the request is: " . $request->getMethod();
+ $body .= "The requested path is: " . $request->getUri()->getPath();
+
+ return new Response(
+ 200,
+ array('Content-Type' => 'text/plain'),
+ $body
+ );
+});
+```
+
+The `getServerParams(): mixed[]` method can be used to
+get server-side parameters similar to the `$_SERVER` variable.
+The following parameters are currently available:
+
+* `REMOTE_ADDR`
+ The IP address of the request sender
+* `REMOTE_PORT`
+ Port of the request sender
+* `SERVER_ADDR`
+ The IP address of the server
+* `SERVER_PORT`
+ The port of the server
+* `REQUEST_TIME`
+ Unix timestamp when the complete request header has been received,
+ as integer similar to `time()`
+* `REQUEST_TIME_FLOAT`
+ Unix timestamp when the complete request header has been received,
+ as float similar to `microtime(true)`
+* `HTTPS`
+ Set to 'on' if the request used HTTPS, otherwise it won't be set
+
+```php
+$server = new Server(function (ServerRequestInterface $request) {
+ $body = "Your IP is: " . $request->getServerParams()['REMOTE_ADDR'];
+
+ return new Response(
+ 200,
+ array('Content-Type' => 'text/plain'),
+ $body
+ );
+});
+```
+
+See also [example #2](examples).
+
+The `getQueryParams(): array` method can be used to get the query parameters
+similiar to the `$_GET` variable.
+
+```php
+$server = new Server(function (ServerRequestInterface $request) {
+ $queryParams = $request->getQueryParams();
+
+ $body = 'The query parameter "foo" is not set. Click the following link ';
+ $body .= 'to use query parameter in your request';
+
+ if (isset($queryParams['foo'])) {
+ $body = 'The value of "foo" is: ' . htmlspecialchars($queryParams['foo']);
+ }
+
+ return new Response(
+ 200,
+ array('Content-Type' => 'text/html'),
+ $body
+ );
+});
+```
+
+The response in the above example will return a response body with a link.
+The URL contains the query parameter `foo` with the value `bar`.
+Use [`htmlentities`](http://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 #3](examples).
+
+For more details about the request object, check out the documentation of
+[PSR-7 ServerRequestInterface](https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-7-http-message.md#321-psrhttpmessageserverrequestinterface)
+and
+[PSR-7 RequestInterface](https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-7-http-message.md#32-psrhttpmessagerequestinterface).
+
+> Currently the uploaded files are not added by the
+ `Server`, but you can add these parameters by yourself using the given methods.
+ The next versions of this project will cover these features.
+
+Note that 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
+(potentially much larger) request body.
+While this may be uncommon in the PHP ecosystem, this is actually a very powerful
+approach that gives you several advantages not otherwise possible:
+
+* React to requests *before* receiving a large request body,
+ such as rejecting an unauthenticated request or one that exceeds allowed
+ message lengths (file uploads).
+* Start processing parts of the request body before the remainder of the request
+ body arrives or if the sender is slowly streaming data.
+* Process a large request body without having to buffer anything in memory,
+ such as accepting a huge file upload or possibly unlimited request body stream.
+
+The `getBody()` method can be used to access the request body stream.
+This method returns a stream instance that implements both the
+[PSR-7 StreamInterface](http://www.php-fig.org/psr/psr-7/#psrhttpmessagestreaminterface)
+and the [ReactPHP ReadableStreamInterface](https://github.com/reactphp/stream#readablestreaminterface).
+However, most of the `PSR-7 StreamInterface` methods have been
+designed under the assumption of being in control of the request body.
+Given that this does not apply to this server, the following
+`PSR-7 StreamInterface` methods are not used and SHOULD NOT be called:
+`tell()`, `eof()`, `seek()`, `rewind()`, `write()` and `read()`.
+Instead, you should use the `ReactPHP ReadableStreamInterface` which
+gives you access to the incoming request body as the individual chunks arrive:
+
+```php
+$server = new Server(function (ServerRequestInterface $request) {
+ return new Promise(function ($resolve, $reject) use ($request) {
+ $contentLength = 0;
+ $request->getBody()->on('data', function ($data) use (&$contentLength) {
+ $contentLength += strlen($data);
+ });
+
+ $request->getBody()->on('end', function () use ($resolve, &$contentLength){
+ $response = new Response(
+ 200,
+ array('Content-Type' => 'text/plain'),
+ "The length of the submitted request body is: " . $contentLength
+ );
+ $resolve($response);
+ });
+
+ // an error occures e.g. on invalid chunked encoded data or an unexpected 'end' event
+ $request->getBody()->on('error', function (\Exception $exception) use ($resolve, &$contentLength) {
+ $response = new Response(
+ 400,
+ array('Content-Type' => 'text/plain'),
+ "An error occured while reading at length: " . $contentLength
+ );
+ $resolve($response);
+ });
+ });
+});
+```
+
+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 #4](examples) for more details.
+
+The `data` event will be emitted whenever new data is available on the request
+body stream.
+The server automatically takes care of decoding chunked transfer encoding
+and will only emit the actual payload as data.
+In this case, the `Transfer-Encoding` header will be removed.
+
+The `end` event will be emitted when the request body stream terminates
+successfully, i.e. it was read until its expected end.
+
+The `error` event will be emitted in case the request stream contains invalid
+chunked data or the connection closes before the complete request stream has
+been received.
+The server will automatically `pause()` the connection instead of closing it.
+A response message can still be sent (unless the connection is already closed).
+
+A `close` event will be emitted after an `error` or `end` event.
+
+For more details about the request body stream, check out the documentation of
+[ReactPHP ReadableStreamInterface](https://github.com/reactphp/stream#readablestreaminterface).
+
+The `getSize(): ?int` method can be used if you only want to know the request
+body size.
+This method returns the complete size of the request body as defined by the
+message boundaries.
+This value may be `0` if the request message does not contain a request body
+(such as a simple `GET` request).
+Note that this value may be `null` if the request body size is unknown in
+advance because the request message uses chunked transfer encoding.
+
+```php
+$server = new Server(function (ServerRequestInterface $request) {
+ $size = $request->getBody()->getSize();
+ if ($size === null) {
+ $body = 'The request does not contain an explicit length.';
+ $body .= 'This server does not accept chunked transfer encoding.';
+
+ return new Response(
+ 411,
+ array('Content-Type' => 'text/plain'),
+ $body
+ );
+ }
+
+ return new Response(
+ 200,
+ array('Content-Type' => 'text/plain'),
+ "Request body size: " . $size . " bytes\n"
+ );
+});
+```
+
+Note that the server supports *any* request method (including custom and non-
+standard ones) and all request-target formats defined in the HTTP specs for each
+respective method, including *normal* `origin-form` requests as well as
+proxy requests in `absolute-form` and `authority-form`.
+The `getUri(): UriInterface` method can be used to get the effective request
+URI which provides you access to individiual URI components.
+Note that (depending on the given `request-target`) certain URI components may
+or may not be present, for example the `getPath(): string` method will return
+an empty string for requests in `asterisk-form` or `authority-form`.
+Its `getHost(): string` method will return the host as determined by the
+effective request URI, which defaults to the local socket address if a HTTP/1.0
+client did not specify one (i.e. no `Host` header).
+Its `getScheme(): string` method will return `http` or `https` depending
+on whether the request was made over a secure TLS connection to the target host.
+
+The `Host` header value will be sanitized to match this host component plus the
+port component only if it is non-standard for this URI scheme.
+
+You can use `getMethod(): string` and `getRequestTarget(): string` to
+check this is an accepted request and may want to reject other requests with
+an appropriate error code, such as `400` (Bad Request) or `405` (Method Not
+Allowed).
+
+> The `CONNECT` method is useful in a tunneling setup (HTTPS proxy) and not
+ something most HTTP servers would want to care about.
+ Note that if you want to handle this method, the client MAY send a different
+ request-target than the `Host` header value (such as removing default ports)
+ and the request-target MUST take precendence when forwarding.
+
+The `getCookieParams(): string[]` method can be used to
+get all cookies sent with the current request.
+
+```php
+$server = new Server(function (ServerRequestInterface $request) {
+ $key = 'react\php';
+
+ if (isset($request->getCookieParams()[$key])) {
+ $body = "Your cookie value is: " . $request->getCookieParams()[$key];
+
+ return new Response(
+ 200,
+ array('Content-Type' => 'text/plain'),
+ $body
+ );
+ }
+
+ return new Response(
+ 200,
+ array(
+ 'Content-Type' => 'text/plain',
+ 'Set-Cookie' => urlencode($key) . '=' . urlencode('test;more')
+ ),
+ "Your cookie has been set."
+ );
+});
+```
+
+The above example will try to set a cookie on first access and
+will try to print the cookie value on all subsequent tries.
+Note how the example uses the `urlencode()` function to encode
+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 #6](examples) for more details.
+
+### Response
+
+The callback function passed to the constructor of the [Server](#server)
+is responsible for processing the request and returning a response,
+which will be delivered to the client.
+This function MUST return an instance imlementing
+[PSR-7 ResponseInterface](https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-7-http-message.md#33-psrhttpmessageresponseinterface)
+object or a
+[ReactPHP Promise](https://github.com/reactphp/promise#reactpromise)
+which will resolve a `PSR-7 ResponseInterface` object.
+
+You will find a `Response` class
+which implements the `PSR-7 ResponseInterface` in this project.
+We use instantiation of this class in our projects,
+but feel free to use any implemantation of the
+`PSR-7 ResponseInterface` you prefer.
+
+```php
+$server = new Server(function (ServerRequestInterface $request) {
+ return new Response(
+ 200,
+ array('Content-Type' => 'text/plain'),
+ "Hello World!\n"
+ );
+});
+```
+
+The example above returns the response directly, because it needs
+no time to be processed.
+Using a database, the file system or long calculations
+(in fact every action that will take >=1ms) to create your
+response, will slow down the server.
+To prevent this you SHOULD use a
+[ReactPHP Promise](https://github.com/reactphp/promise#reactpromise).
+This example shows how such a long-term action could look like:
+
+```php
+$server = new Server(function (ServerRequestInterface $request) use ($loop) {
+ return new Promise(function ($resolve, $reject) use ($request, $loop) {
+ $loop->addTimer(1.5, function() use ($loop, $resolve) {
+ $response = new Response(
+ 200,
+ array('Content-Type' => 'text/plain'),
+ "Hello world"
+ );
+ $resolve($response);
+ });
+ });
+});
+```
+
+The above example will create a response after 1.5 second.
+This example shows that you need a promise,
+if your response needs time to created.
+The `ReactPHP Promise` will resolve in a `Response` object when the request
+body ends.
+If the client closes the connection while the promise is still pending, the
+promise will automatically be cancelled.
+The promise cancellation handler can be used to clean up any pending resources
+allocated in this case (if applicable).
+If a promise is resolved after the client closes, it will simply be ignored.
+
+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.
+So you are able stream data directly into the response body.
+Note that other implementations of the `PSR-7 ResponseInterface` likely
+only support strings.
+
+```php
+$server = new Server(function (ServerRequestInterface $request) use ($loop) {
+ $stream = new ThroughStream();
+
+ $timer = $loop->addPeriodicTimer(0.5, function () use ($stream) {
+ $stream->emit('data', array(microtime(true) . PHP_EOL));
+ });
+
+ $loop->addTimer(5, function() use ($loop, $timer, $stream) {
+ $loop->cancelTimer($timer);
+ $stream->emit('end');
+ });
+
+ return new Response(200, array('Content-Type' => 'text/plain'), $stream);
+});
+```
+
+The above example will emit every 0.5 seconds the current Unix timestamp
+with microseconds as float to the client and will end after 5 seconds.
+This is just a example you could use of the streaming,
+you could also send a big amount of data via little chunks
+or use it for body data that needs to calculated.
+
+If the request handler resolves with a response stream that is already closed,
+it will simply send an empty response body.
+If the client closes the connection while the stream is still open, the
+response stream will automatically be closed.
+If a promise is resolved with a streaming body after the client closes, the
+response stream will automatically be closed.
+The `close` event can be used to clean up any pending resources allocated
+in this case (if applicable).
+
+If the response body is a `string`, a `Content-Length` header will be added
+automatically.
+If the response body is a ReactPHP `ReadableStreamInterface` and you do not
+specify a `Content-Length` header, HTTP/1.1 responses will automatically use
+chunked transfer encoding and send the respective header
+(`Transfer-Encoding: chunked`) automatically.
+The server is responsible for handling `Transfer-Encoding`, so you SHOULD NOT
+pass this header yourself.
+If you know the length of your stream body, you MAY specify it like this instead:
+
+```php
+$stream = new ThroughStream()
+$server = new Server(function (ServerRequestInterface $request) use ($stream) {
+ return new Response(
+ 200,
+ array(
+ 'Content-Length' => '5',
+ 'Content-Type' => 'text/plain',
+ ),
+ $stream
+ );
+});
+```
+
+An invalid return value or an unhandled `Exception` in the code of the callback
+function, will result in an `500 Internal Server Error` message.
+Make sure to catch `Exceptions` to create own response messages.
+
+After the return in the callback function the response will be processed by the `Server`.
+The `Server` will add the protocol version of the request, so you don't have to.
+
+Any response to a `HEAD` request and any response with a `1xx` (Informational),
+`204` (No Content) or `304` (Not Modified) status code will *not* include a
+message body as per the HTTP specs.
+This means that your callback does not have to take special care of this and any
+response body will simply be ignored.
+
+Similarly, any `2xx` (Successful) response to a `CONNECT` request, any response
+with a `1xx` (Informational) or `204` (No Content) status code will *not*
+include a `Content-Length` or `Transfer-Encoding` header as these do not apply
+to these messages.
+Note that a response to a `HEAD` request and any response with a `304` (Not
+Modified) status code MAY include these headers even though
+the message does not contain a response body, because these header would apply
+to the message if the same request would have used an (unconditional) `GET`.
+
+> Note that special care has to be taken if you use a body stream instance that
+ implements ReactPHP's
+ [`DuplexStreamInterface`](https://github.com/reactphp/stream#duplexstreaminterface)
+ (such as the `ThroughStream` in the above example).
+>
+> For *most* cases, this will simply only consume its readable side and forward
+ (send) any data that is emitted by the stream, thus entirely ignoring the
+ writable side of the stream.
+ If however this is either a `101` (Switching Protocols) response or a `2xx`
+ (Successful) response to a `CONNECT` method, it will also *write* data to the
+ writable side of the stream.
+ This can be avoided by either rejecting all requests with the `CONNECT`
+ method (which is what most *normal* origin HTTP servers would likely do) or
+ or ensuring that only ever an instance of `ReadableStreamInterface` is
+ used.
+>
+> The `101` (Switching Protocols) response code is useful for the more advanced
+ `Upgrade` requests, such as upgrading to the WebSocket protocol or
+ implementing custom protocol logic that is out of scope of the HTTP specs and
+ this HTTP library.
+ If you want to handle the `Upgrade: WebSocket` header, you will likely want
+ 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.
+ 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.
+ The server automatically takes care of sending a `Connection: upgrade`
+ header value in this case, so you don't have to.
+>
+> The `CONNECT` method is useful in a tunneling setup (HTTPS proxy) and not
+ something most origin HTTP servers would want to care about.
+ The HTTP specs define an opaque "tunneling mode" for this method and make no
+ use of the message body.
+ For consistency reasons, this library uses a `DuplexStreamInterface` in the
+ response body for tunneled application data.
+ This implies that that a `2xx` (Successful) response to a `CONNECT` request
+ can in fact use a streaming response body for the tunneled application data,
+ so that any raw data the client sends over the connection will be piped
+ through the writable stream for consumption.
+ Note that while the HTTP specs make no use of the request body for `CONNECT`
+ 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.
+
+A `Date` header will be automatically added with the system date and time if none is given.
+You can add a custom `Date` header yourself like this:
+
+```php
+$server = new Server(function (ServerRequestInterface $request) {
+ return new Response(200, array('Date' => date('D, d M Y H:i:s T')));
+});
+```
+
+If you don't have a appropriate clock to rely on, you should
+unset this header with an empty string:
+
+```php
+$server = new Server(function (ServerRequestInterface $request) {
+ return new Response(200, array('Date' => ''));
+});
+```
+
+Note that it will automatically assume a `X-Powered-By: react/alpha` header
+unless your specify a custom `X-Powered-By` header yourself:
+
+```php
+$server = new Server(function (ServerRequestInterface $request) {
+ return new Response(200, array('X-Powered-By' => 'PHP 3'));
+});
+```
+
+If you do not want to send this header at all, you can use an empty string as
+value like this:
+
+```php
+$server = new Server(function (ServerRequestInterface $request) {
+ return new Response(200, array('X-Powered-By' => ''));
+});
+```
+
+Note that persistent connections (`Connection: keep-alive`) are currently
+not supported.
+As such, HTTP/1.1 response messages will automatically include a
+`Connection: close` header, irrespective of what header values are
+passed explicitly.
+
+## Install
+
+The recommended way to install this library is [through Composer](http://getcomposer.org).
+[New to Composer?](http://getcomposer.org/doc/00-intro.md)
+
+This will install the latest supported version:
+
+```bash
+$ composer require react/http:^0.7
+```
+
+More details about version upgrades can be found in the [CHANGELOG](CHANGELOG.md).
+
+## Tests
+
+To run the test suite, you first need to clone this repo and then install all
+dependencies [through Composer](http://getcomposer.org):
+
+```bash
+$ composer install
+```
+
+To run the test suite, go to the project root and run:
+
+```bash
+$ php vendor/bin/phpunit
+```
+
+## License
+
+MIT, see [LICENSE file](LICENSE).
diff --git a/composer.json b/composer.json
index c186927d..e73e29c7 100644
--- a/composer.json
+++ b/composer.json
@@ -1,18 +1,25 @@
{
"name": "react/http",
- "description": "Library for building an evented http server.",
- "keywords": ["http"],
+ "description": "Event-driven, streaming plaintext HTTP and secure HTTPS server for ReactPHP",
+ "keywords": ["event-driven", "streaming", "HTTP", "HTTPS", "server", "ReactPHP"],
"license": "MIT",
"require": {
- "php": ">=5.4.0",
- "guzzlehttp/psr7": "^1.0",
- "react/socket": "^0.4",
- "react/stream": "^0.4",
- "evenement/evenement": "^2.0"
+ "php": ">=5.3.0",
+ "ringcentral/psr7": "^1.2",
+ "react/socket": "^1.0 || ^0.8 || ^0.7 || ^0.6 || ^0.5",
+ "react/stream": "^1.0 || ^0.7 || ^0.6 || ^0.5 || ^0.4.6",
+ "react/promise": "^2.1 || ^1.2.1",
+ "evenement/evenement": "^2.0 || ^1.0"
},
"autoload": {
"psr-4": {
"React\\Http\\": "src"
}
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^4.8.10||^5.0",
+ "react/promise-stream": "^0.1.1",
+ "react/socket": "^1.0 || ^0.8 || ^0.7",
+ "clue/block-react": "^1.1"
}
}
diff --git a/examples/01-hello-world.php b/examples/01-hello-world.php
index 0a8e9d56..f703a5d7 100644
--- a/examples/01-hello-world.php
+++ b/examples/01-hello-world.php
@@ -1,23 +1,27 @@
on('request', function (Request $reques, Response $response) {
- $response->writeHead(200, array('Content-Type' => 'text/plain'));
- $response->end("Hello world!\n");
+$server = new Server(function (ServerRequestInterface $request) {
+ return new Response(
+ 200,
+ array(
+ 'Content-Type' => 'text/plain'
+ ),
+ "Hello world\n"
+ );
});
-$socket->listen(isset($argv[1]) ? $argv[1] : 0, '0.0.0.0');
+$socket = new \React\Socket\Server(isset($argv[1]) ? $argv[1] : '0.0.0.0:0', $loop);
+$server->listen($socket);
-echo 'Listening on ' . $socket->getPort() . PHP_EOL;
+echo 'Listening on ' . str_replace('tcp:', 'http:', $socket->getAddress()) . PHP_EOL;
$loop->run();
diff --git a/examples/02-count-visitors.php b/examples/02-count-visitors.php
new file mode 100644
index 00000000..5a225110
--- /dev/null
+++ b/examples/02-count-visitors.php
@@ -0,0 +1,26 @@
+ 'text/plain'),
+ "Welcome number " . ++$counter . "!\n"
+ );
+});
+
+$socket = new \React\Socket\Server(isset($argv[1]) ? $argv[1] : '0.0.0.0:0', $loop);
+$server->listen($socket);
+
+echo 'Listening on ' . str_replace('tcp:', 'http:', $socket->getAddress()) . PHP_EOL;
+
+$loop->run();
diff --git a/examples/03-client-ip.php b/examples/03-client-ip.php
new file mode 100644
index 00000000..3fbcabfd
--- /dev/null
+++ b/examples/03-client-ip.php
@@ -0,0 +1,27 @@
+getServerParams()['REMOTE_ADDR'];
+
+ return new Response(
+ 200,
+ array('Content-Type' => 'text/plain'),
+ $body
+ );
+});
+
+$socket = new \React\Socket\Server(isset($argv[1]) ? $argv[1] : '0.0.0.0:0', $loop);
+$server->listen($socket);
+
+echo 'Listening on ' . str_replace('tcp:', 'http:', $socket->getAddress()) . PHP_EOL;
+
+$loop->run();
diff --git a/examples/04-query-parameter.php b/examples/04-query-parameter.php
new file mode 100644
index 00000000..3a60aae8
--- /dev/null
+++ b/examples/04-query-parameter.php
@@ -0,0 +1,34 @@
+getQueryParams();
+
+ $body = 'The query parameter "foo" is not set. Click the following link ';
+ $body .= 'to use query parameter in your request';
+
+ if (isset($queryParams['foo'])) {
+ $body = 'The value of "foo" is: ' . htmlspecialchars($queryParams['foo']);
+ }
+
+ return new Response(
+ 200,
+ array('Content-Type' => 'text/html'),
+ $body
+ );
+});
+
+$socket = new \React\Socket\Server(isset($argv[1]) ? $argv[1] : '0.0.0.0:0', $loop);
+$server->listen($socket);
+
+echo 'Listening on ' . str_replace('tcp:', 'http:', $socket->getAddress()) . PHP_EOL;
+
+$loop->run();
diff --git a/examples/05-cookie-handling.php b/examples/05-cookie-handling.php
new file mode 100644
index 00000000..5441adbe
--- /dev/null
+++ b/examples/05-cookie-handling.php
@@ -0,0 +1,40 @@
+getCookieParams()[$key])) {
+ $body = "Your cookie value is: " . $request->getCookieParams()[$key];
+
+ return new Response(
+ 200,
+ array('Content-Type' => 'text/plain'),
+ $body
+ );
+ }
+
+ return new Response(
+ 200,
+ array(
+ 'Content-Type' => 'text/plain',
+ 'Set-Cookie' => urlencode($key) . '=' . urlencode('test;more')
+ ),
+ "Your cookie has been set."
+ );
+});
+
+$socket = new \React\Socket\Server(isset($argv[1]) ? $argv[1] : '0.0.0.0:0', $loop);
+$server->listen($socket);
+
+echo 'Listening on ' . str_replace('tcp:', 'http:', $socket->getAddress()) . PHP_EOL;
+
+$loop->run();
diff --git a/examples/06-sleep.php b/examples/06-sleep.php
new file mode 100644
index 00000000..926aac10
--- /dev/null
+++ b/examples/06-sleep.php
@@ -0,0 +1,31 @@
+addTimer(1.5, function() use ($loop, $resolve) {
+ $response = new Response(
+ 200,
+ array('Content-Type' => 'text/plain'),
+ "Hello world"
+ );
+ $resolve($response);
+ });
+ });
+});
+
+$socket = new \React\Socket\Server(isset($argv[1]) ? $argv[1] : '0.0.0.0:0', $loop);
+$server->listen($socket);
+
+echo 'Listening on ' . str_replace('tcp:', 'http:', $socket->getAddress()) . PHP_EOL;
+
+$loop->run();
diff --git a/examples/07-error-handling.php b/examples/07-error-handling.php
new file mode 100644
index 00000000..5dbc6955
--- /dev/null
+++ b/examples/07-error-handling.php
@@ -0,0 +1,37 @@
+ 'text/plain'),
+ "Hello World!\n"
+ );
+
+ $resolve($response);
+ });
+});
+
+$socket = new \React\Socket\Server(isset($argv[1]) ? $argv[1] : '0.0.0.0:0', $loop);
+$server->listen($socket);
+
+echo 'Listening on ' . str_replace('tcp:', 'http:', $socket->getAddress()) . PHP_EOL;
+
+$loop->run();
diff --git a/examples/08-stream-response.php b/examples/08-stream-response.php
new file mode 100644
index 00000000..399e3a77
--- /dev/null
+++ b/examples/08-stream-response.php
@@ -0,0 +1,41 @@
+getMethod() !== 'GET' || $request->getUri()->getPath() !== '/') {
+ return new Response(404);
+ }
+
+ $stream = new ThroughStream();
+
+ $timer = $loop->addPeriodicTimer(0.5, function () use ($stream) {
+ $stream->emit('data', array(microtime(true) . PHP_EOL));
+ });
+
+ $loop->addTimer(5, function() use ($loop, $timer, $stream) {
+ $loop->cancelTimer($timer);
+ $stream->emit('end');
+ });
+
+ return new Response(
+ 200,
+ array('Content-Type' => 'text/plain'),
+ $stream
+ );
+});
+
+$socket = new \React\Socket\Server(isset($argv[1]) ? $argv[1] : '0.0.0.0:0', $loop);
+$server->listen($socket);
+
+echo 'Listening on ' . str_replace('tcp:', 'http:', $socket->getAddress()) . PHP_EOL;
+
+$loop->run();
diff --git a/examples/09-stream-request.php b/examples/09-stream-request.php
new file mode 100644
index 00000000..bcf5456b
--- /dev/null
+++ b/examples/09-stream-request.php
@@ -0,0 +1,46 @@
+getBody()->on('data', function ($data) use (&$contentLength) {
+ $contentLength += strlen($data);
+ });
+
+ $request->getBody()->on('end', function () use ($resolve, &$contentLength){
+ $response = new Response(
+ 200,
+ array('Content-Type' => 'text/plain'),
+ "The length of the submitted request body is: " . $contentLength
+ );
+ $resolve($response);
+ });
+
+ // an error occures e.g. on invalid chunked encoded data or an unexpected 'end' event
+ $request->getBody()->on('error', function (\Exception $exception) use ($resolve, &$contentLength) {
+ $response = new Response(
+ 400,
+ array('Content-Type' => 'text/plain'),
+ "An error occured while reading at length: " . $contentLength
+ );
+ $resolve($response);
+ });
+ });
+});
+
+$socket = new \React\Socket\Server(isset($argv[1]) ? $argv[1] : '0.0.0.0:0', $loop);
+$server->listen($socket);
+
+echo 'Listening on ' . str_replace('tcp:', 'http:', $socket->getAddress()) . PHP_EOL;
+
+$loop->run();
diff --git a/examples/11-hello-world-https.php b/examples/11-hello-world-https.php
new file mode 100644
index 00000000..6610c3e0
--- /dev/null
+++ b/examples/11-hello-world-https.php
@@ -0,0 +1,30 @@
+ 'text/plain'),
+ "Hello world!\n"
+ );
+});
+
+$socket = new \React\Socket\Server(isset($argv[1]) ? $argv[1] : '0.0.0.0:0', $loop);
+$socket = new \React\Socket\SecureServer($socket, $loop, array(
+ 'local_cert' => isset($argv[2]) ? $argv[2] : __DIR__ . '/localhost.pem'
+));
+$server->listen($socket);
+
+//$socket->on('error', 'printf');
+
+echo 'Listening on ' . str_replace('tls:', 'https:', $socket->getAddress()) . PHP_EOL;
+
+$loop->run();
diff --git a/examples/21-http-proxy.php b/examples/21-http-proxy.php
new file mode 100644
index 00000000..250cbf7a
--- /dev/null
+++ b/examples/21-http-proxy.php
@@ -0,0 +1,45 @@
+getRequestTarget(), '://') === false) {
+ return new Response(
+ 400,
+ array('Content-Type' => 'text/plain'),
+ 'This is a plain HTTP proxy'
+ );
+ }
+
+ // prepare outgoing client request by updating request-target and Host header
+ $host = (string)$request->getUri()->withScheme('')->withPath('')->withQuery('');
+ $target = (string)$request->getUri()->withScheme('')->withHost('')->withPort(null);
+ if ($target === '') {
+ $target = $request->getMethod() === 'OPTIONS' ? '*' : '/';
+ }
+ $outgoing = $request->withRequestTarget($target)->withHeader('Host', $host);
+
+ // pseudo code only: simply dump the outgoing request as a string
+ // left up as an exercise: use an HTTP client to send the outgoing request
+ // and forward the incoming response to the original client request
+ return new Response(
+ 200,
+ array('Content-Type' => 'text/plain'),
+ Psr7\str($outgoing)
+ );
+});
+
+$socket = new \React\Socket\Server(isset($argv[1]) ? $argv[1] : '0.0.0.0:0', $loop);
+$server->listen($socket);
+
+echo 'Listening on ' . str_replace('tcp:', 'http:', $socket->getAddress()) . PHP_EOL;
+
+$loop->run();
diff --git a/examples/22-connect-proxy.php b/examples/22-connect-proxy.php
new file mode 100644
index 00000000..ed8e80b0
--- /dev/null
+++ b/examples/22-connect-proxy.php
@@ -0,0 +1,49 @@
+getMethod() !== 'CONNECT') {
+ return new Response(
+ 405,
+ array('Content-Type' => 'text/plain', 'Allow' => 'CONNECT'),
+ 'This is a HTTP CONNECT (secure HTTPS) proxy'
+ );
+ }
+
+ // try to connect to given target host
+ return $connector->connect($request->getRequestTarget())->then(
+ function (ConnectionInterface $remote) {
+ // connection established => forward data
+ return new Response(
+ 200,
+ array(),
+ $remote
+ );
+ },
+ function ($e) {
+ return new Response(
+ 502,
+ array('Content-Type' => 'text/plain'),
+ 'Unable to connect: ' . $e->getMessage()
+ );
+ }
+ );
+});
+
+$socket = new \React\Socket\Server(isset($argv[1]) ? $argv[1] : '0.0.0.0:0', $loop);
+$server->listen($socket);
+
+echo 'Listening on ' . str_replace('tcp:', 'http:', $socket->getAddress()) . PHP_EOL;
+
+$loop->run();
diff --git a/examples/31-upgrade-echo.php b/examples/31-upgrade-echo.php
new file mode 100644
index 00000000..b098ef03
--- /dev/null
+++ b/examples/31-upgrade-echo.php
@@ -0,0 +1,58 @@
+ GET / HTTP/1.1
+> Upgrade: echo
+>
+< HTTP/1.1 101 Switching Protocols
+< Upgrade: echo
+< Connection: upgrade
+<
+> hello
+< hello
+> world
+< world
+*/
+
+use Psr\Http\Message\ServerRequestInterface;
+use React\EventLoop\Factory;
+use React\Http\Response;
+use React\Http\Server;
+use React\Stream\ThroughStream;
+
+require __DIR__ . '/../vendor/autoload.php';
+
+$loop = Factory::create();
+
+$server = new Server(function (ServerRequestInterface $request) use ($loop) {
+ if ($request->getHeaderLine('Upgrade') !== 'echo' || $request->getProtocolVersion() === '1.0') {
+ return new Response(426, array('Upgrade' => 'echo'), '"Upgrade: echo" required');
+ }
+
+ // simply return a duplex ThroughStream here
+ // it will simply emit any data that is sent to it
+ // this means that any Upgraded data will simply be sent back to the client
+ $stream = new ThroughStream();
+
+ $loop->addTimer(0, function () use ($stream) {
+ $stream->write("Hello! Anything you send will be piped back." . PHP_EOL);
+ });
+
+ return new Response(
+ 101,
+ array(
+ 'Upgrade' => 'echo'
+ ),
+ $stream
+ );
+});
+
+$socket = new \React\Socket\Server(isset($argv[1]) ? $argv[1] : '0.0.0.0:0', $loop);
+$server->listen($socket);
+
+echo 'Listening on ' . str_replace('tcp:', 'http:', $socket->getAddress()) . PHP_EOL;
+
+$loop->run();
diff --git a/examples/32-upgrade-chat.php b/examples/32-upgrade-chat.php
new file mode 100644
index 00000000..49cb0305
--- /dev/null
+++ b/examples/32-upgrade-chat.php
@@ -0,0 +1,86 @@
+ GET / HTTP/1.1
+> Upgrade: chat
+>
+< HTTP/1.1 101 Switching Protocols
+< Upgrade: chat
+< Connection: upgrade
+<
+> hello
+< user123: hello
+> world
+< user123: world
+
+Hint: try this with multiple connections :)
+*/
+
+use Psr\Http\Message\ServerRequestInterface;
+use React\EventLoop\Factory;
+use React\Http\Response;
+use React\Http\Server;
+use React\Stream\CompositeStream;
+use React\Stream\ThroughStream;
+
+require __DIR__ . '/../vendor/autoload.php';
+
+$loop = Factory::create();
+
+// simply use a shared duplex ThroughStream for all clients
+// it will simply emit any data that is sent to it
+// this means that any Upgraded data will simply be sent back to the client
+$chat = new ThroughStream();
+
+$server = new Server(function (ServerRequestInterface $request) use ($loop, $chat) {
+ if ($request->getHeaderLine('Upgrade') !== 'chat' || $request->getProtocolVersion() === '1.0') {
+ return new Response(426, array('Upgrade' => 'chat'), '"Upgrade: chat" required');
+ }
+
+ // user stream forwards chat data and accepts incoming data
+ $out = $chat->pipe(new ThroughStream());
+ $in = new ThroughStream();
+ $stream = new CompositeStream(
+ $out,
+ $in
+ );
+
+ // assign some name for this new connection
+ $username = 'user' . mt_rand();
+
+ // send anything that is received to the whole channel
+ $in->on('data', function ($data) use ($username, $chat) {
+ $data = trim(preg_replace('/[^\w\d \.\,\-\!\?]/u', '', $data));
+
+ $chat->write($username . ': ' . $data . PHP_EOL);
+ });
+
+ // say hello to new user
+ $loop->addTimer(0, function () use ($chat, $username, $out) {
+ $out->write('Welcome to this chat example, ' . $username . '!' . PHP_EOL);
+ $chat->write($username . ' joined' . PHP_EOL);
+ });
+
+ // send goodbye to channel once connection closes
+ $stream->on('close', function () use ($username, $chat) {
+ $chat->write($username . ' left' . PHP_EOL);
+ });
+
+ return new Response(
+ 101,
+ array(
+ 'Upgrade' => 'chat'
+ ),
+ $stream
+ );
+});
+
+$socket = new \React\Socket\Server(isset($argv[1]) ? $argv[1] : '0.0.0.0:0', $loop);
+$server->listen($socket);
+
+echo 'Listening on ' . str_replace('tcp:', 'http:', $socket->getAddress()) . PHP_EOL;
+
+$loop->run();
diff --git a/examples/99-benchmark-download.php b/examples/99-benchmark-download.php
new file mode 100644
index 00000000..a8a6e03a
--- /dev/null
+++ b/examples/99-benchmark-download.php
@@ -0,0 +1,121 @@
+ /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
+
+use Evenement\EventEmitter;
+use Psr\Http\Message\ServerRequestInterface;
+use React\EventLoop\Factory;
+use React\Http\Response;
+use React\Http\Server;
+use React\Stream\ReadableStreamInterface;
+use React\Stream\WritableStreamInterface;
+
+require __DIR__ . '/../vendor/autoload.php';
+
+$loop = Factory::create();
+
+/** A readable stream that can emit a lot of data */
+class ChunkRepeater extends EventEmitter implements ReadableStreamInterface
+{
+ private $chunk;
+ private $count;
+ private $position = 0;
+ private $paused = true;
+ private $closed = false;
+
+ public function __construct($chunk, $count)
+ {
+ $this->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;
+ }
+
+ 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 getSize()
+ {
+ return strlen($this->chunk) * $this->count;
+ }
+}
+
+$server = new Server(function (ServerRequestInterface $request) use ($loop) {
+ switch ($request->getUri()->getPath()) {
+ case '/':
+ return new Response(
+ 200,
+ array('Content-Type' => 'text/html'),
+ '1g.bin
10g.bin'
+ );
+ case '/1g.bin':
+ $stream = new ChunkRepeater(str_repeat('.', 1000000), 1000);
+ break;
+ case '/10g.bin':
+ $stream = new ChunkRepeater(str_repeat('.', 1000000), 10000);
+ break;
+ default:
+ return new Response(404);
+ }
+
+ $loop->addTimer(0, array($stream, 'resume'));
+
+ return new Response(
+ 200,
+ array('Content-Type' => 'application/octet-data', 'Content-Length' => $stream->getSize()),
+ $stream
+ );
+});
+
+$socket = new \React\Socket\Server(isset($argv[1]) ? $argv[1] : '0.0.0.0:0', $loop);
+$server->listen($socket);
+
+echo 'Listening on ' . str_replace('tcp:', 'http:', $socket->getAddress()) . PHP_EOL;
+
+$loop->run();
diff --git a/examples/localhost.pem b/examples/localhost.pem
new file mode 100644
index 00000000..be692792
--- /dev/null
+++ b/examples/localhost.pem
@@ -0,0 +1,49 @@
+-----BEGIN CERTIFICATE-----
+MIIDfTCCAmWgAwIBAgIBADANBgkqhkiG9w0BAQUFADBZMRIwEAYDVQQDDAkxMjcu
+MC4wLjExCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQK
+DBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwHhcNMTYxMjMwMTQ1OTA2WhcNMjYx
+MjI4MTQ1OTA2WjBZMRIwEAYDVQQDDAkxMjcuMC4wLjExCzAJBgNVBAYTAkFVMRMw
+EQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0
+eSBMdGQwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC8SZWNS+Ktg0Py
+W8dx5uXZ+ZUawd3wnzLMHW7EhoUpIrIdp3kDU9NezF68dOhPMJY/Kh+6btRCxWXN
+2OVTqS5Xi826j3TSE07iF83JRLeveW0PcodjUBd+RzdwCWWo2pfMJz4v7x1wu1c9
+zNi6JxxpDAXTFSB4GiWsI4tFu2XmMRhfm6LRK4WPfsZIJKokdiG5fKSPDn7nrVj0
+UUXr2eBsEAzdwL14U9+mwbLdaAkz3qK3fqi8sEC09lEWm95gKMOhkQf5qvXODtT4
+wdVrrKDTyehLv0xaItnUDnXzrkMBU5QS9TQzzqSW6ZaBsSxtONEFUiXiN9dtyXsY
+YCUE54G/AgMBAAGjUDBOMB0GA1UdDgQWBBQ2GRz3QsQzdXaTMnPVCKfpigA10DAf
+BgNVHSMEGDAWgBQ2GRz3QsQzdXaTMnPVCKfpigA10DAMBgNVHRMEBTADAQH/MA0G
+CSqGSIb3DQEBBQUAA4IBAQA77iZ4KrpPY18Ezjt0mngYAuAxunKddXYdLZ2khywN
+0uI/VzYnkFVtrsC7y2jLHSxlmE2/viPPGZDUplENV2acN6JNW+tlt7/bsrQHDQw3
+7VCF27EWiDxHsaghhLkqC+kcop5YR5c0oDQTdEWEKSbow2zayUXDYbRRs76SClTe
+824Yul+Ts8Mka+AX2PXDg47iZ84fJRN/nKavcJUTJ2iS1uYw0GNnFMge/uwsfMR3
+V47qN0X5emky8fcq99FlMCbcy0gHAeSWAjClgr2dd2i0LDatUbj7YmdmFcskOgII
+IwGfvuWR2yPevYGAE0QgFeLHniN3RW8zmpnX/XtrJ4a7
+-----END CERTIFICATE-----
+-----BEGIN PRIVATE KEY-----
+MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQC8SZWNS+Ktg0Py
+W8dx5uXZ+ZUawd3wnzLMHW7EhoUpIrIdp3kDU9NezF68dOhPMJY/Kh+6btRCxWXN
+2OVTqS5Xi826j3TSE07iF83JRLeveW0PcodjUBd+RzdwCWWo2pfMJz4v7x1wu1c9
+zNi6JxxpDAXTFSB4GiWsI4tFu2XmMRhfm6LRK4WPfsZIJKokdiG5fKSPDn7nrVj0
+UUXr2eBsEAzdwL14U9+mwbLdaAkz3qK3fqi8sEC09lEWm95gKMOhkQf5qvXODtT4
+wdVrrKDTyehLv0xaItnUDnXzrkMBU5QS9TQzzqSW6ZaBsSxtONEFUiXiN9dtyXsY
+YCUE54G/AgMBAAECggEBAKiO/3FE1CMddkCLZVtUp8ShqJgRokx9WI5ecwFApAkV
+ZHsjqDQQYRNmxhDUX/w0tOzLGyhde2xjJyZG29YviKsbHwu6zYwbeOzy/mkGOaK/
+g6DmmMmRs9Z6juifoQCu4GIFZ6il2adIL2vF7OeJh+eKudQj/7NFRSB7mXzNrQWK
+tZY3eux5zXWmio7pgZrx1HFZQiiL9NVLwT9J7oBnaoO3fREiu5J2xBpljG9Cr0j1
+LLiVLhukWJYRlHDtGt1CzI9w8iKo44PCRzpKyxpbsOrQxeSyEWUYQRv9VHA59LC7
+tVAJTbnTX1BNHkGZkOkoOpoZLwBaM2XbbDtcOGCAZMECgYEA+mTURFQ85/pxawvk
+9ndqZ+5He1u/bMLYIJDp0hdB/vgD+vw3gb2UyRwp0I6Wc6Si4FEEnbY7L0pzWsiR
+43CpLs+cyLfnD9NycuIasxs5fKb/1s1nGTkRAp7x9x/ZTtEf8v4YTmmMXFHzdo7V
+pv+czO89ppEDkxEtMf/b5SifhO8CgYEAwIDIUvXLduGhL+RPDwjc2SKdydXGV6om
+OEdt/V8oS801Z7k8l3gHXFm7zL/MpHmh9cag+F9dHK42kw2RSjDGsBlXXiAO1Z0I
+2A34OdPw/kow8fmIKWTMu3+28Kca+3RmUqeyaq0vazQ/bWMO9px+Ud3YfLo1Tn5I
+li0MecAx8DECgYEAvsLceKYYtL83c09fg2oc1ctSCCgw4WJcGAtvJ9DyRZacKbXH
+b/+H/+OF8879zmKqd+0hcCnqUzAMTCisBLPLIM+o6b45ufPkqKObpcJi/JWaKgLY
+vf2c+Psw6o4IF6T5Cz4MNIjzF06UBknxecYZpoPJ20F1kLCwVvxPgfl99l8CgYAb
+XfOcv67WTstgiJ+oroTfJamy+P5ClkDqvVTosW+EHz9ZaJ8xlXHOcj9do2LPey9I
+Rp250azmF+pQS5x9JKQKgv/FtN8HBVUtigbhCb14GUoODICMCfWFLmnumoMefnTR
+iV+3BLn6Dqp5vZxx+NuIffZ5/Or5JsDhALSGVomC8QKBgAi3Z/dNQrDHfkXMNn/L
++EAoLuAbFgLs76r9VGgNaRQ/q5gex2bZEGoBj4Sxvs95NUIcfD9wKT7FF8HdxARv
+y3o6Bfc8Xp9So9SlFXrje+gkdEJ0rQR67d+XBuJZh86bXJHVrMwpoNL+ahLGdVSe
+81oh1uCH1YPLM29hPyaohxL8
+-----END PRIVATE KEY-----
diff --git a/src/ChunkedDecoder.php b/src/ChunkedDecoder.php
new file mode 100644
index 00000000..4b6ebc4f
--- /dev/null
+++ b/src/ChunkedDecoder.php
@@ -0,0 +1,165 @@
+input = $input;
+
+ $this->input->on('data', array($this, 'handleData'));
+ $this->input->on('end', array($this, 'handleEnd'));
+ $this->input->on('error', array($this, 'handleError'));
+ $this->input->on('close', array($this, 'close'));
+ }
+
+ public function isReadable()
+ {
+ return !$this->closed && $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 close()
+ {
+ if ($this->closed) {
+ return;
+ }
+
+ $this->buffer = '';
+
+ $this->closed = true;
+
+ $this->input->close();
+
+ $this->emit('close');
+ $this->removeAllListeners();
+ }
+
+ /** @internal */
+ public function handleEnd()
+ {
+ if (!$this->closed) {
+ $this->handleError(new \Exception('Unexpected end event'));
+ }
+ }
+
+ /** @internal */
+ public function handleError(\Exception $e)
+ {
+ $this->emit('error', array($e));
+ $this->close();
+ }
+
+ /** @internal */
+ public function handleData($data)
+ {
+ $this->buffer .= $data;
+
+ while ($this->buffer !== '') {
+ if (!$this->headerCompleted) {
+ $positionCrlf = strpos($this->buffer, static::CRLF);
+
+ if ($positionCrlf === false) {
+ // Header shouldn't be bigger than 1024 bytes
+ if (isset($this->buffer[static::MAX_CHUNK_HEADER_SIZE])) {
+ $this->handleError(new \Exception('Chunk header size inclusive extension bigger than' . static::MAX_CHUNK_HEADER_SIZE. ' bytes'));
+ }
+ return;
+ }
+
+ $header = strtolower((string)substr($this->buffer, 0, $positionCrlf));
+ $hexValue = $header;
+
+ if (strpos($header, ';') !== false) {
+ $array = explode(';', $header);
+ $hexValue = $array[0];
+ }
+
+ if ($hexValue !== '') {
+ $hexValue = ltrim($hexValue, "0");
+ if ($hexValue === '') {
+ $hexValue = "0";
+ }
+ }
+
+ $this->chunkSize = hexdec($hexValue);
+ if (dechex($this->chunkSize) !== $hexValue) {
+ $this->handleError(new \Exception($hexValue . ' is not a valid hexadecimal number'));
+ return;
+ }
+
+ $this->buffer = (string)substr($this->buffer, $positionCrlf + 2);
+ $this->headerCompleted = true;
+ if ($this->buffer === '') {
+ return;
+ }
+ }
+
+ $chunk = (string)substr($this->buffer, 0, $this->chunkSize - $this->transferredSize);
+
+ if ($chunk !== '') {
+ $this->transferredSize += strlen($chunk);
+ $this->emit('data', array($chunk));
+ $this->buffer = (string)substr($this->buffer, strlen($chunk));
+ }
+
+ $positionCrlf = strpos($this->buffer, static::CRLF);
+
+ if ($positionCrlf === 0) {
+ if ($this->chunkSize === 0) {
+ $this->emit('end');
+ $this->close();
+ return;
+ }
+ $this->chunkSize = 0;
+ $this->headerCompleted = false;
+ $this->transferredSize = 0;
+ $this->buffer = (string)substr($this->buffer, 2);
+ }
+
+ if ($positionCrlf !== 0 && $this->chunkSize === $this->transferredSize && strlen($this->buffer) > 2) {
+ // the first 2 characters are not CLRF, send error event
+ $this->handleError(new \Exception('Chunk does not end with a CLRF'));
+ return;
+ }
+
+ if ($positionCrlf !== 0 && strlen($this->buffer) < 2) {
+ // No CLRF found, wait for additional data which could be a CLRF
+ return;
+ }
+ }
+ }
+}
diff --git a/src/ChunkedEncoder.php b/src/ChunkedEncoder.php
new file mode 100644
index 00000000..eaa453c8
--- /dev/null
+++ b/src/ChunkedEncoder.php
@@ -0,0 +1,105 @@
+input = $input;
+
+ $this->input->on('data', array($this, 'handleData'));
+ $this->input->on('end', array($this, 'handleEnd'));
+ $this->input->on('error', array($this, 'handleError'));
+ $this->input->on('close', array($this, 'close'));
+ }
+
+ public function isReadable()
+ {
+ return !$this->closed && $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 close()
+ {
+ if ($this->closed) {
+ return;
+ }
+
+ $this->closed = true;
+ $this->input->close();
+
+ $this->emit('close');
+ $this->removeAllListeners();
+ }
+
+ /** @internal */
+ public function handleData($data)
+ {
+ if ($data === '') {
+ return;
+ }
+
+ $completeChunk = $this->createChunk($data);
+
+ $this->emit('data', array($completeChunk));
+ }
+
+ /** @internal */
+ public function handleError(\Exception $e)
+ {
+ $this->emit('error', array($e));
+ $this->close();
+ }
+
+ /** @internal */
+ public function handleEnd()
+ {
+ $this->emit('data', array("0\r\n\r\n"));
+
+ if (!$this->closed) {
+ $this->emit('end');
+ $this->close();
+ }
+ }
+
+ /**
+ * @param string $data - string to be transformed in an valid
+ * HTTP encoded chunk string
+ * @return string
+ */
+ private function createChunk($data)
+ {
+ $byteSize = strlen($data);
+ $byteSize = dechex($byteSize);
+ $chunkBeginning = $byteSize . "\r\n";
+
+ return $chunkBeginning . $data . "\r\n";
+ }
+
+}
diff --git a/src/CloseProtectionStream.php b/src/CloseProtectionStream.php
new file mode 100644
index 00000000..da4b2625
--- /dev/null
+++ b/src/CloseProtectionStream.php
@@ -0,0 +1,101 @@
+input = $input;
+
+ $this->input->on('data', array($this, 'handleData'));
+ $this->input->on('end', array($this, 'handleEnd'));
+ $this->input->on('error', array($this, 'handleError'));
+ $this->input->on('close', array($this, 'close'));
+ }
+
+ public function isReadable()
+ {
+ return !$this->closed && $this->input->isReadable();
+ }
+
+ public function pause()
+ {
+ if ($this->closed) {
+ return;
+ }
+
+ $this->input->pause();
+ }
+
+ public function resume()
+ {
+ if ($this->closed) {
+ return;
+ }
+
+ $this->input->resume();
+ }
+
+ public function pipe(WritableStreamInterface $dest, array $options = array())
+ {
+ Util::pipe($this, $dest, $options);
+
+ return $dest;
+ }
+
+ public function close()
+ {
+ if ($this->closed) {
+ return;
+ }
+
+ $this->closed = true;
+
+ $this->emit('close');
+
+ // 'pause' the stream avoids additional traffic transferred by this stream
+ $this->input->pause();
+
+ $this->input->removeListener('data', array($this, 'handleData'));
+ $this->input->removeListener('error', array($this, 'handleError'));
+ $this->input->removeListener('end', array($this, 'handleEnd'));
+ $this->input->removeListener('close', array($this, 'close'));
+
+ $this->removeAllListeners();
+ }
+
+ /** @internal */
+ public function handleData($data)
+ {
+ $this->emit('data', array($data));
+ }
+
+ /** @internal */
+ public function handleEnd()
+ {
+ $this->emit('end');
+ $this->close();
+ }
+
+ /** @internal */
+ public function handleError(\Exception $e)
+ {
+ $this->emit('error', array($e));
+ }
+
+}
diff --git a/src/HttpBodyStream.php b/src/HttpBodyStream.php
new file mode 100644
index 00000000..8f44f4fc
--- /dev/null
+++ b/src/HttpBodyStream.php
@@ -0,0 +1,173 @@
+input = $input;
+ $this->size = $size;
+
+ $this->input->on('data', array($this, 'handleData'));
+ $this->input->on('end', array($this, 'handleEnd'));
+ $this->input->on('error', array($this, 'handleError'));
+ $this->input->on('close', array($this, 'close'));
+ }
+
+ public function isReadable()
+ {
+ return !$this->closed && $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 close()
+ {
+ if ($this->closed) {
+ return;
+ }
+
+ $this->closed = true;
+
+ $this->input->close();
+
+ $this->emit('close');
+ $this->removeAllListeners();
+ }
+
+ public function getSize()
+ {
+ return $this->size;
+ }
+
+ /** @ignore */
+ public function __toString()
+ {
+ return '';
+ }
+
+ /** @ignore */
+ public function detach()
+ {
+ return null;
+ }
+
+ /** @ignore */
+ public function tell()
+ {
+ throw new \BadMethodCallException();
+ }
+
+ /** @ignore */
+ public function eof()
+ {
+ throw new \BadMethodCallException();
+ }
+
+ /** @ignore */
+ public function isSeekable()
+ {
+ return false;
+ }
+
+ /** @ignore */
+ public function seek($offset, $whence = SEEK_SET)
+ {
+ throw new \BadMethodCallException();
+ }
+
+ /** @ignore */
+ public function rewind()
+ {
+ throw new \BadMethodCallException();
+ }
+
+ /** @ignore */
+ public function isWritable()
+ {
+ return false;
+ }
+
+ /** @ignore */
+ public function write($string)
+ {
+ throw new \BadMethodCallException();
+ }
+
+ /** @ignore */
+ public function read($length)
+ {
+ throw new \BadMethodCallException();
+ }
+
+ /** @ignore */
+ public function getContents()
+ {
+ return '';
+ }
+
+ /** @ignore */
+ public function getMetadata($key = null)
+ {
+ return null;
+ }
+
+ /** @internal */
+ public function handleData($data)
+ {
+ $this->emit('data', array($data));
+ }
+
+ /** @internal */
+ public function handleError(\Exception $e)
+ {
+ $this->emit('error', array($e));
+ $this->close();
+ }
+
+ /** @internal */
+ public function handleEnd()
+ {
+ if (!$this->closed) {
+ $this->emit('end');
+ $this->close();
+ }
+ }
+}
diff --git a/src/LengthLimitedStream.php b/src/LengthLimitedStream.php
new file mode 100644
index 00000000..225f9b0b
--- /dev/null
+++ b/src/LengthLimitedStream.php
@@ -0,0 +1,102 @@
+stream = $stream;
+ $this->maxLength = $maxLength;
+
+ $this->stream->on('data', array($this, 'handleData'));
+ $this->stream->on('end', array($this, 'handleEnd'));
+ $this->stream->on('error', array($this, 'handleError'));
+ $this->stream->on('close', array($this, 'close'));
+ }
+
+ public function isReadable()
+ {
+ return !$this->closed && $this->stream->isReadable();
+ }
+
+ public function pause()
+ {
+ $this->stream->pause();
+ }
+
+ public function resume()
+ {
+ $this->stream->resume();
+ }
+
+ public function pipe(WritableStreamInterface $dest, array $options = array())
+ {
+ Util::pipe($this, $dest, $options);
+
+ return $dest;
+ }
+
+ public function close()
+ {
+ if ($this->closed) {
+ return;
+ }
+
+ $this->closed = true;
+
+ $this->stream->close();
+
+ $this->emit('close');
+ $this->removeAllListeners();
+ }
+
+ /** @internal */
+ public function handleData($data)
+ {
+ if (($this->transferredLength + strlen($data)) > $this->maxLength) {
+ // Only emit data until the value of 'Content-Length' is reached, the rest will be ignored
+ $data = (string)substr($data, 0, $this->maxLength - $this->transferredLength);
+ }
+
+ if ($data !== '') {
+ $this->transferredLength += strlen($data);
+ $this->emit('data', array($data));
+ }
+
+ if ($this->transferredLength === $this->maxLength) {
+ // 'Content-Length' reached, stream will end
+ $this->emit('end');
+ $this->close();
+ $this->stream->removeListener('data', array($this, 'handleData'));
+ }
+ }
+
+ /** @internal */
+ public function handleError(\Exception $e)
+ {
+ $this->emit('error', array($e));
+ $this->close();
+ }
+
+ /** @internal */
+ public function handleEnd()
+ {
+ if (!$this->closed) {
+ $this->handleError(new \Exception('Unexpected end event'));
+ }
+ }
+
+}
diff --git a/src/Request.php b/src/Request.php
deleted file mode 100644
index 605b909e..00000000
--- a/src/Request.php
+++ /dev/null
@@ -1,89 +0,0 @@
-method = $method;
- $this->path = $path;
- $this->query = $query;
- $this->httpVersion = $httpVersion;
- $this->headers = $headers;
- }
-
- public function getMethod()
- {
- return $this->method;
- }
-
- public function getPath()
- {
- return $this->path;
- }
-
- public function getQuery()
- {
- return $this->query;
- }
-
- public function getHttpVersion()
- {
- return $this->httpVersion;
- }
-
- public function getHeaders()
- {
- return $this->headers;
- }
-
- public function expectsContinue()
- {
- return isset($this->headers['Expect']) && '100-continue' === $this->headers['Expect'];
- }
-
- public function isReadable()
- {
- return $this->readable;
- }
-
- public function pause()
- {
- $this->emit('pause');
- }
-
- public function resume()
- {
- $this->emit('resume');
- }
-
- public function close()
- {
- $this->readable = false;
- $this->emit('end');
- $this->removeAllListeners();
- }
-
- public function pipe(WritableStreamInterface $dest, array $options = array())
- {
- Util::pipe($this, $dest, $options);
-
- return $dest;
- }
-}
diff --git a/src/RequestHeaderParser.php b/src/RequestHeaderParser.php
index 4e4db46f..02d67e32 100644
--- a/src/RequestHeaderParser.php
+++ b/src/RequestHeaderParser.php
@@ -4,71 +4,222 @@
use Evenement\EventEmitter;
use Exception;
-use GuzzleHttp\Psr7 as g7;
+use RingCentral\Psr7 as g7;
/**
* @event headers
* @event error
+ *
+ * @internal
*/
class RequestHeaderParser extends EventEmitter
{
private $buffer = '';
private $maxSize = 4096;
+ private $localSocketUri;
+ private $remoteSocketUri;
+
+ public function __construct($localSocketUri = null, $remoteSocketUri = null)
+ {
+ $this->localSocketUri = $localSocketUri;
+ $this->remoteSocketUri = $remoteSocketUri;
+ }
+
public function feed($data)
{
- if (strlen($this->buffer) + strlen($data) > $this->maxSize) {
- $this->emit('error', array(new \OverflowException("Maximum header size of {$this->maxSize} exceeded."), $this));
+ $this->buffer .= $data;
- return;
+ $endOfHeader = strpos($this->buffer, "\r\n\r\n");
+
+ if (false !== $endOfHeader) {
+ $currentHeaderSize = $endOfHeader;
+ } else {
+ $currentHeaderSize = strlen($this->buffer);
}
- $this->buffer .= $data;
+ if ($currentHeaderSize > $this->maxSize) {
+ $this->emit('error', array(new \OverflowException("Maximum header size of {$this->maxSize} exceeded.", 431), $this));
+ $this->removeAllListeners();
+ return;
+ }
- if (false !== strpos($this->buffer, "\r\n\r\n")) {
+ if (false !== $endOfHeader) {
try {
$this->parseAndEmitRequest();
} catch (Exception $exception) {
- $this->emit('error', [$exception]);
+ $this->emit('error', array($exception));
}
-
$this->removeAllListeners();
}
}
- protected function parseAndEmitRequest()
+ private function parseAndEmitRequest()
{
list($request, $bodyBuffer) = $this->parseRequest($this->buffer);
$this->emit('headers', array($request, $bodyBuffer));
}
- public function parseRequest($data)
+ private function parseRequest($data)
{
list($headers, $bodyBuffer) = explode("\r\n\r\n", $data, 2);
- $psrRequest = g7\parse_request($headers);
+ // parser does not support asterisk-form and authority-form
+ // remember original target and temporarily replace and re-apply below
+ $originalTarget = null;
+ if (strpos($headers, 'OPTIONS * ') === 0) {
+ $originalTarget = '*';
+ $headers = 'OPTIONS / ' . substr($headers, 10);
+ } elseif (strpos($headers, 'CONNECT ') === 0) {
+ $parts = explode(' ', $headers, 3);
+ $uri = parse_url('tcp://' . $parts[1]);
+
+ // check this is a valid authority-form request-target (host:port)
+ if (isset($uri['scheme'], $uri['host'], $uri['port']) && count($uri) === 3) {
+ $originalTarget = $parts[1];
+ $parts[1] = '/';
+ $headers = implode(' ', $parts);
+ } else {
+ throw new \InvalidArgumentException('CONNECT method MUST use authority-form request target');
+ }
+ }
+
+ // parse request headers into obj implementing RequestInterface
+ $request = g7\parse_request($headers);
+
+ // create new obj implementing ServerRequestInterface by preserving all
+ // previous properties and restoring original request-target
+ $serverParams = array(
+ 'REQUEST_TIME' => time(),
+ 'REQUEST_TIME_FLOAT' => microtime(true)
+ );
- $parsedQuery = [];
- $queryString = $psrRequest->getUri()->getQuery();
- if ($queryString) {
- parse_str($queryString, $parsedQuery);
+ if ($this->remoteSocketUri !== null) {
+ $remoteAddress = parse_url($this->remoteSocketUri);
+ $serverParams['REMOTE_ADDR'] = $remoteAddress['host'];
+ $serverParams['REMOTE_PORT'] = $remoteAddress['port'];
}
- $headers = array_map(function($val) {
- if (1 === count($val)) {
- $val = $val[0];
+ if ($this->localSocketUri !== null) {
+ $localAddress = parse_url($this->localSocketUri);
+ $serverParams['SERVER_ADDR'] = $localAddress['host'];
+ $serverParams['SERVER_PORT'] = $localAddress['port'];
+ if (isset($localAddress['scheme']) && $localAddress['scheme'] === 'https') {
+ $serverParams['HTTPS'] = 'on';
}
+ }
- return $val;
- }, $psrRequest->getHeaders());
-
- $request = new Request(
- $psrRequest->getMethod(),
- $psrRequest->getUri()->getPath(),
- $parsedQuery,
- $psrRequest->getProtocolVersion(),
- $headers
+ $target = $request->getRequestTarget();
+ $request = new ServerRequest(
+ $request->getMethod(),
+ $request->getUri(),
+ $request->getHeaders(),
+ $request->getBody(),
+ $request->getProtocolVersion(),
+ $serverParams
);
+ $request = $request->withRequestTarget($target);
+
+ // Add query params
+ $queryString = $request->getUri()->getQuery();
+ if ($queryString !== '') {
+ $queryParams = array();
+ parse_str($queryString, $queryParams);
+ $request = $request->withQueryParams($queryParams);
+ }
+
+ $cookies = ServerRequest::parseCookie($request->getHeaderLine('Cookie'));
+ if ($cookies !== false) {
+ $request = $request->withCookieParams($cookies);
+ }
+
+ // re-apply actual request target from above
+ if ($originalTarget !== null) {
+ $uri = $request->getUri()->withPath('');
+
+ // re-apply host and port from request-target if given
+ $parts = parse_url('tcp://' . $originalTarget);
+ if (isset($parts['host'], $parts['port'])) {
+ $uri = $uri->withHost($parts['host'])->withPort($parts['port']);
+ }
+
+ $request = $request->withUri(
+ $uri,
+ true
+ )->withRequestTarget($originalTarget);
+ }
+
+ // only support HTTP/1.1 and HTTP/1.0 requests
+ if ($request->getProtocolVersion() !== '1.1' && $request->getProtocolVersion() !== '1.0') {
+ throw new \InvalidArgumentException('Received request with invalid protocol version', 505);
+ }
+
+ // ensure absolute-form request-target contains a valid URI
+ if (strpos($request->getRequestTarget(), '://') !== false) {
+ $parts = parse_url($request->getRequestTarget());
+
+ // make sure value contains valid host component (IP or hostname), but no fragment
+ if (!isset($parts['scheme'], $parts['host']) || $parts['scheme'] !== 'http' || isset($parts['fragment'])) {
+ throw new \InvalidArgumentException('Invalid absolute-form request-target');
+ }
+ }
+
+ // Optional Host header value MUST be valid (host and optional port)
+ if ($request->hasHeader('Host')) {
+ $parts = parse_url('http://' . $request->getHeaderLine('Host'));
+
+ // make sure value contains valid host component (IP or hostname)
+ if (!$parts || !isset($parts['scheme'], $parts['host'])) {
+ $parts = false;
+ }
+
+ // make sure value does not contain any other URI component
+ unset($parts['scheme'], $parts['host'], $parts['port']);
+ if ($parts === false || $parts) {
+ throw new \InvalidArgumentException('Invalid Host header value');
+ }
+ }
+
+ // set URI components from socket address if not already filled via Host header
+ if ($request->getUri()->getHost() === '') {
+ $parts = parse_url($this->localSocketUri);
+ if (!isset($parts['host'], $parts['port'])) {
+ $parts = array('host' => '127.0.0.1', 'port' => 80);
+ }
+
+ $request = $request->withUri(
+ $request->getUri()->withScheme('http')->withHost($parts['host'])->withPort($parts['port']),
+ true
+ );
+ }
+
+ // Do not assume this is HTTPS when this happens to be port 443
+ // detecting HTTPS is left up to the socket layer (TLS detection)
+ if ($request->getUri()->getScheme() === 'https') {
+ $request = $request->withUri(
+ $request->getUri()->withScheme('http')->withPort(443),
+ true
+ );
+ }
+
+ // Update request URI to "https" scheme if the connection is encrypted
+ $parts = parse_url($this->localSocketUri);
+ if (isset($parts['scheme']) && $parts['scheme'] === 'https') {
+ // The request URI may omit default ports here, so try to parse port
+ // from Host header field (if possible)
+ $port = $request->getUri()->getPort();
+ if ($port === null) {
+ $port = parse_url('tcp://' . $request->getHeaderLine('Host'), PHP_URL_PORT); // @codeCoverageIgnore
+ }
+
+ $request = $request->withUri(
+ $request->getUri()->withScheme('https')->withPort($port),
+ true
+ );
+ }
+
+ // always sanitize Host header because it contains critical routing information
+ $request = $request->withUri($request->getUri()->withUserInfo('u')->withUserInfo(''));
return array($request, $bodyBuffer);
}
diff --git a/src/Response.php b/src/Response.php
index 36e375fc..49aadf85 100644
--- a/src/Response.php
+++ b/src/Response.php
@@ -2,137 +2,35 @@
namespace React\Http;
-use Evenement\EventEmitter;
-use React\Socket\ConnectionInterface;
-use React\Stream\WritableStreamInterface;
-
-class Response extends EventEmitter implements WritableStreamInterface
+use RingCentral\Psr7\Response as Psr7Response;
+use React\Stream\ReadableStreamInterface;
+use React\Http\HttpBodyStream;
+
+/**
+ * Implementation of the PSR-7 ResponseInterface
+ * This class is an extension of RingCentral\Psr7\Response.
+ * The only difference is that this class will accept implemenations
+ * of the ReactPHPs ReadableStreamInterface for $body.
+ */
+class Response extends Psr7Response
{
- private $closed = false;
- private $writable = true;
- private $conn;
- private $headWritten = false;
- private $chunkedEncoding = true;
-
- public function __construct(ConnectionInterface $conn)
- {
- $this->conn = $conn;
-
- $this->conn->on('end', function () {
- $this->close();
- });
-
- $this->conn->on('error', function ($error) {
- $this->emit('error', array($error, $this));
- $this->close();
- });
-
- $this->conn->on('drain', function () {
- $this->emit('drain');
- });
- }
-
- public function isWritable()
- {
- return $this->writable;
- }
-
- public function writeContinue()
- {
- if ($this->headWritten) {
- throw new \Exception('Response head has already been written.');
- }
-
- $this->conn->write("HTTP/1.1 100 Continue\r\n\r\n");
- }
-
- public function writeHead($status = 200, array $headers = array())
- {
- if ($this->headWritten) {
- throw new \Exception('Response head has already been written.');
- }
-
- if (isset($headers['Content-Length'])) {
- $this->chunkedEncoding = false;
- }
-
- $headers = array_merge(
- array('X-Powered-By' => 'React/alpha'),
- $headers
+ public function __construct(
+ $status = 200,
+ array $headers = array(),
+ $body = null,
+ $version = '1.1',
+ $reason = null
+ ) {
+ if ($body instanceof ReadableStreamInterface) {
+ $body = new HttpBodyStream($body, null);
+ }
+
+ parent::__construct(
+ $status,
+ $headers,
+ $body,
+ $version,
+ $reason
);
- if ($this->chunkedEncoding) {
- $headers['Transfer-Encoding'] = 'chunked';
- }
-
- $data = $this->formatHead($status, $headers);
- $this->conn->write($data);
-
- $this->headWritten = true;
- }
-
- private function formatHead($status, array $headers)
- {
- $status = (int) $status;
- $text = isset(ResponseCodes::$statusTexts[$status]) ? ResponseCodes::$statusTexts[$status] : '';
- $data = "HTTP/1.1 $status $text\r\n";
-
- foreach ($headers as $name => $value) {
- $name = str_replace(array("\r", "\n"), '', $name);
-
- foreach ((array) $value as $val) {
- $val = str_replace(array("\r", "\n"), '', $val);
-
- $data .= "$name: $val\r\n";
- }
- }
- $data .= "\r\n";
-
- return $data;
- }
-
- public function write($data)
- {
- if (!$this->headWritten) {
- throw new \Exception('Response head has not yet been written.');
- }
-
- if ($this->chunkedEncoding) {
- $len = strlen($data);
- $chunk = dechex($len)."\r\n".$data."\r\n";
- $flushed = $this->conn->write($chunk);
- } else {
- $flushed = $this->conn->write($data);
- }
-
- return $flushed;
- }
-
- public function end($data = null)
- {
- if (null !== $data) {
- $this->write($data);
- }
-
- if ($this->chunkedEncoding) {
- $this->conn->write("0\r\n\r\n");
- }
-
- $this->emit('end');
- $this->removeAllListeners();
- $this->conn->end();
- }
-
- public function close()
- {
- if ($this->closed) {
- return;
- }
-
- $this->closed = true;
-
- $this->writable = false;
- $this->emit('close');
- $this->removeAllListeners();
- $this->conn->close();
}
}
diff --git a/src/ResponseCodes.php b/src/ResponseCodes.php
index ae241ded..27b29435 100644
--- a/src/ResponseCodes.php
+++ b/src/ResponseCodes.php
@@ -4,6 +4,8 @@
/**
* This is copy-pasted from Symfony2's Response class
+ *
+ * @internal
*/
class ResponseCodes
{
diff --git a/src/Server.php b/src/Server.php
index e1abfd8e..e45de1dc 100644
--- a/src/Server.php
+++ b/src/Server.php
@@ -5,59 +5,189 @@
use Evenement\EventEmitter;
use React\Socket\ServerInterface as SocketServerInterface;
use React\Socket\ConnectionInterface;
+use Psr\Http\Message\RequestInterface;
-/** @event request */
-class Server extends EventEmitter implements ServerInterface
+/**
+ * The `Server` class is responsible for handling incoming connections and then
+ * emit a `request` event for each incoming HTTP request.
+ *
+ * ```php
+ * $socket = new React\Socket\Server(8080, $loop);
+ *
+ * $http = new React\Http\Server($socket);
+ * ```
+ *
+ * For each incoming connection, it emits a `request` event with the respective
+ * [`Request`](#request) and [`Response`](#response) objects:
+ *
+ * ```php
+ * $http->on('request', function (Request $request, Response $response) {
+ * $response->writeHead(200, array('Content-Type' => 'text/plain'));
+ * $response->end("Hello World!\n");
+ * });
+ * ```
+ *
+ * When HTTP/1.1 clients want to send a bigger request body, they MAY send only
+ * the request headers with an additional `Expect: 100-continue` header and
+ * wait before sending the actual (large) message body.
+ * In this case the server will automatically send an intermediary
+ * `HTTP/1.1 100 Continue` response to the client.
+ * This ensures you will receive the request body without a delay as expected.
+ * The [Response](#response) still needs to be created as described in the
+ * examples above.
+ *
+ * See also [`Request`](#request) and [`Response`](#response) for more details.
+ *
+ * > Note that you SHOULD always listen for the `request` event.
+ * Failing to do so will result in the server parsing the incoming request,
+ * but never sending a response back to the client.
+ *
+ * The `Server` supports both HTTP/1.1 and HTTP/1.0 request messages.
+ * If a client sends an invalid request message or uses an invalid HTTP protocol
+ * version, it will emit an `error` event, send an HTTP error response to the
+ * client and close the connection:
+ *
+ * ```php
+ * $http->on('error', function (Exception $e) {
+ * echo 'Error: ' . $e->getMessage() . PHP_EOL;
+ * });
+ * ```
+ * The request object can also emit an error. Checkout [Request](#request)
+ * for more details.
+ *
+ * @see Request
+ * @see Response
+ */
+class Server extends EventEmitter
{
- private $io;
-
+ /**
+ * Creates a HTTP server that accepts connections from the given socket.
+ *
+ * It attaches itself to an instance of `React\Socket\ServerInterface` which
+ * emits underlying streaming connections in order to then parse incoming data
+ * as HTTP:
+ *
+ * ```php
+ * $socket = new React\Socket\Server(8080, $loop);
+ *
+ * $http = new React\Http\Server($socket);
+ * ```
+ *
+ * Similarly, you can also attach this to a
+ * [`React\Socket\SecureServer`](https://github.com/reactphp/socket#secureserver)
+ * in order to start a secure HTTPS server like this:
+ *
+ * ```php
+ * $socket = new Server(8080, $loop);
+ * $socket = new SecureServer($socket, $loop, array(
+ * 'local_cert' => __DIR__ . '/localhost.pem'
+ * ));
+ *
+ * $http = new React\Http\Server($socket);
+ * ```
+ *
+ * @param \React\Socket\ServerInterface $io
+ */
public function __construct(SocketServerInterface $io)
{
- $this->io = $io;
-
- $this->io->on('connection', function (ConnectionInterface $conn) {
- // TODO: http 1.1 keep-alive
- // TODO: chunked transfer encoding (also for outgoing data)
- // TODO: multipart parsing
-
- $parser = new RequestHeaderParser();
- $parser->on('headers', function (Request $request, $bodyBuffer) use ($conn, $parser) {
- // attach remote ip to the request as metadata
- $request->remoteAddress = $conn->getRemoteAddress();
-
- $this->handleRequest($conn, $request, $bodyBuffer);
-
- $conn->removeListener('data', array($parser, 'feed'));
- $conn->on('end', function () use ($request) {
- $request->emit('end');
- });
- $conn->on('data', function ($data) use ($request) {
- $request->emit('data', array($data));
- });
- $request->on('pause', function () use ($conn) {
- $conn->emit('pause');
- });
- $request->on('resume', function () use ($conn) {
- $conn->emit('resume');
- });
- });
-
- $conn->on('data', array($parser, 'feed'));
+ $io->on('connection', array($this, 'handleConnection'));
+ }
+
+ /** @internal */
+ public function handleConnection(ConnectionInterface $conn)
+ {
+ $that = $this;
+ $parser = new RequestHeaderParser();
+ $listener = array($parser, 'feed');
+ $parser->on('headers', function (RequestInterface $request, $bodyBuffer) use ($conn, $listener, $parser, $that) {
+ // parsing request completed => stop feeding parser
+ $conn->removeListener('data', $listener);
+
+ $that->handleRequest($conn, $request);
+
+ if ($bodyBuffer !== '') {
+ $conn->emit('data', array($bodyBuffer));
+ }
+ });
+
+ $conn->on('data', $listener);
+ $parser->on('error', function(\Exception $e) use ($conn, $listener, $that) {
+ $conn->removeListener('data', $listener);
+ $that->emit('error', array($e));
+
+ $that->writeError(
+ $conn,
+ ($e instanceof \OverflowException) ? 431 : 400
+ );
});
}
- public function handleRequest(ConnectionInterface $conn, Request $request, $bodyBuffer)
+ /** @internal */
+ public function handleRequest(ConnectionInterface $conn, RequestInterface $request)
{
- $response = new Response($conn);
- $response->on('close', array($request, 'close'));
+ // only support HTTP/1.1 and HTTP/1.0 requests
+ if ($request->getProtocolVersion() !== '1.1' && $request->getProtocolVersion() !== '1.0') {
+ $this->emit('error', array(new \InvalidArgumentException('Received request with invalid protocol version')));
+ return $this->writeError($conn, 505);
+ }
+
+ // HTTP/1.1 requests MUST include a valid host header (host and optional port)
+ // https://tools.ietf.org/html/rfc7230#section-5.4
+ if ($request->getProtocolVersion() === '1.1') {
+ $parts = parse_url('http://' . $request->getHeaderLine('Host'));
- if (!$this->listeners('request')) {
- $response->end();
+ // make sure value contains valid host component (IP or hostname)
+ if (!$parts || !isset($parts['scheme'], $parts['host'])) {
+ $parts = false;
+ }
- return;
+ // make sure value does not contain any other URI component
+ unset($parts['scheme'], $parts['host'], $parts['port']);
+ if ($parts === false || $parts) {
+ $this->emit('error', array(new \InvalidArgumentException('Invalid Host header for HTTP/1.1 request')));
+ return $this->writeError($conn, 400);
+ }
}
+ $response = new Response($conn, $request->getProtocolVersion());
+
+ $stream = $conn;
+ if ($request->hasHeader('Transfer-Encoding')) {
+ $transferEncodingHeader = $request->getHeader('Transfer-Encoding');
+ // 'chunked' must always be the final value of 'Transfer-Encoding' according to: https://tools.ietf.org/html/rfc7230#section-3.3.1
+ if (strtolower(end($transferEncodingHeader)) === 'chunked') {
+ $stream = new ChunkedDecoder($conn);
+ }
+ }
+
+ $request = $request->withBody(new HttpBodyStream($stream, $contentLength));
+
+ if ($request->getProtocolVersion() !== '1.0' && '100-continue' === strtolower($request->getHeaderLine('Expect'))) {
+ $conn->write("HTTP/1.1 100 Continue\r\n\r\n");
+ }
+
+ // attach remote ip to the request as metadata
+ $request->remoteAddress = trim(
+ parse_url('tcp://' . $conn->getRemoteAddress(), PHP_URL_HOST),
+ '[]'
+ );
+
$this->emit('request', array($request, $response));
- $request->emit('data', array($bodyBuffer));
+ }
+
+ /** @internal */
+ public function writeError(ConnectionInterface $conn, $code)
+ {
+ $message = 'Error ' . $code;
+ if (isset(ResponseCodes::$statusTexts[$code])) {
+ $message .= ': ' . ResponseCodes::$statusTexts[$code];
+ }
+
+ $response = new Response($conn);
+ $response->writeHead($code, array(
+ 'Content-Length' => strlen($message),
+ 'Content-Type' => 'text/plain'
+ ));
+ $response->end($message);
}
}
diff --git a/src/ServerInterface.php b/src/ServerInterface.php
deleted file mode 100644
index 56dd61fe..00000000
--- a/src/ServerInterface.php
+++ /dev/null
@@ -1,9 +0,0 @@
-serverParams = $serverParams;
+ parent::__construct($method, $uri, $headers, $body, $protocolVersion);
+ }
+
+ public function getServerParams()
+ {
+ return $this->serverParams;
+ }
+
+ public function getCookieParams()
+ {
+ return $this->cookies;
+ }
+
+ public function withCookieParams(array $cookies)
+ {
+ $new = clone $this;
+ $new->cookies = $cookies;
+ return $new;
+ }
+
+ public function getQueryParams()
+ {
+ return $this->queryParams;
+ }
+
+ public function withQueryParams(array $query)
+ {
+ $new = clone $this;
+ $new->queryParams = $query;
+ return $new;
+ }
+
+ public function getUploadedFiles()
+ {
+ return $this->fileParams;
+ }
+
+ public function withUploadedFiles(array $uploadedFiles)
+ {
+ $new = clone $this;
+ $new->fileParams = $uploadedFiles;
+ return $new;
+ }
+
+ public function getParsedBody()
+ {
+ return $this->parsedBody;
+ }
+
+ public function withParsedBody($data)
+ {
+ $new = clone $this;
+ $new->parsedBody = $data;
+ return $new;
+ }
+
+ public function getAttributes()
+ {
+ return $this->attributes;
+ }
+
+ public function getAttribute($name, $default = null)
+ {
+ if (!array_key_exists($name, $this->attributes)) {
+ return $default;
+ }
+ return $this->attributes[$name];
+ }
+
+ public function withAttribute($name, $value)
+ {
+ $new = clone $this;
+ $new->attributes[$name] = $value;
+ return $new;
+ }
+
+ public function withoutAttribute($name)
+ {
+ $new = clone $this;
+ unset($new->attributes[$name]);
+ return $new;
+ }
+
+ /**
+ * @internal
+ * @param string $cookie
+ * @return boolean|mixed[]
+ */
+ public static function parseCookie($cookie)
+ {
+ // PSR-7 `getHeaderLine('Cookies')` will return multiple
+ // cookie header comma-seperated. Multiple cookie headers
+ // are not allowed according to https://tools.ietf.org/html/rfc6265#section-5.4
+ if (strpos($cookie, ',') !== false) {
+ return false;
+ }
+
+ $cookieArray = explode(';', $cookie);
+ $result = array();
+
+ foreach ($cookieArray as $pair) {
+ $pair = trim($pair);
+ $nameValuePair = explode('=', $pair, 2);
+
+ if (count($nameValuePair) === 2) {
+ $key = urldecode($nameValuePair[0]);
+ $value = urldecode($nameValuePair[1]);
+ $result[$key] = $value;
+ }
+ }
+
+ return $result;
+ }
+}
diff --git a/src/StreamingBodyParser/MultipartParser.php b/src/StreamingBodyParser/MultipartParser.php
new file mode 100644
index 00000000..1080428e
--- /dev/null
+++ b/src/StreamingBodyParser/MultipartParser.php
@@ -0,0 +1,307 @@
+onDataCallable = [$this, 'onData'];
+ $this->promise = (new Deferred(function () {
+ $this->body->removeListener('data', $this->onDataCallable);
+ $this->body->close();
+ }))->promise();
+ $this->request = $request;
+ $this->body = $this->request->getBody();
+
+ $dataMethod = $this->determineOnDataMethod();
+ $this->setOnDataListener([$this, $dataMethod]);
+ }
+
+ protected function determineOnDataMethod()
+ {
+ if (!$this->request->hasHeader('content-type')) {
+ return 'findBoundary';
+ }
+
+ $contentType = $this->request->getHeaderLine('content-type');
+ preg_match('/boundary="?(.*)"?$/', $contentType, $matches);
+ if (isset($matches[1])) {
+ $this->setBoundary($matches[1]);
+ return 'onData';
+ }
+
+ return 'findBoundary';
+ }
+
+ protected function setBoundary($boundary)
+ {
+ $this->boundary = $boundary;
+ $this->ending = $this->boundary . "--\r\n";
+ $this->endingSize = strlen($this->ending);
+ }
+
+ public function findBoundary($data)
+ {
+ $this->buffer .= $data;
+
+ if (substr($this->buffer, 0, 3) === '---' && strpos($this->buffer, "\r\n") !== false) {
+ $boundary = substr($this->buffer, 2, strpos($this->buffer, "\r\n"));
+ $boundary = substr($boundary, 0, -2);
+ $this->setBoundary($boundary);
+ $this->setOnDataListener([$this, 'onData']);
+ }
+ }
+
+ public function onData($data)
+ {
+ $this->buffer .= $data;
+ $ending = strpos($this->buffer, $this->ending) == strlen($this->buffer) - $this->endingSize;
+
+ if (
+ strrpos($this->buffer, $this->boundary) < strrpos($this->buffer, "\r\n\r\n") || $ending
+ ) {
+ $this->parseBuffer();
+ }
+
+ if ($ending) {
+ $this->emit('end');
+ }
+ }
+
+ protected function parseBuffer()
+ {
+ $chunks = preg_split('/-+' . $this->boundary . '/', $this->buffer);
+ $this->buffer = array_pop($chunks);
+ foreach ($chunks as $chunk) {
+ $this->parseChunk(ltrim($chunk));
+ }
+
+ $split = explode("\r\n\r\n", $this->buffer);
+ if (count($split) <= 1) {
+ return;
+ }
+
+ $chunks = preg_split('/-+' . $this->boundary . '/', trim($split[0]), -1, PREG_SPLIT_NO_EMPTY);
+ $headers = $this->parseHeaders(trim($chunks[0]));
+ if (isset($headers['content-disposition']) && $this->headerStartsWith($headers['content-disposition'], 'filename')) {
+ $this->parseFile($headers, $split[1]);
+ $this->buffer = '';
+ }
+ }
+
+ protected function parseChunk($chunk)
+ {
+ if ($chunk == '') {
+ return;
+ }
+
+ list ($header, $body) = explode("\r\n\r\n", $chunk);
+ $headers = $this->parseHeaders($header);
+
+ if (!isset($headers['content-disposition'])) {
+ return;
+ }
+
+ if ($this->headerStartsWith($headers['content-disposition'], 'filename')) {
+ $this->parseFile($headers, $body, false);
+ return;
+ }
+
+ if ($this->headerStartsWith($headers['content-disposition'], 'name')) {
+ $this->parsePost($headers, $body);
+ return;
+ }
+ }
+
+ protected function parseFile($headers, $body, $streaming = true)
+ {
+ if (
+ !$this->headerContains($headers['content-disposition'], 'name=') ||
+ !$this->headerContains($headers['content-disposition'], 'filename=')
+ ) {
+ return;
+ }
+
+ $stream = new ThroughStream();
+ $this->emit('file', [
+ $this->getFieldFromHeader($headers['content-disposition'], 'name'),
+ new File(
+ $this->getFieldFromHeader($headers['content-disposition'], 'filename'),
+ $headers['content-type'][0],
+ $stream
+ ),
+ $headers,
+ ]);
+
+ if (!$streaming) {
+ $stream->end($body);
+ return;
+ }
+
+ $this->setOnDataListener($this->chunkStreamFunc($stream));
+ $stream->write($body);
+ }
+
+ protected function chunkStreamFunc(ThroughStream $stream)
+ {
+ $buffer = '';
+ $func = function($data) use (&$func, &$buffer, $stream) {
+ $buffer .= $data;
+ if (strpos($buffer, $this->boundary) !== false) {
+ $chunks = preg_split('/-+' . $this->boundary . '/', $buffer);
+ $chunk = array_shift($chunks);
+ $stream->end($chunk);
+
+ $this->setOnDataListener([$this, 'onData']);
+
+ if (count($chunks) == 1) {
+ array_unshift($chunks, '');
+ }
+
+ $this->onData(implode('-' . $this->boundary, $chunks));
+ return;
+ }
+
+ if (strlen($buffer) >= strlen($this->boundary) * 3) {
+ $stream->write($buffer);
+ $buffer = '';
+ }
+ };
+ return $func;
+ }
+
+ protected function parsePost($headers, $body)
+ {
+ foreach ($headers['content-disposition'] as $part) {
+ if (strpos($part, 'name') === 0) {
+ preg_match('/name="?(.*)"$/', $part, $matches);
+ $this->emit('post', [
+ $matches[1],
+ trim($body),
+ $headers,
+ ]);
+ }
+ }
+ }
+
+ protected function parseHeaders($header)
+ {
+ $headers = [];
+
+ foreach (explode("\r\n", trim($header)) as $line) {
+ list($key, $values) = explode(':', $line, 2);
+ $key = trim($key);
+ $key = strtolower($key);
+ $values = explode(';', $values);
+ $values = array_map('trim', $values);
+ $headers[$key] = $values;
+ }
+
+ return $headers;
+ }
+
+ protected function headerStartsWith(array $header, $needle)
+ {
+ foreach ($header as $part) {
+ if (strpos($part, $needle) === 0) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ protected function headerContains(array $header, $needle)
+ {
+ foreach ($header as $part) {
+ if (strpos($part, $needle) !== false) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ protected function getFieldFromHeader(array $header, $field)
+ {
+ foreach ($header as $part) {
+ if (strpos($part, $field) === 0) {
+ preg_match('/' . $field . '="?(.*)"$/', $part, $matches);
+ return $matches[1];
+ }
+ }
+
+ return '';
+ }
+
+ protected function setOnDataListener(callable $callable)
+ {
+ $this->body->removeListener('data', $this->onDataCallable);
+ $this->onDataCallable = $callable;
+ $this->body->on('data', $this->onDataCallable);
+ }
+
+ public function cancel()
+ {
+ $this->promise->cancel();
+ }
+}
diff --git a/tests/ChunkedDecoderTest.php b/tests/ChunkedDecoderTest.php
new file mode 100644
index 00000000..87548f79
--- /dev/null
+++ b/tests/ChunkedDecoderTest.php
@@ -0,0 +1,482 @@
+input = new ThroughStream();
+ $this->parser = new ChunkedDecoder($this->input);
+ }
+
+ public function testSimpleChunk()
+ {
+ $this->parser->on('data', $this->expectCallableOnceWith('hello'));
+ $this->parser->on('end', $this->expectCallableNever());
+ $this->parser->on('close', $this->expectCallableNever());
+
+ $this->input->emit('data', array("5\r\nhello\r\n"));
+ }
+
+ public function testTwoChunks()
+ {
+ $this->parser->on('data', $this->expectCallableConsecutive(2, array('hello', 'bla')));
+ $this->parser->on('end', $this->expectCallableNever());
+ $this->parser->on('close', $this->expectCallableNever());
+
+ $this->input->emit('data', array("5\r\nhello\r\n3\r\nbla\r\n"));
+ }
+
+ public function testEnd()
+ {
+ $this->parser->on('end', $this->expectCallableOnce());
+ $this->parser->on('close', $this->expectCallableOnce());
+ $this->parser->on('error', $this->expectCallableNever());
+
+ $this->input->emit('data', array("0\r\n\r\n"));
+ }
+
+ public function testParameterWithEnd()
+ {
+ $this->parser->on('data', $this->expectCallableConsecutive(2, array('hello', 'bla')));
+ $this->parser->on('end', $this->expectCallableOnce());
+ $this->parser->on('close', $this->expectCallableOnce());
+ $this->parser->on('error', $this->expectCallableNever());
+
+ $this->input->emit('data', array("5\r\nhello\r\n3\r\nbla\r\n0\r\n\r\n"));
+ }
+
+ public function testInvalidChunk()
+ {
+ $this->parser->on('data', $this->expectCallableNever());
+ $this->parser->on('end', $this->expectCallableNever());
+ $this->parser->on('close', $this->expectCallableOnce());
+ $this->parser->on('error', $this->expectCallableOnce());
+
+ $this->input->emit('data', array("bla\r\n"));
+ }
+
+ public function testNeverEnd()
+ {
+ $this->parser->on('end', $this->expectCallableNever());
+ $this->parser->on('close', $this->expectCallableNever());
+ $this->parser->on('error', $this->expectCallableNever());
+
+ $this->input->emit('data', array("0\r\n"));
+ }
+
+ public function testWrongChunkHex()
+ {
+ $this->parser->on('error', $this->expectCallableOnce());
+ $this->parser->on('close', $this->expectCallableOnce());
+ $this->parser->on('end', $this->expectCallableNever());
+
+ $this->input->emit('data', array("2\r\na\r\n5\r\nhello\r\n"));
+ }
+
+ public function testSplittedChunk()
+ {
+ $this->parser->on('data', $this->expectCallableOnceWith('welt'));
+ $this->parser->on('close', $this->expectCallableNever());
+ $this->parser->on('end', $this->expectCallableNever());
+ $this->parser->on('error', $this->expectCallableNever());
+
+ $this->input->emit('data', array("4\r\n"));
+ $this->input->emit('data', array("welt\r\n"));
+ }
+
+ public function testSplittedHeader()
+ {
+ $this->parser->on('data', $this->expectCallableOnceWith('welt'));
+ $this->parser->on('close', $this->expectCallableNever());
+ $this->parser->on('end', $this->expectCallableNever());#
+ $this->parser->on('error', $this->expectCallableNever());
+
+
+ $this->input->emit('data', array("4"));
+ $this->input->emit('data', array("\r\nwelt\r\n"));
+ }
+
+ public function testSplittedBoth()
+ {
+ $this->parser->on('data', $this->expectCallableOnceWith('welt'));
+ $this->parser->on('close', $this->expectCallableNever());
+ $this->parser->on('end', $this->expectCallableNever());
+ $this->parser->on('error', $this->expectCallableNever());
+
+ $this->input->emit('data', array("4"));
+ $this->input->emit('data', array("\r\n"));
+ $this->input->emit('data', array("welt\r\n"));
+ }
+
+ public function testCompletlySplitted()
+ {
+ $this->parser->on('data', $this->expectCallableConsecutive(2, array('we', 'lt')));
+ $this->parser->on('close', $this->expectCallableNever());
+ $this->parser->on('end', $this->expectCallableNever());
+ $this->parser->on('error', $this->expectCallableNever());
+
+ $this->input->emit('data', array("4"));
+ $this->input->emit('data', array("\r\n"));
+ $this->input->emit('data', array("we"));
+ $this->input->emit('data', array("lt\r\n"));
+ }
+
+ public function testMixed()
+ {
+ $this->parser->on('data', $this->expectCallableConsecutive(3, array('we', 'lt', 'hello')));
+ $this->parser->on('close', $this->expectCallableNever());
+ $this->parser->on('end', $this->expectCallableNever());
+ $this->parser->on('error', $this->expectCallableNever());
+
+ $this->input->emit('data', array("4"));
+ $this->input->emit('data', array("\r\n"));
+ $this->input->emit('data', array("we"));
+ $this->input->emit('data', array("lt\r\n"));
+ $this->input->emit('data', array("5\r\nhello\r\n"));
+ }
+
+ public function testBigger()
+ {
+ $this->parser->on('data', $this->expectCallableConsecutive(2, array('abcdeabcdeabcdea', 'hello')));
+ $this->parser->on('close', $this->expectCallableNever());
+ $this->parser->on('end', $this->expectCallableNever());
+ $this->parser->on('error', $this->expectCallableNever());
+
+ $this->input->emit('data', array("1"));
+ $this->input->emit('data', array("0"));
+ $this->input->emit('data', array("\r\n"));
+ $this->input->emit('data', array("abcdeabcdeabcdea\r\n"));
+ $this->input->emit('data', array("5\r\nhello\r\n"));
+ }
+
+ public function testOneUnfinished()
+ {
+ $this->parser->on('data', $this->expectCallableConsecutive(2, array('bla', 'hello')));
+ $this->parser->on('close', $this->expectCallableNever());
+ $this->parser->on('end', $this->expectCallableNever());
+ $this->parser->on('error', $this->expectCallableNever());
+
+ $this->input->emit('data', array("3\r\n"));
+ $this->input->emit('data', array("bla\r\n"));
+ $this->input->emit('data', array("5\r\nhello"));
+ }
+
+ public function testChunkIsBiggerThenExpected()
+ {
+ $this->parser->on('data', $this->expectCallableOnceWith('hello'));
+ $this->parser->on('close', $this->expectCallableOnce());
+ $this->parser->on('end', $this->expectCallableNever());
+ $this->parser->on('error', $this->expectCallableOnce());
+
+ $this->input->emit('data', array("5\r\n"));
+ $this->input->emit('data', array("hello world\r\n"));
+ }
+
+ public function testHandleUnexpectedEnd()
+ {
+ $this->parser->on('data', $this->expectCallableNever());
+ $this->parser->on('close', $this->expectCallableOnce());
+ $this->parser->on('end', $this->expectCallableNever());
+ $this->parser->on('error', $this->expectCallableOnce());
+
+ $this->input->emit('end');
+ }
+
+ public function testExtensionWillBeIgnored()
+ {
+ $this->parser->on('data', $this->expectCallableOnceWith('bla'));
+ $this->parser->on('close', $this->expectCallableNever());
+ $this->parser->on('end', $this->expectCallableNever());
+ $this->parser->on('error', $this->expectCallableNever());
+
+ $this->input->emit('data', array("3;hello=world;foo=bar\r\nbla"));
+ }
+
+ public function testChunkHeaderIsTooBig()
+ {
+ $this->parser->on('data', $this->expectCallableNever());
+ $this->parser->on('close', $this->expectCallableOnce());
+ $this->parser->on('end', $this->expectCallableNever());
+ $this->parser->on('error', $this->expectCallableOnce());
+
+ $data = '';
+ for ($i = 0; $i < 1025; $i++) {
+ $data .= 'a';
+ }
+ $this->input->emit('data', array($data));
+ }
+
+ public function testChunkIsMaximumSize()
+ {
+ $this->parser->on('data', $this->expectCallableNever());
+ $this->parser->on('close', $this->expectCallableOnce());
+ $this->parser->on('end', $this->expectCallableNever());
+ $this->parser->on('error', $this->expectCallableOnce());
+
+ $data = '';
+ for ($i = 0; $i < 1024; $i++) {
+ $data .= 'a';
+ }
+ $data .= "\r\n";
+
+ $this->input->emit('data', array($data));
+ }
+
+ public function testLateCrlf()
+ {
+ $this->parser->on('data', $this->expectCallableOnceWith('late'));
+ $this->parser->on('close', $this->expectCallableNever());
+ $this->parser->on('end', $this->expectCallableNever());
+ $this->parser->on('error', $this->expectCallableNever());
+
+ $this->input->emit('data', array("4\r\nlate"));
+ $this->input->emit('data', array("\r"));
+ $this->input->emit('data', array("\n"));
+ }
+
+ public function testNoCrlfInChunk()
+ {
+ $this->parser->on('data', $this->expectCallableOnceWith('no'));
+ $this->parser->on('close', $this->expectCallableOnce());
+ $this->parser->on('end', $this->expectCallableNever());
+ $this->parser->on('error', $this->expectCallableOnce());
+
+ $this->input->emit('data', array("2\r\nno crlf"));
+ }
+
+ public function testNoCrlfInChunkSplitted()
+ {
+ $this->parser->on('data', $this->expectCallableOnceWith('no'));
+ $this->parser->on('close', $this->expectCallableOnce());
+ $this->parser->on('end', $this->expectCallableNever());
+ $this->parser->on('error', $this->expectCallableOnce());
+
+ $this->input->emit('data', array("2\r\n"));
+ $this->input->emit('data', array("no"));
+ $this->input->emit('data', array("further"));
+ $this->input->emit('data', array("clrf"));
+ }
+
+ public function testEmitEmptyChunkBody()
+ {
+ $this->parser->on('data', $this->expectCallableNever());
+ $this->parser->on('close', $this->expectCallableNever());
+ $this->parser->on('end', $this->expectCallableNever());
+ $this->parser->on('error', $this->expectCallableNever());
+
+ $this->input->emit('data', array("2\r\n"));
+ $this->input->emit('data', array(""));
+ $this->input->emit('data', array(""));
+ }
+
+ public function testEmitCrlfAsChunkBody()
+ {
+ $this->parser->on('data', $this->expectCallableOnceWith("\r\n"));
+ $this->parser->on('close', $this->expectCallableNever());
+ $this->parser->on('end', $this->expectCallableNever());
+ $this->parser->on('error', $this->expectCallableNever());
+
+ $this->input->emit('data', array("2\r\n"));
+ $this->input->emit('data', array("\r\n"));
+ $this->input->emit('data', array("\r\n"));
+ }
+
+ public function testNegativeHeader()
+ {
+ $this->parser->on('data', $this->expectCallableNever());
+ $this->parser->on('close', $this->expectCallableOnce());
+ $this->parser->on('end', $this->expectCallableNever());
+ $this->parser->on('error', $this->expectCallableOnce());
+
+ $this->input->emit('data', array("-2\r\n"));
+ }
+
+ public function testHexDecimalInBodyIsPotentialThread()
+ {
+ $this->parser->on('data', $this->expectCallableOnce('test'));
+ $this->parser->on('close', $this->expectCallableOnce());
+ $this->parser->on('end', $this->expectCallableNever());
+ $this->parser->on('error', $this->expectCallableOnce());
+
+ $this->input->emit('data', array("4\r\ntest5\r\nworld"));
+ }
+
+ public function testHexDecimalInBodyIsPotentialThreadSplitted()
+ {
+ $this->parser->on('data', $this->expectCallableOnce('test'));
+ $this->parser->on('close', $this->expectCallableOnce());
+ $this->parser->on('end', $this->expectCallableNever());
+ $this->parser->on('error', $this->expectCallableOnce());
+
+ $this->input->emit('data', array("4"));
+ $this->input->emit('data', array("\r\n"));
+ $this->input->emit('data', array("test"));
+ $this->input->emit('data', array("5"));
+ $this->input->emit('data', array("\r\n"));
+ $this->input->emit('data', array("world"));
+ }
+
+ public function testEmitSingleCharacter()
+ {
+ $this->parser->on('data', $this->expectCallableConsecutive(4, array('t', 'e', 's', 't')));
+ $this->parser->on('close', $this->expectCallableOnce());
+ $this->parser->on('end', $this->expectCallableOnce());
+ $this->parser->on('error', $this->expectCallableNever());
+
+ $array = str_split("4\r\ntest\r\n0\r\n\r\n");
+
+ foreach ($array as $character) {
+ $this->input->emit('data', array($character));
+ }
+ }
+
+ public function testHandleError()
+ {
+ $this->parser->on('error', $this->expectCallableOnce());
+ $this->parser->on('close', $this->expectCallableOnce());
+ $this->parser->on('end', $this->expectCallableNever());
+
+ $this->input->emit('error', array(new \RuntimeException()));
+
+ $this->assertFalse($this->parser->isReadable());
+ }
+
+ public function testPauseStream()
+ {
+ $input = $this->getMockBuilder('React\Stream\ReadableStreamInterface')->getMock();
+ $input->expects($this->once())->method('pause');
+
+ $parser = new ChunkedDecoder($input);
+ $parser->pause();
+ }
+
+ public function testResumeStream()
+ {
+ $input = $this->getMockBuilder('React\Stream\ReadableStreamInterface')->getMock();
+ $input->expects($this->once())->method('pause');
+
+ $parser = new ChunkedDecoder($input);
+ $parser->pause();
+ $parser->resume();
+ }
+
+ public function testPipeStream()
+ {
+ $dest = $this->getMockBuilder('React\Stream\WritableStreamInterface')->getMock();
+
+ $ret = $this->parser->pipe($dest);
+
+ $this->assertSame($dest, $ret);
+ }
+
+ public function testHandleClose()
+ {
+ $this->parser->on('close', $this->expectCallableOnce());
+
+ $this->input->close();
+ $this->input->emit('end', array());
+
+ $this->assertFalse($this->parser->isReadable());
+ }
+
+ public function testOutputStreamCanCloseInputStream()
+ {
+ $input = new ThroughStream();
+ $input->on('close', $this->expectCallableOnce());
+
+ $stream = new ChunkedDecoder($input);
+ $stream->on('close', $this->expectCallableOnce());
+
+ $stream->close();
+
+ $this->assertFalse($input->isReadable());
+ }
+
+ public function testLeadingZerosWillBeIgnored()
+ {
+ $this->parser->on('data', $this->expectCallableConsecutive(2, array('hello', 'hello world')));
+ $this->parser->on('error', $this->expectCallableNever());
+ $this->parser->on('end', $this->expectCallableNever());
+ $this->parser->on('close', $this->expectCallableNever());
+
+ $this->input->emit('data', array("00005\r\nhello\r\n"));
+ $this->input->emit('data', array("0000b\r\nhello world\r\n"));
+ }
+
+ public function testLeadingZerosInEndChunkWillBeIgnored()
+ {
+ $this->parser->on('data', $this->expectCallableNever());
+ $this->parser->on('error', $this->expectCallableNever());
+ $this->parser->on('end', $this->expectCallableOnce());
+ $this->parser->on('close', $this->expectCallableOnce());
+
+ $this->input->emit('data', array("0000\r\n\r\n"));
+ }
+
+ public function testLeadingZerosInInvalidChunk()
+ {
+ $this->parser->on('data', $this->expectCallableNever());
+ $this->parser->on('error', $this->expectCallableOnce());
+ $this->parser->on('end', $this->expectCallableNever());
+ $this->parser->on('close', $this->expectCallableOnce());
+
+ $this->input->emit('data', array("0000hello\r\n\r\n"));
+ }
+
+ public function testEmptyHeaderLeadsToError()
+ {
+ $this->parser->on('data', $this->expectCallableNever());
+ $this->parser->on('error', $this->expectCallableOnce());
+ $this->parser->on('end', $this->expectCallableNever());
+ $this->parser->on('close', $this->expectCallableOnce());
+
+ $this->input->emit('data', array("\r\n\r\n"));
+ }
+
+ public function testEmptyHeaderAndFilledBodyLeadsToError()
+ {
+ $this->parser->on('data', $this->expectCallableNever());
+ $this->parser->on('error', $this->expectCallableOnce());
+ $this->parser->on('end', $this->expectCallableNever());
+ $this->parser->on('close', $this->expectCallableOnce());
+
+ $this->input->emit('data', array("\r\nhello\r\n"));
+ }
+
+ public function testUpperCaseHexWillBeHandled()
+ {
+ $this->parser->on('data', $this->expectCallableOnceWith('0123456790'));
+ $this->parser->on('error', $this->expectCallableNever());
+ $this->parser->on('end', $this->expectCallableNever());
+ $this->parser->on('close', $this->expectCallableNever());
+
+ $this->input->emit('data', array("A\r\n0123456790\r\n"));
+ }
+
+ public function testLowerCaseHexWillBeHandled()
+ {
+ $this->parser->on('data', $this->expectCallableOnceWith('0123456790'));
+ $this->parser->on('error', $this->expectCallableNever());
+ $this->parser->on('end', $this->expectCallableNever());
+ $this->parser->on('close', $this->expectCallableNever());
+
+ $this->input->emit('data', array("a\r\n0123456790\r\n"));
+ }
+
+ public function testMixedUpperAndLowerCaseHexValuesInHeaderWillBeHandled()
+ {
+ $data = str_repeat('1', (int)hexdec('AA'));
+
+ $this->parser->on('data', $this->expectCallableOnceWith($data));
+ $this->parser->on('error', $this->expectCallableNever());
+ $this->parser->on('end', $this->expectCallableNever());
+ $this->parser->on('close', $this->expectCallableNever());
+
+ $this->input->emit('data', array("aA\r\n" . $data . "\r\n"));
+ }
+}
diff --git a/tests/ChunkedEncoderTest.php b/tests/ChunkedEncoderTest.php
new file mode 100644
index 00000000..8dcdbdbc
--- /dev/null
+++ b/tests/ChunkedEncoderTest.php
@@ -0,0 +1,83 @@
+input = new ThroughStream();
+ $this->chunkedStream = new ChunkedEncoder($this->input);
+ }
+
+ public function testChunked()
+ {
+ $this->chunkedStream->on('data', $this->expectCallableOnce(array("5\r\nhello\r\n")));
+ $this->input->emit('data', array('hello'));
+ }
+
+ public function testEmptyString()
+ {
+ $this->chunkedStream->on('data', $this->expectCallableNever());
+ $this->input->emit('data', array(''));
+ }
+
+ public function testBiggerStringToCheckHexValue()
+ {
+ $this->chunkedStream->on('data', $this->expectCallableOnce(array("1a\r\nabcdefghijklmnopqrstuvwxyz\r\n")));
+ $this->input->emit('data', array('abcdefghijklmnopqrstuvwxyz'));
+ }
+
+ public function testHandleClose()
+ {
+ $this->chunkedStream->on('close', $this->expectCallableOnce());
+
+ $this->input->close();
+
+ $this->assertFalse($this->chunkedStream->isReadable());
+ }
+
+ public function testHandleError()
+ {
+ $this->chunkedStream->on('error', $this->expectCallableOnce());
+ $this->chunkedStream->on('close', $this->expectCallableOnce());
+
+ $this->input->emit('error', array(new \RuntimeException()));
+
+ $this->assertFalse($this->chunkedStream->isReadable());
+ }
+
+ public function testPauseStream()
+ {
+ $input = $this->getMockBuilder('React\Stream\ReadableStreamInterface')->getMock();
+ $input->expects($this->once())->method('pause');
+
+ $parser = new ChunkedEncoder($input);
+ $parser->pause();
+ }
+
+ public function testResumeStream()
+ {
+ $input = $this->getMockBuilder('React\Stream\ReadableStreamInterface')->getMock();
+ $input->expects($this->once())->method('pause');
+
+ $parser = new ChunkedEncoder($input);
+ $parser->pause();
+ $parser->resume();
+ }
+
+ public function testPipeStream()
+ {
+ $dest = $this->getMockBuilder('React\Stream\WritableStreamInterface')->getMock();
+
+ $ret = $this->chunkedStream->pipe($dest);
+
+ $this->assertSame($dest, $ret);
+ }
+}
diff --git a/tests/CloseProtectionStreamTest.php b/tests/CloseProtectionStreamTest.php
new file mode 100644
index 00000000..e0c82596
--- /dev/null
+++ b/tests/CloseProtectionStreamTest.php
@@ -0,0 +1,146 @@
+getMockBuilder('React\Stream\ReadableStreamInterface')->disableOriginalConstructor()->getMock();
+ $input->expects($this->once())->method('pause');
+ $input->expects($this->never())->method('close');
+
+ $protection = new CloseProtectionStream($input);
+ $protection->close();
+ }
+
+ public function testErrorWontCloseStream()
+ {
+ $input = new ThroughStream();
+
+ $protection = new CloseProtectionStream($input);
+ $protection->on('error', $this->expectCallableOnce());
+ $protection->on('close', $this->expectCallableNever());
+
+ $input->emit('error', array(new \RuntimeException()));
+
+ $this->assertTrue($protection->isReadable());
+ $this->assertTrue($input->isReadable());
+ }
+
+ public function testResumeStreamWillResumeInputStream()
+ {
+ $input = $this->getMockBuilder('React\Stream\ReadableStreamInterface')->getMock();
+ $input->expects($this->once())->method('pause');
+ $input->expects($this->once())->method('resume');
+
+ $protection = new CloseProtectionStream($input);
+ $protection->pause();
+ $protection->resume();
+ }
+
+ public function testInputStreamIsNotReadableAfterClose()
+ {
+ $input = new ThroughStream();
+
+ $protection = new CloseProtectionStream($input);
+ $protection->on('close', $this->expectCallableOnce());
+
+ $input->close();
+
+ $this->assertFalse($protection->isReadable());
+ $this->assertFalse($input->isReadable());
+ }
+
+ public function testPipeStream()
+ {
+ $input = new ThroughStream();
+
+ $protection = new CloseProtectionStream($input);
+ $dest = $this->getMockBuilder('React\Stream\WritableStreamInterface')->getMock();
+
+ $ret = $protection->pipe($dest);
+
+ $this->assertSame($dest, $ret);
+ }
+
+ public function testStopEmittingDataAfterClose()
+ {
+ $input = new ThroughStream();
+
+ $protection = new CloseProtectionStream($input);
+ $protection->on('data', $this->expectCallableNever());
+
+ $protection->on('close', $this->expectCallableOnce());
+
+ $protection->close();
+
+ $input->emit('data', array('hello'));
+
+ $this->assertFalse($protection->isReadable());
+ $this->assertTrue($input->isReadable());
+ }
+
+ public function testErrorIsNeverCalledAfterClose()
+ {
+ $input = new ThroughStream();
+
+ $protection = new CloseProtectionStream($input);
+ $protection->on('data', $this->expectCallableNever());
+ $protection->on('error', $this->expectCallableNever());
+ $protection->on('close', $this->expectCallableOnce());
+
+ $protection->close();
+
+ $input->emit('error', array(new \Exception()));
+
+ $this->assertFalse($protection->isReadable());
+ $this->assertTrue($input->isReadable());
+ }
+
+ public function testEndWontBeEmittedAfterClose()
+ {
+ $input = new ThroughStream();
+
+ $protection = new CloseProtectionStream($input);
+ $protection->on('data', $this->expectCallableNever());
+ $protection->on('close', $this->expectCallableOnce());
+
+ $protection->close();
+
+ $input->emit('end', array());
+
+ $this->assertFalse($protection->isReadable());
+ $this->assertTrue($input->isReadable());
+ }
+
+ public function testPauseAfterCloseHasNoEffect()
+ {
+ $input = $this->getMockBuilder('React\Stream\ReadableStreamInterface')->getMock();
+ $input->expects($this->once())->method('pause');
+
+ $protection = new CloseProtectionStream($input);
+ $protection->on('data', $this->expectCallableNever());
+ $protection->on('close', $this->expectCallableOnce());
+
+ $protection->close();
+ $protection->pause();
+ }
+
+ public function testResumeAfterCloseHasNoEffect()
+ {
+ $input = $this->getMockBuilder('React\Stream\ReadableStreamInterface')->getMock();
+ $input->expects($this->once())->method('pause');
+ $input->expects($this->never())->method('resume');
+
+ $protection = new CloseProtectionStream($input);
+ $protection->on('data', $this->expectCallableNever());
+ $protection->on('close', $this->expectCallableOnce());
+
+ $protection->close();
+ $protection->resume();
+ }
+}
diff --git a/tests/ConnectionStub.php b/tests/ConnectionStub.php
deleted file mode 100644
index 9ddfb052..00000000
--- a/tests/ConnectionStub.php
+++ /dev/null
@@ -1,63 +0,0 @@
-data .= $data;
-
- return true;
- }
-
- public function end($data = null)
- {
- }
-
- public function close()
- {
- }
-
- public function getData()
- {
- return $this->data;
- }
-
- public function getRemoteAddress()
- {
- return '127.0.0.1';
- }
-}
diff --git a/tests/FunctionalServerTest.php b/tests/FunctionalServerTest.php
new file mode 100644
index 00000000..06f06db9
--- /dev/null
+++ b/tests/FunctionalServerTest.php
@@ -0,0 +1,634 @@
+getUri());
+ });
+
+ $socket = new Socket(0, $loop);
+ $server->listen($socket);
+
+ $result = $connector->connect($socket->getAddress())->then(function (ConnectionInterface $conn) {
+ $conn->write("GET / HTTP/1.0\r\nHost: " . noScheme($conn->getRemoteAddress()) . "\r\n\r\n");
+
+ return Stream\buffer($conn);
+ });
+
+ $response = Block\await($result, $loop, 1.0);
+
+ $this->assertContains("HTTP/1.0 200 OK", $response);
+ $this->assertContains('http://' . noScheme($socket->getAddress()) . '/', $response);
+
+ $socket->close();
+ }
+
+ public function testPlainHttpOnRandomPortWithoutHostHeaderUsesSocketUri()
+ {
+ $loop = Factory::create();
+ $connector = new Connector($loop);
+
+ $server = new Server(function (RequestInterface $request) {
+ return new Response(200, array(), (string)$request->getUri());
+ });
+
+ $socket = new Socket(0, $loop);
+ $server->listen($socket);
+
+ $result = $connector->connect($socket->getAddress())->then(function (ConnectionInterface $conn) {
+ $conn->write("GET / HTTP/1.0\r\n\r\n");
+
+ return Stream\buffer($conn);
+ });
+
+ $response = Block\await($result, $loop, 1.0);
+
+ $this->assertContains("HTTP/1.0 200 OK", $response);
+ $this->assertContains('http://' . noScheme($socket->getAddress()) . '/', $response);
+
+ $socket->close();
+ }
+
+ public function testPlainHttpOnRandomPortWithOtherHostHeaderTakesPrecedence()
+ {
+ $loop = Factory::create();
+ $connector = new Connector($loop);
+
+ $server = new Server(function (RequestInterface $request) {
+ return new Response(200, array(), (string)$request->getUri());
+ });
+
+ $socket = new Socket(0, $loop);
+ $server->listen($socket);
+
+ $result = $connector->connect($socket->getAddress())->then(function (ConnectionInterface $conn) {
+ $conn->write("GET / HTTP/1.0\r\nHost: localhost:1000\r\n\r\n");
+
+ return Stream\buffer($conn);
+ });
+
+ $response = Block\await($result, $loop, 1.0);
+
+ $this->assertContains("HTTP/1.0 200 OK", $response);
+ $this->assertContains('http://localhost:1000/', $response);
+
+ $socket->close();
+ }
+
+ public function testSecureHttpsOnRandomPort()
+ {
+ if (!function_exists('stream_socket_enable_crypto')) {
+ $this->markTestSkipped('Not supported on your platform (outdated HHVM?)');
+ }
+
+ $loop = Factory::create();
+ $connector = new Connector($loop, array(
+ 'tls' => array('verify_peer' => false)
+ ));
+
+ $server = new Server(function (RequestInterface $request) {
+ return new Response(200, array(), (string)$request->getUri());
+ });
+
+ $socket = new Socket(0, $loop);
+ $socket = new SecureServer($socket, $loop, array(
+ 'local_cert' => __DIR__ . '/../examples/localhost.pem'
+ ));
+ $server->listen($socket);
+
+ $result = $connector->connect('tls://' . noScheme($socket->getAddress()))->then(function (ConnectionInterface $conn) {
+ $conn->write("GET / HTTP/1.0\r\nHost: " . noScheme($conn->getRemoteAddress()) . "\r\n\r\n");
+
+ return Stream\buffer($conn);
+ });
+
+ $response = Block\await($result, $loop, 1.0);
+
+ $this->assertContains("HTTP/1.0 200 OK", $response);
+ $this->assertContains('https://' . noScheme($socket->getAddress()) . '/', $response);
+
+ $socket->close();
+ }
+
+ public function testSecureHttpsOnRandomPortWithoutHostHeaderUsesSocketUri()
+ {
+ if (!function_exists('stream_socket_enable_crypto')) {
+ $this->markTestSkipped('Not supported on your platform (outdated HHVM?)');
+ }
+
+ $loop = Factory::create();
+ $connector = new Connector($loop, array(
+ 'tls' => array('verify_peer' => false)
+ ));
+
+ $server = new Server(function (RequestInterface $request) {
+ return new Response(200, array(), (string)$request->getUri());
+ });
+
+ $socket = new Socket(0, $loop);
+ $socket = new SecureServer($socket, $loop, array(
+ 'local_cert' => __DIR__ . '/../examples/localhost.pem'
+ ));
+ $server->listen($socket);
+
+ $result = $connector->connect('tls://' . noScheme($socket->getAddress()))->then(function (ConnectionInterface $conn) {
+ $conn->write("GET / HTTP/1.0\r\n\r\n");
+
+ return Stream\buffer($conn);
+ });
+
+ $response = Block\await($result, $loop, 1.0);
+
+ $this->assertContains("HTTP/1.0 200 OK", $response);
+ $this->assertContains('https://' . noScheme($socket->getAddress()) . '/', $response);
+
+ $socket->close();
+ }
+
+ public function testPlainHttpOnStandardPortReturnsUriWithNoPort()
+ {
+ $loop = Factory::create();
+ try {
+ $socket = new Socket(80, $loop);
+ } catch (\RuntimeException $e) {
+ $this->markTestSkipped('Listening on port 80 failed (root and unused?)');
+ }
+ $connector = new Connector($loop);
+
+ $server = new Server(function (RequestInterface $request) {
+ return new Response(200, array(), (string)$request->getUri());
+ });
+
+ $server->listen($socket);
+
+ $result = $connector->connect($socket->getAddress())->then(function (ConnectionInterface $conn) {
+ $conn->write("GET / HTTP/1.0\r\nHost: 127.0.0.1\r\n\r\n");
+
+ return Stream\buffer($conn);
+ });
+
+ $response = Block\await($result, $loop, 1.0);
+
+ $this->assertContains("HTTP/1.0 200 OK", $response);
+ $this->assertContains('http://127.0.0.1/', $response);
+
+ $socket->close();
+ }
+
+ public function testPlainHttpOnStandardPortWithoutHostHeaderReturnsUriWithNoPort()
+ {
+ $loop = Factory::create();
+ try {
+ $socket = new Socket(80, $loop);
+ } catch (\RuntimeException $e) {
+ $this->markTestSkipped('Listening on port 80 failed (root and unused?)');
+ }
+ $connector = new Connector($loop);
+
+ $server = new Server(function (RequestInterface $request) {
+ return new Response(200, array(), (string)$request->getUri());
+ });
+
+ $server->listen($socket);
+
+ $result = $connector->connect($socket->getAddress())->then(function (ConnectionInterface $conn) {
+ $conn->write("GET / HTTP/1.0\r\n\r\n");
+
+ return Stream\buffer($conn);
+ });
+
+ $response = Block\await($result, $loop, 1.0);
+
+ $this->assertContains("HTTP/1.0 200 OK", $response);
+ $this->assertContains('http://127.0.0.1/', $response);
+
+ $socket->close();
+ }
+
+ public function testSecureHttpsOnStandardPortReturnsUriWithNoPort()
+ {
+ if (!function_exists('stream_socket_enable_crypto')) {
+ $this->markTestSkipped('Not supported on your platform (outdated HHVM?)');
+ }
+
+ $loop = Factory::create();
+ try {
+ $socket = new Socket(443, $loop);
+ } catch (\RuntimeException $e) {
+ $this->markTestSkipped('Listening on port 443 failed (root and unused?)');
+ }
+ $socket = new SecureServer($socket, $loop, array(
+ 'local_cert' => __DIR__ . '/../examples/localhost.pem'
+ ));
+ $connector = new Connector($loop, array(
+ 'tls' => array('verify_peer' => false)
+ ));
+
+ $server = new Server(function (RequestInterface $request) {
+ return new Response(200, array(), (string)$request->getUri());
+ });
+
+ $server->listen($socket);
+
+ $result = $connector->connect('tls://' . noScheme($socket->getAddress()))->then(function (ConnectionInterface $conn) {
+ $conn->write("GET / HTTP/1.0\r\nHost: 127.0.0.1\r\n\r\n");
+
+ return Stream\buffer($conn);
+ });
+
+ $response = Block\await($result, $loop, 1.0);
+
+ $this->assertContains("HTTP/1.0 200 OK", $response);
+ $this->assertContains('https://127.0.0.1/', $response);
+
+ $socket->close();
+ }
+
+ public function testSecureHttpsOnStandardPortWithoutHostHeaderUsesSocketUri()
+ {
+ if (!function_exists('stream_socket_enable_crypto')) {
+ $this->markTestSkipped('Not supported on your platform (outdated HHVM?)');
+ }
+
+ $loop = Factory::create();
+ try {
+ $socket = new Socket(443, $loop);
+ } catch (\RuntimeException $e) {
+ $this->markTestSkipped('Listening on port 443 failed (root and unused?)');
+ }
+ $socket = new SecureServer($socket, $loop, array(
+ 'local_cert' => __DIR__ . '/../examples/localhost.pem'
+ ));
+ $connector = new Connector($loop, array(
+ 'tls' => array('verify_peer' => false)
+ ));
+
+ $server = new Server(function (RequestInterface $request) {
+ return new Response(200, array(), (string)$request->getUri());
+ });
+
+ $server->listen($socket);
+
+ $result = $connector->connect('tls://' . noScheme($socket->getAddress()))->then(function (ConnectionInterface $conn) {
+ $conn->write("GET / HTTP/1.0\r\n\r\n");
+
+ return Stream\buffer($conn);
+ });
+
+ $response = Block\await($result, $loop, 1.0);
+
+ $this->assertContains("HTTP/1.0 200 OK", $response);
+ $this->assertContains('https://127.0.0.1/', $response);
+
+ $socket->close();
+ }
+
+ public function testPlainHttpOnHttpsStandardPortReturnsUriWithPort()
+ {
+ $loop = Factory::create();
+ try {
+ $socket = new Socket(443, $loop);
+ } catch (\RuntimeException $e) {
+ $this->markTestSkipped('Listening on port 443 failed (root and unused?)');
+ }
+ $connector = new Connector($loop);
+
+ $server = new Server(function (RequestInterface $request) {
+ return new Response(200, array(), (string)$request->getUri());
+ });
+
+ $server->listen($socket);
+
+ $result = $connector->connect($socket->getAddress())->then(function (ConnectionInterface $conn) {
+ $conn->write("GET / HTTP/1.0\r\nHost: " . noScheme($conn->getRemoteAddress()) . "\r\n\r\n");
+
+ return Stream\buffer($conn);
+ });
+
+ $response = Block\await($result, $loop, 1.0);
+
+ $this->assertContains("HTTP/1.0 200 OK", $response);
+ $this->assertContains('http://127.0.0.1:443/', $response);
+
+ $socket->close();
+ }
+
+ public function testSecureHttpsOnHttpStandardPortReturnsUriWithPort()
+ {
+ if (!function_exists('stream_socket_enable_crypto')) {
+ $this->markTestSkipped('Not supported on your platform (outdated HHVM?)');
+ }
+
+ $loop = Factory::create();
+ try {
+ $socket = new Socket(80, $loop);
+ } catch (\RuntimeException $e) {
+ $this->markTestSkipped('Listening on port 80 failed (root and unused?)');
+ }
+ $socket = new SecureServer($socket, $loop, array(
+ 'local_cert' => __DIR__ . '/../examples/localhost.pem'
+ ));
+ $connector = new Connector($loop, array(
+ 'tls' => array('verify_peer' => false)
+ ));
+
+ $server = new Server(function (RequestInterface $request) {
+ return new Response(200, array(), (string)$request->getUri() . 'x' . $request->getHeaderLine('Host'));
+ });
+
+ $server->listen($socket);
+
+ $result = $connector->connect('tls://' . noScheme($socket->getAddress()))->then(function (ConnectionInterface $conn) {
+ $conn->write("GET / HTTP/1.0\r\nHost: " . noScheme($conn->getRemoteAddress()) . "\r\n\r\n");
+
+ return Stream\buffer($conn);
+ });
+
+ $response = Block\await($result, $loop, 1.0);
+
+ $this->assertContains("HTTP/1.0 200 OK", $response);
+ $this->assertContains('https://127.0.0.1:80/', $response);
+
+ $socket->close();
+ }
+
+ public function testClosedStreamFromRequestHandlerWillSendEmptyBody()
+ {
+ $loop = Factory::create();
+ $connector = new Connector($loop);
+
+ $stream = new ThroughStream();
+ $stream->close();
+
+ $server = new Server(function (RequestInterface $request) use ($stream) {
+ return new Response(200, array(), $stream);
+ });
+
+ $socket = new Socket(0, $loop);
+ $server->listen($socket);
+
+ $result = $connector->connect($socket->getAddress())->then(function (ConnectionInterface $conn) use ($loop) {
+ $conn->write("GET / HTTP/1.0\r\n\r\n");
+
+ return Stream\buffer($conn);
+ });
+
+ $response = Block\await($result, $loop, 1.0);
+
+ $this->assertStringStartsWith("HTTP/1.0 200 OK", $response);
+ $this->assertStringEndsWith("\r\n\r\n", $response);
+
+ $socket->close();
+ }
+
+ public function testStreamFromRequestHandlerWillBeClosedIfConnectionClosesWhileSendingBody()
+ {
+ $loop = Factory::create();
+ $connector = new Connector($loop);
+
+ $stream = new ThroughStream();
+ $stream->on('close', $this->expectCallableOnce());
+
+ $server = new Server(function (RequestInterface $request) use ($stream) {
+ return new Response(200, array(), $stream);
+ });
+
+ $socket = new Socket(0, $loop);
+ $server->listen($socket);
+
+ $result = $connector->connect($socket->getAddress())->then(function (ConnectionInterface $conn) use ($loop) {
+ $conn->write("GET / HTTP/1.0\r\nContent-Length: 100\r\n\r\n");
+
+ $loop->addTimer(0.1, function() use ($conn) {
+ $conn->end();
+ });
+
+ return Stream\buffer($conn);
+ });
+
+ $response = Block\await($result, $loop, 1.0);
+
+ $this->assertStringStartsWith("HTTP/1.0 200 OK", $response);
+ $this->assertStringEndsWith("\r\n\r\n", $response);
+
+ $socket->close();
+ }
+
+ public function testStreamFromRequestHandlerWillBeClosedIfConnectionClosesButWillOnlyBeDetectedOnNextWrite()
+ {
+ $loop = Factory::create();
+ $connector = new Connector($loop);
+
+ $stream = new ThroughStream();
+ $stream->on('close', $this->expectCallableOnce());
+
+ $server = new Server(function (RequestInterface $request) use ($stream) {
+ return new Response(200, array(), $stream);
+ });
+
+ $socket = new Socket(0, $loop);
+ $server->listen($socket);
+
+ $result = $connector->connect($socket->getAddress())->then(function (ConnectionInterface $conn) use ($loop) {
+ $conn->write("GET / HTTP/1.0\r\n\r\n");
+
+ $loop->addTimer(0.1, function() use ($conn) {
+ $conn->end();
+ });
+
+ return Stream\buffer($conn);
+ });
+
+ $response = Block\await($result, $loop, 1.0);
+
+ $stream->write('nope');
+ Block\sleep(0.1, $loop);
+ $stream->write('nope');
+ Block\sleep(0.1, $loop);
+
+ $this->assertStringStartsWith("HTTP/1.0 200 OK", $response);
+ $this->assertStringEndsWith("\r\n\r\n", $response);
+
+ $socket->close();
+ }
+
+ public function testUpgradeWithThroughStreamReturnsDataAsGiven()
+ {
+ $loop = Factory::create();
+ $connector = new Connector($loop);
+
+ $server = new Server(function (RequestInterface $request) use ($loop) {
+ $stream = new ThroughStream();
+
+ $loop->addTimer(0.1, function () use ($stream) {
+ $stream->end();
+ });
+
+ return new Response(101, array('Upgrade' => 'echo'), $stream);
+ });
+
+ $socket = new Socket(0, $loop);
+ $server->listen($socket);
+
+ $result = $connector->connect($socket->getAddress())->then(function (ConnectionInterface $conn) {
+ $conn->write("GET / HTTP/1.1\r\nHost: example.com:80\r\nUpgrade: echo\r\n\r\n");
+
+ $conn->once('data', function () use ($conn) {
+ $conn->write('hello');
+ $conn->write('world');
+ });
+
+ return Stream\buffer($conn);
+ });
+
+ $response = Block\await($result, $loop, 1.0);
+
+ $this->assertStringStartsWith("HTTP/1.1 101 Switching Protocols\r\n", $response);
+ $this->assertStringEndsWith("\r\n\r\nhelloworld", $response);
+
+ $socket->close();
+ }
+
+ public function testConnectWithThroughStreamReturnsDataAsGiven()
+ {
+ $loop = Factory::create();
+ $connector = new Connector($loop);
+
+ $server = new Server(function (RequestInterface $request) use ($loop) {
+ $stream = new ThroughStream();
+
+ $loop->addTimer(0.1, function () use ($stream) {
+ $stream->end();
+ });
+
+ return new Response(200, array(), $stream);
+ });
+
+ $socket = new Socket(0, $loop);
+ $server->listen($socket);
+
+ $result = $connector->connect($socket->getAddress())->then(function (ConnectionInterface $conn) {
+ $conn->write("CONNECT example.com:80 HTTP/1.1\r\nHost: example.com:80\r\n\r\n");
+
+ $conn->once('data', function () use ($conn) {
+ $conn->write('hello');
+ $conn->write('world');
+ });
+
+ return Stream\buffer($conn);
+ });
+
+ $response = Block\await($result, $loop, 1.0);
+
+ $this->assertStringStartsWith("HTTP/1.1 200 OK\r\n", $response);
+ $this->assertStringEndsWith("\r\n\r\nhelloworld", $response);
+
+ $socket->close();
+ }
+
+ public function testConnectWithThroughStreamReturnedFromPromiseReturnsDataAsGiven()
+ {
+ $loop = Factory::create();
+ $connector = new Connector($loop);
+
+ $server = new Server(function (RequestInterface $request) use ($loop) {
+ $stream = new ThroughStream();
+
+ $loop->addTimer(0.1, function () use ($stream) {
+ $stream->end();
+ });
+
+ return new Promise(function ($resolve) use ($loop, $stream) {
+ $loop->addTimer(0.001, function () use ($resolve, $stream) {
+ $resolve(new Response(200, array(), $stream));
+ });
+ });
+ });
+
+ $socket = new Socket(0, $loop);
+ $server->listen($socket);
+
+ $result = $connector->connect($socket->getAddress())->then(function (ConnectionInterface $conn) {
+ $conn->write("CONNECT example.com:80 HTTP/1.1\r\nHost: example.com:80\r\n\r\n");
+
+ $conn->once('data', function () use ($conn) {
+ $conn->write('hello');
+ $conn->write('world');
+ });
+
+ return Stream\buffer($conn);
+ });
+
+ $response = Block\await($result, $loop, 1.0);
+
+ $this->assertStringStartsWith("HTTP/1.1 200 OK\r\n", $response);
+ $this->assertStringEndsWith("\r\n\r\nhelloworld", $response);
+
+ $socket->close();
+ }
+
+ public function testConnectWithClosedThroughStreamReturnsNoData()
+ {
+ $loop = Factory::create();
+ $connector = new Connector($loop);
+
+ $server = new Server(function (RequestInterface $request) {
+ $stream = new ThroughStream();
+ $stream->close();
+
+ return new Response(200, array(), $stream);
+ });
+
+ $socket = new Socket(0, $loop);
+ $server->listen($socket);
+
+ $result = $connector->connect($socket->getAddress())->then(function (ConnectionInterface $conn) {
+ $conn->write("CONNECT example.com:80 HTTP/1.1\r\nHost: example.com:80\r\n\r\n");
+
+ $conn->once('data', function () use ($conn) {
+ $conn->write('hello');
+ $conn->write('world');
+ });
+
+ return Stream\buffer($conn);
+ });
+
+ $response = Block\await($result, $loop, 1.0);
+
+ $this->assertStringStartsWith("HTTP/1.1 200 OK\r\n", $response);
+ $this->assertStringEndsWith("\r\n\r\n", $response);
+
+ $socket->close();
+ }
+}
+
+function noScheme($uri)
+{
+ $pos = strpos($uri, '://');
+ if ($pos !== false) {
+ $uri = substr($uri, $pos + 3);
+ }
+ return $uri;
+}
diff --git a/tests/HttpBodyStreamTest.php b/tests/HttpBodyStreamTest.php
new file mode 100644
index 00000000..31e168e0
--- /dev/null
+++ b/tests/HttpBodyStreamTest.php
@@ -0,0 +1,187 @@
+input = new ThroughStream();
+ $this->bodyStream = new HttpBodyStream($this->input, null);
+ }
+
+ public function testDataEmit()
+ {
+ $this->bodyStream->on('data', $this->expectCallableOnce(array("hello")));
+ $this->input->emit('data', array("hello"));
+ }
+
+ public function testPauseStream()
+ {
+ $input = $this->getMockBuilder('React\Stream\ReadableStreamInterface')->getMock();
+ $input->expects($this->once())->method('pause');
+
+ $bodyStream = new HttpBodyStream($input, null);
+ $bodyStream->pause();
+ }
+
+ public function testResumeStream()
+ {
+ $input = $this->getMockBuilder('React\Stream\ReadableStreamInterface')->getMock();
+ $input->expects($this->once())->method('pause');
+
+ $bodyStream = new HttpBodyStream($input, null);
+ $bodyStream->pause();
+ $bodyStream->resume();
+ }
+
+ public function testPipeStream()
+ {
+ $dest = $this->getMockBuilder('React\Stream\WritableStreamInterface')->getMock();
+
+ $ret = $this->bodyStream->pipe($dest);
+
+ $this->assertSame($dest, $ret);
+ }
+
+ public function testHandleClose()
+ {
+ $this->bodyStream->on('close', $this->expectCallableOnce());
+
+ $this->input->close();
+ $this->input->emit('end', array());
+
+ $this->assertFalse($this->bodyStream->isReadable());
+ }
+
+ public function testStopDataEmittingAfterClose()
+ {
+ $bodyStream = new HttpBodyStream($this->input, null);
+ $bodyStream->on('close', $this->expectCallableOnce());
+ $this->bodyStream->on('data', $this->expectCallableOnce(array("hello")));
+
+ $this->input->emit('data', array("hello"));
+ $bodyStream->close();
+ $this->input->emit('data', array("world"));
+ }
+
+ public function testHandleError()
+ {
+ $this->bodyStream->on('error', $this->expectCallableOnce());
+ $this->bodyStream->on('close', $this->expectCallableOnce());
+
+ $this->input->emit('error', array(new \RuntimeException()));
+
+ $this->assertFalse($this->bodyStream->isReadable());
+ }
+
+ public function testToString()
+ {
+ $this->assertEquals('', $this->bodyStream->__toString());
+ }
+
+ public function testDetach()
+ {
+ $this->assertEquals(null, $this->bodyStream->detach());
+ }
+
+ public function testGetSizeDefault()
+ {
+ $this->assertEquals(null, $this->bodyStream->getSize());
+ }
+
+ public function testGetSizeCustom()
+ {
+ $stream = new HttpBodyStream($this->input, 5);
+ $this->assertEquals(5, $stream->getSize());
+ }
+
+ /**
+ * @expectedException BadMethodCallException
+ */
+ public function testTell()
+ {
+ $this->bodyStream->tell();
+ }
+
+ /**
+ * @expectedException BadMethodCallException
+ */
+ public function testEof()
+ {
+ $this->bodyStream->eof();
+ }
+
+ public function testIsSeekable()
+ {
+ $this->assertFalse($this->bodyStream->isSeekable());
+ }
+
+ /**
+ * @expectedException BadMethodCallException
+ */
+ public function testWrite()
+ {
+ $this->bodyStream->write('');
+ }
+
+ /**
+ * @expectedException BadMethodCallException
+ */
+ public function testRead()
+ {
+ $this->bodyStream->read('');
+ }
+
+ public function testGetContents()
+ {
+ $this->assertEquals('', $this->bodyStream->getContents());
+ }
+
+ public function testGetMetaData()
+ {
+ $this->assertEquals(null, $this->bodyStream->getMetadata());
+ }
+
+ public function testIsReadable()
+ {
+ $this->assertTrue($this->bodyStream->isReadable());
+ }
+
+ public function testPause()
+ {
+ $this->bodyStream->pause();
+ }
+
+ public function testResume()
+ {
+ $this->bodyStream->resume();
+ }
+
+ /**
+ * @expectedException BadMethodCallException
+ */
+ public function testSeek()
+ {
+ $this->bodyStream->seek('');
+ }
+
+ /**
+ * @expectedException BadMethodCallException
+ */
+ public function testRewind()
+ {
+ $this->bodyStream->rewind();
+ }
+
+ public function testIsWriteable()
+ {
+ $this->assertFalse($this->bodyStream->isWritable());
+ }
+}
diff --git a/tests/LengthLimitedStreamTest.php b/tests/LengthLimitedStreamTest.php
new file mode 100644
index 00000000..61ecdef6
--- /dev/null
+++ b/tests/LengthLimitedStreamTest.php
@@ -0,0 +1,120 @@
+input = new ThroughStream();
+ }
+
+ public function testSimpleChunk()
+ {
+ $stream = new LengthLimitedStream($this->input, 5);
+ $stream->on('data', $this->expectCallableOnceWith('hello'));
+ $stream->on('end', $this->expectCallableOnce());
+ $this->input->emit('data', array("hello world"));
+ }
+
+ public function testInputStreamKeepsEmitting()
+ {
+ $stream = new LengthLimitedStream($this->input, 5);
+ $stream->on('data', $this->expectCallableOnceWith('hello'));
+ $stream->on('end', $this->expectCallableOnce());
+
+ $this->input->emit('data', array("hello world"));
+ $this->input->emit('data', array("world"));
+ $this->input->emit('data', array("world"));
+ }
+
+ public function testZeroLengthInContentLengthWillIgnoreEmittedDataEvents()
+ {
+ $stream = new LengthLimitedStream($this->input, 0);
+ $stream->on('data', $this->expectCallableNever());
+ $stream->on('end', $this->expectCallableOnce());
+ $this->input->emit('data', array("hello world"));
+ }
+
+ public function testHandleError()
+ {
+ $stream = new LengthLimitedStream($this->input, 0);
+ $stream->on('error', $this->expectCallableOnce());
+ $stream->on('close', $this->expectCallableOnce());
+
+ $this->input->emit('error', array(new \RuntimeException()));
+
+ $this->assertFalse($stream->isReadable());
+ }
+
+ public function testPauseStream()
+ {
+ $input = $this->getMockBuilder('React\Stream\ReadableStreamInterface')->getMock();
+ $input->expects($this->once())->method('pause');
+
+ $stream = new LengthLimitedStream($input, 0);
+ $stream->pause();
+ }
+
+ public function testResumeStream()
+ {
+ $input = $this->getMockBuilder('React\Stream\ReadableStreamInterface')->getMock();
+ $input->expects($this->once())->method('pause');
+
+ $stream = new LengthLimitedStream($input, 0);
+ $stream->pause();
+ $stream->resume();
+ }
+
+ public function testPipeStream()
+ {
+ $stream = new LengthLimitedStream($this->input, 0);
+ $dest = $this->getMockBuilder('React\Stream\WritableStreamInterface')->getMock();
+
+ $ret = $stream->pipe($dest);
+
+ $this->assertSame($dest, $ret);
+ }
+
+ public function testHandleClose()
+ {
+ $stream = new LengthLimitedStream($this->input, 0);
+ $stream->on('close', $this->expectCallableOnce());
+
+ $this->input->close();
+ $this->input->emit('end', array());
+
+ $this->assertFalse($stream->isReadable());
+ }
+
+ public function testOutputStreamCanCloseInputStream()
+ {
+ $input = new ThroughStream();
+ $input->on('close', $this->expectCallableOnce());
+
+ $stream = new LengthLimitedStream($input, 0);
+ $stream->on('close', $this->expectCallableOnce());
+
+ $stream->close();
+
+ $this->assertFalse($input->isReadable());
+ }
+
+ public function testHandleUnexpectedEnd()
+ {
+ $stream = new LengthLimitedStream($this->input, 5);
+
+ $stream->on('data', $this->expectCallableNever());
+ $stream->on('close', $this->expectCallableOnce());
+ $stream->on('end', $this->expectCallableNever());
+ $stream->on('error', $this->expectCallableOnce());
+
+ $this->input->emit('end');
+ }
+}
diff --git a/tests/RequestHeaderParserTest.php b/tests/RequestHeaderParserTest.php
index b2fa87dc..f4b20a7f 100644
--- a/tests/RequestHeaderParserTest.php
+++ b/tests/RequestHeaderParserTest.php
@@ -45,12 +45,11 @@ public function testHeadersEventShouldReturnRequestAndBodyBuffer()
$data .= 'RANDOM DATA';
$parser->feed($data);
- $this->assertInstanceOf('React\Http\Request', $request);
+ $this->assertInstanceOf('Psr\Http\Message\RequestInterface', $request);
$this->assertSame('GET', $request->getMethod());
- $this->assertSame('/', $request->getPath());
- $this->assertSame(array(), $request->getQuery());
- $this->assertSame('1.1', $request->getHttpVersion());
- $this->assertSame(array('Host' => 'example.com:80', 'Connection' => 'close'), $request->getHeaders());
+ $this->assertEquals('http://example.com/', $request->getUri());
+ $this->assertSame('1.1', $request->getProtocolVersion());
+ $this->assertSame(array('Host' => array('example.com'), 'Connection' => array('close')), $request->getHeaders());
$this->assertSame('RANDOM DATA', $bodyBuffer);
}
@@ -83,34 +82,98 @@ public function testHeadersEventShouldParsePathAndQueryString()
$data = $this->createAdvancedPostRequest();
$parser->feed($data);
- $this->assertInstanceOf('React\Http\Request', $request);
+ $this->assertInstanceOf('Psr\Http\Message\RequestInterface', $request);
$this->assertSame('POST', $request->getMethod());
- $this->assertSame('/foo', $request->getPath());
- $this->assertSame(array('bar' => 'baz'), $request->getQuery());
- $this->assertSame('1.1', $request->getHttpVersion());
+ $this->assertEquals('http://example.com/foo?bar=baz', $request->getUri());
+ $this->assertSame('1.1', $request->getProtocolVersion());
$headers = array(
- 'Host' => 'example.com:80',
- 'User-Agent' => 'react/alpha',
- 'Connection' => 'close',
+ 'Host' => array('example.com'),
+ 'User-Agent' => array('react/alpha'),
+ 'Connection' => array('close'),
);
$this->assertSame($headers, $request->getHeaders());
}
+ public function testHeaderEventWithShouldApplyDefaultAddressFromConstructor()
+ {
+ $request = null;
+
+ $parser = new RequestHeaderParser('http://127.1.1.1:8000');
+ $parser->on('headers', function ($parsedRequest) use (&$request) {
+ $request = $parsedRequest;
+ });
+
+ $parser->feed("GET /foo HTTP/1.0\r\n\r\n");
+
+ $this->assertEquals('http://127.1.1.1:8000/foo', $request->getUri());
+ $this->assertEquals('127.1.1.1:8000', $request->getHeaderLine('Host'));
+ }
+
+ public function testHeaderEventViaHttpsShouldApplySchemeFromConstructor()
+ {
+ $request = null;
+
+ $parser = new RequestHeaderParser('https://127.1.1.1:8000');
+ $parser->on('headers', function ($parsedRequest) use (&$request) {
+ $request = $parsedRequest;
+ });
+
+ $parser->feed("GET /foo HTTP/1.0\r\nHost: example.com\r\n\r\n");
+
+ $this->assertEquals('https://example.com/foo', $request->getUri());
+ $this->assertEquals('example.com', $request->getHeaderLine('Host'));
+ }
+
public function testHeaderOverflowShouldEmitError()
{
$error = null;
+ $passedParser = null;
$parser = new RequestHeaderParser();
$parser->on('headers', $this->expectCallableNever());
- $parser->on('error', function ($message) use (&$error) {
+ $parser->on('error', function ($message, $parser) use (&$error, &$passedParser) {
$error = $message;
+ $passedParser = $parser;
});
+ $this->assertSame(1, count($parser->listeners('headers')));
+ $this->assertSame(1, count($parser->listeners('error')));
+
$data = str_repeat('A', 4097);
$parser->feed($data);
$this->assertInstanceOf('OverflowException', $error);
$this->assertSame('Maximum header size of 4096 exceeded.', $error->getMessage());
+ $this->assertSame($parser, $passedParser);
+ $this->assertSame(0, count($parser->listeners('headers')));
+ $this->assertSame(0, count($parser->listeners('error')));
+ }
+
+ public function testHeaderOverflowShouldNotEmitErrorWhenDataExceedsMaxHeaderSize()
+ {
+ $request = null;
+ $bodyBuffer = null;
+
+ $parser = new RequestHeaderParser();
+ $parser->on('headers', function ($parsedRequest, $parsedBodyBuffer) use (&$request, &$bodyBuffer) {
+ $request = $parsedRequest;
+ $bodyBuffer = $parsedBodyBuffer;
+ });
+
+ $data = $this->createAdvancedPostRequest();
+ $body = str_repeat('A', 4097 - strlen($data));
+ $data .= $body;
+
+ $parser->feed($data);
+
+ $headers = array(
+ 'Host' => array('example.com'),
+ 'User-Agent' => array('react/alpha'),
+ 'Connection' => array('close'),
+ );
+ $this->assertSame($headers, $request->getHeaders());
+
+ $this->assertSame($body, $bodyBuffer);
}
public function testGuzzleRequestParseException()
@@ -134,6 +197,197 @@ public function testGuzzleRequestParseException()
$this->assertSame(0, count($parser->listeners('error')));
}
+ public function testInvalidAbsoluteFormSchemeEmitsError()
+ {
+ $error = null;
+
+ $parser = new RequestHeaderParser();
+ $parser->on('headers', $this->expectCallableNever());
+ $parser->on('error', function ($message) use (&$error) {
+ $error = $message;
+ });
+
+ $parser->feed("GET tcp://example.com:80/ HTTP/1.0\r\n\r\n");
+
+ $this->assertInstanceOf('InvalidArgumentException', $error);
+ $this->assertSame('Invalid absolute-form request-target', $error->getMessage());
+ }
+
+ public function testInvalidAbsoluteFormWithFragmentEmitsError()
+ {
+ $error = null;
+
+ $parser = new RequestHeaderParser();
+ $parser->on('headers', $this->expectCallableNever());
+ $parser->on('error', function ($message) use (&$error) {
+ $error = $message;
+ });
+
+ $parser->feed("GET http://example.com:80/#home HTTP/1.0\r\n\r\n");
+
+ $this->assertInstanceOf('InvalidArgumentException', $error);
+ $this->assertSame('Invalid absolute-form request-target', $error->getMessage());
+ }
+
+ public function testInvalidHeaderContainsFullUri()
+ {
+ $error = null;
+
+ $parser = new RequestHeaderParser();
+ $parser->on('headers', $this->expectCallableNever());
+ $parser->on('error', function ($message) use (&$error) {
+ $error = $message;
+ });
+
+ $parser->feed("GET / HTTP/1.1\r\nHost: http://user:pass@host/\r\n\r\n");
+
+ $this->assertInstanceOf('InvalidArgumentException', $error);
+ $this->assertSame('Invalid Host header value', $error->getMessage());
+ }
+
+ public function testInvalidAbsoluteFormWithHostHeaderEmpty()
+ {
+ $error = null;
+
+ $parser = new RequestHeaderParser();
+ $parser->on('headers', $this->expectCallableNever());
+ $parser->on('error', function ($message) use (&$error) {
+ $error = $message;
+ });
+
+ $parser->feed("GET http://example.com/ HTTP/1.1\r\nHost: \r\n\r\n");
+
+ $this->assertInstanceOf('InvalidArgumentException', $error);
+ $this->assertSame('Invalid Host header value', $error->getMessage());
+ }
+
+ public function testInvalidConnectRequestWithNonAuthorityForm()
+ {
+ $error = null;
+
+ $parser = new RequestHeaderParser();
+ $parser->on('headers', $this->expectCallableNever());
+ $parser->on('error', function ($message) use (&$error) {
+ $error = $message;
+ });
+
+ $parser->feed("CONNECT http://example.com:8080/ HTTP/1.1\r\nHost: example.com:8080\r\n\r\n");
+
+ $this->assertInstanceOf('InvalidArgumentException', $error);
+ $this->assertSame('CONNECT method MUST use authority-form request target', $error->getMessage());
+ }
+
+ public function testInvalidHttpVersion()
+ {
+ $error = null;
+
+ $parser = new RequestHeaderParser();
+ $parser->on('headers', $this->expectCallableNever());
+ $parser->on('error', function ($message) use (&$error) {
+ $error = $message;
+ });
+
+ $parser->feed("GET / HTTP/1.2\r\n\r\n");
+
+ $this->assertInstanceOf('InvalidArgumentException', $error);
+ $this->assertSame(505, $error->getCode());
+ $this->assertSame('Received request with invalid protocol version', $error->getMessage());
+ }
+
+ public function testServerParamsWillBeSetOnHttpsRequest()
+ {
+ $request = null;
+
+ $parser = new RequestHeaderParser(
+ 'https://127.1.1.1:8000',
+ 'https://192.168.1.1:8001'
+ );
+
+ $parser->on('headers', function ($parsedRequest) use (&$request) {
+ $request = $parsedRequest;
+ });
+
+ $parser->feed("GET /foo HTTP/1.0\r\nHost: example.com\r\n\r\n");
+ $serverParams = $request->getServerParams();
+
+ $this->assertEquals('on', $serverParams['HTTPS']);
+ $this->assertNotEmpty($serverParams['REQUEST_TIME']);
+ $this->assertNotEmpty($serverParams['REQUEST_TIME_FLOAT']);
+
+ $this->assertEquals('127.1.1.1', $serverParams['SERVER_ADDR']);
+ $this->assertEquals('8000', $serverParams['SERVER_PORT']);
+
+ $this->assertEquals('192.168.1.1', $serverParams['REMOTE_ADDR']);
+ $this->assertEquals('8001', $serverParams['REMOTE_PORT']);
+ }
+
+ public function testServerParamsWillBeSetOnHttpRequest()
+ {
+ $request = null;
+
+ $parser = new RequestHeaderParser(
+ 'http://127.1.1.1:8000',
+ 'http://192.168.1.1:8001'
+ );
+
+ $parser->on('headers', function ($parsedRequest) use (&$request) {
+ $request = $parsedRequest;
+ });
+
+ $parser->feed("GET /foo HTTP/1.0\r\nHost: example.com\r\n\r\n");
+ $serverParams = $request->getServerParams();
+
+ $this->assertArrayNotHasKey('HTTPS', $serverParams);
+ $this->assertNotEmpty($serverParams['REQUEST_TIME']);
+ $this->assertNotEmpty($serverParams['REQUEST_TIME_FLOAT']);
+
+ $this->assertEquals('127.1.1.1', $serverParams['SERVER_ADDR']);
+ $this->assertEquals('8000', $serverParams['SERVER_PORT']);
+
+ $this->assertEquals('192.168.1.1', $serverParams['REMOTE_ADDR']);
+ $this->assertEquals('8001', $serverParams['REMOTE_PORT']);
+ }
+
+ public function testServerParamsWontBeSetOnMissingUrls()
+ {
+ $request = null;
+
+ $parser = new RequestHeaderParser();
+
+ $parser->on('headers', function ($parsedRequest) use (&$request) {
+ $request = $parsedRequest;
+ });
+
+ $parser->feed("GET /foo HTTP/1.0\r\nHost: example.com\r\n\r\n");
+ $serverParams = $request->getServerParams();
+
+ $this->assertNotEmpty($serverParams['REQUEST_TIME']);
+ $this->assertNotEmpty($serverParams['REQUEST_TIME_FLOAT']);
+
+ $this->assertArrayNotHasKey('SERVER_ADDR', $serverParams);
+ $this->assertArrayNotHasKey('SERVER_PORT', $serverParams);
+
+ $this->assertArrayNotHasKey('REMOTE_ADDR', $serverParams);
+ $this->assertArrayNotHasKey('REMOTE_PORT', $serverParams);
+ }
+
+ public function testQueryParmetersWillBeSet()
+ {
+ $request = null;
+
+ $parser = new RequestHeaderParser();
+
+ $parser->on('headers', function ($parsedRequest) use (&$request) {
+ $request = $parsedRequest;
+ });
+
+ $parser->feed("GET /foo.php?hello=world&test=this HTTP/1.0\r\nHost: example.com\r\n\r\n");
+ $queryParams = $request->getQueryParams();
+
+ $this->assertEquals('world', $queryParams['hello']);
+ $this->assertEquals('this', $queryParams['test']);
+ }
+
private function createGetRequest()
{
$data = "GET / HTTP/1.1\r\n";
diff --git a/tests/RequestTest.php b/tests/RequestTest.php
deleted file mode 100644
index 1ad85221..00000000
--- a/tests/RequestTest.php
+++ /dev/null
@@ -1,26 +0,0 @@
-assertFalse($request->expectsContinue());
- }
-
- /** @test */
- public function expectsContinueShouldBeTrueIfContinueExpected()
- {
- $headers = array('Expect' => '100-continue');
- $request = new Request('GET', '/', array(), '1.1', $headers);
-
- $this->assertTrue($request->expectsContinue());
- }
-}
diff --git a/tests/ResponseTest.php b/tests/ResponseTest.php
index 7692fb83..68627626 100644
--- a/tests/ResponseTest.php
+++ b/tests/ResponseTest.php
@@ -3,203 +3,19 @@
namespace React\Tests\Http;
use React\Http\Response;
+use React\Stream\ThroughStream;
class ResponseTest extends TestCase
{
- public function testResponseShouldBeChunkedByDefault()
+ public function testResponseBodyWillBeHttpBodyStream()
{
- $expected = '';
- $expected .= "HTTP/1.1 200 OK\r\n";
- $expected .= "X-Powered-By: React/alpha\r\n";
- $expected .= "Transfer-Encoding: chunked\r\n";
- $expected .= "\r\n";
-
- $conn = $this->getMock('React\Socket\ConnectionInterface');
- $conn
- ->expects($this->once())
- ->method('write')
- ->with($expected);
-
- $response = new Response($conn);
- $response->writeHead();
- }
-
- public function testResponseShouldNotBeChunkedWithContentLength()
- {
- $expected = '';
- $expected .= "HTTP/1.1 200 OK\r\n";
- $expected .= "X-Powered-By: React/alpha\r\n";
- $expected .= "Content-Length: 22\r\n";
- $expected .= "\r\n";
-
- $conn = $this->getMock('React\Socket\ConnectionInterface');
- $conn
- ->expects($this->once())
- ->method('write')
- ->with($expected);
-
- $response = new Response($conn);
- $response->writeHead(200, array('Content-Length' => 22));
- }
-
- public function testResponseBodyShouldBeChunkedCorrectly()
- {
- $conn = $this->getMock('React\Socket\ConnectionInterface');
- $conn
- ->expects($this->at(4))
- ->method('write')
- ->with("5\r\nHello\r\n");
- $conn
- ->expects($this->at(5))
- ->method('write')
- ->with("1\r\n \r\n");
- $conn
- ->expects($this->at(6))
- ->method('write')
- ->with("6\r\nWorld\n\r\n");
- $conn
- ->expects($this->at(7))
- ->method('write')
- ->with("0\r\n\r\n");
-
- $response = new Response($conn);
- $response->writeHead();
-
- $response->write('Hello');
- $response->write(' ');
- $response->write("World\n");
- $response->end();
- }
-
- public function testResponseShouldEmitEndOnStreamEnd()
- {
- $ended = false;
-
- $conn = $this->getMock('React\Socket\ConnectionInterface');
- $response = new Response($conn);
-
- $response->on('end', function () use (&$ended) {
- $ended = true;
- });
- $response->end();
-
- $this->assertTrue($ended);
- }
-
- /** @test */
- public function writeContinueShouldSendContinueLineBeforeRealHeaders()
- {
- $conn = $this->getMock('React\Socket\ConnectionInterface');
- $conn
- ->expects($this->at(3))
- ->method('write')
- ->with("HTTP/1.1 100 Continue\r\n\r\n");
- $conn
- ->expects($this->at(4))
- ->method('write')
- ->with($this->stringContains("HTTP/1.1 200 OK\r\n"));
-
- $response = new Response($conn);
- $response->writeContinue();
- $response->writeHead();
+ $response = new Response(200, array(), new ThroughStream());
+ $this->assertInstanceOf('React\Http\HttpBodyStream', $response->getBody());
}
- /** @test */
- public function shouldForwardEndDrainAndErrorEvents()
+ public function testStringBodyWillBePsr7Stream()
{
- $conn = $this->getMock('React\Socket\ConnectionInterface');
- $conn
- ->expects($this->at(0))
- ->method('on')
- ->with('end', $this->isInstanceOf('Closure'));
- $conn
- ->expects($this->at(1))
- ->method('on')
- ->with('error', $this->isInstanceOf('Closure'));
- $conn
- ->expects($this->at(2))
- ->method('on')
- ->with('drain', $this->isInstanceOf('Closure'));
-
- $response = new Response($conn);
- }
-
- /** @test */
- public function shouldRemoveNewlinesFromHeaders()
- {
- $expected = '';
- $expected .= "HTTP/1.1 200 OK\r\n";
- $expected .= "X-Powered-By: React/alpha\r\n";
- $expected .= "FooBar: BazQux\r\n";
- $expected .= "Transfer-Encoding: chunked\r\n";
- $expected .= "\r\n";
-
- $conn = $this->getMock('React\Socket\ConnectionInterface');
- $conn
- ->expects($this->once())
- ->method('write')
- ->with($expected);
-
- $response = new Response($conn);
- $response->writeHead(200, array("Foo\nBar" => "Baz\rQux"));
- }
-
- /** @test */
- public function missingStatusCodeTextShouldResultInNumberOnlyStatus()
- {
- $expected = '';
- $expected .= "HTTP/1.1 700 \r\n";
- $expected .= "X-Powered-By: React/alpha\r\n";
- $expected .= "Transfer-Encoding: chunked\r\n";
- $expected .= "\r\n";
-
- $conn = $this->getMock('React\Socket\ConnectionInterface');
- $conn
- ->expects($this->once())
- ->method('write')
- ->with($expected);
-
- $response = new Response($conn);
- $response->writeHead(700);
- }
-
- /** @test */
- public function shouldAllowArrayHeaderValues()
- {
- $expected = '';
- $expected .= "HTTP/1.1 200 OK\r\n";
- $expected .= "X-Powered-By: React/alpha\r\n";
- $expected .= "Set-Cookie: foo=bar\r\n";
- $expected .= "Set-Cookie: bar=baz\r\n";
- $expected .= "Transfer-Encoding: chunked\r\n";
- $expected .= "\r\n";
-
- $conn = $this->getMock('React\Socket\ConnectionInterface');
- $conn
- ->expects($this->once())
- ->method('write')
- ->with($expected);
-
- $response = new Response($conn);
- $response->writeHead(200, array("Set-Cookie" => array("foo=bar", "bar=baz")));
- }
-
- /** @test */
- public function shouldIgnoreHeadersWithNullValues()
- {
- $expected = '';
- $expected .= "HTTP/1.1 200 OK\r\n";
- $expected .= "X-Powered-By: React/alpha\r\n";
- $expected .= "Transfer-Encoding: chunked\r\n";
- $expected .= "\r\n";
-
- $conn = $this->getMock('React\Socket\ConnectionInterface');
- $conn
- ->expects($this->once())
- ->method('write')
- ->with($expected);
-
- $response = new Response($conn);
- $response->writeHead(200, array("FooBar" => null));
+ $response = new Response(200, array(), 'hello');
+ $this->assertInstanceOf('RingCentral\Psr7\Stream', $response->getBody());
}
}
diff --git a/tests/ServerRequestTest.php b/tests/ServerRequestTest.php
new file mode 100644
index 00000000..a2c68a4a
--- /dev/null
+++ b/tests/ServerRequestTest.php
@@ -0,0 +1,192 @@
+request = new ServerRequest('GET', 'http://localhost');
+ }
+
+ public function testGetNoAttributes()
+ {
+ $this->assertEquals(array(), $this->request->getAttributes());
+ }
+
+ public function testWithAttribute()
+ {
+ $request = $this->request->withAttribute('hello', 'world');
+
+ $this->assertNotSame($request, $this->request);
+ $this->assertEquals(array('hello' => 'world'), $request->getAttributes());
+ }
+
+ public function testGetAttribute()
+ {
+ $request = $this->request->withAttribute('hello', 'world');
+
+ $this->assertNotSame($request, $this->request);
+ $this->assertEquals('world', $request->getAttribute('hello'));
+ }
+
+ public function testGetDefaultAttribute()
+ {
+ $request = $this->request->withAttribute('hello', 'world');
+
+ $this->assertNotSame($request, $this->request);
+ $this->assertEquals(null, $request->getAttribute('hi', null));
+ }
+
+ public function testWithoutAttribute()
+ {
+ $request = $this->request->withAttribute('hello', 'world');
+ $request = $request->withAttribute('test', 'nice');
+
+ $request = $request->withoutAttribute('hello');
+
+ $this->assertNotSame($request, $this->request);
+ $this->assertEquals(array('test' => 'nice'), $request->getAttributes());
+ }
+
+ public function testWithCookieParams()
+ {
+ $request = $this->request->withCookieParams(array('test' => 'world'));
+
+ $this->assertNotSame($request, $this->request);
+ $this->assertEquals(array('test' => 'world'), $request->getCookieParams());
+ }
+
+ public function testWithQueryParams()
+ {
+ $request = $this->request->withQueryParams(array('test' => 'world'));
+
+ $this->assertNotSame($request, $this->request);
+ $this->assertEquals(array('test' => 'world'), $request->getQueryParams());
+ }
+
+ public function testWithUploadedFiles()
+ {
+ $request = $this->request->withUploadedFiles(array('test' => 'world'));
+
+ $this->assertNotSame($request, $this->request);
+ $this->assertEquals(array('test' => 'world'), $request->getUploadedFiles());
+ }
+
+ public function testWithParsedBody()
+ {
+ $request = $this->request->withParsedBody(array('test' => 'world'));
+
+ $this->assertNotSame($request, $this->request);
+ $this->assertEquals(array('test' => 'world'), $request->getParsedBody());
+ }
+
+ public function testServerRequestParameter()
+ {
+ $body = 'hello=world';
+ $request = new ServerRequest(
+ 'POST',
+ 'http://127.0.0.1',
+ array('Content-Length' => strlen($body)),
+ $body,
+ '1.0',
+ array('SERVER_ADDR' => '127.0.0.1')
+ );
+
+ $serverParams = $request->getServerParams();
+ $this->assertEquals('POST', $request->getMethod());
+ $this->assertEquals('http://127.0.0.1', $request->getUri());
+ $this->assertEquals('11', $request->getHeaderLine('Content-Length'));
+ $this->assertEquals('hello=world', $request->getBody());
+ $this->assertEquals('1.0', $request->getProtocolVersion());
+ $this->assertEquals('127.0.0.1', $serverParams['SERVER_ADDR']);
+ }
+
+ public function testParseSingleCookieNameValuePairWillReturnValidArray()
+ {
+ $cookieString = 'hello=world';
+ $cookies = ServerRequest::parseCookie($cookieString);
+ $this->assertEquals(array('hello' => 'world'), $cookies);
+ }
+
+ public function testParseMultipleCookieNameValuePaiWillReturnValidArray()
+ {
+ $cookieString = 'hello=world; test=abc';
+ $cookies = ServerRequest::parseCookie($cookieString);
+ $this->assertEquals(array('hello' => 'world', 'test' => 'abc'), $cookies);
+ }
+
+ public function testParseMultipleCookieNameValuePairWillReturnFalse()
+ {
+ // Could be done through multiple 'Cookie' headers
+ // getHeaderLine('Cookie') will return a value seperated by comma
+ // e.g.
+ // GET / HTTP/1.1\r\n
+ // Host: test.org\r\n
+ // Cookie: hello=world\r\n
+ // Cookie: test=abc\r\n\r\n
+ $cookieString = 'hello=world,test=abc';
+ $cookies = ServerRequest::parseCookie($cookieString);
+ $this->assertEquals(false, $cookies);
+ }
+
+ public function testOnlyFirstSetWillBeAddedToCookiesArray()
+ {
+ $cookieString = 'hello=world; hello=abc';
+ $cookies = ServerRequest::parseCookie($cookieString);
+ $this->assertEquals(array('hello' => 'abc'), $cookies);
+ }
+
+ public function testOtherEqualSignsWillBeAddedToValueAndWillReturnValidArray()
+ {
+ $cookieString = 'hello=world=test=php';
+ $cookies = ServerRequest::parseCookie($cookieString);
+ $this->assertEquals(array('hello' => 'world=test=php'), $cookies);
+ }
+
+ public function testSingleCookieValueInCookiesReturnsEmptyArray()
+ {
+ $cookieString = 'world';
+ $cookies = ServerRequest::parseCookie($cookieString);
+ $this->assertEquals(array(), $cookies);
+ }
+
+ public function testSingleMutlipleCookieValuesReturnsEmptyArray()
+ {
+ $cookieString = 'world; test';
+ $cookies = ServerRequest::parseCookie($cookieString);
+ $this->assertEquals(array(), $cookies);
+ }
+
+ public function testSingleValueIsValidInMultipleValueCookieWillReturnValidArray()
+ {
+ $cookieString = 'world; test=php';
+ $cookies = ServerRequest::parseCookie($cookieString);
+ $this->assertEquals(array('test' => 'php'), $cookies);
+ }
+
+ public function testUrlEncodingForValueWillReturnValidArray()
+ {
+ $cookieString = 'hello=world%21; test=100%25%20coverage';
+ $cookies = ServerRequest::parseCookie($cookieString);
+ $this->assertEquals(array('hello' => 'world!', 'test' => '100% coverage'), $cookies);
+ }
+
+ public function testUrlEncodingForKeyWillReturnValidArray()
+ {
+ $cookieString = 'react%3Bphp=is%20great';
+ $cookies = ServerRequest::parseCookie($cookieString);
+ $this->assertEquals(array('react;php' => 'is great'), $cookies);
+ }
+
+ public function testCookieWithoutSpaceAfterSeparatorWillBeAccepted()
+ {
+ $cookieString = 'hello=world;react=php';
+ $cookies = ServerRequest::parseCookie($cookieString);
+ $this->assertEquals(array('hello' => 'world', 'react' => 'php'), $cookies);
+ }
+}
diff --git a/tests/ServerStub.php b/tests/ServerStub.php
deleted file mode 100644
index fc55e972..00000000
--- a/tests/ServerStub.php
+++ /dev/null
@@ -1,22 +0,0 @@
-connection = $this->getMockBuilder('React\Socket\Connection')
+ ->disableOriginalConstructor()
+ ->setMethods(
+ array(
+ 'write',
+ 'end',
+ 'close',
+ 'pause',
+ 'resume',
+ 'isReadable',
+ 'isWritable',
+ 'getRemoteAddress',
+ 'getLocalAddress',
+ 'pipe'
+ )
+ )
+ ->getMock();
+
+ $this->connection->method('isWritable')->willReturn(true);
+ $this->connection->method('isReadable')->willReturn(true);
+
+ $this->socket = new SocketServerStub();
+ }
+
+ public function testRequestEventWillNotBeEmittedForIncompleteHeaders()
{
- $io = new ServerStub();
+ $server = new Server($this->expectCallableNever());
+
+ $server->listen($this->socket);
+ $this->socket->emit('connection', array($this->connection));
- $server = new Server($io);
- $server->on('request', $this->expectCallableOnce());
+ $data = '';
+ $data .= "GET / HTTP/1.1\r\n";
+ $this->connection->emit('data', array($data));
+ }
+
+ public function testRequestEventIsEmitted()
+ {
+ $server = new Server(function (ServerRequestInterface $request) {
+ return \React\Promise\resolve(new Response());
+ });
- $conn = new ConnectionStub();
- $io->emit('connection', array($conn));
+ $server->listen($this->socket);
+ $this->socket->emit('connection', array($this->connection));
$data = $this->createGetRequest();
- $conn->emit('data', array($data));
+ $this->connection->emit('data', array($data));
}
public function testRequestEvent()
{
- $io = new ServerStub();
-
$i = 0;
-
- $server = new Server($io);
- $server->on('request', function ($request, $response) use (&$i) {
+ $requestAssertion = null;
+ $server = new Server(function (ServerRequestInterface $request) use (&$i, &$requestAssertion) {
$i++;
+ $requestAssertion = $request;
- $this->assertInstanceOf('React\Http\Request', $request);
- $this->assertSame('/', $request->getPath());
- $this->assertSame('GET', $request->getMethod());
- $this->assertSame('127.0.0.1', $request->remoteAddress);
-
- $this->assertInstanceOf('React\Http\Response', $response);
+ return \React\Promise\resolve(new Response());
});
- $conn = new ConnectionStub();
- $io->emit('connection', array($conn));
+ $this->connection
+ ->expects($this->any())
+ ->method('getRemoteAddress')
+ ->willReturn('127.0.0.1:8080');
+
+ $server->listen($this->socket);
+ $this->socket->emit('connection', array($this->connection));
$data = $this->createGetRequest();
- $conn->emit('data', array($data));
+ $this->connection->emit('data', array($data));
+
+ $serverParams = $requestAssertion->getServerParams();
$this->assertSame(1, $i);
+ $this->assertInstanceOf('RingCentral\Psr7\Request', $requestAssertion);
+ $this->assertSame('GET', $requestAssertion->getMethod());
+ $this->assertSame('/', $requestAssertion->getRequestTarget());
+ $this->assertSame('/', $requestAssertion->getUri()->getPath());
+ $this->assertSame(array(), $requestAssertion->getQueryParams());
+ $this->assertSame('http://example.com/', (string)$requestAssertion->getUri());
+ $this->assertSame('example.com', $requestAssertion->getHeaderLine('Host'));
+ $this->assertSame('127.0.0.1', $serverParams['REMOTE_ADDR']);
+ }
+
+ public function testRequestGetWithHostAndCustomPort()
+ {
+ $requestAssertion = null;
+ $server = new Server(function (ServerRequestInterface $request) use (&$requestAssertion) {
+ $requestAssertion = $request;
+ return new Response();
+ });
+
+ $server->listen($this->socket);
+ $this->socket->emit('connection', array($this->connection));
+
+ $data = "GET / HTTP/1.1\r\nHost: example.com:8080\r\n\r\n";
+ $this->connection->emit('data', array($data));
+
+ $this->assertInstanceOf('RingCentral\Psr7\Request', $requestAssertion);
+ $this->assertSame('GET', $requestAssertion->getMethod());
+ $this->assertSame('/', $requestAssertion->getRequestTarget());
+ $this->assertSame('/', $requestAssertion->getUri()->getPath());
+ $this->assertSame('http://example.com:8080/', (string)$requestAssertion->getUri());
+ $this->assertSame(8080, $requestAssertion->getUri()->getPort());
+ $this->assertSame('example.com:8080', $requestAssertion->getHeaderLine('Host'));
+ }
+
+ public function testRequestGetWithHostAndHttpsPort()
+ {
+ $requestAssertion = null;
+ $server = new Server(function (ServerRequestInterface $request) use (&$requestAssertion) {
+ $requestAssertion = $request;
+ return new Response();
+ });
+
+ $server->listen($this->socket);
+ $this->socket->emit('connection', array($this->connection));
+
+ $data = "GET / HTTP/1.1\r\nHost: example.com:443\r\n\r\n";
+ $this->connection->emit('data', array($data));
+
+ $this->assertInstanceOf('RingCentral\Psr7\Request', $requestAssertion);
+ $this->assertSame('GET', $requestAssertion->getMethod());
+ $this->assertSame('/', $requestAssertion->getRequestTarget());
+ $this->assertSame('/', $requestAssertion->getUri()->getPath());
+ $this->assertSame('http://example.com:443/', (string)$requestAssertion->getUri());
+ $this->assertSame(443, $requestAssertion->getUri()->getPort());
+ $this->assertSame('example.com:443', $requestAssertion->getHeaderLine('Host'));
+ }
+
+ public function testRequestGetWithHostAndDefaultPortWillBeIgnored()
+ {
+ $requestAssertion = null;
+ $server = new Server(function (ServerRequestInterface $request) use (&$requestAssertion) {
+ $requestAssertion = $request;
+ return new Response();
+ });
+
+ $server->listen($this->socket);
+ $this->socket->emit('connection', array($this->connection));
+
+ $data = "GET / HTTP/1.1\r\nHost: example.com:80\r\n\r\n";
+ $this->connection->emit('data', array($data));
+
+ $this->assertInstanceOf('RingCentral\Psr7\Request', $requestAssertion);
+ $this->assertSame('GET', $requestAssertion->getMethod());
+ $this->assertSame('/', $requestAssertion->getRequestTarget());
+ $this->assertSame('/', $requestAssertion->getUri()->getPath());
+ $this->assertSame('http://example.com/', (string)$requestAssertion->getUri());
+ $this->assertSame(null, $requestAssertion->getUri()->getPort());
+ $this->assertSame('example.com', $requestAssertion->getHeaderLine('Host'));
+ }
+
+ public function testRequestOptionsAsterisk()
+ {
+ $requestAssertion = null;
+ $server = new Server(function (ServerRequestInterface $request) use (&$requestAssertion) {
+ $requestAssertion = $request;
+ return new Response();
+ });
+
+ $server->listen($this->socket);
+ $this->socket->emit('connection', array($this->connection));
+
+ $data = "OPTIONS * HTTP/1.1\r\nHost: example.com\r\n\r\n";
+ $this->connection->emit('data', array($data));
+
+ $this->assertInstanceOf('RingCentral\Psr7\Request', $requestAssertion);
+ $this->assertSame('OPTIONS', $requestAssertion->getMethod());
+ $this->assertSame('*', $requestAssertion->getRequestTarget());
+ $this->assertSame('', $requestAssertion->getUri()->getPath());
+ $this->assertSame('http://example.com', (string)$requestAssertion->getUri());
+ $this->assertSame('example.com', $requestAssertion->getHeaderLine('Host'));
+ }
+
+ public function testRequestNonOptionsWithAsteriskRequestTargetWillReject()
+ {
+ $server = new Server($this->expectCallableNever());
+ $server->on('error', $this->expectCallableOnce());
+
+ $server->listen($this->socket);
+ $this->socket->emit('connection', array($this->connection));
+
+ $data = "GET * HTTP/1.1\r\nHost: example.com\r\n\r\n";
+ $this->connection->emit('data', array($data));
+ }
+
+ public function testRequestConnectAuthorityForm()
+ {
+ $requestAssertion = null;
+ $server = new Server(function (ServerRequestInterface $request) use (&$requestAssertion) {
+ $requestAssertion = $request;
+ return new Response();
+ });
+
+ $server->listen($this->socket);
+ $this->socket->emit('connection', array($this->connection));
+
+ $data = "CONNECT example.com:443 HTTP/1.1\r\nHost: example.com:443\r\n\r\n";
+ $this->connection->emit('data', array($data));
+
+ $this->assertInstanceOf('RingCentral\Psr7\Request', $requestAssertion);
+ $this->assertSame('CONNECT', $requestAssertion->getMethod());
+ $this->assertSame('example.com:443', $requestAssertion->getRequestTarget());
+ $this->assertSame('', $requestAssertion->getUri()->getPath());
+ $this->assertSame('http://example.com:443', (string)$requestAssertion->getUri());
+ $this->assertSame(443, $requestAssertion->getUri()->getPort());
+ $this->assertSame('example.com:443', $requestAssertion->getHeaderLine('Host'));
+ }
+
+ public function testRequestConnectAuthorityFormWithDefaultPortWillBeIgnored()
+ {
+ $requestAssertion = null;
+ $server = new Server(function (ServerRequestInterface $request) use (&$requestAssertion) {
+ $requestAssertion = $request;
+ return new Response();
+ });
+
+ $server->listen($this->socket);
+ $this->socket->emit('connection', array($this->connection));
+
+ $data = "CONNECT example.com:80 HTTP/1.1\r\nHost: example.com:80\r\n\r\n";
+ $this->connection->emit('data', array($data));
+
+ $this->assertInstanceOf('RingCentral\Psr7\Request', $requestAssertion);
+ $this->assertSame('CONNECT', $requestAssertion->getMethod());
+ $this->assertSame('example.com:80', $requestAssertion->getRequestTarget());
+ $this->assertSame('', $requestAssertion->getUri()->getPath());
+ $this->assertSame('http://example.com', (string)$requestAssertion->getUri());
+ $this->assertSame(null, $requestAssertion->getUri()->getPort());
+ $this->assertSame('example.com', $requestAssertion->getHeaderLine('Host'));
+ }
+
+ public function testRequestConnectAuthorityFormNonMatchingHostWillBeOverwritten()
+ {
+ $requestAssertion = null;
+ $server = new Server(function (ServerRequestInterface $request) use (&$requestAssertion) {
+ $requestAssertion = $request;
+ return new Response();
+ });
+
+ $server->listen($this->socket);
+ $this->socket->emit('connection', array($this->connection));
+
+ $data = "CONNECT example.com:80 HTTP/1.1\r\nHost: other.example.org\r\n\r\n";
+ $this->connection->emit('data', array($data));
+
+ $this->assertInstanceOf('RingCentral\Psr7\Request', $requestAssertion);
+ $this->assertSame('CONNECT', $requestAssertion->getMethod());
+ $this->assertSame('example.com:80', $requestAssertion->getRequestTarget());
+ $this->assertSame('', $requestAssertion->getUri()->getPath());
+ $this->assertSame('http://example.com', (string)$requestAssertion->getUri());
+ $this->assertSame(null, $requestAssertion->getUri()->getPort());
+ $this->assertSame('example.com', $requestAssertion->getHeaderLine('Host'));
+ }
+
+ public function testRequestConnectOriginFormRequestTargetWillReject()
+ {
+ $server = new Server($this->expectCallableNever());
+ $server->on('error', $this->expectCallableOnce());
+
+ $server->listen($this->socket);
+ $this->socket->emit('connection', array($this->connection));
+
+ $data = "CONNECT / HTTP/1.1\r\nHost: example.com\r\n\r\n";
+ $this->connection->emit('data', array($data));
+ }
+
+ public function testRequestNonConnectWithAuthorityRequestTargetWillReject()
+ {
+ $server = new Server($this->expectCallableNever());
+ $server->on('error', $this->expectCallableOnce());
+
+ $server->listen($this->socket);
+ $this->socket->emit('connection', array($this->connection));
+
+ $data = "GET example.com:80 HTTP/1.1\r\nHost: example.com\r\n\r\n";
+ $this->connection->emit('data', array($data));
+ }
+
+ public function testRequestWithoutHostEventUsesSocketAddress()
+ {
+ $requestAssertion = null;
+
+ $server = new Server(function (ServerRequestInterface $request) use (&$requestAssertion) {
+ $requestAssertion = $request;
+ return new Response();
+ });
+
+ $this->connection
+ ->expects($this->any())
+ ->method('getLocalAddress')
+ ->willReturn('127.0.0.1:80');
+
+ $server->listen($this->socket);
+ $this->socket->emit('connection', array($this->connection));
+
+ $data = "GET /test HTTP/1.0\r\n\r\n";
+ $this->connection->emit('data', array($data));
+
+ $this->assertInstanceOf('RingCentral\Psr7\Request', $requestAssertion);
+ $this->assertSame('GET', $requestAssertion->getMethod());
+ $this->assertSame('/test', $requestAssertion->getRequestTarget());
+ $this->assertEquals('http://127.0.0.1/test', $requestAssertion->getUri());
+ $this->assertSame('/test', $requestAssertion->getUri()->getPath());
+ }
+
+ public function testRequestAbsoluteEvent()
+ {
+ $requestAssertion = null;
+
+ $server = new Server(function (ServerRequestInterface $request) use (&$requestAssertion) {
+ $requestAssertion = $request;
+ return new Response();
+ });
+
+ $server->listen($this->socket);
+ $this->socket->emit('connection', array($this->connection));
+
+ $data = "GET http://example.com/test HTTP/1.1\r\nHost: example.com\r\n\r\n";
+ $this->connection->emit('data', array($data));
+
+ $this->assertInstanceOf('RingCentral\Psr7\Request', $requestAssertion);
+ $this->assertSame('GET', $requestAssertion->getMethod());
+ $this->assertSame('http://example.com/test', $requestAssertion->getRequestTarget());
+ $this->assertEquals('http://example.com/test', $requestAssertion->getUri());
+ $this->assertSame('/test', $requestAssertion->getUri()->getPath());
+ $this->assertSame('example.com', $requestAssertion->getHeaderLine('Host'));
+ }
+
+ public function testRequestAbsoluteAddsMissingHostEvent()
+ {
+ $requestAssertion = null;
+
+ $server = new Server(function (ServerRequestInterface $request) use (&$requestAssertion) {
+ $requestAssertion = $request;
+ return new Response();
+ });
+ $server->on('error', 'printf');
+
+ $server->listen($this->socket);
+ $this->socket->emit('connection', array($this->connection));
+
+ $data = "GET http://example.com:8080/test HTTP/1.0\r\n\r\n";
+ $this->connection->emit('data', array($data));
+
+ $this->assertInstanceOf('RingCentral\Psr7\Request', $requestAssertion);
+ $this->assertSame('GET', $requestAssertion->getMethod());
+ $this->assertSame('http://example.com:8080/test', $requestAssertion->getRequestTarget());
+ $this->assertEquals('http://example.com:8080/test', $requestAssertion->getUri());
+ $this->assertSame('/test', $requestAssertion->getUri()->getPath());
+ $this->assertSame('example.com:8080', $requestAssertion->getHeaderLine('Host'));
+ }
+
+ public function testRequestAbsoluteNonMatchingHostWillBeOverwritten()
+ {
+ $requestAssertion = null;
+
+ $server = new Server(function (ServerRequestInterface $request) use (&$requestAssertion) {
+ $requestAssertion = $request;
+ return new Response();
+ });
+
+ $server->listen($this->socket);
+ $this->socket->emit('connection', array($this->connection));
+
+ $data = "GET http://example.com/test HTTP/1.1\r\nHost: other.example.org\r\n\r\n";
+ $this->connection->emit('data', array($data));
+
+ $this->assertInstanceOf('RingCentral\Psr7\Request', $requestAssertion);
+ $this->assertSame('GET', $requestAssertion->getMethod());
+ $this->assertSame('http://example.com/test', $requestAssertion->getRequestTarget());
+ $this->assertEquals('http://example.com/test', $requestAssertion->getUri());
+ $this->assertSame('/test', $requestAssertion->getUri()->getPath());
+ $this->assertSame('example.com', $requestAssertion->getHeaderLine('Host'));
+ }
+
+ public function testRequestOptionsAsteriskEvent()
+ {
+ $requestAssertion = null;
+
+ $server = new Server(function (ServerRequestInterface $request) use (&$requestAssertion) {
+ $requestAssertion = $request;
+ return new Response();
+ });
+
+ $server->listen($this->socket);
+ $this->socket->emit('connection', array($this->connection));
+
+ $data = "OPTIONS * HTTP/1.1\r\nHost: example.com\r\n\r\n";
+ $this->connection->emit('data', array($data));
+
+ $this->assertInstanceOf('RingCentral\Psr7\Request', $requestAssertion);
+ $this->assertSame('OPTIONS', $requestAssertion->getMethod());
+ $this->assertSame('*', $requestAssertion->getRequestTarget());
+ $this->assertEquals('http://example.com', $requestAssertion->getUri());
+ $this->assertSame('', $requestAssertion->getUri()->getPath());
+ $this->assertSame('example.com', $requestAssertion->getHeaderLine('Host'));
+ }
+
+ public function testRequestOptionsAbsoluteEvent()
+ {
+ $requestAssertion = null;
+
+ $server = new Server(function (ServerRequestInterface $request) use (&$requestAssertion) {
+ $requestAssertion = $request;
+ return new Response();
+ });
+
+ $server->listen($this->socket);
+ $this->socket->emit('connection', array($this->connection));
+
+ $data = "OPTIONS http://example.com HTTP/1.1\r\nHost: example.com\r\n\r\n";
+ $this->connection->emit('data', array($data));
+
+ $this->assertInstanceOf('RingCentral\Psr7\Request', $requestAssertion);
+ $this->assertSame('OPTIONS', $requestAssertion->getMethod());
+ $this->assertSame('http://example.com', $requestAssertion->getRequestTarget());
+ $this->assertEquals('http://example.com', $requestAssertion->getUri());
+ $this->assertSame('', $requestAssertion->getUri()->getPath());
+ $this->assertSame('example.com', $requestAssertion->getHeaderLine('Host'));
+ }
+
+ public function testRequestPauseWillbeForwardedToConnection()
+ {
+ $server = new Server(function (ServerRequestInterface $request) {
+ $request->getBody()->pause();
+ return new Response();
+ });
+
+ $this->connection->expects($this->once())->method('pause');
+
+ $server->listen($this->socket);
+ $this->socket->emit('connection', array($this->connection));
+
+ $data = "GET / HTTP/1.1\r\n";
+ $data .= "Host: example.com:80\r\n";
+ $data .= "Connection: close\r\n";
+ $data .= "Content-Length: 5\r\n";
+ $data .= "\r\n";
+
+ $this->connection->emit('data', array($data));
+ }
+
+ public function testRequestResumeWillbeForwardedToConnection()
+ {
+ $server = new Server(function (ServerRequestInterface $request) {
+ $request->getBody()->resume();
+ return new Response();
+ });
+
+ $this->connection->expects($this->once())->method('resume');
+
+ $server->listen($this->socket);
+ $this->socket->emit('connection', array($this->connection));
+
+ $data = $this->createGetRequest();
+ $this->connection->emit('data', array($data));
+ }
+
+ public function testRequestCloseWillPauseConnection()
+ {
+ $server = new Server(function (ServerRequestInterface $request) {
+ $request->getBody()->close();
+ return new Response();
+ });
+
+ $this->connection->expects($this->once())->method('pause');
+
+ $server->listen($this->socket);
+ $this->socket->emit('connection', array($this->connection));
+
+ $data = $this->createGetRequest();
+ $this->connection->emit('data', array($data));
+ }
+
+ public function testRequestPauseAfterCloseWillNotBeForwarded()
+ {
+ $server = new Server(function (ServerRequestInterface $request) {
+ $request->getBody()->close();
+ $request->getBody()->pause();#
+
+ return new Response();
+ });
+
+ $this->connection->expects($this->once())->method('pause');
+
+ $server->listen($this->socket);
+ $this->socket->emit('connection', array($this->connection));
+
+ $data = $this->createGetRequest();
+ $this->connection->emit('data', array($data));
+ }
+
+ public function testRequestResumeAfterCloseWillNotBeForwarded()
+ {
+ $server = new Server(function (ServerRequestInterface $request) {
+ $request->getBody()->close();
+ $request->getBody()->resume();
+
+ return new Response();
+ });
+
+ $this->connection->expects($this->once())->method('pause');
+ $this->connection->expects($this->never())->method('resume');
+
+ $server->listen($this->socket);
+ $this->socket->emit('connection', array($this->connection));
+
+ $data = $this->createGetRequest();
+ $this->connection->emit('data', array($data));
+ }
+
+ public function testRequestEventWithoutBodyWillNotEmitData()
+ {
+ $never = $this->expectCallableNever();
+
+ $server = new Server(function (ServerRequestInterface $request) use ($never) {
+ $request->getBody()->on('data', $never);
+
+ return new Response();
+ });
+
+ $server->listen($this->socket);
+ $this->socket->emit('connection', array($this->connection));
+
+ $data = $this->createGetRequest();
+ $this->connection->emit('data', array($data));
+ }
+
+ public function testRequestEventWithSecondDataEventWillEmitBodyData()
+ {
+ $once = $this->expectCallableOnceWith('incomplete');
+
+ $server = new Server(function (ServerRequestInterface $request) use ($once) {
+ $request->getBody()->on('data', $once);
+
+ return new Response();
+ });
+
+ $server->listen($this->socket);
+ $this->socket->emit('connection', array($this->connection));
+
+ $data = '';
+ $data .= "POST / HTTP/1.1\r\n";
+ $data .= "Host: localhost\r\n";
+ $data .= "Content-Length: 100\r\n";
+ $data .= "\r\n";
+ $data .= "incomplete";
+ $this->connection->emit('data', array($data));
+ }
+
+ public function testRequestEventWithPartialBodyWillEmitData()
+ {
+ $once = $this->expectCallableOnceWith('incomplete');
+
+ $server = new Server(function (ServerRequestInterface $request) use ($once) {
+ $request->getBody()->on('data', $once);
+
+ return new Response();
+ });
+
+ $server->listen($this->socket);
+ $this->socket->emit('connection', array($this->connection));
+
+ $data = '';
+ $data .= "POST / HTTP/1.1\r\n";
+ $data .= "Host: localhost\r\n";
+ $data .= "Content-Length: 100\r\n";
+ $data .= "\r\n";
+ $this->connection->emit('data', array($data));
+
+ $data = '';
+ $data .= "incomplete";
+ $this->connection->emit('data', array($data));
}
public function testResponseContainsPoweredByHeader()
{
- $io = new ServerStub();
+ $server = new Server(function (ServerRequestInterface $request) {
+ return new Response();
+ });
+
+ $buffer = '';
+
+ $this->connection
+ ->expects($this->any())
+ ->method('write')
+ ->will(
+ $this->returnCallback(
+ function ($data) use (&$buffer) {
+ $buffer .= $data;
+ }
+ )
+ );
+
+ $server->listen($this->socket);
+ $this->socket->emit('connection', array($this->connection));
+
+ $data = $this->createGetRequest();
+ $this->connection->emit('data', array($data));
+
+ $this->assertContains("\r\nX-Powered-By: React/alpha\r\n", $buffer);
+ }
+
+ public function testPendingPromiseWillNotSendAnything()
+ {
+ $never = $this->expectCallableNever();
+
+ $server = new Server(function (ServerRequestInterface $request) use ($never) {
+ return new Promise(function () { }, $never);
+ });
+
+ $buffer = '';
+
+ $this->connection
+ ->expects($this->any())
+ ->method('write')
+ ->will(
+ $this->returnCallback(
+ function ($data) use (&$buffer) {
+ $buffer .= $data;
+ }
+ )
+ );
+
+ $server->listen($this->socket);
+ $this->socket->emit('connection', array($this->connection));
+
+ $data = $this->createGetRequest();
+ $this->connection->emit('data', array($data));
+
+ $this->assertEquals('', $buffer);
+ }
+
+ public function testPendingPromiseWillBeCancelledIfConnectionCloses()
+ {
+ $once = $this->expectCallableOnce();
- $server = new Server($io);
- $server->on('request', function ($request, $response) {
- $response->writeHead();
- $response->end();
+ $server = new Server(function (ServerRequestInterface $request) use ($once) {
+ return new Promise(function () { }, $once);
});
- $conn = new ConnectionStub();
- $io->emit('connection', array($conn));
+ $buffer = '';
+
+ $this->connection
+ ->expects($this->any())
+ ->method('write')
+ ->will(
+ $this->returnCallback(
+ function ($data) use (&$buffer) {
+ $buffer .= $data;
+ }
+ )
+ );
+
+ $server->listen($this->socket);
+ $this->socket->emit('connection', array($this->connection));
$data = $this->createGetRequest();
- $conn->emit('data', array($data));
+ $this->connection->emit('data', array($data));
+ $this->connection->emit('close');
+
+ $this->assertEquals('', $buffer);
+ }
+
+ public function testStreamAlreadyClosedWillSendEmptyBodyChunkedEncoded()
+ {
+ $stream = new ThroughStream();
+ $stream->close();
+
+ $server = new Server(function (ServerRequestInterface $request) use ($stream) {
+ return new Response(200, array(), $stream);
+ });
+
+ $buffer = '';
+
+ $this->connection
+ ->expects($this->any())
+ ->method('write')
+ ->will(
+ $this->returnCallback(
+ function ($data) use (&$buffer) {
+ $buffer .= $data;
+ }
+ )
+ );
+
+ $server->listen($this->socket);
+ $this->socket->emit('connection', array($this->connection));
+
+ $data = "GET / HTTP/1.1\r\nHost: localhost\r\n\r\n";
+ $this->connection->emit('data', array($data));
+
+ $this->assertStringStartsWith("HTTP/1.1 200 OK\r\n", $buffer);
+ $this->assertStringEndsWith("\r\n\r\n0\r\n\r\n", $buffer);
+ }
+
+ public function testResponseStreamEndingWillSendEmptyBodyChunkedEncoded()
+ {
+ $stream = new ThroughStream();
+
+ $server = new Server(function (ServerRequestInterface $request) use ($stream) {
+ return new Response(200, array(), $stream);
+ });
+
+ $buffer = '';
+
+ $this->connection
+ ->expects($this->any())
+ ->method('write')
+ ->will(
+ $this->returnCallback(
+ function ($data) use (&$buffer) {
+ $buffer .= $data;
+ }
+ )
+ );
+
+ $server->listen($this->socket);
+ $this->socket->emit('connection', array($this->connection));
+
+ $data = "GET / HTTP/1.1\r\nHost: localhost\r\n\r\n";
+ $this->connection->emit('data', array($data));
+
+ $stream->end();
+
+ $this->assertStringStartsWith("HTTP/1.1 200 OK\r\n", $buffer);
+ $this->assertStringEndsWith("\r\n\r\n0\r\n\r\n", $buffer);
+ }
+
+ public function testResponseStreamAlreadyClosedWillSendEmptyBodyPlainHttp10()
+ {
+ $stream = new ThroughStream();
+ $stream->close();
+
+ $server = new Server(function (ServerRequestInterface $request) use ($stream) {
+ return new Response(200, array(), $stream);
+ });
+
+ $buffer = '';
+
+ $this->connection
+ ->expects($this->any())
+ ->method('write')
+ ->will(
+ $this->returnCallback(
+ function ($data) use (&$buffer) {
+ $buffer .= $data;
+ }
+ )
+ );
+
+ $server->listen($this->socket);
+ $this->socket->emit('connection', array($this->connection));
+
+ $data = "GET / HTTP/1.0\r\nHost: localhost\r\n\r\n";
+ $this->connection->emit('data', array($data));
+
+ $this->assertStringStartsWith("HTTP/1.0 200 OK\r\n", $buffer);
+ $this->assertStringEndsWith("\r\n\r\n", $buffer);
+ }
+
+ public function testResponseStreamWillBeClosedIfConnectionIsAlreadyClosed()
+ {
+ $stream = new ThroughStream();
+ $stream->on('close', $this->expectCallableOnce());
+
+ $server = new Server(function (ServerRequestInterface $request) use ($stream) {
+ return new Response(200, array(), $stream);
+ });
+
+ $buffer = '';
+
+ $this->connection
+ ->expects($this->any())
+ ->method('write')
+ ->will(
+ $this->returnCallback(
+ function ($data) use (&$buffer) {
+ $buffer .= $data;
+ }
+ )
+ );
+
+ $this->connection = $this->getMockBuilder('React\Socket\Connection')
+ ->disableOriginalConstructor()
+ ->setMethods(
+ array(
+ 'write',
+ 'end',
+ 'close',
+ 'pause',
+ 'resume',
+ 'isReadable',
+ 'isWritable',
+ 'getRemoteAddress',
+ 'getLocalAddress',
+ 'pipe'
+ )
+ )
+ ->getMock();
+
+ $this->connection->expects($this->once())->method('isWritable')->willReturn(false);
+ $this->connection->expects($this->never())->method('write');
+ $this->connection->expects($this->never())->method('write');
+
+ $server->listen($this->socket);
+ $this->socket->emit('connection', array($this->connection));
+
+ $data = $this->createGetRequest();
+ $this->connection->emit('data', array($data));
+ }
+
+ public function testResponseStreamWillBeClosedIfConnectionEmitsCloseEvent()
+ {
+ $stream = new ThroughStream();
+ $stream->on('close', $this->expectCallableOnce());
+
+ $server = new Server(function (ServerRequestInterface $request) use ($stream) {
+ return new Response(200, array(), $stream);
+ });
+
+ $server->listen($this->socket);
+ $this->socket->emit('connection', array($this->connection));
+
+ $data = $this->createGetRequest();
+ $this->connection->emit('data', array($data));
+ $this->connection->emit('close');
+ }
+
+ public function testUpgradeInResponseCanBeUsedToAdvertisePossibleUpgrade()
+ {
+ $server = new Server(function (ServerRequestInterface $request) {
+ return new Response(200, array('date' => '', 'x-powered-by' => '', 'Upgrade' => 'demo'), 'foo');
+ });
+
+ $buffer = '';
+
+ $this->connection
+ ->expects($this->any())
+ ->method('write')
+ ->will(
+ $this->returnCallback(
+ function ($data) use (&$buffer) {
+ $buffer .= $data;
+ }
+ )
+ );
+
+ $server->listen($this->socket);
+ $this->socket->emit('connection', array($this->connection));
+
+ $data = "GET / HTTP/1.1\r\n\r\n";
+ $this->connection->emit('data', array($data));
+
+ $this->assertEquals("HTTP/1.1 200 OK\r\nUpgrade: demo\r\nContent-Length: 3\r\nConnection: close\r\n\r\nfoo", $buffer);
+ }
+
+ public function testUpgradeWishInRequestCanBeIgnoredByReturningNormalResponse()
+ {
+ $server = new Server(function (ServerRequestInterface $request) {
+ return new Response(200, array('date' => '', 'x-powered-by' => ''), 'foo');
+ });
+
+ $buffer = '';
+
+ $this->connection
+ ->expects($this->any())
+ ->method('write')
+ ->will(
+ $this->returnCallback(
+ function ($data) use (&$buffer) {
+ $buffer .= $data;
+ }
+ )
+ );
+
+ $server->listen($this->socket);
+ $this->socket->emit('connection', array($this->connection));
+
+ $data = "GET / HTTP/1.1\r\nUpgrade: demo\r\n\r\n";
+ $this->connection->emit('data', array($data));
+
+ $this->assertEquals("HTTP/1.1 200 OK\r\nContent-Length: 3\r\nConnection: close\r\n\r\nfoo", $buffer);
+ }
+
+ public function testUpgradeSwitchingProtocolIncludesConnectionUpgradeHeaderWithoutContentLength()
+ {
+ $server = new Server(function (ServerRequestInterface $request) {
+ return new Response(101, array('date' => '', 'x-powered-by' => '', 'Upgrade' => 'demo'), 'foo');
+ });
+
+ $server->on('error', 'printf');
+
+ $buffer = '';
+
+ $this->connection
+ ->expects($this->any())
+ ->method('write')
+ ->will(
+ $this->returnCallback(
+ function ($data) use (&$buffer) {
+ $buffer .= $data;
+ }
+ )
+ );
+
+ $server->listen($this->socket);
+ $this->socket->emit('connection', array($this->connection));
+
+ $data = "GET / HTTP/1.1\r\nUpgrade: demo\r\n\r\n";
+ $this->connection->emit('data', array($data));
+
+ $this->assertEquals("HTTP/1.1 101 Switching Protocols\r\nUpgrade: demo\r\nConnection: upgrade\r\n\r\nfoo", $buffer);
+ }
+
+ public function testUpgradeSwitchingProtocolWithStreamWillPipeDataToConnection()
+ {
+ $stream = new ThroughStream();
+
+ $server = new Server(function (ServerRequestInterface $request) use ($stream) {
+ return new Response(101, array('date' => '', 'x-powered-by' => '', 'Upgrade' => 'demo'), $stream);
+ });
+
+ $buffer = '';
+
+ $this->connection
+ ->expects($this->any())
+ ->method('write')
+ ->will(
+ $this->returnCallback(
+ function ($data) use (&$buffer) {
+ $buffer .= $data;
+ }
+ )
+ );
+
+ $server->listen($this->socket);
+ $this->socket->emit('connection', array($this->connection));
+
+ $data = "GET / HTTP/1.1\r\nUpgrade: demo\r\n\r\n";
+ $this->connection->emit('data', array($data));
+
+ $stream->write('hello');
+ $stream->write('world');
+
+ $this->assertEquals("HTTP/1.1 101 Switching Protocols\r\nUpgrade: demo\r\nConnection: upgrade\r\n\r\nhelloworld", $buffer);
+ }
+
+ public function testConnectResponseStreamWillPipeDataToConnection()
+ {
+ $stream = new ThroughStream();
+
+ $server = new Server(function (ServerRequestInterface $request) use ($stream) {
+ return new Response(200, array(), $stream);
+ });
+
+ $buffer = '';
+
+ $this->connection
+ ->expects($this->any())
+ ->method('write')
+ ->will(
+ $this->returnCallback(
+ function ($data) use (&$buffer) {
+ $buffer .= $data;
+ }
+ )
+ );
+
+ $server->listen($this->socket);
+ $this->socket->emit('connection', array($this->connection));
+
+ $data = "CONNECT example.com:80 HTTP/1.1\r\nHost: example.com:80\r\n\r\n";
+ $this->connection->emit('data', array($data));
+
+ $stream->write('hello');
+ $stream->write('world');
+
+ $this->assertStringEndsWith("\r\n\r\nhelloworld", $buffer);
+ }
+
+
+ public function testConnectResponseStreamWillPipeDataFromConnection()
+ {
+ $stream = new ThroughStream();
+
+ $server = new Server(function (ServerRequestInterface $request) use ($stream) {
+ return new Response(200, array(), $stream);
+ });
+
+ $server->listen($this->socket);
+ $this->socket->emit('connection', array($this->connection));
+
+ $this->connection->expects($this->once())->method('pipe')->with($stream);
+
+ $data = "CONNECT example.com:80 HTTP/1.1\r\nHost: example.com:80\r\n\r\n";
+ $this->connection->emit('data', array($data));
+ }
+
+ public function testResponseContainsSameRequestProtocolVersionAndChunkedBodyForHttp11()
+ {
+ $server = new Server(function (ServerRequestInterface $request) {
+ $response = new Response(200, array(), 'bye');
+ return \React\Promise\resolve($response);
+ });
+
+ $buffer = '';
+
+ $this->connection
+ ->expects($this->any())
+ ->method('write')
+ ->will(
+ $this->returnCallback(
+ function ($data) use (&$buffer) {
+ $buffer .= $data;
+ }
+ )
+ );
+
+ $server->listen($this->socket);
+ $this->socket->emit('connection', array($this->connection));
+
+ $data = "GET / HTTP/1.1\r\nHost: localhost\r\n\r\n";
+ $this->connection->emit('data', array($data));
+
+ $this->assertContains("HTTP/1.1 200 OK\r\n", $buffer);
+ $this->assertContains("bye", $buffer);
+ }
+
+ public function testResponseContainsSameRequestProtocolVersionAndRawBodyForHttp10()
+ {
+ $server = new Server(function (ServerRequestInterface $request) {
+ $response = new Response(200, array(), 'bye');
+ return \React\Promise\resolve($response);
+ });
+
+ $buffer = '';
+
+ $this->connection
+ ->expects($this->any())
+ ->method('write')
+ ->will(
+ $this->returnCallback(
+ function ($data) use (&$buffer) {
+ $buffer .= $data;
+ }
+ )
+ );
+
+ $server->listen($this->socket);
+ $this->socket->emit('connection', array($this->connection));
+
+ $data = "GET / HTTP/1.0\r\n\r\n";
+ $this->connection->emit('data', array($data));
+
+ $this->assertContains("HTTP/1.0 200 OK\r\n", $buffer);
+ $this->assertContains("\r\n\r\n", $buffer);
+ $this->assertContains("bye", $buffer);
+ }
+
+ public function testResponseContainsNoResponseBodyForHeadRequest()
+ {
+ $server = new Server(function (ServerRequestInterface $request) {
+ return new Response(200, array(), 'bye');
+ });
+
+ $buffer = '';
+ $this->connection
+ ->expects($this->any())
+ ->method('write')
+ ->will(
+ $this->returnCallback(
+ function ($data) use (&$buffer) {
+ $buffer .= $data;
+ }
+ )
+ );
+
+ $server->listen($this->socket);
+ $this->socket->emit('connection', array($this->connection));
+
+ $data = "HEAD / HTTP/1.1\r\nHost: localhost\r\n\r\n";
+ $this->connection->emit('data', array($data));
+
+ $this->assertContains("HTTP/1.1 200 OK\r\n", $buffer);
+ $this->assertNotContains("bye", $buffer);
+ }
+
+ public function testResponseContainsNoResponseBodyAndNoContentLengthForNoContentStatus()
+ {
+ $server = new Server(function (ServerRequestInterface $request) {
+ return new Response(204, array(), 'bye');
+ });
+
+ $buffer = '';
+ $this->connection
+ ->expects($this->any())
+ ->method('write')
+ ->will(
+ $this->returnCallback(
+ function ($data) use (&$buffer) {
+ $buffer .= $data;
+ }
+ )
+ );
+
+ $server->listen($this->socket);
+ $this->socket->emit('connection', array($this->connection));
+
+ $data = "GET / HTTP/1.1\r\nHost: localhost\r\n\r\n";
+ $this->connection->emit('data', array($data));
+
+ $this->assertContains("HTTP/1.1 204 No Content\r\n", $buffer);
+ $this->assertNotContains("\r\n\Content-Length: 3\r\n", $buffer);
+ $this->assertNotContains("bye", $buffer);
+ }
+
+ public function testResponseContainsNoResponseBodyForNotModifiedStatus()
+ {
+ $server = new Server(function (ServerRequestInterface $request) {
+ return new Response(304, array(), 'bye');
+ });
+
+ $buffer = '';
+ $this->connection
+ ->expects($this->any())
+ ->method('write')
+ ->will(
+ $this->returnCallback(
+ function ($data) use (&$buffer) {
+ $buffer .= $data;
+ }
+ )
+ );
+
+ $server->listen($this->socket);
+ $this->socket->emit('connection', array($this->connection));
+
+ $data = "GET / HTTP/1.1\r\nHost: localhost\r\n\r\n";
+ $this->connection->emit('data', array($data));
+
+ $this->assertContains("HTTP/1.1 304 Not Modified\r\n", $buffer);
+ $this->assertContains("\r\nContent-Length: 3\r\n", $buffer);
+ $this->assertNotContains("bye", $buffer);
+ }
+
+ public function testRequestInvalidHttpProtocolVersionWillEmitErrorAndSendErrorResponse()
+ {
+ $error = null;
+ $server = new Server($this->expectCallableNever());
+ $server->on('error', function ($message) use (&$error) {
+ $error = $message;
+ });
+
+ $buffer = '';
+
+ $this->connection
+ ->expects($this->any())
+ ->method('write')
+ ->will(
+ $this->returnCallback(
+ function ($data) use (&$buffer) {
+ $buffer .= $data;
+ }
+ )
+ );
+
+ $server->listen($this->socket);
+ $this->socket->emit('connection', array($this->connection));
+
+ $data = "GET / HTTP/1.2\r\nHost: localhost\r\n\r\n";
+ $this->connection->emit('data', array($data));
+
+ $this->assertInstanceOf('InvalidArgumentException', $error);
+
+ $this->assertContains("HTTP/1.1 505 HTTP Version not supported\r\n", $buffer);
+ $this->assertContains("\r\n\r\n", $buffer);
+ $this->assertContains("Error 505: HTTP Version Not Supported", $buffer);
+ }
+
+ public function testRequestOverflowWillEmitErrorAndSendErrorResponse()
+ {
+ $error = null;
+ $server = new Server($this->expectCallableNever());
+ $server->on('error', function ($message) use (&$error) {
+ $error = $message;
+ });
+
+ $buffer = '';
+
+ $this->connection
+ ->expects($this->any())
+ ->method('write')
+ ->will(
+ $this->returnCallback(
+ function ($data) use (&$buffer) {
+ $buffer .= $data;
+ }
+ )
+ );
+
+ $server->listen($this->socket);
+ $this->socket->emit('connection', array($this->connection));
+
+ $data = "GET / HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\nX-DATA: ";
+ $data .= str_repeat('A', 4097 - strlen($data)) . "\r\n\r\n";
+ $this->connection->emit('data', array($data));
+
+ $this->assertInstanceOf('OverflowException', $error);
+
+ $this->assertContains("HTTP/1.1 431 Request Header Fields Too Large\r\n", $buffer);
+ $this->assertContains("\r\n\r\nError 431: Request Header Fields Too Large", $buffer);
+ }
+
+ public function testRequestInvalidWillEmitErrorAndSendErrorResponse()
+ {
+ $error = null;
+ $server = new Server($this->expectCallableNever());
+ $server->on('error', function ($message) use (&$error) {
+ $error = $message;
+ });
+
+ $buffer = '';
+
+ $this->connection
+ ->expects($this->any())
+ ->method('write')
+ ->will(
+ $this->returnCallback(
+ function ($data) use (&$buffer) {
+ $buffer .= $data;
+ }
+ )
+ );
+
+ $server->listen($this->socket);
+ $this->socket->emit('connection', array($this->connection));
+
+ $data = "bad request\r\n\r\n";
+ $this->connection->emit('data', array($data));
+
+ $this->assertInstanceOf('InvalidArgumentException', $error);
+
+ $this->assertContains("HTTP/1.1 400 Bad Request\r\n", $buffer);
+ $this->assertContains("\r\n\r\nError 400: Bad Request", $buffer);
+ }
+
+ public function testBodyDataWillBeSendViaRequestEvent()
+ {
+ $dataEvent = $this->expectCallableOnceWith('hello');
+ $endEvent = $this->expectCallableOnce();
+ $closeEvent = $this->expectCallableOnce();
+ $errorEvent = $this->expectCallableNever();
+
+ $server = new Server(function (ServerRequestInterface $request) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) {
+ $request->getBody()->on('data', $dataEvent);
+ $request->getBody()->on('end', $endEvent);
+ $request->getBody()->on('close', $closeEvent);
+ $request->getBody()->on('error', $errorEvent);
+
+ return \React\Promise\resolve(new Response());
+ });
+
+ $server->listen($this->socket);
+ $this->socket->emit('connection', array($this->connection));
+
+ $data = "GET / HTTP/1.1\r\n";
+ $data .= "Host: example.com:80\r\n";
+ $data .= "Connection: close\r\n";
+ $data .= "Content-Length: 5\r\n";
+ $data .= "\r\n";
+ $data .= "hello";
+
+ $this->connection->emit('data', array($data));
+ }
+
+ public function testChunkedEncodedRequestWillBeParsedForRequestEvent()
+ {
+ $dataEvent = $this->expectCallableOnceWith('hello');
+ $endEvent = $this->expectCallableOnce();
+ $closeEvent = $this->expectCallableOnce();
+ $errorEvent = $this->expectCallableNever();
+ $requestValidation = null;
+
+ $server = new Server(function (ServerRequestInterface $request) use ($dataEvent, $endEvent, $closeEvent, $errorEvent, &$requestValidation) {
+ $request->getBody()->on('data', $dataEvent);
+ $request->getBody()->on('end', $endEvent);
+ $request->getBody()->on('close', $closeEvent);
+ $request->getBody()->on('error', $errorEvent);
+ $requestValidation = $request;
+
+ return \React\Promise\resolve(new Response());
+ });
+
+ $server->listen($this->socket);
+ $this->socket->emit('connection', array($this->connection));
+
+ $data = "GET / HTTP/1.1\r\n";
+ $data .= "Host: example.com:80\r\n";
+ $data .= "Connection: close\r\n";
+ $data .= "Transfer-Encoding: chunked\r\n";
+ $data .= "\r\n";
+ $data .= "5\r\nhello\r\n";
+ $data .= "0\r\n\r\n";
+
+ $this->connection->emit('data', array($data));
+
+ $this->assertFalse($requestValidation->hasHeader('Transfer-Encoding'));
+ }
+
+ public function testChunkedEncodedRequestAdditionalDataWontBeEmitted()
+ {
+ $dataEvent = $this->expectCallableOnceWith('hello');
+ $endEvent = $this->expectCallableOnce();
+ $closeEvent = $this->expectCallableOnce();
+ $errorEvent = $this->expectCallableNever();
+
+ $server = new Server(function (ServerRequestInterface $request) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) {
+ $request->getBody()->on('data', $dataEvent);
+ $request->getBody()->on('end', $endEvent);
+ $request->getBody()->on('close', $closeEvent);
+ $request->getBody()->on('error', $errorEvent);
+
+ return \React\Promise\resolve(new Response());
+ });
+
+ $server->listen($this->socket);
+ $this->socket->emit('connection', array($this->connection));
+
+ $data = "GET / HTTP/1.1\r\n";
+ $data .= "Host: example.com:80\r\n";
+ $data .= "Connection: close\r\n";
+ $data .= "Transfer-Encoding: chunked\r\n";
+ $data .= "\r\n";
+ $data .= "5\r\nhello\r\n";
+ $data .= "0\r\n\r\n";
+ $data .= "2\r\nhi\r\n";
+
+ $this->connection->emit('data', array($data));
+ }
+
+ public function testEmptyChunkedEncodedRequest()
+ {
+ $dataEvent = $this->expectCallableNever();
+ $endEvent = $this->expectCallableOnce();
+ $closeEvent = $this->expectCallableOnce();
+ $errorEvent = $this->expectCallableNever();
+
+ $server = new Server(function (ServerRequestInterface $request) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) {
+ $request->getBody()->on('data', $dataEvent);
+ $request->getBody()->on('end', $endEvent);
+ $request->getBody()->on('close', $closeEvent);
+ $request->getBody()->on('error', $errorEvent);
+
+ return \React\Promise\resolve(new Response());
+ });
+
+ $server->listen($this->socket);
+ $this->socket->emit('connection', array($this->connection));
+
+ $data = "GET / HTTP/1.1\r\n";
+ $data .= "Host: example.com:80\r\n";
+ $data .= "Connection: close\r\n";
+ $data .= "Transfer-Encoding: chunked\r\n";
+ $data .= "\r\n";
+ $data .= "0\r\n\r\n";
+
+ $this->connection->emit('data', array($data));
+ }
+
+ public function testChunkedIsUpperCase()
+ {
+ $dataEvent = $this->expectCallableOnceWith('hello');
+ $endEvent = $this->expectCallableOnce();
+ $closeEvent = $this->expectCallableOnce();
+ $errorEvent = $this->expectCallableNever();
+
+ $server = new Server(function (ServerRequestInterface $request) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) {
+ $request->getBody()->on('data', $dataEvent);
+ $request->getBody()->on('end', $endEvent);
+ $request->getBody()->on('close', $closeEvent);
+ $request->getBody()->on('error', $errorEvent);
+
+ return \React\Promise\resolve(new Response());
+ });
+
+ $server->listen($this->socket);
+ $this->socket->emit('connection', array($this->connection));
+
+ $data = "GET / HTTP/1.1\r\n";
+ $data .= "Host: example.com:80\r\n";
+ $data .= "Connection: close\r\n";
+ $data .= "Transfer-Encoding: CHUNKED\r\n";
+ $data .= "\r\n";
+ $data .= "5\r\nhello\r\n";
+ $data .= "0\r\n\r\n";
+
+ $this->connection->emit('data', array($data));
+ }
+
+ public function testChunkedIsMixedUpperAndLowerCase()
+ {
+ $dataEvent = $this->expectCallableOnceWith('hello');
+ $endEvent = $this->expectCallableOnce();
+ $closeEvent = $this->expectCallableOnce();
+ $errorEvent = $this->expectCallableNever();
+
+ $server = new Server(function (ServerRequestInterface $request) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) {
+ $request->getBody()->on('data', $dataEvent);
+ $request->getBody()->on('end', $endEvent);
+ $request->getBody()->on('close', $closeEvent);
+ $request->getBody()->on('error', $errorEvent);
+
+ return \React\Promise\resolve(new Response());
+ });
+
+ $server->listen($this->socket);
+ $this->socket->emit('connection', array($this->connection));
+
+ $data = "GET / HTTP/1.1\r\n";
+ $data .= "Host: example.com:80\r\n";
+ $data .= "Connection: close\r\n";
+ $data .= "Transfer-Encoding: CHunKeD\r\n";
+ $data .= "\r\n";
+ $data .= "5\r\nhello\r\n";
+ $data .= "0\r\n\r\n";
+ $this->connection->emit('data', array($data));
+ }
+
+ public function testRequestWithMalformedHostWillEmitErrorAndSendErrorResponse()
+ {
+ $error = null;
+ $server = new Server($this->expectCallableNever());
+ $server->on('error', function ($message) use (&$error) {
+ $error = $message;
+ });
+
+ $buffer = '';
+
+ $this->connection
+ ->expects($this->any())
+ ->method('write')
+ ->will(
+ $this->returnCallback(
+ function ($data) use (&$buffer) {
+ $buffer .= $data;
+ }
+ )
+ );
+
+ $server->listen($this->socket);
+ $this->socket->emit('connection', array($this->connection));
+
+ $data = "GET / HTTP/1.1\r\nHost: ///\r\n\r\n";
+ $this->connection->emit('data', array($data));
+
+ $this->assertInstanceOf('InvalidArgumentException', $error);
+
+ $this->assertContains("HTTP/1.1 400 Bad Request\r\n", $buffer);
+ $this->assertContains("\r\n\r\nError 400: Bad Request", $buffer);
+ }
+
+ public function testRequestWithInvalidHostUriComponentsWillEmitErrorAndSendErrorResponse()
+ {
+ $error = null;
+ $server = new Server($this->expectCallableNever());
+ $server->on('error', function ($message) use (&$error) {
+ $error = $message;
+ });
+
+ $buffer = '';
+
+ $this->connection
+ ->expects($this->any())
+ ->method('write')
+ ->will(
+ $this->returnCallback(
+ function ($data) use (&$buffer) {
+ $buffer .= $data;
+ }
+ )
+ );
+
+ $server->listen($this->socket);
+ $this->socket->emit('connection', array($this->connection));
+
+ $data = "GET / HTTP/1.1\r\nHost: localhost:80/test\r\n\r\n";
+ $this->connection->emit('data', array($data));
+
+ $this->assertInstanceOf('InvalidArgumentException', $error);
+
+ $this->assertContains("HTTP/1.1 400 Bad Request\r\n", $buffer);
+ $this->assertContains("\r\n\r\nError 400: Bad Request", $buffer);
+ }
+
+ public function testWontEmitFurtherDataWhenContentLengthIsReached()
+ {
+ $dataEvent = $this->expectCallableOnceWith('hello');
+ $endEvent = $this->expectCallableOnce();
+ $closeEvent = $this->expectCallableOnce();
+ $errorEvent = $this->expectCallableNever();
+
+ $server = new Server(function (ServerRequestInterface $request) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) {
+ $request->getBody()->on('data', $dataEvent);
+ $request->getBody()->on('end', $endEvent);
+ $request->getBody()->on('close', $closeEvent);
+ $request->getBody()->on('error', $errorEvent);
+
+ return \React\Promise\resolve(new Response());
+ });
+
+ $server->listen($this->socket);
+ $this->socket->emit('connection', array($this->connection));
+
+ $data = "GET / HTTP/1.1\r\n";
+ $data .= "Host: example.com:80\r\n";
+ $data .= "Connection: close\r\n";
+ $data .= "Content-Length: 5\r\n";
+ $data .= "\r\n";
+ $data .= "hello";
+ $data .= "world";
+
+ $this->connection->emit('data', array($data));
+ }
+
+ public function testWontEmitFurtherDataWhenContentLengthIsReachedSplitted()
+ {
+ $dataEvent = $this->expectCallableOnceWith('hello');
+ $endEvent = $this->expectCallableOnce();
+ $closeEvent = $this->expectCallableOnce();
+ $errorEvent = $this->expectCallableNever();
+
+
+ $server = new Server(function (ServerRequestInterface $request) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) {
+ $request->getBody()->on('data', $dataEvent);
+ $request->getBody()->on('end', $endEvent);
+ $request->getBody()->on('close', $closeEvent);
+ $request->getBody()->on('error', $errorEvent);
+
+ return \React\Promise\resolve(new Response());
+ });
+
+ $server->listen($this->socket);
+ $this->socket->emit('connection', array($this->connection));
+
+ $data = "GET / HTTP/1.1\r\n";
+ $data .= "Host: example.com:80\r\n";
+ $data .= "Connection: close\r\n";
+ $data .= "Content-Length: 5\r\n";
+ $data .= "\r\n";
+ $data .= "hello";
+
+ $this->connection->emit('data', array($data));
+
+ $data = "world";
+
+ $this->connection->emit('data', array($data));
+ }
+
+ public function testContentLengthContainsZeroWillEmitEndEvent()
+ {
+
+ $dataEvent = $this->expectCallableNever();
+ $endEvent = $this->expectCallableOnce();
+ $closeEvent = $this->expectCallableOnce();
+ $errorEvent = $this->expectCallableNever();
+
+ $server = new Server(function (ServerRequestInterface $request) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) {
+ $request->getBody()->on('data', $dataEvent);
+ $request->getBody()->on('end', $endEvent);
+ $request->getBody()->on('close', $closeEvent);
+ $request->getBody()->on('error', $errorEvent);
+
+ return \React\Promise\resolve(new Response());
+ });
+
+ $server->listen($this->socket);
+ $this->socket->emit('connection', array($this->connection));
+
+ $data = "GET / HTTP/1.1\r\n";
+ $data .= "Host: example.com:80\r\n";
+ $data .= "Connection: close\r\n";
+ $data .= "Content-Length: 0\r\n";
+ $data .= "\r\n";
+
+ $this->connection->emit('data', array($data));
+ }
+
+ public function testContentLengthContainsZeroWillEmitEndEventAdditionalDataWillBeIgnored()
+ {
+ $dataEvent = $this->expectCallableNever();
+ $endEvent = $this->expectCallableOnce();
+ $closeEvent = $this->expectCallableOnce();
+ $errorEvent = $this->expectCallableNever();
+
+ $server = new Server(function (ServerRequestInterface $request) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) {
+ $request->getBody()->on('data', $dataEvent);
+ $request->getBody()->on('end', $endEvent);
+ $request->getBody()->on('close', $closeEvent);
+ $request->getBody()->on('error', $errorEvent);
+
+ return \React\Promise\resolve(new Response());
+ });
+
+ $server->listen($this->socket);
+ $this->socket->emit('connection', array($this->connection));
+
+ $data = "GET / HTTP/1.1\r\n";
+ $data .= "Host: example.com:80\r\n";
+ $data .= "Connection: close\r\n";
+ $data .= "Content-Length: 0\r\n";
+ $data .= "\r\n";
+ $data .= "hello";
+
+ $this->connection->emit('data', array($data));
+ }
+
+ public function testContentLengthContainsZeroWillEmitEndEventAdditionalDataWillBeIgnoredSplitted()
+ {
+ $dataEvent = $this->expectCallableNever();
+ $endEvent = $this->expectCallableOnce();
+ $closeEvent = $this->expectCallableOnce();
+ $errorEvent = $this->expectCallableNever();
+
+ $server = new Server(function (ServerRequestInterface $request) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) {
+ $request->getBody()->on('data', $dataEvent);
+ $request->getBody()->on('end', $endEvent);
+ $request->getBody()->on('close', $closeEvent);
+ $request->getBody()->on('error', $errorEvent);
+
+ return \React\Promise\resolve(new Response());
+ });
+
+ $server->listen($this->socket);
+ $this->socket->emit('connection', array($this->connection));
+
+ $data = "GET / HTTP/1.1\r\n";
+ $data .= "Host: example.com:80\r\n";
+ $data .= "Connection: close\r\n";
+ $data .= "Content-Length: 0\r\n";
+ $data .= "\r\n";
+
+ $this->connection->emit('data', array($data));
+
+ $data = "hello";
+
+ $this->connection->emit('data', array($data));
+ }
+
+ public function testContentLengthWillBeIgnoredIfTransferEncodingIsSet()
+ {
+ $dataEvent = $this->expectCallableOnceWith('hello');
+ $endEvent = $this->expectCallableOnce();
+ $closeEvent = $this->expectCallableOnce();
+ $errorEvent = $this->expectCallableNever();
+
+ $requestValidation = null;
+ $server = new Server(function (ServerRequestInterface $request) use ($dataEvent, $endEvent, $closeEvent, $errorEvent, &$requestValidation) {
+ $request->getBody()->on('data', $dataEvent);
+ $request->getBody()->on('end', $endEvent);
+ $request->getBody()->on('close', $closeEvent);
+ $request->getBody()->on('error', $errorEvent);
+ $requestValidation = $request;
+
+ return \React\Promise\resolve(new Response());
+ });
+
+ $server->listen($this->socket);
+ $this->socket->emit('connection', array($this->connection));
+
+ $data = "GET / HTTP/1.1\r\n";
+ $data .= "Host: example.com:80\r\n";
+ $data .= "Connection: close\r\n";
+ $data .= "Content-Length: 4\r\n";
+ $data .= "Transfer-Encoding: chunked\r\n";
+ $data .= "\r\n";
+
+ $this->connection->emit('data', array($data));
+
+ $data = "5\r\nhello\r\n";
+ $data .= "0\r\n\r\n";
+
+ $this->connection->emit('data', array($data));
+
+ $this->assertFalse($requestValidation->hasHeader('Content-Length'));
+ $this->assertFalse($requestValidation->hasHeader('Transfer-Encoding'));
+ }
+
+ public function testInvalidContentLengthWillBeIgnoreddIfTransferEncodingIsSet()
+ {
+ $dataEvent = $this->expectCallableOnceWith('hello');
+ $endEvent = $this->expectCallableOnce();
+ $closeEvent = $this->expectCallableOnce();
+ $errorEvent = $this->expectCallableNever();
+
+ $requestValidation = null;
+ $server = new Server(function (ServerRequestInterface $request) use ($dataEvent, $endEvent, $closeEvent, $errorEvent, &$requestValidation) {
+ $request->getBody()->on('data', $dataEvent);
+ $request->getBody()->on('end', $endEvent);
+ $request->getBody()->on('close', $closeEvent);
+ $request->getBody()->on('error', $errorEvent);
+ $requestValidation = $request;
+
+ return \React\Promise\resolve(new Response());
+ });
+
+ $server->listen($this->socket);
+ $this->socket->emit('connection', array($this->connection));
+
+ $data = "GET / HTTP/1.1\r\n";
+ $data .= "Host: example.com:80\r\n";
+ $data .= "Connection: close\r\n";
+ // this is valid behavior according to: https://www.ietf.org/rfc/rfc2616.txt chapter 4.4
+ $data .= "Content-Length: hello world\r\n";
+ $data .= "Transfer-Encoding: chunked\r\n";
+ $data .= "\r\n";
+
+ $this->connection->emit('data', array($data));
+
+ $data = "5\r\nhello\r\n";
+ $data .= "0\r\n\r\n";
+
+ $this->connection->emit('data', array($data));
+
+ $this->assertFalse($requestValidation->hasHeader('Content-Length'));
+ $this->assertFalse($requestValidation->hasHeader('Transfer-Encoding'));
+ }
+
+ public function testNonIntegerContentLengthValueWillLeadToError()
+ {
+ $error = null;
+ $server = new Server($this->expectCallableNever());
+ $server->on('error', function ($message) use (&$error) {
+ $error = $message;
+ });
+
+ $buffer = '';
+ $this->connection
+ ->expects($this->any())
+ ->method('write')
+ ->will(
+ $this->returnCallback(
+ function ($data) use (&$buffer) {
+ $buffer .= $data;
+ }
+ )
+ );
+
+ $server->listen($this->socket);
+ $this->socket->emit('connection', array($this->connection));
+
+ $data = "GET / HTTP/1.1\r\n";
+ $data .= "Host: example.com:80\r\n";
+ $data .= "Connection: close\r\n";
+ $data .= "Content-Length: bla\r\n";
+ $data .= "\r\n";
+ $data .= "hello";
+
+ $this->connection->emit('data', array($data));
+
+ $this->assertContains("HTTP/1.1 400 Bad Request\r\n", $buffer);
+ $this->assertContains("\r\n\r\nError 400: Bad Request", $buffer);
+ $this->assertInstanceOf('InvalidArgumentException', $error);
+ }
+
+ public function testNonIntegerContentLengthValueWillLeadToErrorWithNoBodyForHeadRequest()
+ {
+ $error = null;
+ $server = new Server($this->expectCallableNever());
+ $server->on('error', function ($message) use (&$error) {
+ $error = $message;
+ });
+
+ $buffer = '';
+ $this->connection
+ ->expects($this->any())
+ ->method('write')
+ ->will(
+ $this->returnCallback(
+ function ($data) use (&$buffer) {
+ $buffer .= $data;
+ }
+ )
+ );
+
+ $server->listen($this->socket);
+ $this->socket->emit('connection', array($this->connection));
+
+ $data = "HEAD / HTTP/1.1\r\n";
+ $data .= "Host: example.com:80\r\n";
+ $data .= "Connection: close\r\n";
+ $data .= "Content-Length: bla\r\n";
+ $data .= "\r\n";
+ $data .= "hello";
+
+ $this->connection->emit('data', array($data));
+
+ $this->assertContains("HTTP/1.1 400 Bad Request\r\n", $buffer);
+ $this->assertNotContains("\r\n\r\nError 400: Bad Request", $buffer);
+ $this->assertInstanceOf('InvalidArgumentException', $error);
+ }
+
+ public function testMultipleIntegerInContentLengthWillLeadToError()
+ {
+ $error = null;
+ $server = new Server($this->expectCallableNever());
+ $server->on('error', function ($message) use (&$error) {
+ $error = $message;
+ });
+
+ $buffer = '';
+ $this->connection
+ ->expects($this->any())
+ ->method('write')
+ ->will(
+ $this->returnCallback(
+ function ($data) use (&$buffer) {
+ $buffer .= $data;
+ }
+ )
+ );
+
+ $server->listen($this->socket);
+ $this->socket->emit('connection', array($this->connection));
+
+ $data = "GET / HTTP/1.1\r\n";
+ $data .= "Host: example.com:80\r\n";
+ $data .= "Connection: close\r\n";
+ $data .= "Content-Length: 5, 3, 4\r\n";
+ $data .= "\r\n";
+ $data .= "hello";
+
+ $this->connection->emit('data', array($data));
+
+ $this->assertContains("HTTP/1.1 400 Bad Request\r\n", $buffer);
+ $this->assertContains("\r\n\r\nError 400: Bad Request", $buffer);
+ $this->assertInstanceOf('InvalidArgumentException', $error);
+ }
+
+ public function testInvalidChunkHeaderResultsInErrorOnRequestStream()
+ {
+ $errorEvent = $this->expectCallableOnceWith($this->isInstanceOf('Exception'));
+ $server = new Server(function ($request) use ($errorEvent){
+ $request->getBody()->on('error', $errorEvent);
+ return \React\Promise\resolve(new Response());
+ });
+
+ $this->connection->expects($this->never())->method('close');
+ $this->connection->expects($this->once())->method('pause');
+
+ $server->listen($this->socket);
+ $this->socket->emit('connection', array($this->connection));
+
+ $data = "GET / HTTP/1.1\r\n";
+ $data .= "Host: example.com:80\r\n";
+ $data .= "Connection: close\r\n";
+ $data .= "Transfer-Encoding: chunked\r\n";
+ $data .= "\r\n";
+ $data .= "hello\r\hello\r\n";
+
+ $this->connection->emit('data', array($data));
+ }
+
+ public function testTooLongChunkHeaderResultsInErrorOnRequestStream()
+ {
+ $errorEvent = $this->expectCallableOnceWith($this->isInstanceOf('Exception'));
+ $server = new Server(function ($request) use ($errorEvent){
+ $request->getBody()->on('error', $errorEvent);
+ return \React\Promise\resolve(new Response());
+ });
+
+ $this->connection->expects($this->never())->method('close');
+ $this->connection->expects($this->once())->method('pause');
+
+ $server->listen($this->socket);
+ $this->socket->emit('connection', array($this->connection));
+
+ $data = "GET / HTTP/1.1\r\n";
+ $data .= "Host: example.com:80\r\n";
+ $data .= "Connection: close\r\n";
+ $data .= "Transfer-Encoding: chunked\r\n";
+ $data .= "\r\n";
+ for ($i = 0; $i < 1025; $i++) {
+ $data .= 'a';
+ }
+
+ $this->connection->emit('data', array($data));
+ }
+
+ public function testTooLongChunkBodyResultsInErrorOnRequestStream()
+ {
+ $errorEvent = $this->expectCallableOnceWith($this->isInstanceOf('Exception'));
+ $server = new Server(function ($request) use ($errorEvent){
+ $request->getBody()->on('error', $errorEvent);
+ return \React\Promise\resolve(new Response());
+ });
+
+ $this->connection->expects($this->never())->method('close');
+ $this->connection->expects($this->once())->method('pause');
+
+ $server->listen($this->socket);
+ $this->socket->emit('connection', array($this->connection));
+
+ $data = "GET / HTTP/1.1\r\n";
+ $data .= "Host: example.com:80\r\n";
+ $data .= "Connection: close\r\n";
+ $data .= "Transfer-Encoding: chunked\r\n";
+ $data .= "\r\n";
+ $data .= "5\r\nhello world\r\n";
+
+ $this->connection->emit('data', array($data));
+ }
+
+ public function testUnexpectedEndOfConnectionWillResultsInErrorOnRequestStream()
+ {
+ $errorEvent = $this->expectCallableOnceWith($this->isInstanceOf('Exception'));
+ $server = new Server(function ($request) use ($errorEvent){
+ $request->getBody()->on('error', $errorEvent);
+ return \React\Promise\resolve(new Response());
+ });
+
+ $this->connection->expects($this->never())->method('close');
+ $this->connection->expects($this->once())->method('pause');
+
+ $server->listen($this->socket);
+ $this->socket->emit('connection', array($this->connection));
+
+ $data = "GET / HTTP/1.1\r\n";
+ $data .= "Host: example.com:80\r\n";
+ $data .= "Connection: close\r\n";
+ $data .= "Transfer-Encoding: chunked\r\n";
+ $data .= "\r\n";
+ $data .= "5\r\nhello\r\n";
+
+ $this->connection->emit('data', array($data));
+ $this->connection->emit('end');
+ }
+
+ public function testErrorInChunkedDecoderNeverClosesConnection()
+ {
+ $server = new Server(function (ServerRequestInterface $request) {
+ return \React\Promise\resolve(new Response());
+ });
+
+ $this->connection->expects($this->never())->method('close');
+ $this->connection->expects($this->once())->method('pause');
+
+ $server->listen($this->socket);
+ $this->socket->emit('connection', array($this->connection));
+
+ $data = "GET / HTTP/1.1\r\n";
+ $data .= "Host: example.com:80\r\n";
+ $data .= "Connection: close\r\n";
+ $data .= "Transfer-Encoding: chunked\r\n";
+ $data .= "\r\n";
+ $data .= "hello\r\nhello\r\n";
+
+ $this->connection->emit('data', array($data));
+ }
+
+ public function testErrorInLengthLimitedStreamNeverClosesConnection()
+ {
+ $server = new Server(function (ServerRequestInterface $request) {
+ return \React\Promise\resolve(new Response());
+ });
+
+ $this->connection->expects($this->never())->method('close');
+ $this->connection->expects($this->once())->method('pause');
+
+ $server->listen($this->socket);
+ $this->socket->emit('connection', array($this->connection));
+
+ $data = "GET / HTTP/1.1\r\n";
+ $data .= "Host: example.com:80\r\n";
+ $data .= "Connection: close\r\n";
+ $data .= "Content-Length: 5\r\n";
+ $data .= "\r\n";
+ $data .= "hello";
+
+ $this->connection->emit('data', array($data));
+ $this->connection->emit('end');
+ }
+
+ public function testCloseRequestWillPauseConnection()
+ {
+ $server = new Server(function ($request) {
+ $request->getBody()->close();
+ return \React\Promise\resolve(new Response());
+ });
+
+ $this->connection->expects($this->never())->method('close');
+ $this->connection->expects($this->once())->method('pause');
+
+ $server->listen($this->socket);
+ $this->socket->emit('connection', array($this->connection));
+
+ $data = $this->createGetRequest();
+ $this->connection->emit('data', array($data));
+ }
+
+ public function testEndEventWillBeEmittedOnSimpleRequest()
+ {
+ $dataEvent = $this->expectCallableNever();
+ $closeEvent = $this->expectCallableOnce();
+ $endEvent = $this->expectCallableOnce();
+ $errorEvent = $this->expectCallableNever();
+
+ $server = new Server(function ($request) use ($dataEvent, $closeEvent, $endEvent, $errorEvent){
+ $request->getBody()->on('data', $dataEvent);
+ $request->getBody()->on('close', $closeEvent);
+ $request->getBody()->on('end', $endEvent);
+ $request->getBody()->on('error', $errorEvent);
+
+ return \React\Promise\resolve(new Response());
+ });
+
+ $this->connection->expects($this->once())->method('pause');
+ $this->connection->expects($this->never())->method('close');
+
+ $server->listen($this->socket);
+ $this->socket->emit('connection', array($this->connection));
+
+ $data = $this->createGetRequest();
+
+ $this->connection->emit('data', array($data));
+ }
+
+ public function testRequestWithoutDefinedLengthWillIgnoreDataEvent()
+ {
+ $dataEvent = $this->expectCallableNever();
+ $endEvent = $this->expectCallableOnce();
+ $closeEvent = $this->expectCallableOnce();
+ $errorEvent = $this->expectCallableNever();
+
+ $server = new Server(function (ServerRequestInterface $request) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) {
+ $request->getBody()->on('data', $dataEvent);
+ $request->getBody()->on('end', $endEvent);
+ $request->getBody()->on('close', $closeEvent);
+ $request->getBody()->on('error', $errorEvent);
+
+ return \React\Promise\resolve(new Response());
+ });
+
+ $server->listen($this->socket);
+ $this->socket->emit('connection', array($this->connection));
+
+ $data = $this->createGetRequest();
+ $data .= "hello world";
+
+ $this->connection->emit('data', array($data));
+ }
+
+ public function testResponseWillBeChunkDecodedByDefault()
+ {
+ $stream = new ThroughStream();
+ $server = new Server(function (ServerRequestInterface $request) use ($stream) {
+ $response = new Response(200, array(), $stream);
+ return \React\Promise\resolve($response);
+ });
+
+ $buffer = '';
+ $this->connection
+ ->expects($this->any())
+ ->method('write')
+ ->will(
+ $this->returnCallback(
+ function ($data) use (&$buffer) {
+ $buffer .= $data;
+ }
+ )
+ );
+
+ $server->listen($this->socket);
+ $this->socket->emit('connection', array($this->connection));
+
+ $data = $this->createGetRequest();
+
+ $this->connection->emit('data', array($data));
+ $stream->emit('data', array('hello'));
+
+ $this->assertContains("Transfer-Encoding: chunked", $buffer);
+ $this->assertContains("hello", $buffer);
+ }
+
+ public function testContentLengthWillBeRemovedForResponseStream()
+ {
+ $server = new Server(function (ServerRequestInterface $request) {
+ $response = new Response(
+ 200,
+ array(
+ 'Content-Length' => 5,
+ 'Transfer-Encoding' => 'chunked'
+ ),
+ 'hello'
+ );
+
+ return \React\Promise\resolve($response);
+ });
+
+ $buffer = '';
+ $this->connection
+ ->expects($this->any())
+ ->method('write')
+ ->will(
+ $this->returnCallback(
+ function ($data) use (&$buffer) {
+ $buffer .= $data;
+ }
+ )
+ );
+
+ $server->listen($this->socket);
+ $this->socket->emit('connection', array($this->connection));
+
+ $data = $this->createGetRequest();
+
+ $this->connection->emit('data', array($data));
+
+ $this->assertNotContains("Transfer-Encoding: chunked", $buffer);
+ $this->assertContains("Content-Length: 5", $buffer);
+ $this->assertContains("hello", $buffer);
+ }
+
+ public function testOnlyAllowChunkedEncoding()
+ {
+ $stream = new ThroughStream();
+ $server = new Server(function (ServerRequestInterface $request) use ($stream) {
+ $response = new Response(
+ 200,
+ array(
+ 'Transfer-Encoding' => 'custom'
+ ),
+ $stream
+ );
+
+ return \React\Promise\resolve($response);
+ });
+
+ $buffer = '';
+ $this->connection
+ ->expects($this->any())
+ ->method('write')
+ ->will(
+ $this->returnCallback(
+ function ($data) use (&$buffer) {
+ $buffer .= $data;
+ }
+ )
+ );
+
+ $server->listen($this->socket);
+ $this->socket->emit('connection', array($this->connection));
+
+ $data = $this->createGetRequest();
+
+ $this->connection->emit('data', array($data));
+ $stream->emit('data', array('hello'));
+
+ $this->assertContains('Transfer-Encoding: chunked', $buffer);
+ $this->assertNotContains('Transfer-Encoding: custom', $buffer);
+ $this->assertContains("5\r\nhello\r\n", $buffer);
+ }
+
+ public function testDateHeaderWillBeAddedWhenNoneIsGiven()
+ {
+ $server = new Server(function (ServerRequestInterface $request) {
+ return \React\Promise\resolve(new Response());
+ });
+
+ $buffer = '';
+ $this->connection
+ ->expects($this->any())
+ ->method('write')
+ ->will(
+ $this->returnCallback(
+ function ($data) use (&$buffer) {
+ $buffer .= $data;
+ }
+ )
+ );
+
+ $server->listen($this->socket);
+ $this->socket->emit('connection', array($this->connection));
+
+ $data = $this->createGetRequest();
+
+ $this->connection->emit('data', array($data));
+
+ $this->assertContains("HTTP/1.1 200 OK\r\n", $buffer);
+ $this->assertContains("Date:", $buffer);
+ $this->assertContains("\r\n\r\n", $buffer);
+ }
+
+ public function testAddCustomDateHeader()
+ {
+ $server = new Server(function (ServerRequestInterface $request) {
+ $response = new Response(200, array("Date" => "Tue, 15 Nov 1994 08:12:31 GMT"));
+ return \React\Promise\resolve($response);
+ });
+
+ $buffer = '';
+ $this->connection
+ ->expects($this->any())
+ ->method('write')
+ ->will(
+ $this->returnCallback(
+ function ($data) use (&$buffer) {
+ $buffer .= $data;
+ }
+ )
+ );
+
+ $server->listen($this->socket);
+ $this->socket->emit('connection', array($this->connection));
+
+ $data = $this->createGetRequest();
+
+ $this->connection->emit('data', array($data));
+
+ $this->assertContains("HTTP/1.1 200 OK\r\n", $buffer);
+ $this->assertContains("Date: Tue, 15 Nov 1994 08:12:31 GMT\r\n", $buffer);
+ $this->assertContains("\r\n\r\n", $buffer);
+ }
+
+ public function testRemoveDateHeader()
+ {
+ $server = new Server(function (ServerRequestInterface $request) {
+ $response = new Response(200, array('Date' => ''));
+ return \React\Promise\resolve($response);
+ });
+
+ $buffer = '';
+ $this->connection
+ ->expects($this->any())
+ ->method('write')
+ ->will(
+ $this->returnCallback(
+ function ($data) use (&$buffer) {
+ $buffer .= $data;
+ }
+ )
+ );
+
+ $server->listen($this->socket);
+ $this->socket->emit('connection', array($this->connection));
+
+ $data = $this->createGetRequest();
+
+ $this->connection->emit('data', array($data));
+
+ $this->assertContains("HTTP/1.1 200 OK\r\n", $buffer);
+ $this->assertNotContains("Date:", $buffer);
+ $this->assertContains("\r\n\r\n", $buffer);
+ }
+
+ public function testOnlyChunkedEncodingIsAllowedForTransferEncoding()
+ {
+ $error = null;
+
+ $server = new Server($this->expectCallableNever());
+ $server->on('error', function ($exception) use (&$error) {
+ $error = $exception;
+ });
+
+ $buffer = '';
+ $this->connection
+ ->expects($this->any())
+ ->method('write')
+ ->will(
+ $this->returnCallback(
+ function ($data) use (&$buffer) {
+ $buffer .= $data;
+ }
+ )
+ );
+
+ $server->listen($this->socket);
+ $this->socket->emit('connection', array($this->connection));
+
+ $data = "GET / HTTP/1.1\r\n";
+ $data .= "Host: example.com:80\r\n";
+ $data .= "Connection: close\r\n";
+ $data .= "Transfer-Encoding: custom\r\n";
+ $data .= "\r\n";
+
+ $this->connection->emit('data', array($data));
+
+ $this->assertContains("HTTP/1.1 501 Not Implemented\r\n", $buffer);
+ $this->assertContains("\r\n\r\nError 501: Not Implemented", $buffer);
+ $this->assertInstanceOf('InvalidArgumentException', $error);
+ }
+
+ public function testOnlyChunkedEncodingIsAllowedForTransferEncodingWithHttp10()
+ {
+ $error = null;
+
+ $server = new Server($this->expectCallableNever());
+ $server->on('error', function ($exception) use (&$error) {
+ $error = $exception;
+ });
+
+ $buffer = '';
+ $this->connection
+ ->expects($this->any())
+ ->method('write')
+ ->will(
+ $this->returnCallback(
+ function ($data) use (&$buffer) {
+ $buffer .= $data;
+ }
+ )
+ );
+
+ $server->listen($this->socket);
+ $this->socket->emit('connection', array($this->connection));
+
+ $data = "GET / HTTP/1.0\r\n";
+ $data .= "Transfer-Encoding: custom\r\n";
+ $data .= "\r\n";
+
+ $this->connection->emit('data', array($data));
+
+ $this->assertContains("HTTP/1.0 501 Not Implemented\r\n", $buffer);
+ $this->assertContains("\r\n\r\nError 501: Not Implemented", $buffer);
+ $this->assertInstanceOf('InvalidArgumentException', $error);
+ }
+
+ public function test100ContinueRequestWillBeHandled()
+ {
+ $server = new Server(function (ServerRequestInterface $request) {
+ return \React\Promise\resolve(new Response());
+ });
+
+ $buffer = '';
+ $this->connection
+ ->expects($this->any())
+ ->method('write')
+ ->will(
+ $this->returnCallback(
+ function ($data) use (&$buffer) {
+ $buffer .= $data;
+ }
+ )
+ );
+
+ $server->listen($this->socket);
+ $this->socket->emit('connection', array($this->connection));
+
+ $data = "GET / HTTP/1.1\r\n";
+ $data .= "Host: example.com:80\r\n";
+ $data .= "Connection: close\r\n";
+ $data .= "Expect: 100-continue\r\n";
+ $data .= "\r\n";
+
+ $this->connection->emit('data', array($data));
+ $this->assertContains("HTTP/1.1 100 Continue\r\n", $buffer);
+ $this->assertContains("HTTP/1.1 200 OK\r\n", $buffer);
+ }
+
+ public function testContinueWontBeSendForHttp10()
+ {
+ $server = new Server(function (ServerRequestInterface $request) {
+ return \React\Promise\resolve(new Response());
+ });
+
+ $buffer = '';
+ $this->connection
+ ->expects($this->any())
+ ->method('write')
+ ->will(
+ $this->returnCallback(
+ function ($data) use (&$buffer) {
+ $buffer .= $data;
+ }
+ )
+ );
+
+ $server->listen($this->socket);
+ $this->socket->emit('connection', array($this->connection));
+
+ $data = "GET / HTTP/1.0\r\n";
+ $data .= "Expect: 100-continue\r\n";
+ $data .= "\r\n";
+
+ $this->connection->emit('data', array($data));
+ $this->assertContains("HTTP/1.0 200 OK\r\n", $buffer);
+ $this->assertNotContains("HTTP/1.1 100 Continue\r\n\r\n", $buffer);
+ }
+
+ public function testContinueWithLaterResponse()
+ {
+ $server = new Server(function (ServerRequestInterface $request) {
+ return \React\Promise\resolve(new Response());
+ });
+
+
+ $buffer = '';
+ $this->connection
+ ->expects($this->any())
+ ->method('write')
+ ->will(
+ $this->returnCallback(
+ function ($data) use (&$buffer) {
+ $buffer .= $data;
+ }
+ )
+ );
+
+ $server->listen($this->socket);
+ $this->socket->emit('connection', array($this->connection));
+
+ $data = "GET / HTTP/1.1\r\n";
+ $data .= "Host: example.com:80\r\n";
+ $data .= "Connection: close\r\n";
+ $data .= "Expect: 100-continue\r\n";
+ $data .= "\r\n";
+
+ $this->connection->emit('data', array($data));
+
+ $this->assertContains("HTTP/1.1 100 Continue\r\n\r\n", $buffer);
+ $this->assertContains("HTTP/1.1 200 OK\r\n", $buffer);
+ }
+
+ /**
+ * @expectedException InvalidArgumentException
+ */
+ public function testInvalidCallbackFunctionLeadsToException()
+ {
+ $server = new Server('invalid');
+ }
+
+ public function testHttpBodyStreamAsBodyWillStreamData()
+ {
+ $input = new ThroughStream();
+
+ $server = new Server(function (ServerRequestInterface $request) use ($input) {
+ $response = new Response(200, array(), $input);
+ return \React\Promise\resolve($response);
+ });
+
+ $buffer = '';
+ $this->connection
+ ->expects($this->any())
+ ->method('write')
+ ->will(
+ $this->returnCallback(
+ function ($data) use (&$buffer) {
+ $buffer .= $data;
+ }
+ )
+ );
+
+ $server->listen($this->socket);
+ $this->socket->emit('connection', array($this->connection));
+
+ $data = $this->createGetRequest();
+
+ $this->connection->emit('data', array($data));
+ $input->emit('data', array('1'));
+ $input->emit('data', array('23'));
+
+ $this->assertContains("HTTP/1.1 200 OK\r\n", $buffer);
+ $this->assertContains("\r\n\r\n", $buffer);
+ $this->assertContains("1\r\n1\r\n", $buffer);
+ $this->assertContains("2\r\n23\r\n", $buffer);
+ }
+
+ public function testHttpBodyStreamWithContentLengthWillStreamTillLength()
+ {
+ $input = new ThroughStream();
+
+ $server = new Server(function (ServerRequestInterface $request) use ($input) {
+ $response = new Response(200, array('Content-Length' => 5), $input);
+ return \React\Promise\resolve($response);
+ });
+
+ $buffer = '';
+ $this->connection
+ ->expects($this->any())
+ ->method('write')
+ ->will(
+ $this->returnCallback(
+ function ($data) use (&$buffer) {
+ $buffer .= $data;
+ }
+ )
+ );
+
+ $server->listen($this->socket);
+ $this->socket->emit('connection', array($this->connection));
+
+ $data = $this->createGetRequest();
+
+ $this->connection->emit('data', array($data));
+ $input->emit('data', array('hel'));
+ $input->emit('data', array('lo'));
+
+ $this->assertContains("HTTP/1.1 200 OK\r\n", $buffer);
+ $this->assertContains("Content-Length: 5\r\n", $buffer);
+ $this->assertNotContains("Transfer-Encoding", $buffer);
+ $this->assertContains("\r\n\r\n", $buffer);
+ $this->assertContains("hello", $buffer);
+ }
+
+ public function testCallbackFunctionReturnsPromise()
+ {
+ $server = new Server(function (ServerRequestInterface $request) {
+ return \React\Promise\resolve(new Response());
+ });
+
+ $buffer = '';
+ $this->connection
+ ->expects($this->any())
+ ->method('write')
+ ->will(
+ $this->returnCallback(
+ function ($data) use (&$buffer) {
+ $buffer .= $data;
+ }
+ )
+ );
+
+ $server->listen($this->socket);
+ $this->socket->emit('connection', array($this->connection));
+
+ $data = $this->createGetRequest();
+
+ $this->connection->emit('data', array($data));
+ $this->assertContains("HTTP/1.1 200 OK\r\n", $buffer);
+ $this->assertContains("\r\n\r\n", $buffer);
+ }
+
+ public function testReturnInvalidTypeWillResultInError()
+ {
+ $server = new Server(function (ServerRequestInterface $request) {
+ return "invalid";
+ });
+
+ $exception = null;
+ $server->on('error', function (\Exception $ex) use (&$exception) {
+ $exception = $ex;
+ });
+
+ $buffer = '';
+ $this->connection
+ ->expects($this->any())
+ ->method('write')
+ ->will(
+ $this->returnCallback(
+ function ($data) use (&$buffer) {
+ $buffer .= $data;
+ }
+ )
+ );
+
+ $server->listen($this->socket);
+ $this->socket->emit('connection', array($this->connection));
+
+ $data = "GET / HTTP/1.0\r\n\r\n";
+
+ $data = $this->createGetRequest();
+
+ $this->connection->emit('data', array($data));
+
+ $this->assertContains("HTTP/1.1 500 Internal Server Error\r\n", $buffer);
+ $this->assertInstanceOf('RuntimeException', $exception);
+ }
+
+ public function testResolveWrongTypeInPromiseWillResultInError()
+ {
+ $server = new Server(function (ServerRequestInterface $request) {
+ return \React\Promise\resolve("invalid");
+ });
+
+ $buffer = '';
+ $this->connection
+ ->expects($this->any())
+ ->method('write')
+ ->will(
+ $this->returnCallback(
+ function ($data) use (&$buffer) {
+ $buffer .= $data;
+ }
+ )
+ );
+
+ $server->listen($this->socket);
+ $this->socket->emit('connection', array($this->connection));
+
+ $data = "GET / HTTP/1.0\r\n\r\n";
+
+ $data = $this->createGetRequest();
+
+ $this->connection->emit('data', array($data));
+
+ $this->assertContains("HTTP/1.1 500 Internal Server Error\r\n", $buffer);
+ }
+
+ public function testRejectedPromiseWillResultInErrorMessage()
+ {
+ $server = new Server(function (ServerRequestInterface $request) {
+ return new Promise(function ($resolve, $reject) {
+ $reject(new \Exception());
+ });
+ });
+ $server->on('error', $this->expectCallableOnce());
+
+ $buffer = '';
+ $this->connection
+ ->expects($this->any())
+ ->method('write')
+ ->will(
+ $this->returnCallback(
+ function ($data) use (&$buffer) {
+ $buffer .= $data;
+ }
+ )
+ );
+
+ $server->listen($this->socket);
+ $this->socket->emit('connection', array($this->connection));
+
+ $data = "GET / HTTP/1.0\r\n\r\n";
+
+ $data = $this->createGetRequest();
+
+ $this->connection->emit('data', array($data));
+
+ $this->assertContains("HTTP/1.1 500 Internal Server Error\r\n", $buffer);
+ }
+
+ public function testExcpetionInCallbackWillResultInErrorMessage()
+ {
+ $server = new Server(function (ServerRequestInterface $request) {
+ return new Promise(function ($resolve, $reject) {
+ throw new \Exception('Bad call');
+ });
+ });
+ $server->on('error', $this->expectCallableOnce());
+
+ $buffer = '';
+ $this->connection
+ ->expects($this->any())
+ ->method('write')
+ ->will(
+ $this->returnCallback(
+ function ($data) use (&$buffer) {
+ $buffer .= $data;
+ }
+ )
+ );
+
+ $server->listen($this->socket);
+ $this->socket->emit('connection', array($this->connection));
+
+ $data = "GET / HTTP/1.0\r\n\r\n";
+
+ $data = $this->createGetRequest();
+
+ $this->connection->emit('data', array($data));
+
+ $this->assertContains("HTTP/1.1 500 Internal Server Error\r\n", $buffer);
+ }
+
+ public function testHeaderWillAlwaysBeContentLengthForStringBody()
+ {
+ $server = new Server(function (ServerRequestInterface $request) {
+ return new Response(200, array('Transfer-Encoding' => 'chunked'), 'hello');
+ });
+
+ $buffer = '';
+ $this->connection
+ ->expects($this->any())
+ ->method('write')
+ ->will(
+ $this->returnCallback(
+ function ($data) use (&$buffer) {
+ $buffer .= $data;
+ }
+ )
+ );
+
+ $server->listen($this->socket);
+ $this->socket->emit('connection', array($this->connection));
+
+ $data = "GET / HTTP/1.0\r\n\r\n";
+
+ $data = $this->createGetRequest();
+
+ $this->connection->emit('data', array($data));
+
+ $this->assertContains("HTTP/1.1 200 OK\r\n", $buffer);
+ $this->assertContains("Content-Length: 5\r\n", $buffer);
+ $this->assertContains("hello", $buffer);
+
+ $this->assertNotContains("Transfer-Encoding", $buffer);
+ }
+
+ public function testReturnRequestWillBeHandled()
+ {
+ $server = new Server(function (ServerRequestInterface $request) {
+ return new Response();
+ });
+
+ $buffer = '';
+ $this->connection
+ ->expects($this->any())
+ ->method('write')
+ ->will(
+ $this->returnCallback(
+ function ($data) use (&$buffer) {
+ $buffer .= $data;
+ }
+ )
+ );
+
+ $server->listen($this->socket);
+ $this->socket->emit('connection', array($this->connection));
+
+ $data = "GET / HTTP/1.0\r\n\r\n";
+
+ $data = $this->createGetRequest();
+
+ $this->connection->emit('data', array($data));
+
+ $this->assertContains("HTTP/1.1 200 OK\r\n", $buffer);
+ }
+
+ public function testExceptionThrowInCallBackFunctionWillResultInErrorMessage()
+ {
+ $server = new Server(function (ServerRequestInterface $request) {
+ throw new \Exception('hello');
+ });
+
+ $exception = null;
+ $server->on('error', function (\Exception $ex) use (&$exception) {
+ $exception = $ex;
+ });
+
+ $buffer = '';
+ $this->connection
+ ->expects($this->any())
+ ->method('write')
+ ->will(
+ $this->returnCallback(
+ function ($data) use (&$buffer) {
+ $buffer .= $data;
+ }
+ )
+ );
+
+ $server->listen($this->socket);
+ $this->socket->emit('connection', array($this->connection));
+
+ $data = "GET / HTTP/1.0\r\n\r\n";
+
+ $data = $this->createGetRequest();
+
+ $this->connection->emit('data', array($data));
+
+ $this->assertInstanceOf('RuntimeException', $exception);
+ $this->assertContains("HTTP/1.1 500 Internal Server Error\r\n", $buffer);
+ $this->assertEquals('hello', $exception->getPrevious()->getMessage());
+ }
+
+ public function testRejectOfNonExceptionWillResultInErrorMessage()
+ {
+ $server = new Server(function (ServerRequestInterface $request) {
+ return new Promise(function ($resolve, $reject) {
+ $reject('Invalid type');
+ });
+ });
+
+ $exception = null;
+ $server->on('error', function (\Exception $ex) use (&$exception) {
+ $exception = $ex;
+ });
+
+ $buffer = '';
+ $this->connection
+ ->expects($this->any())
+ ->method('write')
+ ->will(
+ $this->returnCallback(
+ function ($data) use (&$buffer) {
+ $buffer .= $data;
+ }
+ )
+ );
+
+ $server->listen($this->socket);
+ $this->socket->emit('connection', array($this->connection));
+
+ $data = "GET / HTTP/1.0\r\n\r\n";
+
+ $data = $this->createGetRequest();
+
+ $this->connection->emit('data', array($data));
+
+ $this->assertContains("HTTP/1.1 500 Internal Server Error\r\n", $buffer);
+ $this->assertInstanceOf('RuntimeException', $exception);
+ }
+
+ public function testServerRequestParams()
+ {
+ $requestValidation = null;
+ $server = new Server(function (ServerRequestInterface $request) use (&$requestValidation) {
+ $requestValidation = $request;
+ return new Response();
+ });
+
+ $this->connection
+ ->expects($this->any())
+ ->method('getRemoteAddress')
+ ->willReturn('192.168.1.2:80');
+
+ $this->connection
+ ->expects($this->any())
+ ->method('getLocalAddress')
+ ->willReturn('127.0.0.1:8080');
+
+ $server->listen($this->socket);
+ $this->socket->emit('connection', array($this->connection));
+
+ $data = $this->createGetRequest();
+
+ $this->connection->emit('data', array($data));
+
+ $serverParams = $requestValidation->getServerParams();
+
+ $this->assertEquals('127.0.0.1', $serverParams['SERVER_ADDR']);
+ $this->assertEquals('8080', $serverParams['SERVER_PORT']);
+ $this->assertEquals('192.168.1.2', $serverParams['REMOTE_ADDR']);
+ $this->assertEquals('80', $serverParams['REMOTE_PORT']);
+ $this->assertNotNull($serverParams['REQUEST_TIME']);
+ $this->assertNotNull($serverParams['REQUEST_TIME_FLOAT']);
+ }
+
+ public function testQueryParametersWillBeAddedToRequest()
+ {
+ $requestValidation = null;
+ $server = new Server(function (ServerRequestInterface $request) use (&$requestValidation) {
+ $requestValidation = $request;
+ return new Response();
+ });
+
+ $server->listen($this->socket);
+ $this->socket->emit('connection', array($this->connection));
+
+ $data = "GET /foo.php?hello=world&test=bar HTTP/1.0\r\n\r\n";
+
+ $this->connection->emit('data', array($data));
+
+ $queryParams = $requestValidation->getQueryParams();
+
+ $this->assertEquals('world', $queryParams['hello']);
+ $this->assertEquals('bar', $queryParams['test']);
+ }
+
+ public function testCookieWillBeAddedToServerRequest()
+ {
+ $requestValidation = null;
+ $server = new Server(function (ServerRequestInterface $request) use (&$requestValidation) {
+ $requestValidation = $request;
+ return new Response();
+ });
+
+ $server->listen($this->socket);
+ $this->socket->emit('connection', array($this->connection));
+
+ $data = "GET / HTTP/1.1\r\n";
+ $data .= "Host: example.com:80\r\n";
+ $data .= "Connection: close\r\n";
+ $data .= "Cookie: hello=world\r\n";
+ $data .= "\r\n";
+
+ $this->connection->emit('data', array($data));
+
+ $this->assertEquals(array('hello' => 'world'), $requestValidation->getCookieParams());
+ }
+
+ public function testMultipleCookiesWontBeAddedToServerRequest()
+ {
+ $requestValidation = null;
+ $server = new Server(function (ServerRequestInterface $request) use (&$requestValidation) {
+ $requestValidation = $request;
+ return new Response();
+ });
+
+ $server->listen($this->socket);
+ $this->socket->emit('connection', array($this->connection));
+
+ $data = "GET / HTTP/1.1\r\n";
+ $data .= "Host: example.com:80\r\n";
+ $data .= "Connection: close\r\n";
+ $data .= "Cookie: hello=world\r\n";
+ $data .= "Cookie: test=failed\r\n";
+ $data .= "\r\n";
+
+ $this->connection->emit('data', array($data));
+ $this->assertEquals(array(), $requestValidation->getCookieParams());
+ }
+
+ public function testCookieWithSeparatorWillBeAddedToServerRequest()
+ {
+ $requestValidation = null;
+ $server = new Server(function (ServerRequestInterface $request) use (&$requestValidation) {
+ $requestValidation = $request;
+ return new Response();
+ });
+
+ $server->listen($this->socket);
+ $this->socket->emit('connection', array($this->connection));
+
+ $data = "GET / HTTP/1.1\r\n";
+ $data .= "Host: example.com:80\r\n";
+ $data .= "Connection: close\r\n";
+ $data .= "Cookie: hello=world; test=abc\r\n";
+ $data .= "\r\n";
- $this->assertContains("\r\nX-Powered-By: React/alpha\r\n", $conn->getData());
+ $this->connection->emit('data', array($data));
+ $this->assertEquals(array('hello' => 'world', 'test' => 'abc'), $requestValidation->getCookieParams());
}
private function createGetRequest()
diff --git a/tests/SocketServerStub.php b/tests/SocketServerStub.php
new file mode 100644
index 00000000..a6610243
--- /dev/null
+++ b/tests/SocketServerStub.php
@@ -0,0 +1,29 @@
+ 'multipart/mixed; boundary=' . $boundary,
+ ), new HttpBodyStream($stream, 0), 1.1);
+
+ $parser = MultipartParser::create($request);
+ $parser->on('post', function ($key, $value) use (&$post) {
+ $post[$key] = $value;
+ });
+ $parser->on('file', function ($name, UploadedFileInterface $file) use (&$files) {
+ $files[] = [$name, $file];
+ });
+
+ $data = "--$boundary\r\n";
+ $data .= "Content-Disposition: form-data; name=\"users[one]\"\r\n";
+ $data .= "\r\n";
+ $data .= "single\r\n";
+ $data .= "--$boundary\r\n";
+ $data .= "Content-Disposition: form-data; name=\"users[two]\"\r\n";
+ $data .= "\r\n";
+ $data .= "second\r\n";
+ $data .= "--$boundary--\r\n";
+
+ $stream->write($data);
+
+ $this->assertEmpty($files);
+ $this->assertEquals(
+ [
+ 'users[one]' => 'single',
+ 'users[two]' => 'second',
+ ],
+ $post
+ );
+ }
+
+ public function testFileUpload()
+ {
+ $files = [];
+ $post = [];
+
+ $stream = new ThroughStream();
+ $boundary = "---------------------------12758086162038677464950549563";
+
+ $request = new Request('POST', 'http://example.com/', array(
+ 'Content-Type' => 'multipart/form-data',
+ ), new HttpBodyStream($stream, 0), 1.1);
+
+ $multipart = MultipartParser::create($request);
+
+ $multipart->on('post', function ($key, $value) use (&$post) {
+ $post[] = [$key => $value];
+ });
+ $multipart->on('file', function ($name, /*UploadedFileInterface*/ $file, $headers) use (&$files) {
+ $files[] = [$name, $file, $headers];
+ });
+
+ $file = base64_decode("R0lGODlhAQABAIAAAP///wAAACH5BAAAAAAALAAAAAABAAEAAAICRAEAOw==");
+
+ $data = "--$boundary\r\n";
+ $data .= "Content-Disposition: form-data; name=\"users[one]\"\r\n";
+ $data .= "\r\n";
+ $data .= "single\r\n";
+ $data .= "--$boundary\r\n";
+ $data .= "Content-Disposition: form-data; name=\"users[two]\"\r\n";
+ $data .= "\r\n";
+ $data .= "second\r\n";
+ $stream->write($data);
+ $stream->write("--$boundary\r\n");
+ $stream->write("Content-disposition: form-data; name=\"user\"\r\n");
+ $stream->write("\r\n");
+ $stream->write("single\r\n");
+ $stream->write("--$boundary\r\n");
+ $stream->write("content-Disposition: form-data; name=\"user2\"\r\n");
+ $stream->write("\r\n");
+ $stream->write("second\r\n");
+ $stream->write("--$boundary\r\n");
+ $stream->write("Content-Disposition: form-data; name=\"users[]\"\r\n");
+ $stream->write("\r\n");
+ $stream->write("first in array\r\n");
+ $stream->write("--$boundary\r\n");
+ $stream->write("Content-Disposition: form-data; name=\"users[]\"\r\n");
+ $stream->write("\r\n");
+ $stream->write("second in array\r\n");
+ $stream->write("--$boundary\r\n");
+ $stream->write("Content-Disposition: form-data; name=\"file\"; filename=\"Us er.php\"\r\n");
+ $stream->write("Content-type: text/php\r\n");
+ $stream->write("\r\n");
+ $stream->write("write("\r\n");
+ $line = "--$boundary";
+ $lines = str_split($line, round(strlen($line) / 2));
+ $stream->write($lines[0]);
+ $stream->write($lines[1]);
+ $stream->write("\r\n");
+ $stream->write("Content-Disposition: form-data; name=\"files[]\"; filename=\"blank.gif\"\r\n");
+ $stream->write("content-Type: image/gif\r\n");
+ $stream->write("X-Foo-Bar: base64\r\n");
+ $stream->write("\r\n");
+ $stream->write($file . "\r\n");
+ $stream->write("--$boundary\r\n");
+ $stream->write("Content-Disposition: form-data; name=\"files[]\"; filename=\"User.php\"\r\n" .
+ "Content-Type: text/php\r\n" .
+ "\r\n" .
+ "write("\r\n");
+ $stream->write("--$boundary--\r\n");
+
+ $this->assertEquals(6, count($post));
+ $this->assertEquals(
+ [
+ ['users[one]' => 'single'],
+ ['users[two]' => 'second'],
+ ['user' => 'single'],
+ ['user2' => 'second'],
+ ['users[]' => 'first in array'],
+ ['users[]' => 'second in array'],
+ ],
+ $post
+ );
+
+ $this->assertEquals(3, count($files));
+ $this->assertEquals('file', $files[0][0]);
+ $this->assertEquals('Us er.php', $files[0][1]->getFilename());
+ $this->assertEquals('text/php', $files[0][1]->getContentType());
+ $this->assertEquals([
+ 'content-disposition' => [
+ 'form-data',
+ 'name="file"',
+ 'filename="Us er.php"',
+ ],
+ 'content-type' => [
+ 'text/php',
+ ],
+ ], $files[0][2]);
+
+ $this->assertEquals('files[]', $files[1][0]);
+ $this->assertEquals('blank.gif', $files[1][1]->getFilename());
+ $this->assertEquals('image/gif', $files[1][1]->getContentType());
+ $this->assertEquals([
+ 'content-disposition' => [
+ 'form-data',
+ 'name="files[]"',
+ 'filename="blank.gif"',
+ ],
+ 'content-type' => [
+ 'image/gif',
+ ],
+ 'x-foo-bar' => [
+ 'base64',
+ ],
+ ], $files[1][2]);
+
+ $this->assertEquals('files[]', $files[2][0]);
+ $this->assertEquals('User.php', $files[2][1]->getFilename());
+ $this->assertEquals('text/php', $files[2][1]->getContentType());
+ $this->assertEquals([
+ 'content-disposition' => [
+ 'form-data',
+ 'name="files[]"',
+ 'filename="User.php"',
+ ],
+ 'content-type' => [
+ 'text/php',
+ ],
+ ], $files[2][2]);
+ }
+}
diff --git a/tests/TestCase.php b/tests/TestCase.php
index a08675c9..74ad0bc7 100644
--- a/tests/TestCase.php
+++ b/tests/TestCase.php
@@ -24,6 +24,17 @@ protected function expectCallableOnce()
return $mock;
}
+ protected function expectCallableOnceWith($value)
+ {
+ $mock = $this->createCallableMock();
+ $mock
+ ->expects($this->once())
+ ->method('__invoke')
+ ->with($value);
+
+ return $mock;
+ }
+
protected function expectCallableNever()
{
$mock = $this->createCallableMock();
@@ -34,8 +45,24 @@ protected function expectCallableNever()
return $mock;
}
+ protected function expectCallableConsecutive($numberOfCalls, array $with)
+ {
+ $mock = $this->createCallableMock();
+
+ for ($i = 0; $i < $numberOfCalls; $i++) {
+ $mock
+ ->expects($this->at($i))
+ ->method('__invoke')
+ ->with($this->equalTo($with[$i]));
+ }
+
+ return $mock;
+ }
+
protected function createCallableMock()
{
- return $this->getMock('React\Tests\Http\CallableStub');
+ return $this
+ ->getMockBuilder('React\Tests\Http\CallableStub')
+ ->getMock();
}
}