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 @@ [![Build Status](https://secure.travis-ci.org/reactphp/http.png?branch=master)](http://travis-ci.org/reactphp/http) [![Code Climate](https://codeclimate.com/github/reactphp/http/badges/gpa.svg)](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(); } }