diff --git a/.gitattributes b/.gitattributes index f2f51ddf..f658344c 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,6 +1,7 @@ /.gitattributes export-ignore +/.github/ export-ignore /.gitignore export-ignore -/.travis.yml export-ignore /examples export-ignore /phpunit.xml.dist export-ignore +/phpunit.xml.legacy export-ignore /tests export-ignore diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..22793579 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,34 @@ +name: CI + +on: + push: + pull_request: + +jobs: + PHPUnit: + name: PHPUnit (PHP ${{ matrix.php }}) + runs-on: ubuntu-24.04 + strategy: + matrix: + php: + - 8.4 + - 8.3 + - 8.2 + - 8.1 + - 8.0 + - 7.4 + - 7.3 + - 7.2 + - 7.1 + steps: + - uses: actions/checkout@v4 + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + coverage: ${{ matrix.php < 8.0 && 'xdebug' || 'pcov' }} + ini-file: development + - run: composer install + - run: vendor/bin/phpunit --coverage-text + if: ${{ matrix.php >= 7.3 }} + - run: vendor/bin/phpunit --coverage-text -c phpunit.xml.legacy + if: ${{ matrix.php < 7.3 }} diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 59b46fbd..00000000 --- a/.travis.yml +++ /dev/null @@ -1,31 +0,0 @@ -language: php - -# lock distro so new future defaults will not break the build -dist: trusty - -matrix: - include: - - php: 5.3 - dist: precise - - php: 5.4 - - php: 5.5 - - php: 5.6 - - php: 7.0 - - php: 7.0 - env: - - DEPENDENCIES=lowest - - php: 7.1 - - php: 7.2 - - php: 7.3 - - php: 7.4 - - php: hhvm - allow_failures: - - php: hhvm - -install: - - composer install --no-interaction - - if [ "$DEPENDENCIES" = "lowest" ]; then composer update --prefer-lowest -n; fi - -script: - - ./vendor/bin/phpunit --coverage-text - - if [ "$DEPENDENCIES" = "lowest" ]; then php -n tests/benchmark-middleware-runner.php; fi diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f0980ce..f69779c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,369 @@ # Changelog +## 1.10.0 (2024-03-27) + +* Feature: Add new PSR-7 implementation and remove dated RingCentral PSR-7 dependency. + (#518, #519, #520 and #522 by @clue) + + This changeset allows us to maintain our own PSR-7 implementation and reduce + dependencies on external projects. It also improves performance slightly and + does not otherwise affect our public API. If you want to explicitly install + the old RingCentral PSR-7 dependency, you can still install it like this: + + ```bash + composer require ringcentral/psr7 + ``` + +* Feature: Add new `Uri` class for new PSR-7 implementation. + (#521 by @clue) + +* Feature: Validate outgoing HTTP message headers and reject invalid messages. + (#523 by @clue) + +* Feature: Full PHP 8.3 compatibility. + (#508 by @clue) + +* Fix: Fix HTTP client to omit `Transfer-Encoding: chunked` when streaming empty request body. + (#516 by @clue) + +* Fix: Ensure connection close handler is cleaned up for each request. + (#515 by @WyriHaximus) + +* Update test suite and avoid unhandled promise rejections. + (#501 and #502 by @clue) + +## 1.9.0 (2023-04-26) + +This is a **SECURITY** and feature release for the 1.x series of ReactPHP's HTTP component. + +* Security fix: This release fixes a medium severity security issue in ReactPHP's HTTP server component + that affects all versions between `v0.8.0` and `v1.8.0`. All users are encouraged to upgrade immediately. + (CVE-2023-26044 reported and fixed by @WyriHaximus) + +* Feature: Support HTTP keep-alive for HTTP client (reusing persistent connections). + (#481, #484, #486 and #495 by @clue) + + This feature offers significant performance improvements when sending many + requests to the same host as it avoids recreating the underlying TCP/IP + connection and repeating the TLS handshake for secure HTTPS requests. + + ```php + $browser = new React\Http\Browser(); + + // Up to 300% faster! HTTP keep-alive is enabled by default + $response = React\Async\await($browser->get('https://httpbingo.org/redirect/6')); + assert($response instanceof Psr\Http\Message\ResponseInterface); + ``` + +* Feature: Add `Request` class to represent outgoing HTTP request message. + (#480 by @clue) + +* Feature: Preserve request method and body for `307 Temporary Redirect` and `308 Permanent Redirect`. + (#442 by @dinooo13) + +* Feature: Include buffer logic to avoid dependency on reactphp/promise-stream. + (#482 by @clue) + +* Improve test suite and project setup and report failed assertions. + (#478 by @clue, #487 and #491 by @WyriHaximus and #475 and #479 by @SimonFrings) + +## 1.8.0 (2022-09-29) + +* Feature: Support for default request headers. + (#461 by @51imyy) + + ```php + $browser = new React\Http\Browser(); + $browser = $browser->withHeader('User-Agent', 'ACME'); + + $browser->get($url)->then(…); + ``` + +* Feature: Forward compatibility with upcoming Promise v3. + (#460 by @clue) + +## 1.7.0 (2022-08-23) + +This is a **SECURITY** and feature release for the 1.x series of ReactPHP's HTTP component. + +* Security fix: This release fixes a medium severity security issue in ReactPHP's HTTP server component + that affects all versions between `v0.7.0` and `v1.6.0`. All users are encouraged to upgrade immediately. + Special thanks to Marco Squarcina (TU Wien) for reporting this and working with us to coordinate this release. + (CVE-2022-36032 reported by @lavish and fixed by @clue) + +* Feature: Improve HTTP server performance by ~20%, reuse syscall values for clock time and socket addresses. + (#457 and #467 by @clue) + +* Feature: Full PHP 8.2+ compatibility, refactor internal `Transaction` to avoid assigning dynamic properties. + (#459 by @clue and #466 by @WyriHaximus) + +* Feature / Fix: Allow explicit `Content-Length` response header on `HEAD` requests. + (#444 by @mrsimonbennett) + +* Minor documentation improvements. + (#452 by @clue, #458 by @nhedger, #448 by @jorrit and #446 by @SimonFrings) + +* Improve test suite, update to use new reactphp/async package instead of clue/reactphp-block, + skip memory tests when lowering memory limit fails and fix legacy HHVM build. + (#464 and #440 by @clue and #450 by @SimonFrings) + +## 1.6.0 (2022-02-03) + +* Feature: Add factory methods for common HTML/JSON/plaintext/XML response types. + (#439 by @clue) + + ```php + $response = React\Http\Response\html("

Hello wörld!

\n"); + $response = React\Http\Response\json(['message' => 'Hello wörld!']); + $response = React\Http\Response\plaintext("Hello wörld!\n"); + $response = React\Http\Response\xml("Hello wörld!\n"); + ``` + +* Feature: Expose all status code constants via `Response` class. + (#432 by @clue) + + ```php + $response = new React\Http\Message\Response( + React\Http\Message\Response::STATUS_OK, // 200 OK + … + ); + $response = new React\Http\Message\Response( + React\Http\Message\Response::STATUS_NOT_FOUND, // 404 Not Found + … + ); + ``` + +* Feature: Full support for PHP 8.1 release. + (#433 by @SimonFrings and #434 by @clue) + +* Feature / Fix: Improve protocol handling for HTTP responses with no body. + (#429 and #430 by @clue) + +* Internal refactoring and internal improvements for handling requests and responses. + (#422 by @WyriHaximus and #431 by @clue) + +* Improve documentation, update proxy examples, include error reporting in examples. + (#420, #424, #426, and #427 by @clue) + +* Update test suite to use default loop. + (#438 by @clue) + +## 1.5.0 (2021-08-04) + +* Feature: Update `Browser` signature to take optional `$connector` as first argument and + to match new Socket API without nullable loop arguments. + (#418 and #419 by @clue) + + ```php + // unchanged + $browser = new React\Http\Browser(); + + // deprecated + $browser = new React\Http\Browser(null, $connector); + $browser = new React\Http\Browser($loop, $connector); + + // new + $browser = new React\Http\Browser($connector); + $browser = new React\Http\Browser($connector, $loop); + ``` + +* Feature: Rename `Server` to `HttpServer` to avoid class name collisions and + to avoid any ambiguities with regards to the new `SocketServer` API. + (#417 and #419 by @clue) + + ```php + // deprecated + $server = new React\Http\Server($handler); + $server->listen(new React\Socket\Server(8080)); + + // new + $http = new React\Http\HttpServer($handler); + $http->listen(new React\Socket\SocketServer('127.0.0.1:8080')); + ``` + +## 1.4.0 (2021-07-11) + +A major new feature release, see [**release announcement**](https://clue.engineering/2021/announcing-reactphp-default-loop). + +* Feature: Simplify usage by supporting new [default loop](https://reactphp.org/event-loop/#loop). + (#410 by @clue) + + ```php + // old (still supported) + $browser = new React\Http\Browser($loop); + $server = new React\Http\Server($loop, $handler); + + // new (using default loop) + $browser = new React\Http\Browser(); + $server = new React\Http\Server($handler); + ``` + +## 1.3.0 (2021-04-11) + +* Feature: Support persistent connections (`Connection: keep-alive`). + (#405 by @clue) + + This shows a noticeable performance improvement especially when benchmarking + using persistent connections (which is the default pretty much everywhere). + Together with other changes in this release, this improves benchmarking + performance by around 100%. + +* Feature: Require `Host` request header for HTTP/1.1 requests. + (#404 by @clue) + +* Minor documentation improvements. + (#398 by @fritz-gerneth and #399 and #400 by @pavog) + +* Improve test suite, use GitHub actions for continuous integration (CI). + (#402 by @SimonFrings) + +## 1.2.0 (2020-12-04) + +* Feature: Keep request body in memory also after consuming request body. + (#395 by @clue) + + This means consumers can now always access the complete request body as + detailed in the documentation. This allows building custom parsers and more + advanced processing models without having to mess with the default parsers. + +## 1.1.0 (2020-09-11) + +* Feature: Support upcoming PHP 8 release, update to reactphp/socket v1.6 and adjust type checks for invalid chunk headers. + (#391 by @clue) + +* Feature: Consistently resolve base URL according to HTTP specs. + (#379 by @clue) + +* Feature / Fix: Expose `Transfer-Encoding: chunked` response header and fix chunked responses for `HEAD` requests. + (#381 by @clue) + +* Internal refactoring to remove unneeded `MessageFactory` and `Response` classes. + (#380 and #389 by @clue) + +* Minor documentation improvements and improve test suite, update to support PHPUnit 9.3. + (#385 by @clue and #393 by @SimonFrings) + +## 1.0.0 (2020-07-11) + +A major new feature release, see [**release announcement**](https://clue.engineering/2020/announcing-reactphp-http). + +* First stable LTS release, now following [SemVer](https://semver.org/). + We'd like to emphasize that this component is production ready and battle-tested. + We plan to support all long-term support (LTS) releases for at least 24 months, + so you have a rock-solid foundation to build on top of. + +This update involves some major new features and a number of BC breaks due to +some necessary API cleanup. We've tried hard to avoid BC breaks where possible +and minimize impact otherwise. We expect that most consumers of this package +will be affected by BC breaks, but updating should take no longer than a few +minutes. See below for more details: + +* Feature: Add async HTTP client implementation. + (#368 by @clue) + + ```php + $browser = new React\Http\Browser($loop); + $browser->get($url)->then(function (Psr\Http\Message\ResponseInterface $response) { + echo $response->getBody(); + }); + ``` + + The code has been imported as-is from [clue/reactphp-buzz v2.9.0](https://github.com/clue/reactphp-buzz), + with only minor changes to the namespace and we otherwise leave all the existing APIs unchanged. + Upgrading from [clue/reactphp-buzz v2.9.0](https://github.com/clue/reactphp-buzz) + to this release should be a matter of updating some namespace references only: + + ```php + // old + $browser = new Clue\React\Buzz\Browser($loop); + + // new + $browser = new React\Http\Browser($loop); + ``` + +* Feature / BC break: Add `LoopInterface` as required first constructor argument to `Server` and + change `Server` to accept variadic middleware handlers instead of `array`. + (#361 and #362 by @WyriHaximus) + + ```php + // old + $server = new React\Http\Server($handler); + $server = new React\Http\Server([$middleware, $handler]); + + // new + $server = new React\Http\Server($loop, $handler); + $server = new React\Http\Server($loop, $middleware, $handler); + ``` + +* Feature / BC break: Move `Response` class to `React\Http\Message\Response` and + expose `ServerRequest` class to `React\Http\Message\ServerRequest`. + (#370 by @clue) + + ```php + // old + $response = new React\Http\Response(200, [], 'Hello!'); + + // new + $response = new React\Http\Message\Response(200, [], 'Hello!'); + ``` + +* Feature / BC break: Add `StreamingRequestMiddleware` to stream incoming requests, mark `StreamingServer` as internal. + (#367 by @clue) + + ```php + // old: advanced StreamingServer is now internal only + $server = new React\Http\StreamingServer($handler); + + // new: use StreamingRequestMiddleware instead of StreamingServer + $server = new React\Http\Server( + $loop, + new React\Http\Middleware\StreamingRequestMiddleware(), + $handler + ); + ``` + +* Feature / BC break: Improve default concurrency to 1024 requests and cap default request buffer at 64K. + (#371 by @clue) + + This improves default concurrency to 1024 requests and caps the default request buffer at 64K. + The previous defaults resulted in just 4 concurrent requests with a request buffer of 8M. + See [`Server`](README.md#server) for details on how to override these defaults. + +* Feature: Expose ReactPHP in `User-Agent` client-side request header and in `Server` server-side response header. + (#374 by @clue) + +* Mark all classes as `final` to discourage inheriting from it. + (#373 by @WyriHaximus) + +* Improve documentation and use fully-qualified class names throughout the documentation and + add ReactPHP core team as authors to `composer.json` and license file. + (#366 and #369 by @WyriHaximus and #375 by @clue) + +* Improve test suite and support skipping all online tests with `--exclude-group internet`. + (#372 by @clue) + +## 0.8.7 (2020-07-05) + +* Fix: Fix parsing multipart request body with quoted header parameters (dot net). + (#363 by @ebimmel) + +* Fix: Fix calculating concurrency when `post_max_size` ini is unlimited. + (#365 by @clue) + +* Improve test suite to run tests on PHPUnit 9 and clean up test suite. + (#364 by @SimonFrings) + +## 0.8.6 (2020-01-12) + +* Fix: Fix parsing `Cookie` request header with comma in its values. + (#352 by @fiskie) + +* Fix: Avoid unneeded warning when decoding invalid data on PHP 7.4. + (#357 by @WyriHaximus) + +* Add .gitattributes to exclude dev files from exports. + (#353 by @reedy) + ## 0.8.5 (2019-10-29) * Internal refactorings and optimizations to improve request parsing performance. diff --git a/LICENSE b/LICENSE index a808108c..d6f8901f 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,6 @@ -Copyright (c) 2012 Igor Wiedler, Chris Boden +The MIT License (MIT) + +Copyright (c) 2012 Christian Lück, Cees-Jan Kiewiet, Jan Sorgalla, Chris Boden, Igor Wiedler Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 4fb2febe..7761a245 100644 --- a/README.md +++ b/README.md @@ -1,84 +1,756 @@ -# Http +# HTTP -[![Build Status](https://travis-ci.org/reactphp/http.svg?branch=master)](https://travis-ci.org/reactphp/http) +[![CI status](https://github.com/reactphp/http/actions/workflows/ci.yml/badge.svg)](https://github.com/reactphp/http/actions) +[![installs on Packagist](https://img.shields.io/packagist/dt/react/http?color=blue&label=installs%20on%20Packagist)](https://packagist.org/packages/react/http) -Event-driven, streaming plaintext HTTP and secure HTTPS server for [ReactPHP](https://reactphp.org/). +Event-driven, streaming HTTP client and server implementation for [ReactPHP](https://reactphp.org/). -**Table of Contents** +> **Development version:** This branch contains the code for the upcoming v3 +> release. For the code of the current stable v1 release, check out the +> [`1.x` branch](https://github.com/reactphp/http/tree/1.x). +> +> The upcoming v3 release will be the way forward for this package. However, +> we will still actively support v1 for those not yet on the latest version. +> See also [installation instructions](#install) for more details. + +This HTTP library provides re-usable implementations for an HTTP client and +server based on ReactPHP's [`Socket`](https://github.com/reactphp/socket) and +[`EventLoop`](https://github.com/reactphp/event-loop) components. +Its client component allows you to send any number of async HTTP/HTTPS requests +concurrently. +Its server component allows you to build plaintext HTTP and secure HTTPS servers +that accept incoming HTTP requests from HTTP clients (such as web browsers). +This library provides async, streaming means for all of this, so you can handle +multiple concurrent HTTP requests without blocking. + +**Table of contents** * [Quickstart example](#quickstart-example) -* [Usage](#usage) - * [Server](#server) - * [StreamingServer](#streamingserver) - * [listen()](#listen) - * [Request](#request) - * [Request parameters](#request-parameters) - * [Query parameters](#query-parameters) - * [Request body](#request-body) - * [Streaming request](#streaming-request) - * [Request method](#request-method) - * [Cookie parameters](#cookie-parameters) - * [Invalid request](#invalid-request) - * [Response](#response) - * [Deferred response](#deferred-response) +* [Client Usage](#client-usage) + * [Request methods](#request-methods) + * [Promises](#promises) + * [Cancellation](#cancellation) + * [Timeouts](#timeouts) + * [Authentication](#authentication) + * [Redirects](#redirects) + * [Blocking](#blocking) + * [Concurrency](#concurrency) * [Streaming response](#streaming-response) - * [Response length](#response-length) - * [Invalid response](#invalid-response) - * [Default response headers](#default-response-headers) - * [Middleware](#middleware) - * [LimitConcurrentRequestsMiddleware](#limitconcurrentrequestsmiddleware) - * [RequestBodyBufferMiddleware](#requestbodybuffermiddleware) - * [RequestBodyParserMiddleware](#requestbodyparsermiddleware) - * [Third-Party Middleware](#third-party-middleware) + * [Streaming request](#streaming-request) + * [HTTP proxy](#http-proxy) + * [SOCKS proxy](#socks-proxy) + * [SSH proxy](#ssh-proxy) + * [Unix domain sockets](#unix-domain-sockets) +* [Server Usage](#server-usage) + * [HttpServer](#httpserver) + * [listen()](#listen) + * [Server Request](#server-request) + * [Request parameters](#request-parameters) + * [Query parameters](#query-parameters) + * [Request body](#request-body) + * [Streaming incoming request](#streaming-incoming-request) + * [Request method](#request-method) + * [Cookie parameters](#cookie-parameters) + * [Invalid request](#invalid-request) + * [Server Response](#server-response) + * [Deferred response](#deferred-response) + * [Streaming outgoing response](#streaming-outgoing-response) + * [Response length](#response-length) + * [Invalid response](#invalid-response) + * [Default response headers](#default-response-headers) + * [Middleware](#middleware) + * [Custom middleware](#custom-middleware) + * [Third-Party Middleware](#third-party-middleware) +* [API](#api) + * [Browser](#browser) + * [get()](#get) + * [post()](#post) + * [head()](#head) + * [patch()](#patch) + * [put()](#put) + * [delete()](#delete) + * [request()](#request) + * [requestStreaming()](#requeststreaming) + * [withTimeout()](#withtimeout) + * [withFollowRedirects()](#withfollowredirects) + * [withRejectErrorResponse()](#withrejecterrorresponse) + * [withBase()](#withbase) + * [withProtocolVersion()](#withprotocolversion) + * [withResponseBuffer()](#withresponsebuffer) + * [withHeader()](#withheader) + * [withoutHeader()](#withoutheader) + * [React\Http\Message](#reacthttpmessage) + * [Response](#response) + * [html()](#html) + * [json()](#json) + * [plaintext()](#plaintext) + * [xml()](#xml) + * [Request](#request-1) + * [ServerRequest](#serverrequest) + * [Uri](#uri) + * [ResponseException](#responseexception) + * [React\Http\Middleware](#reacthttpmiddleware) + * [StreamingRequestMiddleware](#streamingrequestmiddleware) + * [LimitConcurrentRequestsMiddleware](#limitconcurrentrequestsmiddleware) + * [RequestBodyBufferMiddleware](#requestbodybuffermiddleware) + * [RequestBodyParserMiddleware](#requestbodyparsermiddleware) * [Install](#install) * [Tests](#tests) * [License](#license) ## Quickstart example +Once [installed](#install), you can use the following code to access an +HTTP web server and send some simple HTTP GET requests: + +```php +get('http://www.google.com/')->then(function (Psr\Http\Message\ResponseInterface $response) { + var_dump($response->getHeaders(), (string)$response->getBody()); +}, function (Exception $e) { + echo 'Error: ' . $e->getMessage() . PHP_EOL; +}); +``` + This is an HTTP server which responds with `Hello World!` to every request. ```php -$loop = React\EventLoop\Factory::create(); + 'text/plain' - ), +require __DIR__ . '/vendor/autoload.php'; + +$http = new React\Http\HttpServer(function (Psr\Http\Message\ServerRequestInterface $request) { + return React\Http\Message\Response::plaintext( "Hello World!\n" ); }); -$socket = new React\Socket\Server(8080, $loop); -$server->listen($socket); +$socket = new React\Socket\SocketServer('127.0.0.1:8080'); +$http->listen($socket); +``` + +See also the [examples](examples/). + +## Client Usage + +### Request methods + +Most importantly, this project provides a [`Browser`](#browser) object that +offers several methods that resemble the HTTP protocol methods: + +```php +$browser->get($url, array $headers = []); +$browser->head($url, array $headers = []); +$browser->post($url, array $headers = [], string|ReadableStreamInterface $body = ''); +$browser->delete($url, array $headers = [], string|ReadableStreamInterface $body = ''); +$browser->put($url, array $headers = [], string|ReadableStreamInterface $body = ''); +$browser->patch($url, array $headers = [], string|ReadableStreamInterface $body = ''); +``` + +Each of these methods requires a `$url` and some optional parameters to send an +HTTP request. Each of these method names matches the respective HTTP request +method, for example the [`get()`](#get) method sends an HTTP `GET` request. + +You can optionally pass an associative array of additional `$headers` that will be +sent with this HTTP request. Additionally, each method will automatically add a +matching `Content-Length` request header if an outgoing request body is given and its +size is known and non-empty. For an empty request body, if will only include a +`Content-Length: 0` request header if the request method usually expects a request +body (only applies to `POST`, `PUT` and `PATCH` HTTP request methods). + +If you're using a [streaming request body](#streaming-request), it will default +to using `Transfer-Encoding: chunked` unless you explicitly pass in a matching `Content-Length` +request header. See also [streaming request](#streaming-request) for more details. + +By default, all of the above methods default to sending requests using the +HTTP/1.1 protocol version. If you want to explicitly use the legacy HTTP/1.0 +protocol version, you can use the [`withProtocolVersion()`](#withprotocolversion) +method. If you want to use any other or even custom HTTP request method, you can +use the [`request()`](#request) method. + +Each of the above methods supports async operation and either *fulfills* with a +[PSR-7 `ResponseInterface`](https://www.php-fig.org/psr/psr-7/#33-psrhttpmessageresponseinterface) +or *rejects* with an `Exception`. +Please see the following chapter about [promises](#promises) for more details. + +### Promises + +Sending requests is async (non-blocking), so you can actually send multiple +requests in parallel. +The `Browser` will respond to each request with a +[PSR-7 `ResponseInterface`](https://www.php-fig.org/psr/psr-7/#33-psrhttpmessageresponseinterface) +message, the order is not guaranteed. +Sending requests uses a [Promise](https://github.com/reactphp/promise)-based +interface that makes it easy to react to when an HTTP request is completed +(i.e. either successfully fulfilled or rejected with an error): + +```php +$browser->get($url)->then( + function (Psr\Http\Message\ResponseInterface $response) { + var_dump('Response received', $response); + }, + function (Exception $e) { + echo 'Error: ' . $e->getMessage() . PHP_EOL; + } +); +``` + +If this looks strange to you, you can also use the more traditional [blocking API](#blocking). + +Keep in mind that resolving the Promise with the full response message means the +whole response body has to be kept in memory. +This is easy to get started and works reasonably well for smaller responses +(such as common HTML pages or RESTful or JSON API requests). + +You may also want to look into the [streaming API](#streaming-response): + +* If you're dealing with lots of concurrent requests (100+) or +* If you want to process individual data chunks as they happen (without having to wait for the full response body) or +* If you're expecting a big response body size (1 MiB or more, for example when downloading binary files) or +* If you're unsure about the response body size (better be safe than sorry when accessing arbitrary remote HTTP endpoints and the response body size is unknown in advance). + +### Cancellation + +The returned Promise is implemented in such a way that it can be cancelled +when it is still pending. +Cancelling a pending promise will reject its value with an Exception and +clean up any underlying resources. + +```php +$promise = $browser->get($url); + +Loop::addTimer(2.0, function () use ($promise) { + $promise->cancel(); +}); +``` + +### Timeouts + +This library uses a very efficient HTTP implementation, so most HTTP requests +should usually be completed in mere milliseconds. However, when sending HTTP +requests over an unreliable network (the internet), there are a number of things +that can go wrong and may cause the request to fail after a time. As such, this +library respects PHP's `default_socket_timeout` setting (default 60s) as a timeout +for sending the outgoing HTTP request and waiting for a successful response and +will otherwise cancel the pending request and reject its value with an Exception. + +Note that this timeout value covers creating the underlying transport connection, +sending the HTTP request, receiving the HTTP response headers and its full +response body and following any eventual [redirects](#redirects). See also +[redirects](#redirects) below to configure the number of redirects to follow (or +disable following redirects altogether) and also [streaming](#streaming-response) +below to not take receiving large response bodies into account for this timeout. + +You can use the [`withTimeout()` method](#withtimeout) to pass a custom timeout +value in seconds like this: + +```php +$browser = $browser->withTimeout(10.0); + +$browser->get($url)->then(function (Psr\Http\Message\ResponseInterface $response) { + // response received within 10 seconds maximum + var_dump($response->getHeaders()); +}, function (Exception $e) { + echo 'Error: ' . $e->getMessage() . PHP_EOL; +}); +``` + +Similarly, you can use a bool `false` to not apply a timeout at all +or use a bool `true` value to restore the default handling. +See [`withTimeout()`](#withtimeout) for more details. + +If you're using a [streaming response body](#streaming-response), the time it +takes to receive the response body stream will not be included in the timeout. +This allows you to keep this incoming stream open for a longer time, such as +when downloading a very large stream or when streaming data over a long-lived +connection. + +If you're using a [streaming request body](#streaming-request), the time it +takes to send the request body stream will not be included in the timeout. This +allows you to keep this outgoing stream open for a longer time, such as when +uploading a very large stream. + +Note that this timeout handling applies to the higher-level HTTP layer. Lower +layers such as socket and DNS may also apply (different) timeout values. In +particular, the underlying socket connection uses the same `default_socket_timeout` +setting to establish the underlying transport connection. To control this +connection timeout behavior, you can [inject a custom `Connector`](#browser) +like this: + +```php +$browser = new React\Http\Browser( + new React\Socket\Connector( + [ + 'timeout' => 5 + ] + ) +); +``` + +### Authentication + +This library supports [HTTP Basic Authentication](https://en.wikipedia.org/wiki/Basic_access_authentication) +using the `Authorization: Basic …` request header or allows you to set an explicit +`Authorization` request header. + +By default, this library does not include an outgoing `Authorization` request +header. If the server requires authentication, if may return a `401` (Unauthorized) +status code which will reject the request by default (see also the +[`withRejectErrorResponse()` method](#withrejecterrorresponse) below). + +In order to pass authentication details, you can simply pass the username and +password as part of the request URL like this: + +```php +$promise = $browser->get('https://user:pass@example.com/api'); +``` + +Note that special characters in the authentication details have to be +percent-encoded, see also [`rawurlencode()`](https://www.php.net/manual/en/function.rawurlencode.php). +This example will automatically pass the base64-encoded authentication details +using the outgoing `Authorization: Basic …` request header. If the HTTP endpoint +you're talking to requires any other authentication scheme, you can also pass +this header explicitly. This is common when using (RESTful) HTTP APIs that use +OAuth access tokens or JSON Web Tokens (JWT): + +```php +$token = 'abc123'; + +$promise = $browser->get( + 'https://example.com/api', + [ + 'Authorization' => 'Bearer ' . $token + ] +); +``` + +When following redirects, the `Authorization` request header will never be sent +to any remote hosts by default. When following a redirect where the `Location` +response header contains authentication details, these details will be sent for +following requests. See also [redirects](#redirects) below. + +### Redirects + +By default, this library follows any redirects and obeys `3xx` (Redirection) +status codes using the `Location` response header from the remote server. +The promise will be fulfilled with the last response from the chain of redirects. + +```php +$browser->get($url, $headers)->then(function (Psr\Http\Message\ResponseInterface $response) { + // the final response will end up here + var_dump($response->getHeaders()); +}, function (Exception $e) { + echo 'Error: ' . $e->getMessage() . PHP_EOL; +}); +``` + +Any redirected requests will follow the semantics of the original request and +will include the same request headers as the original request except for those +listed below. +If the original request is a temporary (307) or a permanent (308) redirect, request +body and headers will be passed to the redirected request. Otherwise, the request +body will never be passed to the redirected request. Accordingly, each redirected +request will remove any `Content-Length` and `Content-Type` request headers. + +If the original request used HTTP authentication with an `Authorization` request +header, this request header will only be passed as part of the redirected +request if the redirected URL is using the same host. In other words, the +`Authorizaton` request header will not be forwarded to other foreign hosts due to +possible privacy/security concerns. When following a redirect where the `Location` +response header contains authentication details, these details will be sent for +following requests. + +You can use the [`withFollowRedirects()`](#withfollowredirects) method to +control the maximum number of redirects to follow or to return any redirect +responses as-is and apply custom redirection logic like this: + +```php +$browser = $browser->withFollowRedirects(false); + +$browser->get($url)->then(function (Psr\Http\Message\ResponseInterface $response) { + // any redirects will now end up here + var_dump($response->getHeaders()); +}, function (Exception $e) { + echo 'Error: ' . $e->getMessage() . PHP_EOL; +}); +``` + +See also [`withFollowRedirects()`](#withfollowredirects) for more details. + +### Blocking + +As stated above, this library provides you a powerful, async API by default. + +You can also integrate this into your traditional, blocking environment by using +[reactphp/async](https://github.com/reactphp/async). This allows you to simply +await async HTTP requests like this: + +```php +use function React\Async\await; + +$browser = new React\Http\Browser(); + +$promise = $browser->get('http://example.com/'); + +try { + $response = await($promise); + // response successfully received +} catch (Exception $e) { + // an error occurred while performing the request +} +``` + +Similarly, you can also process multiple requests concurrently and await an array of `Response` objects: + +```php +use function React\Async\await; +use function React\Promise\all; + +$promises = [ + $browser->get('http://example.com/'), + $browser->get('http://www.example.org/'), +]; + +$responses = await(all($promises)); +``` + +This is made possible thanks to fibers available in PHP 8.1+ and our +compatibility API that also works on all supported PHP versions. +Please refer to [reactphp/async](https://github.com/reactphp/async#readme) for more details. + +Keep in mind the above remark about buffering the whole response message in memory. +As an alternative, you may also see one of the following chapters for the +[streaming API](#streaming-response). + +### Concurrency + +As stated above, this library provides you a powerful, async API. Being able to +send a large number of requests at once is one of the core features of this +project. For instance, you can easily send 100 requests concurrently while +processing SQL queries at the same time. + +Remember, with great power comes great responsibility. Sending an excessive +number of requests may either take up all resources on your side or it may even +get you banned by the remote side if it sees an unreasonable number of requests +from your side. + +```php +// watch out if array contains many elements +foreach ($urls as $url) { + $browser->get($url)->then(function (Psr\Http\Message\ResponseInterface $response) { + var_dump($response->getHeaders()); + }, function (Exception $e) { + echo 'Error: ' . $e->getMessage() . PHP_EOL; + }); +} +``` + +As a consequence, it's usually recommended to limit concurrency on the sending +side to a reasonable value. It's common to use a rather small limit, as doing +more than a dozen of things at once may easily overwhelm the receiving side. You +can use [clue/reactphp-mq](https://github.com/clue/reactphp-mq) as a lightweight +in-memory queue to concurrently do many (but not too many) things at once: + +```php +// wraps Browser in a Queue object that executes no more than 10 operations at once +$q = new Clue\React\Mq\Queue(10, null, function ($url) use ($browser) { + return $browser->get($url); +}); + +foreach ($urls as $url) { + $q($url)->then(function (Psr\Http\Message\ResponseInterface $response) { + var_dump($response->getHeaders()); + }, function (Exception $e) { + echo 'Error: ' . $e->getMessage() . PHP_EOL; + }); +} +``` + +Additional requests that exceed the concurrency limit will automatically be +enqueued until one of the pending requests completes. This integrates nicely +with the existing [Promise-based API](#promises). Please refer to +[clue/reactphp-mq](https://github.com/clue/reactphp-mq) for more details. + +This in-memory approach works reasonably well for some thousand outstanding +requests. If you're processing a very large input list (think millions of rows +in a CSV or NDJSON file), you may want to look into using a streaming approach +instead. See [clue/reactphp-flux](https://github.com/clue/reactphp-flux) for +more details. + +### Streaming response + +All of the above examples assume you want to store the whole response body in memory. +This is easy to get started and works reasonably well for smaller responses. + +However, there are several situations where it's usually a better idea to use a +streaming approach, where only small chunks have to be kept in memory: + +* If you're dealing with lots of concurrent requests (100+) or +* If you want to process individual data chunks as they happen (without having to wait for the full response body) or +* If you're expecting a big response body size (1 MiB or more, for example when downloading binary files) or +* If you're unsure about the response body size (better be safe than sorry when accessing arbitrary remote HTTP endpoints and the response body size is unknown in advance). + +You can use the [`requestStreaming()`](#requeststreaming) method to send an +arbitrary HTTP request and receive a streaming response. It uses the same HTTP +message API, but does not buffer the response body in memory. It only processes +the response body in small chunks as data is received and forwards this data +through [ReactPHP's Stream API](https://github.com/reactphp/stream). This works +for (any number of) responses of arbitrary sizes. + +This means it resolves with a normal +[PSR-7 `ResponseInterface`](https://www.php-fig.org/psr/psr-7/#33-psrhttpmessageresponseinterface), +which can be used to access the response message parameters as usual. +You can access the message body as usual, however it now also +implements [ReactPHP's `ReadableStreamInterface`](https://github.com/reactphp/stream#readablestreaminterface) +as well as parts of the [PSR-7 `StreamInterface`](https://www.php-fig.org/psr/psr-7/#34-psrhttpmessagestreaminterface). + +```php +$browser->requestStreaming('GET', $url)->then(function (Psr\Http\Message\ResponseInterface $response) { + $body = $response->getBody(); + assert($body instanceof Psr\Http\Message\StreamInterface); + assert($body instanceof React\Stream\ReadableStreamInterface); + + $body->on('data', function ($chunk) { + echo $chunk; + }); + + $body->on('error', function (Exception $e) { + echo 'Error: ' . $e->getMessage() . PHP_EOL; + }); + + $body->on('close', function () { + echo '[DONE]' . PHP_EOL; + }); +}, function (Exception $e) { + echo 'Error: ' . $e->getMessage() . PHP_EOL; +}); +``` + +See also the [stream download benchmark example](examples/91-client-benchmark-download.php) and +the [stream forwarding example](examples/21-client-request-streaming-to-stdout.php). + +You can invoke the following methods on the message body: + +```php +$body->on($event, $callback); +$body->eof(); +$body->isReadable(); +$body->pipe(React\Stream\WritableStreamInterface $dest, array $options = []); +$body->close(); +$body->pause(); +$body->resume(); +``` + +Because the message body is in a streaming state, invoking the following methods +doesn't make much sense: + +```php +$body->__toString(); // '' +$body->detach(); // throws BadMethodCallException +$body->getSize(); // null +$body->tell(); // throws BadMethodCallException +$body->isSeekable(); // false +$body->seek(); // throws BadMethodCallException +$body->rewind(); // throws BadMethodCallException +$body->isWritable(); // false +$body->write(); // throws BadMethodCallException +$body->read(); // throws BadMethodCallException +$body->getContents(); // throws BadMethodCallException +``` + +Note how [timeouts](#timeouts) apply slightly differently when using streaming. +In streaming mode, the timeout value covers creating the underlying transport +connection, sending the HTTP request, receiving the HTTP response headers and +following any eventual [redirects](#redirects). In particular, the timeout value +does not take receiving (possibly large) response bodies into account. + +If you want to integrate the streaming response into a higher level API, then +working with Promise objects that resolve with Stream objects is often inconvenient. +Consider looking into also using [react/promise-stream](https://github.com/reactphp/promise-stream). +The resulting streaming code could look something like this: + +```php +use function React\Promise\Stream\unwrapReadable; + +function download(Browser $browser, string $url): React\Stream\ReadableStreamInterface { + return unwrapReadable( + $browser->requestStreaming('GET', $url)->then(function (Psr\Http\Message\ResponseInterface $response) { + return $response->getBody(); + }) + ); +} + +$stream = download($browser, $url); +$stream->on('data', function ($data) { + echo $data; +}); +$stream->on('error', function (Exception $e) { + echo 'Error: ' . $e->getMessage() . PHP_EOL; +}); +``` + +See also the [`requestStreaming()`](#requeststreaming) method for more details. + +### Streaming request + +Besides streaming the response body, you can also stream the request body. +This can be useful if you want to send big POST requests (uploading files etc.) +or process many outgoing streams at once. +Instead of passing the body as a string, you can simply pass an instance +implementing [ReactPHP's `ReadableStreamInterface`](https://github.com/reactphp/stream#readablestreaminterface) +to the [request methods](#request-methods) like this: + +```php +$browser->post($url, [], $stream)->then(function (Psr\Http\Message\ResponseInterface $response) { + echo 'Successfully sent.'; +}, function (Exception $e) { + echo 'Error: ' . $e->getMessage() . PHP_EOL; +}); +``` + +If you're using a streaming request body (`React\Stream\ReadableStreamInterface`), it will +default to using `Transfer-Encoding: chunked` or you have to explicitly pass in a +matching `Content-Length` request header like so: + +```php +$body = new React\Stream\ThroughStream(); +Loop::addTimer(1.0, function () use ($body) { + $body->end("hello world"); +}); + +$browser->post($url, ['Content-Length' => '11'], $body); +``` + +If the streaming request body emits an `error` event or is explicitly closed +without emitting a successful `end` event first, the request will automatically +be closed and rejected. + +### HTTP proxy + +You can also establish your outgoing connections through an HTTP CONNECT proxy server +by adding a dependency to [clue/reactphp-http-proxy](https://github.com/clue/reactphp-http-proxy). + +HTTP CONNECT proxy servers (also commonly known as "HTTPS proxy" or "SSL proxy") +are commonly used to tunnel HTTPS traffic through an intermediary ("proxy"), to +conceal the origin address (anonymity) or to circumvent address blocking +(geoblocking). While many (public) HTTP CONNECT proxy servers often limit this +to HTTPS port `443` only, this can technically be used to tunnel any TCP/IP-based +protocol, such as plain HTTP and TLS-encrypted HTTPS. + +```php +$proxy = new Clue\React\HttpProxy\ProxyConnector('127.0.0.1:8080'); + +$connector = new React\Socket\Connector([ + 'tcp' => $proxy, + 'dns' => false +]); + +$browser = new React\Http\Browser($connector); +``` + +See also the [HTTP proxy example](examples/11-client-http-proxy.php). + +### SOCKS proxy + +You can also establish your outgoing connections through a SOCKS proxy server +by adding a dependency to [clue/reactphp-socks](https://github.com/clue/reactphp-socks). + +The SOCKS proxy protocol family (SOCKS5, SOCKS4 and SOCKS4a) is commonly used to +tunnel HTTP(S) traffic through an intermediary ("proxy"), to conceal the origin +address (anonymity) or to circumvent address blocking (geoblocking). While many +(public) SOCKS proxy servers often limit this to HTTP(S) port `80` and `443` +only, this can technically be used to tunnel any TCP/IP-based protocol. + +```php +$proxy = new Clue\React\Socks\Client('127.0.0.1:1080'); + +$connector = new React\Socket\Connector([ + 'tcp' => $proxy, + 'dns' => false +]); + +$browser = new React\Http\Browser($connector); +``` + +See also the [SOCKS proxy example](examples/12-client-socks-proxy.php). + +### SSH proxy + +You can also establish your outgoing connections through an SSH server +by adding a dependency to [clue/reactphp-ssh-proxy](https://github.com/clue/reactphp-ssh-proxy). + +[Secure Shell (SSH)](https://en.wikipedia.org/wiki/Secure_Shell) is a secure +network protocol that is most commonly used to access a login shell on a remote +server. Its architecture allows it to use multiple secure channels over a single +connection. Among others, this can also be used to create an "SSH tunnel", which +is commonly used to tunnel HTTP(S) traffic through an intermediary ("proxy"), to +conceal the origin address (anonymity) or to circumvent address blocking +(geoblocking). This can be used to tunnel any TCP/IP-based protocol (HTTP, SMTP, +IMAP etc.), allows you to access local services that are otherwise not accessible +from the outside (database behind firewall) and as such can also be used for +plain HTTP and TLS-encrypted HTTPS. + +```php +$proxy = new Clue\React\SshProxy\SshSocksConnector('alice@example.com'); + +$connector = new React\Socket\Connector([ + 'tcp' => $proxy, + 'dns' => false +]); + +$browser = new React\Http\Browser($connector); +``` + +See also the [SSH proxy example](examples/13-client-ssh-proxy.php). + +### Unix domain sockets + +By default, this library supports transport over plaintext TCP/IP and secure +TLS connections for the `http://` and `https://` URL schemes respectively. +This library also supports Unix domain sockets (UDS) when explicitly configured. + +In order to use a UDS path, you have to explicitly configure the connector to +override the destination URL so that the hostname given in the request URL will +no longer be used to establish the connection: + +```php +$connector = new React\Socket\FixedUriConnector( + 'unix:///var/run/docker.sock', + new React\Socket\UnixConnector() +); + +$browser = new React\Http\Browser($connector); -$loop->run(); +$client->get('http://localhost/info')->then(function (Psr\Http\Message\ResponseInterface $response) { + var_dump($response->getHeaders(), (string)$response->getBody()); +}, function (Exception $e) { + echo 'Error: ' . $e->getMessage() . PHP_EOL; +}); ``` -See also the [examples](examples). +See also the [Unix Domain Sockets (UDS) example](examples/14-client-unix-domain-sockets.php). -## Usage -### Server +## Server Usage -The `Server` class is responsible for handling incoming connections and then +### HttpServer + +The `React\Http\HttpServer` class is responsible for handling incoming connections and then processing each incoming HTTP request. -It buffers and parses the complete incoming HTTP request in memory. Once the -complete request has been received, it will invoke the request handler function. -This request handler function needs to be passed to the constructor and will be -invoked with the respective [request](#request) object and expects a -[response](#response) object in return: +When a complete HTTP request has been received, it will invoke the given +request handler function. This request handler function needs to be passed to +the constructor and will be invoked with the respective [request](#server-request) +object and expects a [response](#server-response) object in return: ```php -$server = new Server(function (ServerRequestInterface $request) { - return new Response( - 200, - array( - 'Content-Type' => 'text/plain' - ), +$http = new React\Http\HttpServer(function (Psr\Http\Message\ServerRequestInterface $request) { + return React\Http\Message\Response::plaintext( "Hello World!\n" ); }); @@ -86,36 +758,44 @@ $server = new Server(function (ServerRequestInterface $request) { Each incoming HTTP request message is always represented by the [PSR-7 `ServerRequestInterface`](https://www.php-fig.org/psr/psr-7/#321-psrhttpmessageserverrequestinterface), -see also following [request](#request) chapter for more details. +see also following [request](#server-request) chapter for more details. + Each outgoing HTTP response message is always represented by the [PSR-7 `ResponseInterface`](https://www.php-fig.org/psr/psr-7/#33-psrhttpmessageresponseinterface), -see also following [response](#response) chapter for more details. - -In order to process any connections, the server needs to be attached to an -instance of `React\Socket\ServerInterface` through the [`listen()`](#listen) method -as described in the following chapter. In its most simple form, you can attach -this to a [`React\Socket\Server`](https://github.com/reactphp/socket#server) +see also following [response](#server-response) chapter for more details. + +This class takes an optional `LoopInterface|null $loop` parameter that can be used to +pass the event loop instance to use for this object. You can use a `null` value +here in order to use the [default loop](https://github.com/reactphp/event-loop#loop). +This value SHOULD NOT be given unless you're sure you want to explicitly use a +given event loop instance. + +In order to start listening for any incoming connections, the `HttpServer` needs +to be attached to an instance of +[`React\Socket\ServerInterface`](https://github.com/reactphp/socket#serverinterface) +through the [`listen()`](#listen) method as described in the following +chapter. In its most simple form, you can attach this to a +[`React\Socket\SocketServer`](https://github.com/reactphp/socket#socketserver) in order to start a plaintext HTTP server like this: ```php -$server = new Server($handler); +$http = new React\Http\HttpServer($handler); -$socket = new React\Socket\Server('0.0.0.0:8080', $loop); -$server->listen($socket); +$socket = new React\Socket\SocketServer('0.0.0.0:8080'); +$http->listen($socket); ``` -See also the [`listen()`](#listen) method and the [first example](examples) for more details. +See also the [`listen()`](#listen) method and the +[hello world server example](examples/51-server-hello-world.php) +for more details. -The `Server` class is built as a facade around the underlying -[`StreamingServer`](#streamingserver) to provide sane defaults for 80% of the -use cases and is the recommended way to use this library unless you're sure -you know what you're doing. - -Unlike the underlying [`StreamingServer`](#streamingserver), this class -buffers and parses the complete incoming HTTP request in memory. Once the -complete request has been received, it will invoke the request handler -function. This means the [request](#request) passed to your request handler -function will be fully compatible with PSR-7. +By default, the `HttpServer` buffers and parses the complete incoming HTTP +request in memory. It will invoke the given request handler function when the +complete request headers and request body has been received. This means the +[request](#server-request) object passed to your request handler function will be +fully compatible with PSR-7 (http-message). This provides sane defaults for +80% of the use cases and is the recommended way to use this library unless +you're sure you know what you're doing. On the other hand, buffering complete HTTP requests in memory until they can be processed by your request handler function means that this class has to @@ -127,7 +807,8 @@ their respective default values: ``` memory_limit 128M -post_max_size 8M +post_max_size 8M // capped at 64K + enable_post_data_reading 1 max_input_nesting_level 64 max_input_vars 1000 @@ -137,168 +818,158 @@ upload_max_filesize 2M max_file_uploads 20 ``` -In particular, the `post_max_size` setting limits how much memory a single HTTP -request is allowed to consume while buffering its request body. On top of -this, this class will try to avoid consuming more than 1/4 of your +In particular, the `post_max_size` setting limits how much memory a single +HTTP request is allowed to consume while buffering its request body. This +needs to be limited because the server can process a large number of requests +concurrently, so the server may potentially consume a large amount of memory +otherwise. To support higher concurrency by default, this value is capped +at `64K`. If you assign a higher value, it will only allow `64K` by default. +If a request exceeds this limit, its request body will be ignored and it will +be processed like a request with no request body at all. See below for +explicit configuration to override this setting. + +By default, this class will try to avoid consuming more than half of your `memory_limit` for buffering multiple concurrent HTTP requests. As such, with the above default settings of `128M` max, it will try to consume no more than -`32M` for buffering multiple concurrent HTTP requests. As a consequence, it -will limit the concurrency to 4 HTTP requests with the above defaults. +`64M` for buffering multiple concurrent HTTP requests. As a consequence, it +will limit the concurrency to `1024` HTTP requests with the above defaults. It is imperative that you assign reasonable values to your PHP ini settings. -It is usually recommended to either reduce the memory a single request is -allowed to take (set `post_max_size 1M` or less) or to increase the total memory -limit to allow for more concurrent requests (set `memory_limit 512M` or more). -Failure to do so means that this class may have to disable concurrency and -only handle one request at a time. - -Internally, this class automatically assigns these limits to the -[middleware](#middleware) request handlers as described below. For more -advanced use cases, you may also use the advanced -[`StreamingServer`](#streamingserver) and assign these middleware request -handlers yourself as described in the following chapters. - -### StreamingServer - -The advanced `StreamingServer` class is responsible for handling incoming connections and then -processing each incoming HTTP request. - -Unlike the [`Server`](#server) class, it does not buffer and parse the incoming -HTTP request body by default. This means that the request handler will be -invoked with a streaming request body. Once the request headers have been -received, it will invoke the request handler function. This request handler -function needs to be passed to the constructor and will be invoked with the -respective [request](#request) object and expects a [response](#response) -object in return: +It is usually recommended to not support buffering incoming HTTP requests +with a large HTTP request body (e.g. large file uploads). If you want to +increase this buffer size, you will have to also increase the total memory +limit to allow for more concurrent requests (set `memory_limit 512M` or more) +or explicitly limit concurrency. + +In order to override the above buffering defaults, you can configure the +`HttpServer` explicitly. You can use the +[`LimitConcurrentRequestsMiddleware`](#limitconcurrentrequestsmiddleware) and +[`RequestBodyBufferMiddleware`](#requestbodybuffermiddleware) (see below) +to explicitly configure the total number of requests that can be handled at +once like this: ```php -$server = new StreamingServer(function (ServerRequestInterface $request) { - return new Response( - 200, - array( - 'Content-Type' => 'text/plain' - ), - "Hello World!\n" - ); -}); +$http = new React\Http\HttpServer( + new React\Http\Middleware\StreamingRequestMiddleware(), + new React\Http\Middleware\LimitConcurrentRequestsMiddleware(100), // 100 concurrent buffering handlers + new React\Http\Middleware\RequestBodyBufferMiddleware(2 * 1024 * 1024), // 2 MiB per request + new React\Http\Middleware\RequestBodyParserMiddleware(), + $handler +); ``` -Each incoming HTTP request message is always represented by the -[PSR-7 `ServerRequestInterface`](https://www.php-fig.org/psr/psr-7/#321-psrhttpmessageserverrequestinterface), -see also following [request](#request) chapter for more details. -Each outgoing HTTP response message is always represented by the -[PSR-7 `ResponseInterface`](https://www.php-fig.org/psr/psr-7/#33-psrhttpmessageresponseinterface), -see also following [response](#response) chapter for more details. +In this example, we allow processing up to 100 concurrent requests at once +and each request can buffer up to `2M`. This means you may have to keep a +maximum of `200M` of memory for incoming request body buffers. Accordingly, +you need to adjust the `memory_limit` ini setting to allow for these buffers +plus your actual application logic memory requirements (think `512M` or more). -In order to process any connections, the server needs to be attached to an -instance of `React\Socket\ServerInterface` through the [`listen()`](#listen) method -as described in the following chapter. In its most simple form, 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: +> Internally, this class automatically assigns these middleware handlers + automatically when no [`StreamingRequestMiddleware`](#streamingrequestmiddleware) + is given. Accordingly, you can use this example to override all default + settings to implement custom limits. -```php -$server = new StreamingServer($handler); +As an alternative to buffering the complete request body in memory, you can +also use a streaming approach where only small chunks of data have to be kept +in memory: -$socket = new React\Socket\Server('0.0.0.0:8080', $loop); -$server->listen($socket); +```php +$http = new React\Http\HttpServer( + new React\Http\Middleware\StreamingRequestMiddleware(), + $handler +); ``` -See also the [`listen()`](#listen) method and the [first example](examples) for more details. - -The `StreamingServer` class is considered advanced usage and unless you know -what you're doing, you're recommended to use the [`Server`](#server) class -instead. The `StreamingServer` class is specifically designed to help with -more advanced use cases where you want to have full control over consuming -the incoming HTTP request body and concurrency settings. - -In particular, this class does not buffer and parse the incoming HTTP request -in memory. It will invoke the request handler function once the HTTP request -headers have been received, i.e. before receiving the potentially much larger -HTTP request body. This means the [request](#request) passed to your request -handler function may not be fully compatible with PSR-7. See also -[streaming request](#streaming-request) below for more details. +In this case, it will invoke the request handler function once the HTTP +request headers have been received, i.e. before receiving the potentially +much larger HTTP request body. This means the [request](#server-request) passed to +your request handler function may not be fully compatible with PSR-7. This is +specifically designed to help with more advanced use cases where you want to +have full control over consuming the incoming HTTP request body and +concurrency settings. See also [streaming incoming request](#streaming-incoming-request) +below for more details. ### listen() The `listen(React\Socket\ServerInterface $socket): void` method can be used to -start processing connections from the given socket server. +start listening for HTTP requests on the given socket server instance. + The given [`React\Socket\ServerInterface`](https://github.com/reactphp/socket#serverinterface) -is responsible for emitting the underlying streaming connections. -This HTTP server needs to be attached to it in order to process any connections -and pase incoming streaming data as incoming HTTP request messages. -In its most common form, you can attach this to a -[`React\Socket\Server`](https://github.com/reactphp/socket#server) +is responsible for emitting the underlying streaming connections. This +HTTP server needs to be attached to it in order to process any +connections and pase incoming streaming data as incoming HTTP request +messages. In its most common form, you can attach this to a +[`React\Socket\SocketServer`](https://github.com/reactphp/socket#socketserver) in order to start a plaintext HTTP server like this: ```php -$server = new Server($handler); -// or -$server = new StreamingServer($handler); +$http = new React\Http\HttpServer($handler); -$socket = new React\Socket\Server('0.0.0.0:8080', $loop); -$server->listen($socket); +$socket = new React\Socket\SocketServer('0.0.0.0:8080'); +$http->listen($socket); ``` -This example will start listening for HTTP requests on the alternative HTTP port -`8080` on all interfaces (publicly). As an alternative, it is very common to use -a reverse proxy and let this HTTP server listen on the localhost (loopback) -interface only by using the listen address `127.0.0.1:8080` instead. This way, you -host your application(s) on the default HTTP port `80` and only route specific -requests to this HTTP server. +See also [hello world server example](examples/51-server-hello-world.php) +for more details. + +This example will start listening for HTTP requests on the alternative +HTTP port `8080` on all interfaces (publicly). As an alternative, it is +very common to use a reverse proxy and let this HTTP server listen on the +localhost (loopback) interface only by using the listen address +`127.0.0.1:8080` instead. This way, you host your application(s) on the +default HTTP port `80` and only route specific requests to this HTTP +server. Likewise, it's usually recommended to use a reverse proxy setup to accept -secure HTTPS requests on default HTTPS port `443` (TLS termination) and only -route plaintext requests to this HTTP server. As an alternative, you can also -accept secure HTTPS requests with this HTTP server by attaching this to a -[`React\Socket\Server`](https://github.com/reactphp/socket#server) using a -secure TLS listen address, a certificate file and optional `passphrase` like this: +secure HTTPS requests on default HTTPS port `443` (TLS termination) and +only route plaintext requests to this HTTP server. As an alternative, you +can also accept secure HTTPS requests with this HTTP server by attaching +this to a [`React\Socket\SocketServer`](https://github.com/reactphp/socket#socketserver) +using a secure TLS listen address, a certificate file and optional +`passphrase` like this: ```php -$server = new Server($handler); -// or -$server = new StreamingServer($handler); - -$socket = new React\Socket\Server('tls://0.0.0.0:8443', $loop, array( - 'local_cert' => __DIR__ . '/localhost.pem' -)); -$server->listen($socket); +$http = new React\Http\HttpServer($handler); + +$socket = new React\Socket\SocketServer('tls://0.0.0.0:8443', [ + 'tls' => [ + 'local_cert' => __DIR__ . '/localhost.pem' + ] +]); +$http->listen($socket); ``` -See also [example #11](examples) for more details. +See also [hello world HTTPS example](examples/61-server-hello-world-https.php) +for more details. -### Request +### Server Request -As seen above, the [`Server`](#server) and [`StreamingServer`](#streamingserver) -classes are responsible for handling incoming connections and then processing -each incoming HTTP request. +As seen above, the [`HttpServer`](#httpserver) class is responsible for handling +incoming connections and then processing each incoming HTTP request. The request object will be processed once the request has 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) +[PSR-7 `ServerRequestInterface`](https://www.php-fig.org/psr/psr-7/#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) +[PSR-7 `RequestInterface`](https://www.php-fig.org/psr/psr-7/#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(); +$http = new React\Http\HttpServer(function (Psr\Http\Message\ServerRequestInterface $request) { + $body = "The method of the request is: " . $request->getMethod() . "\n"; + $body .= "The requested path is: " . $request->getUri()->getPath() . "\n"; - return new Response( - 200, - array( - 'Content-Type' => 'text/plain' - ), + return React\Http\Message\Response::plaintext( $body ); }); ``` For more details about the request object, also 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) +[PSR-7 `ServerRequestInterface`](https://www.php-fig.org/psr/psr-7/#321-psrhttpmessageserverrequestinterface) and -[PSR-7 RequestInterface](https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-7-http-message.md#32-psrhttpmessagerequestinterface). +[PSR-7 `RequestInterface`](https://www.php-fig.org/psr/psr-7/#32-psrhttpmessagerequestinterface). #### Request parameters @@ -324,20 +995,16 @@ The following parameters are currently available: 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']; +$http = new React\Http\HttpServer(function (Psr\Http\Message\ServerRequestInterface $request) { + $body = "Your IP is: " . $request->getServerParams()['REMOTE_ADDR'] . "\n"; - return new Response( - 200, - array( - 'Content-Type' => 'text/plain' - ), + return React\Http\Message\Response::plaintext( $body ); }); ``` -See also [example #3](examples). +See also [whatsmyip server example](examples/53-server-whatsmyip.php). > Advanced: Note that address parameters will not be set if you're listening on a Unix domain socket (UDS) path as this protocol lacks the concept of @@ -349,7 +1016,7 @@ The `getQueryParams(): array` method can be used to get the query parameters similiar to the `$_GET` variable. ```php -$server = new Server(function (ServerRequestInterface $request) { +$http = new React\Http\HttpServer(function (Psr\Http\Message\ServerRequestInterface $request) { $queryParams = $request->getQueryParams(); $body = 'The query parameter "foo" is not set. Click the following link '; @@ -359,11 +1026,7 @@ $server = new Server(function (ServerRequestInterface $request) { $body = 'The value of "foo" is: ' . htmlspecialchars($queryParams['foo']); } - return new Response( - 200, - array( - 'Content-Type' => 'text/html' - ), + return React\Http\Message\Response::html( $body ); }); @@ -375,17 +1038,18 @@ Use [`htmlentities`](https://www.php.net/manual/en/function.htmlentities.php) like in this example to prevent [Cross-Site Scripting (abbreviated as XSS)](https://en.wikipedia.org/wiki/Cross-site_scripting). -See also [example #4](examples). +See also [server query parameters example](examples/54-server-query-parameter.php). #### Request body -If you're using the [`Server`](#server), then the request object will be -buffered and parsed in memory and contains the full request body. -This includes the parsed request body and any file uploads. +By default, the [`Server`](#httpserver) will buffer and parse the full request body +in memory. This means the given request object includes the parsed request body +and any file uploads. -> If you're using the advanced [`StreamingServer`](#streamingserver) class, jump - to the next chapter to learn more about how to process a - [streaming request](#streaming-request). +> As an alternative to the default buffering logic, you can also use the + [`StreamingRequestMiddleware`](#streamingrequestmiddleware). Jump to the next + chapter to learn more about how to process a + [streaming incoming request](#streaming-incoming-request). As stated above, each incoming HTTP request is always represented by the [PSR-7 `ServerRequestInterface`](https://www.php-fig.org/psr/psr-7/#321-psrhttpmessageserverrequestinterface). @@ -402,18 +1066,16 @@ By default, this method will only return parsed data for requests using request headers (commonly used for `POST` requests for HTML form submission data). ```php -$server = new Server(function (ServerRequestInterface $request) { +$http = new React\Http\HttpServer(function (Psr\Http\Message\ServerRequestInterface $request) { $name = $request->getParsedBody()['name'] ?? 'anonymous'; - return new Response( - 200, - array(), + return React\Http\Message\Response::plaintext( "Hello $name!\n" ); }); ``` -See also [example #12](examples) for more details. +See also [form upload example](examples/62-server-form-upload.php) for more details. The `getBody(): StreamInterface` method can be used to get the raw data from this request body, similar to @@ -426,19 +1088,17 @@ an XML (`Content-Type: application/xml`) request body (which is commonly used fo `POST`, `PUT` or `PATCH` requests in JSON-based or RESTful/RESTish APIs). ```php -$server = new Server(function (ServerRequestInterface $request) { +$http = new React\Http\HttpServer(function (Psr\Http\Message\ServerRequestInterface $request) { $data = json_decode((string)$request->getBody()); $name = $data->name ?? 'anonymous'; - return new Response( - 200, - array('Content-Type' => 'application/json'), - json_encode(['message' => "Hello $name!"]) + return React\Http\Message\Response::json( + ['message' => "Hello $name!"] ); }); ``` -See also [example #9](examples) for more details. +See also [JSON API server example](examples/59-server-json-api.php) for more details. The `getUploadedFiles(): array` method can be used to get the uploaded files in this request, similar to @@ -449,19 +1109,17 @@ This array will only be filled when using the `Content-Type: multipart/form-data request header (commonly used for `POST` requests for HTML file uploads). ```php -$server = new Server(function (ServerRequestInterface $request) { +$http = new React\Http\HttpServer(function (Psr\Http\Message\ServerRequestInterface $request) { $files = $request->getUploadedFiles(); $name = isset($files['avatar']) ? $files['avatar']->getClientFilename() : 'nothing'; - return new Response( - 200, - array(), + return React\Http\Message\Response::plaintext( "Uploaded $name\n" ); }); ``` -See also [example #12](examples) for more details. +See also [form upload server example](examples/62-server-form-upload.php) for more details. The `getSize(): ?int` method can be used to get the size of the request body, similar to PHP's `$_SERVER['CONTENT_LENGTH']` variable. @@ -473,7 +1131,7 @@ This method operates on the buffered request body, i.e. the request body size is always known, even when the request does not specify a `Content-Length` request header or when using `Transfer-Encoding: chunked` for HTTP/1.1 requests. -> Note: The `Server` automatically takes care of handling requests with the +> Note: The `HttpServer` automatically takes care of handling requests with the additional `Expect: 100-continue` request header. 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` request header and wait before sending the actual @@ -481,15 +1139,15 @@ header or when using `Transfer-Encoding: chunked` for HTTP/1.1 requests. intermediary `HTTP/1.1 100 Continue` response to the client. This ensures you will receive the request body without a delay as expected. -#### Streaming request +#### Streaming incoming request -If you're using the advanced [`StreamingServer`](#streamingserver), the -request object will be processed once the request headers have been received. +If you're using the advanced [`StreamingRequestMiddleware`](#streamingrequestmiddleware), +the request object will be processed once the request headers have been received. This means that this happens irrespective of (i.e. *before*) receiving the (potentially much larger) request body. -> If you're using the [`Server`](#server) class, jump to the previous chapter - to learn more about how to process a buffered [request body](#request-body). +> Note that this is non-standard behavior considered advanced usage. Jump to the + previous chapter to learn more about how to process a buffered [request body](#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: @@ -507,55 +1165,55 @@ access the request body stream. In the streaming mode, this method returns a stream instance that implements both the [PSR-7 `StreamInterface`](https://www.php-fig.org/psr/psr-7/#34-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 a synchronous request body. +However, most of the +[PSR-7 `StreamInterface`](https://www.php-fig.org/psr/psr-7/#34-psrhttpmessagestreaminterface) +methods have been designed under the assumption of being in control of a +synchronous 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: +[PSR-7 `StreamInterface`](https://www.php-fig.org/psr/psr-7/#34-psrhttpmessagestreaminterface) +methods are not used and SHOULD NOT be called: `tell()`, `eof()`, `seek()`, `rewind()`, `write()` and `read()`. If this is an issue for your use case and/or you want to access uploaded files, it's highly recommended to use a buffered [request body](#request-body) or use the [`RequestBodyBufferMiddleware`](#requestbodybuffermiddleware) instead. -The ReactPHP `ReadableStreamInterface` gives you access to the incoming -request body as the individual chunks arrive: +The [ReactPHP `ReadableStreamInterface`](https://github.com/reactphp/stream#readablestreaminterface) +gives you access to the incoming request body as the individual chunks arrive: ```php -$server = new StreamingServer(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); +$http = new React\Http\HttpServer( + new React\Http\Middleware\StreamingRequestMiddleware(), + function (Psr\Http\Message\ServerRequestInterface $request) { + $body = $request->getBody(); + assert($body instanceof Psr\Http\Message\StreamInterface); + assert($body instanceof React\Stream\ReadableStreamInterface); + + return new React\Promise\Promise(function ($resolve, $reject) use ($body) { + $bytes = 0; + $body->on('data', function ($data) use (&$bytes) { + $bytes += strlen($data); + }); + + $body->on('end', function () use ($resolve, &$bytes){ + $resolve(React\Http\Message\Response::plaintext( + "Received $bytes bytes\n" + )); + }); + + // an error occures e.g. on invalid chunked encoded data or an unexpected 'end' event + $body->on('error', function (Exception $e) use ($resolve, &$bytes) { + $resolve(React\Http\Message\Response::plaintext( + "Encountered error after $bytes bytes: {$e->getMessage()}\n" + )->withStatus(React\Http\Message\Response::STATUS_BAD_REQUEST)); + }); }); - - // 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 #13](examples) for more details. +See also [streaming request server example](examples/63-server-streaming-request.php) for more details. The `data` event will be emitted whenever new data is available on the request body stream. @@ -575,7 +1233,7 @@ 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). +[ReactPHP `ReadableStreamInterface`](https://github.com/reactphp/stream#readablestreaminterface). The `getSize(): ?int` method can be used to get the size of the request body, similar to PHP's `$_SERVER['CONTENT_LENGTH']` variable. @@ -587,32 +1245,27 @@ This method operates on the streaming request body, i.e. the request body size may be unknown (`null`) when using `Transfer-Encoding: chunked` for HTTP/1.1 requests. ```php -$server = new StreamingServer(function (ServerRequestInterface $request) { - $size = $request->getBody()->getSize(); - if ($size === null) { - $body = 'The request does not contain an explicit length.'; - $body .= 'This example does not accept chunked transfer encoding.'; - - return new Response( - 411, - array( - 'Content-Type' => 'text/plain' - ), - $body +$http = new React\Http\HttpServer( + new React\Http\Middleware\StreamingRequestMiddleware(), + function (Psr\Http\Message\ServerRequestInterface $request) { + $size = $request->getBody()->getSize(); + if ($size === null) { + $body = "The request does not contain an explicit length. "; + $body .= "This example does not accept chunked transfer encoding.\n"; + + return React\Http\Message\Response::plaintext( + $body + )->withStatus(React\Http\Message\Response::STATUS_LENGTH_REQUIRED); + } + + return React\Http\Message\Response::plaintext( + "Request body size: " . $size . " bytes\n" ); } - - return new Response( - 200, - array( - 'Content-Type' => 'text/plain' - ), - "Request body size: " . $size . " bytes\n" - ); -}); +); ``` -> Note: The `StreamingServer` automatically takes care of handling requests with the +> Note: The `HttpServer` automatically takes care of handling requests with the additional `Expect: 100-continue` request header. 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` request header and wait before sending the actual @@ -632,7 +1285,7 @@ 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 +effective request URI, which defaults to the local socket address if an 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. @@ -657,29 +1310,20 @@ 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'; +$http = new React\Http\HttpServer(function (Psr\Http\Message\ServerRequestInterface $request) { + $key = 'greeting'; if (isset($request->getCookieParams()[$key])) { - $body = "Your cookie value is: " . $request->getCookieParams()[$key]; + $body = "Your cookie value is: " . $request->getCookieParams()[$key] . "\n"; - return new Response( - 200, - array( - 'Content-Type' => 'text/plain' - ), + return React\Http\Message\Response::plaintext( $body ); } - return new Response( - 200, - array( - 'Content-Type' => 'text/plain', - 'Set-Cookie' => urlencode($key) . '=' . urlencode('test;more') - ), - "Your cookie has been set." - ); + return React\Http\Message\Response::plaintext( + "Your cookie has been set.\n" + )->withHeader('Set-Cookie', $key . '=' . urlencode('Hello world!')); }); ``` @@ -690,12 +1334,12 @@ non-alphanumeric characters. This encoding is also used internally when decoding the name and value of cookies (which is in line with other implementations, such as PHP's cookie functions). -See also [example #5](examples) for more details. +See also [cookie server example](examples/55-server-cookie-handling.php) for more details. #### Invalid request -The `Server` and `StreamingServer` classes support both HTTP/1.1 and HTTP/1.0 request -messages. If a client sends an invalid request message, uses an invalid HTTP +The `HttpServer` class 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` request header value, the server will automatically send a `400` (Bad Request) HTTP error response to the client and close the connection. @@ -703,7 +1347,7 @@ On top of this, it will emit an `error` event that can be used for logging purposes like this: ```php -$server->on('error', function (Exception $e) { +$http->on('error', function (Exception $e) { echo 'Error: ' . $e->getMessage() . PHP_EOL; }); ``` @@ -712,35 +1356,35 @@ Note that the server will also emit an `error` event if you do not return a valid response object from your request handler function. See also [invalid response](#invalid-response) for more details. -### Response +### Server Response + +The callback function passed to the constructor of the [`HttpServer`](#httpserver) is +responsible for processing the request and returning a response, which will be +delivered to the client. -The callback function passed to the constructor of the [`Server`](#server) or -advanced [`StreamingServer`](#server) is responsible for processing the request -and returning a response, which will be delivered to the client. This function MUST return an instance implementing -[PSR-7 ResponseInterface](https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-7-http-message.md#33-psrhttpmessageresponseinterface) +[PSR-7 `ResponseInterface`](https://www.php-fig.org/psr/psr-7/#33-psrhttpmessageresponseinterface) object or a -[ReactPHP Promise](https://github.com/reactphp/promise#reactpromise) -which will resolve a `PSR-7 ResponseInterface` object. +[ReactPHP Promise](https://github.com/reactphp/promise) +which resolves with a [PSR-7 `ResponseInterface`](https://www.php-fig.org/psr/psr-7/#33-psrhttpmessageresponseinterface) 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. +This projects ships a [`Response` class](#response) which implements the +[PSR-7 `ResponseInterface`](https://www.php-fig.org/psr/psr-7/#33-psrhttpmessageresponseinterface). +In its most simple form, you can use it like this: ```php -$server = new Server(function (ServerRequestInterface $request) { - return new Response( - 200, - array( - 'Content-Type' => 'text/plain' - ), +$http = new React\Http\HttpServer(function (Psr\Http\Message\ServerRequestInterface $request) { + return React\Http\Message\Response::plaintext( "Hello World!\n" ); }); ``` +We use this [`Response` class](#response) throughout our project examples, but +feel free to use any other implementation of the +[PSR-7 `ResponseInterface`](https://www.php-fig.org/psr/psr-7/#33-psrhttpmessageresponseinterface). +See also the [`Response` class](#response) for more details. + #### Deferred response The example above returns the response directly, because it needs @@ -753,19 +1397,18 @@ To prevent this you SHOULD use a 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 ($loop) { - $loop->addTimer(1.5, function() use ($resolve) { - $response = new Response( - 200, - array( - 'Content-Type' => 'text/plain' - ), - "Hello world" - ); - $resolve($response); +$http = new React\Http\HttpServer(function (Psr\Http\Message\ServerRequestInterface $request) { + $promise = new Promise(function ($resolve, $reject) { + Loop::addTimer(1.5, function() use ($resolve) { + $resolve(); }); }); + + return $promise->then(function () { + return React\Http\Message\Response::plaintext( + "Hello World!" + ); + }); }); ``` @@ -780,33 +1423,42 @@ 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. -#### Streaming response +#### Streaming outgoing response The `Response` class in this project supports to add an instance which implements the -[ReactPHP ReadableStreamInterface](https://github.com/reactphp/stream#readablestreaminterface) +[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. +Note that other implementations of the +[PSR-7 `ResponseInterface`](https://www.php-fig.org/psr/psr-7/#33-psrhttpmessageresponseinterface) +may only support strings. ```php -$server = new Server(function (ServerRequestInterface $request) use ($loop) { +$http = new React\Http\HttpServer(function (Psr\Http\Message\ServerRequestInterface $request) { $stream = new ThroughStream(); - $timer = $loop->addPeriodicTimer(0.5, function () use ($stream) { + // send some data every once in a while with periodic timer + $timer = Loop::addPeriodicTimer(0.5, function () use ($stream) { $stream->write(microtime(true) . PHP_EOL); }); - $loop->addTimer(5, function() use ($loop, $timer, $stream) { - $loop->cancelTimer($timer); + // end stream after a few seconds + $timeout = Loop::addTimer(5.0, function() use ($stream, $timer) { + Loop::cancelTimer($timer); $stream->end(); }); - return new Response( - 200, - array( + // stop timer if stream is closed (such as when connection is closed) + $stream->on('close', function () use ($timer, $timeout) { + Loop::cancelTimer($timer); + Loop::cancelTimer($timeout); + }); + + return new React\Http\Message\Response( + React\Http\Message\Response::STATUS_OK, + [ 'Content-Type' => 'text/plain' - ), + ], $stream ); }); @@ -840,8 +1492,9 @@ in this case (if applicable). 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. + or ensuring that only ever an instance of + [ReactPHP's `ReadableStreamInterface`](https://github.com/reactphp/stream#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 @@ -851,7 +1504,7 @@ in this case (if applicable). to look into using [Ratchet](http://socketo.me/) instead. If you want to handle a custom protocol, you will likely want to look into the [HTTP specs](https://tools.ietf.org/html/rfc7230#section-6.7) and also see - [examples #31 and #32](examples) for more details. + [examples #81 and #82](examples/) for more details. In particular, the `101` (Switching Protocols) response code MUST NOT be used unless you send an `Upgrade` response header value that is also present in the corresponding HTTP/1.1 `Upgrade` request header value. @@ -872,7 +1525,7 @@ in this case (if applicable). requests, one may still be present. Normal request body processing applies here and the connection will only turn to "tunneling mode" after the request body has been processed (which should be empty in most cases). - See also [example #22](examples) for more details. + See also [HTTP CONNECT server example](examples/72-server-http-connect-proxy.php) for more details. #### Response length @@ -881,38 +1534,34 @@ added automatically. This is the most common use case, for example when using a `string` response body like this: ```php -$server = new Server(function (ServerRequestInterface $request) { - return new Response( - 200, - array( - 'Content-Type' => 'text/plain' - ), +$http = new React\Http\HttpServer(function (Psr\Http\Message\ServerRequestInterface $request) { + return React\Http\Message\Response::plaintext( "Hello World!\n" ); }); ``` If the response body size is unknown, a `Content-Length` response header can not -be added automatically. When using a [streaming response](#streaming-response) +be added automatically. When using a [streaming outgoing response](#streaming-outgoing-response) without an explicit `Content-Length` response header, outgoing HTTP/1.1 response messages will automatically use `Transfer-Encoding: chunked` while legacy HTTP/1.0 response messages will contain the plain response body. If you know the length of your streaming response body, you MAY want to specify it explicitly like this: ```php -$server = new Server(function (ServerRequestInterface $request) use ($loop) { +$http = new React\Http\HttpServer(function (Psr\Http\Message\ServerRequestInterface $request) { $stream = new ThroughStream(); - $loop->addTimer(2.0, function () use ($stream) { + Loop::addTimer(2.0, function () use ($stream) { $stream->end("Hello World!\n"); }); - return new Response( - 200, - array( + return new React\Http\Message\Response( + React\Http\Message\Response::STATUS_OK, + [ 'Content-Length' => '13', 'Content-Type' => 'text/plain', - ), + ], $stream ); }); @@ -944,7 +1593,7 @@ On top of this, it will emit an `error` event that can be used for logging purposes like this: ```php -$server->on('error', function (Exception $e) { +$http->on('error', function (Exception $e) { echo 'Error: ' . $e->getMessage() . PHP_EOL; if ($e->getPrevious() !== null) { echo 'Previous: ' . $e->getPrevious()->getMessage() . PHP_EOL; @@ -955,8 +1604,8 @@ $server->on('error', function (Exception $e) { Note that the server will also emit an `error` event if the client sends an invalid HTTP request that never reaches your request handler function. See also [invalid request](#invalid-request) for more details. -Additionally, a [streaming request](#streaming-request) body can also emit -an `error` event on the request body. +Additionally, a [streaming incoming request](#streaming-incoming-request) body +can also emit an `error` event on the request body. The server will only send a very generic `500` (Interval Server Error) HTTP error response without any further details to the client if an unhandled @@ -969,93 +1618,106 @@ create your own HTTP response message instead. #### Default response headers -After the return in the callback function the response will be processed by the -[`Server`](#server) or [`StreamingServer`](#streamingserver) respectively. -They will add the protocol version of the request, so you don't have to. +When a response is returned from the request handler function, it will be +processed by the [`HttpServer`](#httpserver) and then sent back to the client. -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: +A `Server: ReactPHP/1` response header will be added automatically. You can add +a custom `Server` response header like this: ```php -$server = new Server(function (ServerRequestInterface $request) { - return new Response( - 200, - array( - 'Date' => date('D, d M Y H:i:s T') - ) +$http = new React\Http\HttpServer(function (ServerRequestInterface $request) { + return new React\Http\Message\Response( + React\Http\Message\Response::STATUS_OK, + [ + 'Server' => 'PHP/3' + ] ); }); ``` -If you don't have a appropriate clock to rely on, you should -unset this header with an empty string: +If you do not want to send this `Sever` response header at all (such as when you +don't want to expose the underlying server software), you can use an empty +string value like this: ```php -$server = new Server(function (ServerRequestInterface $request) { - return new Response( - 200, - array( - 'Date' => '' - ) +$http = new React\Http\HttpServer(function (ServerRequestInterface $request) { + return new React\Http\Message\Response( + React\Http\Message\Response::STATUS_OK, + [ + 'Server' => '' + ] ); }); ``` -Note that it will automatically assume a `X-Powered-By: react/alpha` header -unless your specify a custom `X-Powered-By` header yourself: +A `Date` response header will be added automatically with the current system +date and time if none is given. You can add a custom `Date` response header +like this: ```php -$server = new Server(function (ServerRequestInterface $request) { - return new Response( - 200, - array( - 'X-Powered-By' => 'PHP 3' - ) +$http = new React\Http\HttpServer(function (ServerRequestInterface $request) { + return new React\Http\Message\Response( + React\Http\Message\Response::STATUS_OK, + [ + 'Date' => gmdate('D, d M Y H:i:s \G\M\T') + ] ); }); ``` -If you do not want to send this header at all, you can use an empty string as -value like this: +If you do not want to send this `Date` response header at all (such as when you +don't have an appropriate clock to rely on), you can use an empty string value +like this: ```php -$server = new Server(function (ServerRequestInterface $request) { - return new Response( - 200, - array( - 'X-Powered-By' => '' - ) +$http = new React\Http\HttpServer(function (ServerRequestInterface $request) { + return new React\Http\Message\Response( + React\Http\Message\Response::STATUS_OK, + [ + 'Date' => '' + ] ); }); ``` -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. +The `HttpServer` class will automatically add the protocol version of the request, +so you don't have to. For instance, if the client sends the request using the +HTTP/1.1 protocol version, the response message will also use the same protocol +version, no matter what version is returned from the request handler function. + +The server supports persistent connections. An appropriate `Connection: keep-alive` +or `Connection: close` response header will be added automatically, respecting the +matching request header value and HTTP default header values. The server is +responsible for handling the `Connection` response header, so you SHOULD NOT pass +this response header yourself, unless you explicitly want to override the user's +choice with a `Connection: close` response header. ### Middleware -As documented above, the [`Server`](#server) and advanced -[`StreamingServer`](#streamingserver) accept a single -request handler argument that is responsible for processing an incoming -HTTP request and then creating and returning an outgoing HTTP response. +As documented above, the [`HttpServer`](#httpserver) accepts a single request handler +argument that is responsible for processing an incoming HTTP request and then +creating and returning an outgoing HTTP response. Many common use cases involve validating, processing, manipulating the incoming HTTP request before passing it to the final business logic request handler. As such, this project supports the concept of middleware request handlers. +#### Custom middleware + A middleware request handler is expected to adhere the following rules: * It is a valid `callable`. -* It accepts `ServerRequestInterface` as first argument and an optional - `callable` as second argument. +* It accepts an instance implementing + [PSR-7 `ServerRequestInterface`](https://www.php-fig.org/psr/psr-7/#321-psrhttpmessageserverrequestinterface) + as first argument and an optional `callable` as second argument. * It returns either: - * An instance implementing `ResponseInterface` for direct consumption. + * An instance implementing + [PSR-7 `ResponseInterface`](https://www.php-fig.org/psr/psr-7/#33-psrhttpmessageresponseinterface) + for direct consumption. * Any promise which can be consumed by [`Promise\resolve()`](https://reactphp.org/promise/#resolve) resolving to a - `ResponseInterface` for deferred consumption. + [PSR-7 `ResponseInterface`](https://www.php-fig.org/psr/psr-7/#33-psrhttpmessageresponseinterface) + for deferred consumption. * It MAY throw an `Exception` (or return a rejected promise) in order to signal an error condition and abort the chain. * It calls `$next($request)` to continue processing the next middleware @@ -1085,22 +1747,21 @@ As such, this project only bundles a few middleware implementations that are required to match PHP's request behavior (see below) and otherwise actively encourages [Third-Party Middleware](#third-party-middleware) implementations. -In order to use middleware request handlers, simply pass an array with all -callables as defined above to the [`Server`](#server) or -[`StreamingServer`](#streamingserver) respectively. +In order to use middleware request handlers, simply pass a list of all +callables as defined above to the [`HttpServer`](#httpserver). The following example adds a middleware request handler that adds the current time to the request as a -header (`Request-Time`) and a final request handler that always returns a 200 code without a body: +header (`Request-Time`) and a final request handler that always returns a `200 OK` status code without a body: ```php -$server = new Server(array( - function (ServerRequestInterface $request, callable $next) { +$http = new React\Http\HttpServer( + function (Psr\Http\Message\ServerRequestInterface $request, callable $next) { $request = $request->withHeader('Request-Time', time()); return $next($request); }, - function (ServerRequestInterface $request) { - return new Response(200); + function (Psr\Http\Message\ServerRequestInterface $request) { + return new React\Http\Message\Response(React\Http\Message\Response::STATUS_OK); } -)); +); ``` > Note how the middleware request handler and the final request handler have a @@ -1110,59 +1771,985 @@ $server = new Server(array( Similarly, you can use the result of the `$next` middleware request handler function to modify the outgoing response. Note that as per the above documentation, the `$next` middleware request handler may return a -`ResponseInterface` directly or one wrapped in a promise for deferred -resolution. +[PSR-7 `ResponseInterface`](https://www.php-fig.org/psr/psr-7/#33-psrhttpmessageresponseinterface) +directly or one wrapped in a promise for deferred resolution. In order to simplify handling both paths, you can simply wrap this in a [`Promise\resolve()`](https://reactphp.org/promise/#resolve) call like this: ```php -$server = new Server(array( - function (ServerRequestInterface $request, callable $next) { +$http = new React\Http\HttpServer( + function (Psr\Http\Message\ServerRequestInterface $request, callable $next) { $promise = React\Promise\resolve($next($request)); return $promise->then(function (ResponseInterface $response) { return $response->withHeader('Content-Type', 'text/html'); }); }, - function (ServerRequestInterface $request) { - return new Response(200); + function (Psr\Http\Message\ServerRequestInterface $request) { + return new React\Http\Message\Response(React\Http\Message\Response::STATUS_OK); } -)); +); ``` Note that the `$next` middleware request handler may also throw an `Exception` (or return a rejected promise) as described above. The previous example does not catch any exceptions and would thus signal an -error condition to the `Server`. +error condition to the `HttpServer`. Alternatively, you can also catch any `Exception` to implement custom error handling logic (or logging etc.) by wrapping this in a [`Promise`](https://reactphp.org/promise/#promise) like this: ```php -$server = new Server(array( - function (ServerRequestInterface $request, callable $next) { +$http = new React\Http\HttpServer( + function (Psr\Http\Message\ServerRequestInterface $request, callable $next) { $promise = new React\Promise\Promise(function ($resolve) use ($next, $request) { $resolve($next($request)); }); return $promise->then(null, function (Exception $e) { - return new Response( - 500, - array(), - 'Internal error: ' . $e->getMessage() - ); + return React\Http\Message\Response::plaintext( + 'Internal error: ' . $e->getMessage() . "\n" + )->withStatus(React\Http\Message\Response::STATUS_INTERNAL_SERVER_ERROR); }); }, - function (ServerRequestInterface $request) { + function (Psr\Http\Message\ServerRequestInterface $request) { if (mt_rand(0, 1) === 1) { throw new RuntimeException('Database error'); } - return new Response(200); + return new React\Http\Message\Response(React\Http\Message\Response::STATUS_OK); } -)); +); +``` + +#### Third-Party Middleware + +While this project does provide the means to *use* middleware implementations +(see above), it does not aim to *define* how middleware implementations should +look like. We realize that there's a vivid ecosystem of middleware +implementations and ongoing effort to standardize interfaces between these with +[PSR-15](https://www.php-fig.org/psr/psr-15/) (HTTP Server Request Handlers) +and support this goal. +As such, this project only bundles a few middleware implementations that are +required to match PHP's request behavior (see +[middleware implementations](#reacthttpmiddleware)) and otherwise actively +encourages third-party middleware implementations. + +While we would love to support PSR-15 directly in `react/http`, we understand +that this interface does not specifically target async APIs and as such does +not take advantage of promises for [deferred responses](#deferred-response). +The gist of this is that where PSR-15 enforces a +[PSR-7 `ResponseInterface`](https://www.php-fig.org/psr/psr-7/#33-psrhttpmessageresponseinterface) +return value, we also accept a `PromiseInterface`. +As such, we suggest using the external +[PSR-15 middleware adapter](https://github.com/friends-of-reactphp/http-middleware-psr15-adapter) +that uses on the fly monkey patching of these return values which makes using +most PSR-15 middleware possible with this package without any changes required. + +Other than that, you can also use the above [middleware definition](#middleware) +to create custom middleware. A non-exhaustive list of third-party middleware can +be found at the [middleware wiki](https://github.com/reactphp/reactphp/wiki/Users#http-middleware). +If you build or know a custom middleware, make sure to let the world know and +feel free to add it to this list. + +## API + +### Browser + +The `React\Http\Browser` is responsible for sending HTTP requests to your HTTP server +and keeps track of pending incoming HTTP responses. + +```php +$browser = new React\Http\Browser(); +``` + +This class takes two optional arguments for more advanced usage: + +```php +$browser = new React\Http\Browser(?ConnectorInterface $connector = null, ?LoopInterface $loop = null); +``` + +If you need custom connector settings (DNS resolution, TLS parameters, timeouts, +proxy servers etc.), you can explicitly pass a custom instance of the +[`ConnectorInterface`](https://github.com/reactphp/socket#connectorinterface): + +```php +$connector = new React\Socket\Connector([ + 'dns' => '127.0.0.1', + 'tcp' => [ + 'bindto' => '192.168.10.1:0' + ], + 'tls' => [ + 'verify_peer' => false, + 'verify_peer_name' => false + ] +]); + +$browser = new React\Http\Browser($connector); +``` + +This class takes an optional `LoopInterface|null $loop` parameter that can be used to +pass the event loop instance to use for this object. You can use a `null` value +here in order to use the [default loop](https://github.com/reactphp/event-loop#loop). +This value SHOULD NOT be given unless you're sure you want to explicitly use a +given event loop instance. + +> Note that the browser class is final and shouldn't be extended, it is likely to be marked final in a future release. + +#### get() + +The `get(string $url, array $headers = []): PromiseInterface` method can be used to +send an HTTP GET request. + +```php +$browser->get($url)->then(function (Psr\Http\Message\ResponseInterface $response) { + var_dump((string)$response->getBody()); +}, function (Exception $e) { + echo 'Error: ' . $e->getMessage() . PHP_EOL; +}); +``` + +See also [GET request client example](examples/01-client-get-request.php). + +#### post() + +The `post(string $url, array $headers = [], string|ReadableStreamInterface $body = ''): PromiseInterface` method can be used to +send an HTTP POST request. + +```php +$browser->post( + $url, + [ + 'Content-Type' => 'application/json' + ], + json_encode($data) +)->then(function (Psr\Http\Message\ResponseInterface $response) { + var_dump(json_decode((string)$response->getBody())); +}, function (Exception $e) { + echo 'Error: ' . $e->getMessage() . PHP_EOL; +}); +``` + +See also [POST JSON client example](examples/04-client-post-json.php). + +This method is also commonly used to submit HTML form data: + +```php +$data = [ + 'user' => 'Alice', + 'password' => 'secret' +]; + +$browser->post( + $url, + [ + 'Content-Type' => 'application/x-www-form-urlencoded' + ], + http_build_query($data) +); +``` + +This method will automatically add a matching `Content-Length` request +header if the outgoing request body is a `string`. If you're using a +streaming request body (`ReadableStreamInterface`), it will default to +using `Transfer-Encoding: chunked` or you have to explicitly pass in a +matching `Content-Length` request header like so: + +```php +$body = new React\Stream\ThroughStream(); +Loop::addTimer(1.0, function () use ($body) { + $body->end("hello world"); +}); + +$browser->post($url, ['Content-Length' => '11'), $body); +``` + +#### head() + +The `head(string $url, array $headers = []): PromiseInterface` method can be used to +send an HTTP HEAD request. + +```php +$browser->head($url)->then(function (Psr\Http\Message\ResponseInterface $response) { + var_dump($response->getHeaders()); +}, function (Exception $e) { + echo 'Error: ' . $e->getMessage() . PHP_EOL; +}); +``` + +#### patch() + +The `patch(string $url, array $headers = [], string|ReadableStreamInterface $body = ''): PromiseInterface` method can be used to +send an HTTP PATCH request. + +```php +$browser->patch( + $url, + [ + 'Content-Type' => 'application/json' + ], + json_encode($data) +)->then(function (Psr\Http\Message\ResponseInterface $response) { + var_dump(json_decode((string)$response->getBody())); +}, function (Exception $e) { + echo 'Error: ' . $e->getMessage() . PHP_EOL; +}); +``` + +This method will automatically add a matching `Content-Length` request +header if the outgoing request body is a `string`. If you're using a +streaming request body (`ReadableStreamInterface`), it will default to +using `Transfer-Encoding: chunked` or you have to explicitly pass in a +matching `Content-Length` request header like so: + +```php +$body = new React\Stream\ThroughStream(); +Loop::addTimer(1.0, function () use ($body) { + $body->end("hello world"); +}); + +$browser->patch($url, ['Content-Length' => '11'], $body); +``` + +#### put() + +The `put(string $url, array $headers = [], string|ReadableStreamInterface $body = ''): PromiseInterface` method can be used to +send an HTTP PUT request. + +```php +$browser->put( + $url, + [ + 'Content-Type' => 'text/xml' + ], + $xml->asXML() +)->then(function (Psr\Http\Message\ResponseInterface $response) { + var_dump((string)$response->getBody()); +}, function (Exception $e) { + echo 'Error: ' . $e->getMessage() . PHP_EOL; +}); +``` + +See also [PUT XML client example](examples/05-client-put-xml.php). + +This method will automatically add a matching `Content-Length` request +header if the outgoing request body is a `string`. If you're using a +streaming request body (`ReadableStreamInterface`), it will default to +using `Transfer-Encoding: chunked` or you have to explicitly pass in a +matching `Content-Length` request header like so: + +```php +$body = new React\Stream\ThroughStream(); +Loop::addTimer(1.0, function () use ($body) { + $body->end("hello world"); +}); + +$browser->put($url, ['Content-Length' => '11'], $body); +``` + +#### delete() + +The `delete(string $url, array $headers = [], string|ReadableStreamInterface $body = ''): PromiseInterface` method can be used to +send an HTTP DELETE request. + +```php +$browser->delete($url)->then(function (Psr\Http\Message\ResponseInterface $response) { + var_dump((string)$response->getBody()); +}, function (Exception $e) { + echo 'Error: ' . $e->getMessage() . PHP_EOL; +}); ``` +#### request() + +The `request(string $method, string $url, array $headers = [], string|ReadableStreamInterface $body = ''): PromiseInterface` method can be used to +send an arbitrary HTTP request. + +The preferred way to send an HTTP request is by using the above +[request methods](#request-methods), for example the [`get()`](#get) +method to send an HTTP `GET` request. + +As an alternative, if you want to use a custom HTTP request method, you +can use this method: + +```php +$browser->request('OPTIONS', $url)->then(function (Psr\Http\Message\ResponseInterface $response) { + var_dump((string)$response->getBody()); +}, function (Exception $e) { + echo 'Error: ' . $e->getMessage() . PHP_EOL; +}); +``` + +This method will automatically add a matching `Content-Length` request +header if the size of the outgoing request body is known and non-empty. +For an empty request body, if will only include a `Content-Length: 0` +request header if the request method usually expects a request body (only +applies to `POST`, `PUT` and `PATCH`). + +If you're using a streaming request body (`ReadableStreamInterface`), it +will default to using `Transfer-Encoding: chunked` or you have to +explicitly pass in a matching `Content-Length` request header like so: + +```php +$body = new React\Stream\ThroughStream(); +Loop::addTimer(1.0, function () use ($body) { + $body->end("hello world"); +}); + +$browser->request('POST', $url, ['Content-Length' => '11'], $body); +``` + +#### requestStreaming() + +The `requestStreaming(string $method, string $url, array $headers = [], string|ReadableStreamInterface $body = ''): PromiseInterface` method can be used to +send an arbitrary HTTP request and receive a streaming response without buffering the response body. + +The preferred way to send an HTTP request is by using the above +[request methods](#request-methods), for example the [`get()`](#get) +method to send an HTTP `GET` request. Each of these methods will buffer +the whole response body in memory by default. This is easy to get started +and works reasonably well for smaller responses. + +In some situations, it's a better idea to use a streaming approach, where +only small chunks have to be kept in memory. You can use this method to +send an arbitrary HTTP request and receive a streaming response. It uses +the same HTTP message API, but does not buffer the response body in +memory. It only processes the response body in small chunks as data is +received and forwards this data through [ReactPHP's Stream API](https://github.com/reactphp/stream). +This works for (any number of) responses of arbitrary sizes. + +```php +$browser->requestStreaming('GET', $url)->then(function (Psr\Http\Message\ResponseInterface $response) { + $body = $response->getBody(); + assert($body instanceof Psr\Http\Message\StreamInterface); + assert($body instanceof React\Stream\ReadableStreamInterface); + + $body->on('data', function ($chunk) { + echo $chunk; + }); + + $body->on('error', function (Exception $e) { + echo 'Error: ' . $e->getMessage() . PHP_EOL; + }); + + $body->on('close', function () { + echo '[DONE]' . PHP_EOL; + }); +}, function (Exception $e) { + echo 'Error: ' . $e->getMessage() . PHP_EOL; +}); +``` + +See also [ReactPHP's `ReadableStreamInterface`](https://github.com/reactphp/stream#readablestreaminterface) +and the [streaming response](#streaming-response) for more details, +examples and possible use-cases. + +This method will automatically add a matching `Content-Length` request +header if the size of the outgoing request body is known and non-empty. +For an empty request body, if will only include a `Content-Length: 0` +request header if the request method usually expects a request body (only +applies to `POST`, `PUT` and `PATCH`). + +If you're using a streaming request body (`ReadableStreamInterface`), it +will default to using `Transfer-Encoding: chunked` or you have to +explicitly pass in a matching `Content-Length` request header like so: + +```php +$body = new React\Stream\ThroughStream(); +Loop::addTimer(1.0, function () use ($body) { + $body->end("hello world"); +}); + +$browser->requestStreaming('POST', $url, ['Content-Length' => '11'], $body); +``` + +#### withTimeout() + +The `withTimeout(bool|number $timeout): Browser` method can be used to +change the maximum timeout used for waiting for pending requests. + +You can pass in the number of seconds to use as a new timeout value: + +```php +$browser = $browser->withTimeout(10.0); +``` + +You can pass in a bool `false` to disable any timeouts. In this case, +requests can stay pending forever: + +```php +$browser = $browser->withTimeout(false); +``` + +You can pass in a bool `true` to re-enable default timeout handling. This +will respects PHP's `default_socket_timeout` setting (default 60s): + +```php +$browser = $browser->withTimeout(true); +``` + +See also [timeouts](#timeouts) for more details about timeout handling. + +Notice that the [`Browser`](#browser) is an immutable object, i.e. this +method actually returns a *new* [`Browser`](#browser) instance with the +given timeout value applied. + +#### withFollowRedirects() + +The `withFollowRedirects(bool|int $followRedirects): Browser` method can be used to +change how HTTP redirects will be followed. + +You can pass in the maximum number of redirects to follow: + +```php +$browser = $browser->withFollowRedirects(5); +``` + +The request will automatically be rejected when the number of redirects +is exceeded. You can pass in a `0` to reject the request for any +redirects encountered: + +```php +$browser = $browser->withFollowRedirects(0); + +$browser->get($url)->then(function (Psr\Http\Message\ResponseInterface $response) { + // only non-redirected responses will now end up here + var_dump($response->getHeaders()); +}, function (Exception $e) { + echo 'Error: ' . $e->getMessage() . PHP_EOL; +}); +``` + +You can pass in a bool `false` to disable following any redirects. In +this case, requests will resolve with the redirection response instead +of following the `Location` response header: + +```php +$browser = $browser->withFollowRedirects(false); + +$browser->get($url)->then(function (Psr\Http\Message\ResponseInterface $response) { + // any redirects will now end up here + var_dump($response->getHeaderLine('Location')); +}, function (Exception $e) { + echo 'Error: ' . $e->getMessage() . PHP_EOL; +}); +``` + +You can pass in a bool `true` to re-enable default redirect handling. +This defaults to following a maximum of 10 redirects: + +```php +$browser = $browser->withFollowRedirects(true); +``` + +See also [redirects](#redirects) for more details about redirect handling. + +Notice that the [`Browser`](#browser) is an immutable object, i.e. this +method actually returns a *new* [`Browser`](#browser) instance with the +given redirect setting applied. + +#### withRejectErrorResponse() + +The `withRejectErrorResponse(bool $obeySuccessCode): Browser` method can be used to +change whether non-successful HTTP response status codes (4xx and 5xx) will be rejected. + +You can pass in a bool `false` to disable rejecting incoming responses +that use a 4xx or 5xx response status code. In this case, requests will +resolve with the response message indicating an error condition: + +```php +$browser = $browser->withRejectErrorResponse(false); + +$browser->get($url)->then(function (Psr\Http\Message\ResponseInterface $response) { + // any HTTP response will now end up here + var_dump($response->getStatusCode(), $response->getReasonPhrase()); +}, function (Exception $e) { + echo 'Error: ' . $e->getMessage() . PHP_EOL; +}); +``` + +You can pass in a bool `true` to re-enable default status code handling. +This defaults to rejecting any response status codes in the 4xx or 5xx +range with a [`ResponseException`](#responseexception): + +```php +$browser = $browser->withRejectErrorResponse(true); + +$browser->get($url)->then(function (Psr\Http\Message\ResponseInterface $response) { + // any successful HTTP response will now end up here + var_dump($response->getStatusCode(), $response->getReasonPhrase()); +}, function (Exception $e) { + if ($e instanceof React\Http\Message\ResponseException) { + // any HTTP response error message will now end up here + $response = $e->getResponse(); + var_dump($response->getStatusCode(), $response->getReasonPhrase()); + } else { + echo 'Error: ' . $e->getMessage() . PHP_EOL; + } +}); +``` + +Notice that the [`Browser`](#browser) is an immutable object, i.e. this +method actually returns a *new* [`Browser`](#browser) instance with the +given setting applied. + +#### withBase() + +The `withBase(string|null $baseUrl): Browser` method can be used to +change the base URL used to resolve relative URLs to. + +If you configure a base URL, any requests to relative URLs will be +processed by first resolving this relative to the given absolute base +URL. This supports resolving relative path references (like `../` etc.). +This is particularly useful for (RESTful) API calls where all endpoints +(URLs) are located under a common base URL. + +```php +$browser = $browser->withBase('http://api.example.com/v3/'); + +// will request http://api.example.com/v3/users +$browser->get('users')->then(…); +``` + +You can pass in a `null` base URL to return a new instance that does not +use a base URL: + +```php +$browser = $browser->withBase(null); +``` + +Accordingly, any requests using relative URLs to a browser that does not +use a base URL can not be completed and will be rejected without sending +a request. + +This method will throw an `InvalidArgumentException` if the given +`$baseUrl` argument is not a valid URL. + +Notice that the [`Browser`](#browser) is an immutable object, i.e. the `withBase()` method +actually returns a *new* [`Browser`](#browser) instance with the given base URL applied. + +#### withProtocolVersion() + +The `withProtocolVersion(string $protocolVersion): Browser` method can be used to +change the HTTP protocol version that will be used for all subsequent requests. + +All the above [request methods](#request-methods) default to sending +requests as HTTP/1.1. This is the preferred HTTP protocol version which +also provides decent backwards-compatibility with legacy HTTP/1.0 +servers. As such, there should rarely be a need to explicitly change this +protocol version. + +If you want to explicitly use the legacy HTTP/1.0 protocol version, you +can use this method: + +```php +$browser = $browser->withProtocolVersion('1.0'); + +$browser->get($url)->then(…); +``` + +Notice that the [`Browser`](#browser) is an immutable object, i.e. this +method actually returns a *new* [`Browser`](#browser) instance with the +new protocol version applied. + +#### withResponseBuffer() + +The `withResponseBuffer(int $maximumSize): Browser` method can be used to +change the maximum size for buffering a response body. + +The preferred way to send an HTTP request is by using the above +[request methods](#request-methods), for example the [`get()`](#get) +method to send an HTTP `GET` request. Each of these methods will buffer +the whole response body in memory by default. This is easy to get started +and works reasonably well for smaller responses. + +By default, the response body buffer will be limited to 16 MiB. If the +response body exceeds this maximum size, the request will be rejected. + +You can pass in the maximum number of bytes to buffer: + +```php +$browser = $browser->withResponseBuffer(1024 * 1024); + +$browser->get($url)->then(function (Psr\Http\Message\ResponseInterface $response) { + // response body will not exceed 1 MiB + var_dump($response->getHeaders(), (string) $response->getBody()); +}, function (Exception $e) { + echo 'Error: ' . $e->getMessage() . PHP_EOL; +}); +``` + +Note that the response body buffer has to be kept in memory for each +pending request until its transfer is completed and it will only be freed +after a pending request is fulfilled. As such, increasing this maximum +buffer size to allow larger response bodies is usually not recommended. +Instead, you can use the [`requestStreaming()` method](#requeststreaming) +to receive responses with arbitrary sizes without buffering. Accordingly, +this maximum buffer size setting has no effect on streaming responses. + +Notice that the [`Browser`](#browser) is an immutable object, i.e. this +method actually returns a *new* [`Browser`](#browser) instance with the +given setting applied. + +#### withHeader() + +The `withHeader(string $header, string $value): Browser` method can be used to +add a request header for all following requests. + +```php +$browser = $browser->withHeader('User-Agent', 'ACME'); + +$browser->get($url)->then(…); +``` + +Note that the new header will overwrite any headers previously set with +the same name (case-insensitive). Following requests will use these headers +by default unless they are explicitly set for any requests. + +#### withoutHeader() + +The `withoutHeader(string $header): Browser` method can be used to +remove any default request headers previously set via +the [`withHeader()` method](#withheader). + +```php +$browser = $browser->withoutHeader('User-Agent'); + +$browser->get($url)->then(…); +``` + +Note that this method only affects the headers which were set with the +method `withHeader(string $header, string $value): Browser` + +### React\Http\Message + +#### Response + +The `React\Http\Message\Response` class can be used to +represent an outgoing server response message. + +```php +$response = new React\Http\Message\Response( + React\Http\Message\Response::STATUS_OK, + [ + 'Content-Type' => 'text/html' + ], + "Hello world!\n" +); +``` + +This class implements the +[PSR-7 `ResponseInterface`](https://www.php-fig.org/psr/psr-7/#33-psrhttpmessageresponseinterface) +which in turn extends the +[PSR-7 `MessageInterface`](https://www.php-fig.org/psr/psr-7/#31-psrhttpmessagemessageinterface). + +On top of this, this class implements the +[PSR-7 Message Util `StatusCodeInterface`](https://github.com/php-fig/http-message-util/blob/master/src/StatusCodeInterface.php) +which means that most common HTTP status codes are available as class +constants with the `STATUS_*` prefix. For instance, the `200 OK` and +`404 Not Found` status codes can used as `Response::STATUS_OK` and +`Response::STATUS_NOT_FOUND` respectively. + +> Internally, this implementation builds on top of a base class which is + considered an implementation detail that may change in the future. + +##### html() + +The static `html(string $html): Response` method can be used to +create an HTML response. + +```php +$html = << + +Hello wörld! + + +HTML; + +$response = React\Http\Message\Response::html($html); +``` + +This is a convenient shortcut method that returns the equivalent of this: + +``` +$response = new React\Http\Message\Response( + React\Http\Message\Response::STATUS_OK, + [ + 'Content-Type' => 'text/html; charset=utf-8' + ], + $html +); +``` + +This method always returns a response with a `200 OK` status code and +the appropriate `Content-Type` response header for the given HTTP source +string encoded in UTF-8 (Unicode). It's generally recommended to end the +given plaintext string with a trailing newline. + +If you want to use a different status code or custom HTTP response +headers, you can manipulate the returned response object using the +provided PSR-7 methods or directly instantiate a custom HTTP response +object using the `Response` constructor: + +```php +$response = React\Http\Message\Response::html( + "

Error

\n

Invalid user name given.

\n" +)->withStatus(React\Http\Message\Response::STATUS_BAD_REQUEST); +``` + +##### json() + +The static `json(mixed $data): Response` method can be used to +create a JSON response. + +```php +$response = React\Http\Message\Response::json(['name' => 'Alice']); +``` + +This is a convenient shortcut method that returns the equivalent of this: + +``` +$response = new React\Http\Message\Response( + React\Http\Message\Response::STATUS_OK, + [ + 'Content-Type' => 'application/json' + ], + json_encode( + ['name' => 'Alice'], + JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRESERVE_ZERO_FRACTION + ) . "\n" +); +``` + +This method always returns a response with a `200 OK` status code and +the appropriate `Content-Type` response header for the given structured +data encoded as a JSON text. + +The given structured data will be encoded as a JSON text. Any `string` +values in the data must be encoded in UTF-8 (Unicode). If the encoding +fails, this method will throw an `InvalidArgumentException`. + +By default, the given structured data will be encoded with the flags as +shown above. This includes pretty printing and preserving zero fractions +for `float` values to ease debugging. It is assumed any additional data +overhead is usually compensated by using HTTP response compression. + +If you want to use a different status code or custom HTTP response +headers, you can manipulate the returned response object using the +provided PSR-7 methods or directly instantiate a custom HTTP response +object using the `Response` constructor: + +```php +$response = React\Http\Message\Response::json( + ['error' => 'Invalid user name given'] +)->withStatus(React\Http\Message\Response::STATUS_BAD_REQUEST); +``` + +##### plaintext() + +The static `plaintext(string $text): Response` method can be used to +create a plaintext response. + +```php +$response = React\Http\Message\Response::plaintext("Hello wörld!\n"); +``` + +This is a convenient shortcut method that returns the equivalent of this: + +``` +$response = new React\Http\Message\Response( + React\Http\Message\Response::STATUS_OK, + [ + 'Content-Type' => 'text/plain; charset=utf-8' + ], + "Hello wörld!\n" +); +``` + +This method always returns a response with a `200 OK` status code and +the appropriate `Content-Type` response header for the given plaintext +string encoded in UTF-8 (Unicode). It's generally recommended to end the +given plaintext string with a trailing newline. + +If you want to use a different status code or custom HTTP response +headers, you can manipulate the returned response object using the +provided PSR-7 methods or directly instantiate a custom HTTP response +object using the `Response` constructor: + +```php +$response = React\Http\Message\Response::plaintext( + "Error: Invalid user name given.\n" +)->withStatus(React\Http\Message\Response::STATUS_BAD_REQUEST); +``` + +##### xml() + +The static `xml(string $xml): Response` method can be used to +create an XML response. + +```php +$xml = << + + Hello wörld! + + +XML; + +$response = React\Http\Message\Response::xml($xml); +``` + +This is a convenient shortcut method that returns the equivalent of this: + +``` +$response = new React\Http\Message\Response( + React\Http\Message\Response::STATUS_OK, + [ + 'Content-Type' => 'application/xml' + ], + $xml +); +``` + +This method always returns a response with a `200 OK` status code and +the appropriate `Content-Type` response header for the given XML source +string. It's generally recommended to use UTF-8 (Unicode) and specify +this as part of the leading XML declaration and to end the given XML +source string with a trailing newline. + +If you want to use a different status code or custom HTTP response +headers, you can manipulate the returned response object using the +provided PSR-7 methods or directly instantiate a custom HTTP response +object using the `Response` constructor: + +```php +$response = React\Http\Message\Response::xml( + "Invalid user name given.\n" +)->withStatus(React\Http\Message\Response::STATUS_BAD_REQUEST); +``` + +#### Request + +The `React\Http\Message\Request` class can be used to +respresent an outgoing HTTP request message. + +This class implements the +[PSR-7 `RequestInterface`](https://www.php-fig.org/psr/psr-7/#32-psrhttpmessagerequestinterface) +which extends the +[PSR-7 `MessageInterface`](https://www.php-fig.org/psr/psr-7/#31-psrhttpmessagemessageinterface). + +This is mostly used internally to represent each outgoing HTTP request +message for the HTTP client implementation. Likewise, you can also use this +class with other HTTP client implementations and for tests. + +> Internally, this implementation builds on top of a base class which is + considered an implementation detail that may change in the future. + +#### ServerRequest + +The `React\Http\Message\ServerRequest` class can be used to +respresent an incoming server request message. + +This class implements the +[PSR-7 `ServerRequestInterface`](https://www.php-fig.org/psr/psr-7/#321-psrhttpmessageserverrequestinterface) +which extends the +[PSR-7 `RequestInterface`](https://www.php-fig.org/psr/psr-7/#32-psrhttpmessagerequestinterface) +which in turn extends the +[PSR-7 `MessageInterface`](https://www.php-fig.org/psr/psr-7/#31-psrhttpmessagemessageinterface). + +This is mostly used internally to represent each incoming request message. +Likewise, you can also use this class in test cases to test how your web +application reacts to certain HTTP requests. + +> Internally, this implementation builds on top of a base class which is + considered an implementation detail that may change in the future. + +#### Uri + +The `React\Http\Message\Uri` class can be used to +respresent a URI (or URL). + +This class implements the +[PSR-7 `UriInterface`](https://www.php-fig.org/psr/psr-7/#35-psrhttpmessageuriinterface). + +This is mostly used internally to represent the URI of each HTTP request +message for our HTTP client and server implementations. Likewise, you may +also use this class with other HTTP implementations and for tests. + +#### ResponseException + +The `React\Http\Message\ResponseException` is an `Exception` sub-class that will be used to reject +a request promise if the remote server returns a non-success status code +(anything but 2xx or 3xx). +You can control this behavior via the [`withRejectErrorResponse()` method](#withrejecterrorresponse). + +The `getCode(): int` method can be used to +return the HTTP response status code. + +The `getResponse(): ResponseInterface` method can be used to +access its underlying response object. + +### React\Http\Middleware + +#### StreamingRequestMiddleware + +The `React\Http\Middleware\StreamingRequestMiddleware` can be used to +process incoming requests with a streaming request body (without buffering). + +This allows you to process requests of any size without buffering the request +body in memory. Instead, it will represent the request body as a +[`ReadableStreamInterface`](https://github.com/reactphp/stream#readablestreaminterface) +that emit chunks of incoming data as it is received: + +```php +$http = new React\Http\HttpServer( + new React\Http\Middleware\StreamingRequestMiddleware(), + function (Psr\Http\Message\ServerRequestInterface $request) { + $body = $request->getBody(); + assert($body instanceof Psr\Http\Message\StreamInterface); + assert($body instanceof React\Stream\ReadableStreamInterface); + + return new React\Promise\Promise(function ($resolve) use ($body) { + $bytes = 0; + $body->on('data', function ($chunk) use (&$bytes) { + $bytes += \count($chunk); + }); + $body->on('close', function () use (&$bytes, $resolve) { + $resolve(new React\Http\Message\Response( + React\Http\Message\Response::STATUS_OK, + [], + "Received $bytes bytes\n" + )); + }); + }); + } +); +``` + +See also [streaming incoming request](#streaming-incoming-request) +for more details. + +Additionally, this middleware can be used in combination with the +[`LimitConcurrentRequestsMiddleware`](#limitconcurrentrequestsmiddleware) and +[`RequestBodyBufferMiddleware`](#requestbodybuffermiddleware) (see below) +to explicitly configure the total number of requests that can be handled at +once: + +```php +$http = new React\Http\HttpServer( + new React\Http\Middleware\StreamingRequestMiddleware(), + new React\Http\Middleware\LimitConcurrentRequestsMiddleware(100), // 100 concurrent buffering handlers + new React\Http\Middleware\RequestBodyBufferMiddleware(2 * 1024 * 1024), // 2 MiB per request + new React\Http\Middleware\RequestBodyParserMiddleware(), + $handler +); +``` + +> Internally, this class is used as a "marker" to not trigger the default + request buffering behavior in the `HttpServer`. It does not implement any logic + on its own. + #### LimitConcurrentRequestsMiddleware -The `LimitConcurrentRequestsMiddleware` can be used to +The `React\Http\Middleware\LimitConcurrentRequestsMiddleware` can be used to limit how many next handlers can be executed concurrently. If this middleware is invoked, it will check if the number of pending @@ -1180,10 +2767,10 @@ The following example shows how this middleware can be used to ensure no more than 10 handlers will be invoked at once: ```php -$server = new Server(array( - new LimitConcurrentRequestsMiddleware(10), +$http = new React\Http\HttpServer( + new React\Http\Middleware\LimitConcurrentRequestsMiddleware(10), $handler -)); +); ``` Similarly, this middleware is often used in combination with the @@ -1191,12 +2778,13 @@ Similarly, this middleware is often used in combination with the to limit the total number of requests that can be buffered at once: ```php -$server = new StreamingServer(array( - new LimitConcurrentRequestsMiddleware(100), // 100 concurrent buffering handlers - new RequestBodyBufferMiddleware(2 * 1024 * 1024), // 2 MiB per request - new RequestBodyParserMiddleware(), +$http = new React\Http\HttpServer( + new React\Http\Middleware\StreamingRequestMiddleware(), + new React\Http\Middleware\LimitConcurrentRequestsMiddleware(100), // 100 concurrent buffering handlers + new React\Http\Middleware\RequestBodyBufferMiddleware(2 * 1024 * 1024), // 2 MiB per request + new React\Http\Middleware\RequestBodyParserMiddleware(), $handler -)); +); ``` More sophisticated examples include limiting the total number of requests @@ -1204,18 +2792,19 @@ that can be buffered at once and then ensure the actual request handler only processes one request after another without any concurrency: ```php -$server = new StreamingServer(array( - new LimitConcurrentRequestsMiddleware(100), // 100 concurrent buffering handlers - new RequestBodyBufferMiddleware(2 * 1024 * 1024), // 2 MiB per request - new RequestBodyParserMiddleware(), - new LimitConcurrentRequestsMiddleware(1), // only execute 1 handler (no concurrency) +$http = new React\Http\HttpServer( + new React\Http\Middleware\StreamingRequestMiddleware(), + new React\Http\Middleware\LimitConcurrentRequestsMiddleware(100), // 100 concurrent buffering handlers + new React\Http\Middleware\RequestBodyBufferMiddleware(2 * 1024 * 1024), // 2 MiB per request + new React\Http\Middleware\RequestBodyParserMiddleware(), + new React\Http\Middleware\LimitConcurrentRequestsMiddleware(1), // only execute 1 handler (no concurrency) $handler -)); +); ``` #### RequestBodyBufferMiddleware -One of the built-in middleware is the `RequestBodyBufferMiddleware` which +One of the built-in middleware is the `React\Http\Middleware\RequestBodyBufferMiddleware` which can be used to buffer the whole incoming request body in memory. This can be useful if full PSR-7 compatibility is needed for the request handler and the default streaming request body handling is not needed. @@ -1256,19 +2845,20 @@ the total number of concurrent requests. Usage: ```php -$server = new StreamingServer(array( - new LimitConcurrentRequestsMiddleware(100), // 100 concurrent buffering handlers - new RequestBodyBufferMiddleware(16 * 1024 * 1024), // 16 MiB - function (ServerRequestInterface $request) { +$http = new React\Http\HttpServer( + new React\Http\Middleware\StreamingRequestMiddleware(), + new React\Http\Middleware\LimitConcurrentRequestsMiddleware(100), // 100 concurrent buffering handlers + new React\Http\Middleware\RequestBodyBufferMiddleware(16 * 1024 * 1024), // 16 MiB + function (Psr\Http\Message\ServerRequestInterface $request) { // The body from $request->getBody() is now fully available without the need to stream it - return new Response(200); + return new React\Http\Message\Response(React\Http\Message\Response::STATUS_OK); }, -)); +); ``` #### RequestBodyParserMiddleware -The `RequestBodyParserMiddleware` takes a fully buffered request body +The `React\Http\Middleware\RequestBodyParserMiddleware` takes a fully buffered request body (generally from [`RequestBodyBufferMiddleware`](#requestbodybuffermiddleware)), and parses the form values and file uploads from the incoming HTTP request body. @@ -1280,21 +2870,22 @@ Instead of relying on these superglobals, you can use the `$request->getParsedBody()` and `$request->getUploadedFiles()` methods as defined by PSR-7. -Accordingly, each file upload will be represented as instance implementing [`UploadedFileInterface`](https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-7-http-message.md#36-psrhttpmessageuploadedfileinterface). +Accordingly, each file upload will be represented as instance implementing the +[PSR-7 `UploadedFileInterface`](https://www.php-fig.org/psr/psr-7/#36-psrhttpmessageuploadedfileinterface). Due to its blocking nature, the `moveTo()` method is not available and throws a `RuntimeException` instead. You can use `$contents = (string)$file->getStream();` to access the file contents and persist this to your favorite data store. ```php -$handler = function (ServerRequestInterface $request) { +$handler = function (Psr\Http\Message\ServerRequestInterface $request) { // If any, parsed form fields are now available from $request->getParsedBody() $body = $request->getParsedBody(); $name = isset($body['name']) ? $body['name'] : 'unnamed'; $files = $request->getUploadedFiles(); $avatar = isset($files['avatar']) ? $files['avatar'] : null; - if ($avatar instanceof UploadedFileInterface) { + if ($avatar instanceof Psr\Http\Message\UploadedFileInterface) { if ($avatar->getError() === UPLOAD_ERR_OK) { $uploaded = $avatar->getSize() . ' bytes'; } elseif ($avatar->getError() === UPLOAD_ERR_INI_SIZE) { @@ -1306,24 +2897,25 @@ $handler = function (ServerRequestInterface $request) { $uploaded = 'nothing'; } - return new Response( - 200, - array( + return new React\Http\Message\Response( + React\Http\Message\Response::STATUS_OK, + [ 'Content-Type' => 'text/plain' - ), + ], $name . ' uploaded ' . $uploaded ); }; -$server = new StreamingServer(array(( - new LimitConcurrentRequestsMiddleware(100), // 100 concurrent buffering handlers - new RequestBodyBufferMiddleware(16 * 1024 * 1024), // 16 MiB - new RequestBodyParserMiddleware(), +$http = new React\Http\HttpServer( + new React\Http\Middleware\StreamingRequestMiddleware(), + new React\Http\Middleware\LimitConcurrentRequestsMiddleware(100), // 100 concurrent buffering handlers + new React\Http\Middleware\RequestBodyBufferMiddleware(16 * 1024 * 1024), // 16 MiB + new React\Http\Middleware\RequestBodyParserMiddleware(), $handler -)); +); ``` -See also [example #12](examples) for more details. +See also [form upload server example](examples/62-server-form-upload.php) for more details. By default, this middleware respects the [`upload_max_filesize`](https://www.php.net/manual/en/ini.core.php#ini.upload-max-filesize) @@ -1334,7 +2926,7 @@ explicitly passing the maximum filesize in bytes as the first parameter to the constructor like this: ```php -new RequestBodyParserMiddleware(8 * 1024 * 1024); // 8 MiB limit per file +new React\Http\Middleware\RequestBodyParserMiddleware(8 * 1024 * 1024); // 8 MiB limit per file ``` By default, this middleware respects the @@ -1350,7 +2942,7 @@ You can control the maximum number of file uploads per request by explicitly passing the second parameter to the constructor like this: ```php -new RequestBodyParserMiddleware(10 * 1024, 100); // 100 files with 10 KiB each +new React\Http\Middleware\RequestBodyParserMiddleware(10 * 1024, 100); // 100 files with 10 KiB each ``` > Note that this middleware handler simply parses everything that is already @@ -1382,65 +2974,45 @@ new RequestBodyParserMiddleware(10 * 1024, 100); // 100 files with 10 KiB each If you want to respect this setting, you have to check its value and effectively avoid using this middleware entirely. -#### Third-Party Middleware - -While this project does provide the means to *use* middleware implementations -(see above), it does not aim to *define* how middleware implementations should -look like. We realize that there's a vivid ecosystem of middleware -implementations and ongoing effort to standardize interfaces between these with -[PSR-15](https://www.php-fig.org/psr/psr-15/) (HTTP Server Request Handlers) -and support this goal. -As such, this project only bundles a few middleware implementations that are -required to match PHP's request behavior (see above) and otherwise actively -encourages third-party middleware implementations. - -While we would love to support PSR-15 directy in `react/http`, we understand -that this interface does not specifically target async APIs and as such does -not take advantage of promises for [deferred responses](#deferred-response). -The gist of this is that where PSR-15 enforces a `ResponseInterface` return -value, we also accept a `PromiseInterface`. -As such, we suggest using the external -[PSR-15 middleware adapter](https://github.com/friends-of-reactphp/http-middleware-psr15-adapter) -that uses on the fly monkey patching of these return values which makes using -most PSR-15 middleware possible with this package without any changes required. - -Other than that, you can also use the above [middleware definition](#middleware) -to create custom middleware. A non-exhaustive list of third-party middleware can -be found at the [middleware wiki](https://github.com/reactphp/http/wiki/Middleware). -If you build or know a custom middleware, make sure to let the world know and -feel free to add it to this list. - ## Install -The recommended way to install this library is [through Composer](https://getcomposer.org). +The recommended way to install this library is [through Composer](https://getcomposer.org/). [New to Composer?](https://getcomposer.org/doc/00-intro.md) -This will install the latest supported version: +Once released, this project will follow [SemVer](https://semver.org/). +At the moment, this will install the latest development version: ```bash -$ composer require react/http:^0.8.5 +composer require react/http:^3@dev ``` See also the [CHANGELOG](CHANGELOG.md) for details about version upgrades. This project aims to run on any platform and thus does not require any PHP -extensions and supports running on legacy PHP 5.3 through current PHP 7+ and -HHVM. -It's *highly recommended to use PHP 7+* for this project. +extensions and supports running on PHP 7.1 through current PHP 8+. +It's *highly recommended to use the latest supported PHP version* for this project. ## Tests To run the test suite, you first need to clone this repo and then install all -dependencies [through Composer](https://getcomposer.org): +dependencies [through Composer](https://getcomposer.org/): ```bash -$ composer install +composer install ``` To run the test suite, go to the project root and run: ```bash -$ php vendor/bin/phpunit +vendor/bin/phpunit +``` + +The test suite also contains a number of functional integration tests that rely +on a stable internet connection. +If you do not want to run these, they can simply be skipped like this: + +```bash +vendor/bin/phpunit --exclude-group internet ``` ## License diff --git a/composer.json b/composer.json index ab26ee88..809e81c9 100644 --- a/composer.json +++ b/composer.json @@ -1,24 +1,57 @@ { "name": "react/http", - "description": "Event-driven, streaming plaintext HTTP and secure HTTPS server for ReactPHP", - "keywords": ["event-driven", "streaming", "HTTP", "HTTPS", "server", "ReactPHP"], + "description": "Event-driven, streaming HTTP client and server implementation for ReactPHP", + "keywords": ["HTTP client", "HTTP server", "HTTP", "HTTPS", "event-driven", "streaming", "client", "server", "PSR-7", "async", "ReactPHP"], "license": "MIT", + "authors": [ + { + "name": "Christian Lück", + "homepage": "https://clue.engineering/", + "email": "christian@clue.engineering" + }, + { + "name": "Cees-Jan Kiewiet", + "homepage": "https://wyrihaximus.net/", + "email": "reactphp@ceesjankiewiet.nl" + }, + { + "name": "Jan Sorgalla", + "homepage": "https://sorgalla.com/", + "email": "jsorgalla@gmail.com" + }, + { + "name": "Chris Boden", + "homepage": "https://cboden.dev/", + "email": "cboden@gmail.com" + } + ], "require": { - "php": ">=5.3.0", - "ringcentral/psr7": "^1.2", - "react/socket": "^1.0 || ^0.8.3", - "react/stream": "^1.0 || ^0.7.1", - "react/promise": "^2.3 || ^1.2.1", + "php": ">=7.1", "evenement/evenement": "^3.0 || ^2.0 || ^1.0", - "react/promise-stream": "^1.1" + "fig/http-message-util": "^1.1", + "psr/http-message": "^1.0", + "react/event-loop": "^1.2", + "react/promise": "^3.2 || ^2.3 || ^1.2.1", + "react/socket": "^1.16", + "react/stream": "^1.4" + }, + "require-dev": { + "clue/http-proxy-react": "^1.8", + "clue/reactphp-ssh-proxy": "^1.4", + "clue/socks-react": "^1.4", + "phpunit/phpunit": "^9.6 || ^7.5", + "react/async": "^4.2 || ^3", + "react/promise-stream": "^1.4", + "react/promise-timer": "^1.11" }, "autoload": { "psr-4": { - "React\\Http\\": "src" + "React\\Http\\": "src/" } }, - "require-dev": { - "clue/block-react": "^1.1", - "phpunit/phpunit": "^7.0 || ^6.4 || ^5.7 || ^4.8.35" + "autoload-dev": { + "psr-4": { + "React\\Tests\\Http\\": "tests/" + } } } diff --git a/examples/01-client-get-request.php b/examples/01-client-get-request.php new file mode 100644 index 00000000..278f6597 --- /dev/null +++ b/examples/01-client-get-request.php @@ -0,0 +1,14 @@ +get('http://google.com/')->then(function (ResponseInterface $response) { + echo (string) $response->getBody(); +}, function (Exception $e) { + echo 'Error: ' . $e->getMessage() . PHP_EOL; +}); diff --git a/examples/01-hello-world.php b/examples/01-hello-world.php deleted file mode 100644 index f703a5d7..00000000 --- a/examples/01-hello-world.php +++ /dev/null @@ -1,27 +0,0 @@ - 'text/plain' - ), - "Hello world\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/02-client-concurrent-requests.php b/examples/02-client-concurrent-requests.php new file mode 100644 index 00000000..e372515c --- /dev/null +++ b/examples/02-client-concurrent-requests.php @@ -0,0 +1,26 @@ +head('http://www.github.com/clue/http-react')->then(function (ResponseInterface $response) { + echo (string) $response->getBody(); +}, function (Exception $e) { + echo 'Error: ' . $e->getMessage() . PHP_EOL; +}); + +$client->get('http://google.com/')->then(function (ResponseInterface $response) { + echo (string) $response->getBody(); +}, function (Exception $e) { + echo 'Error: ' . $e->getMessage() . PHP_EOL; +}); + +$client->get('http://www.lueck.tv/psocksd')->then(function (ResponseInterface $response) { + echo (string) $response->getBody(); +}, function (Exception $e) { + echo 'Error: ' . $e->getMessage() . PHP_EOL; +}); diff --git a/examples/02-count-visitors.php b/examples/02-count-visitors.php deleted file mode 100644 index 5dec93f9..00000000 --- a/examples/02-count-visitors.php +++ /dev/null @@ -1,28 +0,0 @@ - '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 deleted file mode 100644 index 25e3d408..00000000 --- a/examples/03-client-ip.php +++ /dev/null @@ -1,29 +0,0 @@ -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/03-client-request-any.php b/examples/03-client-request-any.php new file mode 100644 index 00000000..9ee80131 --- /dev/null +++ b/examples/03-client-request-any.php @@ -0,0 +1,37 @@ +head('http://www.github.com/clue/http-react'), + $client->get('https://httpbingo.org/'), + $client->get('https://google.com/'), + $client->get('http://www.lueck.tv/psocksd'), + $client->get('http://httpbingo.org/absolute-redirect/5') +]; + +React\Promise\any($promises)->then(function (ResponseInterface $response) use ($promises) { + // first response arrived => cancel all other pending requests + foreach ($promises as $promise) { + $promise->cancel(); + } + + var_dump($response->getHeaders()); + echo PHP_EOL . $response->getBody(); +}, function ($e) { + // Promise v1 and v2 reject with an array of Exceptions here, Promise v3 will use an Exception object instead + if (is_array($e)) { + $e = end($e); + } + assert($e instanceof Exception); + + echo 'Error: ' . $e->getMessage() . PHP_EOL; +}); diff --git a/examples/04-client-post-json.php b/examples/04-client-post-json.php new file mode 100644 index 00000000..2ecc0636 --- /dev/null +++ b/examples/04-client-post-json.php @@ -0,0 +1,28 @@ + [ + 'first' => 'Alice', + 'name' => 'Smith' + ], + 'email' => 'alice@example.com' +]; + +$client->post( + 'https://httpbingo.org/post', + [ + 'Content-Type' => 'application/json' + ], + json_encode($data) +)->then(function (ResponseInterface $response) { + echo (string) $response->getBody(); +}, function (Exception $e) { + echo 'Error: ' . $e->getMessage() . PHP_EOL; +}); diff --git a/examples/05-client-put-xml.php b/examples/05-client-put-xml.php new file mode 100644 index 00000000..af01c47a --- /dev/null +++ b/examples/05-client-put-xml.php @@ -0,0 +1,25 @@ +'); +$child = $xml->addChild('user'); +$child->alias = 'clue'; +$child->name = 'Christian Lück'; + +$client->put( + 'https://httpbingo.org/put', + [ + 'Content-Type' => 'text/xml' + ], + $xml->asXML() +)->then(function (ResponseInterface $response) { + echo (string) $response->getBody(); +}, function (Exception $e) { + echo 'Error: ' . $e->getMessage() . PHP_EOL; +}); diff --git a/examples/05-cookie-handling.php b/examples/05-cookie-handling.php deleted file mode 100644 index e09d9277..00000000 --- a/examples/05-cookie-handling.php +++ /dev/null @@ -1,42 +0,0 @@ -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 deleted file mode 100644 index ae465fb5..00000000 --- a/examples/06-sleep.php +++ /dev/null @@ -1,33 +0,0 @@ -addTimer(1.5, function() use ($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 deleted file mode 100644 index 76544a6b..00000000 --- a/examples/07-error-handling.php +++ /dev/null @@ -1,39 +0,0 @@ - '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 deleted file mode 100644 index ab0ea0ec..00000000 --- a/examples/08-stream-response.php +++ /dev/null @@ -1,51 +0,0 @@ -getMethod() !== 'GET' || $request->getUri()->getPath() !== '/') { - return new Response(404); - } - - $stream = new ThroughStream(); - - // send some data every once in a while with periodic timer - $timer = $loop->addPeriodicTimer(0.5, function () use ($stream) { - $stream->write(microtime(true) . PHP_EOL); - }); - - // demo for ending stream after a few seconds - $loop->addTimer(5.0, function() use ($stream) { - $stream->end(); - }); - - // stop timer if stream is closed (such as when connection is closed) - $stream->on('close', function () use ($loop, $timer) { - $loop->cancelTimer($timer); - }); - - 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-json-api.php b/examples/09-json-api.php deleted file mode 100644 index a3b50d2b..00000000 --- a/examples/09-json-api.php +++ /dev/null @@ -1,64 +0,0 @@ -getHeaderLine('Content-Type') !== 'application/json') { - return new Response( - 415, // Unsupported Media Type - array( - 'Content-Type' => 'application/json' - ), - json_encode(array('error' => 'Only supports application/json')) . "\n" - ); - } - - $input = json_decode($request->getBody()->getContents()); - if (json_last_error() !== JSON_ERROR_NONE) { - return new Response( - 400, // Bad Request - array( - 'Content-Type' => 'application/json' - ), - json_encode(array('error' => 'Invalid JSON data given')) . "\n" - ); - } - - if (!isset($input->name) || !is_string($input->name)) { - return new Response( - 422, // Unprocessable Entity - array( - 'Content-Type' => 'application/json' - ), - json_encode(array('error' => 'JSON data does not contain a string "name" property')) . "\n" - ); - } - - return new Response( - 200, - array( - 'Content-Type' => 'application/json' - ), - json_encode(array('message' => 'Hello ' . $input->name)) . "\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/11-client-http-proxy.php b/examples/11-client-http-proxy.php new file mode 100644 index 00000000..f15cf2a0 --- /dev/null +++ b/examples/11-client-http-proxy.php @@ -0,0 +1,31 @@ + $proxy, + 'dns' => false +]); + +$browser = new Browser($connector); + +// demo fetching HTTP headers (or bail out otherwise) +$browser->get('https://www.google.com/')->then(function (ResponseInterface $response) { + echo (string) $response->getBody(); +}, function (Exception $e) { + echo 'Error: ' . $e->getMessage() . PHP_EOL; +}); diff --git a/examples/11-hello-world-https.php b/examples/11-hello-world-https.php deleted file mode 100644 index c8bc52e8..00000000 --- a/examples/11-hello-world-https.php +++ /dev/null @@ -1,32 +0,0 @@ - '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/12-client-socks-proxy.php b/examples/12-client-socks-proxy.php new file mode 100644 index 00000000..0e0039ca --- /dev/null +++ b/examples/12-client-socks-proxy.php @@ -0,0 +1,31 @@ + $proxy, + 'dns' => false +]); + +$browser = new Browser($connector); + +// demo fetching HTTP headers (or bail out otherwise) +$browser->get('https://www.google.com/')->then(function (ResponseInterface $response) { + echo (string) $response->getBody(); +}, function (Exception $e) { + echo 'Error: ' . $e->getMessage() . PHP_EOL; +}); diff --git a/examples/13-client-ssh-proxy.php b/examples/13-client-ssh-proxy.php new file mode 100644 index 00000000..e387d4fc --- /dev/null +++ b/examples/13-client-ssh-proxy.php @@ -0,0 +1,27 @@ + $proxy, + 'dns' => false +]); + +$browser = new Browser($connector); + +// demo fetching HTTP headers (or bail out otherwise) +$browser->get('https://www.google.com/')->then(function (ResponseInterface $response) { + echo (string) $response->getBody(); +}, function (Exception $e) { + echo 'Error: ' . $e->getMessage() . PHP_EOL; +}); diff --git a/examples/13-stream-request.php b/examples/13-stream-request.php deleted file mode 100644 index 07cd21ed..00000000 --- a/examples/13-stream-request.php +++ /dev/null @@ -1,54 +0,0 @@ -getBody(); - $requestBody->on('data', function ($data) use (&$contentLength) { - $contentLength += strlen($data); - }); - - $requestBody->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 - $requestBody->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/14-client-unix-domain-sockets.php b/examples/14-client-unix-domain-sockets.php new file mode 100644 index 00000000..5af0394d --- /dev/null +++ b/examples/14-client-unix-domain-sockets.php @@ -0,0 +1,23 @@ +get('http://localhost/info')->then(function (ResponseInterface $response) { + echo (string) $response->getBody(); +}, function (Exception $e) { + echo 'Error: ' . $e->getMessage() . PHP_EOL; +}); diff --git a/examples/21-client-request-streaming-to-stdout.php b/examples/21-client-request-streaming-to-stdout.php new file mode 100644 index 00000000..47c2371f --- /dev/null +++ b/examples/21-client-request-streaming-to-stdout.php @@ -0,0 +1,31 @@ +write('Requesting ' . $url . '…' . PHP_EOL); + +$client->requestStreaming('GET', $url)->then(function (ResponseInterface $response) use ($info, $out) { + $info->write('Received ' . $response->getStatusCode() . ' ' . $response->getReasonPhrase() . PHP_EOL); + + $body = $response->getBody(); + assert($body instanceof ReadableStreamInterface); + $body->pipe($out); +}, function (Exception $e) { + echo 'Error: ' . $e->getMessage() . PHP_EOL; +}); diff --git a/examples/22-client-stream-upload-from-stdin.php b/examples/22-client-stream-upload-from-stdin.php new file mode 100644 index 00000000..438b6280 --- /dev/null +++ b/examples/22-client-stream-upload-from-stdin.php @@ -0,0 +1,25 @@ +post($url, ['Content-Type' => 'text/plain'], $in)->then(function (ResponseInterface $response) { + echo (string) $response->getBody(); +}, function (Exception $e) { + echo 'Error: ' . $e->getMessage() . PHP_EOL; +}); diff --git a/examples/51-server-hello-world.php b/examples/51-server-hello-world.php new file mode 100644 index 00000000..e25efc65 --- /dev/null +++ b/examples/51-server-hello-world.php @@ -0,0 +1,14 @@ +listen($socket); + +echo 'Listening on ' . str_replace('tcp:', 'http:', $socket->getAddress()) . PHP_EOL; diff --git a/examples/52-server-count-visitors.php b/examples/52-server-count-visitors.php new file mode 100644 index 00000000..333a8011 --- /dev/null +++ b/examples/52-server-count-visitors.php @@ -0,0 +1,15 @@ +listen($socket); + +echo 'Listening on ' . str_replace('tcp:', 'http:', $socket->getAddress()) . PHP_EOL; diff --git a/examples/53-server-whatsmyip.php b/examples/53-server-whatsmyip.php new file mode 100644 index 00000000..d9018a64 --- /dev/null +++ b/examples/53-server-whatsmyip.php @@ -0,0 +1,16 @@ +getServerParams()['REMOTE_ADDR'] . "\n"; + + return React\Http\Message\Response::plaintext( + $body + ); +}); + +$socket = new React\Socket\SocketServer($argv[1] ?? '0.0.0.0:0'); +$http->listen($socket); + +echo 'Listening on ' . str_replace('tcp:', 'http:', $socket->getAddress()) . PHP_EOL; diff --git a/examples/04-query-parameter.php b/examples/54-server-query-parameter.php similarity index 52% rename from examples/04-query-parameter.php rename to examples/54-server-query-parameter.php index 13015430..507e8862 100644 --- a/examples/04-query-parameter.php +++ b/examples/54-server-query-parameter.php @@ -1,15 +1,8 @@ getQueryParams(); $body = 'The query parameter "foo" is not set. Click the following link '; @@ -19,18 +12,12 @@ $body = 'The value of "foo" is: ' . htmlspecialchars($queryParams['foo']); } - return new Response( - 200, - array( - 'Content-Type' => 'text/html' - ), + return React\Http\Message\Response::html( $body ); }); -$socket = new \React\Socket\Server(isset($argv[1]) ? $argv[1] : '0.0.0.0:0', $loop); -$server->listen($socket); +$socket = new React\Socket\SocketServer($argv[1] ?? '0.0.0.0:0'); +$http->listen($socket); echo 'Listening on ' . str_replace('tcp:', 'http:', $socket->getAddress()) . PHP_EOL; - -$loop->run(); diff --git a/examples/55-server-cookie-handling.php b/examples/55-server-cookie-handling.php new file mode 100644 index 00000000..b2b62100 --- /dev/null +++ b/examples/55-server-cookie-handling.php @@ -0,0 +1,24 @@ +getCookieParams()[$key])) { + $body = "Your cookie value is: " . $request->getCookieParams()[$key] . "\n"; + + return React\Http\Message\Response::plaintext( + $body + ); + } + + return React\Http\Message\Response::plaintext( + "Your cookie has been set.\n" + )->withHeader('Set-Cookie', $key . '=' . urlencode('Hello world!')); +}); + +$socket = new React\Socket\SocketServer($argv[1] ?? '0.0.0.0:0'); +$http->listen($socket); + +echo 'Listening on ' . str_replace('tcp:', 'http:', $socket->getAddress()) . PHP_EOL; diff --git a/examples/56-server-sleep.php b/examples/56-server-sleep.php new file mode 100644 index 00000000..45f64149 --- /dev/null +++ b/examples/56-server-sleep.php @@ -0,0 +1,24 @@ +then(function () { + return React\Http\Message\Response::plaintext( + "Hello world!\n" + ); + }); +}); + +$socket = new React\Socket\SocketServer($argv[1] ?? '0.0.0.0:0'); +$http->listen($socket); + +echo 'Listening on ' . str_replace('tcp:', 'http:', $socket->getAddress()) . PHP_EOL; diff --git a/examples/57-server-error-handling.php b/examples/57-server-error-handling.php new file mode 100644 index 00000000..c5141161 --- /dev/null +++ b/examples/57-server-error-handling.php @@ -0,0 +1,21 @@ +listen($socket); + +echo 'Listening on ' . str_replace('tcp:', 'http:', $socket->getAddress()) . PHP_EOL; diff --git a/examples/58-server-stream-response.php b/examples/58-server-stream-response.php new file mode 100644 index 00000000..d99b1548 --- /dev/null +++ b/examples/58-server-stream-response.php @@ -0,0 +1,45 @@ +getMethod() !== 'GET' || $request->getUri()->getPath() !== '/') { + return new Response(Response::STATUS_NOT_FOUND); + } + + $stream = new ThroughStream(); + + // send some data every once in a while with periodic timer + $timer = Loop::addPeriodicTimer(0.5, function () use ($stream) { + $stream->write(microtime(true) . PHP_EOL); + }); + + // end stream after a few seconds + $timeout = Loop::addTimer(5.0, function() use ($stream, $timer) { + Loop::cancelTimer($timer); + $stream->end(); + }); + + // stop timer if stream is closed (such as when connection is closed) + $stream->on('close', function () use ($timer, $timeout) { + Loop::cancelTimer($timer); + Loop::cancelTimer($timeout); + }); + + return new Response( + Response::STATUS_OK, + [ + 'Content-Type' => 'text/plain' + ], + $stream + ); +}); + +$socket = new React\Socket\SocketServer($argv[1] ?? '0.0.0.0:0'); +$http->listen($socket); + +echo 'Listening on ' . str_replace('tcp:', 'http:', $socket->getAddress()) . PHP_EOL; diff --git a/examples/59-server-json-api.php b/examples/59-server-json-api.php new file mode 100644 index 00000000..7e7477c0 --- /dev/null +++ b/examples/59-server-json-api.php @@ -0,0 +1,41 @@ +getHeaderLine('Content-Type') !== 'application/json') { + return Response::json([ + 'error' => 'Only supports application/json' + ])->withStatus(Response::STATUS_UNSUPPORTED_MEDIA_TYPE); + } + + $input = json_decode($request->getBody()->getContents()); + if (json_last_error() !== JSON_ERROR_NONE) { + return Response::json([ + 'error' => 'Invalid JSON data given' + ])->withStatus(Response::STATUS_BAD_REQUEST); + } + + if (!isset($input->name) || !is_string($input->name)) { + return Response::json([ + 'error' => 'JSON data does not contain a string "name" property' + ])->withStatus(Response::STATUS_UNPROCESSABLE_ENTITY); + } + + return Response::json([ + 'message' => 'Hello ' . $input->name + ]); +}); + +$socket = new React\Socket\SocketServer($argv[1] ?? '0.0.0.0:0'); +$http->listen($socket); + +echo 'Listening on ' . str_replace('tcp:', 'http:', $socket->getAddress()) . PHP_EOL; diff --git a/examples/61-server-hello-world-https.php b/examples/61-server-hello-world-https.php new file mode 100644 index 00000000..8ce487c7 --- /dev/null +++ b/examples/61-server-hello-world-https.php @@ -0,0 +1,23 @@ + [ + 'local_cert' => $argv[2] ?? __DIR__ . '/localhost.pem' + ] +]); +$http->listen($socket); + +$socket->on('error', function (Exception $e) { + echo 'Error: ' . $e->getMessage() . PHP_EOL; +}); + +echo 'Listening on ' . str_replace('tls:', 'https:', $socket->getAddress()) . PHP_EOL; diff --git a/examples/12-upload.php b/examples/62-server-form-upload.php similarity index 89% rename from examples/12-upload.php rename to examples/62-server-form-upload.php index 4e21bdb0..9c5c8aa3 100644 --- a/examples/12-upload.php +++ b/examples/62-server-form-upload.php @@ -3,23 +3,20 @@ // Simple HTML form with file upload // Launch demo and use your favorite browser or CLI tool to test form submissions // -// $ php examples/12-upload.php 8080 +// $ php examples/62-server-form-upload.php 8080 // $ curl --form name=test --form age=30 http://localhost:8080/ // $ curl --form name=hi --form avatar=@avatar.png http://localhost:8080/ use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\UploadedFileInterface; -use React\EventLoop\Factory; +use React\Http\Message\Response; use React\Http\Middleware\LimitConcurrentRequestsMiddleware; use React\Http\Middleware\RequestBodyBufferMiddleware; use React\Http\Middleware\RequestBodyParserMiddleware; -use React\Http\Response; -use React\Http\StreamingServer; +use React\Http\Middleware\StreamingRequestMiddleware; require __DIR__ . '/../vendor/autoload.php'; -$loop = Factory::create(); - $handler = function (ServerRequestInterface $request) { if ($request->getMethod() === 'POST') { // Take form input values from POST values (for illustration purposes only!) @@ -112,27 +109,22 @@ HTML; - return new Response( - 200, - array( - 'Content-Type' => 'text/html; charset=UTF-8' - ), + return Response::html( $html ); }; -// Note how this example explicitly uses the advanced `StreamingServer` to apply +// Note how this example explicitly uses the advanced `StreamingRequestMiddleware` to apply // custom request buffering limits below before running our request handler. -$server = new StreamingServer(array( +$http = new React\Http\HttpServer( + new StreamingRequestMiddleware(), new LimitConcurrentRequestsMiddleware(100), // 100 concurrent buffering handlers, queue otherwise new RequestBodyBufferMiddleware(8 * 1024 * 1024), // 8 MiB max, ignore body otherwise new RequestBodyParserMiddleware(100 * 1024, 1), // 1 file with 100 KiB max, reject upload otherwise $handler -)); +); -$socket = new \React\Socket\Server(isset($argv[1]) ? $argv[1] : '0.0.0.0:0', $loop); -$server->listen($socket); +$socket = new React\Socket\SocketServer($argv[1] ?? '0.0.0.0:0'); +$http->listen($socket); echo 'Listening on ' . str_replace('tcp:', 'http:', $socket->getAddress()) . PHP_EOL; - -$loop->run(); diff --git a/examples/63-server-streaming-request.php b/examples/63-server-streaming-request.php new file mode 100644 index 00000000..8ed3a4e1 --- /dev/null +++ b/examples/63-server-streaming-request.php @@ -0,0 +1,44 @@ +getBody(); + assert($body instanceof Psr\Http\Message\StreamInterface); + assert($body instanceof React\Stream\ReadableStreamInterface); + + return new React\Promise\Promise(function ($resolve, $reject) use ($body) { + $bytes = 0; + $body->on('data', function ($data) use (&$bytes) { + $bytes += strlen($data); + }); + + $body->on('end', function () use ($resolve, &$bytes){ + $resolve(React\Http\Message\Response::plaintext( + "Received $bytes bytes\n" + )); + }); + + // an error occures e.g. on invalid chunked encoded data or an unexpected 'end' event + $body->on('error', function (Exception $e) use ($resolve, &$bytes) { + $resolve(React\Http\Message\Response::plaintext( + "Encountered error after $bytes bytes: {$e->getMessage()}\n" + )->withStatus(React\Http\Message\Response::STATUS_BAD_REQUEST)); + }); + }); + } +); + +$http->on('error', function (Exception $e) { + echo 'Error: ' . $e->getMessage() . PHP_EOL; +}); + +$socket = new React\Socket\SocketServer($argv[1] ?? '0.0.0.0:0'); +$http->listen($socket); + +echo 'Listening on ' . str_replace('tcp:', 'http:', $socket->getAddress()) . PHP_EOL; diff --git a/examples/21-http-proxy.php b/examples/71-server-http-proxy.php similarity index 55% rename from examples/21-http-proxy.php rename to examples/71-server-http-proxy.php index a881c050..d513ede2 100644 --- a/examples/21-http-proxy.php +++ b/examples/71-server-http-proxy.php @@ -1,28 +1,19 @@ getRequestTarget(), '://') === false) { - return new Response( - 400, - array( - 'Content-Type' => 'text/plain' - ), - 'This is a plain HTTP proxy' - ); + return React\Http\Message\Response::plaintext( + "This is a plain HTTP proxy\n" + )->withStatus(React\Http\Message\Response::STATUS_BAD_REQUEST); } // prepare outgoing client request by updating request-target and Host header @@ -36,18 +27,12 @@ // 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) + return React\Http\Message\Response::plaintext( + $outgoing->getMethod() . ' ' . $outgoing->getRequestTarget() . ' HTTP/' . $outgoing->getProtocolVersion() . "\r\n\r\n" . (string) $outgoing->getBody() ); }); -$socket = new \React\Socket\Server(isset($argv[1]) ? $argv[1] : '0.0.0.0:0', $loop); -$server->listen($socket); +$socket = new React\Socket\SocketServer($argv[1] ?? '0.0.0.0:0'); +$http->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/72-server-http-connect-proxy.php similarity index 59% rename from examples/22-connect-proxy.php rename to examples/72-server-http-connect-proxy.php index 63c20833..98a21f34 100644 --- a/examples/22-connect-proxy.php +++ b/examples/72-server-http-connect-proxy.php @@ -1,30 +1,30 @@ getMethod() !== 'CONNECT') { return new Response( - 405, - array( + Response::STATUS_METHOD_NOT_ALLOWED, + [ 'Content-Type' => 'text/plain', 'Allow' => 'CONNECT' - ), - 'This is a HTTP CONNECT (secure HTTPS) proxy' + ], + 'This is an HTTP CONNECT (secure HTTPS) proxy' ); } @@ -33,26 +33,24 @@ function (ConnectionInterface $remote) { // connection established => forward data return new Response( - 200, - array(), + Response::STATUS_OK, + [], $remote ); }, - function ($e) { + function (Exception $e) { return new Response( - 502, - array( + Response::STATUS_BAD_GATEWAY, + [ '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); +$socket = new React\Socket\SocketServer($argv[1] ?? '0.0.0.0:0'); +$http->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/81-server-upgrade-echo.php similarity index 68% rename from examples/31-upgrade-echo.php rename to examples/81-server-upgrade-echo.php index 21d6eb67..fbc4d75a 100644 --- a/examples/31-upgrade-echo.php +++ b/examples/81-server-upgrade-echo.php @@ -18,25 +18,22 @@ */ use Psr\Http\Message\ServerRequestInterface; -use React\EventLoop\Factory; -use React\Http\Response; -use React\Http\Server; +use React\EventLoop\Loop; +use React\Http\Message\Response; use React\Stream\ThroughStream; require __DIR__ . '/../vendor/autoload.php'; -$loop = Factory::create(); - -// Note how this example uses the `Server` instead of `StreamingServer`. +// Note how this example uses the `HttpServer` without the `StreamingRequestMiddleware`. // The initial incoming request does not contain a body and we upgrade to a // stream object below. -$server = new Server(function (ServerRequestInterface $request) use ($loop) { +$http = new React\Http\HttpServer(function (ServerRequestInterface $request) { if ($request->getHeaderLine('Upgrade') !== 'echo' || $request->getProtocolVersion() === '1.0') { return new Response( - 426, - array( + Response::STATUS_UPGRADE_REQUIRED, + [ 'Upgrade' => 'echo' - ), + ], '"Upgrade: echo" required' ); } @@ -46,22 +43,20 @@ // this means that any Upgraded data will simply be sent back to the client $stream = new ThroughStream(); - $loop->addTimer(0, function () use ($stream) { + Loop::addTimer(0, function () use ($stream) { $stream->write("Hello! Anything you send will be piped back." . PHP_EOL); }); return new Response( - 101, - array( + Response::STATUS_SWITCHING_PROTOCOLS, + [ 'Upgrade' => 'echo' - ), + ], $stream ); }); -$socket = new \React\Socket\Server(isset($argv[1]) ? $argv[1] : '0.0.0.0:0', $loop); -$server->listen($socket); +$socket = new React\Socket\SocketServer($argv[1] ?? '0.0.0.0:0'); +$http->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/82-server-upgrade-chat.php similarity index 79% rename from examples/32-upgrade-chat.php rename to examples/82-server-upgrade-chat.php index 89230f31..00788922 100644 --- a/examples/32-upgrade-chat.php +++ b/examples/82-server-upgrade-chat.php @@ -20,31 +20,28 @@ */ use Psr\Http\Message\ServerRequestInterface; -use React\EventLoop\Factory; -use React\Http\Response; -use React\Http\Server; +use React\EventLoop\Loop; +use React\Http\Message\Response; 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(); -// Note how this example uses the `Server` instead of `StreamingServer`. +// Note how this example uses the `HttpServer` without the `StreamingRequestMiddleware`. // The initial incoming request does not contain a body and we upgrade to a // stream object below. -$server = new Server(function (ServerRequestInterface $request) use ($loop, $chat) { +$http = new React\Http\HttpServer(function (ServerRequestInterface $request) use ($chat) { if ($request->getHeaderLine('Upgrade') !== 'chat' || $request->getProtocolVersion() === '1.0') { return new Response( - 426, - array( + Response::STATUS_UPGRADE_REQUIRED, + [ 'Upgrade' => 'chat' - ), + ], '"Upgrade: chat" required' ); } @@ -68,7 +65,7 @@ }); // say hello to new user - $loop->addTimer(0, function () use ($chat, $username, $out) { + Loop::addTimer(0, function () use ($chat, $username, $out) { $out->write('Welcome to this chat example, ' . $username . '!' . PHP_EOL); $chat->write($username . ' joined' . PHP_EOL); }); @@ -79,17 +76,15 @@ }); return new Response( - 101, - array( + Response::STATUS_SWITCHING_PROTOCOLS, + [ 'Upgrade' => 'chat' - ), + ], $stream ); }); -$socket = new \React\Socket\Server(isset($argv[1]) ? $argv[1] : '0.0.0.0:0', $loop); -$server->listen($socket); +$socket = new React\Socket\SocketServer($argv[1] ?? '0.0.0.0:0'); +$http->listen($socket); echo 'Listening on ' . str_replace('tcp:', 'http:', $socket->getAddress()) . PHP_EOL; - -$loop->run(); diff --git a/examples/91-client-benchmark-download.php b/examples/91-client-benchmark-download.php new file mode 100644 index 00000000..f74b8925 --- /dev/null +++ b/examples/91-client-benchmark-download.php @@ -0,0 +1,59 @@ +requestStreaming('GET', $url)->then(function (ResponseInterface $response) { + echo 'Received ' . $response->getStatusCode() . ' ' . $response->getReasonPhrase() . PHP_EOL; + + $stream = $response->getBody(); + assert($stream instanceof ReadableStreamInterface); + + // count number of bytes received + $bytes = 0; + $stream->on('data', function ($chunk) use (&$bytes) { + $bytes += strlen($chunk); + }); + + // report progress every 0.1s + $timer = Loop::addPeriodicTimer(0.1, function () use (&$bytes) { + echo "\rDownloaded " . $bytes . " bytes…"; + }); + + // report results once the stream closes + $time = microtime(true); + $stream->on('close', function() use (&$bytes, $timer, $time) { + Loop::cancelTimer($timer); + + $time = microtime(true) - $time; + + echo "\r" . 'Downloaded ' . $bytes . ' bytes in ' . round($time, 3) . 's => ' . round($bytes / $time / 1000000, 1) . ' MB/s' . PHP_EOL; + }); +}, function (Exception $e) { + echo 'Error: ' . $e->getMessage() . PHP_EOL; +}); diff --git a/examples/92-client-benchmark-upload.php b/examples/92-client-benchmark-upload.php new file mode 100644 index 00000000..10434bfd --- /dev/null +++ b/examples/92-client-benchmark-upload.php @@ -0,0 +1,122 @@ +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', [$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 = []) + { + return Util::pipe($this, $dest, $options); + } + + public function isReadable() + { + return !$this->closed; + } + + public function close() + { + if ($this->closed) { + return; + } + + $this->closed = true; + $this->count = 0; + $this->paused = true; + $this->emit('close'); + } + + public function getPosition() + { + return $this->position * strlen($this->chunk); + } +} + +$client = new Browser(); + +$url = $argv[1] ?? 'http://httpbin.org/post'; +$n = $argv[2] ?? 10; +$source = new ChunkRepeater(str_repeat('x', 1000000), $n); +Loop::futureTick(function () use ($source) { + $source->resume(); +}); + +echo 'POSTing ' . $n . ' MB to ' . $url . PHP_EOL; + +$start = microtime(true); +$report = Loop::addPeriodicTimer(0.05, function () use ($source, $start) { + printf("\r%d bytes in %0.3fs...", $source->getPosition(), microtime(true) - $start); +}); + +$client->post($url, ['Content-Length' => $n * 1000000], $source)->then(function (ResponseInterface $response) use ($source, $report, $start) { + $now = microtime(true); + Loop::cancelTimer($report); + + printf("\r%d bytes in %0.3fs => %.1f MB/s\n", $source->getPosition(), $now - $start, $source->getPosition() / ($now - $start) / 1000000); + + echo rtrim(preg_replace('/x{5,}/','x…', (string) $response->getBody()), PHP_EOL) . PHP_EOL; +}, function (Exception $e) use ($report) { + Loop::cancelTimer($report); + echo 'Error: ' . $e->getMessage() . PHP_EOL; +}); diff --git a/examples/99-benchmark-download.php b/examples/99-server-benchmark-download.php similarity index 70% rename from examples/99-benchmark-download.php rename to examples/99-server-benchmark-download.php index 692bd810..ee1cfc8f 100644 --- a/examples/99-benchmark-download.php +++ b/examples/99-server-benchmark-download.php @@ -1,23 +1,27 @@ /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 +// $ ab -n10 -c10 -k http://localhost:8080/1g.bin +// $ docker run -it --rm --net=host jordi/ab -n100000 -c10 -k http://localhost:8080/ +// $ docker run -it --rm --net=host jordi/ab -n10 -c10 -k http://localhost:8080/1g.bin +// $ docker run -it --rm --net=host skandyla/wrk -t8 -c10 -d20 http://localhost:8080/ use Evenement\EventEmitter; use Psr\Http\Message\ServerRequestInterface; -use React\EventLoop\Factory; -use React\Http\Response; -use React\Http\Server; +use React\Http\Message\Response; 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 { @@ -48,7 +52,7 @@ public function resume() $this->paused = false; while ($this->position < $this->count && !$this->paused) { ++$this->position; - $this->emit('data', array($this->chunk)); + $this->emit('data', [$this->chunk]); } // end once the last chunk has been written @@ -58,7 +62,7 @@ public function resume() } } - public function pipe(WritableStreamInterface $dest, array $options = array()) + public function pipe(WritableStreamInterface $dest, array $options = []) { return; } @@ -86,16 +90,10 @@ public function getSize() } } -// Note how this example still uses `Server` instead of `StreamingServer`. -// The `StreamingServer` is only required for streaming *incoming* requests. -$server = new Server(function (ServerRequestInterface $request) use ($loop) { +$http = new React\Http\HttpServer(function (ServerRequestInterface $request) { switch ($request->getUri()->getPath()) { case '/': - return new Response( - 200, - array( - 'Content-Type' => 'text/html' - ), + return Response::html( '1g.bin
10g.bin' ); case '/1g.bin': @@ -105,24 +103,22 @@ public function getSize() $stream = new ChunkRepeater(str_repeat('.', 1000000), 10000); break; default: - return new Response(404); + return new Response(Response::STATUS_NOT_FOUND); } - $loop->addTimer(0, array($stream, 'resume')); + React\EventLoop\Loop::addTimer(0, [$stream, 'resume']); return new Response( - 200, - array( + Response::STATUS_OK, + [ '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); +$socket = new React\Socket\SocketServer($argv[1] ?? '0.0.0.0:0'); +$http->listen($socket); echo 'Listening on ' . str_replace('tcp:', 'http:', $socket->getAddress()) . PHP_EOL; - -$loop->run(); diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 79c0ee66..ac542e77 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,24 +1,28 @@ - + + convertDeprecationsToExceptions="true"> ./tests/ - - - + + ./src/ - - + + + + + + + + + + diff --git a/phpunit.xml.legacy b/phpunit.xml.legacy new file mode 100644 index 00000000..00868603 --- /dev/null +++ b/phpunit.xml.legacy @@ -0,0 +1,26 @@ + + + + + + + ./tests/ + + + + + ./src/ + + + + + + + + + + + diff --git a/src/Browser.php b/src/Browser.php new file mode 100644 index 00000000..f042b799 --- /dev/null +++ b/src/Browser.php @@ -0,0 +1,842 @@ + 'ReactPHP/1' + ]; + + /** + * The `Browser` is responsible for sending HTTP requests to your HTTP server + * and keeps track of pending incoming HTTP responses. + * + * ```php + * $browser = new React\Http\Browser(); + * ``` + * + * This class takes two optional arguments for more advanced usage: + * + * ```php + * $browser = new React\Http\Browser(?ConnectorInterface $connector = null, ?LoopInterface $loop = null); + * ``` + * + * If you need custom connector settings (DNS resolution, TLS parameters, timeouts, + * proxy servers etc.), you can explicitly pass a custom instance of the + * [`ConnectorInterface`](https://github.com/reactphp/socket#connectorinterface): + * + * ```php + * $connector = new React\Socket\Connector([ + * 'dns' => '127.0.0.1', + * 'tcp' => [ + * 'bindto' => '192.168.10.1:0' + * ], + * 'tls' => [ + * 'verify_peer' => false, + * 'verify_peer_name' => false + * ] + * ]); + * + * $browser = new React\Http\Browser($connector); + * ``` + * + * This class takes an optional `LoopInterface|null $loop` parameter that can be used to + * pass the event loop instance to use for this object. You can use a `null` value + * here in order to use the [default loop](https://github.com/reactphp/event-loop#loop). + * This value SHOULD NOT be given unless you're sure you want to explicitly use a + * given event loop instance. + * + * @param ?ConnectorInterface $connector + * @param ?LoopInterface $loop + */ + public function __construct(?ConnectorInterface $connector = null, ?LoopInterface $loop = null) + { + $loop = $loop ?? Loop::get(); + $this->transaction = new Transaction( + Sender::createFromLoop($loop, $connector ?? new Connector([], $loop)), + $loop + ); + } + + /** + * Sends an HTTP GET request + * + * ```php + * $browser->get($url)->then(function (Psr\Http\Message\ResponseInterface $response) { + * var_dump((string)$response->getBody()); + * }, function (Exception $e) { + * echo 'Error: ' . $e->getMessage() . PHP_EOL; + * }); + * ``` + * + * See also [GET request client example](../examples/01-client-get-request.php). + * + * @param string $url URL for the request. + * @param array $headers + * @return PromiseInterface + */ + public function get($url, array $headers = []) + { + return $this->requestMayBeStreaming('GET', $url, $headers); + } + + /** + * Sends an HTTP POST request + * + * ```php + * $browser->post( + * $url, + * [ + * 'Content-Type' => 'application/json' + * ], + * json_encode($data) + * )->then(function (Psr\Http\Message\ResponseInterface $response) { + * var_dump(json_decode((string)$response->getBody())); + * }, function (Exception $e) { + * echo 'Error: ' . $e->getMessage() . PHP_EOL; + * }); + * ``` + * + * See also [POST JSON client example](../examples/04-client-post-json.php). + * + * This method is also commonly used to submit HTML form data: + * + * ```php + * $data = [ + * 'user' => 'Alice', + * 'password' => 'secret' + * ]; + * + * $browser->post( + * $url, + * [ + * 'Content-Type' => 'application/x-www-form-urlencoded' + * ], + * http_build_query($data) + * ); + * ``` + * + * This method will automatically add a matching `Content-Length` request + * header if the outgoing request body is a `string`. If you're using a + * streaming request body (`ReadableStreamInterface`), it will default to + * using `Transfer-Encoding: chunked` or you have to explicitly pass in a + * matching `Content-Length` request header like so: + * + * ```php + * $body = new React\Stream\ThroughStream(); + * Loop::addTimer(1.0, function () use ($body) { + * $body->end("hello world"); + * }); + * + * $browser->post($url, ['Content-Length' => '11'], $body); + * ``` + * + * @param string $url URL for the request. + * @param array $headers + * @param string|ReadableStreamInterface $body + * @return PromiseInterface + */ + public function post($url, array $headers = [], $body = '') + { + return $this->requestMayBeStreaming('POST', $url, $headers, $body); + } + + /** + * Sends an HTTP HEAD request + * + * ```php + * $browser->head($url)->then(function (Psr\Http\Message\ResponseInterface $response) { + * var_dump($response->getHeaders()); + * }, function (Exception $e) { + * echo 'Error: ' . $e->getMessage() . PHP_EOL; + * }); + * ``` + * + * @param string $url URL for the request. + * @param array $headers + * @return PromiseInterface + */ + public function head($url, array $headers = []) + { + return $this->requestMayBeStreaming('HEAD', $url, $headers); + } + + /** + * Sends an HTTP PATCH request + * + * ```php + * $browser->patch( + * $url, + * [ + * 'Content-Type' => 'application/json' + * ], + * json_encode($data) + * )->then(function (Psr\Http\Message\ResponseInterface $response) { + * var_dump(json_decode((string)$response->getBody())); + * }, function (Exception $e) { + * echo 'Error: ' . $e->getMessage() . PHP_EOL; + * }); + * ``` + * + * This method will automatically add a matching `Content-Length` request + * header if the outgoing request body is a `string`. If you're using a + * streaming request body (`ReadableStreamInterface`), it will default to + * using `Transfer-Encoding: chunked` or you have to explicitly pass in a + * matching `Content-Length` request header like so: + * + * ```php + * $body = new React\Stream\ThroughStream(); + * Loop::addTimer(1.0, function () use ($body) { + * $body->end("hello world"); + * }); + * + * $browser->patch($url, ['Content-Length' => '11'], $body); + * ``` + * + * @param string $url URL for the request. + * @param array $headers + * @param string|ReadableStreamInterface $body + * @return PromiseInterface + */ + public function patch($url, array $headers = [], $body = '') + { + return $this->requestMayBeStreaming('PATCH', $url , $headers, $body); + } + + /** + * Sends an HTTP PUT request + * + * ```php + * $browser->put( + * $url, + * [ + * 'Content-Type' => 'text/xml' + * ], + * $xml->asXML() + * )->then(function (Psr\Http\Message\ResponseInterface $response) { + * var_dump((string)$response->getBody()); + * }, function (Exception $e) { + * echo 'Error: ' . $e->getMessage() . PHP_EOL; + * }); + * ``` + * + * See also [PUT XML client example](../examples/05-client-put-xml.php). + * + * This method will automatically add a matching `Content-Length` request + * header if the outgoing request body is a `string`. If you're using a + * streaming request body (`ReadableStreamInterface`), it will default to + * using `Transfer-Encoding: chunked` or you have to explicitly pass in a + * matching `Content-Length` request header like so: + * + * ```php + * $body = new React\Stream\ThroughStream(); + * Loop::addTimer(1.0, function () use ($body) { + * $body->end("hello world"); + * }); + * + * $browser->put($url, ['Content-Length' => '11'], $body); + * ``` + * + * @param string $url URL for the request. + * @param array $headers + * @param string|ReadableStreamInterface $body + * @return PromiseInterface + */ + public function put($url, array $headers = [], $body = '') + { + return $this->requestMayBeStreaming('PUT', $url, $headers, $body); + } + + /** + * Sends an HTTP DELETE request + * + * ```php + * $browser->delete($url)->then(function (Psr\Http\Message\ResponseInterface $response) { + * var_dump((string)$response->getBody()); + * }, function (Exception $e) { + * echo 'Error: ' . $e->getMessage() . PHP_EOL; + * }); + * ``` + * + * @param string $url URL for the request. + * @param array $headers + * @param string|ReadableStreamInterface $body + * @return PromiseInterface + */ + public function delete($url, array $headers = [], $body = '') + { + return $this->requestMayBeStreaming('DELETE', $url, $headers, $body); + } + + /** + * Sends an arbitrary HTTP request. + * + * The preferred way to send an HTTP request is by using the above + * [request methods](#request-methods), for example the [`get()`](#get) + * method to send an HTTP `GET` request. + * + * As an alternative, if you want to use a custom HTTP request method, you + * can use this method: + * + * ```php + * $browser->request('OPTIONS', $url)->then(function (Psr\Http\Message\ResponseInterface $response) { + * var_dump((string)$response->getBody()); + * }, function (Exception $e) { + * echo 'Error: ' . $e->getMessage() . PHP_EOL; + * }); + * ``` + * + * This method will automatically add a matching `Content-Length` request + * header if the size of the outgoing request body is known and non-empty. + * For an empty request body, if will only include a `Content-Length: 0` + * request header if the request method usually expects a request body (only + * applies to `POST`, `PUT` and `PATCH`). + * + * If you're using a streaming request body (`ReadableStreamInterface`), it + * will default to using `Transfer-Encoding: chunked` or you have to + * explicitly pass in a matching `Content-Length` request header like so: + * + * ```php + * $body = new React\Stream\ThroughStream(); + * Loop::addTimer(1.0, function () use ($body) { + * $body->end("hello world"); + * }); + * + * $browser->request('POST', $url, ['Content-Length' => '11'], $body); + * ``` + * + * @param string $method HTTP request method, e.g. GET/HEAD/POST etc. + * @param string $url URL for the request + * @param array $headers Additional request headers + * @param string|ReadableStreamInterface $body HTTP request body contents + * @return PromiseInterface + */ + public function request($method, $url, array $headers = [], $body = '') + { + return $this->withOptions(['streaming' => false])->requestMayBeStreaming($method, $url, $headers, $body); + } + + /** + * Sends an arbitrary HTTP request and receives a streaming response without buffering the response body. + * + * The preferred way to send an HTTP request is by using the above + * [request methods](#request-methods), for example the [`get()`](#get) + * method to send an HTTP `GET` request. Each of these methods will buffer + * the whole response body in memory by default. This is easy to get started + * and works reasonably well for smaller responses. + * + * In some situations, it's a better idea to use a streaming approach, where + * only small chunks have to be kept in memory. You can use this method to + * send an arbitrary HTTP request and receive a streaming response. It uses + * the same HTTP message API, but does not buffer the response body in + * memory. It only processes the response body in small chunks as data is + * received and forwards this data through [ReactPHP's Stream API](https://github.com/reactphp/stream). + * This works for (any number of) responses of arbitrary sizes. + * + * ```php + * $browser->requestStreaming('GET', $url)->then(function (Psr\Http\Message\ResponseInterface $response) { + * $body = $response->getBody(); + * assert($body instanceof Psr\Http\Message\StreamInterface); + * assert($body instanceof React\Stream\ReadableStreamInterface); + * + * $body->on('data', function ($chunk) { + * echo $chunk; + * }); + * + * $body->on('error', function (Exception $e) { + * echo 'Error: ' . $e->getMessage() . PHP_EOL; + * }); + * + * $body->on('close', function () { + * echo '[DONE]' . PHP_EOL; + * }); + * }, function (Exception $e) { + * echo 'Error: ' . $e->getMessage() . PHP_EOL; + * }); + * ``` + * + * See also [`ReadableStreamInterface`](https://github.com/reactphp/stream#readablestreaminterface) + * and the [streaming response](#streaming-response) for more details, + * examples and possible use-cases. + * + * This method will automatically add a matching `Content-Length` request + * header if the size of the outgoing request body is known and non-empty. + * For an empty request body, if will only include a `Content-Length: 0` + * request header if the request method usually expects a request body (only + * applies to `POST`, `PUT` and `PATCH`). + * + * If you're using a streaming request body (`ReadableStreamInterface`), it + * will default to using `Transfer-Encoding: chunked` or you have to + * explicitly pass in a matching `Content-Length` request header like so: + * + * ```php + * $body = new React\Stream\ThroughStream(); + * Loop::addTimer(1.0, function () use ($body) { + * $body->end("hello world"); + * }); + * + * $browser->requestStreaming('POST', $url, ['Content-Length' => '11'], $body); + * ``` + * + * @param string $method HTTP request method, e.g. GET/HEAD/POST etc. + * @param string $url URL for the request + * @param array $headers Additional request headers + * @param string|ReadableStreamInterface $body HTTP request body contents + * @return PromiseInterface + */ + public function requestStreaming($method, $url, $headers = [], $body = '') + { + return $this->withOptions(['streaming' => true])->requestMayBeStreaming($method, $url, $headers, $body); + } + + /** + * Changes the maximum timeout used for waiting for pending requests. + * + * You can pass in the number of seconds to use as a new timeout value: + * + * ```php + * $browser = $browser->withTimeout(10.0); + * ``` + * + * You can pass in a bool `false` to disable any timeouts. In this case, + * requests can stay pending forever: + * + * ```php + * $browser = $browser->withTimeout(false); + * ``` + * + * You can pass in a bool `true` to re-enable default timeout handling. This + * will respects PHP's `default_socket_timeout` setting (default 60s): + * + * ```php + * $browser = $browser->withTimeout(true); + * ``` + * + * See also [timeouts](#timeouts) for more details about timeout handling. + * + * Notice that the [`Browser`](#browser) is an immutable object, i.e. this + * method actually returns a *new* [`Browser`](#browser) instance with the + * given timeout value applied. + * + * @param bool|number $timeout + * @return self + */ + public function withTimeout($timeout) + { + if ($timeout === true) { + $timeout = null; + } elseif ($timeout === false) { + $timeout = -1; + } elseif ($timeout < 0) { + $timeout = 0; + } + + return $this->withOptions([ + 'timeout' => $timeout, + ]); + } + + /** + * Changes how HTTP redirects will be followed. + * + * You can pass in the maximum number of redirects to follow: + * + * ```php + * $browser = $browser->withFollowRedirects(5); + * ``` + * + * The request will automatically be rejected when the number of redirects + * is exceeded. You can pass in a `0` to reject the request for any + * redirects encountered: + * + * ```php + * $browser = $browser->withFollowRedirects(0); + * + * $browser->get($url)->then(function (Psr\Http\Message\ResponseInterface $response) { + * // only non-redirected responses will now end up here + * var_dump($response->getHeaders()); + * }, function (Exception $e) { + * echo 'Error: ' . $e->getMessage() . PHP_EOL; + * }); + * ``` + * + * You can pass in a bool `false` to disable following any redirects. In + * this case, requests will resolve with the redirection response instead + * of following the `Location` response header: + * + * ```php + * $browser = $browser->withFollowRedirects(false); + * + * $browser->get($url)->then(function (Psr\Http\Message\ResponseInterface $response) { + * // any redirects will now end up here + * var_dump($response->getHeaderLine('Location')); + * }, function (Exception $e) { + * echo 'Error: ' . $e->getMessage() . PHP_EOL; + * }); + * ``` + * + * You can pass in a bool `true` to re-enable default redirect handling. + * This defaults to following a maximum of 10 redirects: + * + * ```php + * $browser = $browser->withFollowRedirects(true); + * ``` + * + * See also [redirects](#redirects) for more details about redirect handling. + * + * Notice that the [`Browser`](#browser) is an immutable object, i.e. this + * method actually returns a *new* [`Browser`](#browser) instance with the + * given redirect setting applied. + * + * @param bool|int $followRedirects + * @return self + */ + public function withFollowRedirects($followRedirects) + { + return $this->withOptions([ + 'followRedirects' => $followRedirects !== false, + 'maxRedirects' => \is_bool($followRedirects) ? null : $followRedirects + ]); + } + + /** + * Changes whether non-successful HTTP response status codes (4xx and 5xx) will be rejected. + * + * You can pass in a bool `false` to disable rejecting incoming responses + * that use a 4xx or 5xx response status code. In this case, requests will + * resolve with the response message indicating an error condition: + * + * ```php + * $browser = $browser->withRejectErrorResponse(false); + * + * $browser->get($url)->then(function (Psr\Http\Message\ResponseInterface $response) { + * // any HTTP response will now end up here + * var_dump($response->getStatusCode(), $response->getReasonPhrase()); + * }, function (Exception $e) { + * echo 'Error: ' . $e->getMessage() . PHP_EOL; + * }); + * ``` + * + * You can pass in a bool `true` to re-enable default status code handling. + * This defaults to rejecting any response status codes in the 4xx or 5xx + * range: + * + * ```php + * $browser = $browser->withRejectErrorResponse(true); + * + * $browser->get($url)->then(function (Psr\Http\Message\ResponseInterface $response) { + * // any successful HTTP response will now end up here + * var_dump($response->getStatusCode(), $response->getReasonPhrase()); + * }, function (Exception $e) { + * if ($e instanceof React\Http\Message\ResponseException) { + * // any HTTP response error message will now end up here + * $response = $e->getResponse(); + * var_dump($response->getStatusCode(), $response->getReasonPhrase()); + * } else { + * echo 'Error: ' . $e->getMessage() . PHP_EOL; + * } + * }); + * ``` + * + * Notice that the [`Browser`](#browser) is an immutable object, i.e. this + * method actually returns a *new* [`Browser`](#browser) instance with the + * given setting applied. + * + * @param bool $obeySuccessCode + * @return self + */ + public function withRejectErrorResponse($obeySuccessCode) + { + return $this->withOptions([ + 'obeySuccessCode' => $obeySuccessCode, + ]); + } + + /** + * Changes the base URL used to resolve relative URLs to. + * + * If you configure a base URL, any requests to relative URLs will be + * processed by first resolving this relative to the given absolute base + * URL. This supports resolving relative path references (like `../` etc.). + * This is particularly useful for (RESTful) API calls where all endpoints + * (URLs) are located under a common base URL. + * + * ```php + * $browser = $browser->withBase('http://api.example.com/v3/'); + * + * // will request http://api.example.com/v3/users + * $browser->get('users')->then(…); + * ``` + * + * You can pass in a `null` base URL to return a new instance that does not + * use a base URL: + * + * ```php + * $browser = $browser->withBase(null); + * ``` + * + * Accordingly, any requests using relative URLs to a browser that does not + * use a base URL can not be completed and will be rejected without sending + * a request. + * + * This method will throw an `InvalidArgumentException` if the given + * `$baseUrl` argument is not a valid URL. + * + * Notice that the [`Browser`](#browser) is an immutable object, i.e. the `withBase()` method + * actually returns a *new* [`Browser`](#browser) instance with the given base URL applied. + * + * @param string|null $baseUrl absolute base URL + * @return self + * @throws InvalidArgumentException if the given $baseUrl is not a valid absolute URL + * @see self::withoutBase() + */ + public function withBase($baseUrl) + { + $browser = clone $this; + if ($baseUrl === null) { + $browser->baseUrl = null; + return $browser; + } + + $browser->baseUrl = new Uri($baseUrl); + if (!\in_array($browser->baseUrl->getScheme(), ['http', 'https']) || $browser->baseUrl->getHost() === '') { + throw new \InvalidArgumentException('Base URL must be absolute'); + } + + return $browser; + } + + /** + * Changes the HTTP protocol version that will be used for all subsequent requests. + * + * All the above [request methods](#request-methods) default to sending + * requests as HTTP/1.1. This is the preferred HTTP protocol version which + * also provides decent backwards-compatibility with legacy HTTP/1.0 + * servers. As such, there should rarely be a need to explicitly change this + * protocol version. + * + * If you want to explicitly use the legacy HTTP/1.0 protocol version, you + * can use this method: + * + * ```php + * $browser = $browser->withProtocolVersion('1.0'); + * + * $browser->get($url)->then(…); + * ``` + * + * Notice that the [`Browser`](#browser) is an immutable object, i.e. this + * method actually returns a *new* [`Browser`](#browser) instance with the + * new protocol version applied. + * + * @param string $protocolVersion HTTP protocol version to use, must be one of "1.1" or "1.0" + * @return self + * @throws InvalidArgumentException + */ + public function withProtocolVersion($protocolVersion) + { + if (!\in_array($protocolVersion, ['1.0', '1.1'], true)) { + throw new InvalidArgumentException('Invalid HTTP protocol version, must be one of "1.1" or "1.0"'); + } + + $browser = clone $this; + $browser->protocolVersion = (string) $protocolVersion; + + return $browser; + } + + /** + * Changes the maximum size for buffering a response body. + * + * The preferred way to send an HTTP request is by using the above + * [request methods](#request-methods), for example the [`get()`](#get) + * method to send an HTTP `GET` request. Each of these methods will buffer + * the whole response body in memory by default. This is easy to get started + * and works reasonably well for smaller responses. + * + * By default, the response body buffer will be limited to 16 MiB. If the + * response body exceeds this maximum size, the request will be rejected. + * + * You can pass in the maximum number of bytes to buffer: + * + * ```php + * $browser = $browser->withResponseBuffer(1024 * 1024); + * + * $browser->get($url)->then(function (Psr\Http\Message\ResponseInterface $response) { + * // response body will not exceed 1 MiB + * var_dump($response->getHeaders(), (string) $response->getBody()); + * }, function (Exception $e) { + * echo 'Error: ' . $e->getMessage() . PHP_EOL; + * }); + * ``` + * + * Note that the response body buffer has to be kept in memory for each + * pending request until its transfer is completed and it will only be freed + * after a pending request is fulfilled. As such, increasing this maximum + * buffer size to allow larger response bodies is usually not recommended. + * Instead, you can use the [`requestStreaming()` method](#requeststreaming) + * to receive responses with arbitrary sizes without buffering. Accordingly, + * this maximum buffer size setting has no effect on streaming responses. + * + * Notice that the [`Browser`](#browser) is an immutable object, i.e. this + * method actually returns a *new* [`Browser`](#browser) instance with the + * given setting applied. + * + * @param int $maximumSize + * @return self + * @see self::requestStreaming() + */ + public function withResponseBuffer($maximumSize) + { + return $this->withOptions([ + 'maximumSize' => $maximumSize + ]); + } + + /** + * Add a request header for all following requests. + * + * ```php + * $browser = $browser->withHeader('User-Agent', 'ACME'); + * + * $browser->get($url)->then(…); + * ``` + * + * Note that the new header will overwrite any headers previously set with + * the same name (case-insensitive). Following requests will use these headers + * by default unless they are explicitly set for any requests. + * + * @param string $header + * @param string $value + * @return Browser + */ + public function withHeader($header, $value) + { + $browser = $this->withoutHeader($header); + $browser->defaultHeaders[$header] = $value; + + return $browser; + } + + /** + * Remove any default request headers previously set via + * the [`withHeader()` method](#withheader). + * + * ```php + * $browser = $browser->withoutHeader('User-Agent'); + * + * $browser->get($url)->then(…); + * ``` + * + * Note that this method only affects the headers which were set with the + * method `withHeader(string $header, string $value): Browser` + * + * @param string $header + * @return Browser + */ + public function withoutHeader($header) + { + $browser = clone $this; + + /** @var string|int $key */ + foreach (\array_keys($browser->defaultHeaders) as $key) { + if (\strcasecmp($key, $header) === 0) { + unset($browser->defaultHeaders[$key]); + break; + } + } + + return $browser; + } + + /** + * Changes the [options](#options) to use: + * + * The [`Browser`](#browser) class exposes several options for the handling of + * HTTP transactions. These options resemble some of PHP's + * [HTTP context options](http://php.net/manual/en/context.http.php) and + * can be controlled via the following API (and their defaults): + * + * ```php + * // deprecated + * $newBrowser = $browser->withOptions([ + * 'timeout' => null, // see withTimeout() instead + * 'followRedirects' => true, // see withFollowRedirects() instead + * 'maxRedirects' => 10, // see withFollowRedirects() instead + * 'obeySuccessCode' => true, // see withRejectErrorResponse() instead + * 'streaming' => false, // deprecated, see requestStreaming() instead + * ]); + * ``` + * + * See also [timeouts](#timeouts), [redirects](#redirects) and + * [streaming](#streaming) for more details. + * + * Notice that the [`Browser`](#browser) is an immutable object, i.e. this + * method actually returns a *new* [`Browser`](#browser) instance with the + * options applied. + * + * @param array $options + * @return self + * @see self::withTimeout() + * @see self::withFollowRedirects() + * @see self::withRejectErrorResponse() + */ + private function withOptions(array $options) + { + $browser = clone $this; + $browser->transaction = $this->transaction->withOptions($options); + + return $browser; + } + + /** + * @param string $method + * @param string $url + * @param array $headers + * @param string|ReadableStreamInterface $body + * @return PromiseInterface + */ + private function requestMayBeStreaming($method, $url, array $headers = [], $body = '') + { + if ($this->baseUrl !== null) { + // ensure we're actually below the base URL + $url = Uri::resolve($this->baseUrl, new Uri($url)); + } + + foreach ($this->defaultHeaders as $key => $value) { + $explicitHeaderExists = false; + foreach (\array_keys($headers) as $headerKey) { + if (\strcasecmp($headerKey, $key) === 0) { + $explicitHeaderExists = true; + break; + } + } + if (!$explicitHeaderExists) { + $headers[$key] = $value; + } + } + + return $this->transaction->send( + new Request($method, $url, $headers, $body, $this->protocolVersion) + ); + } +} diff --git a/src/Client/Client.php b/src/Client/Client.php new file mode 100644 index 00000000..7a5180ab --- /dev/null +++ b/src/Client/Client.php @@ -0,0 +1,27 @@ +connectionManager = $connectionManager; + } + + /** @return ClientRequestStream */ + public function request(RequestInterface $request) + { + return new ClientRequestStream($this->connectionManager, $request); + } +} diff --git a/src/HttpServer.php b/src/HttpServer.php new file mode 100644 index 00000000..24168cc5 --- /dev/null +++ b/src/HttpServer.php @@ -0,0 +1,346 @@ + 'text/plain' + * ], + * "Hello World!\n" + * ); + * }); + * ``` + * + * Each incoming HTTP request message is always represented by the + * [PSR-7 `ServerRequestInterface`](https://www.php-fig.org/psr/psr-7/#321-psrhttpmessageserverrequestinterface), + * see also following [request](#server-request) chapter for more details. + * + * Each outgoing HTTP response message is always represented by the + * [PSR-7 `ResponseInterface`](https://www.php-fig.org/psr/psr-7/#33-psrhttpmessageresponseinterface), + * see also following [response](#server-response) chapter for more details. + * + * This class takes an optional `LoopInterface|null $loop` parameter that can be used to + * pass the event loop instance to use for this object. You can use a `null` value + * here in order to use the [default loop](https://github.com/reactphp/event-loop#loop). + * This value SHOULD NOT be given unless you're sure you want to explicitly use a + * given event loop instance. + * + * In order to start listening for any incoming connections, the `HttpServer` needs + * to be attached to an instance of + * [`React\Socket\ServerInterface`](https://github.com/reactphp/socket#serverinterface) + * through the [`listen()`](#listen) method as described in the following + * chapter. In its most simple form, you can attach this to a + * [`React\Socket\SocketServer`](https://github.com/reactphp/socket#socketserver) + * in order to start a plaintext HTTP server like this: + * + * ```php + * $http = new React\Http\HttpServer($handler); + * + * $socket = new React\Socket\SocketServer('0.0.0.0:8080'); + * $http->listen($socket); + * ``` + * + * See also the [`listen()`](#listen) method and + * [hello world server example](../examples/51-server-hello-world.php) + * for more details. + * + * By default, the `HttpServer` buffers and parses the complete incoming HTTP + * request in memory. It will invoke the given request handler function when the + * complete request headers and request body has been received. This means the + * [request](#server-request) object passed to your request handler function will be + * fully compatible with PSR-7 (http-message). This provides sane defaults for + * 80% of the use cases and is the recommended way to use this library unless + * you're sure you know what you're doing. + * + * On the other hand, buffering complete HTTP requests in memory until they can + * be processed by your request handler function means that this class has to + * employ a number of limits to avoid consuming too much memory. In order to + * take the more advanced configuration out your hand, it respects setting from + * your [`php.ini`](https://www.php.net/manual/en/ini.core.php) to apply its + * default settings. This is a list of PHP settings this class respects with + * their respective default values: + * + * ``` + * memory_limit 128M + * post_max_size 8M // capped at 64K + * + * enable_post_data_reading 1 + * max_input_nesting_level 64 + * max_input_vars 1000 + * + * file_uploads 1 + * upload_max_filesize 2M + * max_file_uploads 20 + * ``` + * + * In particular, the `post_max_size` setting limits how much memory a single + * HTTP request is allowed to consume while buffering its request body. This + * needs to be limited because the server can process a large number of requests + * concurrently, so the server may potentially consume a large amount of memory + * otherwise. To support higher concurrency by default, this value is capped + * at `64K`. If you assign a higher value, it will only allow `64K` by default. + * If a request exceeds this limit, its request body will be ignored and it will + * be processed like a request with no request body at all. See below for + * explicit configuration to override this setting. + * + * By default, this class will try to avoid consuming more than half of your + * `memory_limit` for buffering multiple concurrent HTTP requests. As such, with + * the above default settings of `128M` max, it will try to consume no more than + * `64M` for buffering multiple concurrent HTTP requests. As a consequence, it + * will limit the concurrency to `1024` HTTP requests with the above defaults. + * + * It is imperative that you assign reasonable values to your PHP ini settings. + * It is usually recommended to not support buffering incoming HTTP requests + * with a large HTTP request body (e.g. large file uploads). If you want to + * increase this buffer size, you will have to also increase the total memory + * limit to allow for more concurrent requests (set `memory_limit 512M` or more) + * or explicitly limit concurrency. + * + * In order to override the above buffering defaults, you can configure the + * `HttpServer` explicitly. You can use the + * [`LimitConcurrentRequestsMiddleware`](#limitconcurrentrequestsmiddleware) and + * [`RequestBodyBufferMiddleware`](#requestbodybuffermiddleware) (see below) + * to explicitly configure the total number of requests that can be handled at + * once like this: + * + * ```php + * $http = new React\Http\HttpServer( + * new React\Http\Middleware\StreamingRequestMiddleware(), + * new React\Http\Middleware\LimitConcurrentRequestsMiddleware(100), // 100 concurrent buffering handlers + * new React\Http\Middleware\RequestBodyBufferMiddleware(2 * 1024 * 1024), // 2 MiB per request + * new React\Http\Middleware\RequestBodyParserMiddleware(), + * $handler + * )); + * ``` + * + * In this example, we allow processing up to 100 concurrent requests at once + * and each request can buffer up to `2M`. This means you may have to keep a + * maximum of `200M` of memory for incoming request body buffers. Accordingly, + * you need to adjust the `memory_limit` ini setting to allow for these buffers + * plus your actual application logic memory requirements (think `512M` or more). + * + * > Internally, this class automatically assigns these middleware handlers + * automatically when no [`StreamingRequestMiddleware`](#streamingrequestmiddleware) + * is given. Accordingly, you can use this example to override all default + * settings to implement custom limits. + * + * As an alternative to buffering the complete request body in memory, you can + * also use a streaming approach where only small chunks of data have to be kept + * in memory: + * + * ```php + * $http = new React\Http\HttpServer( + * new React\Http\Middleware\StreamingRequestMiddleware(), + * $handler + * ); + * ``` + * + * In this case, it will invoke the request handler function once the HTTP + * request headers have been received, i.e. before receiving the potentially + * much larger HTTP request body. This means the [request](#server-request) passed to + * your request handler function may not be fully compatible with PSR-7. This is + * specifically designed to help with more advanced use cases where you want to + * have full control over consuming the incoming HTTP request body and + * concurrency settings. See also [streaming incoming request](#streaming-incoming-request) + * below for more details. + */ +final class HttpServer extends EventEmitter +{ + /** + * The maximum buffer size used for each request. + * + * This needs to be limited because the server can process a large number of + * requests concurrently, so the server may potentially consume a large + * amount of memory otherwise. + * + * See `RequestBodyBufferMiddleware` to override this setting. + * + * @internal + */ + const MAXIMUM_BUFFER_SIZE = 65536; // 64 KiB + + /** + * @var StreamingServer + */ + private $streamingServer; + + /** + * Creates an HTTP server that invokes the given callback for each incoming HTTP request + * + * 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. + * See also [listen()](#listen) for more details. + * + * @param callable|LoopInterface $requestHandlerOrLoop + * @param callable[] ...$requestHandler + * @see self::listen() + */ + public function __construct($requestHandlerOrLoop) + { + $requestHandlers = \func_get_args(); + if (reset($requestHandlers) instanceof LoopInterface) { + $loop = \array_shift($requestHandlers); + } else { + $loop = Loop::get(); + } + + $requestHandlersCount = \count($requestHandlers); + if ($requestHandlersCount === 0 || \count(\array_filter($requestHandlers, 'is_callable')) < $requestHandlersCount) { + throw new \InvalidArgumentException('Invalid request handler given'); + } + + $streaming = false; + foreach ((array) $requestHandlers as $handler) { + if ($handler instanceof StreamingRequestMiddleware) { + $streaming = true; + break; + } + } + + $middleware = []; + if (!$streaming) { + $maxSize = $this->getMaxRequestSize(); + $concurrency = $this->getConcurrentRequestsLimit(\ini_get('memory_limit'), $maxSize); + if ($concurrency !== null) { + $middleware[] = new LimitConcurrentRequestsMiddleware($concurrency); + } + $middleware[] = new RequestBodyBufferMiddleware($maxSize); + // Checking for an empty string because that is what a boolean + // false is returned as by ini_get depending on the PHP version. + // @link http://php.net/manual/en/ini.core.php#ini.enable-post-data-reading + // @link http://php.net/manual/en/function.ini-get.php#refsect1-function.ini-get-notes + // @link https://3v4l.org/qJtsa + $enablePostDataReading = \ini_get('enable_post_data_reading'); + if ($enablePostDataReading !== '') { + $middleware[] = new RequestBodyParserMiddleware(); + } + } + + $middleware = \array_merge($middleware, $requestHandlers); + + /** + * Filter out any configuration middleware, no need to run requests through something that isn't + * doing anything with the request. + */ + $middleware = \array_filter($middleware, function ($handler) { + return !($handler instanceof StreamingRequestMiddleware); + }); + + $this->streamingServer = new StreamingServer($loop, new MiddlewareRunner($middleware)); + + $this->streamingServer->on('error', function ($error) { + $this->emit('error', [$error]); + }); + } + + /** + * Starts listening for HTTP requests on the given socket server instance + * + * The given [`React\Socket\ServerInterface`](https://github.com/reactphp/socket#serverinterface) + * is responsible for emitting the underlying streaming connections. This + * HTTP server needs to be attached to it in order to process any + * connections and pase incoming streaming data as incoming HTTP request + * messages. In its most common form, you can attach this to a + * [`React\Socket\SocketServer`](https://github.com/reactphp/socket#socketserver) + * in order to start a plaintext HTTP server like this: + * + * ```php + * $http = new React\Http\HttpServer($handler); + * + * $socket = new React\Socket\SocketServer('0.0.0.0:8080'); + * $http->listen($socket); + * ``` + * + * See also [hello world server example](../examples/51-server-hello-world.php) + * for more details. + * + * This example will start listening for HTTP requests on the alternative + * HTTP port `8080` on all interfaces (publicly). As an alternative, it is + * very common to use a reverse proxy and let this HTTP server listen on the + * localhost (loopback) interface only by using the listen address + * `127.0.0.1:8080` instead. This way, you host your application(s) on the + * default HTTP port `80` and only route specific requests to this HTTP + * server. + * + * Likewise, it's usually recommended to use a reverse proxy setup to accept + * secure HTTPS requests on default HTTPS port `443` (TLS termination) and + * only route plaintext requests to this HTTP server. As an alternative, you + * can also accept secure HTTPS requests with this HTTP server by attaching + * this to a [`React\Socket\SocketServer`](https://github.com/reactphp/socket#socketserver) + * using a secure TLS listen address, a certificate file and optional + * `passphrase` like this: + * + * ```php + * $http = new React\Http\HttpServer($handler); + * + * $socket = new React\Socket\SocketServer('tls://0.0.0.0:8443', [ + * 'tls' => [ + * 'local_cert' => __DIR__ . '/localhost.pem' + * ] + * ]); + * $http->listen($socket); + * ``` + * + * See also [hello world HTTPS example](../examples/61-server-hello-world-https.php) + * for more details. + * + * @param ServerInterface $socket + */ + public function listen(ServerInterface $socket) + { + $this->streamingServer->listen($socket); + } + + /** + * @param string $memory_limit + * @param string $post_max_size + * @return ?int + */ + private function getConcurrentRequestsLimit($memory_limit, $post_max_size) + { + if ($memory_limit == -1) { + return null; + } + + $availableMemory = IniUtil::iniSizeToBytes($memory_limit) / 2; + $concurrentRequests = (int) \ceil($availableMemory / IniUtil::iniSizeToBytes($post_max_size)); + + return $concurrentRequests; + } + + /** + * @param ?string $post_max_size + * @return int + */ + private function getMaxRequestSize($post_max_size = null) + { + $maxSize = IniUtil::iniSizeToBytes($post_max_size ?? \ini_get('post_max_size')); + + return ($maxSize === 0 || $maxSize >= self::MAXIMUM_BUFFER_SIZE) ? self::MAXIMUM_BUFFER_SIZE : $maxSize; + } +} diff --git a/src/Io/AbstractMessage.php b/src/Io/AbstractMessage.php new file mode 100644 index 00000000..232a5442 --- /dev/null +++ b/src/Io/AbstractMessage.php @@ -0,0 +1,172 @@ +@,;:\\\"\/\[\]?={}\x00-\x20\x7F]++):[\x20\x09]*+((?:[\x20\x09]*+[\x21-\x7E\x80-\xFF]++)*+)[\x20\x09]*+[\r]?+\n/m'; + + /** @var array */ + private $headers = []; + + /** @var array */ + private $headerNamesLowerCase = []; + + /** @var string */ + private $protocolVersion; + + /** @var StreamInterface */ + private $body; + + /** + * @param string $protocolVersion + * @param array $headers + * @param StreamInterface $body + */ + protected function __construct($protocolVersion, array $headers, StreamInterface $body) + { + foreach ($headers as $name => $value) { + if ($value !== []) { + if (\is_array($value)) { + foreach ($value as &$one) { + $one = (string) $one; + } + } else { + $value = [(string) $value]; + } + + $lower = \strtolower($name); + if (isset($this->headerNamesLowerCase[$lower])) { + $value = \array_merge($this->headers[$this->headerNamesLowerCase[$lower]], $value); + unset($this->headers[$this->headerNamesLowerCase[$lower]]); + } + + $this->headers[$name] = $value; + $this->headerNamesLowerCase[$lower] = $name; + } + } + + $this->protocolVersion = (string) $protocolVersion; + $this->body = $body; + } + + public function getProtocolVersion() + { + return $this->protocolVersion; + } + + public function withProtocolVersion($version) + { + if ((string) $version === $this->protocolVersion) { + return $this; + } + + $message = clone $this; + $message->protocolVersion = (string) $version; + + return $message; + } + + public function getHeaders() + { + return $this->headers; + } + + public function hasHeader($name) + { + return isset($this->headerNamesLowerCase[\strtolower($name)]); + } + + public function getHeader($name) + { + $lower = \strtolower($name); + return isset($this->headerNamesLowerCase[$lower]) ? $this->headers[$this->headerNamesLowerCase[$lower]] : []; + } + + public function getHeaderLine($name) + { + return \implode(', ', $this->getHeader($name)); + } + + public function withHeader($name, $value) + { + if ($value === []) { + return $this->withoutHeader($name); + } elseif (\is_array($value)) { + foreach ($value as &$one) { + $one = (string) $one; + } + } else { + $value = [(string) $value]; + } + + $lower = \strtolower($name); + if (isset($this->headerNamesLowerCase[$lower]) && $this->headerNamesLowerCase[$lower] === (string) $name && $this->headers[$this->headerNamesLowerCase[$lower]] === $value) { + return $this; + } + + $message = clone $this; + if (isset($message->headerNamesLowerCase[$lower])) { + unset($message->headers[$message->headerNamesLowerCase[$lower]]); + } + + $message->headers[$name] = $value; + $message->headerNamesLowerCase[$lower] = $name; + + return $message; + } + + public function withAddedHeader($name, $value) + { + if ($value === []) { + return $this; + } + + return $this->withHeader($name, \array_merge($this->getHeader($name), \is_array($value) ? $value : [$value])); + } + + public function withoutHeader($name) + { + $lower = \strtolower($name); + if (!isset($this->headerNamesLowerCase[$lower])) { + return $this; + } + + $message = clone $this; + unset($message->headers[$message->headerNamesLowerCase[$lower]], $message->headerNamesLowerCase[$lower]); + + return $message; + } + + public function getBody() + { + return $this->body; + } + + public function withBody(StreamInterface $body) + { + if ($body === $this->body) { + return $this; + } + + $message = clone $this; + $message->body = $body; + + return $message; + } +} diff --git a/src/Io/AbstractRequest.php b/src/Io/AbstractRequest.php new file mode 100644 index 00000000..1182f7ab --- /dev/null +++ b/src/Io/AbstractRequest.php @@ -0,0 +1,156 @@ + $headers + * @param StreamInterface $body + * @param string unknown $protocolVersion + */ + protected function __construct( + $method, + $uri, + array $headers, + StreamInterface $body, + $protocolVersion + ) { + if (\is_string($uri)) { + $uri = new Uri($uri); + } elseif (!$uri instanceof UriInterface) { + throw new \InvalidArgumentException( + 'Argument #2 ($uri) expected string|Psr\Http\Message\UriInterface' + ); + } + + // assign default `Host` request header from URI unless already given explicitly + $host = $uri->getHost(); + if ($host !== '') { + foreach ($headers as $name => $value) { + if (\strtolower($name) === 'host' && $value !== []) { + $host = ''; + break; + } + } + if ($host !== '') { + $port = $uri->getPort(); + if ($port !== null && (!($port === 80 && $uri->getScheme() === 'http') || !($port === 443 && $uri->getScheme() === 'https'))) { + $host .= ':' . $port; + } + + $headers = ['Host' => $host] + $headers; + } + } + + parent::__construct($protocolVersion, $headers, $body); + + $this->method = $method; + $this->uri = $uri; + } + + public function getRequestTarget() + { + if ($this->requestTarget !== null) { + return $this->requestTarget; + } + + $target = $this->uri->getPath(); + if ($target === '') { + $target = '/'; + } + if (($query = $this->uri->getQuery()) !== '') { + $target .= '?' . $query; + } + + return $target; + } + + public function withRequestTarget($requestTarget) + { + if ((string) $requestTarget === $this->requestTarget) { + return $this; + } + + $request = clone $this; + $request->requestTarget = (string) $requestTarget; + + return $request; + } + + public function getMethod() + { + return $this->method; + } + + public function withMethod($method) + { + if ((string) $method === $this->method) { + return $this; + } + + $request = clone $this; + $request->method = (string) $method; + + return $request; + } + + public function getUri() + { + return $this->uri; + } + + public function withUri(UriInterface $uri, $preserveHost = false) + { + if ($uri === $this->uri) { + return $this; + } + + $request = clone $this; + $request->uri = $uri; + + $host = $uri->getHost(); + $port = $uri->getPort(); + if ($port !== null && $host !== '' && (!($port === 80 && $uri->getScheme() === 'http') || !($port === 443 && $uri->getScheme() === 'https'))) { + $host .= ':' . $port; + } + + // update `Host` request header if URI contains a new host and `$preserveHost` is false + if ($host !== '' && (!$preserveHost || $request->getHeaderLine('Host') === '')) { + // first remove all headers before assigning `Host` header to ensure it always comes first + foreach (\array_keys($request->getHeaders()) as $name) { + $request = $request->withoutHeader($name); + } + + // add `Host` header first, then all other original headers + $request = $request->withHeader('Host', $host); + foreach ($this->withoutHeader('Host')->getHeaders() as $name => $value) { + $request = $request->withHeader($name, $value); + } + } + + return $request; + } +} diff --git a/src/Io/BufferedBody.php b/src/Io/BufferedBody.php new file mode 100644 index 00000000..9b1d9887 --- /dev/null +++ b/src/Io/BufferedBody.php @@ -0,0 +1,179 @@ +buffer = $buffer; + } + + public function __toString() + { + if ($this->closed) { + return ''; + } + + $this->seek(0); + + return $this->getContents(); + } + + public function close() + { + $this->buffer = ''; + $this->position = 0; + $this->closed = true; + } + + public function detach() + { + $this->close(); + + return null; + } + + public function getSize() + { + return $this->closed ? null : \strlen($this->buffer); + } + + public function tell() + { + if ($this->closed) { + throw new \RuntimeException('Unable to tell position of closed stream'); + } + + return $this->position; + } + + public function eof() + { + return $this->position >= \strlen($this->buffer); + } + + public function isSeekable() + { + return !$this->closed; + } + + public function seek($offset, $whence = \SEEK_SET) + { + if ($this->closed) { + throw new \RuntimeException('Unable to seek on closed stream'); + } + + $old = $this->position; + + if ($whence === \SEEK_SET) { + $this->position = $offset; + } elseif ($whence === \SEEK_CUR) { + $this->position += $offset; + } elseif ($whence === \SEEK_END) { + $this->position = \strlen($this->buffer) + $offset; + } else { + throw new \InvalidArgumentException('Invalid seek mode given'); + } + + if (!\is_int($this->position) || $this->position < 0) { + $this->position = $old; + throw new \RuntimeException('Unable to seek to position'); + } + } + + public function rewind() + { + $this->seek(0); + } + + public function isWritable() + { + return !$this->closed; + } + + public function write($string) + { + if ($this->closed) { + throw new \RuntimeException('Unable to write to closed stream'); + } + + if ($string === '') { + return 0; + } + + if ($this->position > 0 && !isset($this->buffer[$this->position - 1])) { + $this->buffer = \str_pad($this->buffer, $this->position, "\0"); + } + + $len = \strlen($string); + $this->buffer = \substr($this->buffer, 0, $this->position) . $string . \substr($this->buffer, $this->position + $len); + $this->position += $len; + + return $len; + } + + public function isReadable() + { + return !$this->closed; + } + + public function read($length) + { + if ($this->closed) { + throw new \RuntimeException('Unable to read from closed stream'); + } + + if ($length < 1) { + throw new \InvalidArgumentException('Invalid read length given'); + } + + if ($this->position + $length > \strlen($this->buffer)) { + $length = \strlen($this->buffer) - $this->position; + } + + if (!isset($this->buffer[$this->position])) { + return ''; + } + + $pos = $this->position; + $this->position += $length; + + return \substr($this->buffer, $pos, $length); + } + + public function getContents() + { + if ($this->closed) { + throw new \RuntimeException('Unable to read from closed stream'); + } + + if (!isset($this->buffer[$this->position])) { + return ''; + } + + $pos = $this->position; + $this->position = \strlen($this->buffer); + + return \substr($this->buffer, $pos); + } + + public function getMetadata($key = null) + { + return $key === null ? [] : null; + } +} diff --git a/src/Io/ChunkedDecoder.php b/src/Io/ChunkedDecoder.php index f7bbe603..996484db 100644 --- a/src/Io/ChunkedDecoder.php +++ b/src/Io/ChunkedDecoder.php @@ -31,10 +31,10 @@ public function __construct(ReadableStreamInterface $input) { $this->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')); + $this->input->on('data', [$this, 'handleData']); + $this->input->on('end', [$this, 'handleEnd']); + $this->input->on('error', [$this, 'handleError']); + $this->input->on('close', [$this, 'close']); } public function isReadable() @@ -52,7 +52,7 @@ public function resume() $this->input->resume(); } - public function pipe(WritableStreamInterface $dest, array $options = array()) + public function pipe(WritableStreamInterface $dest, array $options = []) { Util::pipe($this, $dest, $options); @@ -86,7 +86,7 @@ public function handleEnd() /** @internal */ public function handleError(Exception $e) { - $this->emit('error', array($e)); + $this->emit('error', [$e]); $this->close(); } @@ -116,14 +116,14 @@ public function handleData($data) } if ($hexValue !== '') { - $hexValue = \ltrim($hexValue, "0"); + $hexValue = \ltrim(\trim($hexValue), "0"); if ($hexValue === '') { $hexValue = "0"; } } $this->chunkSize = @\hexdec($hexValue); - if (\dechex($this->chunkSize) !== $hexValue) { + if (!\is_int($this->chunkSize) || \dechex($this->chunkSize) !== $hexValue) { $this->handleError(new Exception($hexValue . ' is not a valid hexadecimal number')); return; } @@ -139,7 +139,7 @@ public function handleData($data) if ($chunk !== '') { $this->transferredSize += \strlen($chunk); - $this->emit('data', array($chunk)); + $this->emit('data', [$chunk]); $this->buffer = (string)\substr($this->buffer, \strlen($chunk)); } @@ -155,16 +155,19 @@ public function handleData($data) $this->headerCompleted = false; $this->transferredSize = 0; $this->buffer = (string)\substr($this->buffer, 2); + } elseif ($this->chunkSize === 0) { + // end chunk received, skip all trailer data + $this->buffer = (string)\substr($this->buffer, $positionCrlf); } - 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')); + if ($positionCrlf !== 0 && $this->chunkSize !== 0 && $this->chunkSize === $this->transferredSize && \strlen($this->buffer) > 2) { + // the first 2 characters are not CRLF, send error event + $this->handleError(new Exception('Chunk does not end with a CRLF')); return; } if ($positionCrlf !== 0 && \strlen($this->buffer) < 2) { - // No CLRF found, wait for additional data which could be a CLRF + // No CRLF found, wait for additional data which could be a CRLF return; } } diff --git a/src/Io/ChunkedEncoder.php b/src/Io/ChunkedEncoder.php index d4e53b91..0bfe34f8 100644 --- a/src/Io/ChunkedEncoder.php +++ b/src/Io/ChunkedEncoder.php @@ -17,16 +17,16 @@ class ChunkedEncoder extends EventEmitter implements ReadableStreamInterface { private $input; - private $closed; + private $closed = false; public function __construct(ReadableStreamInterface $input) { $this->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')); + $this->input->on('data', [$this, 'handleData']); + $this->input->on('end', [$this, 'handleEnd']); + $this->input->on('error', [$this, 'handleError']); + $this->input->on('close', [$this, 'close']); } public function isReadable() @@ -44,11 +44,9 @@ public function resume() $this->input->resume(); } - public function pipe(WritableStreamInterface $dest, array $options = array()) + public function pipe(WritableStreamInterface $dest, array $options = []) { - Util::pipe($this, $dest, $options); - - return $dest; + return Util::pipe($this, $dest, $options); } public function close() @@ -67,44 +65,28 @@ public function close() /** @internal */ public function handleData($data) { - if ($data === '') { - return; + if ($data !== '') { + $this->emit('data', [ + \dechex(\strlen($data)) . "\r\n" . $data . "\r\n" + ]); } - - $completeChunk = $this->createChunk($data); - - $this->emit('data', array($completeChunk)); } /** @internal */ public function handleError(\Exception $e) { - $this->emit('error', array($e)); + $this->emit('error', [$e]); $this->close(); } /** @internal */ public function handleEnd() { - $this->emit('data', array("0\r\n\r\n")); + $this->emit('data', ["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 = \dechex(\strlen($data)); - $chunkBeginning = $byteSize . "\r\n"; - - return $chunkBeginning . $data . "\r\n"; - } - } diff --git a/src/Io/ClientConnectionManager.php b/src/Io/ClientConnectionManager.php new file mode 100644 index 00000000..794c0340 --- /dev/null +++ b/src/Io/ClientConnectionManager.php @@ -0,0 +1,134 @@ +connector = $connector; + $this->loop = $loop; + } + + /** + * @return PromiseInterface + */ + public function connect(UriInterface $uri) + { + $scheme = $uri->getScheme(); + if ($scheme !== 'https' && $scheme !== 'http') { + return reject(new \InvalidArgumentException( + 'Invalid request URL given' + )); + } + + $port = $uri->getPort(); + if ($port === null) { + $port = $scheme === 'https' ? 443 : 80; + } + $uri = ($scheme === 'https' ? 'tls://' : '') . $uri->getHost() . ':' . $port; + + // Reuse idle connection for same URI if available + foreach ($this->idleConnections as $id => $connection) { + if ($this->idleUris[$id] === $uri) { + assert($this->idleStreamHandlers[$id] instanceof \Closure); + $connection->removeListener('close', $this->idleStreamHandlers[$id]); + $connection->removeListener('data', $this->idleStreamHandlers[$id]); + $connection->removeListener('error', $this->idleStreamHandlers[$id]); + + assert($this->idleTimers[$id] instanceof TimerInterface); + $this->loop->cancelTimer($this->idleTimers[$id]); + unset($this->idleUris[$id], $this->idleConnections[$id], $this->idleTimers[$id], $this->idleStreamHandlers[$id]); + + return resolve($connection); + } + } + + // Create new connection if no idle connection to same URI is available + return $this->connector->connect($uri); + } + + /** + * Hands back an idle connection to the connection manager for possible future reuse. + * + * @return void + */ + public function keepAlive(UriInterface $uri, ConnectionInterface $connection) + { + $scheme = $uri->getScheme(); + assert($scheme === 'https' || $scheme === 'http'); + + $port = $uri->getPort(); + if ($port === null) { + $port = $scheme === 'https' ? 443 : 80; + } + + $this->idleUris[] = ($scheme === 'https' ? 'tls://' : '') . $uri->getHost() . ':' . $port; + $this->idleConnections[] = $connection; + + $cleanUp = function () use ($connection) { + $this->cleanUpConnection($connection); + }; + + // clean up and close connection when maximum time to keep-alive idle connection has passed + $this->idleTimers[] = $this->loop->addTimer($this->maximumTimeToKeepAliveIdleConnection, $cleanUp); + + // clean up and close connection when unexpected close/data/error event happens during idle time + $this->idleStreamHandlers[] = $cleanUp; + $connection->on('close', $cleanUp); + $connection->on('data', $cleanUp); + $connection->on('error', $cleanUp); + } + + /** @return void */ + private function cleanUpConnection(ConnectionInterface $connection) + { + $id = \array_search($connection, $this->idleConnections, true); + if ($id === false) { + return; + } + + assert(\is_int($id)); + assert($this->idleTimers[$id] instanceof TimerInterface); + $this->loop->cancelTimer($this->idleTimers[$id]); + unset($this->idleUris[$id], $this->idleConnections[$id], $this->idleTimers[$id], $this->idleStreamHandlers[$id]); + + $connection->close(); + } +} diff --git a/src/Io/ClientRequestState.php b/src/Io/ClientRequestState.php new file mode 100644 index 00000000..73a63a14 --- /dev/null +++ b/src/Io/ClientRequestState.php @@ -0,0 +1,16 @@ +connectionManager = $connectionManager; + $this->request = $request; + } + + public function isWritable() + { + return self::STATE_END > $this->state && !$this->ended; + } + + private function writeHead() + { + $this->state = self::STATE_WRITING_HEAD; + + $expected = 0; + $headers = "{$this->request->getMethod()} {$this->request->getRequestTarget()} HTTP/{$this->request->getProtocolVersion()}\r\n"; + foreach ($this->request->getHeaders() as $name => $values) { + if (\strpos($name, ':') !== false) { + $expected = -1; + break; + } + foreach ($values as $value) { + $headers .= "$name: $value\r\n"; + ++$expected; + } + } + + if (!\preg_match('#^\S+ \S+ HTTP/1\.[01]\r\n#m', $headers) || \substr_count($headers, "\n") !== ($expected + 1) || \preg_match_all(AbstractMessage::REGEX_HEADERS, $headers) !== $expected) { + $this->closeError(new \InvalidArgumentException('Unable to send request with invalid request headers')); + return; + } + + $promise = $this->connectionManager->connect($this->request->getUri()); + $promise->then( + function (ConnectionInterface $connection) use ($headers) { + $this->connection = $connection; + + $connection->on('drain', [$this, 'handleDrain']); + $connection->on('data', [$this, 'handleData']); + $connection->on('end', [$this, 'handleEnd']); + $connection->on('error', [$this, 'handleError']); + $connection->on('close', [$this, 'close']); + + $more = $connection->write($headers . "\r\n" . $this->pendingWrites); + + assert($this->state === ClientRequestStream::STATE_WRITING_HEAD); + $this->state = ClientRequestStream::STATE_HEAD_WRITTEN; + + // clear pending writes if non-empty + if ($this->pendingWrites !== '') { + $this->pendingWrites = ''; + + if ($more) { + $this->emit('drain'); + } + } + }, + [$this, 'closeError'] + ); + + $this->on('close', function() use ($promise) { + $promise->cancel(); + }); + } + + public function write($data) + { + if (!$this->isWritable()) { + return false; + } + + // write directly to connection stream if already available + if (self::STATE_HEAD_WRITTEN <= $this->state) { + return $this->connection->write($data); + } + + // otherwise buffer and try to establish connection + $this->pendingWrites .= $data; + if (self::STATE_WRITING_HEAD > $this->state) { + $this->writeHead(); + } + + return false; + } + + public function end($data = null) + { + if (!$this->isWritable()) { + return; + } + + if (null !== $data) { + $this->write($data); + } else if (self::STATE_WRITING_HEAD > $this->state) { + $this->writeHead(); + } + + $this->ended = true; + } + + /** @internal */ + public function handleDrain() + { + $this->emit('drain'); + } + + /** @internal */ + public function handleData($data) + { + $this->buffer .= $data; + + // buffer until double CRLF (or double LF for compatibility with legacy servers) + $eom = \strpos($this->buffer, "\r\n\r\n"); + $eomLegacy = \strpos($this->buffer, "\n\n"); + if ($eom !== false || $eomLegacy !== false) { + try { + if ($eom !== false && ($eomLegacy === false || $eom < $eomLegacy)) { + $response = Response::parseMessage(\substr($this->buffer, 0, $eom + 2)); + $bodyChunk = (string) \substr($this->buffer, $eom + 4); + } else { + $response = Response::parseMessage(\substr($this->buffer, 0, $eomLegacy + 1)); + $bodyChunk = (string) \substr($this->buffer, $eomLegacy + 2); + } + } catch (\InvalidArgumentException $exception) { + $this->closeError($exception); + return; + } + + // response headers successfully received => remove listeners for connection events + $connection = $this->connection; + assert($connection instanceof ConnectionInterface); + $connection->removeListener('drain', [$this, 'handleDrain']); + $connection->removeListener('data', [$this, 'handleData']); + $connection->removeListener('end', [$this, 'handleEnd']); + $connection->removeListener('error', [$this, 'handleError']); + $connection->removeListener('close', [$this, 'close']); + $this->connection = null; + $this->buffer = ''; + + // take control over connection handling and check if we can reuse the connection once response body closes + $successfulEndReceived = false; + $input = $body = new CloseProtectionStream($connection); + $input->on('close', function () use ($connection, $response, &$successfulEndReceived) { + // only reuse connection after successful response and both request and response allow keep alive + if ($successfulEndReceived && $connection->isReadable() && $this->hasMessageKeepAliveEnabled($response) && $this->hasMessageKeepAliveEnabled($this->request)) { + $this->connectionManager->keepAlive($this->request->getUri(), $connection); + } else { + $connection->close(); + } + + $this->close(); + }); + + // determine length of response body + $length = null; + $code = $response->getStatusCode(); + if ($this->request->getMethod() === 'HEAD' || ($code >= 100 && $code < 200) || $code == Response::STATUS_NO_CONTENT || $code == Response::STATUS_NOT_MODIFIED) { + $length = 0; + } elseif (\strtolower($response->getHeaderLine('Transfer-Encoding')) === 'chunked') { + $body = new ChunkedDecoder($body); + } elseif ($response->hasHeader('Content-Length')) { + $length = (int) $response->getHeaderLine('Content-Length'); + } + $response = $response->withBody($body = new ReadableBodyStream($body, $length)); + $body->on('end', function () use (&$successfulEndReceived) { + $successfulEndReceived = true; + }); + + // emit response with streaming response body (see `Sender`) + $this->emit('response', [$response, $body]); + + // re-emit HTTP response body to trigger body parsing if parts of it are buffered + if ($bodyChunk !== '') { + $input->handleData($bodyChunk); + } elseif ($length === 0) { + $input->handleEnd(); + } + } + } + + /** @internal */ + public function handleEnd() + { + $this->closeError(new \RuntimeException( + "Connection ended before receiving response" + )); + } + + /** @internal */ + public function handleError(\Exception $error) + { + $this->closeError(new \RuntimeException( + "An error occurred in the underlying stream", + 0, + $error + )); + } + + /** @internal */ + public function closeError(\Exception $error) + { + if (self::STATE_END <= $this->state) { + return; + } + $this->emit('error', [$error]); + $this->close(); + } + + public function close() + { + if (self::STATE_END <= $this->state) { + return; + } + + $this->state = self::STATE_END; + $this->pendingWrites = ''; + $this->buffer = ''; + + if ($this->connection instanceof ConnectionInterface) { + $this->connection->close(); + $this->connection = null; + } + + $this->emit('close'); + $this->removeAllListeners(); + } + + /** + * @internal + * @return bool + * @link https://www.rfc-editor.org/rfc/rfc9112#section-9.3 + * @link https://www.rfc-editor.org/rfc/rfc7230#section-6.1 + */ + public function hasMessageKeepAliveEnabled(MessageInterface $message) + { + // @link https://www.rfc-editor.org/rfc/rfc9110#section-7.6.1 + $connectionOptions = \array_map('trim', \explode(',', \strtolower($message->getHeaderLine('Connection')))); + + if (\in_array('close', $connectionOptions, true)) { + return false; + } + + if ($message->getProtocolVersion() === '1.1') { + return true; + } + + if (\in_array('keep-alive', $connectionOptions, true)) { + return true; + } + + return false; + } +} diff --git a/src/Io/Clock.php b/src/Io/Clock.php new file mode 100644 index 00000000..c2445a94 --- /dev/null +++ b/src/Io/Clock.php @@ -0,0 +1,53 @@ +loop = $loop; + } + + /** @return float */ + public function now() + { + if ($this->now === null) { + $this->now = \microtime(true); + + // remember clock for current loop tick only and update on next tick + $this->loop->futureTick(function () { + assert($this->now !== null); + $this->now = null; + }); + } + + return $this->now; + } +} diff --git a/src/Io/CloseProtectionStream.php b/src/Io/CloseProtectionStream.php index 2e1ed6e4..7fae08e7 100644 --- a/src/Io/CloseProtectionStream.php +++ b/src/Io/CloseProtectionStream.php @@ -28,10 +28,10 @@ public function __construct(ReadableStreamInterface $input) { $this->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')); + $this->input->on('data', [$this, 'handleData']); + $this->input->on('end', [$this, 'handleEnd']); + $this->input->on('error', [$this, 'handleError']); + $this->input->on('close', [$this, 'close']); } public function isReadable() @@ -59,7 +59,7 @@ public function resume() $this->input->resume(); } - public function pipe(WritableStreamInterface $dest, array $options = array()) + public function pipe(WritableStreamInterface $dest, array $options = []) { Util::pipe($this, $dest, $options); @@ -75,10 +75,10 @@ public function close() $this->closed = true; // stop listening for incoming events - $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->input->removeListener('data', [$this, 'handleData']); + $this->input->removeListener('error', [$this, 'handleError']); + $this->input->removeListener('end', [$this, 'handleEnd']); + $this->input->removeListener('close', [$this, 'close']); // resume the stream to ensure we discard everything from incoming connection if ($this->paused) { @@ -93,7 +93,7 @@ public function close() /** @internal */ public function handleData($data) { - $this->emit('data', array($data)); + $this->emit('data', [$data]); } /** @internal */ @@ -106,6 +106,6 @@ public function handleEnd() /** @internal */ public function handleError(\Exception $e) { - $this->emit('error', array($e)); + $this->emit('error', [$e]); } } diff --git a/src/Io/EmptyBodyStream.php b/src/Io/EmptyBodyStream.php index 5056219c..7f9c8ad0 100644 --- a/src/Io/EmptyBodyStream.php +++ b/src/Io/EmptyBodyStream.php @@ -44,7 +44,7 @@ public function resume() // NOOP } - public function pipe(WritableStreamInterface $dest, array $options = array()) + public function pipe(WritableStreamInterface $dest, array $options = []) { Util::pipe($this, $dest, $options); @@ -137,6 +137,6 @@ public function getContents() /** @ignore */ public function getMetadata($key = null) { - return ($key === null) ? array() : null; + return ($key === null) ? [] : null; } } diff --git a/src/Io/HttpBodyStream.php b/src/Io/HttpBodyStream.php index 25d15a18..8be9b854 100644 --- a/src/Io/HttpBodyStream.php +++ b/src/Io/HttpBodyStream.php @@ -39,10 +39,10 @@ public function __construct(ReadableStreamInterface $input, $size) $this->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')); + $this->input->on('data', [$this, 'handleData']); + $this->input->on('end', [$this, 'handleEnd']); + $this->input->on('error', [$this, 'handleError']); + $this->input->on('close', [$this, 'close']); } public function isReadable() @@ -60,7 +60,7 @@ public function resume() $this->input->resume(); } - public function pipe(WritableStreamInterface $dest, array $options = array()) + public function pipe(WritableStreamInterface $dest, array $options = []) { Util::pipe($this, $dest, $options); @@ -161,13 +161,13 @@ public function getMetadata($key = null) /** @internal */ public function handleData($data) { - $this->emit('data', array($data)); + $this->emit('data', [$data]); } /** @internal */ public function handleError(\Exception $e) { - $this->emit('error', array($e)); + $this->emit('error', [$e]); $this->close(); } diff --git a/src/Io/LengthLimitedStream.php b/src/Io/LengthLimitedStream.php index bc64c54b..c4a38b13 100644 --- a/src/Io/LengthLimitedStream.php +++ b/src/Io/LengthLimitedStream.php @@ -27,10 +27,10 @@ public function __construct(ReadableStreamInterface $stream, $maxLength) $this->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')); + $this->stream->on('data', [$this, 'handleData']); + $this->stream->on('end', [$this, 'handleEnd']); + $this->stream->on('error', [$this, 'handleError']); + $this->stream->on('close', [$this, 'close']); } public function isReadable() @@ -48,7 +48,7 @@ public function resume() $this->stream->resume(); } - public function pipe(WritableStreamInterface $dest, array $options = array()) + public function pipe(WritableStreamInterface $dest, array $options = []) { Util::pipe($this, $dest, $options); @@ -79,21 +79,21 @@ public function handleData($data) if ($data !== '') { $this->transferredLength += \strlen($data); - $this->emit('data', array($data)); + $this->emit('data', [$data]); } if ($this->transferredLength === $this->maxLength) { // 'Content-Length' reached, stream will end $this->emit('end'); $this->close(); - $this->stream->removeListener('data', array($this, 'handleData')); + $this->stream->removeListener('data', [$this, 'handleData']); } } /** @internal */ public function handleError(\Exception $e) { - $this->emit('error', array($e)); + $this->emit('error', [$e]); $this->close(); } diff --git a/src/Io/MiddlewareRunner.php b/src/Io/MiddlewareRunner.php index dedf6ff1..c05c5a1a 100644 --- a/src/Io/MiddlewareRunner.php +++ b/src/Io/MiddlewareRunner.php @@ -40,8 +40,7 @@ public function __invoke(ServerRequestInterface $request) return $this->call($request, 0); } - /** @internal */ - public function call(ServerRequestInterface $request, $position) + private function call(ServerRequestInterface $request, $position) { // final request handler will be invoked without a next handler if (!isset($this->middleware[$position + 1])) { @@ -49,9 +48,8 @@ public function call(ServerRequestInterface $request, $position) return $handler($request); } - $that = $this; - $next = function (ServerRequestInterface $request) use ($that, $position) { - return $that->call($request, $position + 1); + $next = function (ServerRequestInterface $request) use ($position) { + return $this->call($request, $position + 1); }; // invoke middleware request handler with next handler diff --git a/src/Io/MultipartParser.php b/src/Io/MultipartParser.php index 8749b6c5..cdfe189b 100644 --- a/src/Io/MultipartParser.php +++ b/src/Io/MultipartParser.php @@ -3,7 +3,6 @@ namespace React\Http\Io; use Psr\Http\Message\ServerRequestInterface; -use RingCentral\Psr7; /** * [Internal] Parses a string body with "Content-Type: multipart/form-data" into structured data @@ -27,10 +26,17 @@ final class MultipartParser */ private $maxFileSize; + /** + * Based on $maxInputVars and $maxFileUploads + * + * @var int + */ + private $maxMultipartBodyParts; + /** * ini setting "max_input_vars" * - * Does not exist in PHP < 5.3.9 or HHVM, so assume PHP's default 1000 here. + * Assume PHP' default of 1000 here. * * @var int * @link http://php.net/manual/en/info.configuration.php#ini.max-input-vars @@ -40,7 +46,7 @@ final class MultipartParser /** * ini setting "max_input_nesting_level" * - * Does not exist in HHVM, but assumes hard coded to 64 (PHP's default). + * Assume PHP's default of 64 here. * * @var int * @link http://php.net/manual/en/info.configuration.php#ini.max-input-nesting-level @@ -63,9 +69,11 @@ final class MultipartParser */ private $maxFileUploads; + private $multipartBodyPartCount = 0; private $postCount = 0; private $filesCount = 0; private $emptyCount = 0; + private $cursor = 0; /** * @param int|string|null $uploadMaxFilesize @@ -73,14 +81,8 @@ final class MultipartParser */ public function __construct($uploadMaxFilesize = null, $maxFileUploads = null) { - $var = \ini_get('max_input_vars'); - if ($var !== false) { - $this->maxInputVars = (int)$var; - } - $var = \ini_get('max_input_nesting_level'); - if ($var !== false) { - $this->maxInputNestingLevel = (int)$var; - } + $this->maxInputVars = (int) \ini_get('max_input_vars'); + $this->maxInputNestingLevel = (int) \ini_get('max_input_nesting_level'); if ($uploadMaxFilesize === null) { $uploadMaxFilesize = \ini_get('upload_max_filesize'); @@ -88,12 +90,14 @@ public function __construct($uploadMaxFilesize = null, $maxFileUploads = null) $this->uploadMaxFilesize = IniUtil::iniSizeToBytes($uploadMaxFilesize); $this->maxFileUploads = $maxFileUploads === null ? (\ini_get('file_uploads') === '' ? 0 : (int)\ini_get('max_file_uploads')) : (int)$maxFileUploads; + + $this->maxMultipartBodyParts = $this->maxInputVars + $this->maxFileUploads; } public function parse(ServerRequestInterface $request) { $contentType = $request->getHeaderLine('content-type'); - if(!\preg_match('/boundary="?(.*)"?$/', $contentType, $matches)) { + if(!\preg_match('/boundary="?(.*?)"?$/', $contentType, $matches)) { return $request; } @@ -102,6 +106,8 @@ public function parse(ServerRequestInterface $request) $request = $this->request; $this->request = null; + $this->multipartBodyPartCount = 0; + $this->cursor = 0; $this->postCount = 0; $this->filesCount = 0; $this->emptyCount = 0; @@ -115,20 +121,24 @@ private function parseBody($boundary, $buffer) $len = \strlen($boundary); // ignore everything before initial boundary (SHOULD be empty) - $start = \strpos($buffer, $boundary . "\r\n"); + $this->cursor = \strpos($buffer, $boundary . "\r\n"); - while ($start !== false) { + while ($this->cursor !== false) { // search following boundary (preceded by newline) // ignore last if not followed by boundary (SHOULD end with "--") - $start += $len + 2; - $end = \strpos($buffer, "\r\n" . $boundary, $start); + $this->cursor += $len + 2; + $end = \strpos($buffer, "\r\n" . $boundary, $this->cursor); if ($end === false) { break; } // parse one part and continue searching for next - $this->parsePart(\substr($buffer, $start, $end - $start)); - $start = $end; + $this->parsePart(\substr($buffer, $this->cursor, $end - $this->cursor)); + $this->cursor = $end; + + if (++$this->multipartBodyPartCount > $this->maxMultipartBodyParts) { + break; + } } } @@ -156,7 +166,7 @@ private function parsePart($chunk) $this->parseFile( $name, $filename, - isset($headers['content-type'][0]) ? $headers['content-type'][0] : null, + $headers['content-type'][0] ?? null, $body ); } else { @@ -190,7 +200,7 @@ private function parseUploadedFile($filename, $contentType, $contents) } return new UploadedFile( - Psr7\stream_for(), + new BufferedBody(''), $size, \UPLOAD_ERR_NO_FILE, $filename, @@ -206,7 +216,7 @@ private function parseUploadedFile($filename, $contentType, $contents) // file exceeds "upload_max_filesize" ini setting if ($size > $this->uploadMaxFilesize) { return new UploadedFile( - Psr7\stream_for(), + new BufferedBody(''), $size, \UPLOAD_ERR_INI_SIZE, $filename, @@ -217,7 +227,7 @@ private function parseUploadedFile($filename, $contentType, $contents) // file exceeds MAX_FILE_SIZE value if ($this->maxFileSize !== null && $size > $this->maxFileSize) { return new UploadedFile( - Psr7\stream_for(), + new BufferedBody(''), $size, \UPLOAD_ERR_FORM_SIZE, $filename, @@ -226,7 +236,7 @@ private function parseUploadedFile($filename, $contentType, $contents) } return new UploadedFile( - Psr7\stream_for($contents), + new BufferedBody($contents), $size, \UPLOAD_ERR_OK, $filename, @@ -258,7 +268,7 @@ private function parsePost($name, $value) private function parseHeaders($header) { - $headers = array(); + $headers = []; foreach (\explode("\r\n", \trim($header)) as $line) { $parts = \explode(':', $line, 2); @@ -278,7 +288,7 @@ private function parseHeaders($header) private function getParameterFromHeader(array $header, $parameter) { foreach ($header as $part) { - if (\preg_match('/' . $parameter . '="?(.*)"$/', $part, $matches)) { + if (\preg_match('/' . $parameter . '="?(.*?)"?$/', $part, $matches)) { return $matches[1]; } } @@ -305,12 +315,12 @@ private function extractPost($postFields, $key, $value) $previousChunkKey = $chunkKey; if ($previousChunkKey === '') { - $parent[] = array(); + $parent[] = []; \end($parent); $parent = &$parent[\key($parent)]; } else { if (!isset($parent[$previousChunkKey]) || !\is_array($parent[$previousChunkKey])) { - $parent[$previousChunkKey] = array(); + $parent[$previousChunkKey] = []; } $parent = &$parent[$previousChunkKey]; } diff --git a/src/Io/PauseBufferStream.php b/src/Io/PauseBufferStream.php index fb5ed456..b1132adc 100644 --- a/src/Io/PauseBufferStream.php +++ b/src/Io/PauseBufferStream.php @@ -36,10 +36,10 @@ public function __construct(ReadableStreamInterface $input) { $this->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, 'handleClose')); + $this->input->on('data', [$this, 'handleData']); + $this->input->on('end', [$this, 'handleEnd']); + $this->input->on('error', [$this, 'handleError']); + $this->input->on('close', [$this, 'handleClose']); } /** @@ -91,12 +91,12 @@ public function resume() $this->implicit = false; if ($this->dataPaused !== '') { - $this->emit('data', array($this->dataPaused)); + $this->emit('data', [$this->dataPaused]); $this->dataPaused = ''; } if ($this->errorPaused) { - $this->emit('error', array($this->errorPaused)); + $this->emit('error', [$this->errorPaused]); return $this->close(); } @@ -114,7 +114,7 @@ public function resume() $this->input->resume(); } - public function pipe(WritableStreamInterface $dest, array $options = array()) + public function pipe(WritableStreamInterface $dest, array $options = []) { Util::pipe($this, $dest, $options); @@ -146,7 +146,7 @@ public function handleData($data) return; } - $this->emit('data', array($data)); + $this->emit('data', [$data]); } /** @internal */ @@ -157,7 +157,7 @@ public function handleError(\Exception $e) return; } - $this->emit('error', array($e)); + $this->emit('error', [$e]); $this->close(); } diff --git a/src/Io/ReadableBodyStream.php b/src/Io/ReadableBodyStream.php new file mode 100644 index 00000000..9a8bd105 --- /dev/null +++ b/src/Io/ReadableBodyStream.php @@ -0,0 +1,151 @@ +input = $input; + $this->size = $size; + + $input->on('data', function ($data) use ($size) { + $this->emit('data', [$data]); + + $this->position += \strlen($data); + if ($size !== null && $this->position >= $size) { + $this->handleEnd(); + } + }); + $input->on('error', function ($error) { + $this->emit('error', [$error]); + $this->close(); + }); + $input->on('end', [$this, 'handleEnd']); + $input->on('close', [$this, 'close']); + } + + public function close() + { + if (!$this->closed) { + $this->closed = true; + $this->input->close(); + + $this->emit('close'); + $this->removeAllListeners(); + } + } + + public function isReadable() + { + return $this->input->isReadable(); + } + + public function pause() + { + $this->input->pause(); + } + + public function resume() + { + $this->input->resume(); + } + + public function pipe(WritableStreamInterface $dest, array $options = []) + { + Util::pipe($this, $dest, $options); + + return $dest; + } + + public function eof() + { + return !$this->isReadable(); + } + + public function __toString() + { + return ''; + } + + public function detach() + { + throw new \BadMethodCallException(); + } + + public function getSize() + { + return $this->size; + } + + public function tell() + { + throw new \BadMethodCallException(); + } + + public function isSeekable() + { + return false; + } + + public function seek($offset, $whence = SEEK_SET) + { + throw new \BadMethodCallException(); + } + + public function rewind() + { + throw new \BadMethodCallException(); + } + + public function isWritable() + { + return false; + } + + public function write($string) + { + throw new \BadMethodCallException(); + } + + public function read($length) + { + throw new \BadMethodCallException(); + } + + public function getContents() + { + throw new \BadMethodCallException(); + } + + public function getMetadata($key = null) + { + return ($key === null) ? [] : null; + } + + /** @internal */ + public function handleEnd() + { + if ($this->position !== $this->size && $this->size !== null) { + $this->emit('error', [new \UnderflowException('Unexpected end of response body after ' . $this->position . '/' . $this->size . ' bytes')]); + } else { + $this->emit('end'); + } + + $this->close(); + } +} diff --git a/src/Io/RequestHeaderParser.php b/src/Io/RequestHeaderParser.php index f7f77e7e..403ab0cc 100644 --- a/src/Io/RequestHeaderParser.php +++ b/src/Io/RequestHeaderParser.php @@ -4,6 +4,8 @@ use Evenement\EventEmitter; use Psr\Http\Message\ServerRequestInterface; +use React\Http\Message\Response; +use React\Http\Message\ServerRequest; use React\Socket\ConnectionInterface; use Exception; @@ -22,25 +24,34 @@ class RequestHeaderParser extends EventEmitter { private $maxSize = 8192; + /** @var Clock */ + private $clock; + + /** @var array> */ + private $connectionParams = []; + + public function __construct(Clock $clock) + { + $this->clock = $clock; + } + public function handle(ConnectionInterface $conn) { $buffer = ''; - $maxSize = $this->maxSize; - $that = $this; - $conn->on('data', $fn = function ($data) use (&$buffer, &$fn, $conn, $maxSize, $that) { + $conn->on('data', $fn = function ($data) use (&$buffer, &$fn, $conn) { // append chunk of data to buffer and look for end of request headers $buffer .= $data; $endOfHeader = \strpos($buffer, "\r\n\r\n"); // reject request if buffer size is exceeded - if ($endOfHeader > $maxSize || ($endOfHeader === false && isset($buffer[$maxSize]))) { + if ($endOfHeader > $this->maxSize || ($endOfHeader === false && isset($buffer[$this->maxSize]))) { $conn->removeListener('data', $fn); $fn = null; - $that->emit('error', array( - new \OverflowException("Maximum header size of {$maxSize} exceeded.", 431), + $this->emit('error', [ + new \OverflowException("Maximum header size of {$this->maxSize} exceeded.", Response::STATUS_REQUEST_HEADER_FIELDS_TOO_LARGE), $conn - )); + ]); return; } @@ -54,17 +65,16 @@ public function handle(ConnectionInterface $conn) $fn = null; try { - $request = $that->parseRequest( + $request = $this->parseRequest( (string)\substr($buffer, 0, $endOfHeader + 2), - $conn->getRemoteAddress(), - $conn->getLocalAddress() + $conn ); } catch (Exception $exception) { $buffer = ''; - $that->emit('error', array( + $this->emit('error', [ $exception, $conn - )); + ]); return; } @@ -93,10 +103,10 @@ public function handle(ConnectionInterface $conn) $bodyBuffer = isset($buffer[$endOfHeader + 4]) ? \substr($buffer, $endOfHeader + 4) : ''; $buffer = ''; - $that->emit('headers', array($request, $conn)); + $this->emit('headers', [$request, $conn]); if ($bodyBuffer !== '') { - $conn->emit('data', array($bodyBuffer)); + $conn->emit('data', [$bodyBuffer]); } // happy path: request body is known to be empty => immediately end stream @@ -105,173 +115,62 @@ public function handle(ConnectionInterface $conn) $stream->close(); } }); - - $conn->on('close', function () use (&$buffer, &$fn) { - $fn = $buffer = null; - }); } /** * @param string $headers buffer string containing request headers only - * @param ?string $remoteSocketUri - * @param ?string $localSocketUri + * @param ConnectionInterface $connection * @return ServerRequestInterface * @throws \InvalidArgumentException * @internal */ - public function parseRequest($headers, $remoteSocketUri, $localSocketUri) + public function parseRequest($headers, ConnectionInterface $connection) { - // additional, stricter safe-guard for request line - // because request parser doesn't properly cope with invalid ones - $start = array(); - if (!\preg_match('#^(?[^ ]+) (?[^ ]+) HTTP/(?\d\.\d)#m', $headers, $start)) { - throw new \InvalidArgumentException('Unable to parse invalid request-line'); - } - - // only support HTTP/1.1 and HTTP/1.0 requests - if ($start['version'] !== '1.1' && $start['version'] !== '1.0') { - throw new \InvalidArgumentException('Received request with invalid protocol version', 505); - } - - // match all request header fields into array, thanks to @kelunik for checking the HTTP specs and coming up with this regex - $matches = array(); - $n = \preg_match_all('/^([^()<>@,;:\\\"\/\[\]?={}\x01-\x20\x7F]++):[\x20\x09]*+((?:[\x20\x09]*+[\x21-\x7E\x80-\xFF]++)*+)[\x20\x09]*+[\r]?+\n/m', $headers, $matches, \PREG_SET_ORDER); - - // check number of valid header fields matches number of lines + request line - if (\substr_count($headers, "\n") !== $n + 1) { - throw new \InvalidArgumentException('Unable to parse invalid request header fields'); - } - - // format all header fields into associative array - $host = null; - $fields = array(); - foreach ($matches as $match) { - $fields[$match[1]][] = $match[2]; - - // match `Host` request header - if ($host === null && \strtolower($match[1]) === 'host') { - $host = $match[2]; - } - } - - // 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) - ); - - // scheme is `http` unless TLS is used - $localParts = \parse_url(http://github.com/$localSocketUri); - if (isset($localParts['scheme']) && $localParts['scheme'] === 'tls') { - $scheme = 'https://'; - $serverParams['HTTPS'] = 'on'; - } else { - $scheme = 'http://'; - } - - // default host if unset comes from local socket address or defaults to localhost - if ($host === null) { - $host = isset($localParts['host'], $localParts['port']) ? $localParts['host'] . ':' . $localParts['port'] : '127.0.0.1'; - } - - if ($start['method'] === 'OPTIONS' && $start['target'] === '*') { - // support asterisk-form for `OPTIONS *` request line only - $uri = $scheme . $host; - } elseif ($start['method'] === 'CONNECT') { - $parts = \parse_url('tcp://' . $start['target']); - - // check this is a valid authority-form request-target (host:port) - if (!isset($parts['scheme'], $parts['host'], $parts['port']) || \count($parts) !== 3) { - throw new \InvalidArgumentException('CONNECT method MUST use authority-form request target'); - } - $uri = $scheme . $start['target']; + // reuse same connection params for all server params for this connection + $cid = \PHP_VERSION_ID < 70200 ? \spl_object_hash($connection) : \spl_object_id($connection); + if (isset($this->connectionParams[$cid])) { + $serverParams = $this->connectionParams[$cid]; } else { - // support absolute-form or origin-form for proxy requests - if ($start['target'][0] === '/') { - $uri = $scheme . $host . $start['target']; - } else { - // ensure absolute-form request-target contains a valid URI - $parts = \parse_url(http://github.com/$start['target']); - - // 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'); - } - - $uri = $start['target']; + // assign new server params for new connection + $serverParams = []; + + // scheme is `http` unless TLS is used + $localSocketUri = $connection->getLocalAddress(); + $localParts = $localSocketUri === null ? [] : \parse_url(http://github.com/$localSocketUri); + if (isset($localParts['scheme']) && $localParts['scheme'] === 'tls') { + $serverParams['HTTPS'] = 'on'; } - } - - // apply REMOTE_ADDR and REMOTE_PORT if source address is known - // address should always be known, unless this is over Unix domain sockets (UDS) - if ($remoteSocketUri !== null) { - $remoteAddress = \parse_url(http://github.com/$remoteSocketUri); - $serverParams['REMOTE_ADDR'] = $remoteAddress['host']; - $serverParams['REMOTE_PORT'] = $remoteAddress['port']; - } - - // apply SERVER_ADDR and SERVER_PORT if server address is known - // address should always be known, even for Unix domain sockets (UDS) - // but skip UDS as it doesn't have a concept of host/port. - if ($localSocketUri !== null && isset($localParts['host'], $localParts['port'])) { - $serverParams['SERVER_ADDR'] = $localParts['host']; - $serverParams['SERVER_PORT'] = $localParts['port']; - } - $request = new ServerRequest( - $start['method'], - $uri, - $fields, - null, - $start['version'], - $serverParams - ); - - // only assign request target if it is not in origin-form (happy path for most normal requests) - if ($start['target'][0] !== '/') { - $request = $request->withRequestTarget($start['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'); - } - } - - // ensure message boundaries are valid according to Content-Length and Transfer-Encoding request headers - if ($request->hasHeader('Transfer-Encoding')) { - if (\strtolower($request->getHeaderLine('Transfer-Encoding')) !== 'chunked') { - throw new \InvalidArgumentException('Only chunked-encoding is allowed for Transfer-Encoding', 501); + // apply SERVER_ADDR and SERVER_PORT if server address is known + // address should always be known, even for Unix domain sockets (UDS) + // but skip UDS as it doesn't have a concept of host/port. + if ($localSocketUri !== null && isset($localParts['host'], $localParts['port'])) { + $serverParams['SERVER_ADDR'] = $localParts['host']; + $serverParams['SERVER_PORT'] = $localParts['port']; } - // Transfer-Encoding: chunked and Content-Length header MUST NOT be used at the same time - // as per https://tools.ietf.org/html/rfc7230#section-3.3.3 - if ($request->hasHeader('Content-Length')) { - throw new \InvalidArgumentException('Using both `Transfer-Encoding: chunked` and `Content-Length` is not allowed', 400); + // apply REMOTE_ADDR and REMOTE_PORT if source address is known + // address should always be known, unless this is over Unix domain sockets (UDS) + $remoteSocketUri = $connection->getRemoteAddress(); + if ($remoteSocketUri !== null) { + $remoteAddress = \parse_url(http://github.com/$remoteSocketUri); + $serverParams['REMOTE_ADDR'] = $remoteAddress['host']; + $serverParams['REMOTE_PORT'] = $remoteAddress['port']; } - } elseif ($request->hasHeader('Content-Length')) { - $string = $request->getHeaderLine('Content-Length'); - if ((string)(int)$string !== $string) { - // Content-Length value is not an integer or not a single integer - throw new \InvalidArgumentException('The value of `Content-Length` is not valid', 400); - } + // remember server params for all requests from this connection, reset on connection close + $this->connectionParams[$cid] = $serverParams; + $connection->on('close', function () use ($cid) { + assert(\is_array($this->connectionParams[$cid])); + unset($this->connectionParams[$cid]); + }); } - // always sanitize Host header because it contains critical routing information - $request = $request->withUri($request->getUri()->withUserInfo('u')->withUserInfo('')); + // create new obj implementing ServerRequestInterface by preserving all + // previous properties and restoring original request-target + $serverParams['REQUEST_TIME'] = (int) ($now = $this->clock->now()); + $serverParams['REQUEST_TIME_FLOAT'] = $now; - return $request; + return ServerRequest::parseMessage($headers, $serverParams); } } diff --git a/src/Io/Sender.php b/src/Io/Sender.php new file mode 100644 index 00000000..ccb1e1da --- /dev/null +++ b/src/Io/Sender.php @@ -0,0 +1,152 @@ +http = $http; + } + + /** + * + * @internal + * @param RequestInterface $request + * @return PromiseInterface Promise + */ + public function send(RequestInterface $request) + { + // support HTTP/1.1 and HTTP/1.0 only, ensured by `Browser` already + assert(\in_array($request->getProtocolVersion(), ['1.0', '1.1'], true)); + + $body = $request->getBody(); + $size = $body->getSize(); + + if ($size !== null && $size !== 0) { + // automatically assign a "Content-Length" request header if the body size is known and non-empty + $request = $request->withHeader('Content-Length', (string)$size); + } elseif ($size === 0 && \in_array($request->getMethod(), ['POST', 'PUT', 'PATCH'])) { + // only assign a "Content-Length: 0" request header if the body is expected for certain methods + $request = $request->withHeader('Content-Length', '0'); + } elseif ($body instanceof ReadableStreamInterface && $size !== 0 && $body->isReadable() && !$request->hasHeader('Content-Length')) { + // use "Transfer-Encoding: chunked" when this is a streaming body and body size is unknown + $request = $request->withHeader('Transfer-Encoding', 'chunked'); + } else { + // do not use chunked encoding if size is known or if this is an empty request body + $size = 0; + } + + // automatically add `Authorization: Basic …` request header if URL includes `user:pass@host` + if ($request->getUri()->getUserInfo() !== '' && !$request->hasHeader('Authorization')) { + $request = $request->withHeader('Authorization', 'Basic ' . \base64_encode($request->getUri()->getUserInfo())); + } + + $requestStream = $this->http->request($request); + + $deferred = new Deferred(function ($_, $reject) use ($requestStream) { + // close request stream if request is cancelled + $reject(new \RuntimeException('Request cancelled')); + $requestStream->close(); + }); + + $requestStream->on('error', function($error) use ($deferred) { + $deferred->reject($error); + }); + + $requestStream->on('response', function (ResponseInterface $response) use ($deferred, $request) { + $deferred->resolve($response); + }); + + if ($body instanceof ReadableStreamInterface) { + if ($body->isReadable()) { + // length unknown => apply chunked transfer-encoding + if ($size === null) { + $body = new ChunkedEncoder($body); + } + + // pipe body into request stream + // add dummy write to immediately start request even if body does not emit any data yet + $body->pipe($requestStream); + $requestStream->write(''); + + $body->on('close', $close = function () use ($deferred, $requestStream) { + $deferred->reject(new \RuntimeException('Request failed because request body closed unexpectedly')); + $requestStream->close(); + }); + $body->on('error', function ($e) use ($deferred, $requestStream, $close, $body) { + $body->removeListener('close', $close); + $deferred->reject(new \RuntimeException('Request failed because request body reported an error', 0, $e)); + $requestStream->close(); + }); + $body->on('end', function () use ($close, $body) { + $body->removeListener('close', $close); + }); + } else { + // stream is not readable => end request without body + $requestStream->end(); + } + } else { + // body is fully buffered => write as one chunk + $requestStream->end((string)$body); + } + + return $deferred->promise(); + } +} diff --git a/src/Io/ServerRequest.php b/src/Io/ServerRequest.php deleted file mode 100644 index 28a8c5db..00000000 --- a/src/Io/ServerRequest.php +++ /dev/null @@ -1,175 +0,0 @@ -serverParams = $serverParams; - parent::__construct($method, $uri, $headers, $body, $protocolVersion); - - $query = $this->getUri()->getQuery(); - if ($query !== '') { - \parse_str($query, $this->queryParams); - } - - // Multiple cookie headers are not allowed according - // to https://tools.ietf.org/html/rfc6265#section-5.4 - $cookieHeaders = $this->getHeader("Cookie"); - - if (count($cookieHeaders) === 1) { - $this->cookies = $this->parseCookie($cookieHeaders[0]); - } - } - - 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; - } - - /** - * @param string $cookie - * @return array - */ - private function parseCookie($cookie) - { - if ($cookie === '') { - return array(); - } - - $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/StreamingServer.php b/src/Io/StreamingServer.php similarity index 62% rename from src/StreamingServer.php rename to src/Io/StreamingServer.php index 3826dbc3..6d12d359 100644 --- a/src/StreamingServer.php +++ b/src/Io/StreamingServer.php @@ -1,31 +1,26 @@ 'text/plain' - * ), + * ], * "Hello World!\n" * ); * }); @@ -55,20 +50,20 @@ * In order to process any connections, the server needs to be attached to an * instance of `React\Socket\ServerInterface` through the [`listen()`](#listen) method * as described in the following chapter. In its most simple form, you can attach - * this to a [`React\Socket\Server`](https://github.com/reactphp/socket#server) + * this to a [`React\Socket\SocketServer`](https://github.com/reactphp/socket#socketserver) * in order to start a plaintext HTTP server like this: * * ```php - * $server = new StreamingServer($handler); + * $server = new StreamingServer($loop, $handler); * - * $socket = new React\Socket\Server('0.0.0.0:8080', $loop); + * $socket = new React\Socket\SocketServer('0.0.0.0:8080', [], $loop); * $server->listen($socket); * ``` * * See also the [`listen()`](#listen) method and the [first example](examples) for more details. * * The `StreamingServer` class is considered advanced usage and unless you know - * what you're doing, you're recommended to use the [`Server`](#server) class + * what you're doing, you're recommended to use the [`HttpServer`](#httpserver) class * instead. The `StreamingServer` class is specifically designed to help with * more advanced use cases where you want to have full control over consuming * the incoming HTTP request body and concurrency settings. @@ -80,15 +75,19 @@ * handler function may not be fully compatible with PSR-7. See also * [streaming request](#streaming-request) below for more details. * - * @see Request - * @see Response + * @see \React\Http\HttpServer + * @see \React\Http\Message\Response * @see self::listen() + * @internal */ final class StreamingServer extends EventEmitter { private $callback; private $parser; + /** @var Clock */ + private $clock; + /** * Creates an HTTP server that invokes the given callback for each incoming HTTP request * @@ -97,32 +96,31 @@ final class StreamingServer extends EventEmitter * connections in order to then parse incoming data as HTTP. * See also [listen()](#listen) for more details. * - * @param callable|callable[] $requestHandler + * @param LoopInterface $loop + * @param callable $requestHandler * @see self::listen() */ - public function __construct($requestHandler) + public function __construct(LoopInterface $loop, $requestHandler) { - if (!\is_callable($requestHandler) && !\is_array($requestHandler)) { + if (!\is_callable($requestHandler)) { throw new \InvalidArgumentException('Invalid request handler given'); - } elseif (!\is_callable($requestHandler)) { - $requestHandler = new MiddlewareRunner($requestHandler); } $this->callback = $requestHandler; - $this->parser = new RequestHeaderParser(); + $this->clock = new Clock($loop); + $this->parser = new RequestHeaderParser($this->clock); - $that = $this; - $this->parser->on('headers', function (ServerRequestInterface $request, ConnectionInterface $conn) use ($that) { - $that->handleRequest($conn, $request); + $this->parser->on('headers', function (ServerRequestInterface $request, ConnectionInterface $conn) { + $this->handleRequest($conn, $request); }); - $this->parser->on('error', function(\Exception $e, ConnectionInterface $conn) use ($that) { - $that->emit('error', array($e)); + $this->parser->on('error', function(\Exception $e, ConnectionInterface $conn) { + $this->emit('error', [$e]); // parsing failed => assume dummy request and send appropriate error - $that->writeError( + $this->writeError( $conn, - $e->getCode() !== 0 ? $e->getCode() : 400, + $e->getCode() !== 0 ? $e->getCode() : Response::STATUS_BAD_REQUEST, new ServerRequest('GET', '/') ); }); @@ -131,48 +129,12 @@ public function __construct($requestHandler) /** * Starts listening for HTTP requests on the given socket server instance * - * 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. - * 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. - * - * 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 StreamingServer($handler); - * - * $socket = new React\Socket\Server(8080, $loop); - * $server->listen($socket); - * ``` - * - * See also [example #1](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 StreamingServer($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. - * * @param ServerInterface $socket + * @see \React\Http\HttpServer::listen() */ public function listen(ServerInterface $socket) { - $socket->on('connection', array($this->parser, 'handle')); + $socket->on('connection', [$this->parser, 'handle']); } /** @internal */ @@ -183,22 +145,25 @@ public function handleRequest(ConnectionInterface $conn, ServerRequestInterface } // execute request handler callback - $callback = $this->callback; try { - $response = $callback($request); - } catch (\Exception $error) { + $response = ($this->callback)($request); + } catch (\Throwable $error) { // request handler callback throws an Exception - $response = Promise\reject($error); - } catch (\Throwable $error) { // @codeCoverageIgnoreStart - // request handler callback throws a PHP7+ Error - $response = Promise\reject($error); // @codeCoverageIgnoreEnd + $response = reject($error); } // cancel pending promise once connection closes - if ($response instanceof CancellablePromiseInterface) { - $conn->on('close', function () use ($response) { + $connectionOnCloseResponseCancelerHandler = function () {}; + if ($response instanceof PromiseInterface && \method_exists($response, 'cancel')) { + $connectionOnCloseResponseCanceler = function () use ($response) { $response->cancel(); - }); + }; + $connectionOnCloseResponseCancelerHandler = function () use ($connectionOnCloseResponseCanceler, $conn) { + if ($connectionOnCloseResponseCanceler !== null) { + $conn->removeListener('close', $connectionOnCloseResponseCanceler); + } + }; + $conn->on('close', $connectionOnCloseResponseCanceler); } // happy path: response returned, handle and return immediately @@ -208,23 +173,22 @@ public function handleRequest(ConnectionInterface $conn, ServerRequestInterface // did not return a promise? this is an error, convert into one for rejection below. if (!$response instanceof PromiseInterface) { - $response = Promise\resolve($response); + $response = resolve($response); } - $that = $this; $response->then( - function ($response) use ($that, $conn, $request) { + function ($response) use ($conn, $request) { if (!$response instanceof ResponseInterface) { $message = 'The response callback is expected to resolve with an object implementing Psr\Http\Message\ResponseInterface, but resolved with "%s" instead.'; $message = \sprintf($message, \is_object($response) ? \get_class($response) : \gettype($response)); $exception = new \RuntimeException($message); - $that->emit('error', array($exception)); - return $that->writeError($conn, 500, $request); + $this->emit('error', [$exception]); + return $this->writeError($conn, Response::STATUS_INTERNAL_SERVER_ERROR, $request); } - $that->handleResponse($conn, $request, $response); + $this->handleResponse($conn, $request, $response); }, - function ($error) use ($that, $conn, $request) { + function ($error) use ($conn, $request) { $message = 'The response callback is expected to resolve with an object implementing Psr\Http\Message\ResponseInterface, but rejected with "%s" instead.'; $message = \sprintf($message, \is_object($error) ? \get_class($error) : \gettype($error)); @@ -234,12 +198,12 @@ function ($error) use ($that, $conn, $request) { $previous = $error; } - $exception = new \RuntimeException($message, null, $previous); + $exception = new \RuntimeException($message, 0, $previous); - $that->emit('error', array($exception)); - return $that->writeError($conn, 500, $request); + $this->emit('error', [$exception]); + return $this->writeError($conn, Response::STATUS_INTERNAL_SERVER_ERROR, $request); } - ); + )->then($connectionOnCloseResponseCancelerHandler, $connectionOnCloseResponseCancelerHandler); } /** @internal */ @@ -247,9 +211,10 @@ public function writeError(ConnectionInterface $conn, $code, ServerRequestInterf { $response = new Response( $code, - array( - 'Content-Type' => 'text/plain' - ), + [ + 'Content-Type' => 'text/plain', + 'Connection' => 'close' // we do not want to keep the connection open after an error + ], 'Error ' . $code ); @@ -282,46 +247,63 @@ public function handleResponse(ConnectionInterface $connection, ServerRequestInt $version = $request->getProtocolVersion(); $response = $response->withProtocolVersion($version); - // assign default "X-Powered-By" header automatically - if (!$response->hasHeader('X-Powered-By')) { - $response = $response->withHeader('X-Powered-By', 'React/alpha'); - } elseif ($response->getHeaderLine('X-Powered-By') === ''){ - $response = $response->withoutHeader('X-Powered-By'); + // assign default "Server" header automatically + if (!$response->hasHeader('Server')) { + $response = $response->withHeader('Server', 'ReactPHP/1'); + } elseif ($response->getHeaderLine('Server') === ''){ + $response = $response->withoutHeader('Server'); } // assign default "Date" header from current time automatically if (!$response->hasHeader('Date')) { // IMF-fixdate = day-name "," SP date1 SP time-of-day SP GMT - $response = $response->withHeader('Date', gmdate('D, d M Y H:i:s') . ' GMT'); + $response = $response->withHeader('Date', gmdate('D, d M Y H:i:s', (int) $this->clock->now()) . ' GMT'); } elseif ($response->getHeaderLine('Date') === ''){ $response = $response->withoutHeader('Date'); } - // assign "Content-Length" and "Transfer-Encoding" headers automatically + // assign "Content-Length" header automatically $chunked = false; - if (($method === 'CONNECT' && $code >= 200 && $code < 300) || ($code >= 100 && $code < 200) || $code === 204) { + if (($method === 'CONNECT' && $code >= 200 && $code < 300) || ($code >= 100 && $code < 200) || $code === Response::STATUS_NO_CONTENT) { // 2xx response to CONNECT and 1xx and 204 MUST NOT include Content-Length or Transfer-Encoding header - $response = $response->withoutHeader('Content-Length')->withoutHeader('Transfer-Encoding'); + $response = $response->withoutHeader('Content-Length'); + } elseif ($method === 'HEAD' && $response->hasHeader('Content-Length')) { + // HEAD Request: preserve explicit Content-Length + } elseif ($code === Response::STATUS_NOT_MODIFIED && ($response->hasHeader('Content-Length') || $body->getSize() === 0)) { + // 304 Not Modified: preserve explicit Content-Length and preserve missing header if body is empty } elseif ($body->getSize() !== null) { // assign Content-Length header when using a "normal" buffered body string - $response = $response->withHeader('Content-Length', (string)$body->getSize())->withoutHeader('Transfer-Encoding'); + $response = $response->withHeader('Content-Length', (string)$body->getSize()); } elseif (!$response->hasHeader('Content-Length') && $version === '1.1') { // assign chunked transfer-encoding if no 'content-length' is given for HTTP/1.1 responses - $response = $response->withHeader('Transfer-Encoding', 'chunked'); $chunked = true; + } + + // assign "Transfer-Encoding" header automatically + if ($chunked) { + $response = $response->withHeader('Transfer-Encoding', 'chunked'); } else { // remove any Transfer-Encoding headers unless automatically enabled above $response = $response->withoutHeader('Transfer-Encoding'); } // assign "Connection" header automatically - if ($code === 101) { + $persist = false; + if ($code === Response::STATUS_SWITCHING_PROTOCOLS) { // 101 (Switching Protocols) response uses Connection: upgrade header + // This implies that this stream now uses another protocol and we + // may not persist this connection for additional requests. $response = $response->withHeader('Connection', 'upgrade'); - } elseif ($version === '1.1') { - // HTTP/1.1 assumes persistent connection support by default - // we do not support persistent connections, so let the client know + } elseif (\strtolower($request->getHeaderLine('Connection')) === 'close' || \strtolower($response->getHeaderLine('Connection')) === 'close') { + // obey explicit "Connection: close" request header or response header if present $response = $response->withHeader('Connection', 'close'); + } elseif ($version === '1.1') { + // HTTP/1.1 assumes persistent connection support by default, so we don't need to inform client + $persist = true; + } elseif (strtolower($request->getHeaderLine('Connection')) === 'keep-alive') { + // obey explicit "Connection: keep-alive" request header and inform client + $persist = true; + $response = $response->withHeader('Connection', 'keep-alive'); } else { // remove any Connection headers unless automatically enabled above $response = $response->withoutHeader('Connection'); @@ -329,7 +311,7 @@ public function handleResponse(ConnectionInterface $connection, ServerRequestInt // 101 (Switching Protocols) response (for Upgrade request) forwards upgraded data through duplex stream // 2xx (Successful) response to CONNECT forwards tunneled application data through duplex stream - if (($code === 101 || ($method === 'CONNECT' && $code >= 200 && $code < 300)) && $body instanceof HttpBodyStream && $body->input instanceof WritableStreamInterface) { + if (($code === Response::STATUS_SWITCHING_PROTOCOLS || ($method === 'CONNECT' && $code >= 200 && $code < 300)) && $body instanceof HttpBodyStream && $body->input instanceof WritableStreamInterface) { if ($request->getBody()->isReadable()) { // request is still streaming => wait for request close before forwarding following data from connection $request->getBody()->on('close', function () use ($connection, $body) { @@ -346,16 +328,29 @@ public function handleResponse(ConnectionInterface $connection, ServerRequestInt } // build HTTP response header by appending status line and header fields + $expected = 0; $headers = "HTTP/" . $version . " " . $code . " " . $response->getReasonPhrase() . "\r\n"; foreach ($response->getHeaders() as $name => $values) { + if (\strpos($name, ':') !== false) { + $expected = -1; + break; + } foreach ($values as $value) { $headers .= $name . ": " . $value . "\r\n"; + ++$expected; } } + if ($code < 100 || $code > 999 || \substr_count($headers, "\n") !== ($expected + 1) || \preg_match_all(AbstractMessage::REGEX_HEADERS, $headers) !== $expected) { + $this->emit('error', [new \InvalidArgumentException('Unable to send response with invalid response headers')]); + $this->writeError($connection, Response::STATUS_INTERNAL_SERVER_ERROR, $request); + return; + } + // response to HEAD and 1xx, 204 and 304 responses MUST NOT include a body // exclude status 101 (Switching Protocols) here for Upgrade request handling above - if ($method === 'HEAD' || $code === 100 || ($code > 101 && $code < 200) || $code === 204 || $code === 304) { + if ($method === 'HEAD' || ($code >= 100 && $code < 200 && $code !== Response::STATUS_SWITCHING_PROTOCOLS) || $code === Response::STATUS_NO_CONTENT || $code === Response::STATUS_NOT_MODIFIED) { + $body->close(); $body = ''; } @@ -366,9 +361,15 @@ public function handleResponse(ConnectionInterface $connection, ServerRequestInt $body = "0\r\n\r\n"; } - // end connection after writing response headers and body + // write response headers and body $connection->write($headers . "\r\n" . $body); - $connection->end(); + + // either wait for next request over persistent connection or end connection + if ($persist) { + $this->parser->handle($connection); + } else { + $connection->end(); + } return; } @@ -381,8 +382,17 @@ public function handleResponse(ConnectionInterface $connection, ServerRequestInt // Close response stream once connection closes. // Note that this TCP/IP close detection may take some time, // in particular this may only fire on a later read/write attempt. - $connection->on('close', array($body, 'close')); - - $body->pipe($connection); + $connection->on('close', [$body, 'close']); + + // write streaming body and then wait for next request over persistent connection + if ($persist) { + $body->pipe($connection, ['end' => false]); + $body->on('end', function () use ($connection, $body) { + $connection->removeListener('close', [$body, 'close']); + $this->parser->handle($connection); + }); + } else { + $body->pipe($connection); + } } } diff --git a/src/Io/Transaction.php b/src/Io/Transaction.php new file mode 100644 index 00000000..6790cb45 --- /dev/null +++ b/src/Io/Transaction.php @@ -0,0 +1,328 @@ +sender = $sender; + $this->loop = $loop; + } + + /** + * @param array $options + * @return self returns new instance, without modifying existing instance + */ + public function withOptions(array $options) + { + $transaction = clone $this; + foreach ($options as $name => $value) { + if (property_exists($transaction, $name)) { + // restore default value if null is given + if ($value === null) { + $default = new self($this->sender, $this->loop); + $value = $default->$name; + } + + $transaction->$name = $value; + } + } + + return $transaction; + } + + public function send(RequestInterface $request) + { + $state = new ClientRequestState(); + $deferred = new Deferred(function () use ($state) { + if ($state->pending !== null) { + $state->pending->cancel(); + $state->pending = null; + } + }); + + // use timeout from options or default to PHP's default_socket_timeout (60) + $timeout = (float) ($this->timeout ?? ini_get("default_socket_timeout")); + + $this->next($request, $deferred, $state)->then( + function (ResponseInterface $response) use ($state, $deferred, &$timeout) { + if ($state->timeout !== null) { + $this->loop->cancelTimer($state->timeout); + $state->timeout = null; + } + $timeout = -1; + $deferred->resolve($response); + }, + function ($e) use ($state, $deferred, &$timeout) { + if ($state->timeout !== null) { + $this->loop->cancelTimer($state->timeout); + $state->timeout = null; + } + $timeout = -1; + $deferred->reject($e); + } + ); + + if ($timeout < 0) { + return $deferred->promise(); + } + + $body = $request->getBody(); + if ($body instanceof ReadableStreamInterface && $body->isReadable()) { + $body->on('close', function () use ($deferred, $state, &$timeout) { + if ($timeout >= 0) { + $this->applyTimeout($deferred, $state, $timeout); + } + }); + } else { + $this->applyTimeout($deferred, $state, $timeout); + } + + return $deferred->promise(); + } + + /** + * @internal + * @param number $timeout + * @return void + */ + public function applyTimeout(Deferred $deferred, ClientRequestState $state, $timeout) + { + $state->timeout = $this->loop->addTimer($timeout, function () use ($timeout, $deferred, $state) { + $deferred->reject(new \RuntimeException( + 'Request timed out after ' . $timeout . ' seconds' + )); + if ($state->pending !== null) { + $state->pending->cancel(); + $state->pending = null; + } + }); + } + + private function next(RequestInterface $request, Deferred $deferred, ClientRequestState $state) + { + $this->progress('request', [$request]); + + ++$state->numRequests; + + $promise = $this->sender->send($request); + + if (!$this->streaming) { + $promise = $promise->then(function ($response) use ($deferred, $state) { + return $this->bufferResponse($response, $deferred, $state); + }); + } + + $state->pending = $promise; + + return $promise->then( + function (ResponseInterface $response) use ($request, $deferred, $state) { + return $this->onResponse($response, $request, $deferred, $state); + } + ); + } + + /** + * @internal + * @return PromiseInterface Promise + */ + public function bufferResponse(ResponseInterface $response, Deferred $deferred, ClientRequestState $state) + { + $body = $response->getBody(); + $size = $body->getSize(); + + if ($size !== null && $size > $this->maximumSize) { + $body->close(); + return reject(new \OverflowException( + 'Response body size of ' . $size . ' bytes exceeds maximum of ' . $this->maximumSize . ' bytes', + \defined('SOCKET_EMSGSIZE') ? \SOCKET_EMSGSIZE : 90 + )); + } + + // body is not streaming => already buffered + if (!$body instanceof ReadableStreamInterface) { + return resolve($response); + } + + /** @var ?\Closure $closer */ + $closer = null; + + return $state->pending = new Promise(function ($resolve, $reject) use ($body, $response, &$closer) { + // resolve with current buffer when stream closes successfully + $buffer = ''; + $body->on('close', $closer = function () use (&$buffer, $response, $resolve, $reject) { + $resolve($response->withBody(new BufferedBody($buffer))); + }); + + // buffer response body data in memory + $body->on('data', function ($data) use (&$buffer, $body, $closer, $reject) { + $buffer .= $data; + + // close stream and reject promise if limit is exceeded + if (isset($buffer[$this->maximumSize])) { + $buffer = ''; + assert($closer instanceof \Closure); + $body->removeListener('close', $closer); + $body->close(); + + $reject(new \OverflowException( + 'Response body size exceeds maximum of ' . $this->maximumSize . ' bytes', + \defined('SOCKET_EMSGSIZE') ? \SOCKET_EMSGSIZE : 90 + )); + } + }); + + // reject buffering if body emits error + $body->on('error', function (\Exception $e) use ($reject) { + $reject(new \RuntimeException( + 'Error while buffering response body: ' . $e->getMessage(), + $e->getCode(), + $e + )); + }); + }, function () use ($body, &$closer) { + // cancelled buffering: remove close handler to avoid resolving, then close and reject + assert($closer instanceof \Closure); + $body->removeListener('close', $closer); + $body->close(); + + throw new \RuntimeException('Cancelled buffering response body'); + }); + } + + /** + * @internal + * @throws ResponseException + * @return ResponseInterface|PromiseInterface + */ + public function onResponse(ResponseInterface $response, RequestInterface $request, Deferred $deferred, ClientRequestState $state) + { + $this->progress('response', [$response, $request]); + + // follow 3xx (Redirection) response status codes if Location header is present and not explicitly disabled + // @link https://tools.ietf.org/html/rfc7231#section-6.4 + if ($this->followRedirects && ($response->getStatusCode() >= 300 && $response->getStatusCode() < 400) && $response->hasHeader('Location')) { + return $this->onResponseRedirect($response, $request, $deferred, $state); + } + + // only status codes 200-399 are considered to be valid, reject otherwise + if ($this->obeySuccessCode && ($response->getStatusCode() < 200 || $response->getStatusCode() >= 400)) { + throw new ResponseException($response); + } + + // resolve our initial promise + return $response; + } + + /** + * @param ResponseInterface $response + * @param RequestInterface $request + * @param Deferred $deferred + * @param ClientRequestState $state + * @return PromiseInterface + * @throws \RuntimeException + */ + private function onResponseRedirect(ResponseInterface $response, RequestInterface $request, Deferred $deferred, ClientRequestState $state) + { + // resolve location relative to last request URI + $location = Uri::resolve($request->getUri(), new Uri($response->getHeaderLine('Location'))); + + $request = $this->makeRedirectRequest($request, $location, $response->getStatusCode()); + $this->progress('redirect', [$request]); + + if ($state->numRequests >= $this->maxRedirects) { + throw new \RuntimeException('Maximum number of redirects (' . $this->maxRedirects . ') exceeded'); + } + + return $this->next($request, $deferred, $state); + } + + /** + * @param RequestInterface $request + * @param UriInterface $location + * @param int $statusCode + * @return RequestInterface + * @throws \RuntimeException + */ + private function makeRedirectRequest(RequestInterface $request, UriInterface $location, $statusCode) + { + // Remove authorization if changing hostnames (but not if just changing ports or protocols). + $originalHost = $request->getUri()->getHost(); + if ($location->getHost() !== $originalHost) { + $request = $request->withoutHeader('Authorization'); + } + + $request = $request->withoutHeader('Host')->withUri($location); + + if ($statusCode === Response::STATUS_TEMPORARY_REDIRECT || $statusCode === Response::STATUS_PERMANENT_REDIRECT) { + if ($request->getBody() instanceof ReadableStreamInterface) { + throw new \RuntimeException('Unable to redirect request with streaming body'); + } + } else { + $request = $request + ->withMethod($request->getMethod() === 'HEAD' ? 'HEAD' : 'GET') + ->withoutHeader('Content-Type') + ->withoutHeader('Content-Length') + ->withBody(new BufferedBody('')); + } + + return $request; + } + + private function progress($name, array $args = []) + { + return; + + echo $name; + + foreach ($args as $arg) { + echo ' '; + if ($arg instanceof ResponseInterface) { + echo 'HTTP/' . $arg->getProtocolVersion() . ' ' . $arg->getStatusCode() . ' ' . $arg->getReasonPhrase(); + } elseif ($arg instanceof RequestInterface) { + echo $arg->getMethod() . ' ' . $arg->getRequestTarget() . ' HTTP/' . $arg->getProtocolVersion(); + } else { + echo $arg; + } + } + + echo PHP_EOL; + } +} diff --git a/src/Io/UploadedFile.php b/src/Io/UploadedFile.php index f2a6c9e7..b0d0dd98 100644 --- a/src/Io/UploadedFile.php +++ b/src/Io/UploadedFile.php @@ -57,7 +57,7 @@ public function __construct(StreamInterface $stream, $size, $error, $filename, $ $this->stream = $stream; $this->size = $size; - if (!\is_int($error) || !\in_array($error, array( + if (!\is_int($error) || !\in_array($error, [ \UPLOAD_ERR_OK, \UPLOAD_ERR_INI_SIZE, \UPLOAD_ERR_FORM_SIZE, @@ -66,7 +66,7 @@ public function __construct(StreamInterface $stream, $size, $error, $filename, $ \UPLOAD_ERR_NO_TMP_DIR, \UPLOAD_ERR_CANT_WRITE, \UPLOAD_ERR_EXTENSION, - ))) { + ])) { throw new InvalidArgumentException( 'Invalid error code, must be an UPLOAD_ERR_* constant' ); diff --git a/src/Message/Request.php b/src/Message/Request.php new file mode 100644 index 00000000..fdba39f5 --- /dev/null +++ b/src/Message/Request.php @@ -0,0 +1,57 @@ + Internally, this implementation builds on top of a base class which is + * considered an implementation detail that may change in the future. + * + * @see RequestInterface + */ +final class Request extends AbstractRequest implements RequestInterface +{ + /** + * @param string $method HTTP method for the request. + * @param string|UriInterface $url URL for the request. + * @param array $headers Headers for the message. + * @param string|ReadableStreamInterface|StreamInterface $body Message body. + * @param string $version HTTP protocol version. + * @throws \InvalidArgumentException for an invalid URL or body + */ + public function __construct( + $method, + $url, + array $headers = [], + $body = '', + $version = '1.1' + ) { + if (\is_string($body)) { + $body = new BufferedBody($body); + } elseif ($body instanceof ReadableStreamInterface && !$body instanceof StreamInterface) { + $body = new ReadableBodyStream($body); + } elseif (!$body instanceof StreamInterface) { + throw new \InvalidArgumentException('Invalid request body given'); + } + + parent::__construct($method, $url, $headers, $body, $version); + } +} diff --git a/src/Message/Response.php b/src/Message/Response.php new file mode 100644 index 00000000..93557fab --- /dev/null +++ b/src/Message/Response.php @@ -0,0 +1,412 @@ + 'text/html' + * ], + * "Hello world!\n" + * ); + * ``` + * + * This class implements the + * [PSR-7 `ResponseInterface`](https://www.php-fig.org/psr/psr-7/#33-psrhttpmessageresponseinterface) + * which in turn extends the + * [PSR-7 `MessageInterface`](https://www.php-fig.org/psr/psr-7/#31-psrhttpmessagemessageinterface). + * + * On top of this, this class implements the + * [PSR-7 Message Util `StatusCodeInterface`](https://github.com/php-fig/http-message-util/blob/master/src/StatusCodeInterface.php) + * which means that most common HTTP status codes are available as class + * constants with the `STATUS_*` prefix. For instance, the `200 OK` and + * `404 Not Found` status codes can used as `Response::STATUS_OK` and + * `Response::STATUS_NOT_FOUND` respectively. + * + * > Internally, this implementation builds on top a base class which is + * considered an implementation detail that may change in the future. + * + * @see \Psr\Http\Message\ResponseInterface + */ +final class Response extends AbstractMessage implements ResponseInterface, StatusCodeInterface +{ + /** + * Create an HTML response + * + * ```php + * $html = << + * + * Hello wörld! + * + * + * HTML; + * + * $response = React\Http\Message\Response::html($html); + * ``` + * + * This is a convenient shortcut method that returns the equivalent of this: + * + * ``` + * $response = new React\Http\Message\Response( + * React\Http\Message\Response::STATUS_OK, + * [ + * 'Content-Type' => 'text/html; charset=utf-8' + * ], + * $html + * ); + * ``` + * + * This method always returns a response with a `200 OK` status code and + * the appropriate `Content-Type` response header for the given HTTP source + * string encoded in UTF-8 (Unicode). It's generally recommended to end the + * given plaintext string with a trailing newline. + * + * If you want to use a different status code or custom HTTP response + * headers, you can manipulate the returned response object using the + * provided PSR-7 methods or directly instantiate a custom HTTP response + * object using the `Response` constructor: + * + * ```php + * $response = React\Http\Message\Response::html( + * "

Error

\n

Invalid user name given.

\n" + * )->withStatus(React\Http\Message\Response::STATUS_BAD_REQUEST); + * ``` + * + * @param string $html + * @return self + */ + public static function html($html) + { + return new self(self::STATUS_OK, ['Content-Type' => 'text/html; charset=utf-8'], $html); + } + + /** + * Create a JSON response + * + * ```php + * $response = React\Http\Message\Response::json(['name' => 'Alice']); + * ``` + * + * This is a convenient shortcut method that returns the equivalent of this: + * + * ``` + * $response = new React\Http\Message\Response( + * React\Http\Message\Response::STATUS_OK, + * [ + * 'Content-Type' => 'application/json' + * ], + * json_encode( + * ['name' => 'Alice'], + * JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRESERVE_ZERO_FRACTION + * ) . "\n" + * ); + * ``` + * + * This method always returns a response with a `200 OK` status code and + * the appropriate `Content-Type` response header for the given structured + * data encoded as a JSON text. + * + * The given structured data will be encoded as a JSON text. Any `string` + * values in the data must be encoded in UTF-8 (Unicode). If the encoding + * fails, this method will throw an `InvalidArgumentException`. + * + * By default, the given structured data will be encoded with the flags as + * shown above. This includes pretty printing and preserving zero fractions + * for `float` values to ease debugging. It is assumed any additional data + * overhead is usually compensated by using HTTP response compression. + * + * If you want to use a different status code or custom HTTP response + * headers, you can manipulate the returned response object using the + * provided PSR-7 methods or directly instantiate a custom HTTP response + * object using the `Response` constructor: + * + * ```php + * $response = React\Http\Message\Response::json( + * ['error' => 'Invalid user name given'] + * )->withStatus(React\Http\Message\Response::STATUS_BAD_REQUEST); + * ``` + * + * @param mixed $data + * @return self + * @throws \InvalidArgumentException when encoding fails + */ + public static function json($data) + { + $json = \json_encode( + $data, + \JSON_PRETTY_PRINT | \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE | \JSON_PRESERVE_ZERO_FRACTION + ); + + if ($json === false) { + throw new \InvalidArgumentException( + 'Unable to encode given data as JSON: ' . \json_last_error_msg(), + \json_last_error() + ); + } + + return new self(self::STATUS_OK, ['Content-Type' => 'application/json'], $json . "\n"); + } + + /** + * Create a plaintext response + * + * ```php + * $response = React\Http\Message\Response::plaintext("Hello wörld!\n"); + * ``` + * + * This is a convenient shortcut method that returns the equivalent of this: + * + * ``` + * $response = new React\Http\Message\Response( + * React\Http\Message\Response::STATUS_OK, + * [ + * 'Content-Type' => 'text/plain; charset=utf-8' + * ], + * "Hello wörld!\n" + * ); + * ``` + * + * This method always returns a response with a `200 OK` status code and + * the appropriate `Content-Type` response header for the given plaintext + * string encoded in UTF-8 (Unicode). It's generally recommended to end the + * given plaintext string with a trailing newline. + * + * If you want to use a different status code or custom HTTP response + * headers, you can manipulate the returned response object using the + * provided PSR-7 methods or directly instantiate a custom HTTP response + * object using the `Response` constructor: + * + * ```php + * $response = React\Http\Message\Response::plaintext( + * "Error: Invalid user name given.\n" + * )->withStatus(React\Http\Message\Response::STATUS_BAD_REQUEST); + * ``` + * + * @param string $text + * @return self + */ + public static function plaintext($text) + { + return new self(self::STATUS_OK, ['Content-Type' => 'text/plain; charset=utf-8'], $text); + } + + /** + * Create an XML response + * + * ```php + * $xml = << + * + * Hello wörld! + * + * + * XML; + * + * $response = React\Http\Message\Response::xml($xml); + * ``` + * + * This is a convenient shortcut method that returns the equivalent of this: + * + * ``` + * $response = new React\Http\Message\Response( + * React\Http\Message\Response::STATUS_OK, + * [ + * 'Content-Type' => 'application/xml' + * ], + * $xml + * ); + * ``` + * + * This method always returns a response with a `200 OK` status code and + * the appropriate `Content-Type` response header for the given XML source + * string. It's generally recommended to use UTF-8 (Unicode) and specify + * this as part of the leading XML declaration and to end the given XML + * source string with a trailing newline. + * + * If you want to use a different status code or custom HTTP response + * headers, you can manipulate the returned response object using the + * provided PSR-7 methods or directly instantiate a custom HTTP response + * object using the `Response` constructor: + * + * ```php + * $response = React\Http\Message\Response::xml( + * "Invalid user name given.\n" + * )->withStatus(React\Http\Message\Response::STATUS_BAD_REQUEST); + * ``` + * + * @param string $xml + * @return self + */ + public static function xml($xml) + { + return new self(self::STATUS_OK, ['Content-Type' => 'application/xml'], $xml); + } + + /** + * @var bool + * @see self::$phrasesMap + */ + private static $phrasesInitialized = false; + + /** + * Map of standard HTTP status codes to standard reason phrases. + * + * This map will be fully populated with all standard reason phrases on + * first access. By default, it only contains a subset of HTTP status codes + * that have a custom mapping to reason phrases (such as those with dashes + * and all caps words). See `self::STATUS_*` for all possible status code + * constants. + * + * @var array + * @see self::STATUS_* + * @see self::getReasonPhraseForStatusCode() + */ + private static $phrasesMap = [ + 200 => 'OK', + 203 => 'Non-Authoritative Information', + 207 => 'Multi-Status', + 226 => 'IM Used', + 414 => 'URI Too Large', + 418 => 'I\'m a teapot', + 505 => 'HTTP Version Not Supported' + ]; + + /** @var int */ + private $statusCode; + + /** @var string */ + private $reasonPhrase; + + /** + * @param int $status HTTP status code (e.g. 200/404), see `self::STATUS_*` constants + * @param array $headers additional response headers + * @param string|ReadableStreamInterface|StreamInterface $body response body + * @param string $version HTTP protocol version (e.g. 1.1/1.0) + * @param ?string $reason custom HTTP response phrase + * @throws \InvalidArgumentException for an invalid body + */ + public function __construct( + $status = self::STATUS_OK, + array $headers = [], + $body = '', + $version = '1.1', + $reason = null + ) { + if (\is_string($body)) { + $body = new BufferedBody($body); + } elseif ($body instanceof ReadableStreamInterface && !$body instanceof StreamInterface) { + $body = new HttpBodyStream($body, null); + } elseif (!$body instanceof StreamInterface) { + throw new \InvalidArgumentException('Invalid response body given'); + } + + parent::__construct($version, $headers, $body); + + $this->statusCode = (int) $status; + $this->reasonPhrase = ($reason !== '' && $reason !== null) ? (string) $reason : self::getReasonPhraseForStatusCode($status); + } + + public function getStatusCode() + { + return $this->statusCode; + } + + public function withStatus($code, $reasonPhrase = '') + { + if ((string) $reasonPhrase === '') { + $reasonPhrase = self::getReasonPhraseForStatusCode($code); + } + + if ($this->statusCode === (int) $code && $this->reasonPhrase === (string) $reasonPhrase) { + return $this; + } + + $response = clone $this; + $response->statusCode = (int) $code; + $response->reasonPhrase = (string) $reasonPhrase; + + return $response; + } + + public function getReasonPhrase() + { + return $this->reasonPhrase; + } + + /** + * @param int $code + * @return string default reason phrase for given status code or empty string if unknown + */ + private static function getReasonPhraseForStatusCode($code) + { + if (!self::$phrasesInitialized) { + self::$phrasesInitialized = true; + + // map all `self::STATUS_` constants from status code to reason phrase + // e.g. `self::STATUS_NOT_FOUND = 404` will be mapped to `404 Not Found` + $ref = new \ReflectionClass(__CLASS__); + foreach ($ref->getConstants() as $name => $value) { + if (!isset(self::$phrasesMap[$value]) && \strpos($name, 'STATUS_') === 0) { + self::$phrasesMap[$value] = \ucwords(\strtolower(\str_replace('_', ' ', \substr($name, 7)))); + } + } + } + + return self::$phrasesMap[$code] ?? ''; + } + + /** + * [Internal] Parse incoming HTTP protocol message + * + * @internal + * @param string $message + * @return self + * @throws \InvalidArgumentException if given $message is not a valid HTTP response message + */ + public static function parseMessage($message) + { + $start = []; + if (!\preg_match('#^HTTP/(?\d\.\d) (?\d{3})(?: (?[^\r\n]*+))?[\r]?+\n#m', $message, $start)) { + throw new \InvalidArgumentException('Unable to parse invalid status-line'); + } + + // only support HTTP/1.1 and HTTP/1.0 requests + if ($start['version'] !== '1.1' && $start['version'] !== '1.0') { + throw new \InvalidArgumentException('Received response with invalid protocol version'); + } + + // check number of valid header fields matches number of lines + status line + $matches = []; + $n = \preg_match_all(self::REGEX_HEADERS, $message, $matches, \PREG_SET_ORDER); + if (\substr_count($message, "\n") !== $n + 1) { + throw new \InvalidArgumentException('Unable to parse invalid response header fields'); + } + + // format all header fields into associative array + $headers = []; + foreach ($matches as $match) { + $headers[$match[1]][] = $match[2]; + } + + return new self( + (int) $start['status'], + $headers, + '', + $start['version'], + $start['reason'] ?? '' + ); + } +} diff --git a/src/Message/ResponseException.php b/src/Message/ResponseException.php new file mode 100644 index 00000000..f4912c90 --- /dev/null +++ b/src/Message/ResponseException.php @@ -0,0 +1,43 @@ +getStatusCode() . ' (' . $response->getReasonPhrase() . ')'; + } + if ($code === null) { + $code = $response->getStatusCode(); + } + parent::__construct($message, $code, $previous); + + $this->response = $response; + } + + /** + * Access its underlying response object. + * + * @return ResponseInterface + */ + public function getResponse() + { + return $this->response; + } +} diff --git a/src/Message/ServerRequest.php b/src/Message/ServerRequest.php new file mode 100644 index 00000000..da0d76ab --- /dev/null +++ b/src/Message/ServerRequest.php @@ -0,0 +1,331 @@ + Internally, this implementation builds on top of a base class which is + * considered an implementation detail that may change in the future. + * + * @see ServerRequestInterface + */ +final class ServerRequest extends AbstractRequest implements ServerRequestInterface +{ + private $attributes = []; + + private $serverParams; + private $fileParams = []; + private $cookies = []; + private $queryParams = []; + private $parsedBody; + + /** + * @param string $method HTTP method for the request. + * @param string|UriInterface $url URL for the request. + * @param array $headers Headers for the message. + * @param string|ReadableStreamInterface|StreamInterface $body Message body. + * @param string $version HTTP protocol version. + * @param array $serverParams server-side parameters + * @throws \InvalidArgumentException for an invalid URL or body + */ + public function __construct( + $method, + $url, + array $headers = [], + $body = '', + $version = '1.1', + $serverParams = [] + ) { + if (\is_string($body)) { + $body = new BufferedBody($body); + } elseif ($body instanceof ReadableStreamInterface && !$body instanceof StreamInterface) { + $temp = new self($method, '', $headers); + $size = (int) $temp->getHeaderLine('Content-Length'); + if (\strtolower($temp->getHeaderLine('Transfer-Encoding')) === 'chunked') { + $size = null; + } + $body = new HttpBodyStream($body, $size); + } elseif (!$body instanceof StreamInterface) { + throw new \InvalidArgumentException('Invalid server request body given'); + } + + parent::__construct($method, $url, $headers, $body, $version); + + $this->serverParams = $serverParams; + + $query = $this->getUri()->getQuery(); + if ($query !== '') { + \parse_str($query, $this->queryParams); + } + + // Multiple cookie headers are not allowed according + // to https://tools.ietf.org/html/rfc6265#section-5.4 + $cookieHeaders = $this->getHeader("Cookie"); + + if (count($cookieHeaders) === 1) { + $this->cookies = $this->parseCookie($cookieHeaders[0]); + } + } + + 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; + } + + /** + * @param string $cookie + * @return array + */ + private function parseCookie($cookie) + { + $cookieArray = \explode(';', $cookie); + $result = []; + + foreach ($cookieArray as $pair) { + $pair = \trim($pair); + $nameValuePair = \explode('=', $pair, 2); + + if (\count($nameValuePair) === 2) { + $key = $nameValuePair[0]; + $value = \urldecode($nameValuePair[1]); + $result[$key] = $value; + } + } + + return $result; + } + + /** + * [Internal] Parse incoming HTTP protocol message + * + * @internal + * @param string $message + * @param array $serverParams + * @return self + * @throws \InvalidArgumentException if given $message is not a valid HTTP request message + */ + public static function parseMessage($message, array $serverParams) + { + // parse request line like "GET /path HTTP/1.1" + $start = []; + if (!\preg_match('#^(?[^ ]+) (?[^ ]+) HTTP/(?\d\.\d)#m', $message, $start)) { + throw new \InvalidArgumentException('Unable to parse invalid request-line'); + } + + // only support HTTP/1.1 and HTTP/1.0 requests + if ($start['version'] !== '1.1' && $start['version'] !== '1.0') { + throw new \InvalidArgumentException('Received request with invalid protocol version', Response::STATUS_VERSION_NOT_SUPPORTED); + } + + // check number of valid header fields matches number of lines + request line + $matches = []; + $n = \preg_match_all(self::REGEX_HEADERS, $message, $matches, \PREG_SET_ORDER); + if (\substr_count($message, "\n") !== $n + 1) { + throw new \InvalidArgumentException('Unable to parse invalid request header fields'); + } + + // format all header fields into associative array + $host = null; + $headers = []; + foreach ($matches as $match) { + $headers[$match[1]][] = $match[2]; + + // match `Host` request header + if ($host === null && \strtolower($match[1]) === 'host') { + $host = $match[2]; + } + } + + // scheme is `http` unless TLS is used + $scheme = isset($serverParams['HTTPS']) ? 'https://' : 'http://'; + + // default host if unset comes from local socket address or defaults to localhost + $hasHost = $host !== null; + if ($host === null) { + $host = isset($serverParams['SERVER_ADDR'], $serverParams['SERVER_PORT']) ? $serverParams['SERVER_ADDR'] . ':' . $serverParams['SERVER_PORT'] : '127.0.0.1'; + } + + if ($start['method'] === 'OPTIONS' && $start['target'] === '*') { + // support asterisk-form for `OPTIONS *` request line only + $uri = $scheme . $host; + } elseif ($start['method'] === 'CONNECT') { + $parts = \parse_url('tcp://' . $start['target']); + + // check this is a valid authority-form request-target (host:port) + if (!isset($parts['scheme'], $parts['host'], $parts['port']) || \count($parts) !== 3) { + throw new \InvalidArgumentException('CONNECT method MUST use authority-form request target'); + } + $uri = $scheme . $start['target']; + } else { + // support absolute-form or origin-form for proxy requests + if ($start['target'][0] === '/') { + $uri = $scheme . $host . $start['target']; + } else { + // ensure absolute-form request-target contains a valid URI + $parts = \parse_url(http://github.com/$start['target']); + + // 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'); + } + + $uri = $start['target']; + } + } + + $request = new self( + $start['method'], + $uri, + $headers, + '', + $start['version'], + $serverParams + ); + + // only assign request target if it is not in origin-form (happy path for most normal requests) + if ($start['target'][0] !== '/') { + $request = $request->withRequestTarget($start['target']); + } + + if ($hasHost) { + // Optional Host request header value MUST be valid (host and optional port) + $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 + if (\is_array($parts)) { + unset($parts['scheme'], $parts['host'], $parts['port']); + } + if ($parts === false || $parts) { + throw new \InvalidArgumentException('Invalid Host header value'); + } + } elseif (!$hasHost && $start['version'] === '1.1' && $start['method'] !== 'CONNECT') { + // require Host request header for HTTP/1.1 (except for CONNECT method) + throw new \InvalidArgumentException('Missing required Host request header'); + } elseif (!$hasHost) { + // remove default Host request header for HTTP/1.0 when not explicitly given + $request = $request->withoutHeader('Host'); + } + + // ensure message boundaries are valid according to Content-Length and Transfer-Encoding request headers + if ($request->hasHeader('Transfer-Encoding')) { + if (\strtolower($request->getHeaderLine('Transfer-Encoding')) !== 'chunked') { + throw new \InvalidArgumentException('Only chunked-encoding is allowed for Transfer-Encoding', Response::STATUS_NOT_IMPLEMENTED); + } + + // Transfer-Encoding: chunked and Content-Length header MUST NOT be used at the same time + // as per https://tools.ietf.org/html/rfc7230#section-3.3.3 + if ($request->hasHeader('Content-Length')) { + throw new \InvalidArgumentException('Using both `Transfer-Encoding: chunked` and `Content-Length` is not allowed', Response::STATUS_BAD_REQUEST); + } + } elseif ($request->hasHeader('Content-Length')) { + $string = $request->getHeaderLine('Content-Length'); + + if ((string)(int)$string !== $string) { + // Content-Length value is not an integer or not a single integer + throw new \InvalidArgumentException('The value of `Content-Length` is not valid', Response::STATUS_BAD_REQUEST); + } + } + + return $request; + } +} diff --git a/src/Message/Uri.php b/src/Message/Uri.php new file mode 100644 index 00000000..661f90c4 --- /dev/null +++ b/src/Message/Uri.php @@ -0,0 +1,347 @@ +scheme = \strtolower($parts['scheme']); + } + + if (isset($parts['user'])) { + $this->userInfo = $this->encode($parts['user'], \PHP_URL_USER) . (isset($parts['pass']) ? ':' . $this->encode($parts['pass'], \PHP_URL_PASS) : ''); + } + + if (isset($parts['host'])) { + $this->host = \strtolower($parts['host']); + } + + if (isset($parts['port']) && !(($parts['port'] === 80 && $this->scheme === 'http') || ($parts['port'] === 443 && $this->scheme === 'https'))) { + $this->port = $parts['port']; + } + + if (isset($parts['path'])) { + $this->path = $this->encode($parts['path'], \PHP_URL_PATH); + } + + if (isset($parts['query'])) { + $this->query = $this->encode($parts['query'], \PHP_URL_QUERY); + } + + if (isset($parts['fragment'])) { + $this->fragment = $this->encode($parts['fragment'], \PHP_URL_FRAGMENT); + } + } + + public function getScheme() + { + return $this->scheme; + } + + public function getAuthority() + { + if ($this->host === '') { + return ''; + } + + return ($this->userInfo !== '' ? $this->userInfo . '@' : '') . $this->host . ($this->port !== null ? ':' . $this->port : ''); + } + + public function getUserInfo() + { + return $this->userInfo; + } + + public function getHost() + { + return $this->host; + } + + public function getPort() + { + return $this->port; + } + + public function getPath() + { + return $this->path; + } + + public function getQuery() + { + return $this->query; + } + + public function getFragment() + { + return $this->fragment; + } + + public function withScheme($scheme) + { + $scheme = \strtolower($scheme); + if ($scheme === $this->scheme) { + return $this; + } + + if (!\preg_match('#^[a-z]*$#', $scheme)) { + throw new \InvalidArgumentException('Invalid URI scheme given'); + } + + $new = clone $this; + $new->scheme = $scheme; + + if (($this->port === 80 && $scheme === 'http') || ($this->port === 443 && $scheme === 'https')) { + $new->port = null; + } + + return $new; + } + + public function withUserInfo($user, $password = null) + { + $userInfo = $this->encode($user, \PHP_URL_USER) . ($password !== null ? ':' . $this->encode($password, \PHP_URL_PASS) : ''); + if ($userInfo === $this->userInfo) { + return $this; + } + + $new = clone $this; + $new->userInfo = $userInfo; + + return $new; + } + + public function withHost($host) + { + $host = \strtolower($host); + if ($host === $this->host) { + return $this; + } + + if (\preg_match('#[\s%+]#', $host) || ($host !== '' && \parse_url('http://' . $host, \PHP_URL_HOST) !== $host)) { + throw new \InvalidArgumentException('Invalid URI host given'); + } + + $new = clone $this; + $new->host = $host; + + return $new; + } + + public function withPort($port) + { + $port = $port === null ? null : (int) $port; + if (($port === 80 && $this->scheme === 'http') || ($port === 443 && $this->scheme === 'https')) { + $port = null; + } + + if ($port === $this->port) { + return $this; + } + + if ($port !== null && ($port < 1 || $port > 0xffff)) { + throw new \InvalidArgumentException('Invalid URI port given'); + } + + $new = clone $this; + $new->port = $port; + + return $new; + } + + public function withPath($path) + { + $path = $this->encode($path, \PHP_URL_PATH); + if ($path === $this->path) { + return $this; + } + + $new = clone $this; + $new->path = $path; + + return $new; + } + + public function withQuery($query) + { + $query = $this->encode($query, \PHP_URL_QUERY); + if ($query === $this->query) { + return $this; + } + + $new = clone $this; + $new->query = $query; + + return $new; + } + + public function withFragment($fragment) + { + $fragment = $this->encode($fragment, \PHP_URL_FRAGMENT); + if ($fragment === $this->fragment) { + return $this; + } + + $new = clone $this; + $new->fragment = $fragment; + + return $new; + } + + public function __toString() + { + $uri = ''; + if ($this->scheme !== '') { + $uri .= $this->scheme . ':'; + } + + $authority = $this->getAuthority(); + if ($authority !== '') { + $uri .= '//' . $authority; + } + + if ($authority !== '' && isset($this->path[0]) && $this->path[0] !== '/') { + $uri .= '/' . $this->path; + } elseif ($authority === '' && isset($this->path[0]) && $this->path[0] === '/') { + $uri .= '/' . \ltrim($this->path, '/'); + } else { + $uri .= $this->path; + } + + if ($this->query !== '') { + $uri .= '?' . $this->query; + } + + if ($this->fragment !== '') { + $uri .= '#' . $this->fragment; + } + + return $uri; + } + + /** + * @param string $part + * @param int $component + * @return string + */ + private function encode($part, $component) + { + return \preg_replace_callback( + '/(?:[^a-z0-9_\-\.~!\$&\'\(\)\*\+,;=' . ($component === \PHP_URL_PATH ? ':@\/' : ($component === \PHP_URL_QUERY || $component === \PHP_URL_FRAGMENT ? ':@\/\?' : '')) . '%]++|%(?![a-f0-9]{2}))/i', + function (array $match) { + return \rawurlencode($match[0]); + }, + $part + ); + } + + /** + * [Internal] Resolve URI relative to base URI and return new absolute URI + * + * @internal + * @param UriInterface $base + * @param UriInterface $rel + * @return UriInterface + * @throws void + */ + public static function resolve(UriInterface $base, UriInterface $rel) + { + if ($rel->getScheme() !== '') { + return $rel->getPath() === '' ? $rel : $rel->withPath(self::removeDotSegments($rel->getPath())); + } + + $reset = false; + $new = $base; + if ($rel->getAuthority() !== '') { + $reset = true; + $userInfo = \explode(':', $rel->getUserInfo(), 2); + $new = $base->withUserInfo($userInfo[0], $userInfo[1] ?? null)->withHost($rel->getHost())->withPort($rel->getPort()); + } + + if ($reset && $rel->getPath() === '') { + $new = $new->withPath(''); + } elseif (($path = $rel->getPath()) !== '') { + $start = ''; + if ($path === '' || $path[0] !== '/') { + $start = $base->getPath(); + if (\substr($start, -1) !== '/') { + $start .= '/../'; + } + } + $reset = true; + $new = $new->withPath(self::removeDotSegments($start . $path)); + } + if ($reset || $rel->getQuery() !== '') { + $reset = true; + $new = $new->withQuery($rel->getQuery()); + } + if ($reset || $rel->getFragment() !== '') { + $new = $new->withFragment($rel->getFragment()); + } + + return $new; + } + + /** + * @param string $path + * @return string + */ + private static function removeDotSegments($path) + { + $segments = []; + foreach (\explode('/', $path) as $segment) { + if ($segment === '..') { + \array_pop($segments); + } elseif ($segment !== '.' && $segment !== '') { + $segments[] = $segment; + } + } + return '/' . \implode('/', $segments) . ($path !== '/' && \substr($path, -1) === '/' ? '/' : ''); + } +} diff --git a/src/Middleware/LimitConcurrentRequestsMiddleware.php b/src/Middleware/LimitConcurrentRequestsMiddleware.php index f816f70b..41477f91 100644 --- a/src/Middleware/LimitConcurrentRequestsMiddleware.php +++ b/src/Middleware/LimitConcurrentRequestsMiddleware.php @@ -6,10 +6,11 @@ use Psr\Http\Message\ServerRequestInterface; use React\Http\Io\HttpBodyStream; use React\Http\Io\PauseBufferStream; -use React\Promise; use React\Promise\PromiseInterface; use React\Promise\Deferred; use React\Stream\ReadableStreamInterface; +use function React\Promise\reject; +use function React\Promise\resolve; /** * Limits how many next handlers can be executed concurrently. @@ -29,10 +30,11 @@ * than 10 handlers will be invoked at once: * * ```php - * $server = new StreamingServer(array( - * new LimitConcurrentRequestsMiddleware(10), + * $http = new React\Http\HttpServer( + * new React\Http\Middleware\StreamingRequestMiddleware(), + * new React\Http\Middleware\LimitConcurrentRequestsMiddleware(10), * $handler - * )); + * ); * ``` * * Similarly, this middleware is often used in combination with the @@ -40,12 +42,13 @@ * to limit the total number of requests that can be buffered at once: * * ```php - * $server = new StreamingServer(array( - * new LimitConcurrentRequestsMiddleware(100), // 100 concurrent buffering handlers - * new RequestBodyBufferMiddleware(2 * 1024 * 1024), // 2 MiB per request - * new RequestBodyParserMiddleware(), + * $http = new React\Http\HttpServer( + * new React\Http\Middleware\StreamingRequestMiddleware(), + * new React\Http\Middleware\LimitConcurrentRequestsMiddleware(100), // 100 concurrent buffering handlers + * new React\Http\Middleware\RequestBodyBufferMiddleware(2 * 1024 * 1024), // 2 MiB per request + * new React\Http\Middleware\RequestBodyParserMiddleware(), * $handler - * )); + * ); * ``` * * More sophisticated examples include limiting the total number of requests @@ -53,13 +56,14 @@ * processes one request after another without any concurrency: * * ```php - * $server = new StreamingServer(array( - * new LimitConcurrentRequestsMiddleware(100), // 100 concurrent buffering handlers - * new RequestBodyBufferMiddleware(2 * 1024 * 1024), // 2 MiB per request - * new RequestBodyParserMiddleware(), - * new LimitConcurrentRequestsMiddleware(1), // only execute 1 handler (no concurrency) + * $http = new React\Http\HttpServer( + * new React\Http\Middleware\StreamingRequestMiddleware(), + * new React\Http\Middleware\LimitConcurrentRequestsMiddleware(100), // 100 concurrent buffering handlers + * new React\Http\Middleware\RequestBodyBufferMiddleware(2 * 1024 * 1024), // 2 MiB per request + * new React\Http\Middleware\RequestBodyParserMiddleware(), + * new React\Http\Middleware\LimitConcurrentRequestsMiddleware(1), // only execute 1 handler (no concurrency) * $handler - * )); + * ); * ``` * * @see RequestBodyBufferMiddleware @@ -68,7 +72,7 @@ final class LimitConcurrentRequestsMiddleware { private $limit; private $pending = 0; - private $queue = array(); + private $queue = []; /** * @param int $limit Maximum amount of concurrent requests handled. @@ -89,13 +93,9 @@ public function __invoke(ServerRequestInterface $request, $next) try { $response = $next($request); - } catch (\Exception $e) { + } catch (\Throwable $e) { $this->processQueue(); throw $e; - } catch (\Throwable $e) { // @codeCoverageIgnoreStart - // handle Errors just like Exceptions (PHP 7+ only) - $this->processQueue(); - throw $e; // @codeCoverageIgnoreEnd } // happy path: if next request handler returned immediately, @@ -107,7 +107,7 @@ public function __invoke(ServerRequestInterface $request, $next) // if the next handler returns a pending promise, we have to // await its resolution before invoking next queued request - return $this->await(Promise\resolve($response)); + return $this->await(resolve($response)); } // if we reach this point, then this request will need to be queued @@ -127,36 +127,29 @@ public function __invoke(ServerRequestInterface $request, $next) } // get next queue position - $queue =& $this->queue; - $queue[] = null; - \end($queue); - $id = \key($queue); + $this->queue[] = null; + \end($this->queue); + $id = \key($this->queue); - $deferred = new Deferred(function ($_, $reject) use (&$queue, $id) { + $deferred = new Deferred(function ($_, $reject) use ($id) { // queued promise cancelled before its next handler is invoked // remove from queue and reject explicitly - unset($queue[$id]); + unset($this->queue[$id]); $reject(new \RuntimeException('Cancelled queued next handler')); }); // queue request and process queue if pending does not exceed limit - $queue[$id] = $deferred; + $this->queue[$id] = $deferred; - $pending = &$this->pending; - $that = $this; - return $deferred->promise()->then(function () use ($request, $next, $body, &$pending, $that) { + return $deferred->promise()->then(function () use ($request, $next, $body) { // invoke next request handler - ++$pending; + ++$this->pending; try { $response = $next($request); - } catch (\Exception $e) { - $that->processQueue(); + } catch (\Throwable $e) { + $this->processQueue(); throw $e; - } catch (\Throwable $e) { // @codeCoverageIgnoreStart - // handle Errors just like Exceptions (PHP 7+ only) - $that->processQueue(); - throw $e; // @codeCoverageIgnoreEnd } // resume readable stream and replay buffered events @@ -166,27 +159,24 @@ public function __invoke(ServerRequestInterface $request, $next) // if the next handler returns a pending promise, we have to // await its resolution before invoking next queued request - return $that->await(Promise\resolve($response)); + return $this->await(resolve($response)); }); } /** - * @internal * @param PromiseInterface $promise * @return PromiseInterface */ - public function await(PromiseInterface $promise) + private function await(PromiseInterface $promise) { - $that = $this; - - return $promise->then(function ($response) use ($that) { - $that->processQueue(); + return $promise->then(function ($response) { + $this->processQueue(); return $response; - }, function ($error) use ($that) { - $that->processQueue(); + }, function ($error) { + $this->processQueue(); - return Promise\reject($error); + return reject($error); }); } @@ -203,6 +193,6 @@ public function processQueue() $first = \reset($this->queue); unset($this->queue[key($this->queue)]); - $first->resolve(); + $first->resolve(null); } } diff --git a/src/Middleware/RequestBodyBufferMiddleware.php b/src/Middleware/RequestBodyBufferMiddleware.php index 0e6f5145..ea889bd3 100644 --- a/src/Middleware/RequestBodyBufferMiddleware.php +++ b/src/Middleware/RequestBodyBufferMiddleware.php @@ -4,10 +4,10 @@ use OverflowException; use Psr\Http\Message\ServerRequestInterface; +use React\Http\Io\BufferedBody; use React\Http\Io\IniUtil; -use React\Promise\Stream; +use React\Promise\Promise; use React\Stream\ReadableStreamInterface; -use RingCentral\Psr7\BufferStream; final class RequestBodyBufferMiddleware { @@ -29,19 +29,19 @@ public function __construct($sizeLimit = null) $this->sizeLimit = IniUtil::iniSizeToBytes($sizeLimit); } - public function __invoke(ServerRequestInterface $request, $stack) + public function __invoke(ServerRequestInterface $request, $next) { $body = $request->getBody(); $size = $body->getSize(); // happy path: skip if body is known to be empty (or is already buffered) - if ($size === 0 || !$body instanceof ReadableStreamInterface) { + if ($size === 0 || !$body instanceof ReadableStreamInterface || !$body->isReadable()) { // replace with empty body if body is streaming (or buffered size exceeds limit) if ($body instanceof ReadableStreamInterface || $size > $this->sizeLimit) { - $request = $request->withBody(new BufferStream(0)); + $request = $request->withBody(new BufferedBody('')); } - return $stack($request); + return $next($request); } // request body of known size exceeding limit @@ -50,23 +50,57 @@ public function __invoke(ServerRequestInterface $request, $stack) $sizeLimit = 0; } - return Stream\buffer($body, $sizeLimit)->then(function ($buffer) use ($request, $stack) { - $stream = new BufferStream(\strlen($buffer)); - $stream->write($buffer); - $request = $request->withBody($stream); - - return $stack($request); - }, function ($error) use ($stack, $request, $body) { - // On buffer overflow keep the request body stream in, - // but ignore the contents and wait for the close event - // before passing the request on to the next middleware. - if ($error instanceof OverflowException) { - return Stream\first($body, 'close')->then(function () use ($stack, $request) { - return $stack($request); - }); - } + /** @var ?\Closure $closer */ + $closer = null; + + return new Promise(function ($resolve, $reject) use ($body, &$closer, $sizeLimit, $request, $next) { + // buffer request body data in memory, discard but keep buffering if limit is reached + $buffer = ''; + $bufferer = null; + $body->on('data', $bufferer = function ($data) use (&$buffer, $sizeLimit, $body, &$bufferer) { + $buffer .= $data; + + // On buffer overflow keep the request body stream in, + // but ignore the contents and wait for the close event + // before passing the request on to the next middleware. + if (isset($buffer[$sizeLimit])) { + assert($bufferer instanceof \Closure); + $body->removeListener('data', $bufferer); + $bufferer = null; + $buffer = ''; + } + }); + + // call $next with current buffer and resolve or reject with its results + $body->on('close', $closer = function () use (&$buffer, $request, $resolve, $reject, $next) { + try { + // resolve with result of next handler + $resolve($next($request->withBody(new BufferedBody($buffer)))); + } catch (\Throwable $e) { + $reject($e); + } + }); + + // reject buffering if body emits error + $body->on('error', function (\Exception $e) use ($reject, $body, $closer) { + // remove close handler to avoid resolving, then close and reject + assert($closer instanceof \Closure); + $body->removeListener('close', $closer); + $body->close(); + + $reject(new \RuntimeException( + 'Error while buffering request body: ' . $e->getMessage(), + $e->getCode(), + $e + )); + }); + }, function () use ($body, &$closer) { + // cancelled buffering: remove close handler to avoid resolving, then close and reject + assert($closer instanceof \Closure); + $body->removeListener('close', $closer); + $body->close(); - throw $error; + throw new \RuntimeException('Cancelled buffering request body'); }); } } diff --git a/src/Middleware/RequestBodyParserMiddleware.php b/src/Middleware/RequestBodyParserMiddleware.php index be5ba16f..63013337 100644 --- a/src/Middleware/RequestBodyParserMiddleware.php +++ b/src/Middleware/RequestBodyParserMiddleware.php @@ -21,7 +21,7 @@ public function __construct($uploadMaxFilesize = null, $maxFileUploads = null) public function __invoke(ServerRequestInterface $request, $next) { $type = \strtolower($request->getHeaderLine('Content-Type')); - list ($type) = \explode(';', $type); + [$type] = \explode(';', $type); if ($type === 'application/x-www-form-urlencoded') { return $next($this->parseFormUrlencoded($request)); @@ -38,7 +38,7 @@ private function parseFormUrlencoded(ServerRequestInterface $request) { // parse string into array structure // ignore warnings due to excessive data structures (max_input_vars and max_input_nesting_level) - $ret = array(); + $ret = []; @\parse_str((string)$request->getBody(), $ret); return $request->withParsedBody($ret); diff --git a/src/Middleware/StreamingRequestMiddleware.php b/src/Middleware/StreamingRequestMiddleware.php new file mode 100644 index 00000000..6ab74b71 --- /dev/null +++ b/src/Middleware/StreamingRequestMiddleware.php @@ -0,0 +1,69 @@ +getBody(); + * assert($body instanceof Psr\Http\Message\StreamInterface); + * assert($body instanceof React\Stream\ReadableStreamInterface); + * + * return new React\Promise\Promise(function ($resolve) use ($body) { + * $bytes = 0; + * $body->on('data', function ($chunk) use (&$bytes) { + * $bytes += \count($chunk); + * }); + * $body->on('close', function () use (&$bytes, $resolve) { + * $resolve(new React\Http\Response( + * 200, + * [], + * "Received $bytes bytes\n" + * )); + * }); + * }); + * } + * ); + * ``` + * + * See also [streaming incoming request](../../README.md#streaming-incoming-request) + * for more details. + * + * Additionally, this middleware can be used in combination with the + * [`LimitConcurrentRequestsMiddleware`](#limitconcurrentrequestsmiddleware) and + * [`RequestBodyBufferMiddleware`](#requestbodybuffermiddleware) (see below) + * to explicitly configure the total number of requests that can be handled at + * once: + * + * ```php + * $http = new React\Http\HttpServer( + * new React\Http\Middleware\StreamingRequestMiddleware(), + * new React\Http\Middleware\LimitConcurrentRequestsMiddleware(100), // 100 concurrent buffering handlers + * new React\Http\Middleware\RequestBodyBufferMiddleware(2 * 1024 * 1024), // 2 MiB per request + * new React\Http\Middleware\RequestBodyParserMiddleware(), + * $handler + * ); + * ``` + * + * > Internally, this class is used as a "marker" to not trigger the default + * request buffering behavior in the `HttpServer`. It does not implement any logic + * on its own. + */ +final class StreamingRequestMiddleware +{ + public function __invoke(ServerRequestInterface $request, $next) + { + return $next($request); + } +} diff --git a/src/Response.php b/src/Response.php deleted file mode 100644 index 0964dac9..00000000 --- a/src/Response.php +++ /dev/null @@ -1,36 +0,0 @@ - 'text/plain' - * ), - * "Hello World!\n" - * ); - * }); - * ``` - * - * Each incoming HTTP request message is always represented by the - * [PSR-7 `ServerRequestInterface`](https://www.php-fig.org/psr/psr-7/#321-psrhttpmessageserverrequestinterface), - * see also following [request](#request) chapter for more details. - * Each outgoing HTTP response message is always represented by the - * [PSR-7 `ResponseInterface`](https://www.php-fig.org/psr/psr-7/#33-psrhttpmessageresponseinterface), - * see also following [response](#response) chapter for more details. - * - * In order to process any connections, the server needs to be attached to an - * instance of `React\Socket\ServerInterface` through the [`listen()`](#listen) method - * as described in the following chapter. In its most simple form, 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('0.0.0.0:8080', $loop); - * $server->listen($socket); - * ``` - * - * See also the [`listen()`](#listen) method and the [first example](examples) for more details. - * - * The `Server` class is built as a facade around the underlying - * [`StreamingServer`](#streamingserver) to provide sane defaults for 80% of the - * use cases and is the recommended way to use this library unless you're sure - * you know what you're doing. - * - * Unlike the underlying [`StreamingServer`](#streamingserver), this class - * buffers and parses the complete incoming HTTP request in memory. Once the - * complete request has been received, it will invoke the request handler - * function. This means the [request](#request) passed to your request handler - * function will be fully compatible with PSR-7. - * - * On the other hand, buffering complete HTTP requests in memory until they can - * be processed by your request handler function means that this class has to - * employ a number of limits to avoid consuming too much memory. In order to - * take the more advanced configuration out your hand, it respects setting from - * your [`php.ini`](https://www.php.net/manual/en/ini.core.php) to apply its - * default settings. This is a list of PHP settings this class respects with - * their respective default values: - * - * ``` - * memory_limit 128M - * post_max_size 8M - * enable_post_data_reading 1 - * max_input_nesting_level 64 - * max_input_vars 1000 - * - * file_uploads 1 - * upload_max_filesize 2M - * max_file_uploads 20 - * ``` - * - * In particular, the `post_max_size` setting limits how much memory a single HTTP - * request is allowed to consume while buffering its request body. On top of - * this, this class will try to avoid consuming more than 1/4 of your - * `memory_limit` for buffering multiple concurrent HTTP requests. As such, with - * the above default settings of `128M` max, it will try to consume no more than - * `32M` for buffering multiple concurrent HTTP requests. As a consequence, it - * will limit the concurrency to 4 HTTP requests with the above defaults. - * - * It is imperative that you assign reasonable values to your PHP ini settings. - * It is usually recommended to either reduce the memory a single request is - * allowed to take (set `post_max_size 1M` or less) or to increase the total memory - * limit to allow for more concurrent requests (set `memory_limit 512M` or more). - * Failure to do so means that this class may have to disable concurrency and - * only handle one request at a time. - * - * Internally, this class automatically assigns these limits to the - * [middleware](#middleware) request handlers as described below. For more - * advanced use cases, you may also use the advanced - * [`StreamingServer`](#streamingserver) and assign these middleware request - * handlers yourself as described in the following chapters. - */ -final class Server extends EventEmitter -{ - /** - * @internal - */ - const MAXIMUM_CONCURRENT_REQUESTS = 100; - - /** - * @var StreamingServer - */ - private $streamingServer; - - /** - * @see StreamingServer::__construct() - */ - public function __construct($requestHandler) - { - if (!\is_callable($requestHandler) && !\is_array($requestHandler)) { - throw new \InvalidArgumentException('Invalid request handler given'); - } - - $middleware = array(); - $middleware[] = new LimitConcurrentRequestsMiddleware($this->getConcurrentRequestsLimit()); - $middleware[] = new RequestBodyBufferMiddleware(); - // Checking for an empty string because that is what a boolean - // false is returned as by ini_get depending on the PHP version. - // @link http://php.net/manual/en/ini.core.php#ini.enable-post-data-reading - // @link http://php.net/manual/en/function.ini-get.php#refsect1-function.ini-get-notes - // @link https://3v4l.org/qJtsa - $enablePostDataReading = \ini_get('enable_post_data_reading'); - if ($enablePostDataReading !== '') { - $middleware[] = new RequestBodyParserMiddleware(); - } - - if (\is_callable($requestHandler)) { - $middleware[] = $requestHandler; - } else { - $middleware = \array_merge($middleware, $requestHandler); - } - - $this->streamingServer = new StreamingServer($middleware); - - $that = $this; - $this->streamingServer->on('error', function ($error) use ($that) { - $that->emit('error', array($error)); - }); - } - - /** - * @see StreamingServer::listen() - */ - public function listen(ServerInterface $server) - { - $this->streamingServer->listen($server); - } - - /** - * @return int - * @codeCoverageIgnore - */ - private function getConcurrentRequestsLimit() - { - if (\ini_get('memory_limit') == -1) { - return self::MAXIMUM_CONCURRENT_REQUESTS; - } - - $availableMemory = IniUtil::iniSizeToBytes(\ini_get('memory_limit')) / 4; - $concurrentRequests = \ceil($availableMemory / IniUtil::iniSizeToBytes(\ini_get('post_max_size'))); - - if ($concurrentRequests >= self::MAXIMUM_CONCURRENT_REQUESTS) { - return self::MAXIMUM_CONCURRENT_REQUESTS; - } - - return $concurrentRequests; - } -} diff --git a/tests/BrowserTest.php b/tests/BrowserTest.php new file mode 100644 index 00000000..8f3e10bd --- /dev/null +++ b/tests/BrowserTest.php @@ -0,0 +1,545 @@ +loop = $this->createMock(LoopInterface::class); + $this->sender = $this->createMock(Transaction::class); + $this->browser = new Browser(null, $this->loop); + + $ref = new \ReflectionProperty($this->browser, 'transaction'); + $ref->setAccessible(true); + $ref->setValue($this->browser, $this->sender); + } + + public function testConstructWithoutLoopAssignsLoopAutomatically() + { + $browser = new Browser(); + + $ref = new \ReflectionProperty($browser, 'transaction'); + $ref->setAccessible(true); + $transaction = $ref->getValue($browser); + + $ref = new \ReflectionProperty($transaction, 'loop'); + $ref->setAccessible(true); + $loop = $ref->getValue($transaction); + + $this->assertInstanceOf(LoopInterface::class, $loop); + } + + public function testConstructWithConnectorAssignsGivenConnector() + { + $connector = $this->createMock(ConnectorInterface::class); + + $browser = new Browser($connector); + + $ref = new \ReflectionProperty($browser, 'transaction'); + $ref->setAccessible(true); + $transaction = $ref->getValue($browser); + + $ref = new \ReflectionProperty($transaction, 'sender'); + $ref->setAccessible(true); + $sender = $ref->getValue($transaction); + + $ref = new \ReflectionProperty($sender, 'http'); + $ref->setAccessible(true); + $client = $ref->getValue($sender); + + $ref = new \ReflectionProperty($client, 'connectionManager'); + $ref->setAccessible(true); + $connectionManager = $ref->getValue($client); + + $ref = new \ReflectionProperty($connectionManager, 'connector'); + $ref->setAccessible(true); + $ret = $ref->getValue($connectionManager); + + $this->assertSame($connector, $ret); + } + + public function testConstructWithLoopAssignsGivenLoop() + { + $browser = new Browser(null, $this->loop); + + $ref = new \ReflectionProperty($browser, 'transaction'); + $ref->setAccessible(true); + $transaction = $ref->getValue($browser); + + $ref = new \ReflectionProperty($transaction, 'loop'); + $ref->setAccessible(true); + $loop = $ref->getValue($transaction); + + $this->assertSame($this->loop, $loop); + } + + public function testGetSendsGetRequest() + { + $this->sender->expects($this->once())->method('send')->with($this->callback(function (RequestInterface $request) { + $this->assertEquals('GET', $request->getMethod()); + return true; + }))->willReturn(new Promise(function () { })); + + $this->browser->get('http://example.com/'); + } + + public function testPostSendsPostRequest() + { + $this->sender->expects($this->once())->method('send')->with($this->callback(function (RequestInterface $request) { + $this->assertEquals('POST', $request->getMethod()); + return true; + }))->willReturn(new Promise(function () { })); + + $this->browser->post('http://example.com/'); + } + + public function testHeadSendsHeadRequest() + { + $this->sender->expects($this->once())->method('send')->with($this->callback(function (RequestInterface $request) { + $this->assertEquals('HEAD', $request->getMethod()); + return true; + }))->willReturn(new Promise(function () { })); + + $this->browser->head('http://example.com/'); + } + + public function testPatchSendsPatchRequest() + { + $this->sender->expects($this->once())->method('send')->with($this->callback(function (RequestInterface $request) { + $this->assertEquals('PATCH', $request->getMethod()); + return true; + }))->willReturn(new Promise(function () { })); + + $this->browser->patch('http://example.com/'); + } + + public function testPutSendsPutRequest() + { + $this->sender->expects($this->once())->method('send')->with($this->callback(function (RequestInterface $request) { + $this->assertEquals('PUT', $request->getMethod()); + return true; + }))->willReturn(new Promise(function () { })); + + $this->browser->put('http://example.com/'); + } + + public function testDeleteSendsDeleteRequest() + { + $this->sender->expects($this->once())->method('send')->with($this->callback(function (RequestInterface $request) { + $this->assertEquals('DELETE', $request->getMethod()); + return true; + }))->willReturn(new Promise(function () { })); + + $this->browser->delete('http://example.com/'); + } + + public function testRequestOptionsSendsPutRequestWithStreamingExplicitlyDisabled() + { + $this->sender->expects($this->once())->method('withOptions')->with(['streaming' => false])->willReturnSelf(); + + $this->sender->expects($this->once())->method('send')->with($this->callback(function (RequestInterface $request) { + $this->assertEquals('OPTIONS', $request->getMethod()); + return true; + }))->willReturn(new Promise(function () { })); + + $this->browser->request('OPTIONS', 'http://example.com/'); + } + + public function testRequestStreamingGetSendsGetRequestWithStreamingExplicitlyEnabled() + { + $this->sender->expects($this->once())->method('withOptions')->with(['streaming' => true])->willReturnSelf(); + + $this->sender->expects($this->once())->method('send')->with($this->callback(function (RequestInterface $request) { + $this->assertEquals('GET', $request->getMethod()); + return true; + }))->willReturn(new Promise(function () { })); + + $this->browser->requestStreaming('GET', 'http://example.com/'); + } + + public function testWithTimeoutTrueSetsDefaultTimeoutOption() + { + $this->sender->expects($this->once())->method('withOptions')->with(['timeout' => null])->willReturnSelf(); + + $this->browser->withTimeout(true); + } + + public function testWithTimeoutFalseSetsNegativeTimeoutOption() + { + $this->sender->expects($this->once())->method('withOptions')->with(['timeout' => -1])->willReturnSelf(); + + $this->browser->withTimeout(false); + } + + public function testWithTimeout10SetsTimeoutOption() + { + $this->sender->expects($this->once())->method('withOptions')->with(['timeout' => 10])->willReturnSelf(); + + $this->browser->withTimeout(10); + } + + public function testWithTimeoutNegativeSetsZeroTimeoutOption() + { + $this->sender->expects($this->once())->method('withOptions')->with(['timeout' => null])->willReturnSelf(); + + $this->browser->withTimeout(-10); + } + + public function testWithFollowRedirectsTrueSetsSenderOption() + { + $this->sender->expects($this->once())->method('withOptions')->with(['followRedirects' => true, 'maxRedirects' => null])->willReturnSelf(); + + $this->browser->withFollowRedirects(true); + } + + public function testWithFollowRedirectsFalseSetsSenderOption() + { + $this->sender->expects($this->once())->method('withOptions')->with(['followRedirects' => false, 'maxRedirects' => null])->willReturnSelf(); + + $this->browser->withFollowRedirects(false); + } + + public function testWithFollowRedirectsTenSetsSenderOption() + { + $this->sender->expects($this->once())->method('withOptions')->with(['followRedirects' => true, 'maxRedirects' => 10])->willReturnSelf(); + + $this->browser->withFollowRedirects(10); + } + + public function testWithFollowRedirectsZeroSetsSenderOption() + { + $this->sender->expects($this->once())->method('withOptions')->with(['followRedirects' => true, 'maxRedirects' => 0])->willReturnSelf(); + + $this->browser->withFollowRedirects(0); + } + + public function testWithRejectErrorResponseTrueSetsSenderOption() + { + $this->sender->expects($this->once())->method('withOptions')->with(['obeySuccessCode' => true])->willReturnSelf(); + + $this->browser->withRejectErrorResponse(true); + } + + public function testWithRejectErrorResponseFalseSetsSenderOption() + { + $this->sender->expects($this->once())->method('withOptions')->with(['obeySuccessCode' => false])->willReturnSelf(); + + $this->browser->withRejectErrorResponse(false); + } + + public function testWithResponseBufferThousandSetsSenderOption() + { + $this->sender->expects($this->once())->method('withOptions')->with(['maximumSize' => 1000])->willReturnSelf(); + + $this->browser->withResponseBuffer(1000); + } + + public function testWithBase() + { + $browser = $this->browser->withBase('http://example.com/root'); + + $this->assertInstanceOf(Browser::class, $browser); + $this->assertNotSame($this->browser, $browser); + } + + public static function provideOtherUris() + { + yield 'empty returns base' => [ + 'http://example.com/base', + '', + 'http://example.com/base', + ]; + yield 'absolute same as base returns base' => [ + 'http://example.com/base', + 'http://example.com/base', + 'http://example.com/base', + ]; + yield 'absolute below base returns absolute' => [ + 'http://example.com/base', + 'http://example.com/base/another', + 'http://example.com/base/another', + ]; + yield 'slash returns base without path' => [ + 'http://example.com/base', + '/', + 'http://example.com/', + ]; + yield 'relative is added behind base' => [ + 'http://example.com/base/', + 'test', + 'http://example.com/base/test', + ]; + yield 'relative is added behind base without path' => [ + 'http://example.com/base', + 'test', + 'http://example.com/test', + ]; + yield 'relative level up is added behind parent path' => [ + 'http://example.com/base/foo/', + '../bar', + 'http://example.com/base/bar', + ]; + yield 'absolute with slash is added behind base without path' => [ + 'http://example.com/base', + '/test', + 'http://example.com/test', + ]; + yield 'query string is added behind base' => [ + 'http://example.com/base', + '?key=value', + 'http://example.com/base?key=value', + ]; + yield 'query string is added behind base with slash' => [ + 'http://example.com/base/', + '?key=value', + 'http://example.com/base/?key=value', + ]; + yield 'query string with slash is added behind base without path' => [ + 'http://example.com/base', + '/?key=value', + 'http://example.com/?key=value', + ]; + yield 'absolute with query string below base is returned as-is' => [ + 'http://example.com/base', + 'http://example.com/base?test', + 'http://example.com/base?test', + ]; + yield 'urlencoded special chars will stay as-is' => [ + 'http://example.com/%7Bversion%7D/', + '', + 'http://example.com/%7Bversion%7D/' + ]; + yield 'special chars will be urlencoded' => [ + 'http://example.com/%7Bversion%7D/', + '', + 'http://example.com/%7Bversion%7D/' + ]; + yield 'other domain' => [ + 'http://example.com/base/', + 'http://example.org/base/', + 'http://example.org/base/' + ]; + yield 'other scheme' => [ + 'http://example.com/base/', + 'https://example.com/base/', + 'https://example.com/base/' + ]; + yield 'other port' => [ + 'http://example.com/base/', + 'http://example.com:81/base/', + 'http://example.com:81/base/' + ]; + yield 'other path' => [ + 'http://example.com/base/', + 'http://example.com/other/', + 'http://example.com/other/' + ]; + yield 'other path due to missing slash' => [ + 'http://example.com/base/', + 'http://example.com/other', + 'http://example.com/other' + ]; + } + + /** + * @dataProvider provideOtherUris + * @param string $uri + * @param string $expected + */ + public function testResolveUriWithBaseEndsWithoutSlash($base, $uri, $expectedAbsolute) + { + $browser = $this->browser->withBase($base); + + $this->sender->expects($this->once())->method('send')->with($this->callback(function (RequestInterface $request) use ($expectedAbsolute) { + $this->assertEquals($expectedAbsolute, $request->getUri()); + return true; + }))->willReturn(new Promise(function () { })); + + $browser->get($uri); + } + + public function testWithBaseUrlNotAbsoluteFails() + { + $this->expectException(\InvalidArgumentException::class); + $this->browser->withBase('hello'); + } + + public function testWithBaseUrlInvalidSchemeFails() + { + $this->expectException(\InvalidArgumentException::class); + $this->browser->withBase('ftp://example.com'); + } + + public function testWithoutBaseFollowedByGetRequestTriesToSendIncompleteRequestUrl() + { + $this->browser = $this->browser->withBase('http://example.com/')->withBase(null); + + $this->sender->expects($this->once())->method('send')->with($this->callback(function (RequestInterface $request) { + $this->assertEquals('path', $request->getUri()); + return true; + }))->willReturn(new Promise(function () { })); + + $this->browser->get('path'); + } + + public function testWithProtocolVersionFollowedByGetRequestSendsRequestWithProtocolVersion() + { + $this->browser = $this->browser->withProtocolVersion('1.0'); + + $this->sender->expects($this->once())->method('send')->with($this->callback(function (RequestInterface $request) { + $this->assertEquals('1.0', $request->getProtocolVersion()); + return true; + }))->willReturn(new Promise(function () { })); + + $this->browser->get('http://example.com/'); + } + + public function testWithProtocolVersionInvalidThrows() + { + $this->expectException(\InvalidArgumentException::class); + $this->browser->withProtocolVersion('1.2'); + } + + public function testCancelGetRequestShouldCancelUnderlyingSocketConnection() + { + $pending = new Promise(function () { }, $this->expectCallableOnce()); + + $connector = $this->createMock(ConnectorInterface::class); + $connector->expects($this->once())->method('connect')->with('example.com:80')->willReturn($pending); + + $this->browser = new Browser($connector, $this->loop); + + $promise = $this->browser->get('http://example.com/'); + $promise->cancel(); + } + + public function testWithHeaderShouldOverwriteExistingHeader() + { + $this->browser = $this->browser->withHeader('User-Agent', 'ACMC'); //should be overwritten + $this->browser = $this->browser->withHeader('user-agent', 'ABC'); //should be the user-agent + + $this->sender->expects($this->once())->method('send')->with($this->callback(function (RequestInterface $request) { + $this->assertEquals(['ABC'], $request->getHeader('UsEr-AgEnT')); + return true; + }))->willReturn(new Promise(function () { })); + + $this->browser->get('http://example.com/'); + } + + public function testWithHeaderShouldBeOverwrittenByExplicitHeaderInGetMethod() + { + $this->browser = $this->browser->withHeader('User-Agent', 'ACMC'); + + $this->sender->expects($this->once())->method('send')->with($this->callback(function (RequestInterface $request) { + $this->assertEquals(['ABC'], $request->getHeader('UsEr-AgEnT')); + return true; + }))->willReturn(new Promise(function () { })); + + $this->browser->get('http://example.com/', ['user-Agent' => 'ABC']); //should win + } + + public function testWithMultipleHeadersShouldBeMergedCorrectlyWithMultipleDefaultHeaders() + { + $this->browser = $this->browser->withHeader('User-Agent', 'ACMC'); + $this->browser = $this->browser->withHeader('User-Test', 'Test'); + $this->browser = $this->browser->withHeader('Custom-HEADER', 'custom'); + $this->browser = $this->browser->withHeader('just-a-header', 'header-value'); + + $this->sender->expects($this->once())->method('send')->with($this->callback(function (RequestInterface $request) { + $expectedHeaders = [ + 'Host' => ['example.com'], + + 'User-Test' => ['Test'], + 'just-a-header' => ['header-value'], + + 'user-Agent' => ['ABC'], + 'another-header' => ['value'], + 'custom-header' => ['data'], + ]; + + $this->assertEquals($expectedHeaders, $request->getHeaders()); + return true; + }))->willReturn(new Promise(function () { })); + + $headers = [ + 'user-Agent' => 'ABC', //should overwrite: 'User-Agent', 'ACMC' + 'another-header' => 'value', + 'custom-header' => 'data', //should overwrite: 'Custom-header', 'custom' + ]; + $this->browser->get('http://example.com/', $headers); + } + + public function testWithoutHeaderShouldRemoveExistingHeader() + { + $this->browser = $this->browser->withHeader('User-Agent', 'ACMC'); + $this->browser = $this->browser->withoutHeader('UsEr-AgEnT'); //should remove case-insensitive header + + $this->sender->expects($this->once())->method('send')->with($this->callback(function (RequestInterface $request) { + $this->assertEquals([], $request->getHeader('user-agent')); + return true; + }))->willReturn(new Promise(function () { })); + + $this->browser->get('http://example.com/'); + } + + public function testWithoutHeaderConnectionShouldRemoveDefaultConnectionHeader() + { + $this->browser = $this->browser->withoutHeader('Connection'); + + $this->sender->expects($this->once())->method('send')->with($this->callback(function (RequestInterface $request) { + $this->assertEquals([], $request->getHeader('Connection')); + return true; + }))->willReturn(new Promise(function () { })); + + $this->browser->get('http://example.com/'); + } + + public function testWithHeaderConnectionShouldOverwriteDefaultConnectionHeader() + { + $this->browser = $this->browser->withHeader('Connection', 'keep-alive'); + + $this->sender->expects($this->once())->method('send')->with($this->callback(function (RequestInterface $request) { + $this->assertEquals(['keep-alive'], $request->getHeader('Connection')); + return true; + }))->willReturn(new Promise(function () { })); + + $this->browser->get('http://example.com/'); + } + + public function testBrowserShouldSendDefaultUserAgentHeader() + { + $this->sender->expects($this->once())->method('send')->with($this->callback(function (RequestInterface $request) { + $this->assertEquals([0 => 'ReactPHP/1'], $request->getHeader('user-agent')); + return true; + }))->willReturn(new Promise(function () { })); + + $this->browser->get('http://example.com/'); + } + + public function testBrowserShouldNotSendDefaultUserAgentHeaderIfWithoutHeaderRemovesUserAgent() + { + $this->browser = $this->browser->withoutHeader('UsEr-AgEnT'); + + $this->sender->expects($this->once())->method('send')->with($this->callback(function (RequestInterface $request) { + $this->assertEquals([], $request->getHeader('User-Agent')); + return true; + }))->willReturn(new Promise(function () { })); + + $this->browser->get('http://example.com/'); + } +} diff --git a/tests/CallableStub.php b/tests/CallableStub.php deleted file mode 100644 index cfb8acfa..00000000 --- a/tests/CallableStub.php +++ /dev/null @@ -1,10 +0,0 @@ -on('connection', $this->expectCallableOnce()); + $socket->on('connection', function (ConnectionInterface $conn) use ($socket) { + $conn->end("HTTP/1.1 200 OK\r\n\r\nOk"); + $socket->close(); + }); + $port = parse_url(http://github.com/$socket-%3EgetAddress(), PHP_URL_PORT); + + $client = new Client(new ClientConnectionManager(new Connector(), Loop::get())); + $request = $client->request(new Request('GET', 'http://localhost/' . $port, [], '', '1.0')); + + $promise = first($request, 'close'); + $request->end(); + + await(timeout($promise, self::TIMEOUT_LOCAL)); + } + + public function testRequestToLocalhostWillConnectAndCloseConnectionAfterResponseWhenKeepAliveTimesOut() + { + $socket = new SocketServer('127.0.0.1:0'); + $socket->on('connection', $this->expectCallableOnce()); + + $promise = new Promise(function ($resolve) use ($socket) { + $socket->on('connection', function (ConnectionInterface $conn) use ($socket, $resolve) { + $conn->write("HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nOK"); + $conn->on('close', function () use ($resolve) { + $resolve(null); + }); + $socket->close(); + }); + }); + $port = parse_url(http://github.com/$socket-%3EgetAddress(), PHP_URL_PORT); + + $client = new Client(new ClientConnectionManager(new Connector(), Loop::get())); + $request = $client->request(new Request('GET', 'http://localhost/' . $port, [], '', '1.1')); + + $request->end(); + + await(timeout($promise, self::TIMEOUT_LOCAL)); + } + + public function testRequestToLocalhostWillReuseExistingConnectionForSecondRequest() + { + $socket = new SocketServer('127.0.0.1:0'); + $socket->on('connection', $this->expectCallableOnce()); + + $socket->on('connection', function (ConnectionInterface $connection) use ($socket) { + $connection->on('data', function () use ($connection) { + $connection->write("HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nOK"); + }); + $socket->close(); + }); + $port = parse_url(http://github.com/$socket-%3EgetAddress(), PHP_URL_PORT); + + $client = new Client(new ClientConnectionManager(new Connector(), Loop::get())); + + $request = $client->request(new Request('GET', 'http://localhost/' . $port, [], '', '1.1')); + $promise = first($request, 'close'); + $request->end(); + + await(timeout($promise, self::TIMEOUT_LOCAL)); + + $request = $client->request(new Request('GET', 'http://localhost/' . $port, [], '', '1.1')); + $promise = first($request, 'close'); + $request->end(); + + await(timeout($promise, self::TIMEOUT_LOCAL)); + } + + public function testRequestLegacyHttpServerWithOnlyLineFeedReturnsSuccessfulResponse() + { + $socket = new SocketServer('127.0.0.1:0'); + $socket->on('connection', function (ConnectionInterface $conn) use ($socket) { + $conn->end("HTTP/1.0 200 OK\n\nbody"); + $socket->close(); + }); + + $client = new Client(new ClientConnectionManager(new Connector(), Loop::get())); + $request = $client->request(new Request('GET', str_replace('tcp:', 'http:', $socket->getAddress()), [], '', '1.0')); + + $once = $this->expectCallableOnceWith('body'); + $request->on('response', function (ResponseInterface $response, ReadableStreamInterface $body) use ($once) { + $body->on('data', $once); + }); + + $promise = first($request, 'close'); + $request->end(); + + await(timeout($promise, self::TIMEOUT_LOCAL)); + } + + /** @group internet */ + public function testSuccessfulResponseEmitsEnd() + { + $client = new Client(new ClientConnectionManager(new Connector(), Loop::get())); + + $request = $client->request(new Request('GET', 'http://www.google.com/', [], '', '1.0')); + + $once = $this->expectCallableOnce(); + $request->on('response', function (ResponseInterface $response, ReadableStreamInterface $body) use ($once) { + $body->on('end', $once); + }); + + $promise = first($request, 'close'); + $request->end(); + + await(timeout($promise, self::TIMEOUT_REMOTE)); + } + + /** @group internet */ + public function testCancelPendingConnectionEmitsClose() + { + $client = new Client(new ClientConnectionManager(new Connector(), Loop::get())); + + $request = $client->request(new Request('GET', 'http://www.google.com/', [], '', '1.0')); + $request->on('error', $this->expectCallableNever()); + $request->on('close', $this->expectCallableOnce()); + $request->end(); + $request->close(); + } +} diff --git a/tests/FunctionalBrowserTest.php b/tests/FunctionalBrowserTest.php new file mode 100644 index 00000000..210cfa50 --- /dev/null +++ b/tests/FunctionalBrowserTest.php @@ -0,0 +1,800 @@ +browser = new Browser(); + + $http = new HttpServer(new StreamingRequestMiddleware(), function (ServerRequestInterface $request) { + $path = $request->getUri()->getPath(); + + $headers = []; + foreach ($request->getHeaders() as $name => $values) { + $headers[$name] = implode(', ', $values); + } + + if ($path === '/get') { + return new Response( + 200, + [], + 'hello' + ); + } + + if ($path === '/redirect-to') { + $params = $request->getQueryParams(); + return new Response( + 302, + ['Location' => $params['url']] + ); + } + + if ($path === '/basic-auth/user/pass') { + return new Response( + $request->getHeaderLine('Authorization') === 'Basic dXNlcjpwYXNz' ? 200 : 401, + [], + '' + ); + } + + if ($path === '/status/204') { + return new Response( + 204, + [], + '' + ); + } + + if ($path === '/status/304') { + return new Response( + 304, + [], + 'Not modified' + ); + } + + if ($path === '/status/404') { + return new Response( + 404, + [], + '' + ); + } + + if ($path === '/delay/10') { + $timer = null; + return new Promise(function ($resolve) use (&$timer) { + $timer = Loop::addTimer(10, function () use ($resolve) { + $resolve(new Response( + 200, + [], + 'hello' + )); + }); + }, function () use (&$timer) { + Loop::cancelTimer($timer); + }); + } + + if ($path === '/post') { + return new Promise(function ($resolve) use ($request, $headers) { + $body = $request->getBody(); + assert($body instanceof ReadableStreamInterface); + + $buffer = ''; + $body->on('data', function ($data) use (&$buffer) { + $buffer .= $data; + }); + + $body->on('close', function () use (&$buffer, $resolve, $headers) { + $resolve(new Response( + 200, + [], + json_encode([ + 'data' => $buffer, + 'headers' => $headers + ]) + )); + }); + }); + } + + if ($path === '/stream/1') { + $stream = new ThroughStream(); + + Loop::futureTick(function () use ($stream, $headers) { + $stream->end(json_encode([ + 'headers' => $headers + ])); + }); + + return new Response( + 200, + [], + $stream + ); + } + + var_dump($path); + }); + + $this->socket = new SocketServer('127.0.0.1:0'); + $http->listen($this->socket); + + $this->base = str_replace('tcp:', 'http:', $this->socket->getAddress()) . '/'; + } + + /** + * @after + */ + public function cleanUpSocketServer() + { + $this->socket->close(); + $this->socket = null; + } + + /** + * @doesNotPerformAssertions + */ + public function testSimpleRequest() + { + await($this->browser->get($this->base . 'get')); + } + + public function testGetRequestWithRelativeAddressRejects() + { + $promise = $this->browser->get('delay'); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid request URL given'); + await($promise); + } + + /** + * @doesNotPerformAssertions + */ + public function testGetRequestWithBaseAndRelativeAddressResolves() + { + await($this->browser->withBase($this->base)->get('get')); + } + + /** + * @doesNotPerformAssertions + */ + public function testGetRequestWithBaseAndFullAddressResolves() + { + await($this->browser->withBase('http://example.com/')->get($this->base . 'get')); + } + + public function testCancelGetRequestWillRejectRequest() + { + $promise = $this->browser->get($this->base . 'get'); + $promise->cancel(); + + $this->expectException(\RuntimeException::class); + await($promise); + } + + public function testCancelRequestWithPromiseFollowerWillRejectRequest() + { + $promise = $this->browser->request('GET', $this->base . 'get')->then(function () { + var_dump('noop'); + }); + $promise->cancel(); + + $this->expectException(\RuntimeException::class); + await($promise); + } + + public function testRequestWithoutAuthenticationFails() + { + $this->expectException(\RuntimeException::class); + await($this->browser->get($this->base . 'basic-auth/user/pass')); + } + + /** + * @doesNotPerformAssertions + */ + public function testRequestWithAuthenticationSucceeds() + { + $base = str_replace('://', '://user:pass@', $this->base); + + await($this->browser->get($base . 'basic-auth/user/pass')); + } + + /** + * ```bash + * $ curl -vL "http://httpbingo.org/redirect-to?url=http://user:pass@httpbingo.org/basic-auth/user/pass" + * ``` + * + * @doesNotPerformAssertions + */ + public function testRedirectToPageWithAuthenticationSendsAuthenticationFromLocationHeader() + { + $target = str_replace('://', '://user:pass@', $this->base) . 'basic-auth/user/pass'; + + await($this->browser->get($this->base . 'redirect-to?url=' . urlencode($target))); + } + + /** + * ```bash + * $ curl -vL "http://unknown:invalid@httpbingo.org/redirect-to?url=http://user:pass@httpbingo.org/basic-auth/user/pass" + * ``` + * + * @doesNotPerformAssertions + */ + public function testRedirectFromPageWithInvalidAuthToPageWithCorrectAuthenticationSucceeds() + { + $base = str_replace('://', '://unknown:invalid@', $this->base); + $target = str_replace('://', '://user:pass@', $this->base) . 'basic-auth/user/pass'; + + await($this->browser->get($base . 'redirect-to?url=' . urlencode($target))); + } + + public function testCancelRedirectedRequestShouldReject() + { + $promise = $this->browser->get($this->base . 'redirect-to?url=delay%2F10'); + + Loop::addTimer(0.1, function () use ($promise) { + $promise->cancel(); + }); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Request cancelled'); + await($promise); + } + + public function testTimeoutDelayedResponseShouldReject() + { + $promise = $this->browser->withTimeout(0.1)->get($this->base . 'delay/10'); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Request timed out after 0.1 seconds'); + await($promise); + } + + public function testTimeoutDelayedResponseAfterStreamingRequestShouldReject() + { + $stream = new ThroughStream(); + $promise = $this->browser->withTimeout(0.1)->post($this->base . 'delay/10', [], $stream); + $stream->end(); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Request timed out after 0.1 seconds'); + await($promise); + } + + /** + * @doesNotPerformAssertions + */ + public function testTimeoutFalseShouldResolveSuccessfully() + { + await($this->browser->withTimeout(false)->get($this->base . 'get')); + } + + /** + * @doesNotPerformAssertions + */ + public function testRedirectRequestRelative() + { + await($this->browser->get($this->base . 'redirect-to?url=get')); + } + + /** + * @doesNotPerformAssertions + */ + public function testRedirectRequestAbsolute() + { + await($this->browser->get($this->base . 'redirect-to?url=' . urlencode($this->base . 'get'))); + } + + /** + * @doesNotPerformAssertions + */ + public function testFollowingRedirectsFalseResolvesWithRedirectResult() + { + $browser = $this->browser->withFollowRedirects(false); + + await($browser->get($this->base . 'redirect-to?url=get')); + } + + public function testFollowRedirectsZeroRejectsOnRedirect() + { + $browser = $this->browser->withFollowRedirects(0); + + $this->expectException(\RuntimeException::class); + await($browser->get($this->base . 'redirect-to?url=get')); + } + + public function testResponseStatus204ShouldResolveWithEmptyBody() + { + $response = await($this->browser->get($this->base . 'status/204')); + $this->assertFalse($response->hasHeader('Content-Length')); + + $body = $response->getBody(); + $this->assertEquals(0, $body->getSize()); + $this->assertEquals('', (string) $body); + } + + public function testResponseStatus304ShouldResolveWithEmptyBodyButContentLengthResponseHeader() + { + $response = await($this->browser->get($this->base . 'status/304')); + $this->assertEquals('12', $response->getHeaderLine('Content-Length')); + + $body = $response->getBody(); + $this->assertEquals(0, $body->getSize()); + $this->assertEquals('', (string) $body); + } + + /** + * @doesNotPerformAssertions + */ + public function testGetRequestWithResponseBufferMatchedExactlyResolves() + { + $promise = $this->browser->withResponseBuffer(5)->get($this->base . 'get'); + + await($promise); + } + + public function testGetRequestWithResponseBufferExceededRejects() + { + $promise = $this->browser->withResponseBuffer(4)->get($this->base . 'get'); + + $this->expectException(\OverflowException::class); + $this->expectExceptionMessage('Response body size of 5 bytes exceeds maximum of 4 bytes'); + $this->expectExceptionCode(defined('SOCKET_EMSGSIZE') ? SOCKET_EMSGSIZE : 90); + await($promise); + } + + public function testGetRequestWithResponseBufferExceededDuringStreamingRejects() + { + $promise = $this->browser->withResponseBuffer(4)->get($this->base . 'stream/1'); + + $this->expectException(\OverflowException::class); + $this->expectExceptionMessage('Response body size exceeds maximum of 4 bytes'); + $this->expectExceptionCode(defined('SOCKET_EMSGSIZE') ? SOCKET_EMSGSIZE : 90); + await($promise); + } + + /** + * @group internet + * @doesNotPerformAssertions + */ + public function testCanAccessHttps() + { + await($this->browser->get('https://www.google.com/')); + } + + /** + * @group internet + */ + public function testVerifyPeerEnabledForBadSslRejects() + { + $connector = new Connector([ + 'tls' => [ + 'verify_peer' => true + ] + ]); + + $browser = new Browser($connector); + + $this->expectException(\RuntimeException::class); + await($browser->get('https://self-signed.badssl.com/')); + } + + /** + * @group internet + * @doesNotPerformAssertions + */ + public function testVerifyPeerDisabledForBadSslResolves() + { + $connector = new Connector([ + 'tls' => [ + 'verify_peer' => false + ] + ]); + + $browser = new Browser($connector); + + await($browser->get('https://self-signed.badssl.com/')); + } + + /** + * @group internet + */ + public function testInvalidPort() + { + $this->expectException(\RuntimeException::class); + await($this->browser->get('http://www.google.com:443/')); + } + + public function testErrorStatusCodeRejectsWithResponseException() + { + try { + await($this->browser->get($this->base . 'status/404')); + $this->fail(); + } catch (ResponseException $e) { + $this->assertEquals(404, $e->getCode()); + + $this->assertInstanceOf(ResponseInterface::class, $e->getResponse()); + $this->assertEquals(404, $e->getResponse()->getStatusCode()); + } + } + + public function testErrorStatusCodeDoesNotRejectWithRejectErrorResponseFalse() + { + $response = await($this->browser->withRejectErrorResponse(false)->get($this->base . 'status/404')); + + $this->assertEquals(404, $response->getStatusCode()); + } + + public function testPostString() + { + $response = await($this->browser->post($this->base . 'post', [], 'hello world')); + $data = json_decode((string)$response->getBody(), true); + + $this->assertEquals('hello world', $data['data']); + } + + public function testRequestStreamReturnsResponseBodyUntilConnectionsEndsForHttp10() + { + $response = await($this->browser->withProtocolVersion('1.0')->get($this->base . 'stream/1')); + + $this->assertEquals('1.0', $response->getProtocolVersion()); + $this->assertFalse($response->hasHeader('Transfer-Encoding')); + + $this->assertStringStartsWith('{', (string) $response->getBody()); + $this->assertStringEndsWith('}', (string) $response->getBody()); + } + + public function testRequestStreamReturnsResponseWithTransferEncodingChunkedAndResponseBodyDecodedForHttp11() + { + $response = await($this->browser->get($this->base . 'stream/1')); + + $this->assertEquals('1.1', $response->getProtocolVersion()); + + $this->assertEquals('chunked', $response->getHeaderLine('Transfer-Encoding')); + + $this->assertStringStartsWith('{', (string) $response->getBody()); + $this->assertStringEndsWith('}', (string) $response->getBody()); + } + + public function testRequestStreamWithHeadRequestReturnsEmptyResponseBodWithTransferEncodingChunkedForHttp11() + { + $response = await($this->browser->head($this->base . 'stream/1')); + + $this->assertEquals('1.1', $response->getProtocolVersion()); + + $this->assertEquals('chunked', $response->getHeaderLine('Transfer-Encoding')); + $this->assertEquals('', (string) $response->getBody()); + } + + public function testRequestStreamReturnsResponseWithResponseBodyUndecodedWhenResponseHasDoubleTransferEncoding() + { + $socket = new SocketServer('127.0.0.1:0'); + $socket->on('connection', function (ConnectionInterface $connection) { + $connection->on('data', function () use ($connection) { + $connection->end("HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked, chunked\r\nConnection: close\r\n\r\nhello"); + }); + }); + + $this->base = str_replace('tcp:', 'http:', $socket->getAddress()) . '/'; + + $response = await($this->browser->get($this->base . 'stream/1')); + + $socket->close(); + + $this->assertEquals('1.1', $response->getProtocolVersion()); + + $this->assertEquals('chunked, chunked', $response->getHeaderLine('Transfer-Encoding')); + $this->assertEquals('hello', (string) $response->getBody()); + } + + public function testReceiveStreamAndExplicitlyCloseConnectionEvenWhenServerKeepsConnectionOpen() + { + $closed = new Deferred(); + $socket = new SocketServer('127.0.0.1:0'); + $socket->on('connection', function (ConnectionInterface $connection) use ($closed) { + $connection->on('data', function () use ($connection) { + $connection->write("HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nhello"); + }); + $connection->on('close', function () use ($closed) { + $closed->resolve(true); + }); + }); + + $this->base = str_replace('tcp:', 'http:', $socket->getAddress()) . '/'; + + $response = await($this->browser->get($this->base . 'get', [])); + $this->assertEquals('hello', (string)$response->getBody()); + + $ret = await(timeout($closed->promise(), 0.1)); + $this->assertTrue($ret); + + $socket->close(); + } + + public function testRequestWithConnectionCloseHeaderWillCreateNewConnectionForSecondRequestEvenWhenServerKeepsConnectionOpen() + { + $twice = $this->expectCallableOnce(); + $socket = new SocketServer('127.0.0.1:0'); + $socket->on('connection', function (ConnectionInterface $connection) use ($socket, $twice) { + $connection->on('data', function () use ($connection) { + $connection->write("HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nhello"); + }); + + $socket->on('connection', $twice); + $socket->on('connection', function () use ($socket) { + $socket->close(); + }); + }); + + $this->base = str_replace('tcp:', 'http:', $socket->getAddress()) . '/'; + + // add `Connection: close` request header to disable HTTP keep-alive + $this->browser = $this->browser->withHeader('Connection', 'close'); + + $response = await($this->browser->get($this->base . 'get')); + assert($response instanceof ResponseInterface); + $this->assertEquals('hello', (string)$response->getBody()); + + $response = await($this->browser->get($this->base . 'get')); + assert($response instanceof ResponseInterface); + $this->assertEquals('hello', (string)$response->getBody()); + } + + public function testRequestWithHttp10WillCreateNewConnectionForSecondRequestEvenWhenServerKeepsConnectionOpen() + { + $twice = $this->expectCallableOnce(); + $socket = new SocketServer('127.0.0.1:0'); + $socket->on('connection', function (ConnectionInterface $connection) use ($socket, $twice) { + $connection->on('data', function () use ($connection) { + $connection->write("HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nhello"); + }); + + $socket->on('connection', $twice); + $socket->on('connection', function () use ($socket) { + $socket->close(); + }); + }); + + $this->base = str_replace('tcp:', 'http:', $socket->getAddress()) . '/'; + + // use HTTP/1.0 to disable HTTP keep-alive + $this->browser = $this->browser->withProtocolVersion('1.0'); + + $response = await($this->browser->get($this->base . 'get')); + assert($response instanceof ResponseInterface); + $this->assertEquals('hello', (string)$response->getBody()); + + $response = await($this->browser->get($this->base . 'get')); + assert($response instanceof ResponseInterface); + $this->assertEquals('hello', (string)$response->getBody()); + } + + public function testRequestWillReuseExistingConnectionForSecondRequestByDefault() + { + $this->socket->on('connection', $this->expectCallableOnce()); + + $response = await($this->browser->get($this->base . 'get')); + assert($response instanceof ResponseInterface); + $this->assertEquals('hello', (string)$response->getBody()); + + $response = await($this->browser->get($this->base . 'get')); + assert($response instanceof ResponseInterface); + $this->assertEquals('hello', (string)$response->getBody()); + } + + public function testRequestWithHttp10AndConnectionKeepAliveHeaderWillReuseExistingConnectionForSecondRequest() + { + $this->socket->on('connection', $this->expectCallableOnce()); + + $this->browser = $this->browser->withProtocolVersion('1.0'); + $this->browser = $this->browser->withHeader('Connection', 'keep-alive'); + + $response = await($this->browser->get($this->base . 'get')); + assert($response instanceof ResponseInterface); + $this->assertEquals('hello', (string)$response->getBody()); + + $response = await($this->browser->get($this->base . 'get')); + assert($response instanceof ResponseInterface); + $this->assertEquals('hello', (string)$response->getBody()); + } + + public function testRequestWithoutConnectionHeaderWillReuseExistingConnectionForRedirectedRequest() + { + $this->socket->on('connection', $this->expectCallableOnce()); + + // remove default `Connection: close` request header to enable keep-alive + $this->browser = $this->browser->withoutHeader('Connection'); + + $response = await($this->browser->get($this->base . 'redirect-to?url=get')); + assert($response instanceof ResponseInterface); + $this->assertEquals('hello', (string)$response->getBody()); + } + + public function testPostStreamChunked() + { + $stream = new ThroughStream(); + + Loop::addTimer(0.001, function () use ($stream) { + $stream->end('hello world'); + }); + + $response = await($this->browser->post($this->base . 'post', [], $stream)); + $data = json_decode((string)$response->getBody(), true); + + $this->assertEquals('hello world', $data['data']); + $this->assertFalse(isset($data['headers']['Content-Length'])); + $this->assertEquals('chunked', $data['headers']['Transfer-Encoding']); + } + + public function testPostStreamKnownLength() + { + $stream = new ThroughStream(); + + Loop::addTimer(0.001, function () use ($stream) { + $stream->end('hello world'); + }); + + $response = await($this->browser->post($this->base . 'post', ['Content-Length' => 11], $stream)); + $data = json_decode((string)$response->getBody(), true); + + $this->assertEquals('hello world', $data['data']); + } + + /** + * @doesNotPerformAssertions + */ + public function testPostStreamWillStartSendingRequestEvenWhenBodyDoesNotEmitData() + { + $http = new HttpServer(new StreamingRequestMiddleware(), function (ServerRequestInterface $request) { + return new Response(200); + }); + $socket = new SocketServer('127.0.0.1:0'); + $http->listen($socket); + + $this->base = str_replace('tcp:', 'http:', $socket->getAddress()) . '/'; + + $stream = new ThroughStream(); + await($this->browser->post($this->base . 'post', [], $stream)); + + $socket->close(); + } + + public function testPostStreamClosed() + { + $stream = new ThroughStream(); + $stream->close(); + + $response = await($this->browser->post($this->base . 'post', [], $stream)); + $data = json_decode((string)$response->getBody(), true); + + $this->assertEquals('', $data['data']); + } + + public function testSendsHttp11ByDefault() + { + $http = new HttpServer(function (ServerRequestInterface $request) { + return new Response( + 200, + [], + $request->getProtocolVersion() + ); + }); + $socket = new SocketServer('127.0.0.1:0'); + $http->listen($socket); + + $this->base = str_replace('tcp:', 'http:', $socket->getAddress()) . '/'; + + $response = await($this->browser->get($this->base)); + $this->assertEquals('1.1', (string)$response->getBody()); + + $socket->close(); + } + + public function testSendsExplicitHttp10Request() + { + $http = new HttpServer(function (ServerRequestInterface $request) { + return new Response( + 200, + [], + $request->getProtocolVersion() + ); + }); + $socket = new SocketServer('127.0.0.1:0'); + $http->listen($socket); + + $this->base = str_replace('tcp:', 'http:', $socket->getAddress()) . '/'; + + $response = await($this->browser->withProtocolVersion('1.0')->get($this->base)); + $this->assertEquals('1.0', (string)$response->getBody()); + + $socket->close(); + } + + public function testHeadRequestReceivesResponseWithEmptyBodyButWithContentLengthResponseHeader() + { + $response = await($this->browser->head($this->base . 'get')); + $this->assertEquals('5', $response->getHeaderLine('Content-Length')); + + $body = $response->getBody(); + $this->assertEquals(0, $body->getSize()); + $this->assertEquals('', (string) $body); + } + + public function testRequestStreamingGetReceivesResponseWithStreamingBodyAndKnownSize() + { + $response = await($this->browser->requestStreaming('GET', $this->base . 'get')); + $this->assertEquals('5', $response->getHeaderLine('Content-Length')); + + $body = $response->getBody(); + $this->assertEquals(5, $body->getSize()); + $this->assertEquals('', (string) $body); + $this->assertInstanceOf(ReadableStreamInterface::class, $body); + } + + public function testRequestStreamingGetReceivesResponseWithStreamingBodyAndUnknownSizeFromStreamingEndpoint() + { + $response = await($this->browser->requestStreaming('GET', $this->base . 'stream/1')); + $this->assertFalse($response->hasHeader('Content-Length')); + + $body = $response->getBody(); + $this->assertNull($body->getSize()); + $this->assertEquals('', (string) $body); + $this->assertInstanceOf(ReadableStreamInterface::class, $body); + } + + public function testRequestStreamingGetReceivesStreamingResponseBody() + { + $buffer = await( + $this->browser->requestStreaming('GET', $this->base . 'get')->then(function (ResponseInterface $response) { + return buffer($response->getBody()); + }) + ); + + $this->assertEquals('hello', $buffer); + } + + public function testRequestStreamingGetReceivesStreamingResponseBodyEvenWhenResponseBufferExceeded() + { + $buffer = await( + $this->browser->withResponseBuffer(4)->requestStreaming('GET', $this->base . 'get')->then(function (ResponseInterface $response) { + return buffer($response->getBody()); + }) + ); + + $this->assertEquals('hello', $buffer); + } +} diff --git a/tests/FunctionalHttpServerTest.php b/tests/FunctionalHttpServerTest.php new file mode 100644 index 00000000..dc0bd276 --- /dev/null +++ b/tests/FunctionalHttpServerTest.php @@ -0,0 +1,762 @@ +getUri()); + }); + + $socket = new SocketServer('127.0.0.1:0'); + $http->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 buffer($conn); + }); + + $response = await(timeout($result, 1.0)); + + $this->assertStringContainsString("HTTP/1.0 200 OK", $response); + $this->assertStringContainsString('http://' . noScheme($socket->getAddress()) . '/', $response); + + $socket->close(); + } + + public function testPlainHttpOnRandomPortWithSingleRequestHandlerArray() + { + $connector = new Connector(); + + $http = new HttpServer( + function () { + return new Response(404); + } + ); + + $socket = new SocketServer('127.0.0.1:0'); + $http->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 buffer($conn); + }); + + $response = await(timeout($result, 1.0)); + + $this->assertStringContainsString("HTTP/1.0 404 Not Found", $response); + + $socket->close(); + } + + public function testPlainHttpOnRandomPortWithoutHostHeaderUsesSocketUri() + { + $connector = new Connector(); + + $http = new HttpServer(function (RequestInterface $request) { + return new Response(200, [], (string)$request->getUri()); + }); + + $socket = new SocketServer('127.0.0.1:0'); + $http->listen($socket); + + $result = $connector->connect($socket->getAddress())->then(function (ConnectionInterface $conn) { + $conn->write("GET / HTTP/1.0\r\n\r\n"); + + return buffer($conn); + }); + + $response = await(timeout($result, 1.0)); + + $this->assertStringContainsString("HTTP/1.0 200 OK", $response); + $this->assertStringContainsString('http://' . noScheme($socket->getAddress()) . '/', $response); + + $socket->close(); + } + + public function testPlainHttpOnRandomPortWithOtherHostHeaderTakesPrecedence() + { + $connector = new Connector(); + + $http = new HttpServer(function (RequestInterface $request) { + return new Response(200, [], (string)$request->getUri()); + }); + + $socket = new SocketServer('127.0.0.1:0'); + $http->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 buffer($conn); + }); + + $response = await(timeout($result, 1.0)); + + $this->assertStringContainsString("HTTP/1.0 200 OK", $response); + $this->assertStringContainsString('http://localhost:1000/', $response); + + $socket->close(); + } + + public function testSecureHttpsOnRandomPort() + { + $connector = new Connector([ + 'tls' => [ + 'verify_peer' => false + ] + ]); + + $http = new HttpServer(function (RequestInterface $request) { + return new Response(200, [], (string)$request->getUri()); + }); + + $socket = new SocketServer('tls://127.0.0.1:0', ['tls' => [ + 'local_cert' => __DIR__ . '/../examples/localhost.pem' + ]]); + $http->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 buffer($conn); + }); + + $response = await(timeout($result, 1.0)); + + $this->assertStringContainsString("HTTP/1.0 200 OK", $response); + $this->assertStringContainsString('https://' . noScheme($socket->getAddress()) . '/', $response); + + $socket->close(); + } + + public function testSecureHttpsReturnsData() + { + $http = new HttpServer(function (RequestInterface $request) { + return new Response( + 200, + [], + str_repeat('.', 33000) + ); + }); + + $socket = new SocketServer('tls://127.0.0.1:0', ['tls' => ['local_cert' => __DIR__ . '/../examples/localhost.pem']]); + $http->listen($socket); + + $connector = new Connector(['tls' => [ + 'verify_peer' => false + ]]); + + $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 buffer($conn); + }); + + $response = await(timeout($result, 1.0)); + + $this->assertStringContainsString("HTTP/1.0 200 OK", $response); + $this->assertStringContainsString("\r\nContent-Length: 33000\r\n", $response); + $this->assertStringEndsWith("\r\n". str_repeat('.', 33000), $response); + + $socket->close(); + } + + public function testSecureHttpsOnRandomPortWithoutHostHeaderUsesSocketUri() + { + $connector = new Connector([ + 'tls' => ['verify_peer' => false] + ]); + + $http = new HttpServer(function (RequestInterface $request) { + return new Response(200, [], (string)$request->getUri()); + }); + + $socket = new SocketServer('tls://127.0.0.1:0', ['tls' => [ + 'local_cert' => __DIR__ . '/../examples/localhost.pem' + ]]); + $http->listen($socket); + + $result = $connector->connect($socket->getAddress())->then(function (ConnectionInterface $conn) { + $conn->write("GET / HTTP/1.0\r\n\r\n"); + + return buffer($conn); + }); + + $response = await(timeout($result, 1.0)); + + $this->assertStringContainsString("HTTP/1.0 200 OK", $response); + $this->assertStringContainsString('https://' . noScheme($socket->getAddress()) . '/', $response); + + $socket->close(); + } + + public function testPlainHttpOnStandardPortReturnsUriWithNoPort() + { + try { + $socket = new SocketServer('127.0.0.1:80'); + } catch (\RuntimeException $e) { + $this->markTestSkipped('Listening on port 80 failed (root and unused?)'); + } + $connector = new Connector(); + + $http = new HttpServer(function (RequestInterface $request) { + return new Response(200, [], (string)$request->getUri()); + }); + + $http->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 buffer($conn); + }); + + $response = await(timeout($result, 1.0)); + + $this->assertStringContainsString("HTTP/1.0 200 OK", $response); + $this->assertStringContainsString('http://127.0.0.1/', $response); + + $socket->close(); + } + + public function testPlainHttpOnStandardPortWithoutHostHeaderReturnsUriWithNoPort() + { + try { + $socket = new SocketServer('127.0.0.1:80'); + } catch (\RuntimeException $e) { + $this->markTestSkipped('Listening on port 80 failed (root and unused?)'); + } + $connector = new Connector(); + + $http = new HttpServer(function (RequestInterface $request) { + return new Response(200, [], (string)$request->getUri()); + }); + + $http->listen($socket); + + $result = $connector->connect($socket->getAddress())->then(function (ConnectionInterface $conn) { + $conn->write("GET / HTTP/1.0\r\n\r\n"); + + return buffer($conn); + }); + + $response = await(timeout($result, 1.0)); + + $this->assertStringContainsString("HTTP/1.0 200 OK", $response); + $this->assertStringContainsString('http://127.0.0.1/', $response); + + $socket->close(); + } + + public function testSecureHttpsOnStandardPortReturnsUriWithNoPort() + { + try { + $socket = new SocketServer('tls://127.0.0.1:443', ['tls' => [ + 'local_cert' => __DIR__ . '/../examples/localhost.pem' + ]]); + } catch (\RuntimeException $e) { + $this->markTestSkipped('Listening on port 443 failed (root and unused?)'); + } + + $connector = new Connector([ + 'tls' => ['verify_peer' => false] + ]); + + $http = new HttpServer(function (RequestInterface $request) { + return new Response(200, [], (string)$request->getUri()); + }); + + $http->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 buffer($conn); + }); + + $response = await(timeout($result, 1.0)); + + $this->assertStringContainsString("HTTP/1.0 200 OK", $response); + $this->assertStringContainsString('https://127.0.0.1/', $response); + + $socket->close(); + } + + public function testSecureHttpsOnStandardPortWithoutHostHeaderUsesSocketUri() + { + try { + $socket = new SocketServer('tls://127.0.0.1:443', ['tls' => [ + 'local_cert' => __DIR__ . '/../examples/localhost.pem' + ]]); + } catch (\RuntimeException $e) { + $this->markTestSkipped('Listening on port 443 failed (root and unused?)'); + } + + $connector = new Connector([ + 'tls' => ['verify_peer' => false] + ]); + + $http = new HttpServer(function (RequestInterface $request) { + return new Response(200, [], (string)$request->getUri()); + }); + + $http->listen($socket); + + $result = $connector->connect($socket->getAddress())->then(function (ConnectionInterface $conn) { + $conn->write("GET / HTTP/1.0\r\n\r\n"); + + return buffer($conn); + }); + + $response = await(timeout($result, 1.0)); + + $this->assertStringContainsString("HTTP/1.0 200 OK", $response); + $this->assertStringContainsString('https://127.0.0.1/', $response); + + $socket->close(); + } + + public function testPlainHttpOnHttpsStandardPortReturnsUriWithPort() + { + try { + $socket = new SocketServer('127.0.0.1:443'); + } catch (\RuntimeException $e) { + $this->markTestSkipped('Listening on port 443 failed (root and unused?)'); + } + $connector = new Connector(); + + $http = new HttpServer(function (RequestInterface $request) { + return new Response(200, [], (string)$request->getUri()); + }); + + $http->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 buffer($conn); + }); + + $response = await(timeout($result, 1.0)); + + $this->assertStringContainsString("HTTP/1.0 200 OK", $response); + $this->assertStringContainsString('http://127.0.0.1:443/', $response); + + $socket->close(); + } + + public function testSecureHttpsOnHttpStandardPortReturnsUriWithPort() + { + try { + $socket = new SocketServer('tls://127.0.0.1:80', ['tls' => [ + 'local_cert' => __DIR__ . '/../examples/localhost.pem' + ]]); + } catch (\RuntimeException $e) { + $this->markTestSkipped('Listening on port 80 failed (root and unused?)'); + } + + $connector = new Connector([ + 'tls' => ['verify_peer' => false] + ]); + + $http = new HttpServer(function (RequestInterface $request) { + return new Response(200, [], (string)$request->getUri() . 'x' . $request->getHeaderLine('Host')); + }); + + $http->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 buffer($conn); + }); + + $response = await(timeout($result, 1.0)); + + $this->assertStringContainsString("HTTP/1.0 200 OK", $response); + $this->assertStringContainsString('https://127.0.0.1:80/', $response); + + $socket->close(); + } + + public function testClosedStreamFromRequestHandlerWillSendEmptyBody() + { + $connector = new Connector(); + + $stream = new ThroughStream(); + $stream->close(); + + $http = new HttpServer(function (RequestInterface $request) use ($stream) { + return new Response(200, [], $stream); + }); + + $socket = new SocketServer('127.0.0.1:0'); + $http->listen($socket); + + $result = $connector->connect($socket->getAddress())->then(function (ConnectionInterface $conn) { + $conn->write("GET / HTTP/1.0\r\n\r\n"); + + return buffer($conn); + }); + + $response = await(timeout($result, 1.0)); + + $this->assertStringStartsWith("HTTP/1.0 200 OK", $response); + $this->assertStringEndsWith("\r\n\r\n", $response); + + $socket->close(); + } + + public function testRequestHandlerWithStreamingRequestWillReceiveCloseEventIfConnectionClosesWhileSendingBody() + { + $connector = new Connector(); + + $once = $this->expectCallableOnce(); + $http = new HttpServer( + new StreamingRequestMiddleware(), + function (RequestInterface $request) use ($once) { + $request->getBody()->on('close', $once); + } + ); + + $socket = new SocketServer('127.0.0.1:0'); + $http->listen($socket); + + $connector->connect($socket->getAddress())->then(function (ConnectionInterface $conn) { + $conn->write("GET / HTTP/1.0\r\nContent-Length: 100\r\n\r\n"); + + Loop::addTimer(0.001, function() use ($conn) { + $conn->end(); + }); + }); + + await(sleep(0.1)); + + $socket->close(); + } + + public function testStreamFromRequestHandlerWillBeClosedIfConnectionClosesWhileSendingStreamingRequestBody() + { + $connector = new Connector(); + + $stream = new ThroughStream(); + + $http = new HttpServer( + new StreamingRequestMiddleware(), + function (RequestInterface $request) use ($stream) { + return new Response(200, [], $stream); + } + ); + + $socket = new SocketServer('127.0.0.1:0'); + $http->listen($socket); + + $connector->connect($socket->getAddress())->then(function (ConnectionInterface $conn) { + $conn->write("GET / HTTP/1.0\r\nContent-Length: 100\r\n\r\n"); + + Loop::addTimer(0.001, function() use ($conn) { + $conn->end(); + }); + }); + + // stream will be closed within 0.1s + $ret = await(timeout(first($stream, 'close'), 0.1)); + + $socket->close(); + + $this->assertNull($ret); + } + + public function testStreamFromRequestHandlerWillBeClosedIfConnectionCloses() + { + $connector = new Connector(); + + $stream = new ThroughStream(); + + $http = new HttpServer(function (RequestInterface $request) use ($stream) { + return new Response(200, [], $stream); + }); + + $socket = new SocketServer('127.0.0.1:0'); + $http->listen($socket); + + $connector->connect($socket->getAddress())->then(function (ConnectionInterface $conn) { + $conn->write("GET / HTTP/1.0\r\n\r\n"); + + Loop::addTimer(0.1, function () use ($conn) { + $conn->close(); + }); + }); + + // await response stream to be closed + $ret = await(timeout(first($stream, 'close'), 1.0)); + + $socket->close(); + + $this->assertNull($ret); + } + + public function testUpgradeWithThroughStreamReturnsDataAsGiven() + { + $connector = new Connector(); + + $http = new HttpServer(function (RequestInterface $request) { + $stream = new ThroughStream(); + + Loop::addTimer(0.1, function () use ($stream) { + $stream->end(); + }); + + return new Response(101, ['Upgrade' => 'echo'], $stream); + }); + + $socket = new SocketServer('127.0.0.1:0'); + $http->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 buffer($conn); + }); + + $response = await(timeout($result, 1.0)); + + $this->assertStringStartsWith("HTTP/1.1 101 Switching Protocols\r\n", $response); + $this->assertStringEndsWith("\r\n\r\nhelloworld", $response); + + $socket->close(); + } + + public function testUpgradeWithRequestBodyAndThroughStreamReturnsDataAsGiven() + { + $connector = new Connector(); + + $http = new HttpServer(function (RequestInterface $request) { + $stream = new ThroughStream(); + + Loop::addTimer(0.1, function () use ($stream) { + $stream->end(); + }); + + return new Response(101, ['Upgrade' => 'echo'], $stream); + }); + + $socket = new SocketServer('127.0.0.1:0'); + $http->listen($socket); + + $result = $connector->connect($socket->getAddress())->then(function (ConnectionInterface $conn) { + $conn->write("POST / HTTP/1.1\r\nHost: example.com:80\r\nUpgrade: echo\r\nContent-Length: 3\r\n\r\n"); + $conn->write('hoh'); + + $conn->once('data', function () use ($conn) { + $conn->write('hello'); + $conn->write('world'); + }); + + return buffer($conn); + }); + + $response = await(timeout($result, 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() + { + $connector = new Connector(); + + $http = new HttpServer(function (RequestInterface $request) { + $stream = new ThroughStream(); + + Loop::addTimer(0.1, function () use ($stream) { + $stream->end(); + }); + + return new Response(200, [], $stream); + }); + + $socket = new SocketServer('127.0.0.1:0'); + $http->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\nConnection: close\r\n\r\n"); + + $conn->once('data', function () use ($conn) { + $conn->write('hello'); + $conn->write('world'); + }); + + return buffer($conn); + }); + + $response = await(timeout($result, 1.0)); + + $this->assertStringStartsWith("HTTP/1.1 200 OK\r\n", $response); + $this->assertStringEndsWith("\r\n\r\nhelloworld", $response); + + $socket->close(); + } + + public function testConnectWithThroughStreamReturnedFromPromiseReturnsDataAsGiven() + { + $connector = new Connector(); + + $http = new HttpServer(function (RequestInterface $request) { + $stream = new ThroughStream(); + + Loop::addTimer(0.1, function () use ($stream) { + $stream->end(); + }); + + return new Promise(function ($resolve) use ($stream) { + Loop::addTimer(0.001, function () use ($resolve, $stream) { + $resolve(new Response(200, [], $stream)); + }); + }); + }); + + $socket = new SocketServer('127.0.0.1:0'); + $http->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\nConnection: close\r\n\r\n"); + + $conn->once('data', function () use ($conn) { + $conn->write('hello'); + $conn->write('world'); + }); + + return buffer($conn); + }); + + $response = await(timeout($result, 1.0)); + + $this->assertStringStartsWith("HTTP/1.1 200 OK\r\n", $response); + $this->assertStringEndsWith("\r\n\r\nhelloworld", $response); + + $socket->close(); + } + + public function testConnectWithClosedThroughStreamReturnsNoData() + { + $connector = new Connector(); + + $http = new HttpServer(function (RequestInterface $request) { + $stream = new ThroughStream(); + $stream->close(); + + return new Response(200, [], $stream); + }); + + $socket = new SocketServer('127.0.0.1:0'); + $http->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\nConnection: close\r\n\r\n"); + + $conn->once('data', function () use ($conn) { + $conn->write('hello'); + $conn->write('world'); + }); + + return buffer($conn); + }); + + $response = await(timeout($result, 1.0)); + + $this->assertStringStartsWith("HTTP/1.1 200 OK\r\n", $response); + $this->assertStringEndsWith("\r\n\r\n", $response); + + $socket->close(); + } + + public function testLimitConcurrentRequestsMiddlewareRequestStreamPausing() + { + $connector = new Connector(); + + $http = new HttpServer( + new LimitConcurrentRequestsMiddleware(5), + new RequestBodyBufferMiddleware(16 * 1024 * 1024), // 16 MiB + function (ServerRequestInterface $request, $next) { + return new Promise(function ($resolve) use ($request, $next) { + Loop::addTimer(0.1, function () use ($request, $resolve, $next) { + $resolve($next($request)); + }); + }); + }, + function (ServerRequestInterface $request) { + return new Response(200, [], (string)strlen((string)$request->getBody())); + } + ); + + $socket = new SocketServer('127.0.0.1:0'); + $http->listen($socket); + + $result = []; + for ($i = 0; $i < 6; $i++) { + $result[] = $connector->connect($socket->getAddress())->then(function (ConnectionInterface $conn) { + $conn->write( + "GET / HTTP/1.0\r\nContent-Length: 1024\r\nHost: " . noScheme($conn->getRemoteAddress()) . "\r\n\r\n" . + str_repeat('a', 1024) . + "\r\n\r\n" + ); + + return buffer($conn); + }); + } + + $responses = await(timeout(all($result), 1.0)); + + foreach ($responses as $response) { + $this->assertStringContainsString("HTTP/1.0 200 OK", $response, $response); + $this->assertTrue(substr($response, -4) == 1024, $response); + } + + $socket->close(); + } + +} + +function noScheme($uri) +{ + $pos = strpos($uri, '://'); + if ($pos !== false) { + $uri = substr($uri, $pos + 3); + } + return $uri; +} diff --git a/tests/FunctionalServerTest.php b/tests/FunctionalServerTest.php deleted file mode 100644 index 6202a99f..00000000 --- a/tests/FunctionalServerTest.php +++ /dev/null @@ -1,803 +0,0 @@ -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 testPlainHttpOnRandomPortWithSingleRequestHandlerArray() - { - $loop = Factory::create(); - $connector = new Connector($loop); - - $server = new StreamingServer(array( - function () { - return new Response(404); - }, - )); - - $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 404 Not Found", $response); - - $socket->close(); - } - - public function testPlainHttpOnRandomPortWithoutHostHeaderUsesSocketUri() - { - $loop = Factory::create(); - $connector = new Connector($loop); - - $server = new StreamingServer(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 StreamingServer(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 StreamingServer(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($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 testSecureHttpsReturnsData() - { - if (!function_exists('stream_socket_enable_crypto')) { - $this->markTestSkipped('Not supported on your platform (outdated HHVM?)'); - } - - $loop = Factory::create(); - - $server = new StreamingServer(function (RequestInterface $request) { - return new Response( - 200, - array(), - str_repeat('.', 33000) - ); - }); - - $socket = new Socket(0, $loop); - $socket = new SecureServer($socket, $loop, array( - 'local_cert' => __DIR__ . '/../examples/localhost.pem' - )); - $server->listen($socket); - - $connector = new Connector($loop, array( - 'tls' => array('verify_peer' => false) - )); - - $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("\r\nContent-Length: 33000\r\n", $response); - $this->assertStringEndsWith("\r\n". str_repeat('.', 33000), $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 StreamingServer(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($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 StreamingServer(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 StreamingServer(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 StreamingServer(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('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 StreamingServer(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('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 StreamingServer(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 StreamingServer(function (RequestInterface $request) { - return new Response(200, array(), (string)$request->getUri() . 'x' . $request->getHeaderLine('Host')); - }); - - $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('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 StreamingServer(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) { - $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 testRequestHandlerWillReceiveCloseEventIfConnectionClosesWhileSendingBody() - { - $loop = Factory::create(); - $connector = new Connector($loop); - - $once = $this->expectCallableOnce(); - $server = new StreamingServer(function (RequestInterface $request) use ($once) { - $request->getBody()->on('close', $once); - }); - - $socket = new Socket(0, $loop); - $server->listen($socket); - - $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.001, function() use ($conn) { - $conn->end(); - }); - }); - - Block\sleep(0.1, $loop); - - $socket->close(); - } - - public function testStreamFromRequestHandlerWillBeClosedIfConnectionClosesWhileSendingRequestBody() - { - $loop = Factory::create(); - $connector = new Connector($loop); - - $stream = new ThroughStream(); - - $server = new StreamingServer(function (RequestInterface $request) use ($stream) { - return new Response(200, array(), $stream); - }); - - $socket = new Socket(0, $loop); - $server->listen($socket); - - $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.001, function() use ($conn) { - $conn->end(); - }); - }); - - // stream will be closed within 0.1s - $ret = Block\await(Stream\first($stream, 'close'), $loop, 0.1); - - $socket->close(); - - $this->assertNull($ret); - } - - public function testStreamFromRequestHandlerWillBeClosedIfConnectionCloses() - { - $loop = Factory::create(); - $connector = new Connector($loop); - - $stream = new ThroughStream(); - - $server = new StreamingServer(function (RequestInterface $request) use ($stream) { - return new Response(200, array(), $stream); - }); - - $socket = new Socket(0, $loop); - $server->listen($socket); - - $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->close(); - }); - }); - - // await response stream to be closed - $ret = Block\await(Stream\first($stream, 'close'), $loop, 1.0); - - $socket->close(); - - $this->assertNull($ret); - } - - public function testUpgradeWithThroughStreamReturnsDataAsGiven() - { - $loop = Factory::create(); - $connector = new Connector($loop); - - $server = new StreamingServer(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 testUpgradeWithRequestBodyAndThroughStreamReturnsDataAsGiven() - { - $loop = Factory::create(); - $connector = new Connector($loop); - - $server = new StreamingServer(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("POST / HTTP/1.1\r\nHost: example.com:80\r\nUpgrade: echo\r\nContent-Length: 3\r\n\r\n"); - $conn->write('hoh'); - - $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 StreamingServer(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 StreamingServer(function (RequestInterface $request) use ($loop) { - $stream = new ThroughStream(); - - $loop->addTimer(0.1, function () use ($stream) { - $stream->end(); - }); - - return new Promise\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 StreamingServer(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(); - } - - public function testLimitConcurrentRequestsMiddlewareRequestStreamPausing() - { - $loop = Factory::create(); - $connector = new Connector($loop); - - $server = new StreamingServer(array( - new LimitConcurrentRequestsMiddleware(5), - new RequestBodyBufferMiddleware(16 * 1024 * 1024), // 16 MiB - function (ServerRequestInterface $request, $next) use ($loop) { - return new Promise\Promise(function ($resolve) use ($request, $loop, $next) { - $loop->addTimer(0.1, function () use ($request, $resolve, $next) { - $resolve($next($request)); - }); - }); - }, - function (ServerRequestInterface $request) { - return new Response(200, array(), (string)strlen((string)$request->getBody())); - } - )); - - $socket = new Socket(0, $loop); - $server->listen($socket); - - $result = array(); - for ($i = 0; $i < 6; $i++) { - $result[] = $connector->connect($socket->getAddress())->then(function (ConnectionInterface $conn) { - $conn->write( - "GET / HTTP/1.0\r\nContent-Length: 1024\r\nHost: " . noScheme($conn->getRemoteAddress()) . "\r\n\r\n" . - str_repeat('a', 1024) . - "\r\n\r\n" - ); - - return Stream\buffer($conn); - }); - } - - $responses = Block\await(Promise\all($result), $loop, 1.0); - - foreach ($responses as $response) { - $this->assertContains("HTTP/1.0 200 OK", $response, $response); - $this->assertTrue(substr($response, -4) == 1024, $response); - } - - $socket->close(); - } - -} - -function noScheme($uri) -{ - $pos = strpos($uri, '://'); - if ($pos !== false) { - $uri = substr($uri, $pos + 3); - } - return $uri; -} diff --git a/tests/HttpServerTest.php b/tests/HttpServerTest.php new file mode 100644 index 00000000..a62e5fbd --- /dev/null +++ b/tests/HttpServerTest.php @@ -0,0 +1,461 @@ +connection = $this->getMockBuilder(Connection::class) + ->disableOriginalConstructor() + ->setMethods( + [ + '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 testConstructWithoutLoopAssignsLoopAutomatically() + { + $http = new HttpServer(function () { }); + + $ref = new \ReflectionProperty($http, 'streamingServer'); + $ref->setAccessible(true); + $streamingServer = $ref->getValue($http); + + $ref = new \ReflectionProperty($streamingServer, 'clock'); + $ref->setAccessible(true); + $clock = $ref->getValue($streamingServer); + + $ref = new \ReflectionProperty($clock, 'loop'); + $ref->setAccessible(true); + $loop = $ref->getValue($clock); + + $this->assertInstanceOf(LoopInterface::class, $loop); + } + + public function testInvalidCallbackFunctionLeadsToException() + { + $this->expectException(\InvalidArgumentException::class); + new HttpServer('invalid'); + } + + public function testSimpleRequestCallsRequestHandlerOnce() + { + $called = null; + $http = new HttpServer(function (ServerRequestInterface $request) use (&$called) { + ++$called; + }); + + $http->listen($this->socket); + $this->socket->emit('connection', [$this->connection]); + $this->connection->emit('data', ["GET / HTTP/1.0\r\n\r\n"]); + + $this->assertSame(1, $called); + } + + public function testSimpleRequestCallsArrayRequestHandlerOnce() + { + $this->called = null; + $http = new HttpServer([$this, 'helperCallableOnce']); + + $http->listen($this->socket); + $this->socket->emit('connection', [$this->connection]); + $this->connection->emit('data', ["GET / HTTP/1.0\r\n\r\n"]); + + $this->assertSame(1, $this->called); + } + + public function helperCallableOnce() + { + ++$this->called; + } + + public function testSimpleRequestWithMiddlewareArrayProcessesMiddlewareStack() + { + $called = null; + $http = new HttpServer( + function (ServerRequestInterface $request, $next) use (&$called) { + $called = 'before'; + $ret = $next($request->withHeader('Demo', 'ok')); + $called .= 'after'; + + return $ret; + }, + function (ServerRequestInterface $request) use (&$called) { + $called .= $request->getHeaderLine('Demo'); + } + ); + + $http->listen($this->socket); + $this->socket->emit('connection', [$this->connection]); + $this->connection->emit('data', ["GET / HTTP/1.0\r\n\r\n"]); + + $this->assertSame('beforeokafter', $called); + } + + public function testPostFormData() + { + $deferred = new Deferred(); + $http = new HttpServer(function (ServerRequestInterface $request) use ($deferred) { + $deferred->resolve($request); + }); + + $http->listen($this->socket); + $this->socket->emit('connection', [$this->connection]); + $this->connection->emit('data', ["POST / HTTP/1.0\r\nContent-Type: application/x-www-form-urlencoded\r\nContent-Length: 7\r\n\r\nfoo=bar"]); + + $request = await($deferred->promise()); + assert($request instanceof ServerRequestInterface); + + $form = $request->getParsedBody(); + + $this->assertTrue(isset($form['foo'])); + $this->assertEquals('bar', $form['foo']); + + $this->assertEquals([], $request->getUploadedFiles()); + + $body = $request->getBody(); + + $this->assertSame(7, $body->getSize()); + $this->assertSame(7, $body->tell()); + $this->assertSame('foo=bar', (string) $body); + } + + public function testPostFileUpload() + { + $deferred = new Deferred(); + $http = new HttpServer(function (ServerRequestInterface $request) use ($deferred) { + $deferred->resolve($request); + }); + + $http->listen($this->socket); + $this->socket->emit('connection', [$this->connection]); + + $data = $this->createPostFileUploadRequest(); + Loop::addPeriodicTimer(0.01, function ($timer) use (&$data) { + $line = array_shift($data); + $this->connection->emit('data', [$line]); + + if (count($data) === 0) { + Loop::cancelTimer($timer); + } + }); + + $request = await($deferred->promise()); + assert($request instanceof ServerRequestInterface); + + $this->assertEmpty($request->getParsedBody()); + + $this->assertNotEmpty($request->getUploadedFiles()); + + $files = $request->getUploadedFiles(); + + $this->assertTrue(isset($files['file'])); + $this->assertCount(1, $files); + + $this->assertSame('hello.txt', $files['file']->getClientFilename()); + $this->assertSame('text/plain', $files['file']->getClientMediaType()); + $this->assertSame("hello\r\n", (string)$files['file']->getStream()); + + $body = $request->getBody(); + + $this->assertSame(220, $body->getSize()); + $this->assertSame(220, $body->tell()); + } + + public function testPostJsonWillNotBeParsedByDefault() + { + $deferred = new Deferred(); + $http = new HttpServer(function (ServerRequestInterface $request) use ($deferred) { + $deferred->resolve($request); + }); + + $http->listen($this->socket); + $this->socket->emit('connection', [$this->connection]); + $this->connection->emit('data', ["POST / HTTP/1.0\r\nContent-Type: application/json\r\nContent-Length: 6\r\n\r\n[true]"]); + + $request = await($deferred->promise()); + assert($request instanceof ServerRequestInterface); + + $this->assertNull($request->getParsedBody()); + + $this->assertSame([], $request->getUploadedFiles()); + + $body = $request->getBody(); + + $this->assertSame(6, $body->getSize()); + $this->assertSame(0, $body->tell()); + $this->assertSame('[true]', (string) $body); + } + + public function testServerReceivesBufferedRequestByDefault() + { + $streaming = null; + $http = new HttpServer(function (ServerRequestInterface $request) use (&$streaming) { + $streaming = $request->getBody() instanceof ReadableStreamInterface; + }); + + $http->listen($this->socket); + $this->socket->emit('connection', [$this->connection]); + $this->connection->emit('data', ["GET / HTTP/1.0\r\n\r\n"]); + + $this->assertEquals(false, $streaming); + } + + public function testServerWithStreamingRequestMiddlewareReceivesStreamingRequest() + { + $streaming = null; + $http = new HttpServer( + new StreamingRequestMiddleware(), + function (ServerRequestInterface $request) use (&$streaming) { + $streaming = $request->getBody() instanceof ReadableStreamInterface; + } + ); + + $http->listen($this->socket); + $this->socket->emit('connection', [$this->connection]); + $this->connection->emit('data', ["GET / HTTP/1.0\r\n\r\n"]); + + $this->assertEquals(true, $streaming); + } + + public function testForwardErrors() + { + $exception = new \Exception(); + $capturedException = null; + $http = new HttpServer(function () use ($exception) { + return reject($exception); + }); + $http->on('error', function ($error) use (&$capturedException) { + $capturedException = $error; + }); + + $http->listen($this->socket); + $this->socket->emit('connection', [$this->connection]); + + $data = $this->createPostFileUploadRequest(); + $this->connection->emit('data', [implode('', $data)]); + + $this->assertInstanceOf(\RuntimeException::class, $capturedException); + $this->assertInstanceOf(\Exception::class, $capturedException->getPrevious()); + $this->assertSame($exception, $capturedException->getPrevious()); + } + + private function createPostFileUploadRequest() + { + $boundary = "---------------------------5844729766471062541057622570"; + + $data = []; + $data[] = "POST / HTTP/1.1\r\n"; + $data[] = "Host: localhost\r\n"; + $data[] = "Content-Type: multipart/form-data; boundary=" . $boundary . "\r\n"; + $data[] = "Content-Length: 220\r\n"; + $data[] = "\r\n"; + $data[] = "--$boundary\r\n"; + $data[] = "Content-Disposition: form-data; name=\"file\"; filename=\"hello.txt\"\r\n"; + $data[] = "Content-type: text/plain\r\n"; + $data[] = "\r\n"; + $data[] = "hello\r\n"; + $data[] = "\r\n"; + $data[] = "--$boundary--\r\n"; + + return $data; + } + + public static function provideIniSettingsForConcurrency() + { + yield 'default settings' => [ + '128M', + '64K', // 8M capped at maximum + 1024 + ]; + yield 'unlimited memory_limit has no concurrency limit' => [ + '-1', + '8M', + null + ]; + yield 'small post_max_size results in high concurrency' => [ + '128M', + '1k', + 65536 + ]; + } + + /** + * @param string $memory_limit + * @param string $post_max_size + * @param ?int $expectedConcurrency + * @dataProvider provideIniSettingsForConcurrency + */ + public function testServerConcurrency($memory_limit, $post_max_size, $expectedConcurrency) + { + $http = new HttpServer(function () { }); + + $ref = new \ReflectionMethod($http, 'getConcurrentRequestsLimit'); + $ref->setAccessible(true); + + $value = $ref->invoke($http, $memory_limit, $post_max_size); + + $this->assertEquals($expectedConcurrency, $value); + } + + public function testServerGetPostMaxSizeReturnsSizeFromGivenIniSetting() + { + $http = new HttpServer(function () { }); + + $ref = new \ReflectionMethod($http, 'getMaxRequestSize'); + $ref->setAccessible(true); + + $value = $ref->invoke($http, '1k'); + + $this->assertEquals(1024, $value); + } + + public function testServerGetPostMaxSizeReturnsSizeCappedFromGivenIniSetting() + { + $http = new HttpServer(function () { }); + + $ref = new \ReflectionMethod($http, 'getMaxRequestSize'); + $ref->setAccessible(true); + + $value = $ref->invoke($http, '1M'); + + $this->assertEquals(64 * 1024, $value); + } + + public function testServerGetPostMaxSizeFromIniIsCapped() + { + if (IniUtil::iniSizeToBytes(ini_get('post_max_size')) < 64 * 1024) { + $this->markTestSkipped(); + } + + $http = new HttpServer(function () { }); + + $ref = new \ReflectionMethod($http, 'getMaxRequestSize'); + $ref->setAccessible(true); + + $value = $ref->invoke($http); + + $this->assertEquals(64 * 1024, $value); + } + + public function testConstructServerWithUnlimitedMemoryLimitDoesNotLimitConcurrency() + { + $old = ini_get('memory_limit'); + ini_set('memory_limit', '-1'); + + $http = new HttpServer(function () { }); + + ini_set('memory_limit', $old); + + $ref = new \ReflectionProperty($http, 'streamingServer'); + $ref->setAccessible(true); + + $streamingServer = $ref->getValue($http); + + $ref = new \ReflectionProperty($streamingServer, 'callback'); + $ref->setAccessible(true); + + $middlewareRunner = $ref->getValue($streamingServer); + + $ref = new \ReflectionProperty($middlewareRunner, 'middleware'); + $ref->setAccessible(true); + + $middleware = $ref->getValue($middlewareRunner); + + $this->assertTrue(is_array($middleware)); + $this->assertInstanceOf(RequestBodyBufferMiddleware::class, $middleware[0]); + } + + public function testConstructServerWithMemoryLimitDoesLimitConcurrency() + { + $old = ini_get('memory_limit'); + if (@ini_set('memory_limit', '128M') === false) { + $this->markTestSkipped('Unable to change memory limit'); + } + + $http = new HttpServer(function () { }); + + ini_set('memory_limit', $old); + + $ref = new \ReflectionProperty($http, 'streamingServer'); + $ref->setAccessible(true); + + $streamingServer = $ref->getValue($http); + + $ref = new \ReflectionProperty($streamingServer, 'callback'); + $ref->setAccessible(true); + + $middlewareRunner = $ref->getValue($streamingServer); + + $ref = new \ReflectionProperty($middlewareRunner, 'middleware'); + $ref->setAccessible(true); + + $middleware = $ref->getValue($middlewareRunner); + + $this->assertTrue(is_array($middleware)); + $this->assertInstanceOf(LimitConcurrentRequestsMiddleware::class, $middleware[0]); + } + + public function testConstructFiltersOutConfigurationMiddlewareBefore() + { + $http = new HttpServer(new StreamingRequestMiddleware(), function () { }); + + $ref = new \ReflectionProperty($http, 'streamingServer'); + $ref->setAccessible(true); + + $streamingServer = $ref->getValue($http); + + $ref = new \ReflectionProperty($streamingServer, 'callback'); + $ref->setAccessible(true); + + $middlewareRunner = $ref->getValue($streamingServer); + + $ref = new \ReflectionProperty($middlewareRunner, 'middleware'); + $ref->setAccessible(true); + + $middleware = $ref->getValue($middlewareRunner); + + $this->assertTrue(is_array($middleware)); + $this->assertCount(1, $middleware); + } +} diff --git a/tests/Io/AbstractMessageTest.php b/tests/Io/AbstractMessageTest.php new file mode 100644 index 00000000..5451281a --- /dev/null +++ b/tests/Io/AbstractMessageTest.php @@ -0,0 +1,222 @@ + $headers + * @param StreamInterface $body + */ + public function __construct($protocolVersion, array $headers, StreamInterface $body) + { + return parent::__construct($protocolVersion, $headers, $body); + } +} + +class AbstractMessageTest extends TestCase +{ + public function testWithProtocolVersionReturnsNewInstanceWhenProtocolVersionIsChanged() + { + $message = new MessageMock( + '1.1', + [], + $this->createMock(StreamInterface::class) + ); + + $new = $message->withProtocolVersion('1.0'); + $this->assertNotSame($message, $new); + $this->assertEquals('1.0', $new->getProtocolVersion()); + $this->assertEquals('1.1', $message->getProtocolVersion()); + } + + public function testWithProtocolVersionReturnsSameInstanceWhenProtocolVersionIsUnchanged() + { + $message = new MessageMock( + '1.1', + [], + $this->createMock(StreamInterface::class) + ); + + $new = $message->withProtocolVersion('1.1'); + $this->assertSame($message, $new); + $this->assertEquals('1.1', $message->getProtocolVersion()); + } + + public function testHeaderWithStringValue() + { + $message = new MessageMock( + '1.1', + [ + 'Content-Type' => 'text/plain' + ], + $this->createMock(StreamInterface::class) + ); + + $this->assertEquals(['Content-Type' => ['text/plain']], $message->getHeaders()); + + $this->assertEquals(['text/plain'], $message->getHeader('Content-Type')); + $this->assertEquals(['text/plain'], $message->getHeader('CONTENT-type')); + + $this->assertEquals('text/plain', $message->getHeaderLine('Content-Type')); + $this->assertEquals('text/plain', $message->getHeaderLine('CONTENT-Type')); + + $this->assertTrue($message->hasHeader('Content-Type')); + $this->assertTrue($message->hasHeader('content-TYPE')); + + $new = $message->withHeader('Content-Type', 'text/plain'); + $this->assertSame($message, $new); + + $new = $message->withHeader('Content-Type', ['text/plain']); + $this->assertSame($message, $new); + + $new = $message->withHeader('content-type', 'text/plain'); + $this->assertNotSame($message, $new); + $this->assertEquals(['content-type' => ['text/plain']], $new->getHeaders()); + $this->assertEquals(['Content-Type' => ['text/plain']], $message->getHeaders()); + + $new = $message->withHeader('Content-Type', 'text/html'); + $this->assertNotSame($message, $new); + $this->assertEquals(['Content-Type' => ['text/html']], $new->getHeaders()); + $this->assertEquals(['Content-Type' => ['text/plain']], $message->getHeaders()); + + $new = $message->withHeader('Content-Type', ['text/html']); + $this->assertNotSame($message, $new); + $this->assertEquals(['Content-Type' => ['text/html']], $new->getHeaders()); + $this->assertEquals(['Content-Type' => ['text/plain']], $message->getHeaders()); + + $new = $message->withAddedHeader('Content-Type', []); + $this->assertSame($message, $new); + + $new = $message->withoutHeader('Content-Type'); + $this->assertNotSame($message, $new); + $this->assertEquals([], $new->getHeaders()); + $this->assertEquals(['Content-Type' => ['text/plain']], $message->getHeaders()); + } + + public function testHeaderWithMultipleValues() + { + $message = new MessageMock( + '1.1', + [ + 'Set-Cookie' => [ + 'a=1', + 'b=2' + ] + ], + $this->createMock(StreamInterface::class) + ); + + $this->assertEquals(['Set-Cookie' => ['a=1', 'b=2']], $message->getHeaders()); + + $this->assertEquals(['a=1', 'b=2'], $message->getHeader('Set-Cookie')); + $this->assertEquals(['a=1', 'b=2'], $message->getHeader('Set-Cookie')); + + $this->assertEquals('a=1, b=2', $message->getHeaderLine('Set-Cookie')); + $this->assertEquals('a=1, b=2', $message->getHeaderLine('Set-Cookie')); + + $this->assertTrue($message->hasHeader('Set-Cookie')); + $this->assertTrue($message->hasHeader('Set-Cookie')); + + $new = $message->withHeader('Set-Cookie', ['a=1', 'b=2']); + $this->assertSame($message, $new); + + $new = $message->withHeader('Set-Cookie', ['a=1', 'b=2', 'c=3']); + $this->assertNotSame($message, $new); + $this->assertEquals(['Set-Cookie' => ['a=1', 'b=2', 'c=3']], $new->getHeaders()); + $this->assertEquals(['Set-Cookie' => ['a=1', 'b=2']], $message->getHeaders()); + + $new = $message->withAddedHeader('Set-Cookie', []); + $this->assertSame($message, $new); + + $new = $message->withAddedHeader('Set-Cookie', 'c=3'); + $this->assertNotSame($message, $new); + $this->assertEquals(['Set-Cookie' => ['a=1', 'b=2', 'c=3']], $new->getHeaders()); + $this->assertEquals(['Set-Cookie' => ['a=1', 'b=2']], $message->getHeaders()); + + $new = $message->withAddedHeader('Set-Cookie', ['c=3']); + $this->assertNotSame($message, $new); + $this->assertEquals(['Set-Cookie' => ['a=1', 'b=2', 'c=3']], $new->getHeaders()); + $this->assertEquals(['Set-Cookie' => ['a=1', 'b=2']], $message->getHeaders()); + } + + public function testHeaderWithEmptyValue() + { + $message = new MessageMock( + '1.1', + [ + 'Content-Type' => [] + ], + $this->createMock(StreamInterface::class) + ); + + $this->assertEquals([], $message->getHeaders()); + + $this->assertEquals([], $message->getHeader('Content-Type')); + $this->assertEquals('', $message->getHeaderLine('Content-Type')); + $this->assertFalse($message->hasHeader('Content-Type')); + + $new = $message->withHeader('Empty', []); + $this->assertSame($message, $new); + $this->assertFalse($new->hasHeader('Empty')); + + $new = $message->withAddedHeader('Empty', []); + $this->assertSame($message, $new); + $this->assertFalse($new->hasHeader('Empty')); + + $new = $message->withoutHeader('Empty'); + $this->assertSame($message, $new); + $this->assertFalse($new->hasHeader('Empty')); + } + + public function testHeaderWithMultipleValuesAcrossMixedCaseNamesInConstructorMergesAllValuesWithNameFromLastNonEmptyValue() + { + $message = new MessageMock( + '1.1', + [ + 'SET-Cookie' => 'a=1', + 'set-cookie' => ['b=2'], + 'set-COOKIE' => [] + ], + $this->createMock(StreamInterface::class) + ); + + $this->assertEquals(['set-cookie' => ['a=1', 'b=2']], $message->getHeaders()); + $this->assertEquals(['a=1', 'b=2'], $message->getHeader('Set-Cookie')); + } + + public function testWithBodyReturnsNewInstanceWhenBodyIsChanged() + { + $body = $this->createMock(StreamInterface::class); + $message = new MessageMock( + '1.1', + [], + $body + ); + + $body2 = $this->createMock(StreamInterface::class); + $new = $message->withBody($body2); + $this->assertNotSame($message, $new); + $this->assertSame($body2, $new->getBody()); + $this->assertSame($body, $message->getBody()); + } + + public function testWithBodyReturnsSameInstanceWhenBodyIsUnchanged() + { + $body = $this->createMock(StreamInterface::class); + $message = new MessageMock( + '1.1', + [], + $body + ); + + $new = $message->withBody($body); + $this->assertSame($message, $new); + $this->assertEquals($body, $message->getBody()); + } +} diff --git a/tests/Io/AbstractRequestTest.php b/tests/Io/AbstractRequestTest.php new file mode 100644 index 00000000..5d41369b --- /dev/null +++ b/tests/Io/AbstractRequestTest.php @@ -0,0 +1,449 @@ + $headers + * @param StreamInterface $body + * @param string $protocolVersion + */ + public function __construct( + $method, + $uri, + array $headers, + StreamInterface $body, + $protocolVersion + ) { + parent::__construct($method, $uri, $headers, $body, $protocolVersion); + } +} + +class AbstractRequestTest extends TestCase +{ + public function testCtorWithInvalidUriThrows() + { + $this->expectException(\InvalidArgumentException::class); + new RequestMock( + 'GET', + null, + [], + $this->createMock(StreamInterface::class), + '1.1' + ); + } + + public function testGetHeadersReturnsHostHeaderFromUri() + { + $request = new RequestMock( + 'GET', + 'http://example.com/', + [], + $this->createMock(StreamInterface::class), + '1.1' + ); + + $this->assertEquals(['Host' => ['example.com']], $request->getHeaders()); + } + + public function testGetHeadersReturnsHostHeaderFromUriWithCustomHttpPort() + { + $request = new RequestMock( + 'GET', + 'http://example.com:8080/', + [], + $this->createMock(StreamInterface::class), + '1.1' + ); + + $this->assertEquals(['Host' => ['example.com:8080']], $request->getHeaders()); + } + + public function testGetHeadersReturnsHostHeaderFromUriWithCustomPortHttpOnHttpsPort() + { + $request = new RequestMock( + 'GET', + 'http://example.com:443/', + [], + $this->createMock(StreamInterface::class), + '1.1' + ); + + $this->assertEquals(['Host' => ['example.com:443']], $request->getHeaders()); + } + + public function testGetHeadersReturnsHostHeaderFromUriWithCustomPortHttpsOnHttpPort() + { + $request = new RequestMock( + 'GET', + 'https://example.com:80/', + [], + $this->createMock(StreamInterface::class), + '1.1' + ); + + $this->assertEquals(['Host' => ['example.com:80']], $request->getHeaders()); + } + + public function testGetHeadersReturnsHostHeaderFromUriWithoutDefaultHttpPort() + { + $request = new RequestMock( + 'GET', + 'http://example.com/', + [], + $this->createMock(StreamInterface::class), + '1.1' + ); + + $this->assertEquals(['Host' => ['example.com']], $request->getHeaders()); + } + + public function testGetHeadersReturnsHostHeaderFromUriWithoutDefaultHttpsPort() + { + $request = new RequestMock( + 'GET', + 'https://example.com/', + [], + $this->createMock(StreamInterface::class), + '1.1' + ); + + $this->assertEquals(['Host' => ['example.com']], $request->getHeaders()); + } + + public function testGetHeadersReturnsHostHeaderFromUriBeforeOtherHeadersExplicitlyGiven() + { + $request = new RequestMock( + 'GET', + 'http://example.com/', + [ + 'User-Agent' => 'demo' + ], + $this->createMock(StreamInterface::class), + '1.1' + ); + + $this->assertEquals(['Host' => ['example.com'], 'User-Agent' => ['demo']], $request->getHeaders()); + } + + public function testGetHeadersReturnsHostHeaderFromHeadersExplicitlyGiven() + { + $request = new RequestMock( + 'GET', + 'http://localhost/', + [ + 'Host' => 'example.com:8080' + ], + $this->createMock(StreamInterface::class), + '1.1' + ); + + $this->assertEquals(['Host' => ['example.com:8080']], $request->getHeaders()); + } + + public function testGetHeadersReturnsHostHeaderFromUriWhenHeadersExplicitlyGivenContainEmptyHostArray() + { + $request = new RequestMock( + 'GET', + 'https://example.com/', + [ + 'Host' => [] + ], + $this->createMock(StreamInterface::class), + '1.1' + ); + + $this->assertEquals(['Host' => ['example.com']], $request->getHeaders()); + } + + public function testGetRequestTargetReturnsPathAndQueryFromUri() + { + $request = new RequestMock( + 'GET', + 'http://example.com/demo?name=Alice', + [], + $this->createMock(StreamInterface::class), + '1.1' + ); + + $this->assertEquals('/demo?name=Alice', $request->getRequestTarget()); + } + + public function testGetRequestTargetReturnsSlashOnlyIfUriHasNoPathOrQuery() + { + $request = new RequestMock( + 'GET', + 'http://example.com/', + [], + $this->createMock(StreamInterface::class), + '1.1' + ); + + $this->assertEquals('/', $request->getRequestTarget()); + } + + public function testGetRequestTargetReturnsRequestTargetInAbsoluteFormIfGivenExplicitly() + { + $request = new RequestMock( + 'GET', + 'http://example.com/demo?name=Alice', + [], + $this->createMock(StreamInterface::class), + '1.1' + ); + $request = $request->withRequestTarget('http://example.com/demo?name=Alice'); + + $this->assertEquals('http://example.com/demo?name=Alice', $request->getRequestTarget()); + } + + public function testWithRequestTargetReturnsNewInstanceWhenRequestTargetIsChanged() + { + $request = new RequestMock( + 'GET', + 'http://example.com/', + [], + $this->createMock(StreamInterface::class), + '1.1' + ); + + $new = $request->withRequestTarget('http://example.com/'); + $this->assertNotSame($request, $new); + $this->assertEquals('http://example.com/', $new->getRequestTarget()); + $this->assertEquals('/', $request->getRequestTarget()); + } + + public function testWithRequestTargetReturnsSameInstanceWhenRequestTargetIsUnchanged() + { + $request = new RequestMock( + 'GET', + 'http://example.com/', + [], + $this->createMock(StreamInterface::class), + '1.1' + ); + $request = $request->withRequestTarget('/'); + + $new = $request->withRequestTarget('/'); + $this->assertSame($request, $new); + $this->assertEquals('/', $request->getRequestTarget()); + } + + public function testWithMethodReturnsNewInstanceWhenMethodIsChanged() + { + $request = new RequestMock( + 'GET', + 'http://example.com/', + [], + $this->createMock(StreamInterface::class), + '1.1' + ); + + $new = $request->withMethod('POST'); + $this->assertNotSame($request, $new); + $this->assertEquals('POST', $new->getMethod()); + $this->assertEquals('GET', $request->getMethod()); + } + + public function testWithMethodReturnsSameInstanceWhenMethodIsUnchanged() + { + $request = new RequestMock( + 'GET', + 'http://example.com/', + [], + $this->createMock(StreamInterface::class), + '1.1' + ); + + $new = $request->withMethod('GET'); + $this->assertSame($request, $new); + $this->assertEquals('GET', $request->getMethod()); + } + + public function testGetUriReturnsUriInstanceGivenToCtor() + { + $uri = $this->createMock(UriInterface::class); + + $request = new RequestMock( + 'GET', + $uri, + [], + $this->createMock(StreamInterface::class), + '1.1' + ); + + $this->assertSame($uri, $request->getUri()); + } + + public function testGetUriReturnsUriInstanceForUriStringGivenToCtor() + { + $request = new RequestMock( + 'GET', + 'http://example.com/', + [], + $this->createMock(StreamInterface::class), + '1.1' + ); + + $uri = $request->getUri(); + $this->assertInstanceOf(UriInterface::class, $uri); + $this->assertEquals('http://example.com/', (string) $uri); + } + + public function testWithUriReturnsNewInstanceWhenUriIsChanged() + { + $request = new RequestMock( + 'GET', + 'http://example.com/', + [], + $this->createMock(StreamInterface::class), + '1.1' + ); + + $uri = $this->createMock(UriInterface::class); + $new = $request->withUri($uri); + + $this->assertNotSame($request, $new); + $this->assertEquals($uri, $new->getUri()); + $this->assertEquals('http://example.com/', (string) $request->getUri()); + } + + public function testWithUriReturnsSameInstanceWhenUriIsUnchanged() + { + $uri = $this->createMock(UriInterface::class); + + $request = new RequestMock( + 'GET', + $uri, + [], + $this->createMock(StreamInterface::class), + '1.1' + ); + + $new = $request->withUri($uri); + $this->assertSame($request, $new); + $this->assertEquals($uri, $request->getUri()); + } + + public function testWithUriReturnsNewInstanceWithHostHeaderChangedIfUriContainsHost() + { + $request = new RequestMock( + 'GET', + 'http://example.com/', + [], + $this->createMock(StreamInterface::class), + '1.1' + ); + + $uri = new Uri('http://localhost/'); + $new = $request->withUri($uri); + + $this->assertNotSame($request, $new); + $this->assertEquals('http://localhost/', (string) $new->getUri()); + $this->assertEquals(['Host' => ['localhost']], $new->getHeaders()); + } + + public function testWithUriReturnsNewInstanceWithHostHeaderChangedIfUriContainsHostWithCustomPort() + { + $request = new RequestMock( + 'GET', + 'http://example.com/', + [], + $this->createMock(StreamInterface::class), + '1.1' + ); + + $uri = new Uri('http://localhost:8080/'); + $new = $request->withUri($uri); + + $this->assertNotSame($request, $new); + $this->assertEquals('http://localhost:8080/', (string) $new->getUri()); + $this->assertEquals(['Host' => ['localhost:8080']], $new->getHeaders()); + } + + public function testWithUriReturnsNewInstanceWithHostHeaderAddedAsFirstHeaderBeforeOthersIfUriContainsHost() + { + $request = new RequestMock( + 'GET', + 'http://example.com/', + [ + 'User-Agent' => 'test' + ], + $this->createMock(StreamInterface::class), + '1.1' + ); + $request = $request->withoutHeader('Host'); + + $uri = new Uri('http://localhost/'); + $new = $request->withUri($uri); + + $this->assertNotSame($request, $new); + $this->assertEquals('http://localhost/', (string) $new->getUri()); + $this->assertEquals(['Host' => ['localhost'], 'User-Agent' => ['test']], $new->getHeaders()); + } + + public function testWithUriReturnsNewInstanceWithHostHeaderUnchangedIfUriContainsNoHost() + { + $request = new RequestMock( + 'GET', + 'http://example.com/', + [], + $this->createMock(StreamInterface::class), + '1.1' + ); + + $uri = new Uri('/path'); + $new = $request->withUri($uri); + + $this->assertNotSame($request, $new); + $this->assertEquals('/path', (string) $new->getUri()); + $this->assertEquals(['Host' => ['example.com']], $new->getHeaders()); + } + + public function testWithUriReturnsNewInstanceWithHostHeaderUnchangedIfPreserveHostIsTrue() + { + $request = new RequestMock( + 'GET', + 'http://example.com/', + [], + $this->createMock(StreamInterface::class), + '1.1' + ); + + $uri = new Uri('http://localhost/'); + $new = $request->withUri($uri, true); + + $this->assertNotSame($request, $new); + $this->assertEquals('http://localhost/', (string) $new->getUri()); + $this->assertEquals(['Host' => ['example.com']], $new->getHeaders()); + } + + public function testWithUriReturnsNewInstanceWithHostHeaderAddedAsFirstHeaderNoMatterIfPreserveHostIsTrue() + { + $request = new RequestMock( + 'GET', + 'http://example.com/', + [ + 'User-Agent' => 'test' + ], + $this->createMock(StreamInterface::class), + '1.1' + ); + $request = $request->withoutHeader('Host'); + + $uri = new Uri('http://example.com/'); + $new = $request->withUri($uri, true); + + $this->assertNotSame($request, $new); + $this->assertEquals('http://example.com/', (string) $new->getUri()); + $this->assertEquals(['Host' => ['example.com'], 'User-Agent' => ['test']], $new->getHeaders()); + } +} diff --git a/tests/Io/BufferedBodyTest.php b/tests/Io/BufferedBodyTest.php new file mode 100644 index 00000000..c8534d50 --- /dev/null +++ b/tests/Io/BufferedBodyTest.php @@ -0,0 +1,300 @@ +assertTrue($stream->isReadable()); + $this->assertTrue($stream->isWritable()); + $this->assertTrue($stream->isSeekable()); + $this->assertSame(0, $stream->getSize()); + $this->assertSame('', $stream->getContents()); + $this->assertSame('', (string) $stream); + } + + public function testClose() + { + $stream = new BufferedBody('hello'); + $stream->close(); + + $this->assertFalse($stream->isReadable()); + $this->assertFalse($stream->isWritable()); + $this->assertFalse($stream->isSeekable()); + $this->assertTrue($stream->eof()); + $this->assertNull($stream->getSize()); + $this->assertSame('', (string) $stream); + } + + public function testDetachReturnsNullAndCloses() + { + $stream = new BufferedBody('hello'); + $this->assertNull($stream->detach()); + + $this->assertFalse($stream->isReadable()); + $this->assertFalse($stream->isWritable()); + $this->assertFalse($stream->isSeekable()); + $this->assertTrue($stream->eof()); + $this->assertNull($stream->getSize()); + $this->assertSame('', (string) $stream); + } + + public function testSeekAndTellPosition() + { + $stream = new BufferedBody('hello'); + + $this->assertSame(0, $stream->tell()); + $this->assertFalse($stream->eof()); + + $stream->seek(1); + $this->assertSame(1, $stream->tell()); + $this->assertFalse($stream->eof()); + + $stream->seek(2, SEEK_CUR); + $this->assertSame(3, $stream->tell()); + $this->assertFalse($stream->eof()); + + $stream->seek(-1, SEEK_END); + $this->assertSame(4, $stream->tell()); + $this->assertFalse($stream->eof()); + + $stream->seek(0, SEEK_END); + $this->assertSame(5, $stream->tell()); + $this->assertTrue($stream->eof()); + } + + public function testSeekAfterEndIsPermitted() + { + $stream = new BufferedBody('hello'); + + $stream->seek(1000); + $this->assertSame(1000, $stream->tell()); + $this->assertTrue($stream->eof()); + + $stream->seek(0, SEEK_END); + $this->assertSame(5, $stream->tell()); + $this->assertTrue($stream->eof()); + } + + public function testSeekBeforeStartThrows() + { + $stream = new BufferedBody('hello'); + + try { + $stream->seek(-10, SEEK_CUR); + } catch (\RuntimeException $e) { + $this->assertSame(0, $stream->tell()); + + $this->expectException(\RuntimeException::class); + throw $e; + } + } + + public function testSeekWithInvalidModeThrows() + { + $stream = new BufferedBody('hello'); + + $this->expectException(\InvalidArgumentException::class); + $stream->seek(1, 12345); + } + + public function testSeekAfterCloseThrows() + { + $stream = new BufferedBody('hello'); + $stream->close(); + + $this->expectException(\RuntimeException::class); + $stream->seek(0); + } + + public function testTellAfterCloseThrows() + { + $stream = new BufferedBody('hello'); + $stream->close(); + + $this->expectException(\RuntimeException::class); + $stream->tell(); + } + + public function testRewindSeeksToStartPosition() + { + $stream = new BufferedBody('hello'); + + $stream->seek(1); + $stream->rewind(); + $this->assertSame(0, $stream->tell()); + } + + public function testRewindAfterCloseThrows() + { + $stream = new BufferedBody('hello'); + $stream->close(); + + $this->expectException(\RuntimeException::class); + $stream->rewind(); + } + + public function testGetContentsMultipleTimesReturnsBodyOnlyOnce() + { + $stream = new BufferedBody('hello'); + + $this->assertSame(5, $stream->getSize()); + $this->assertSame('hello', $stream->getContents()); + $this->assertSame('', $stream->getContents()); + } + + public function testReadReturnsChunkAndAdvancesPosition() + { + $stream = new BufferedBody('hello'); + + $this->assertSame('he', $stream->read(2)); + $this->assertSame(2, $stream->tell()); + + $this->assertSame('ll', $stream->read(2)); + $this->assertSame(4, $stream->tell()); + + $this->assertSame('o', $stream->read(2)); + $this->assertSame(5, $stream->tell()); + + $this->assertSame('', $stream->read(2)); + $this->assertSame(5, $stream->tell()); + } + + public function testReadAfterEndReturnsEmptyStringWithoutChangingPosition() + { + $stream = new BufferedBody('hello'); + + $stream->seek(1000); + + $this->assertSame('', $stream->read(2)); + $this->assertSame(1000, $stream->tell()); + } + + public function testReadZeroThrows() + { + $stream = new BufferedBody('hello'); + + $this->expectException(\InvalidArgumentException::class); + $stream->read(0); + } + + public function testReadAfterCloseThrows() + { + $stream = new BufferedBody('hello'); + $stream->close(); + + $this->expectException(\RuntimeException::class); + $stream->read(10); + } + + public function testGetContentsReturnsWholeBufferAndAdvancesPositionToEof() + { + $stream = new BufferedBody('hello'); + + $this->assertSame('hello', $stream->getContents()); + $this->assertSame(5, $stream->tell()); + $this->assertTrue($stream->eof()); + } + + public function testGetContentsAfterEndsReturnsEmptyStringWithoutChangingPosition() + { + $stream = new BufferedBody('hello'); + + $stream->seek(100); + + $this->assertSame('', $stream->getContents()); + $this->assertSame(100, $stream->tell()); + $this->assertTrue($stream->eof()); + } + + public function testGetContentsAfterCloseThrows() + { + $stream = new BufferedBody('hello'); + $stream->close(); + + $this->expectException(\RuntimeException::class); + $stream->getContents(); + } + + public function testWriteAdvancesPosition() + { + $stream = new BufferedBody(''); + + $this->assertSame(2, $stream->write('he')); + $this->assertSame(2, $stream->tell()); + + $this->assertSame(2, $stream->write('ll')); + $this->assertSame(4, $stream->tell()); + + $this->assertSame(1, $stream->write('o')); + $this->assertSame(5, $stream->tell()); + + $this->assertSame(0, $stream->write('')); + $this->assertSame(5, $stream->tell()); + } + + public function testWriteInMiddleOfBufferOverwrites() + { + $stream = new BufferedBody('hello'); + + $stream->seek(1); + $this->assertSame(1, $stream->write('a')); + + $this->assertSame(2, $stream->tell()); + $this->assertsame(5, $stream->getSize()); + $this->assertSame('hallo', (string) $stream); + } + + public function testWriteOverEndOverwritesAndAppends() + { + $stream = new BufferedBody('hello'); + + $stream->seek(4); + $this->assertSame(2, $stream->write('au')); + + $this->assertSame(6, $stream->tell()); + $this->assertsame(6, $stream->getSize()); + $this->assertSame('hellau', (string) $stream); + } + + public function testWriteAfterEndAppendsAndFillsWithNullBytes() + { + $stream = new BufferedBody('hello'); + + $stream->seek(6); + $this->assertSame(6, $stream->write('binary')); + + $this->assertSame(12, $stream->tell()); + $this->assertsame(12, $stream->getSize()); + $this->assertSame('hello' . "\0" . 'binary', (string) $stream); + } + + public function testWriteAfterCloseThrows() + { + $stream = new BufferedBody('hello'); + $stream->close(); + + $this->expectException(\RuntimeException::class); + $stream->write('foo'); + } + + public function testGetMetadataWithoutKeyReturnsEmptyArray() + { + $stream = new BufferedBody('hello'); + + $this->assertEquals([], $stream->getMetadata()); + } + + public function testGetMetadataWithKeyReturnsNull() + { + $stream = new BufferedBody('hello'); + + $this->assertNull($stream->getMetadata('key')); + } +} diff --git a/tests/Io/ChunkedDecoderTest.php b/tests/Io/ChunkedDecoderTest.php index 72a98a70..3ae8c742 100644 --- a/tests/Io/ChunkedDecoderTest.php +++ b/tests/Io/ChunkedDecoderTest.php @@ -3,12 +3,20 @@ namespace React\Tests\Http\Io; use React\Http\Io\ChunkedDecoder; +use React\Stream\ReadableStreamInterface; use React\Stream\ThroughStream; +use React\Stream\WritableStreamInterface; use React\Tests\Http\TestCase; class ChunkedDecoderTest extends TestCase { - public function setUp() + private $input; + private $parser; + + /** + * @before + */ + public function setUpParser() { $this->input = new ThroughStream(); $this->parser = new ChunkedDecoder($this->input); @@ -20,16 +28,22 @@ public function testSimpleChunk() $this->parser->on('end', $this->expectCallableNever()); $this->parser->on('close', $this->expectCallableNever()); - $this->input->emit('data', array("5\r\nhello\r\n")); + $this->input->emit('data', ["5\r\nhello\r\n"]); } public function testTwoChunks() { - $this->parser->on('data', $this->expectCallableConsecutive(2, array('hello', 'bla'))); + $buffer = []; + $this->parser->on('data', function ($data) use (&$buffer) { + $buffer[] = $data; + }); + $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")); + $this->input->emit('data', ["5\r\nhello\r\n3\r\nbla\r\n"]); + + $this->assertEquals(['hello', 'bla'], $buffer); } public function testEnd() @@ -38,17 +52,23 @@ public function testEnd() $this->parser->on('close', $this->expectCallableOnce()); $this->parser->on('error', $this->expectCallableNever()); - $this->input->emit('data', array("0\r\n\r\n")); + $this->input->emit('data', ["0\r\n\r\n"]); } public function testParameterWithEnd() { - $this->parser->on('data', $this->expectCallableConsecutive(2, array('hello', 'bla'))); + $buffer = []; + $this->parser->on('data', function ($data) use (&$buffer) { + $buffer[] = $data; + }); + $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")); + $this->input->emit('data', ["5\r\nhello\r\n3\r\nbla\r\n0\r\n\r\n"]); + + $this->assertEquals(['hello', 'bla'], $buffer); } public function testInvalidChunk() @@ -58,7 +78,7 @@ public function testInvalidChunk() $this->parser->on('close', $this->expectCallableOnce()); $this->parser->on('error', $this->expectCallableOnce()); - $this->input->emit('data', array("bla\r\n")); + $this->input->emit('data', ["bla\r\n"]); } public function testNeverEnd() @@ -67,7 +87,7 @@ public function testNeverEnd() $this->parser->on('close', $this->expectCallableNever()); $this->parser->on('error', $this->expectCallableNever()); - $this->input->emit('data', array("0\r\n")); + $this->input->emit('data', ["0\r\n"]); } public function testWrongChunkHex() @@ -76,7 +96,7 @@ public function testWrongChunkHex() $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")); + $this->input->emit('data', ["2\r\na\r\n5\r\nhello\r\n"]); } public function testSplittedChunk() @@ -86,8 +106,8 @@ public function testSplittedChunk() $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")); + $this->input->emit('data', ["4\r\n"]); + $this->input->emit('data', ["welt\r\n"]); } public function testSplittedHeader() @@ -97,9 +117,8 @@ public function testSplittedHeader() $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")); + $this->input->emit('data', ["4"]); + $this->input->emit('data', ["\r\nwelt\r\n"]); } public function testSplittedBoth() @@ -109,62 +128,85 @@ public function testSplittedBoth() $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")); + $this->input->emit('data', ["4"]); + $this->input->emit('data', ["\r\n"]); + $this->input->emit('data', ["welt\r\n"]); } public function testCompletlySplitted() { - $this->parser->on('data', $this->expectCallableConsecutive(2, array('we', 'lt'))); + $buffer = []; + $this->parser->on('data', function ($data) use (&$buffer) { + $buffer[] = $data; + }); + $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', ["4"]); + $this->input->emit('data', ["\r\n"]); + $this->input->emit('data', ["we"]); + $this->input->emit('data', ["lt\r\n"]); + + $this->assertEquals(['we', 'lt'], $buffer); } public function testMixed() { - $this->parser->on('data', $this->expectCallableConsecutive(3, array('we', 'lt', 'hello'))); + $buffer = []; + $this->parser->on('data', function ($data) use (&$buffer) { + $buffer[] = $data; + }); + $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")); + $this->input->emit('data', ["4"]); + $this->input->emit('data', ["\r\n"]); + $this->input->emit('data', ["welt\r\n"]); + $this->input->emit('data', ["5\r\nhello\r\n"]); + + $this->assertEquals(['welt', 'hello'], $buffer); } public function testBigger() { - $this->parser->on('data', $this->expectCallableConsecutive(2, array('abcdeabcdeabcdea', 'hello'))); + $buffer = []; + $this->parser->on('data', function ($data) use (&$buffer) { + $buffer[] = $data; + }); + $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")); + $this->input->emit('data', ["1"]); + $this->input->emit('data', ["0"]); + $this->input->emit('data', ["\r\n"]); + $this->input->emit('data', ["abcdeabcdeabcdea\r\n"]); + $this->input->emit('data', ["5\r\nhello\r\n"]); + + $this->assertEquals(['abcdeabcdeabcdea', 'hello'], $buffer); } public function testOneUnfinished() { - $this->parser->on('data', $this->expectCallableConsecutive(2, array('bla', 'hello'))); + $buffer = []; + $this->parser->on('data', function ($data) use (&$buffer) { + $buffer[] = $data; + }); + $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")); + $this->input->emit('data', ["3\r\n"]); + $this->input->emit('data', ["bla\r\n"]); + $this->input->emit('data', ["5\r\nhello"]); + + $this->assertEquals(['bla', 'hello'], $buffer); } public function testChunkIsBiggerThenExpected() @@ -174,8 +216,8 @@ public function testChunkIsBiggerThenExpected() $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")); + $this->input->emit('data', ["5\r\n"]); + $this->input->emit('data', ["hello world\r\n"]); } public function testHandleUnexpectedEnd() @@ -195,7 +237,7 @@ public function testExtensionWillBeIgnored() $this->parser->on('end', $this->expectCallableNever()); $this->parser->on('error', $this->expectCallableNever()); - $this->input->emit('data', array("3;hello=world;foo=bar\r\nbla")); + $this->input->emit('data', ["3;hello=world;foo=bar\r\nbla"]); } public function testChunkHeaderIsTooBig() @@ -209,7 +251,7 @@ public function testChunkHeaderIsTooBig() for ($i = 0; $i < 1025; $i++) { $data .= 'a'; } - $this->input->emit('data', array($data)); + $this->input->emit('data', [$data]); } public function testChunkIsMaximumSize() @@ -225,7 +267,7 @@ public function testChunkIsMaximumSize() } $data .= "\r\n"; - $this->input->emit('data', array($data)); + $this->input->emit('data', [$data]); } public function testLateCrlf() @@ -235,9 +277,9 @@ public function testLateCrlf() $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")); + $this->input->emit('data', ["4\r\nlate"]); + $this->input->emit('data', ["\r"]); + $this->input->emit('data', ["\n"]); } public function testNoCrlfInChunk() @@ -247,7 +289,7 @@ public function testNoCrlfInChunk() $this->parser->on('end', $this->expectCallableNever()); $this->parser->on('error', $this->expectCallableOnce()); - $this->input->emit('data', array("2\r\nno crlf")); + $this->input->emit('data', ["2\r\nno crlf"]); } public function testNoCrlfInChunkSplitted() @@ -257,10 +299,10 @@ public function testNoCrlfInChunkSplitted() $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")); + $this->input->emit('data', ["2\r\n"]); + $this->input->emit('data', ["no"]); + $this->input->emit('data', ["further"]); + $this->input->emit('data', ["clrf"]); } public function testEmitEmptyChunkBody() @@ -270,9 +312,9 @@ public function testEmitEmptyChunkBody() $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("")); + $this->input->emit('data', ["2\r\n"]); + $this->input->emit('data', [""]); + $this->input->emit('data', [""]); } public function testEmitCrlfAsChunkBody() @@ -282,9 +324,9 @@ public function testEmitCrlfAsChunkBody() $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")); + $this->input->emit('data', ["2\r\n"]); + $this->input->emit('data', ["\r\n"]); + $this->input->emit('data', ["\r\n"]); } public function testNegativeHeader() @@ -294,7 +336,7 @@ public function testNegativeHeader() $this->parser->on('end', $this->expectCallableNever()); $this->parser->on('error', $this->expectCallableOnce()); - $this->input->emit('data', array("-2\r\n")); + $this->input->emit('data', ["-2\r\n"]); } public function testHexDecimalInBodyIsPotentialThread() @@ -304,7 +346,7 @@ public function testHexDecimalInBodyIsPotentialThread() $this->parser->on('end', $this->expectCallableNever()); $this->parser->on('error', $this->expectCallableOnce()); - $this->input->emit('data', array("4\r\ntest5\r\nworld")); + $this->input->emit('data', ["4\r\ntest5\r\nworld"]); } public function testHexDecimalInBodyIsPotentialThreadSplitted() @@ -314,17 +356,20 @@ public function testHexDecimalInBodyIsPotentialThreadSplitted() $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")); + $this->input->emit('data', ["4"]); + $this->input->emit('data', ["\r\n"]); + $this->input->emit('data', ["test"]); + $this->input->emit('data', ["5"]); + $this->input->emit('data', ["\r\n"]); + $this->input->emit('data', ["world"]); } public function testEmitSingleCharacter() { - $this->parser->on('data', $this->expectCallableConsecutive(4, array('t', 'e', 's', 't'))); + $buffer = []; + $this->parser->on('data', function ($data) use (&$buffer) { + $buffer[] = $data; + }); $this->parser->on('close', $this->expectCallableOnce()); $this->parser->on('end', $this->expectCallableOnce()); $this->parser->on('error', $this->expectCallableNever()); @@ -332,8 +377,10 @@ public function testEmitSingleCharacter() $array = str_split("4\r\ntest\r\n0\r\n\r\n"); foreach ($array as $character) { - $this->input->emit('data', array($character)); + $this->input->emit('data', [$character]); } + + $this->assertEquals(['t', 'e', 's', 't'], $buffer); } public function testHandleError() @@ -342,14 +389,14 @@ public function testHandleError() $this->parser->on('close', $this->expectCallableOnce()); $this->parser->on('end', $this->expectCallableNever()); - $this->input->emit('error', array(new \RuntimeException())); + $this->input->emit('error', [new \RuntimeException()]); $this->assertFalse($this->parser->isReadable()); } public function testPauseStream() { - $input = $this->getMockBuilder('React\Stream\ReadableStreamInterface')->getMock(); + $input = $this->createMock(ReadableStreamInterface::class); $input->expects($this->once())->method('pause'); $parser = new ChunkedDecoder($input); @@ -358,7 +405,7 @@ public function testPauseStream() public function testResumeStream() { - $input = $this->getMockBuilder('React\Stream\ReadableStreamInterface')->getMock(); + $input = $this->createMock(ReadableStreamInterface::class); $input->expects($this->once())->method('pause'); $parser = new ChunkedDecoder($input); @@ -368,7 +415,7 @@ public function testResumeStream() public function testPipeStream() { - $dest = $this->getMockBuilder('React\Stream\WritableStreamInterface')->getMock(); + $dest = $this->createMock(WritableStreamInterface::class); $ret = $this->parser->pipe($dest); @@ -380,7 +427,7 @@ public function testHandleClose() $this->parser->on('close', $this->expectCallableOnce()); $this->input->close(); - $this->input->emit('end', array()); + $this->input->emit('end', []); $this->assertFalse($this->parser->isReadable()); } @@ -400,13 +447,19 @@ public function testOutputStreamCanCloseInputStream() public function testLeadingZerosWillBeIgnored() { - $this->parser->on('data', $this->expectCallableConsecutive(2, array('hello', 'hello world'))); + $buffer = []; + $this->parser->on('data', function ($data) use (&$buffer) { + $buffer[] = $data; + }); + $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")); + $this->input->emit('data', ["00005\r\nhello\r\n"]); + $this->input->emit('data', ["0000b\r\nhello world\r\n"]); + + $this->assertEquals(['hello', 'hello world'], $buffer); } public function testLeadingZerosInEndChunkWillBeIgnored() @@ -416,7 +469,37 @@ public function testLeadingZerosInEndChunkWillBeIgnored() $this->parser->on('end', $this->expectCallableOnce()); $this->parser->on('close', $this->expectCallableOnce()); - $this->input->emit('data', array("0000\r\n\r\n")); + $this->input->emit('data', ["0000\r\n\r\n"]); + } + + public function testAdditionalWhitespaceInEndChunkWillBeIgnored() + { + $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', [" 0 \r\n\r\n"]); + } + + public function testEndChunkWithTrailersWillBeIgnored() + { + $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', ["0\r\nFoo: bar\r\n\r\n"]); + } + + public function testEndChunkWithMultipleTrailersWillBeIgnored() + { + $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', ["0\r\nFoo: a\r\nBar: b\r\nBaz: c\r\n\r\n"]); } public function testLeadingZerosInInvalidChunk() @@ -426,7 +509,7 @@ public function testLeadingZerosInInvalidChunk() $this->parser->on('end', $this->expectCallableNever()); $this->parser->on('close', $this->expectCallableOnce()); - $this->input->emit('data', array("0000hello\r\n\r\n")); + $this->input->emit('data', ["0000hello\r\n\r\n"]); } public function testEmptyHeaderLeadsToError() @@ -436,7 +519,7 @@ public function testEmptyHeaderLeadsToError() $this->parser->on('end', $this->expectCallableNever()); $this->parser->on('close', $this->expectCallableOnce()); - $this->input->emit('data', array("\r\n\r\n")); + $this->input->emit('data', ["\r\n\r\n"]); } public function testEmptyHeaderAndFilledBodyLeadsToError() @@ -446,7 +529,7 @@ public function testEmptyHeaderAndFilledBodyLeadsToError() $this->parser->on('end', $this->expectCallableNever()); $this->parser->on('close', $this->expectCallableOnce()); - $this->input->emit('data', array("\r\nhello\r\n")); + $this->input->emit('data', ["\r\nhello\r\n"]); } public function testUpperCaseHexWillBeHandled() @@ -456,7 +539,7 @@ public function testUpperCaseHexWillBeHandled() $this->parser->on('end', $this->expectCallableNever()); $this->parser->on('close', $this->expectCallableNever()); - $this->input->emit('data', array("A\r\n0123456790\r\n")); + $this->input->emit('data', ["A\r\n0123456790\r\n"]); } public function testLowerCaseHexWillBeHandled() @@ -466,7 +549,7 @@ public function testLowerCaseHexWillBeHandled() $this->parser->on('end', $this->expectCallableNever()); $this->parser->on('close', $this->expectCallableNever()); - $this->input->emit('data', array("a\r\n0123456790\r\n")); + $this->input->emit('data', ["a\r\n0123456790\r\n"]); } public function testMixedUpperAndLowerCaseHexValuesInHeaderWillBeHandled() @@ -478,6 +561,6 @@ public function testMixedUpperAndLowerCaseHexValuesInHeaderWillBeHandled() $this->parser->on('end', $this->expectCallableNever()); $this->parser->on('close', $this->expectCallableNever()); - $this->input->emit('data', array("aA\r\n" . $data . "\r\n")); + $this->input->emit('data', ["aA\r\n" . $data . "\r\n"]); } } diff --git a/tests/Io/ChunkedEncoderTest.php b/tests/Io/ChunkedEncoderTest.php index ef542097..cbb3e7ad 100644 --- a/tests/Io/ChunkedEncoderTest.php +++ b/tests/Io/ChunkedEncoderTest.php @@ -3,7 +3,9 @@ namespace React\Tests\Http\Io; use React\Http\Io\ChunkedEncoder; +use React\Stream\ReadableStreamInterface; use React\Stream\ThroughStream; +use React\Stream\WritableStreamInterface; use React\Tests\Http\TestCase; class ChunkedEncoderTest extends TestCase @@ -11,7 +13,10 @@ class ChunkedEncoderTest extends TestCase private $input; private $chunkedStream; - public function setUp() + /** + * @before + */ + public function setUpChunkedStream() { $this->input = new ThroughStream(); $this->chunkedStream = new ChunkedEncoder($this->input); @@ -19,20 +24,20 @@ public function setUp() public function testChunked() { - $this->chunkedStream->on('data', $this->expectCallableOnce(array("5\r\nhello\r\n"))); - $this->input->emit('data', array('hello')); + $this->chunkedStream->on('data', $this->expectCallableOnceWith("5\r\nhello\r\n")); + $this->input->emit('data', ['hello']); } public function testEmptyString() { $this->chunkedStream->on('data', $this->expectCallableNever()); - $this->input->emit('data', array('')); + $this->input->emit('data', ['']); } public function testBiggerStringToCheckHexValue() { - $this->chunkedStream->on('data', $this->expectCallableOnce(array("1a\r\nabcdefghijklmnopqrstuvwxyz\r\n"))); - $this->input->emit('data', array('abcdefghijklmnopqrstuvwxyz')); + $this->chunkedStream->on('data', $this->expectCallableOnceWith("1a\r\nabcdefghijklmnopqrstuvwxyz\r\n")); + $this->input->emit('data', ['abcdefghijklmnopqrstuvwxyz']); } public function testHandleClose() @@ -49,14 +54,14 @@ public function testHandleError() $this->chunkedStream->on('error', $this->expectCallableOnce()); $this->chunkedStream->on('close', $this->expectCallableOnce()); - $this->input->emit('error', array(new \RuntimeException())); + $this->input->emit('error', [new \RuntimeException()]); $this->assertFalse($this->chunkedStream->isReadable()); } public function testPauseStream() { - $input = $this->getMockBuilder('React\Stream\ReadableStreamInterface')->getMock(); + $input = $this->createMock(ReadableStreamInterface::class); $input->expects($this->once())->method('pause'); $parser = new ChunkedEncoder($input); @@ -65,7 +70,7 @@ public function testPauseStream() public function testResumeStream() { - $input = $this->getMockBuilder('React\Stream\ReadableStreamInterface')->getMock(); + $input = $this->createMock(ReadableStreamInterface::class); $input->expects($this->once())->method('pause'); $parser = new ChunkedEncoder($input); @@ -75,7 +80,7 @@ public function testResumeStream() public function testPipeStream() { - $dest = $this->getMockBuilder('React\Stream\WritableStreamInterface')->getMock(); + $dest = $this->createMock(WritableStreamInterface::class); $ret = $this->chunkedStream->pipe($dest); diff --git a/tests/Io/ClientConnectionManagerTest.php b/tests/Io/ClientConnectionManagerTest.php new file mode 100644 index 00000000..c4b3a07e --- /dev/null +++ b/tests/Io/ClientConnectionManagerTest.php @@ -0,0 +1,394 @@ +createMock(ConnectorInterface::class); + $connector->expects($this->once())->method('connect')->with('tls://reactphp.org:443')->willReturn($promise); + + $loop = $this->createMock(LoopInterface::class); + + $connectionManager = new ClientConnectionManager($connector, $loop); + + $ret = $connectionManager->connect(new Uri('https://reactphp.org/')); + + assert($ret instanceof PromiseInterface); + $this->assertSame($promise, $ret); + } + + public function testConnectWithHttpUriShouldConnectToTcpWithDefaultPort() + { + $promise = new Promise(function () { }); + $connector = $this->createMock(ConnectorInterface::class); + $connector->expects($this->once())->method('connect')->with('reactphp.org:80')->willReturn($promise); + + $loop = $this->createMock(LoopInterface::class); + + $connectionManager = new ClientConnectionManager($connector, $loop); + + $ret = $connectionManager->connect(new Uri('http://reactphp.org/')); + $this->assertSame($promise, $ret); + } + + public function testConnectWithExplicitPortShouldConnectWithGivenPort() + { + $promise = new Promise(function () { }); + $connector = $this->createMock(ConnectorInterface::class); + $connector->expects($this->once())->method('connect')->with('reactphp.org:8080')->willReturn($promise); + + $loop = $this->createMock(LoopInterface::class); + + $connectionManager = new ClientConnectionManager($connector, $loop); + + $ret = $connectionManager->connect(new Uri('http://reactphp.org:8080/')); + $this->assertSame($promise, $ret); + } + + public function testConnectWithInvalidSchemeShouldRejectWithException() + { + $connector = $this->createMock(ConnectorInterface::class); + $connector->expects($this->never())->method('connect'); + + $loop = $this->createMock(LoopInterface::class); + + $connectionManager = new ClientConnectionManager($connector, $loop); + + $promise = $connectionManager->connect(new Uri('ftp://reactphp.org/')); + + $exception = null; + $promise->then(null, function ($reason) use (&$exception) { + $exception = $reason; + }); + + $this->assertInstanceOf(\InvalidArgumentException::class, $exception); + $this->assertEquals('Invalid request URL given', $exception->getMessage()); + } + + public function testConnectWithoutSchemeShouldRejectWithException() + { + $connector = $this->createMock(ConnectorInterface::class); + $connector->expects($this->never())->method('connect'); + + $loop = $this->createMock(LoopInterface::class); + + $connectionManager = new ClientConnectionManager($connector, $loop); + + $promise = $connectionManager->connect(new Uri('reactphp.org')); + + $exception = null; + $promise->then(null, function ($reason) use (&$exception) { + $exception = $reason; + }); + + $this->assertInstanceOf(\InvalidArgumentException::class, $exception); + $this->assertEquals('Invalid request URL given', $exception->getMessage()); + } + + public function testConnectReusesIdleConnectionFromPreviousKeepAliveCallWithoutUsingConnectorAndWillAddAndRemoveStreamEventsAndAddAndCancelIdleTimer() + { + $connectionToReuse = $this->createMock(ConnectionInterface::class); + + $streamHandler = null; + $connectionToReuse->expects($this->exactly(3))->method('on')->withConsecutive( + [ + 'close', + $this->callback(function ($cb) use (&$streamHandler) { + $streamHandler = $cb; + return true; + }) + ], + [ + 'data', + $this->callback(function ($cb) use (&$streamHandler) { + assert($streamHandler instanceof \Closure); + return $cb === $streamHandler; + }) + ], + [ + 'error', + $this->callback(function ($cb) use (&$streamHandler) { + assert($streamHandler instanceof \Closure); + return $cb === $streamHandler; + }) + ] + ); + + $connectionToReuse->expects($this->exactly(3))->method('removeListener')->withConsecutive( + [ + 'close', + $this->callback(function ($cb) use (&$streamHandler) { + assert($streamHandler instanceof \Closure); + return $cb === $streamHandler; + }) + ], + [ + 'data', + $this->callback(function ($cb) use (&$streamHandler) { + assert($streamHandler instanceof \Closure); + return $cb === $streamHandler; + }) + ], + [ + 'error', + $this->callback(function ($cb) use (&$streamHandler) { + assert($streamHandler instanceof \Closure); + return $cb === $streamHandler; + }) + ] + ); + + $connector = $this->createMock(ConnectorInterface::class); + $connector->expects($this->never())->method('connect'); + + $timer = $this->createMock(TimerInterface::class); + $loop = $this->createMock(LoopInterface::class); + $loop->expects($this->once())->method('addTimer')->willReturn($timer); + $loop->expects($this->once())->method('cancelTimer')->with($timer); + + $connectionManager = new ClientConnectionManager($connector, $loop); + + $connectionManager->keepAlive(new Uri('https://reactphp.org/'), $connectionToReuse); + + $promise = $connectionManager->connect(new Uri('https://reactphp.org/')); + assert($promise instanceof PromiseInterface); + + $connection = null; + $promise->then(function ($value) use (&$connection) { + $connection = $value; + }); + + $this->assertSame($connectionToReuse, $connection); + } + + public function testConnectReusesIdleConnectionFromPreviousKeepAliveCallWithoutUsingConnectorAlsoWhenUriPathAndQueryAndFragmentIsDifferent() + { + $connectionToReuse = $this->createMock(ConnectionInterface::class); + + $connector = $this->createMock(ConnectorInterface::class); + $connector->expects($this->never())->method('connect'); + + $timer = $this->createMock(TimerInterface::class); + $loop = $this->createMock(LoopInterface::class); + $loop->expects($this->once())->method('addTimer')->willReturn($timer); + $loop->expects($this->once())->method('cancelTimer')->with($timer); + + $connectionManager = new ClientConnectionManager($connector, $loop); + + $connectionManager->keepAlive(new Uri('https://reactphp.org/http?foo#bar'), $connectionToReuse); + + $promise = $connectionManager->connect(new Uri('https://reactphp.org/http/')); + assert($promise instanceof PromiseInterface); + + $connection = null; + $promise->then(function ($value) use (&$connection) { + $connection = $value; + }); + + $this->assertSame($connectionToReuse, $connection); + } + + public function testConnectUsesConnectorWithSameUriAndReturnsPromiseForNewConnectionFromConnectorWhenPreviousKeepAliveCallUsedDifferentUri() + { + $connectionToReuse = $this->createMock(ConnectionInterface::class); + + $promise = new Promise(function () { }); + $connector = $this->createMock(ConnectorInterface::class); + $connector->expects($this->once())->method('connect')->with('tls://reactphp.org:443')->willReturn($promise); + + $loop = $this->createMock(LoopInterface::class); + + $connectionManager = new ClientConnectionManager($connector, $loop); + + $connectionManager->keepAlive(new Uri('http://reactphp.org/'), $connectionToReuse); + + $ret = $connectionManager->connect(new Uri('https://reactphp.org/')); + + assert($ret instanceof PromiseInterface); + $this->assertSame($promise, $ret); + } + + public function testConnectUsesConnectorForNewConnectionWhenPreviousConnectReusedIdleConnectionFromPreviousKeepAliveCall() + { + $firstConnection = $this->createMock(ConnectionInterface::class); + $secondConnection = $this->createMock(ConnectionInterface::class); + + $connector = $this->createMock(ConnectorInterface::class); + $connector->expects($this->once())->method('connect')->with('tls://reactphp.org:443')->willReturn(resolve($secondConnection)); + + $timer = $this->createMock(TimerInterface::class); + $loop = $this->createMock(LoopInterface::class); + $loop->expects($this->once())->method('addTimer')->willReturn($timer); + $loop->expects($this->once())->method('cancelTimer')->with($timer); + + $connectionManager = new ClientConnectionManager($connector, $loop); + + $connectionManager->keepAlive(new Uri('https://reactphp.org/'), $firstConnection); + + $promise = $connectionManager->connect(new Uri('https://reactphp.org/')); + assert($promise instanceof PromiseInterface); + + $promise = $connectionManager->connect(new Uri('https://reactphp.org/')); + assert($promise instanceof PromiseInterface); + + $connection = null; + $promise->then(function ($value) use (&$connection) { + $connection = $value; + }); + + $this->assertSame($secondConnection, $connection); + } + + public function testKeepAliveAddsTimerAndDoesNotCloseConnectionImmediately() + { + $connection = $this->createMock(ConnectionInterface::class); + $connection->expects($this->never())->method('close'); + + $connector = $this->createMock(ConnectorInterface::class); + + $loop = $this->createMock(LoopInterface::class); + $loop->expects($this->once())->method('addTimer')->with(0.001, $this->anything()); + + $connectionManager = new ClientConnectionManager($connector, $loop); + + $connectionManager->keepAlive(new Uri('https://reactphp.org/'), $connection); + } + + public function testKeepAliveClosesConnectionAfterIdleTimeout() + { + $connection = $this->createMock(ConnectionInterface::class); + $connection->expects($this->once())->method('close'); + + $connector = $this->createMock(ConnectorInterface::class); + + $timerCallback = null; + $timer = $this->createMock(TimerInterface::class); + $loop = $this->createMock(LoopInterface::class); + $loop->expects($this->once())->method('addTimer')->with($this->anything(), $this->callback(function ($cb) use (&$timerCallback) { + $timerCallback = $cb; + return true; + }))->willReturn($timer); + $loop->expects($this->once())->method('cancelTimer')->with($timer); + + $connectionManager = new ClientConnectionManager($connector, $loop); + + $connectionManager->keepAlive(new Uri('https://reactphp.org/'), $connection); + + // manually invoker timer function to emulate time has passed + $this->assertNotNull($timerCallback); + call_user_func($timerCallback); // $timerCallback() (PHP 5.4+) + } + + public function testConnectUsesConnectorForNewConnectionWhenIdleConnectionFromPreviousKeepAliveCallHasAlreadyTimedOut() + { + $firstConnection = $this->createMock(ConnectionInterface::class); + $firstConnection->expects($this->once())->method('close'); + + $secondConnection = $this->createMock(ConnectionInterface::class); + $secondConnection->expects($this->never())->method('close'); + + $connector = $this->createMock(ConnectorInterface::class); + $connector->expects($this->once())->method('connect')->with('tls://reactphp.org:443')->willReturn(resolve($secondConnection)); + + $timerCallback = null; + $timer = $this->createMock(TimerInterface::class); + $loop = $this->createMock(LoopInterface::class); + $loop->expects($this->once())->method('addTimer')->with($this->anything(), $this->callback(function ($cb) use (&$timerCallback) { + $timerCallback = $cb; + return true; + }))->willReturn($timer); + $loop->expects($this->once())->method('cancelTimer')->with($timer); + + $connectionManager = new ClientConnectionManager($connector, $loop); + + $connectionManager->keepAlive(new Uri('https://reactphp.org/'), $firstConnection); + + // manually invoker timer function to emulate time has passed + $this->assertNotNull($timerCallback); + call_user_func($timerCallback); // $timerCallback() (PHP 5.4+) + + $promise = $connectionManager->connect(new Uri('https://reactphp.org/')); + assert($promise instanceof PromiseInterface); + + $connection = null; + $promise->then(function ($value) use (&$connection) { + $connection = $value; + }); + + $this->assertSame($secondConnection, $connection); + } + + public function testConnectUsesConnectorForNewConnectionWhenIdleConnectionFromPreviousKeepAliveCallHasAlreadyFiredUnexpectedStreamEventBeforeIdleTimeoutThatClosesConnection() + { + $firstConnection = $this->createMock(ConnectionInterface::class); + $firstConnection->expects($this->once())->method('close'); + + $streamHandler = null; + $firstConnection->expects($this->exactly(3))->method('on')->withConsecutive( + [ + 'close', + $this->callback(function ($cb) use (&$streamHandler) { + $streamHandler = $cb; + return true; + }) + ], + [ + 'data', + $this->callback(function ($cb) use (&$streamHandler) { + assert($streamHandler instanceof \Closure); + return $cb === $streamHandler; + }) + ], + [ + 'error', + $this->callback(function ($cb) use (&$streamHandler) { + assert($streamHandler instanceof \Closure); + return $cb === $streamHandler; + }) + ] + ); + + $secondConnection = $this->createMock(ConnectionInterface::class); + $secondConnection->expects($this->never())->method('close'); + + $connector = $this->createMock(ConnectorInterface::class); + $connector->expects($this->once())->method('connect')->with('tls://reactphp.org:443')->willReturn(resolve($secondConnection)); + + $timer = $this->createMock(TimerInterface::class); + $loop = $this->createMock(LoopInterface::class); + $loop->expects($this->once())->method('addTimer')->willReturn($timer); + $loop->expects($this->once())->method('cancelTimer')->with($timer); + + $connectionManager = new ClientConnectionManager($connector, $loop); + + $connectionManager->keepAlive(new Uri('https://reactphp.org/'), $firstConnection); + + // manually invoke connection close to emulate server closing idle connection before idle timeout + $this->assertNotNull($streamHandler); + call_user_func($streamHandler); // $streamHandler() (PHP 5.4+) + + $promise = $connectionManager->connect(new Uri('https://reactphp.org/')); + assert($promise instanceof PromiseInterface); + + $connection = null; + $promise->then(function ($value) use (&$connection) { + $connection = $value; + }); + + $this->assertSame($secondConnection, $connection); + } +} diff --git a/tests/Io/ClientRequestStreamTest.php b/tests/Io/ClientRequestStreamTest.php new file mode 100644 index 00000000..a20cda61 --- /dev/null +++ b/tests/Io/ClientRequestStreamTest.php @@ -0,0 +1,1065 @@ +createMock(ConnectionInterface::class); + + $uri = new Uri('http://www.example.com/'); + $connectionManager = $this->createMock(ClientConnectionManager::class); + $connectionManager->expects($this->once())->method('connect')->with($uri)->willReturn(resolve($connection)); + + $requestData = new Request('GET', $uri); + $request = new ClientRequestStream($connectionManager, $requestData); + + $connection->expects($this->atLeast(5))->method('on')->withConsecutive( + ['drain', $this->identicalTo([$request, 'handleDrain'])], + ['data', $this->identicalTo([$request, 'handleData'])], + ['end', $this->identicalTo([$request, 'handleEnd'])], + ['error', $this->identicalTo([$request, 'handleError'])], + ['close', $this->identicalTo([$request, 'close'])] + ); + + $connection->expects($this->exactly(5))->method('removeListener')->withConsecutive( + ['drain', $this->identicalTo([$request, 'handleDrain'])], + ['data', $this->identicalTo([$request, 'handleData'])], + ['end', $this->identicalTo([$request, 'handleEnd'])], + ['error', $this->identicalTo([$request, 'handleError'])], + ['close', $this->identicalTo([$request, 'close'])] + ); + + $request->end(); + + $request->handleData("HTTP/1.0 200 OK\r\n"); + $request->handleData("Content-Type: text/plain\r\n"); + $request->handleData("\r\nbody"); + } + + /** @test */ + public function requestShouldEmitErrorIfConnectionFails() + { + $connectionManager = $this->createMock(ClientConnectionManager::class); + $connectionManager->expects($this->once())->method('connect')->willReturn(reject(new \RuntimeException())); + + $requestData = new Request('GET', 'http://www.example.com/'); + $request = new ClientRequestStream($connectionManager, $requestData); + + $request->on('error', $this->expectCallableOnceWith($this->isInstanceOf(\RuntimeException::class))); + $request->on('close', $this->expectCallableOnce()); + + $request->end(); + } + + /** @test */ + public function requestShouldEmitErrorIfConnectionClosesBeforeResponseIsParsed() + { + $connection = $this->createMock(ConnectionInterface::class); + + $connectionManager = $this->createMock(ClientConnectionManager::class); + $connectionManager->expects($this->once())->method('connect')->willReturn(resolve($connection)); + + $requestData = new Request('GET', 'http://www.example.com/'); + $request = new ClientRequestStream($connectionManager, $requestData); + + $request->on('error', $this->expectCallableOnceWith($this->isInstanceOf(\RuntimeException::class))); + $request->on('close', $this->expectCallableOnce()); + + $request->end(); + $request->handleEnd(); + } + + /** @test */ + public function requestShouldEmitErrorIfConnectionEmitsError() + { + $connection = $this->createMock(ConnectionInterface::class); + + $connectionManager = $this->createMock(ClientConnectionManager::class); + $connectionManager->expects($this->once())->method('connect')->willReturn(resolve($connection)); + + $requestData = new Request('GET', 'http://www.example.com/'); + $request = new ClientRequestStream($connectionManager, $requestData); + + $request->on('error', $this->expectCallableOnceWith($this->isInstanceOf(\Exception::class))); + $request->on('close', $this->expectCallableOnce()); + + $request->end(); + $request->handleError(new \Exception('test')); + } + + public static function provideInvalidRequest() + { + $request = new Request('GET' , "http://localhost/"); + + yield [ + $request->withMethod("INVA\r\nLID", '') + ]; + yield [ + $request->withRequestTarget('/inva lid') + ]; + yield [ + $request->withHeader('Invalid', "Yes\r\n") + ]; + yield [ + $request->withHeader('Invalid', "Yes\n") + ]; + yield [ + $request->withHeader('Invalid', "Yes\r") + ]; + yield [ + $request->withHeader("Inva\r\nlid", 'Yes') + ]; + yield [ + $request->withHeader("Inva\nlid", 'Yes') + ]; + yield [ + $request->withHeader("Inva\rlid", 'Yes') + ]; + yield [ + $request->withHeader('Inva Lid', 'Yes') + ]; + yield [ + $request->withHeader('Inva:Lid', 'Yes') + ]; + yield [ + $request->withHeader('Invalid', "Val\0ue") + ]; + yield [ + $request->withHeader("Inva\0lid", 'Yes') + ]; + } + + /** + * @dataProvider provideInvalidRequest + * @param RequestInterface $request + */ + public function testStreamShouldEmitErrorBeforeCreatingConnectionWhenRequestIsInvalid(RequestInterface $request) + { + $connectionManager = $this->createMock(ClientConnectionManager::class); + $connectionManager->expects($this->never())->method('connect'); + + $stream = new ClientRequestStream($connectionManager, $request); + + $stream->on('error', $this->expectCallableOnceWith($this->isInstanceOf(\InvalidArgumentException::class))); + $stream->on('close', $this->expectCallableOnce()); + + $stream->end(); + } + + /** @test */ + public function requestShouldEmitErrorIfRequestParserThrowsException() + { + $connection = $this->createMock(ConnectionInterface::class); + + $connectionManager = $this->createMock(ClientConnectionManager::class); + $connectionManager->expects($this->once())->method('connect')->willReturn(resolve($connection)); + + $requestData = new Request('GET', 'http://www.example.com/'); + $request = new ClientRequestStream($connectionManager, $requestData); + + $request->on('error', $this->expectCallableOnceWith($this->isInstanceOf(\InvalidArgumentException::class))); + $request->on('close', $this->expectCallableOnce()); + + $request->end(); + $request->handleData("\r\n\r\n"); + } + + /** @test */ + public function getRequestShouldSendAGetRequest() + { + $connection = $this->createMock(ConnectionInterface::class); + $connection->expects($this->once())->method('write')->with("GET / HTTP/1.0\r\nHost: www.example.com\r\n\r\n"); + + $connectionManager = $this->createMock(ClientConnectionManager::class); + $connectionManager->expects($this->once())->method('connect')->willReturn(resolve($connection)); + + $requestData = new Request('GET', 'http://www.example.com/', [], '', '1.0'); + $request = new ClientRequestStream($connectionManager, $requestData); + + $request->end(); + } + + /** @test */ + public function getHttp11RequestShouldSendAGetRequestWithGivenConnectionCloseHeader() + { + $connection = $this->createMock(ConnectionInterface::class); + $connection->expects($this->once())->method('write')->with("GET / HTTP/1.1\r\nHost: www.example.com\r\nConnection: close\r\n\r\n"); + + $connectionManager = $this->createMock(ClientConnectionManager::class); + $connectionManager->expects($this->once())->method('connect')->willReturn(resolve($connection)); + + $requestData = new Request('GET', 'http://www.example.com/', ['Connection' => 'close'], '', '1.1'); + $request = new ClientRequestStream($connectionManager, $requestData); + + $request->end(); + } + + /** @test */ + public function getOptionsAsteriskShouldSendAOptionsRequestAsteriskRequestTarget() + { + $connection = $this->createMock(ConnectionInterface::class); + $connection->expects($this->once())->method('write')->with("OPTIONS * HTTP/1.1\r\nHost: www.example.com\r\nConnection: close\r\n\r\n"); + + $connectionManager = $this->createMock(ClientConnectionManager::class); + $connectionManager->expects($this->once())->method('connect')->willReturn(resolve($connection)); + + $requestData = new Request('OPTIONS', 'http://www.example.com/', ['Connection' => 'close'], '', '1.1'); + $requestData = $requestData->withRequestTarget('*'); + $request = new ClientRequestStream($connectionManager, $requestData); + + $request->end(); + } + + public function testStreamShouldEmitResponseWithEmptyBodyWhenResponseContainsContentLengthZero() + { + $connection = $this->createMock(ConnectionInterface::class); + $connection->expects($this->once())->method('write')->with("GET / HTTP/1.1\r\nHost: www.example.com\r\nConnection: close\r\n\r\n"); + $connection->expects($this->once())->method('close'); + + $connectionManager = $this->createMock(ClientConnectionManager::class); + $connectionManager->expects($this->once())->method('connect')->willReturn(resolve($connection)); + + $requestData = new Request('GET', 'http://www.example.com/', ['Connection' => 'close'], '', '1.1'); + $request = new ClientRequestStream($connectionManager, $requestData); + + $request->on('response', function (ResponseInterface $response, ReadableStreamInterface $body) { + $body->on('data', $this->expectCallableNever()); + $body->on('end', $this->expectCallableOnce()); + $body->on('close', $this->expectCallableOnce()); + }); + $request->on('close', $this->expectCallableOnce()); + + $request->end(); + + $request->handleData("HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n"); + } + + public function testStreamShouldEmitResponseWithEmptyBodyWhenResponseContainsStatusNoContent() + { + $connection = $this->createMock(ConnectionInterface::class); + $connection->expects($this->once())->method('write')->with("GET / HTTP/1.1\r\nHost: www.example.com\r\nConnection: close\r\n\r\n"); + $connection->expects($this->once())->method('close'); + + $connectionManager = $this->createMock(ClientConnectionManager::class); + $connectionManager->expects($this->once())->method('connect')->willReturn(resolve($connection)); + + $requestData = new Request('GET', 'http://www.example.com/', ['Connection' => 'close'], '', '1.1'); + $request = new ClientRequestStream($connectionManager, $requestData); + + $request->on('response', function (ResponseInterface $response, ReadableStreamInterface $body) { + $body->on('data', $this->expectCallableNever()); + $body->on('end', $this->expectCallableOnce()); + $body->on('close', $this->expectCallableOnce()); + }); + $request->on('close', $this->expectCallableOnce()); + + $request->end(); + + $request->handleData("HTTP/1.1 204 No Content\r\n\r\n"); + } + + public function testStreamShouldEmitResponseWithEmptyBodyWhenResponseContainsStatusNotModifiedWithContentLengthGiven() + { + $connection = $this->createMock(ConnectionInterface::class); + $connection->expects($this->once())->method('write')->with("GET / HTTP/1.1\r\nHost: www.example.com\r\nConnection: close\r\n\r\n"); + $connection->expects($this->once())->method('close'); + + $connectionManager = $this->createMock(ClientConnectionManager::class); + $connectionManager->expects($this->once())->method('connect')->willReturn(resolve($connection)); + + $requestData = new Request('GET', 'http://www.example.com/', ['Connection' => 'close'], '', '1.1'); + $request = new ClientRequestStream($connectionManager, $requestData); + + $request->on('response', function (ResponseInterface $response, ReadableStreamInterface $body) { + $body->on('data', $this->expectCallableNever()); + $body->on('end', $this->expectCallableOnce()); + $body->on('close', $this->expectCallableOnce()); + }); + $request->on('close', $this->expectCallableOnce()); + + $request->end(); + + $request->handleData("HTTP/1.1 304 Not Modified\r\nContent-Length: 100\r\n\r\n"); + } + + public function testStreamShouldEmitResponseWithEmptyBodyWhenRequestMethodIsHead() + { + $connection = $this->createMock(ConnectionInterface::class); + $connection->expects($this->once())->method('write')->with("HEAD / HTTP/1.1\r\nHost: www.example.com\r\nConnection: close\r\n\r\n"); + $connection->expects($this->once())->method('close'); + + $connectionManager = $this->createMock(ClientConnectionManager::class); + $connectionManager->expects($this->once())->method('connect')->willReturn(resolve($connection)); + + $requestData = new Request('HEAD', 'http://www.example.com/', ['Connection' => 'close'], '', '1.1'); + $request = new ClientRequestStream($connectionManager, $requestData); + + $request->on('response', function (ResponseInterface $response, ReadableStreamInterface $body) { + $body->on('data', $this->expectCallableNever()); + $body->on('end', $this->expectCallableOnce()); + $body->on('close', $this->expectCallableOnce()); + }); + $request->on('close', $this->expectCallableOnce()); + + $request->end(); + + $request->handleData("HTTP/1.1 200 OK\r\nContent-Length: 100\r\n\r\n"); + } + + public function testStreamShouldEmitResponseWithStreamingBodyUntilEndWhenResponseContainsContentLengthAndResponseBody() + { + $connection = $this->createMock(ConnectionInterface::class); + $connection->expects($this->once())->method('write')->with("GET / HTTP/1.1\r\nHost: www.example.com\r\nConnection: close\r\n\r\n"); + $connection->expects($this->once())->method('close'); + + $connectionManager = $this->createMock(ClientConnectionManager::class); + $connectionManager->expects($this->once())->method('connect')->willReturn(resolve($connection)); + + $requestData = new Request('GET', 'http://www.example.com/', ['Connection' => 'close'], '', '1.1'); + $request = new ClientRequestStream($connectionManager, $requestData); + + $request->on('response', function (ResponseInterface $response, ReadableStreamInterface $body) { + $body->on('data', $this->expectCallableOnceWith('OK')); + $body->on('end', $this->expectCallableOnce()); + $body->on('close', $this->expectCallableOnce()); + }); + $request->on('close', $this->expectCallableOnce()); + + $request->end(); + + $request->handleData("HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nOK"); + } + + public function testStreamShouldEmitResponseWithStreamingBodyWithoutDataWhenResponseContainsContentLengthWithoutResponseBody() + { + $connection = $this->createMock(ConnectionInterface::class); + $connection->expects($this->once())->method('write')->with("GET / HTTP/1.1\r\nHost: www.example.com\r\nConnection: close\r\n\r\n"); + $connection->expects($this->never())->method('close'); + + $connectionManager = $this->createMock(ClientConnectionManager::class); + $connectionManager->expects($this->once())->method('connect')->willReturn(resolve($connection)); + + $requestData = new Request('GET', 'http://www.example.com/', ['Connection' => 'close'], '', '1.1'); + $request = new ClientRequestStream($connectionManager, $requestData); + + $request->on('response', function (ResponseInterface $response, ReadableStreamInterface $body) { + $body->on('data', $this->expectCallableNever()); + $body->on('end', $this->expectCallableNever()); + $body->on('close', $this->expectCallableNever()); + }); + $request->on('close', $this->expectCallableNever()); + + $request->end(); + + $request->handleData("HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\n"); + } + + public function testStreamShouldEmitResponseWithStreamingBodyWithDataWithoutEndWhenResponseContainsContentLengthWithIncompleteResponseBody() + { + $connection = $this->createMock(ConnectionInterface::class); + $connection->expects($this->once())->method('write')->with("GET / HTTP/1.1\r\nHost: www.example.com\r\nConnection: close\r\n\r\n"); + $connection->expects($this->never())->method('close'); + + $connectionManager = $this->createMock(ClientConnectionManager::class); + $connectionManager->expects($this->once())->method('connect')->willReturn(resolve($connection)); + + $requestData = new Request('GET', 'http://www.example.com/', ['Connection' => 'close'], '', '1.1'); + $request = new ClientRequestStream($connectionManager, $requestData); + + $request->on('response', function (ResponseInterface $response, ReadableStreamInterface $body) { + $body->on('data', $this->expectCallableOnce('O')); + $body->on('end', $this->expectCallableNever()); + $body->on('close', $this->expectCallableNever()); + }); + $request->on('close', $this->expectCallableNever()); + + $request->end(); + + $request->handleData("HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nO"); + } + + public function testStreamShouldEmitResponseWithStreamingBodyUntilEndWhenResponseContainsTransferEncodingChunkedAndResponseBody() + { + $connection = $this->createMock(ConnectionInterface::class); + $connection->expects($this->once())->method('write')->with("GET / HTTP/1.1\r\nHost: www.example.com\r\nConnection: close\r\n\r\n"); + $connection->expects($this->once())->method('close'); + + $connectionManager = $this->createMock(ClientConnectionManager::class); + $connectionManager->expects($this->once())->method('connect')->willReturn(resolve($connection)); + + $requestData = new Request('GET', 'http://www.example.com/', ['Connection' => 'close'], '', '1.1'); + $request = new ClientRequestStream($connectionManager, $requestData); + + $request->on('response', function (ResponseInterface $response, ReadableStreamInterface $body) { + $body->on('data', $this->expectCallableOnceWith('OK')); + $body->on('end', $this->expectCallableOnce()); + $body->on('close', $this->expectCallableOnce()); + }); + $request->on('close', $this->expectCallableOnce()); + + $request->end(); + + $request->handleData("HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n2\r\nOK\r\n0\r\n\r\n"); + } + + public function testStreamShouldEmitResponseWithStreamingBodyWithoutDataWhenResponseContainsTransferEncodingChunkedWithoutResponseBody() + { + $connection = $this->createMock(ConnectionInterface::class); + $connection->expects($this->once())->method('write')->with("GET / HTTP/1.1\r\nHost: www.example.com\r\nConnection: close\r\n\r\n"); + $connection->expects($this->never())->method('close'); + + $connectionManager = $this->createMock(ClientConnectionManager::class); + $connectionManager->expects($this->once())->method('connect')->willReturn(resolve($connection)); + + $requestData = new Request('GET', 'http://www.example.com/', ['Connection' => 'close'], '', '1.1'); + $request = new ClientRequestStream($connectionManager, $requestData); + + $request->on('response', function (ResponseInterface $response, ReadableStreamInterface $body) { + $body->on('data', $this->expectCallableNever()); + $body->on('end', $this->expectCallableNever()); + $body->on('close', $this->expectCallableNever()); + }); + $request->on('close', $this->expectCallableNever()); + + $request->end(); + + $request->handleData("HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n"); + } + + public function testStreamShouldEmitResponseWithStreamingBodyWithDataWithoutEndWhenResponseContainsTransferEncodingChunkedWithIncompleteResponseBody() + { + $connection = $this->createMock(ConnectionInterface::class); + $connection->expects($this->once())->method('write')->with("GET / HTTP/1.1\r\nHost: www.example.com\r\nConnection: close\r\n\r\n"); + $connection->expects($this->never())->method('close'); + + $connectionManager = $this->createMock(ClientConnectionManager::class); + $connectionManager->expects($this->once())->method('connect')->willReturn(resolve($connection)); + + $requestData = new Request('GET', 'http://www.example.com/', ['Connection' => 'close'], '', '1.1'); + $request = new ClientRequestStream($connectionManager, $requestData); + + $request->on('response', function (ResponseInterface $response, ReadableStreamInterface $body) { + $body->on('data', $this->expectCallableOnceWith('O')); + $body->on('end', $this->expectCallableNever()); + $body->on('close', $this->expectCallableNever()); + }); + $request->on('close', $this->expectCallableNever()); + + $request->end(); + + $request->handleData("HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n2\r\nO"); + } + + public function testStreamShouldEmitResponseWithStreamingBodyWithDataWithoutEndWhenResponseContainsNoContentLengthAndIncompleteResponseBody() + { + $connection = $this->createMock(ConnectionInterface::class); + $connection->expects($this->once())->method('write')->with("GET / HTTP/1.1\r\nHost: www.example.com\r\nConnection: close\r\n\r\n"); + $connection->expects($this->never())->method('close'); + + $connectionManager = $this->createMock(ClientConnectionManager::class); + $connectionManager->expects($this->once())->method('connect')->willReturn(resolve($connection)); + + $requestData = new Request('GET', 'http://www.example.com/', ['Connection' => 'close'], '', '1.1'); + $request = new ClientRequestStream($connectionManager, $requestData); + + $request->on('response', function (ResponseInterface $response, ReadableStreamInterface $body) { + $body->on('data', $this->expectCallableOnce('O')); + $body->on('end', $this->expectCallableNever()); + $body->on('close', $this->expectCallableNever()); + }); + $request->on('close', $this->expectCallableNever()); + + $request->end(); + + $request->handleData("HTTP/1.1 200 OK\r\n\r\nO"); + } + + public function testStreamShouldEmitResponseWithStreamingBodyUntilEndWhenResponseContainsNoContentLengthAndResponseBodyTerminatedByConnectionEndEvent() + { + $connection = $this->createMock(ConnectionInterface::class); + $connection->expects($this->once())->method('write')->with("GET / HTTP/1.1\r\nHost: www.example.com\r\nConnection: close\r\n\r\n"); + $connection->expects($this->once())->method('close'); + + $endEvent = null; + $eventName = null; + $connection->expects($this->any())->method('on')->with($this->callback(function ($name) use (&$eventName) { + $eventName = $name; + return true; + }), $this->callback(function ($cb) use (&$endEvent, &$eventName) { + if ($eventName === 'end') { + $endEvent = $cb; + } + return true; + })); + + $connectionManager = $this->createMock(ClientConnectionManager::class); + $connectionManager->expects($this->once())->method('connect')->willReturn(resolve($connection)); + + $requestData = new Request('GET', 'http://www.example.com/', ['Connection' => 'close'], '', '1.1'); + $request = new ClientRequestStream($connectionManager, $requestData); + + $request->on('response', function (ResponseInterface $response, ReadableStreamInterface $body) { + $body->on('data', $this->expectCallableOnce('OK')); + $body->on('end', $this->expectCallableOnce()); + $body->on('close', $this->expectCallableOnce()); + }); + $request->on('close', $this->expectCallableOnce()); + + $request->end(); + + $request->handleData("HTTP/1.1 200 OK\r\n\r\nOK"); + + $this->assertNotNull($endEvent); + call_user_func($endEvent); // $endEvent() (PHP 5.4+) + } + + public function testStreamShouldReuseConnectionForHttp11ByDefault() + { + $connection = $this->createMock(ConnectionInterface::class); + $connection->expects($this->once())->method('write')->with("GET / HTTP/1.1\r\nHost: www.example.com\r\n\r\n"); + $connection->expects($this->once())->method('isReadable')->willReturn(true); + $connection->expects($this->never())->method('close'); + + $connectionManager = $this->createMock(ClientConnectionManager::class); + $connectionManager->expects($this->once())->method('connect')->willReturn(resolve($connection)); + $connectionManager->expects($this->once())->method('keepAlive')->with(new Uri('http://www.example.com/'), $connection); + + $requestData = new Request('GET', 'http://www.example.com/', [], '', '1.1'); + $request = new ClientRequestStream($connectionManager, $requestData); + + $request->on('close', $this->expectCallableOnce()); + + $request->end(); + + $request->handleData("HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n"); + } + + public function testStreamShouldNotReuseConnectionWhenResponseContainsConnectionClose() + { + $connection = $this->createMock(ConnectionInterface::class); + $connection->expects($this->once())->method('write')->with("GET / HTTP/1.1\r\nHost: www.example.com\r\n\r\n"); + $connection->expects($this->once())->method('isReadable')->willReturn(true); + $connection->expects($this->once())->method('close'); + + $connectionManager = $this->createMock(ClientConnectionManager::class); + $connectionManager->expects($this->once())->method('connect')->willReturn(resolve($connection)); + + $requestData = new Request('GET', 'http://www.example.com/', [], '', '1.1'); + $request = new ClientRequestStream($connectionManager, $requestData); + + $request->on('close', $this->expectCallableOnce()); + + $request->end(); + + $request->handleData("HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"); + } + + public function testStreamShouldNotReuseConnectionWhenRequestContainsConnectionCloseWithAdditionalOptions() + { + $connection = $this->createMock(ConnectionInterface::class); + $connection->expects($this->once())->method('write')->with("GET / HTTP/1.1\r\nHost: www.example.com\r\nConnection: FOO, CLOSE, BAR\r\n\r\n"); + $connection->expects($this->once())->method('isReadable')->willReturn(true); + $connection->expects($this->once())->method('close'); + + $connectionManager = $this->createMock(ClientConnectionManager::class); + $connectionManager->expects($this->once())->method('connect')->willReturn(resolve($connection)); + + $requestData = new Request('GET', 'http://www.example.com/', ['Connection' => 'FOO, CLOSE, BAR'], '', '1.1'); + $request = new ClientRequestStream($connectionManager, $requestData); + + $request->on('close', $this->expectCallableOnce()); + + $request->end(); + + $request->handleData("HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: Foo, Close, Bar\r\n\r\n"); + } + + public function testStreamShouldNotReuseConnectionForHttp10ByDefault() + { + $connection = $this->createMock(ConnectionInterface::class); + $connection->expects($this->once())->method('write')->with("GET / HTTP/1.0\r\nHost: www.example.com\r\n\r\n"); + $connection->expects($this->once())->method('isReadable')->willReturn(true); + $connection->expects($this->once())->method('close'); + + $connectionManager = $this->createMock(ClientConnectionManager::class); + $connectionManager->expects($this->once())->method('connect')->willReturn(resolve($connection)); + + $requestData = new Request('GET', 'http://www.example.com/', [], '', '1.0'); + $request = new ClientRequestStream($connectionManager, $requestData); + + $request->on('close', $this->expectCallableOnce()); + + $request->end(); + + $request->handleData("HTTP/1.0 200 OK\r\nContent-Length: 0\r\n\r\n"); + } + + public function testStreamShouldReuseConnectionForHttp10WhenBothRequestAndResponseContainConnectionKeepAlive() + { + $connection = $this->createMock(ConnectionInterface::class); + $connection->expects($this->once())->method('write')->with("GET / HTTP/1.0\r\nHost: www.example.com\r\nConnection: keep-alive\r\n\r\n"); + $connection->expects($this->once())->method('isReadable')->willReturn(true); + $connection->expects($this->never())->method('close'); + + $connectionManager = $this->createMock(ClientConnectionManager::class); + $connectionManager->expects($this->once())->method('connect')->willReturn(resolve($connection)); + $connectionManager->expects($this->once())->method('keepAlive')->with(new Uri('http://www.example.com/'), $connection); + + $requestData = new Request('GET', 'http://www.example.com/', ['Connection' => 'keep-alive'], '', '1.0'); + $request = new ClientRequestStream($connectionManager, $requestData); + + $request->on('close', $this->expectCallableOnce()); + + $request->end(); + + $request->handleData("HTTP/1.0 200 OK\r\nContent-Length: 0\r\nConnection: keep-alive\r\n\r\n"); + } + + public function testStreamShouldReuseConnectionForHttp10WhenBothRequestAndResponseContainConnectionKeepAliveWithAdditionalOptions() + { + $connection = $this->createMock(ConnectionInterface::class); + $connection->expects($this->once())->method('write')->with("GET / HTTP/1.0\r\nHost: www.example.com\r\nConnection: FOO, KEEP-ALIVE, BAR\r\n\r\n"); + $connection->expects($this->once())->method('isReadable')->willReturn(true); + $connection->expects($this->never())->method('close'); + + $connectionManager = $this->createMock(ClientConnectionManager::class); + $connectionManager->expects($this->once())->method('connect')->willReturn(resolve($connection)); + $connectionManager->expects($this->once())->method('keepAlive')->with(new Uri('http://www.example.com/'), $connection); + + $requestData = new Request('GET', 'http://www.example.com/', ['Connection' => 'FOO, KEEP-ALIVE, BAR'], '', '1.0'); + $request = new ClientRequestStream($connectionManager, $requestData); + + $request->on('close', $this->expectCallableOnce()); + + $request->end(); + + $request->handleData("HTTP/1.0 200 OK\r\nContent-Length: 0\r\nConnection: Foo, Keep-Alive, Bar\r\n\r\n"); + } + + public function testStreamShouldNotReuseConnectionWhenResponseContainsNoContentLengthAndResponseBodyTerminatedByConnectionEndEvent() + { + $connection = $this->createMock(ConnectionInterface::class); + $connection->expects($this->once())->method('write')->with("GET / HTTP/1.1\r\nHost: www.example.com\r\n\r\n"); + $connection->expects($this->once())->method('isReadable')->willReturn(false); + $connection->expects($this->once())->method('close'); + + $endEvent = null; + $eventName = null; + $connection->expects($this->any())->method('on')->with($this->callback(function ($name) use (&$eventName) { + $eventName = $name; + return true; + }), $this->callback(function ($cb) use (&$endEvent, &$eventName) { + if ($eventName === 'end') { + $endEvent = $cb; + } + return true; + })); + + $connectionManager = $this->createMock(ClientConnectionManager::class); + $connectionManager->expects($this->once())->method('connect')->willReturn(resolve($connection)); + + $requestData = new Request('GET', 'http://www.example.com/', [], '', '1.1'); + $request = new ClientRequestStream($connectionManager, $requestData); + + $request->on('close', $this->expectCallableOnce()); + + $request->end(); + + $request->handleData("HTTP/1.1 200 OK\r\n\r\n"); + + $this->assertNotNull($endEvent); + call_user_func($endEvent); // $endEvent() (PHP 5.4+) + } + + public function testStreamShouldNotReuseConnectionWhenResponseContainsContentLengthButIsTerminatedByUnexpectedCloseEvent() + { + $connection = $this->createMock(ConnectionInterface::class); + $connection->expects($this->once())->method('write')->with("GET / HTTP/1.1\r\nHost: www.example.com\r\n\r\n"); + $connection->expects($this->atMost(1))->method('isReadable')->willReturn(false); + $connection->expects($this->once())->method('close'); + + $closeEvent = null; + $eventName = null; + $connection->expects($this->any())->method('on')->with($this->callback(function ($name) use (&$eventName) { + $eventName = $name; + return true; + }), $this->callback(function ($cb) use (&$closeEvent, &$eventName) { + if ($eventName === 'close') { + $closeEvent = $cb; + } + return true; + })); + + $connectionManager = $this->createMock(ClientConnectionManager::class); + $connectionManager->expects($this->once())->method('connect')->willReturn(resolve($connection)); + + $requestData = new Request('GET', 'http://www.example.com/', [], '', '1.1'); + $request = new ClientRequestStream($connectionManager, $requestData); + + $request->on('close', $this->expectCallableOnce()); + + $request->end(); + + $request->handleData("HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\n"); + + $this->assertNotNull($closeEvent); + call_user_func($closeEvent); // $closeEvent() (PHP 5.4+) + } + + public function testStreamShouldReuseConnectionWhenResponseContainsTransferEncodingChunkedAndResponseBody() + { + $connection = $this->createMock(ConnectionInterface::class); + $connection->expects($this->once())->method('write')->with("GET / HTTP/1.1\r\nHost: www.example.com\r\n\r\n"); + $connection->expects($this->once())->method('isReadable')->willReturn(true); + $connection->expects($this->never())->method('close'); + + $connectionManager = $this->createMock(ClientConnectionManager::class); + $connectionManager->expects($this->once())->method('connect')->willReturn(resolve($connection)); + $connectionManager->expects($this->once())->method('keepAlive')->with(new Uri('http://www.example.com/'), $connection); + + $requestData = new Request('GET', 'http://www.example.com/', [], '', '1.1'); + $request = new ClientRequestStream($connectionManager, $requestData); + + $request->on('close', $this->expectCallableOnce()); + + $request->end(); + + $request->handleData("HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n2\r\nOK\r\n0\r\n\r\n"); + } + + public function testStreamShouldNotReuseConnectionWhenResponseContainsTransferEncodingChunkedAndResponseBodyContainsInvalidData() + { + $connection = $this->createMock(ConnectionInterface::class); + $connection->expects($this->once())->method('write')->with("GET / HTTP/1.1\r\nHost: www.example.com\r\n\r\n"); + $connection->expects($this->atMost(1))->method('isReadable')->willReturn(true); + $connection->expects($this->once())->method('close'); + + $connectionManager = $this->createMock(ClientConnectionManager::class); + $connectionManager->expects($this->once())->method('connect')->willReturn(resolve($connection)); + + $requestData = new Request('GET', 'http://www.example.com/', [], '', '1.1'); + $request = new ClientRequestStream($connectionManager, $requestData); + + $request->on('close', $this->expectCallableOnce()); + + $request->end(); + + $request->handleData("HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\nINVALID\r\n"); + } + + /** @test */ + public function postRequestShouldSendAPostRequest() + { + $connection = $this->createMock(ConnectionInterface::class); + $connection->expects($this->once())->method('write')->with($this->matchesRegularExpression("#^POST / HTTP/1\.0\r\nHost: www.example.com\r\n\r\nsome post data$#")); + + $connectionManager = $this->createMock(ClientConnectionManager::class); + $connectionManager->expects($this->once())->method('connect')->willReturn(resolve($connection)); + + $requestData = new Request('POST', 'http://www.example.com/', [], '', '1.0'); + $request = new ClientRequestStream($connectionManager, $requestData); + + $request->end('some post data'); + + $request->handleData("HTTP/1.0 200 OK\r\n"); + $request->handleData("Content-Type: text/plain\r\n"); + $request->handleData("\r\nbody"); + } + + /** @test */ + public function writeWithAPostRequestShouldSendToTheStream() + { + $connection = $this->createMock(ConnectionInterface::class); + $connection->expects($this->exactly(3))->method('write')->withConsecutive( + [$this->matchesRegularExpression("#^POST / HTTP/1\.0\r\nHost: www.example.com\r\n\r\nsome$#")], + [$this->identicalTo("post")], + [$this->identicalTo("data")] + ); + + $connectionManager = $this->createMock(ClientConnectionManager::class); + $connectionManager->expects($this->once())->method('connect')->willReturn(resolve($connection)); + + $requestData = new Request('POST', 'http://www.example.com/', [], '', '1.0'); + $request = new ClientRequestStream($connectionManager, $requestData); + + $request->write("some"); + $request->write("post"); + $request->end("data"); + + $request->handleData("HTTP/1.0 200 OK\r\n"); + $request->handleData("Content-Type: text/plain\r\n"); + $request->handleData("\r\nbody"); + } + + /** @test */ + public function writeWithAPostRequestShouldSendBodyAfterHeadersAndEmitDrainEvent() + { + $connection = $this->createMock(ConnectionInterface::class); + $connection->expects($this->exactly(2))->method('write')->withConsecutive( + [$this->matchesRegularExpression("#^POST / HTTP/1\.0\r\nHost: www.example.com\r\n\r\nsomepost$#")], + [$this->identicalTo("data")] + )->willReturn( + true + ); + + $deferred = new Deferred(); + $connectionManager = $this->createMock(ClientConnectionManager::class); + $connectionManager->expects($this->once())->method('connect')->willReturn($deferred->promise()); + + $requestData = new Request('POST', 'http://www.example.com/', [], '', '1.0'); + $request = new ClientRequestStream($connectionManager, $requestData); + + $this->assertFalse($request->write("some")); + $this->assertFalse($request->write("post")); + + $request->on('drain', $this->expectCallableOnce()); + $request->once('drain', function () use ($request) { + $request->write("data"); + $request->end(); + }); + + $deferred->resolve($connection); + + $request->handleData("HTTP/1.0 200 OK\r\n"); + $request->handleData("Content-Type: text/plain\r\n"); + $request->handleData("\r\nbody"); + } + + /** @test */ + public function writeWithAPostRequestShouldForwardDrainEventIfFirstChunkExceedsBuffer() + { + $connection = $this->getMockBuilder(Connection::class) + ->disableOriginalConstructor() + ->setMethods(['write']) + ->getMock(); + + $connection->expects($this->exactly(2))->method('write')->withConsecutive( + [$this->matchesRegularExpression("#^POST / HTTP/1\.0\r\nHost: www.example.com\r\n\r\nsomepost$#")], + [$this->identicalTo("data")] + )->willReturn( + false + ); + + $deferred = new Deferred(); + $connectionManager = $this->createMock(ClientConnectionManager::class); + $connectionManager->expects($this->once())->method('connect')->willReturn($deferred->promise()); + + $requestData = new Request('POST', 'http://www.example.com/', [], '', '1.0'); + $request = new ClientRequestStream($connectionManager, $requestData); + + $this->assertFalse($request->write("some")); + $this->assertFalse($request->write("post")); + + $request->on('drain', $this->expectCallableOnce()); + $request->once('drain', function () use ($request) { + $request->write("data"); + $request->end(); + }); + + $deferred->resolve($connection); + $connection->emit('drain'); + + $request->handleData("HTTP/1.0 200 OK\r\n"); + $request->handleData("Content-Type: text/plain\r\n"); + $request->handleData("\r\nbody"); + } + + /** @test */ + public function pipeShouldPipeDataIntoTheRequestBody() + { + $connection = $this->createMock(ConnectionInterface::class); + $connection->expects($this->exactly(3))->method('write')->withConsecutive( + [$this->matchesRegularExpression("#^POST / HTTP/1\.0\r\nHost: www.example.com\r\n\r\nsome$#")], + [$this->identicalTo("post")], + [$this->identicalTo("data")] + ); + + $connectionManager = $this->createMock(ClientConnectionManager::class); + $connectionManager->expects($this->once())->method('connect')->willReturn(resolve($connection)); + + $requestData = new Request('POST', 'http://www.example.com/', [], '', '1.0'); + $request = new ClientRequestStream($connectionManager, $requestData); + + $loop = $this->createMock(LoopInterface::class); + + $stream = fopen('php://memory', 'r+'); + $stream = new DuplexResourceStream($stream, $loop); + + $stream->pipe($request); + $stream->emit('data', ['some']); + $stream->emit('data', ['post']); + $stream->emit('data', ['data']); + + $request->handleData("HTTP/1.0 200 OK\r\n"); + $request->handleData("Content-Type: text/plain\r\n"); + $request->handleData("\r\nbody"); + } + + /** + * @test + */ + public function writeShouldStartConnecting() + { + $connectionManager = $this->createMock(ClientConnectionManager::class); + $connectionManager->expects($this->once())->method('connect')->willReturn(new Promise(function () { })); + + $requestData = new Request('POST', 'http://www.example.com/'); + $request = new ClientRequestStream($connectionManager, $requestData); + + $request->write('test'); + } + + /** + * @test + */ + public function endShouldStartConnectingAndChangeStreamIntoNonWritableMode() + { + $connectionManager = $this->createMock(ClientConnectionManager::class); + $connectionManager->expects($this->once())->method('connect')->willReturn(new Promise(function () { })); + + $requestData = new Request('POST', 'http://www.example.com/'); + $request = new ClientRequestStream($connectionManager, $requestData); + + $request->end(); + + $this->assertFalse($request->isWritable()); + } + + /** + * @test + */ + public function closeShouldEmitCloseEvent() + { + $connectionManager = $this->createMock(ClientConnectionManager::class); + + $requestData = new Request('POST', 'http://www.example.com/'); + $request = new ClientRequestStream($connectionManager, $requestData); + + $request->on('close', $this->expectCallableOnce()); + $request->close(); + } + + /** + * @test + */ + public function writeAfterCloseReturnsFalse() + { + $connectionManager = $this->createMock(ClientConnectionManager::class); + + $requestData = new Request('POST', 'http://www.example.com/'); + $request = new ClientRequestStream($connectionManager, $requestData); + + $request->close(); + + $this->assertFalse($request->isWritable()); + $this->assertFalse($request->write('nope')); + } + + /** + * @test + */ + public function endAfterCloseIsNoOp() + { + $connectionManager = $this->createMock(ClientConnectionManager::class); + $connectionManager->expects($this->never())->method('connect'); + + $requestData = new Request('POST', 'http://www.example.com/'); + $request = new ClientRequestStream($connectionManager, $requestData); + + $request->close(); + $request->end(); + } + + /** + * @test + */ + public function closeShouldCancelPendingConnectionAttempt() + { + $promise = new Promise(function () {}, function () { + throw new \RuntimeException(); + }); + $connectionManager = $this->createMock(ClientConnectionManager::class); + $connectionManager->expects($this->once())->method('connect')->willReturn($promise); + + $requestData = new Request('POST', 'http://www.example.com/'); + $request = new ClientRequestStream($connectionManager, $requestData); + + $request->end(); + + $request->on('error', $this->expectCallableNever()); + $request->on('close', $this->expectCallableOnce()); + + $request->close(); + $request->close(); + } + + /** @test */ + public function requestShouldRemoveAllListenerAfterClosed() + { + $connectionManager = $this->createMock(ClientConnectionManager::class); + + $requestData = new Request('GET', 'http://www.example.com/'); + $request = new ClientRequestStream($connectionManager, $requestData); + + $request->on('close', function () {}); + $this->assertCount(1, $request->listeners('close')); + + $request->close(); + $this->assertCount(0, $request->listeners('close')); + } + + /** @test */ + public function multivalueHeader() + { + $connection = $this->createMock(ConnectionInterface::class); + + $connectionManager = $this->createMock(ClientConnectionManager::class); + $connectionManager->expects($this->once())->method('connect')->willReturn(resolve($connection)); + + $requestData = new Request('GET', 'http://www.example.com/'); + $request = new ClientRequestStream($connectionManager, $requestData); + + $response = null; + $request->on('response', $this->expectCallableOnce()); + $request->on('response', function ($value) use (&$response) { + $response = $value; + }); + + $request->end(); + + $request->handleData("HTTP/1.0 200 OK\r\n"); + $request->handleData("Content-Type: text/plain\r\n"); + $request->handleData("X-Xss-Protection:1; mode=block\r\n"); + $request->handleData("Cache-Control:public, must-revalidate, max-age=0\r\n"); + $request->handleData("\r\nbody"); + + /** @var \Psr\Http\Message\ResponseInterface $response */ + $this->assertInstanceOf(ResponseInterface::class, $response); + $this->assertEquals('1.0', $response->getProtocolVersion()); + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals('OK', $response->getReasonPhrase()); + $this->assertEquals('text/plain', $response->getHeaderLine('Content-Type')); + $this->assertEquals('1; mode=block', $response->getHeaderLine('X-Xss-Protection')); + $this->assertEquals('public, must-revalidate, max-age=0', $response->getHeaderLine('Cache-Control')); + } +} diff --git a/tests/Io/ClockTest.php b/tests/Io/ClockTest.php new file mode 100644 index 00000000..318fa7ef --- /dev/null +++ b/tests/Io/ClockTest.php @@ -0,0 +1,44 @@ +createMock(LoopInterface::class); + + $clock = new Clock($loop); + + $now = $clock->now(); + $this->assertTrue(is_float($now)); // assertIsFloat() on PHPUnit 8+ + $this->assertEquals($now, $clock->now()); + } + + public function testNowResetsMemoizedTimestampOnFutureTick() + { + $tick = null; + $loop = $this->createMock(LoopInterface::class); + $loop->expects($this->once())->method('futureTick')->with($this->callback(function ($cb) use (&$tick) { + $tick = $cb; + return true; + })); + + $clock = new Clock($loop); + + $now = $clock->now(); + + $ref = new \ReflectionProperty($clock, 'now'); + $ref->setAccessible(true); + $this->assertEquals($now, $ref->getValue($clock)); + + $this->assertNotNull($tick); + $tick(); + + $this->assertNull($ref->getValue($clock)); + } +} diff --git a/tests/Io/CloseProtectionStreamTest.php b/tests/Io/CloseProtectionStreamTest.php index 8490daff..f3aa346d 100644 --- a/tests/Io/CloseProtectionStreamTest.php +++ b/tests/Io/CloseProtectionStreamTest.php @@ -3,14 +3,16 @@ namespace React\Tests\Http\Io; use React\Http\Io\CloseProtectionStream; +use React\Stream\ReadableStreamInterface; use React\Stream\ThroughStream; +use React\Stream\WritableStreamInterface; use React\Tests\Http\TestCase; class CloseProtectionStreamTest extends TestCase { public function testCloseDoesNotCloseTheInputStream() { - $input = $this->getMockBuilder('React\Stream\ReadableStreamInterface')->disableOriginalConstructor()->getMock(); + $input = $this->createMock(ReadableStreamInterface::class); $input->expects($this->never())->method('pause'); $input->expects($this->never())->method('resume'); $input->expects($this->never())->method('close'); @@ -27,7 +29,7 @@ public function testErrorWontCloseStream() $protection->on('error', $this->expectCallableOnce()); $protection->on('close', $this->expectCallableNever()); - $input->emit('error', array(new \RuntimeException())); + $input->emit('error', [new \RuntimeException()]); $this->assertTrue($protection->isReadable()); $this->assertTrue($input->isReadable()); @@ -35,7 +37,7 @@ public function testErrorWontCloseStream() public function testResumeStreamWillResumeInputStream() { - $input = $this->getMockBuilder('React\Stream\ReadableStreamInterface')->getMock(); + $input = $this->createMock(ReadableStreamInterface::class); $input->expects($this->once())->method('pause'); $input->expects($this->once())->method('resume'); @@ -46,7 +48,7 @@ public function testResumeStreamWillResumeInputStream() public function testCloseResumesInputStreamIfItWasPreviouslyPaused() { - $input = $this->getMockBuilder('React\Stream\ReadableStreamInterface')->getMock(); + $input = $this->createMock(ReadableStreamInterface::class); $input->expects($this->once())->method('pause'); $input->expects($this->once())->method('resume'); @@ -73,7 +75,7 @@ public function testPipeStream() $input = new ThroughStream(); $protection = new CloseProtectionStream($input); - $dest = $this->getMockBuilder('React\Stream\WritableStreamInterface')->getMock(); + $dest = $this->createMock(WritableStreamInterface::class); $ret = $protection->pipe($dest); @@ -91,7 +93,7 @@ public function testStopEmittingDataAfterClose() $protection->close(); - $input->emit('data', array('hello')); + $input->emit('data', ['hello']); $this->assertFalse($protection->isReadable()); $this->assertTrue($input->isReadable()); @@ -108,7 +110,7 @@ public function testErrorIsNeverCalledAfterClose() $protection->close(); - $input->emit('error', array(new \Exception())); + $input->emit('error', [new \Exception()]); $this->assertFalse($protection->isReadable()); $this->assertTrue($input->isReadable()); @@ -124,7 +126,7 @@ public function testEndWontBeEmittedAfterClose() $protection->close(); - $input->emit('end', array()); + $input->emit('end', []); $this->assertFalse($protection->isReadable()); $this->assertTrue($input->isReadable()); @@ -132,7 +134,7 @@ public function testEndWontBeEmittedAfterClose() public function testPauseAfterCloseHasNoEffect() { - $input = $this->getMockBuilder('React\Stream\ReadableStreamInterface')->getMock(); + $input = $this->createMock(ReadableStreamInterface::class); $input->expects($this->never())->method('pause'); $input->expects($this->never())->method('resume'); @@ -146,7 +148,7 @@ public function testPauseAfterCloseHasNoEffect() public function testResumeAfterCloseHasNoEffect() { - $input = $this->getMockBuilder('React\Stream\ReadableStreamInterface')->getMock(); + $input = $this->createMock(ReadableStreamInterface::class); $input->expects($this->never())->method('pause'); $input->expects($this->never())->method('resume'); diff --git a/tests/Io/EmptyBodyStreamTest.php b/tests/Io/EmptyBodyStreamTest.php index 6f309fa9..4ee92364 100644 --- a/tests/Io/EmptyBodyStreamTest.php +++ b/tests/Io/EmptyBodyStreamTest.php @@ -3,6 +3,7 @@ namespace React\Tests\Http\Io; use React\Http\Io\EmptyBodyStream; +use React\Stream\WritableStreamInterface; use React\Tests\Http\TestCase; class EmptyBodyStreamTest extends TestCase @@ -10,7 +11,10 @@ class EmptyBodyStreamTest extends TestCase private $input; private $bodyStream; - public function setUp() + /** + * @before + */ + public function setUpBodyStream() { $this->bodyStream = new EmptyBodyStream(); } @@ -33,7 +37,7 @@ public function testResumeIsNoop() public function testPipeStreamReturnsDestinationStream() { - $dest = $this->getMockBuilder('React\Stream\WritableStreamInterface')->getMock(); + $dest = $this->createMock(WritableStreamInterface::class); $ret = $this->bodyStream->pipe($dest); @@ -62,22 +66,18 @@ public function testCloseTwiceEmitsCloseEventAndClearsListeners() $this->bodyStream->close(); $this->bodyStream->close(); - $this->assertEquals(array(), $this->bodyStream->listeners('close')); + $this->assertEquals([], $this->bodyStream->listeners('close')); } - /** - * @expectedException BadMethodCallException - */ public function testTell() { + $this->expectException(\BadMethodCallException::class); $this->bodyStream->tell(); } - /** - * @expectedException BadMethodCallException - */ public function testEof() { + $this->expectException(\BadMethodCallException::class); $this->bodyStream->eof(); } @@ -86,19 +86,15 @@ public function testIsSeekable() $this->assertFalse($this->bodyStream->isSeekable()); } - /** - * @expectedException BadMethodCallException - */ public function testWrite() { + $this->expectException(\BadMethodCallException::class); $this->bodyStream->write(''); } - /** - * @expectedException BadMethodCallException - */ public function testRead() { + $this->expectException(\BadMethodCallException::class); $this->bodyStream->read(1); } @@ -109,7 +105,7 @@ public function testGetContentsReturnsEmpy() public function testGetMetaDataWithoutKeyReturnsEmptyArray() { - $this->assertSame(array(), $this->bodyStream->getMetadata()); + $this->assertSame([], $this->bodyStream->getMetadata()); } public function testGetMetaDataWithKeyReturnsNull() @@ -129,19 +125,15 @@ public function testIsReadableReturnsFalseWhenAlreadyClosed() $this->assertFalse($this->bodyStream->isReadable()); } - /** - * @expectedException BadMethodCallException - */ public function testSeek() { + $this->expectException(\BadMethodCallException::class); $this->bodyStream->seek(''); } - /** - * @expectedException BadMethodCallException - */ public function testRewind() { + $this->expectException(\BadMethodCallException::class); $this->bodyStream->rewind(); } diff --git a/tests/Io/HttpBodyStreamTest.php b/tests/Io/HttpBodyStreamTest.php index 343a75a5..c246bd96 100644 --- a/tests/Io/HttpBodyStreamTest.php +++ b/tests/Io/HttpBodyStreamTest.php @@ -3,7 +3,9 @@ namespace React\Tests\Http\Io; use React\Http\Io\HttpBodyStream; +use React\Stream\ReadableStreamInterface; use React\Stream\ThroughStream; +use React\Stream\WritableStreamInterface; use React\Tests\Http\TestCase; class HttpBodyStreamTest extends TestCase @@ -11,7 +13,10 @@ class HttpBodyStreamTest extends TestCase private $input; private $bodyStream; - public function setUp() + /** + * @before + */ + public function setUpBodyStream() { $this->input = new ThroughStream(); $this->bodyStream = new HttpBodyStream($this->input, null); @@ -19,13 +24,13 @@ public function setUp() public function testDataEmit() { - $this->bodyStream->on('data', $this->expectCallableOnce(array("hello"))); - $this->input->emit('data', array("hello")); + $this->bodyStream->on('data', $this->expectCallableOnce(["hello"])); + $this->input->emit('data', ["hello"]); } public function testPauseStream() { - $input = $this->getMockBuilder('React\Stream\ReadableStreamInterface')->getMock(); + $input = $this->createMock(ReadableStreamInterface::class); $input->expects($this->once())->method('pause'); $bodyStream = new HttpBodyStream($input, null); @@ -34,7 +39,7 @@ public function testPauseStream() public function testResumeStream() { - $input = $this->getMockBuilder('React\Stream\ReadableStreamInterface')->getMock(); + $input = $this->createMock(ReadableStreamInterface::class); $input->expects($this->once())->method('resume'); $bodyStream = new HttpBodyStream($input, null); @@ -43,7 +48,7 @@ public function testResumeStream() public function testPipeStream() { - $dest = $this->getMockBuilder('React\Stream\WritableStreamInterface')->getMock(); + $dest = $this->createMock(WritableStreamInterface::class); $ret = $this->bodyStream->pipe($dest); @@ -55,7 +60,7 @@ public function testHandleClose() $this->bodyStream->on('close', $this->expectCallableOnce()); $this->input->close(); - $this->input->emit('end', array()); + $this->input->emit('end', []); $this->assertFalse($this->bodyStream->isReadable()); } @@ -64,11 +69,11 @@ public function testStopDataEmittingAfterClose() { $bodyStream = new HttpBodyStream($this->input, null); $bodyStream->on('close', $this->expectCallableOnce()); - $this->bodyStream->on('data', $this->expectCallableOnce(array("hello"))); + $this->bodyStream->on('data', $this->expectCallableOnce(["hello"])); - $this->input->emit('data', array("hello")); + $this->input->emit('data', ["hello"]); $bodyStream->close(); - $this->input->emit('data', array("world")); + $this->input->emit('data', ["world"]); } public function testHandleError() @@ -76,7 +81,7 @@ public function testHandleError() $this->bodyStream->on('error', $this->expectCallableOnce()); $this->bodyStream->on('close', $this->expectCallableOnce()); - $this->input->emit('error', array(new \RuntimeException())); + $this->input->emit('error', [new \RuntimeException()]); $this->assertFalse($this->bodyStream->isReadable()); } @@ -102,19 +107,15 @@ public function testGetSizeCustom() $this->assertEquals(5, $stream->getSize()); } - /** - * @expectedException BadMethodCallException - */ public function testTell() { + $this->expectException(\BadMethodCallException::class); $this->bodyStream->tell(); } - /** - * @expectedException BadMethodCallException - */ public function testEof() { + $this->expectException(\BadMethodCallException::class); $this->bodyStream->eof(); } @@ -123,19 +124,15 @@ public function testIsSeekable() $this->assertFalse($this->bodyStream->isSeekable()); } - /** - * @expectedException BadMethodCallException - */ public function testWrite() { + $this->expectException(\BadMethodCallException::class); $this->bodyStream->write(''); } - /** - * @expectedException BadMethodCallException - */ public function testRead() { + $this->expectException(\BadMethodCallException::class); $this->bodyStream->read(''); } @@ -154,19 +151,15 @@ public function testIsReadable() $this->assertTrue($this->bodyStream->isReadable()); } - /** - * @expectedException BadMethodCallException - */ public function testSeek() { + $this->expectException(\BadMethodCallException::class); $this->bodyStream->seek(''); } - /** - * @expectedException BadMethodCallException - */ public function testRewind() { + $this->expectException(\BadMethodCallException::class); $this->bodyStream->rewind(); } diff --git a/tests/Io/IniUtilTest.php b/tests/Io/IniUtilTest.php index 390abab1..2e9f99c9 100644 --- a/tests/Io/IniUtilTest.php +++ b/tests/Io/IniUtilTest.php @@ -7,42 +7,40 @@ class IniUtilTest extends TestCase { - public function provideIniSizes() + public static function provideIniSizes() { - return array( - array( - '1', - 1, - ), - array( - '10', - 10, - ), - array( - '1024', - 1024, - ), - array( - '1K', - 1024, - ), - array( - '1.5M', - 1572864, - ), - array( - '64M', - 67108864, - ), - array( - '8G', - 8589934592, - ), - array( - '1T', - 1099511627776, - ), - ); + yield [ + '1', + 1, + ]; + yield [ + '10', + 10, + ]; + yield [ + '1024', + 1024, + ]; + yield [ + '1K', + 1024, + ]; + yield [ + '1.5M', + 1572864, + ]; + yield [ + '64M', + 67108864, + ]; + yield [ + '8G', + 8589934592, + ]; + yield [ + '1T', + 1099511627776, + ]; } /** @@ -53,25 +51,27 @@ public function testIniSizeToBytes($input, $output) $this->assertEquals($output, IniUtil::iniSizeToBytes($input)); } - public function provideInvalidInputIniSizeToBytes() + public function testIniSizeToBytesWithInvalidSuffixReturnsNumberWithoutSuffix() { - return array( - array('-1G'), - array('0G'), - array(null), - array('foo'), - array('fooK'), - array('1ooL'), - array('1ooL'), - ); + $this->assertEquals('2', IniUtil::iniSizeToBytes('2x')); + } + + public static function provideInvalidInputIniSizeToBytes() + { + yield ['-1G']; + yield ['0G']; + yield ['foo']; + yield ['fooK']; + yield ['1ooL']; + yield ['1ooL']; } /** * @dataProvider provideInvalidInputIniSizeToBytes - * @expectedException InvalidArgumentException */ public function testInvalidInputIniSizeToBytes($input) { + $this->expectException(\InvalidArgumentException::class); IniUtil::iniSizeToBytes($input); } } diff --git a/tests/Io/LengthLimitedStreamTest.php b/tests/Io/LengthLimitedStreamTest.php index 9a88ba7c..b841257a 100644 --- a/tests/Io/LengthLimitedStreamTest.php +++ b/tests/Io/LengthLimitedStreamTest.php @@ -3,7 +3,9 @@ namespace React\Tests\Http\Io; use React\Http\Io\LengthLimitedStream; +use React\Stream\ReadableStreamInterface; use React\Stream\ThroughStream; +use React\Stream\WritableStreamInterface; use React\Tests\Http\TestCase; class LengthLimitedStreamTest extends TestCase @@ -11,7 +13,10 @@ class LengthLimitedStreamTest extends TestCase private $input; private $stream; - public function setUp() + /** + * @before + */ + public function setUpInput() { $this->input = new ThroughStream(); } @@ -21,7 +26,7 @@ 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")); + $this->input->emit('data', ["hello world"]); } public function testInputStreamKeepsEmitting() @@ -30,9 +35,9 @@ public function testInputStreamKeepsEmitting() $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")); + $this->input->emit('data', ["hello world"]); + $this->input->emit('data', ["world"]); + $this->input->emit('data', ["world"]); } public function testZeroLengthInContentLengthWillIgnoreEmittedDataEvents() @@ -40,7 +45,7 @@ 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")); + $this->input->emit('data', ["hello world"]); } public function testHandleError() @@ -49,14 +54,14 @@ public function testHandleError() $stream->on('error', $this->expectCallableOnce()); $stream->on('close', $this->expectCallableOnce()); - $this->input->emit('error', array(new \RuntimeException())); + $this->input->emit('error', [new \RuntimeException()]); $this->assertFalse($stream->isReadable()); } public function testPauseStream() { - $input = $this->getMockBuilder('React\Stream\ReadableStreamInterface')->getMock(); + $input = $this->createMock(ReadableStreamInterface::class); $input->expects($this->once())->method('pause'); $stream = new LengthLimitedStream($input, 0); @@ -65,7 +70,7 @@ public function testPauseStream() public function testResumeStream() { - $input = $this->getMockBuilder('React\Stream\ReadableStreamInterface')->getMock(); + $input = $this->createMock(ReadableStreamInterface::class); $input->expects($this->once())->method('pause'); $stream = new LengthLimitedStream($input, 0); @@ -76,7 +81,7 @@ public function testResumeStream() public function testPipeStream() { $stream = new LengthLimitedStream($this->input, 0); - $dest = $this->getMockBuilder('React\Stream\WritableStreamInterface')->getMock(); + $dest = $this->createMock(WritableStreamInterface::class); $ret = $stream->pipe($dest); @@ -89,7 +94,7 @@ public function testHandleClose() $stream->on('close', $this->expectCallableOnce()); $this->input->close(); - $this->input->emit('end', array()); + $this->input->emit('end', []); $this->assertFalse($stream->isReadable()); } diff --git a/tests/Io/MiddlewareRunnerTest.php b/tests/Io/MiddlewareRunnerTest.php index 91b927c9..a2344d3a 100644 --- a/tests/Io/MiddlewareRunnerTest.php +++ b/tests/Io/MiddlewareRunnerTest.php @@ -2,39 +2,36 @@ namespace React\Tests\Http\Io; -use Clue\React\Block; +use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; -use React\EventLoop\Factory; use React\Http\Io\MiddlewareRunner; -use React\Http\Io\ServerRequest; -use React\Promise; +use React\Http\Message\Response; +use React\Http\Message\ServerRequest; +use React\Promise\Promise; +use React\Promise\PromiseInterface; use React\Tests\Http\Middleware\ProcessStack; use React\Tests\Http\TestCase; -use RingCentral\Psr7\Response; -use Psr\Http\Message\RequestInterface; -use React\Promise\CancellablePromiseInterface; -use React\Promise\PromiseInterface; +use function React\Async\await; +use function React\Promise\reject; final class MiddlewareRunnerTest extends TestCase { - /** - * @expectedException RuntimeException - * @expectedExceptionMessage No middleware to run - */ public function testEmptyMiddlewareStackThrowsException() { $request = new ServerRequest('GET', 'https://example.com/'); - $middlewares = array(); + $middlewares = []; $middlewareStack = new MiddlewareRunner($middlewares); + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('No middleware to run'); $middlewareStack($request); } public function testMiddlewareHandlerReceivesTwoArguments() { $args = null; - $middleware = new MiddlewareRunner(array( + $middleware = new MiddlewareRunner([ function (ServerRequestInterface $request, $next) use (&$args) { $args = func_num_args(); return $next($request); @@ -42,7 +39,7 @@ function (ServerRequestInterface $request, $next) use (&$args) { function (ServerRequestInterface $request) { return null; } - )); + ]); $request = new ServerRequest('GET', 'http://example.com/'); @@ -54,12 +51,12 @@ function (ServerRequestInterface $request) { public function testFinalHandlerReceivesOneArgument() { $args = null; - $middleware = new MiddlewareRunner(array( + $middleware = new MiddlewareRunner([ function (ServerRequestInterface $request) use (&$args) { $args = func_num_args(); return null; } - )); + ]); $request = new ServerRequest('GET', 'http://example.com/'); @@ -68,42 +65,37 @@ function (ServerRequestInterface $request) use (&$args) { $this->assertEquals(1, $args); } - /** - * @expectedException RuntimeException - * @expectedExceptionMessage hello - */ public function testThrowsIfHandlerThrowsException() { - $middleware = new MiddlewareRunner(array( + $middleware = new MiddlewareRunner([ function (ServerRequestInterface $request) { throw new \RuntimeException('hello'); } - )); + ]); $request = new ServerRequest('GET', 'http://example.com/'); + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('hello'); $middleware($request); } - /** - * @requires PHP 7 - * @expectedException Throwable - * @expectedExceptionMessage hello - */ public function testThrowsIfHandlerThrowsThrowable() { - $middleware = new MiddlewareRunner(array( + $middleware = new MiddlewareRunner([ function (ServerRequestInterface $request) { throw new \Error('hello'); } - )); + ]); $request = new ServerRequest('GET', 'http://example.com/'); + $this->expectException(\Throwable::class); + $this->expectExceptionMessage('hello'); $middleware($request); } - public function provideProcessStackMiddlewares() + public static function provideProcessStackMiddlewares() { $processStackA = new ProcessStack(); $processStackB = new ProcessStack(); @@ -112,42 +104,40 @@ public function provideProcessStackMiddlewares() $responseMiddleware = function () { return new Response(200); }; - return array( - array( - array( - $processStackA, - $responseMiddleware, - ), - 1, - ), - array( - array( - $processStackB, - $processStackB, - $responseMiddleware, - ), - 2, - ), - array( - array( - $processStackC, - $processStackC, - $processStackC, - $responseMiddleware, - ), - 3, - ), - array( - array( - $processStackD, - $processStackD, - $processStackD, - $processStackD, - $responseMiddleware, - ), - 4, - ), - ); + yield [ + [ + $processStackA, + $responseMiddleware, + ], + 1, + ]; + yield [ + [ + $processStackB, + $processStackB, + $responseMiddleware, + ], + 2, + ]; + yield [ + [ + $processStackC, + $processStackC, + $processStackC, + $responseMiddleware, + ], + 3, + ]; + yield [ + [ + $processStackD, + $processStackD, + $processStackD, + $processStackD, + $responseMiddleware, + ], + 4, + ]; } /** @@ -169,7 +159,7 @@ public function testProcessStack(array $middlewares, $expectedCallCount) $response = $middlewareStack($request); $this->assertTrue($response instanceof PromiseInterface); - $response = Block\await($response, Factory::create()); + $response = await($response); $this->assertTrue($response instanceof ResponseInterface); $this->assertSame(200, $response->getStatusCode()); @@ -183,20 +173,18 @@ public function testProcessStack(array $middlewares, $expectedCallCount) } } - public function provideErrorHandler() + public static function provideErrorHandler() { - return array( - array( - function (\Exception $e) { - throw $e; - } - ), - array( - function (\Exception $e) { - return Promise\reject($e); - } - ) - ); + yield [ + function (\Exception $e) { + throw $e; + } + ]; + yield [ + function (\Exception $e) { + return reject($e); + } + ]; } /** @@ -204,11 +192,11 @@ function (\Exception $e) { */ public function testNextCanBeRunMoreThanOnceWithoutCorruptingTheMiddlewareStack($errorHandler) { - $exception = new \RuntimeException('exception'); + $exception = new \RuntimeException(\exception::class); $retryCalled = 0; $error = null; $retry = function ($request, $next) use (&$error, &$retryCalled) { - $promise = new \React\Promise\Promise(function ($resolve) use ($request, $next) { + $promise = new Promise(function ($resolve) use ($request, $next) { $resolve($next($request)); }); @@ -222,7 +210,7 @@ public function testNextCanBeRunMoreThanOnceWithoutCorruptingTheMiddlewareStack( $response = new Response(); $called = 0; - $runner = new MiddlewareRunner(array( + $runner = new MiddlewareRunner([ $retry, function () use ($errorHandler, &$called, $response, $exception) { $called++; @@ -232,11 +220,11 @@ function () use ($errorHandler, &$called, $response, $exception) { return $response; } - )); + ]); $request = new ServerRequest('GET', 'https://example.com/'); - $this->assertSame($response, Block\await($runner($request), Factory::create())); + $this->assertSame($response, await($runner($request))); $this->assertSame(1, $retryCalled); $this->assertSame(2, $called); $this->assertSame($exception, $error); @@ -244,15 +232,15 @@ function () use ($errorHandler, &$called, $response, $exception) { public function testMultipleRunsInvokeAllMiddlewareInCorrectOrder() { - $requests = array( + $requests = [ new ServerRequest('GET', 'https://example.com/1'), new ServerRequest('GET', 'https://example.com/2'), new ServerRequest('GET', 'https://example.com/3') - ); + ]; - $receivedRequests = array(); + $receivedRequests = []; - $middlewareRunner = new MiddlewareRunner(array( + $middlewareRunner = new MiddlewareRunner([ function (ServerRequestInterface $request, $next) use (&$receivedRequests) { $receivedRequests[] = 'middleware1: ' . $request->getUri(); return $next($request); @@ -263,16 +251,16 @@ function (ServerRequestInterface $request, $next) use (&$receivedRequests) { }, function (ServerRequestInterface $request) use (&$receivedRequests) { $receivedRequests[] = 'middleware3: ' . $request->getUri(); - return new \React\Promise\Promise(function () { }); + return new Promise(function () { }); } - )); + ]); foreach ($requests as $request) { $middlewareRunner($request); } $this->assertEquals( - array( + [ 'middleware1: https://example.com/1', 'middleware2: https://example.com/1', 'middleware3: https://example.com/1', @@ -282,135 +270,133 @@ function (ServerRequestInterface $request) use (&$receivedRequests) { 'middleware1: https://example.com/3', 'middleware2: https://example.com/3', 'middleware3: https://example.com/3' - ), + ], $receivedRequests ); } - public function provideUncommonMiddlewareArrayFormats() + public static function provideUncommonMiddlewareArrayFormats() { - return array( - array( - function () { - $sequence = ''; - - // Numeric index gap - return array( - 0 => function (ServerRequestInterface $request, $next) use (&$sequence) { - $sequence .= 'A'; - - return $next($request); - }, - 2 => function (ServerRequestInterface $request, $next) use (&$sequence) { - $sequence .= 'B'; - - return $next($request); - }, - 3 => function () use (&$sequence) { - return new Response(200, array(), $sequence . 'C'); - }, - ); - }, - 'ABC', - ), - array( - function () { - $sequence = ''; - - // Reversed numeric indexes - return array( - 2 => function (ServerRequestInterface $request, $next) use (&$sequence) { - $sequence .= 'A'; - - return $next($request); - }, - 1 => function (ServerRequestInterface $request, $next) use (&$sequence) { - $sequence .= 'B'; - - return $next($request); - }, - 0 => function () use (&$sequence) { - return new Response(200, array(), $sequence . 'C'); - }, - ); - }, - 'ABC', - ), - array( - function () { - $sequence = ''; - - // Associative array - return array( - 'middleware1' => function (ServerRequestInterface $request, $next) use (&$sequence) { - $sequence .= 'A'; - - return $next($request); - }, - 'middleware2' => function (ServerRequestInterface $request, $next) use (&$sequence) { - $sequence .= 'B'; - - return $next($request); - }, - 'middleware3' => function () use (&$sequence) { - return new Response(200, array(), $sequence . 'C'); - }, - ); - }, - 'ABC', - ), - array( - function () { - $sequence = ''; - - // Associative array with empty or trimmable string keys - return array( - '' => function (ServerRequestInterface $request, $next) use (&$sequence) { - $sequence .= 'A'; - - return $next($request); - }, - ' ' => function (ServerRequestInterface $request, $next) use (&$sequence) { - $sequence .= 'B'; - - return $next($request); - }, - ' ' => function () use (&$sequence) { - return new Response(200, array(), $sequence . 'C'); - }, - ); - }, - 'ABC', - ), - array( - function () { - $sequence = ''; - - // Mixed array keys - return array( - '' => function (ServerRequestInterface $request, $next) use (&$sequence) { - $sequence .= 'A'; - - return $next($request); - }, - 0 => function (ServerRequestInterface $request, $next) use (&$sequence) { - $sequence .= 'B'; - - return $next($request); - }, - 'foo' => function (ServerRequestInterface $request, $next) use (&$sequence) { - $sequence .= 'C'; - - return $next($request); - }, - 2 => function () use (&$sequence) { - return new Response(200, array(), $sequence . 'D'); - }, - ); - }, - 'ABCD', - ), - ); + yield [ + function () { + $sequence = ''; + + // Numeric index gap + return [ + 0 => function (ServerRequestInterface $request, $next) use (&$sequence) { + $sequence .= 'A'; + + return $next($request); + }, + 2 => function (ServerRequestInterface $request, $next) use (&$sequence) { + $sequence .= 'B'; + + return $next($request); + }, + 3 => function () use (&$sequence) { + return new Response(200, [], $sequence . 'C'); + }, + ]; + }, + 'ABC', + ]; + yield [ + function () { + $sequence = ''; + + // Reversed numeric indexes + return [ + 2 => function (ServerRequestInterface $request, $next) use (&$sequence) { + $sequence .= 'A'; + + return $next($request); + }, + 1 => function (ServerRequestInterface $request, $next) use (&$sequence) { + $sequence .= 'B'; + + return $next($request); + }, + 0 => function () use (&$sequence) { + return new Response(200, [], $sequence . 'C'); + }, + ]; + }, + 'ABC', + ]; + yield [ + function () { + $sequence = ''; + + // Associative array + return [ + 'middleware1' => function (ServerRequestInterface $request, $next) use (&$sequence) { + $sequence .= 'A'; + + return $next($request); + }, + 'middleware2' => function (ServerRequestInterface $request, $next) use (&$sequence) { + $sequence .= 'B'; + + return $next($request); + }, + 'middleware3' => function () use (&$sequence) { + return new Response(200, [], $sequence . 'C'); + }, + ]; + }, + 'ABC', + ]; + yield [ + function () { + $sequence = ''; + + // Associative array with empty or trimmable string keys + return [ + '' => function (ServerRequestInterface $request, $next) use (&$sequence) { + $sequence .= 'A'; + + return $next($request); + }, + ' ' => function (ServerRequestInterface $request, $next) use (&$sequence) { + $sequence .= 'B'; + + return $next($request); + }, + ' ' => function () use (&$sequence) { + return new Response(200, [], $sequence . 'C'); + }, + ]; + }, + 'ABC', + ]; + yield [ + function () { + $sequence = ''; + + // Mixed array keys + return [ + '' => function (ServerRequestInterface $request, $next) use (&$sequence) { + $sequence .= 'A'; + + return $next($request); + }, + 0 => function (ServerRequestInterface $request, $next) use (&$sequence) { + $sequence .= 'B'; + + return $next($request); + }, + 'foo' => function (ServerRequestInterface $request, $next) use (&$sequence) { + $sequence .= 'C'; + + return $next($request); + }, + 2 => function () use (&$sequence) { + return new Response(200, [], $sequence . 'D'); + }, + ]; + }, + 'ABCD', + ]; } /** @@ -430,7 +416,7 @@ public function testUncommonMiddlewareArrayFormats($middlewareFactory, $expected public function testPendingNextRequestHandlersCanBeCalledConcurrently() { $called = 0; - $middleware = new MiddlewareRunner(array( + $middleware = new MiddlewareRunner([ function (RequestInterface $request, $next) { $first = $next($request); $second = $next($request); @@ -440,9 +426,9 @@ function (RequestInterface $request, $next) { function (RequestInterface $request) use (&$called) { ++$called; - return new Promise\Promise(function () { }); + return new Promise(function () { }); } - )); + ]); $request = new ServerRequest('GET', 'http://example.com/'); @@ -455,7 +441,7 @@ function (RequestInterface $request) use (&$called) { public function testCancelPendingNextHandler() { $once = $this->expectCallableOnce(); - $middleware = new MiddlewareRunner(array( + $middleware = new MiddlewareRunner([ function (RequestInterface $request, $next) { $ret = $next($request); $ret->cancel(); @@ -463,9 +449,9 @@ function (RequestInterface $request, $next) { return $ret; }, function (RequestInterface $request) use ($once) { - return new Promise\Promise(function () { }, $once); + return new Promise(function () { }, $once); } - )); + ]); $request = new ServerRequest('GET', 'http://example.com/'); @@ -475,20 +461,20 @@ function (RequestInterface $request) use ($once) { public function testCancelResultingPromiseWillCancelPendingNextHandler() { $once = $this->expectCallableOnce(); - $middleware = new MiddlewareRunner(array( + $middleware = new MiddlewareRunner([ function (RequestInterface $request, $next) { return $next($request); }, function (RequestInterface $request) use ($once) { - return new Promise\Promise(function () { }, $once); + return new Promise(function () { }, $once); } - )); + ]); $request = new ServerRequest('GET', 'http://example.com/'); $promise = $middleware($request); - $this->assertTrue($promise instanceof CancellablePromiseInterface); + $this->assertTrue($promise instanceof PromiseInterface && \method_exists($promise, 'cancel')); $promise->cancel(); } } diff --git a/tests/Io/MultipartParserTest.php b/tests/Io/MultipartParserTest.php index 45ac0c4d..ebc5972f 100644 --- a/tests/Io/MultipartParserTest.php +++ b/tests/Io/MultipartParserTest.php @@ -2,8 +2,9 @@ namespace React\Tests\Http\Io\Middleware; +use Psr\Http\Message\UploadedFileInterface; use React\Http\Io\MultipartParser; -use React\Http\Io\ServerRequest; +use React\Http\Message\ServerRequest; use React\Tests\Http\TestCase; final class MultipartParserTest extends TestCase @@ -22,9 +23,9 @@ public function testDoesNotParseWithoutMultipartFormDataContentType() $data .= "second\r\n"; $data .= "--$boundary--\r\n"; - $request = new ServerRequest('POST', 'http://example.com/', array( + $request = new ServerRequest('POST', 'http://example.com/', [ 'Content-Type' => 'multipart/form-data', - ), $data, 1.1); + ], $data, 1.1); $parser = new MultipartParser(); $parsedRequest = $parser->parse($request); @@ -47,21 +48,87 @@ public function testPostKey() $data .= "second\r\n"; $data .= "--$boundary--\r\n"; - $request = new ServerRequest('POST', 'http://example.com/', array( + $request = new ServerRequest('POST', 'http://example.com/', [ 'Content-Type' => 'multipart/form-data; boundary=' . $boundary, - ), $data, 1.1); + ], $data, 1.1); $parser = new MultipartParser(); $parsedRequest = $parser->parse($request); $this->assertEmpty($parsedRequest->getUploadedFiles()); $this->assertSame( - array( - 'users' => array( + [ + 'users' => [ 'one' => 'single', 'two' => 'second', - ), - ), + ], + ], + $parsedRequest->getParsedBody() + ); + } + + public function testPostWithQuotationMarkEncapsulatedBoundary() + { + $boundary = "---------------------------5844729766471062541057622570"; + + $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"; + + $request = new ServerRequest('POST', 'http://example.com/', [ + 'Content-Type' => 'multipart/form-data; boundary="' . $boundary . '"', + ], $data, 1.1); + + $parser = new MultipartParser(); + $parsedRequest = $parser->parse($request); + + $this->assertEmpty($parsedRequest->getUploadedFiles()); + $this->assertSame( + [ + 'users' => [ + 'one' => 'single', + 'two' => 'second', + ], + ], + $parsedRequest->getParsedBody() + ); + } + + public function testPostFormDataNamesWithoutQuotationMark() + { + $boundary = "---------------------------5844729766471062541057622570"; + + $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"; + + $request = new ServerRequest('POST', 'http://example.com/', [ + 'Content-Type' => 'multipart/form-data; boundary="' . $boundary . '"', + ], $data, 1.1); + + $parser = new MultipartParser(); + $parsedRequest = $parser->parse($request); + + $this->assertEmpty($parsedRequest->getUploadedFiles()); + $this->assertSame( + [ + 'users' => [ + 'one' => 'single', + 'two' => 'second', + ], + ], $parsedRequest->getParsedBody() ); } @@ -80,18 +147,18 @@ public function testPostStringOverwritesMap() $data .= "2\r\n"; $data .= "--$boundary--\r\n"; - $request = new ServerRequest('POST', 'http://example.com/', array( + $request = new ServerRequest('POST', 'http://example.com/', [ 'Content-Type' => 'multipart/form-data; boundary=' . $boundary, - ), $data, 1.1); + ], $data, 1.1); $parser = new MultipartParser(); $parsedRequest = $parser->parse($request); $this->assertEmpty($parsedRequest->getUploadedFiles()); $this->assertSame( - array( + [ 'users' => '2' - ), + ], $parsedRequest->getParsedBody() ); } @@ -110,20 +177,20 @@ public function testPostMapOverwritesString() $data .= "2\r\n"; $data .= "--$boundary--\r\n"; - $request = new ServerRequest('POST', 'http://example.com/', array( + $request = new ServerRequest('POST', 'http://example.com/', [ 'Content-Type' => 'multipart/form-data; boundary=' . $boundary, - ), $data, 1.1); + ], $data, 1.1); $parser = new MultipartParser(); $parsedRequest = $parser->parse($request); $this->assertEmpty($parsedRequest->getUploadedFiles()); $this->assertSame( - array( - 'users' => array( + [ + 'users' => [ 'two' => '2', - ), - ), + ], + ], $parsedRequest->getParsedBody() ); } @@ -142,20 +209,20 @@ public function testPostVectorOverwritesString() $data .= "2\r\n"; $data .= "--$boundary--\r\n"; - $request = new ServerRequest('POST', 'http://example.com/', array( + $request = new ServerRequest('POST', 'http://example.com/', [ 'Content-Type' => 'multipart/form-data; boundary=' . $boundary, - ), $data, 1.1); + ], $data, 1.1); $parser = new MultipartParser(); $parsedRequest = $parser->parse($request); $this->assertEmpty($parsedRequest->getUploadedFiles()); $this->assertSame( - array( - 'users' => array( + [ + 'users' => [ '2', - ), - ), + ], + ], $parsedRequest->getParsedBody() ); } @@ -174,25 +241,25 @@ public function testPostDeeplyNestedArray() $data .= "2\r\n"; $data .= "--$boundary--\r\n"; - $request = new ServerRequest('POST', 'http://example.com/', array( + $request = new ServerRequest('POST', 'http://example.com/', [ 'Content-Type' => 'multipart/form-data; boundary=' . $boundary, - ), $data, 1.1); + ], $data, 1.1); $parser = new MultipartParser(); $parsedRequest = $parser->parse($request); $this->assertEmpty($parsedRequest->getUploadedFiles()); $this->assertSame( - array( - 'users' => array( - array( + [ + 'users' => [ + [ '1' - ), - array( + ], + [ '2' - ) - ), - ), + ] + ], + ], $parsedRequest->getParsedBody() ); } @@ -207,18 +274,18 @@ public function testEmptyPostValue() $data .= "\r\n"; $data .= "--$boundary--\r\n"; - $request = new ServerRequest('POST', 'http://example.com/', array( + $request = new ServerRequest('POST', 'http://example.com/', [ 'Content-Type' => 'multipart/form-data; boundary=' . $boundary, - ), $data, 1.1); + ], $data, 1.1); $parser = new MultipartParser(); $parsedRequest = $parser->parse($request); $this->assertEmpty($parsedRequest->getUploadedFiles()); $this->assertSame( - array( + [ 'key' => '' - ), + ], $parsedRequest->getParsedBody() ); } @@ -233,18 +300,18 @@ public function testEmptyPostKey() $data .= "value\r\n"; $data .= "--$boundary--\r\n"; - $request = new ServerRequest('POST', 'http://example.com/', array( + $request = new ServerRequest('POST', 'http://example.com/', [ 'Content-Type' => 'multipart/form-data; boundary=' . $boundary, - ), $data, 1.1); + ], $data, 1.1); $parser = new MultipartParser(); $parsedRequest = $parser->parse($request); $this->assertEmpty($parsedRequest->getUploadedFiles()); $this->assertSame( - array( + [ '' => 'value' - ), + ], $parsedRequest->getParsedBody() ); } @@ -259,22 +326,22 @@ public function testNestedPostKeyAssoc() $data .= "value\r\n"; $data .= "--$boundary--\r\n"; - $request = new ServerRequest('POST', 'http://example.com/', array( + $request = new ServerRequest('POST', 'http://example.com/', [ 'Content-Type' => 'multipart/form-data; boundary=' . $boundary, - ), $data, 1.1); + ], $data, 1.1); $parser = new MultipartParser(); $parsedRequest = $parser->parse($request); $this->assertEmpty($parsedRequest->getUploadedFiles()); $this->assertSame( - array( - 'a' => array( - 'b' => array( + [ + 'a' => [ + 'b' => [ 'c' => 'value' - ) - ) - ), + ] + ] + ], $parsedRequest->getParsedBody() ); } @@ -289,22 +356,22 @@ public function testNestedPostKeyVector() $data .= "value\r\n"; $data .= "--$boundary--\r\n"; - $request = new ServerRequest('POST', 'http://example.com/', array( + $request = new ServerRequest('POST', 'http://example.com/', [ 'Content-Type' => 'multipart/form-data; boundary=' . $boundary, - ), $data, 1.1); + ], $data, 1.1); $parser = new MultipartParser(); $parsedRequest = $parser->parse($request); $this->assertEmpty($parsedRequest->getUploadedFiles()); $this->assertSame( - array( - 'a' => array( - array( + [ + 'a' => [ + [ 'value' - ) - ) - ), + ] + ] + ], $parsedRequest->getParsedBody() ); } @@ -370,25 +437,25 @@ public function testFileUpload() $data .= "\r\n"; $data .= "--$boundary--\r\n"; - $request = new ServerRequest('POST', 'http://example.com/', array( + $request = new ServerRequest('POST', 'http://example.com/', [ 'Content-Type' => 'multipart/form-data; boundary=' . $boundary, - ), $data, 1.1); + ], $data, 1.1); $parser = new MultipartParser(); $parsedRequest = $parser->parse($request); $this->assertSame( - array( + [ 'MAX_FILE_SIZE' => '12000', - 'users' => array( + 'users' => [ 'one' => 'single', 'two' => 'second', 0 => 'first in array', 1 => 'second in array', - ), + ], 'user' => 'single', 'user2' => 'second', - ), + ], $parsedRequest->getParsedBody() ); @@ -425,18 +492,18 @@ public function testInvalidDoubleContentDispositionUsesLast() $data .= "value\r\n"; $data .= "--$boundary--\r\n"; - $request = new ServerRequest('POST', 'http://example.com/', array( + $request = new ServerRequest('POST', 'http://example.com/', [ 'Content-Type' => 'multipart/form-data; boundary=' . $boundary, - ), $data, 1.1); + ], $data, 1.1); $parser = new MultipartParser(); $parsedRequest = $parser->parse($request); $this->assertEmpty($parsedRequest->getUploadedFiles()); $this->assertSame( - array( + [ 'key' => 'value' - ), + ], $parsedRequest->getParsedBody() ); } @@ -451,9 +518,9 @@ public function testInvalidMissingNewlineAfterValueWillBeIgnored() $data .= "value"; $data .= "--$boundary--\r\n"; - $request = new ServerRequest('POST', 'http://example.com/', array( + $request = new ServerRequest('POST', 'http://example.com/', [ 'Content-Type' => 'multipart/form-data; boundary=' . $boundary, - ), $data, 1.1); + ], $data, 1.1); $parser = new MultipartParser(); $parsedRequest = $parser->parse($request); @@ -471,9 +538,9 @@ public function testInvalidMissingValueWillBeIgnored() $data .= "\r\n"; $data .= "--$boundary--\r\n"; - $request = new ServerRequest('POST', 'http://example.com/', array( + $request = new ServerRequest('POST', 'http://example.com/', [ 'Content-Type' => 'multipart/form-data; boundary=' . $boundary, - ), $data, 1.1); + ], $data, 1.1); $parser = new MultipartParser(); $parsedRequest = $parser->parse($request); @@ -489,9 +556,9 @@ public function testInvalidMissingValueAndEndBoundaryWillBeIgnored() $data = "--$boundary\r\n"; $data .= "Content-Disposition: form-data; name=\"key\"\r\n"; - $request = new ServerRequest('POST', 'http://example.com/', array( + $request = new ServerRequest('POST', 'http://example.com/', [ 'Content-Type' => 'multipart/form-data; boundary=' . $boundary, - ), $data, 1.1); + ], $data, 1.1); $parser = new MultipartParser(); $parsedRequest = $parser->parse($request); @@ -510,9 +577,9 @@ public function testInvalidContentDispositionMissingWillBeIgnored() $data .= "hello\r\n"; $data .= "--$boundary--\r\n"; - $request = new ServerRequest('POST', 'http://example.com/', array( + $request = new ServerRequest('POST', 'http://example.com/', [ 'Content-Type' => 'multipart/form-data; boundary=' . $boundary, - ), $data, 1.1); + ], $data, 1.1); $parser = new MultipartParser(); $parsedRequest = $parser->parse($request); @@ -531,9 +598,9 @@ public function testInvalidContentDispositionMissingValueWillBeIgnored() $data .= "value\r\n"; $data .= "--$boundary--\r\n"; - $request = new ServerRequest('POST', 'http://example.com/', array( + $request = new ServerRequest('POST', 'http://example.com/', [ 'Content-Type' => 'multipart/form-data; boundary=' . $boundary, - ), $data, 1.1); + ], $data, 1.1); $parser = new MultipartParser(); $parsedRequest = $parser->parse($request); @@ -552,9 +619,9 @@ public function testInvalidContentDispositionWithoutNameWillBeIgnored() $data .= "value\r\n"; $data .= "--$boundary--\r\n"; - $request = new ServerRequest('POST', 'http://example.com/', array( + $request = new ServerRequest('POST', 'http://example.com/', [ 'Content-Type' => 'multipart/form-data; boundary=' . $boundary, - ), $data, 1.1); + ], $data, 1.1); $parser = new MultipartParser(); $parsedRequest = $parser->parse($request); @@ -572,9 +639,9 @@ public function testInvalidMissingEndBoundaryWillBeIgnored() $data .= "\r\n"; $data .= "value\r\n"; - $request = new ServerRequest('POST', 'http://example.com/', array( + $request = new ServerRequest('POST', 'http://example.com/', [ 'Content-Type' => 'multipart/form-data; boundary=' . $boundary, - ), $data, 1.1); + ], $data, 1.1); $parser = new MultipartParser(); $parsedRequest = $parser->parse($request); @@ -596,9 +663,9 @@ public function testInvalidUploadFileWithoutContentTypeUsesNullValue() $data .= "world\r\n"; $data .= "--$boundary--\r\n"; - $request = new ServerRequest('POST', 'http://example.com/', array( + $request = new ServerRequest('POST', 'http://example.com/', [ 'Content-Type' => 'multipart/form-data; boundary=' . $boundary, - ), $data, 1.1); + ], $data, 1.1); $parser = new MultipartParser(); $parsedRequest = $parser->parse($request); @@ -607,7 +674,7 @@ public function testInvalidUploadFileWithoutContentTypeUsesNullValue() $this->assertCount(1, $files); $this->assertTrue(isset($files['file'])); - $this->assertInstanceOf('Psr\Http\Message\UploadedFileInterface', $files['file']); + $this->assertInstanceOf(UploadedFileInterface::class, $files['file']); /* @var $file \Psr\Http\Message\UploadedFileInterface */ $file = $files['file']; @@ -631,9 +698,9 @@ public function testInvalidUploadFileWithoutMultipleContentTypeUsesLastValue() $data .= "world\r\n"; $data .= "--$boundary--\r\n"; - $request = new ServerRequest('POST', 'http://example.com/', array( + $request = new ServerRequest('POST', 'http://example.com/', [ 'Content-Type' => 'multipart/form-data; boundary=' . $boundary, - ), $data, 1.1); + ], $data, 1.1); $parser = new MultipartParser(); $parsedRequest = $parser->parse($request); @@ -642,7 +709,7 @@ public function testInvalidUploadFileWithoutMultipleContentTypeUsesLastValue() $this->assertCount(1, $files); $this->assertTrue(isset($files['file'])); - $this->assertInstanceOf('Psr\Http\Message\UploadedFileInterface', $files['file']); + $this->assertInstanceOf(UploadedFileInterface::class, $files['file']); /* @var $file \Psr\Http\Message\UploadedFileInterface */ $file = $files['file']; @@ -665,9 +732,9 @@ public function testUploadEmptyFile() $data .= "\r\n"; $data .= "--$boundary--\r\n"; - $request = new ServerRequest('POST', 'http://example.com/', array( + $request = new ServerRequest('POST', 'http://example.com/', [ 'Content-Type' => 'multipart/form-data; boundary=' . $boundary, - ), $data, 1.1); + ], $data, 1.1); $parser = new MultipartParser(); $parsedRequest = $parser->parse($request); @@ -676,7 +743,7 @@ public function testUploadEmptyFile() $this->assertCount(1, $files); $this->assertTrue(isset($files['file'])); - $this->assertInstanceOf('Psr\Http\Message\UploadedFileInterface', $files['file']); + $this->assertInstanceOf(UploadedFileInterface::class, $files['file']); /* @var $file \Psr\Http\Message\UploadedFileInterface */ $file = $files['file']; @@ -699,9 +766,9 @@ public function testUploadTooLargeFile() $data .= "world\r\n"; $data .= "--$boundary--\r\n"; - $request = new ServerRequest('POST', 'http://example.com/', array( + $request = new ServerRequest('POST', 'http://example.com/', [ 'Content-Type' => 'multipart/form-data; boundary=' . $boundary, - ), $data, 1.1); + ], $data, 1.1); $parser = new MultipartParser(4); $parsedRequest = $parser->parse($request); @@ -710,7 +777,7 @@ public function testUploadTooLargeFile() $this->assertCount(1, $files); $this->assertTrue(isset($files['file'])); - $this->assertInstanceOf('Psr\Http\Message\UploadedFileInterface', $files['file']); + $this->assertInstanceOf(UploadedFileInterface::class, $files['file']); /* @var $file \Psr\Http\Message\UploadedFileInterface */ $file = $files['file']; @@ -732,9 +799,9 @@ public function testUploadTooLargeFileWithIniLikeSize() $data .= str_repeat('world', 1024) . "\r\n"; $data .= "--$boundary--\r\n"; - $request = new ServerRequest('POST', 'http://example.com/', array( + $request = new ServerRequest('POST', 'http://example.com/', [ 'Content-Type' => 'multipart/form-data; boundary=' . $boundary, - ), $data, 1.1); + ], $data, 1.1); $parser = new MultipartParser('1K'); $parsedRequest = $parser->parse($request); @@ -743,7 +810,7 @@ public function testUploadTooLargeFileWithIniLikeSize() $this->assertCount(1, $files); $this->assertTrue(isset($files['file'])); - $this->assertInstanceOf('Psr\Http\Message\UploadedFileInterface', $files['file']); + $this->assertInstanceOf(UploadedFileInterface::class, $files['file']); /* @var $file \Psr\Http\Message\UploadedFileInterface */ $file = $files['file']; @@ -765,9 +832,9 @@ public function testUploadNoFile() $data .= "\r\n"; $data .= "--$boundary--\r\n"; - $request = new ServerRequest('POST', 'http://example.com/', array( + $request = new ServerRequest('POST', 'http://example.com/', [ 'Content-Type' => 'multipart/form-data; boundary=' . $boundary, - ), $data, 1.1); + ], $data, 1.1); $parser = new MultipartParser(); $parsedRequest = $parser->parse($request); @@ -776,7 +843,7 @@ public function testUploadNoFile() $this->assertCount(1, $files); $this->assertTrue(isset($files['file'])); - $this->assertInstanceOf('Psr\Http\Message\UploadedFileInterface', $files['file']); + $this->assertInstanceOf(UploadedFileInterface::class, $files['file']); /* @var $file \Psr\Http\Message\UploadedFileInterface */ $file = $files['file']; @@ -803,9 +870,9 @@ public function testUploadTooManyFilesReturnsTruncatedList() $data .= "world\r\n"; $data .= "--$boundary--\r\n"; - $request = new ServerRequest('POST', 'http://example.com/', array( + $request = new ServerRequest('POST', 'http://example.com/', [ 'Content-Type' => 'multipart/form-data; boundary=' . $boundary, - ), $data, 1.1); + ], $data, 1.1); $parser = new MultipartParser(100, 1); $parsedRequest = $parser->parse($request); @@ -844,9 +911,9 @@ public function testUploadTooManyFilesIgnoresEmptyFilesAndIncludesThemDespiteTru $data .= "world\r\n"; $data .= "--$boundary--\r\n"; - $request = new ServerRequest('POST', 'http://example.com/', array( + $request = new ServerRequest('POST', 'http://example.com/', [ 'Content-Type' => 'multipart/form-data; boundary=' . $boundary, - ), $data, 1.1); + ], $data, 1.1); $parser = new MultipartParser(100, 1); $parsedRequest = $parser->parse($request); @@ -887,9 +954,9 @@ public function testPostMaxFileSize() $data .= "\r\n"; $data .= "--$boundary--\r\n"; - $request = new ServerRequest('POST', 'http://example.com/', array( + $request = new ServerRequest('POST', 'http://example.com/', [ 'Content-Type' => 'multipart/form-data; boundary=' . $boundary, - ), $data, 1.1); + ], $data, 1.1); $parser = new MultipartParser(); $parsedRequest = $parser->parse($request); @@ -942,9 +1009,9 @@ public function testPostMaxFileSizeIgnoredByFilesComingBeforeIt() $data .= "\r\n"; $data .= "--$boundary--\r\n"; - $request = new ServerRequest('POST', 'http://example.com/', array( + $request = new ServerRequest('POST', 'http://example.com/', [ 'Content-Type' => 'multipart/form-data; boundary=' . $boundary, - ), $data, 1.1); + ], $data, 1.1); $parser = new MultipartParser(); $parsedRequest = $parser->parse($request); @@ -960,4 +1027,44 @@ public function testPostMaxFileSizeIgnoredByFilesComingBeforeIt() $this->assertTrue(isset($files['file4'])); $this->assertSame(UPLOAD_ERR_OK, $files['file4']->getError()); } -} \ No newline at end of file + + public function testWeOnlyParseTheAmountOfMultiPartChunksWeConfigured() + { + $chunkCount = 5000000; + $boundary = "---------------------------12758086162038677464950549563"; + + $chunk = "--$boundary\r\n"; + $chunk .= "Content-Disposition: form-data; name=\"f\"\r\n"; + $chunk .= "\r\n"; + $chunk .= "u\r\n"; + $data = ''; + $data .= str_repeat($chunk, $chunkCount); + $data .= "--$boundary--\r\n"; + + $request = new ServerRequest('POST', 'http://example.com/', [ + 'Content-Type' => 'multipart/form-data; boundary=' . $boundary, + ], $data, 1.1); + + $parser = new MultipartParser(); + + $reflectecClass = new \ReflectionClass(MultipartParser::class); + $requestProperty = $reflectecClass->getProperty('request'); + $requestProperty->setAccessible(true); + $cursorProperty = $reflectecClass->getProperty('cursor'); + $cursorProperty->setAccessible(true); + $multipartBodyPartCountProperty = $reflectecClass->getProperty('multipartBodyPartCount'); + $multipartBodyPartCountProperty->setAccessible(true); + $maxMultipartBodyPartsProperty = $reflectecClass->getProperty('maxMultipartBodyParts'); + $maxMultipartBodyPartsProperty->setAccessible(true); + $parseBodyMethod = $reflectecClass->getMethod('parseBody'); + $parseBodyMethod->setAccessible(true); + + $this->assertSame(0, $cursorProperty->getValue($parser)); + + $requestProperty->setValue($parser, $request); + $parseBodyMethod->invoke($parser, '--' . $boundary, $data); + + $this->assertSame(strlen(str_repeat($chunk, $multipartBodyPartCountProperty->getValue($parser))), $cursorProperty->getValue($parser) + 2); + $this->assertSame($multipartBodyPartCountProperty->getValue($parser), $maxMultipartBodyPartsProperty->getValue($parser) + 1); + } +} diff --git a/tests/Io/PauseBufferStreamTest.php b/tests/Io/PauseBufferStreamTest.php index a9678a78..139db8fd 100644 --- a/tests/Io/PauseBufferStreamTest.php +++ b/tests/Io/PauseBufferStreamTest.php @@ -2,15 +2,16 @@ namespace React\Tests\Io; -use React\Tests\Http\TestCase; +use React\Stream\ReadableStreamInterface; use React\Stream\ThroughStream; +use React\Tests\Http\TestCase; use React\Http\Io\PauseBufferStream; class PauseBufferStreamTest extends TestCase { public function testPauseMethodWillBePassedThroughToInput() { - $input = $this->getMockBuilder('React\Stream\ReadableStreamInterface')->getMock(); + $input = $this->createMock(ReadableStreamInterface::class); $input->expects($this->once())->method('pause'); $stream = new PauseBufferStream($input); @@ -19,7 +20,7 @@ public function testPauseMethodWillBePassedThroughToInput() public function testCloseMethodWillBePassedThroughToInput() { - $input = $this->getMockBuilder('React\Stream\ReadableStreamInterface')->getMock(); + $input = $this->createMock(ReadableStreamInterface::class); $input->expects($this->once())->method('close'); $stream = new PauseBufferStream($input); @@ -28,7 +29,7 @@ public function testCloseMethodWillBePassedThroughToInput() public function testPauseMethodWillNotBePassedThroughToInputAfterClose() { - $input = $this->getMockBuilder('React\Stream\ReadableStreamInterface')->getMock(); + $input = $this->createMock(ReadableStreamInterface::class); $input->expects($this->never())->method('pause'); $stream = new PauseBufferStream($input); @@ -156,7 +157,7 @@ public function testErrorEventWillBePassedThroughAsIs() $stream->on('error', $this->expectCallableOnce()); $stream->on('close', $this->expectCallableOnce()); - $input->emit('error', array(new \RuntimeException())); + $input->emit('error', [new \RuntimeException()]); } public function testPausedStreamWillNotPassThroughErrorEvent() @@ -167,7 +168,7 @@ public function testPausedStreamWillNotPassThroughErrorEvent() $stream->pause(); $stream->on('error', $this->expectCallableNever()); $stream->on('close', $this->expectCallableNever()); - $input->emit('error', array(new \RuntimeException())); + $input->emit('error', [new \RuntimeException()]); } public function testPausedStreamWillPassThroughErrorEventOnResume() @@ -176,7 +177,7 @@ public function testPausedStreamWillPassThroughErrorEventOnResume() $stream = new PauseBufferStream($input); $stream->pause(); - $input->emit('error', array(new \RuntimeException())); + $input->emit('error', [new \RuntimeException()]); $stream->on('error', $this->expectCallableOnce()); $stream->on('close', $this->expectCallableOnce()); @@ -191,7 +192,7 @@ public function testPausedStreamWillNotPassThroughErrorEventOnExplicitClose() $stream->pause(); $stream->on('error', $this->expectCallableNever()); $stream->on('close', $this->expectCallableOnce()); - $input->emit('error', array(new \RuntimeException())); + $input->emit('error', [new \RuntimeException()]); $stream->close(); } diff --git a/tests/Io/ReadableBodyStreamTest.php b/tests/Io/ReadableBodyStreamTest.php new file mode 100644 index 00000000..2409a6be --- /dev/null +++ b/tests/Io/ReadableBodyStreamTest.php @@ -0,0 +1,256 @@ +input = $this->createMock(ReadableStreamInterface::class); + $this->stream = new ReadableBodyStream($this->input); + } + + public function testIsReadableIfInputIsReadable() + { + $this->input->expects($this->once())->method('isReadable')->willReturn(true); + + $this->assertTrue($this->stream->isReadable()); + } + + public function testIsEofIfInputIsNotReadable() + { + $this->input->expects($this->once())->method('isReadable')->willReturn(false); + + $this->assertTrue($this->stream->eof()); + } + + public function testCloseWillCloseInputStream() + { + $this->input->expects($this->once())->method('close'); + + $this->stream->close(); + } + + public function testCloseWillEmitCloseEvent() + { + $this->input = new ThroughStream(); + $this->stream = new ReadableBodyStream($this->input); + + $called = 0; + $this->stream->on('close', function () use (&$called) { + ++$called; + }); + + $this->stream->close(); + $this->stream->close(); + + $this->assertEquals(1, $called); + } + + public function testCloseInputWillEmitCloseEvent() + { + $this->input = new ThroughStream(); + $this->stream = new ReadableBodyStream($this->input); + + $called = 0; + $this->stream->on('close', function () use (&$called) { + ++$called; + }); + + $this->input->close(); + $this->input->close(); + + $this->assertEquals(1, $called); + } + + public function testEndInputWillEmitCloseEvent() + { + $this->input = new ThroughStream(); + $this->stream = new ReadableBodyStream($this->input); + + $called = 0; + $this->stream->on('close', function () use (&$called) { + ++$called; + }); + + $this->input->end(); + $this->input->end(); + + $this->assertEquals(1, $called); + } + + public function testEndInputWillEmitErrorEventWhenDataDoesNotReachExpectedLength() + { + $this->input = new ThroughStream(); + $this->stream = new ReadableBodyStream($this->input, 5); + + $called = null; + $this->stream->on('error', function ($e) use (&$called) { + $called = $e; + }); + + $this->input->write('hi'); + $this->input->end(); + + $this->assertInstanceOf(\UnderflowException::class, $called); + $this->assertSame('Unexpected end of response body after 2/5 bytes', $called->getMessage()); + } + + public function testDataEventOnInputWillEmitDataEvent() + { + $this->input = new ThroughStream(); + $this->stream = new ReadableBodyStream($this->input); + + $called = null; + $this->stream->on('data', function ($data) use (&$called) { + $called = $data; + }); + + $this->input->write('hello'); + + $this->assertEquals('hello', $called); + } + + public function testDataEventOnInputWillEmitEndWhenDataReachesExpectedLength() + { + $this->input = new ThroughStream(); + $this->stream = new ReadableBodyStream($this->input, 5); + + $called = null; + $this->stream->on('end', function () use (&$called) { + ++$called; + }); + + $this->input->write('hello'); + + $this->assertEquals(1, $called); + } + + public function testEndEventOnInputWillEmitEndOnlyOnceWhenDataAlreadyReachedExpectedLength() + { + $this->input = new ThroughStream(); + $this->stream = new ReadableBodyStream($this->input, 5); + + $called = null; + $this->stream->on('end', function () use (&$called) { + ++$called; + }); + + $this->input->write('hello'); + $this->input->end(); + + $this->assertEquals(1, $called); + } + + public function testDataEventOnInputWillNotEmitEndWhenDataDoesNotReachExpectedLength() + { + $this->input = new ThroughStream(); + $this->stream = new ReadableBodyStream($this->input, 5); + + $called = null; + $this->stream->on('end', function () use (&$called) { + ++$called; + }); + + $this->input->write('hi'); + + $this->assertNull($called); + } + + public function testPauseWillPauseInputStream() + { + $this->input->expects($this->once())->method('pause'); + + $this->stream->pause(); + } + + public function testResumeWillResumeInputStream() + { + $this->input->expects($this->once())->method('resume'); + + $this->stream->resume(); + } + + public function testPointlessTostringReturnsEmptyString() + { + $this->assertEquals('', (string)$this->stream); + } + + public function testPointlessDetachThrows() + { + $this->expectException(\BadMethodCallException::class); + $this->stream->detach(); + } + + public function testPointlessGetSizeReturnsNull() + { + $this->assertEquals(null, $this->stream->getSize()); + } + + public function testPointlessTellThrows() + { + $this->expectException(\BadMethodCallException::class); + $this->stream->tell(); + } + + public function testPointlessIsSeekableReturnsFalse() + { + $this->assertEquals(false, $this->stream->isSeekable()); + } + + public function testPointlessSeekThrows() + { + $this->expectException(\BadMethodCallException::class); + $this->stream->seek(0); + } + + public function testPointlessRewindThrows() + { + $this->expectException(\BadMethodCallException::class); + $this->stream->rewind(); + } + + public function testPointlessIsWritableReturnsFalse() + { + $this->assertEquals(false, $this->stream->isWritable()); + } + + public function testPointlessWriteThrows() + { + $this->expectException(\BadMethodCallException::class); + $this->stream->write(''); + } + + public function testPointlessReadThrows() + { + $this->expectException(\BadMethodCallException::class); + $this->stream->read(8192); + } + + public function testPointlessGetContentsThrows() + { + $this->expectException(\BadMethodCallException::class); + $this->stream->getContents(); + } + + public function testPointlessGetMetadataReturnsNullWhenKeyIsGiven() + { + $this->assertEquals(null, $this->stream->getMetadata('unknown')); + } + + public function testPointlessGetMetadataReturnsEmptyArrayWhenNoKeyIsGiven() + { + $this->assertEquals([], $this->stream->getMetadata()); + } +} diff --git a/tests/Io/RequestHeaderParserTest.php b/tests/Io/RequestHeaderParserTest.php index baf7215a..568fc375 100644 --- a/tests/Io/RequestHeaderParserTest.php +++ b/tests/Io/RequestHeaderParserTest.php @@ -2,60 +2,70 @@ namespace React\Tests\Http\Io; +use Psr\Http\Message\RequestInterface; +use Psr\Http\Message\ServerRequestInterface; +use React\Http\Io\Clock; use React\Http\Io\RequestHeaderParser; +use React\Socket\Connection; +use React\Stream\ReadableStreamInterface; use React\Tests\Http\TestCase; -use Psr\Http\Message\ServerRequestInterface; class RequestHeaderParserTest extends TestCase { public function testSplitShouldHappenOnDoubleCrlf() { - $parser = new RequestHeaderParser(); + $clock = $this->createMock(Clock::class); + + $parser = new RequestHeaderParser($clock); $parser->on('headers', $this->expectCallableNever()); - $connection = $this->getMockBuilder('React\Socket\Connection')->disableOriginalConstructor()->setMethods(null)->getMock(); + $connection = $this->getMockBuilder(Connection::class)->disableOriginalConstructor()->setMethods(null)->getMock(); $parser->handle($connection); - $connection->emit('data', array("GET / HTTP/1.1\r\n")); - $connection->emit('data', array("Host: example.com:80\r\n")); - $connection->emit('data', array("Connection: close\r\n")); + $connection->emit('data', ["GET / HTTP/1.1\r\n"]); + $connection->emit('data', ["Host: example.com:80\r\n"]); + $connection->emit('data', ["Connection: close\r\n"]); $parser->removeAllListeners(); $parser->on('headers', $this->expectCallableOnce()); - $connection->emit('data', array("\r\n")); + $connection->emit('data', ["\r\n"]); } public function testFeedInOneGo() { - $parser = new RequestHeaderParser(); + $clock = $this->createMock(Clock::class); + + $parser = new RequestHeaderParser($clock); $parser->on('headers', $this->expectCallableOnce()); - $connection = $this->getMockBuilder('React\Socket\Connection')->disableOriginalConstructor()->setMethods(null)->getMock(); + $connection = $this->getMockBuilder(Connection::class)->disableOriginalConstructor()->setMethods(null)->getMock(); $parser->handle($connection); $data = $this->createGetRequest(); - $connection->emit('data', array($data)); + $connection->emit('data', [$data]); } public function testFeedTwoRequestsOnSeparateConnections() { - $parser = new RequestHeaderParser(); + $clock = $this->createMock(Clock::class); + + $parser = new RequestHeaderParser($clock); $called = 0; $parser->on('headers', function () use (&$called) { ++$called; }); - $connection1 = $this->getMockBuilder('React\Socket\Connection')->disableOriginalConstructor()->setMethods(null)->getMock(); - $connection2 = $this->getMockBuilder('React\Socket\Connection')->disableOriginalConstructor()->setMethods(null)->getMock(); + $connection1 = $this->getMockBuilder(Connection::class)->disableOriginalConstructor()->setMethods(null)->getMock(); + $connection2 = $this->getMockBuilder(Connection::class)->disableOriginalConstructor()->setMethods(null)->getMock(); $parser->handle($connection1); $parser->handle($connection2); $data = $this->createGetRequest(); - $connection1->emit('data', array($data)); - $connection2->emit('data', array($data)); + $connection1->emit('data', [$data]); + $connection2->emit('data', [$data]); $this->assertEquals(2, $called); } @@ -65,60 +75,64 @@ public function testHeadersEventShouldEmitRequestAndConnection() $request = null; $conn = null; - $parser = new RequestHeaderParser(); + $clock = $this->createMock(Clock::class); + + $parser = new RequestHeaderParser($clock); $parser->on('headers', function ($parsedRequest, $connection) use (&$request, &$conn) { $request = $parsedRequest; $conn = $connection; }); - $connection = $this->getMockBuilder('React\Socket\Connection')->disableOriginalConstructor()->setMethods(null)->getMock(); + $connection = $this->getMockBuilder(Connection::class)->disableOriginalConstructor()->setMethods(null)->getMock(); $parser->handle($connection); $data = $this->createGetRequest(); - $connection->emit('data', array($data)); + $connection->emit('data', [$data]); - $this->assertInstanceOf('Psr\Http\Message\RequestInterface', $request); + $this->assertInstanceOf(RequestInterface::class, $request); $this->assertSame('GET', $request->getMethod()); $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(['Host' => ['example.com'], 'Connection' => ['close']], $request->getHeaders()); $this->assertSame($connection, $conn); } public function testHeadersEventShouldEmitRequestWhichShouldEmitEndForStreamingBodyWithoutContentLengthFromInitialRequestBody() { - $parser = new RequestHeaderParser(); + $clock = $this->createMock(Clock::class); + + $parser = new RequestHeaderParser($clock); $ended = false; - $that = $this; - $parser->on('headers', function (ServerRequestInterface $request) use (&$ended, $that) { + $parser->on('headers', function (ServerRequestInterface $request) use (&$ended) { $body = $request->getBody(); - $that->assertInstanceOf('React\Stream\ReadableStreamInterface', $body); + $this->assertInstanceOf(ReadableStreamInterface::class, $body); $body->on('end', function () use (&$ended) { $ended = true; }); }); - $connection = $this->getMockBuilder('React\Socket\Connection')->disableOriginalConstructor()->setMethods(null)->getMock(); + $connection = $this->getMockBuilder(Connection::class)->disableOriginalConstructor()->setMethods(null)->getMock(); $parser->handle($connection); $data = "GET / HTTP/1.0\r\n\r\n"; - $connection->emit('data', array($data)); + $connection->emit('data', [$data]); $this->assertTrue($ended); } public function testHeadersEventShouldEmitRequestWhichShouldEmitStreamingBodyDataFromInitialRequestBody() { - $parser = new RequestHeaderParser(); + $clock = $this->createMock(Clock::class); + + $parser = new RequestHeaderParser($clock); $buffer = ''; - $that = $this; - $parser->on('headers', function (ServerRequestInterface $request) use (&$buffer, $that) { + $parser->on('headers', function (ServerRequestInterface $request) use (&$buffer) { $body = $request->getBody(); - $that->assertInstanceOf('React\Stream\ReadableStreamInterface', $body); + $this->assertInstanceOf(ReadableStreamInterface::class, $body); $body->on('data', function ($chunk) use (&$buffer) { $buffer .= $chunk; @@ -128,88 +142,91 @@ public function testHeadersEventShouldEmitRequestWhichShouldEmitStreamingBodyDat }); }); - $connection = $this->getMockBuilder('React\Socket\Connection')->disableOriginalConstructor()->setMethods(null)->getMock(); + $connection = $this->getMockBuilder(Connection::class)->disableOriginalConstructor()->setMethods(null)->getMock(); $parser->handle($connection); $data = "POST / HTTP/1.0\r\nContent-Length: 11\r\n\r\n"; $data .= 'RANDOM DATA'; - $connection->emit('data', array($data)); + $connection->emit('data', [$data]); $this->assertSame('RANDOM DATA.', $buffer); } public function testHeadersEventShouldEmitRequestWhichShouldEmitStreamingBodyWithPlentyOfDataFromInitialRequestBody() { - $parser = new RequestHeaderParser(); + $clock = $this->createMock(Clock::class); + + $parser = new RequestHeaderParser($clock); $buffer = ''; - $that = $this; - $parser->on('headers', function (ServerRequestInterface $request) use (&$buffer, $that) { + $parser->on('headers', function (ServerRequestInterface $request) use (&$buffer) { $body = $request->getBody(); - $that->assertInstanceOf('React\Stream\ReadableStreamInterface', $body); + $this->assertInstanceOf(ReadableStreamInterface::class, $body); $body->on('data', function ($chunk) use (&$buffer) { $buffer .= $chunk; }); }); - $connection = $this->getMockBuilder('React\Socket\Connection')->disableOriginalConstructor()->setMethods(null)->getMock(); + $connection = $this->getMockBuilder(Connection::class)->disableOriginalConstructor()->setMethods(null)->getMock(); $parser->handle($connection); $size = 10000; $data = "POST / HTTP/1.0\r\nContent-Length: $size\r\n\r\n"; $data .= str_repeat('x', $size); - $connection->emit('data', array($data)); + $connection->emit('data', [$data]); $this->assertSame($size, strlen($buffer)); } public function testHeadersEventShouldEmitRequestWhichShouldNotEmitStreamingBodyDataWithoutContentLengthFromInitialRequestBody() { - $parser = new RequestHeaderParser(); + $clock = $this->createMock(Clock::class); + + $parser = new RequestHeaderParser($clock); $buffer = ''; - $that = $this; - $parser->on('headers', function (ServerRequestInterface $request) use (&$buffer, $that) { + $parser->on('headers', function (ServerRequestInterface $request) use (&$buffer) { $body = $request->getBody(); - $that->assertInstanceOf('React\Stream\ReadableStreamInterface', $body); + $this->assertInstanceOf(ReadableStreamInterface::class, $body); $body->on('data', function ($chunk) use (&$buffer) { $buffer .= $chunk; }); }); - $connection = $this->getMockBuilder('React\Socket\Connection')->disableOriginalConstructor()->setMethods(null)->getMock(); + $connection = $this->getMockBuilder(Connection::class)->disableOriginalConstructor()->setMethods(null)->getMock(); $parser->handle($connection); $data = "POST / HTTP/1.0\r\n\r\n"; $data .= 'RANDOM DATA'; - $connection->emit('data', array($data)); + $connection->emit('data', [$data]); $this->assertSame('', $buffer); } public function testHeadersEventShouldEmitRequestWhichShouldEmitStreamingBodyDataUntilContentLengthBoundaryFromInitialRequestBody() { - $parser = new RequestHeaderParser(); + $clock = $this->createMock(Clock::class); + + $parser = new RequestHeaderParser($clock); $buffer = ''; - $that = $this; - $parser->on('headers', function (ServerRequestInterface $request) use (&$buffer, $that) { + $parser->on('headers', function (ServerRequestInterface $request) use (&$buffer) { $body = $request->getBody(); - $that->assertInstanceOf('React\Stream\ReadableStreamInterface', $body); + $this->assertInstanceOf(ReadableStreamInterface::class, $body); $body->on('data', function ($chunk) use (&$buffer) { $buffer .= $chunk; }); }); - $connection = $this->getMockBuilder('React\Socket\Connection')->disableOriginalConstructor()->setMethods(null)->getMock(); + $connection = $this->getMockBuilder(Connection::class)->disableOriginalConstructor()->setMethods(null)->getMock(); $parser->handle($connection); $data = "POST / HTTP/1.0\r\nContent-Length: 6\r\n\r\n"; $data .= 'RANDOM DATA'; - $connection->emit('data', array($data)); + $connection->emit('data', [$data]); $this->assertSame('RANDOM', $buffer); } @@ -218,26 +235,28 @@ public function testHeadersEventShouldParsePathAndQueryString() { $request = null; - $parser = new RequestHeaderParser(); + $clock = $this->createMock(Clock::class); + + $parser = new RequestHeaderParser($clock); $parser->on('headers', function ($parsedRequest) use (&$request) { $request = $parsedRequest; }); - $connection = $this->getMockBuilder('React\Socket\Connection')->disableOriginalConstructor()->setMethods(null)->getMock(); + $connection = $this->getMockBuilder(Connection::class)->disableOriginalConstructor()->setMethods(null)->getMock(); $parser->handle($connection); $data = $this->createAdvancedPostRequest(); - $connection->emit('data', array($data)); + $connection->emit('data', [$data]); - $this->assertInstanceOf('Psr\Http\Message\RequestInterface', $request); + $this->assertInstanceOf(RequestInterface::class, $request); $this->assertSame('POST', $request->getMethod()); $this->assertEquals('http://example.com/foo?bar=baz', $request->getUri()); $this->assertSame('1.1', $request->getProtocolVersion()); - $headers = array( - 'Host' => array('example.com'), - 'User-Agent' => array('react/alpha'), - 'Connection' => array('close'), - ); + $headers = [ + 'Host' => ['example.com'], + 'User-Agent' => ['react/alpha'], + 'Connection' => ['close'] + ]; $this->assertSame($headers, $request->getHeaders()); } @@ -245,35 +264,39 @@ public function testHeaderEventWithShouldApplyDefaultAddressFromLocalConnectionA { $request = null; - $parser = new RequestHeaderParser(); + $clock = $this->createMock(Clock::class); + + $parser = new RequestHeaderParser($clock); $parser->on('headers', function ($parsedRequest) use (&$request) { $request = $parsedRequest; }); - $connection = $this->getMockBuilder('React\Socket\Connection')->disableOriginalConstructor()->setMethods(array('getLocalAddress'))->getMock(); + $connection = $this->getMockBuilder(Connection::class)->disableOriginalConstructor()->setMethods(['getLocalAddress'])->getMock(); $connection->expects($this->once())->method('getLocalAddress')->willReturn('tcp://127.1.1.1:8000'); $parser->handle($connection); - $connection->emit('data', array("GET /foo HTTP/1.0\r\n\r\n")); + $connection->emit('data', ["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')); + $this->assertFalse($request->hasHeader('Host')); } public function testHeaderEventViaHttpsShouldApplyHttpsSchemeFromLocalTlsConnectionAddress() { $request = null; - $parser = new RequestHeaderParser(); + $clock = $this->createMock(Clock::class); + + $parser = new RequestHeaderParser($clock); $parser->on('headers', function ($parsedRequest) use (&$request) { $request = $parsedRequest; }); - $connection = $this->getMockBuilder('React\Socket\Connection')->disableOriginalConstructor()->setMethods(array('getLocalAddress'))->getMock(); + $connection = $this->getMockBuilder(Connection::class)->disableOriginalConstructor()->setMethods(['getLocalAddress'])->getMock(); $connection->expects($this->once())->method('getLocalAddress')->willReturn('tls://127.1.1.1:8000'); $parser->handle($connection); - $connection->emit('data', array("GET /foo HTTP/1.0\r\nHost: example.com\r\n\r\n")); + $connection->emit('data', ["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')); @@ -284,20 +307,22 @@ public function testHeaderOverflowShouldEmitError() $error = null; $passedConnection = null; - $parser = new RequestHeaderParser(); + $clock = $this->createMock(Clock::class); + + $parser = new RequestHeaderParser($clock); $parser->on('headers', $this->expectCallableNever()); $parser->on('error', function ($message, $connection) use (&$error, &$passedConnection) { $error = $message; $passedConnection = $connection; }); - $connection = $this->getMockBuilder('React\Socket\Connection')->disableOriginalConstructor()->setMethods(null)->getMock(); + $connection = $this->getMockBuilder(Connection::class)->disableOriginalConstructor()->setMethods(null)->getMock(); $parser->handle($connection); $data = str_repeat('A', 8193); - $connection->emit('data', array($data)); + $connection->emit('data', [$data]); - $this->assertInstanceOf('OverflowException', $error); + $this->assertInstanceOf(\OverflowException::class, $error); $this->assertSame('Maximum header size of 8192 exceeded.', $error->getMessage()); $this->assertSame($connection, $passedConnection); } @@ -306,18 +331,20 @@ public function testInvalidEmptyRequestHeadersParseException() { $error = null; - $parser = new RequestHeaderParser(); + $clock = $this->createMock(Clock::class); + + $parser = new RequestHeaderParser($clock); $parser->on('headers', $this->expectCallableNever()); $parser->on('error', function ($message) use (&$error) { $error = $message; }); - $connection = $this->getMockBuilder('React\Socket\Connection')->disableOriginalConstructor()->setMethods(null)->getMock(); + $connection = $this->getMockBuilder(Connection::class)->disableOriginalConstructor()->setMethods(null)->getMock(); $parser->handle($connection); - $connection->emit('data', array("\r\n\r\n")); + $connection->emit('data', ["\r\n\r\n"]); - $this->assertInstanceOf('InvalidArgumentException', $error); + $this->assertInstanceOf(\InvalidArgumentException::class, $error); $this->assertSame('Unable to parse invalid request-line', $error->getMessage()); } @@ -325,62 +352,62 @@ public function testInvalidMalformedRequestLineParseException() { $error = null; - $parser = new RequestHeaderParser(); + $clock = $this->createMock(Clock::class); + + $parser = new RequestHeaderParser($clock); $parser->on('headers', $this->expectCallableNever()); $parser->on('error', function ($message) use (&$error) { $error = $message; }); - $connection = $this->getMockBuilder('React\Socket\Connection')->disableOriginalConstructor()->setMethods(null)->getMock(); + $connection = $this->getMockBuilder(Connection::class)->disableOriginalConstructor()->setMethods(null)->getMock(); $parser->handle($connection); - $connection->emit('data', array("GET /\r\n\r\n")); + $connection->emit('data', ["GET /\r\n\r\n"]); - $this->assertInstanceOf('InvalidArgumentException', $error); + $this->assertInstanceOf(\InvalidArgumentException::class, $error); $this->assertSame('Unable to parse invalid request-line', $error->getMessage()); } - /** - * @group a - */ public function testInvalidMalformedRequestHeadersThrowsParseException() { $error = null; - $parser = new RequestHeaderParser(); + $clock = $this->createMock(Clock::class); + + $parser = new RequestHeaderParser($clock); $parser->on('headers', $this->expectCallableNever()); $parser->on('error', function ($message) use (&$error) { $error = $message; }); - $connection = $this->getMockBuilder('React\Socket\Connection')->disableOriginalConstructor()->setMethods(null)->getMock(); + $connection = $this->getMockBuilder(Connection::class)->disableOriginalConstructor()->setMethods(null)->getMock(); $parser->handle($connection); - $connection->emit('data', array("GET / HTTP/1.1\r\nHost : yes\r\n\r\n")); + $connection->emit('data', ["GET / HTTP/1.1\r\nHost : yes\r\n\r\n"]); - $this->assertInstanceOf('InvalidArgumentException', $error); + $this->assertInstanceOf(\InvalidArgumentException::class, $error); $this->assertSame('Unable to parse invalid request header fields', $error->getMessage()); } - /** - * @group a - */ public function testInvalidMalformedRequestHeadersWhitespaceThrowsParseException() { $error = null; - $parser = new RequestHeaderParser(); + $clock = $this->createMock(Clock::class); + + $parser = new RequestHeaderParser($clock); $parser->on('headers', $this->expectCallableNever()); $parser->on('error', function ($message) use (&$error) { $error = $message; }); - $connection = $this->getMockBuilder('React\Socket\Connection')->disableOriginalConstructor()->setMethods(null)->getMock(); + $connection = $this->getMockBuilder(Connection::class)->disableOriginalConstructor()->setMethods(null)->getMock(); $parser->handle($connection); - $connection->emit('data', array("GET / HTTP/1.1\r\nHost: yes\rFoo: bar\r\n\r\n")); + $connection->emit('data', ["GET / HTTP/1.1\r\nHost: yes\rFoo: bar\r\n\r\n"]); - $this->assertInstanceOf('InvalidArgumentException', $error); + $this->assertInstanceOf(\InvalidArgumentException::class, $error); $this->assertSame('Unable to parse invalid request header fields', $error->getMessage()); } @@ -388,18 +415,20 @@ public function testInvalidAbsoluteFormSchemeEmitsError() { $error = null; - $parser = new RequestHeaderParser(); + $clock = $this->createMock(Clock::class); + + $parser = new RequestHeaderParser($clock); $parser->on('headers', $this->expectCallableNever()); $parser->on('error', function ($message) use (&$error) { $error = $message; }); - $connection = $this->getMockBuilder('React\Socket\Connection')->disableOriginalConstructor()->setMethods(null)->getMock(); + $connection = $this->getMockBuilder(Connection::class)->disableOriginalConstructor()->setMethods(null)->getMock(); $parser->handle($connection); - $connection->emit('data', array("GET tcp://example.com:80/ HTTP/1.0\r\n\r\n")); + $connection->emit('data', ["GET tcp://example.com:80/ HTTP/1.0\r\n\r\n"]); - $this->assertInstanceOf('InvalidArgumentException', $error); + $this->assertInstanceOf(\InvalidArgumentException::class, $error); $this->assertSame('Invalid absolute-form request-target', $error->getMessage()); } @@ -407,24 +436,26 @@ public function testOriginFormWithSchemeSeparatorInParam() { $request = null; - $parser = new RequestHeaderParser(); + $clock = $this->createMock(Clock::class); + + $parser = new RequestHeaderParser($clock); $parser->on('error', $this->expectCallableNever()); $parser->on('headers', function ($parsedRequest, $parsedBodyBuffer) use (&$request) { $request = $parsedRequest; }); - $connection = $this->getMockBuilder('React\Socket\Connection')->disableOriginalConstructor()->setMethods(null)->getMock(); + $connection = $this->getMockBuilder(Connection::class)->disableOriginalConstructor()->setMethods(null)->getMock(); $parser->handle($connection); - $connection->emit('data', array("GET /somepath?param=http://example.com HTTP/1.1\r\nHost: localhost\r\n\r\n")); + $connection->emit('data', ["GET /somepath?param=http://example.com HTTP/1.1\r\nHost: localhost\r\n\r\n"]); - $this->assertInstanceOf('Psr\Http\Message\RequestInterface', $request); + $this->assertInstanceOf(RequestInterface::class, $request); $this->assertSame('GET', $request->getMethod()); $this->assertEquals('http://localhost/somepath?param=http://example.com', $request->getUri()); $this->assertSame('1.1', $request->getProtocolVersion()); - $headers = array( - 'Host' => array('localhost') - ); + $headers = [ + 'Host' => ['localhost'] + ]; $this->assertSame($headers, $request->getHeaders()); } @@ -432,18 +463,20 @@ public function testUriStartingWithColonSlashSlashFails() { $error = null; - $parser = new RequestHeaderParser(); + $clock = $this->createMock(Clock::class); + + $parser = new RequestHeaderParser($clock); $parser->on('headers', $this->expectCallableNever()); $parser->on('error', function ($message) use (&$error) { $error = $message; }); - $connection = $this->getMockBuilder('React\Socket\Connection')->disableOriginalConstructor()->setMethods(null)->getMock(); + $connection = $this->getMockBuilder(Connection::class)->disableOriginalConstructor()->setMethods(null)->getMock(); $parser->handle($connection); - $connection->emit('data', array("GET ://example.com:80/ HTTP/1.0\r\n\r\n")); + $connection->emit('data', ["GET ://example.com:80/ HTTP/1.0\r\n\r\n"]); - $this->assertInstanceOf('InvalidArgumentException', $error); + $this->assertInstanceOf(\InvalidArgumentException::class, $error); $this->assertSame('Invalid absolute-form request-target', $error->getMessage()); } @@ -451,18 +484,20 @@ public function testInvalidAbsoluteFormWithFragmentEmitsError() { $error = null; - $parser = new RequestHeaderParser(); + $clock = $this->createMock(Clock::class); + + $parser = new RequestHeaderParser($clock); $parser->on('headers', $this->expectCallableNever()); $parser->on('error', function ($message) use (&$error) { $error = $message; }); - $connection = $this->getMockBuilder('React\Socket\Connection')->disableOriginalConstructor()->setMethods(null)->getMock(); + $connection = $this->getMockBuilder(Connection::class)->disableOriginalConstructor()->setMethods(null)->getMock(); $parser->handle($connection); - $connection->emit('data', array("GET http://example.com:80/#home HTTP/1.0\r\n\r\n")); + $connection->emit('data', ["GET http://example.com:80/#home HTTP/1.0\r\n\r\n"]); - $this->assertInstanceOf('InvalidArgumentException', $error); + $this->assertInstanceOf(\InvalidArgumentException::class, $error); $this->assertSame('Invalid absolute-form request-target', $error->getMessage()); } @@ -470,18 +505,20 @@ public function testInvalidHeaderContainsFullUri() { $error = null; - $parser = new RequestHeaderParser(); + $clock = $this->createMock(Clock::class); + + $parser = new RequestHeaderParser($clock); $parser->on('headers', $this->expectCallableNever()); $parser->on('error', function ($message) use (&$error) { $error = $message; }); - $connection = $this->getMockBuilder('React\Socket\Connection')->disableOriginalConstructor()->setMethods(null)->getMock(); + $connection = $this->getMockBuilder(Connection::class)->disableOriginalConstructor()->setMethods(null)->getMock(); $parser->handle($connection); - $connection->emit('data', array("GET / HTTP/1.1\r\nHost: http://user:pass@host/\r\n\r\n")); + $connection->emit('data', ["GET / HTTP/1.1\r\nHost: http://user:pass@host/\r\n\r\n"]); - $this->assertInstanceOf('InvalidArgumentException', $error); + $this->assertInstanceOf(\InvalidArgumentException::class, $error); $this->assertSame('Invalid Host header value', $error->getMessage()); } @@ -489,18 +526,20 @@ public function testInvalidAbsoluteFormWithHostHeaderEmpty() { $error = null; - $parser = new RequestHeaderParser(); + $clock = $this->createMock(Clock::class); + + $parser = new RequestHeaderParser($clock); $parser->on('headers', $this->expectCallableNever()); $parser->on('error', function ($message) use (&$error) { $error = $message; }); - $connection = $this->getMockBuilder('React\Socket\Connection')->disableOriginalConstructor()->setMethods(null)->getMock(); + $connection = $this->getMockBuilder(Connection::class)->disableOriginalConstructor()->setMethods(null)->getMock(); $parser->handle($connection); - $connection->emit('data', array("GET http://example.com/ HTTP/1.1\r\nHost: \r\n\r\n")); + $connection->emit('data', ["GET http://example.com/ HTTP/1.1\r\nHost: \r\n\r\n"]); - $this->assertInstanceOf('InvalidArgumentException', $error); + $this->assertInstanceOf(\InvalidArgumentException::class, $error); $this->assertSame('Invalid Host header value', $error->getMessage()); } @@ -508,18 +547,20 @@ public function testInvalidConnectRequestWithNonAuthorityForm() { $error = null; - $parser = new RequestHeaderParser(); + $clock = $this->createMock(Clock::class); + + $parser = new RequestHeaderParser($clock); $parser->on('headers', $this->expectCallableNever()); $parser->on('error', function ($message) use (&$error) { $error = $message; }); - $connection = $this->getMockBuilder('React\Socket\Connection')->disableOriginalConstructor()->setMethods(null)->getMock(); + $connection = $this->getMockBuilder(Connection::class)->disableOriginalConstructor()->setMethods(null)->getMock(); $parser->handle($connection); - $connection->emit('data', array("CONNECT http://example.com:8080/ HTTP/1.1\r\nHost: example.com:8080\r\n\r\n")); + $connection->emit('data', ["CONNECT http://example.com:8080/ HTTP/1.1\r\nHost: example.com:8080\r\n\r\n"]); - $this->assertInstanceOf('InvalidArgumentException', $error); + $this->assertInstanceOf(\InvalidArgumentException::class, $error); $this->assertSame('CONNECT method MUST use authority-form request target', $error->getMessage()); } @@ -527,18 +568,20 @@ public function testInvalidHttpVersion() { $error = null; - $parser = new RequestHeaderParser(); + $clock = $this->createMock(Clock::class); + + $parser = new RequestHeaderParser($clock); $parser->on('headers', $this->expectCallableNever()); $parser->on('error', function ($message) use (&$error) { $error = $message; }); - $connection = $this->getMockBuilder('React\Socket\Connection')->disableOriginalConstructor()->setMethods(null)->getMock(); + $connection = $this->getMockBuilder(Connection::class)->disableOriginalConstructor()->setMethods(null)->getMock(); $parser->handle($connection); - $connection->emit('data', array("GET / HTTP/1.2\r\n\r\n")); + $connection->emit('data', ["GET / HTTP/1.2\r\n\r\n"]); - $this->assertInstanceOf('InvalidArgumentException', $error); + $this->assertInstanceOf(\InvalidArgumentException::class, $error); $this->assertSame(505, $error->getCode()); $this->assertSame('Received request with invalid protocol version', $error->getMessage()); } @@ -547,18 +590,20 @@ public function testInvalidContentLengthRequestHeaderWillEmitError() { $error = null; - $parser = new RequestHeaderParser(); + $clock = $this->createMock(Clock::class); + + $parser = new RequestHeaderParser($clock); $parser->on('headers', $this->expectCallableNever()); $parser->on('error', function ($message) use (&$error) { $error = $message; }); - $connection = $this->getMockBuilder('React\Socket\Connection')->disableOriginalConstructor()->setMethods(null)->getMock(); + $connection = $this->getMockBuilder(Connection::class)->disableOriginalConstructor()->setMethods(null)->getMock(); $parser->handle($connection); - $connection->emit('data', array("GET / HTTP/1.1\r\nContent-Length: foo\r\n\r\n")); + $connection->emit('data', ["GET / HTTP/1.1\r\nHost: localhost\r\nContent-Length: foo\r\n\r\n"]); - $this->assertInstanceOf('InvalidArgumentException', $error); + $this->assertInstanceOf(\InvalidArgumentException::class, $error); $this->assertSame(400, $error->getCode()); $this->assertSame('The value of `Content-Length` is not valid', $error->getMessage()); } @@ -567,18 +612,20 @@ public function testInvalidRequestWithMultipleContentLengthRequestHeadersWillEmi { $error = null; - $parser = new RequestHeaderParser(); + $clock = $this->createMock(Clock::class); + + $parser = new RequestHeaderParser($clock); $parser->on('headers', $this->expectCallableNever()); $parser->on('error', function ($message) use (&$error) { $error = $message; }); - $connection = $this->getMockBuilder('React\Socket\Connection')->disableOriginalConstructor()->setMethods(null)->getMock(); + $connection = $this->getMockBuilder(Connection::class)->disableOriginalConstructor()->setMethods(null)->getMock(); $parser->handle($connection); - $connection->emit('data', array("GET / HTTP/1.1\r\nContent-Length: 4\r\nContent-Length: 5\r\n\r\n")); + $connection->emit('data', ["GET / HTTP/1.1\r\nHost: localhost\r\nContent-Length: 4\r\nContent-Length: 5\r\n\r\n"]); - $this->assertInstanceOf('InvalidArgumentException', $error); + $this->assertInstanceOf(\InvalidArgumentException::class, $error); $this->assertSame(400, $error->getCode()); $this->assertSame('The value of `Content-Length` is not valid', $error->getMessage()); } @@ -587,18 +634,20 @@ public function testInvalidTransferEncodingRequestHeaderWillEmitError() { $error = null; - $parser = new RequestHeaderParser(); + $clock = $this->createMock(Clock::class); + + $parser = new RequestHeaderParser($clock); $parser->on('headers', $this->expectCallableNever()); $parser->on('error', function ($message) use (&$error) { $error = $message; }); - $connection = $this->getMockBuilder('React\Socket\Connection')->disableOriginalConstructor()->setMethods(null)->getMock(); + $connection = $this->getMockBuilder(Connection::class)->disableOriginalConstructor()->setMethods(null)->getMock(); $parser->handle($connection); - $connection->emit('data', array("GET / HTTP/1.1\r\nTransfer-Encoding: foo\r\n\r\n")); + $connection->emit('data', ["GET / HTTP/1.1\r\nHost: localhost\r\nTransfer-Encoding: foo\r\n\r\n"]); - $this->assertInstanceOf('InvalidArgumentException', $error); + $this->assertInstanceOf(\InvalidArgumentException::class, $error); $this->assertSame(501, $error->getCode()); $this->assertSame('Only chunked-encoding is allowed for Transfer-Encoding', $error->getMessage()); } @@ -607,18 +656,20 @@ public function testInvalidRequestWithBothTransferEncodingAndContentLengthWillEm { $error = null; - $parser = new RequestHeaderParser(); + $clock = $this->createMock(Clock::class); + + $parser = new RequestHeaderParser($clock); $parser->on('headers', $this->expectCallableNever()); $parser->on('error', function ($message) use (&$error) { $error = $message; }); - $connection = $this->getMockBuilder('React\Socket\Connection')->disableOriginalConstructor()->setMethods(null)->getMock(); + $connection = $this->getMockBuilder(Connection::class)->disableOriginalConstructor()->setMethods(null)->getMock(); $parser->handle($connection); - $connection->emit('data', array("GET / HTTP/1.1\r\nTransfer-Encoding: chunked\r\nContent-Length: 0\r\n\r\n")); + $connection->emit('data', ["GET / HTTP/1.1\r\nHost: localhost\r\nTransfer-Encoding: chunked\r\nContent-Length: 0\r\n\r\n"]); - $this->assertInstanceOf('InvalidArgumentException', $error); + $this->assertInstanceOf(\InvalidArgumentException::class, $error); $this->assertSame(400, $error->getCode()); $this->assertSame('Using both `Transfer-Encoding: chunked` and `Content-Length` is not allowed', $error->getMessage()); } @@ -627,24 +678,27 @@ public function testServerParamsWillBeSetOnHttpsRequest() { $request = null; - $parser = new RequestHeaderParser(); + $clock = $this->createMock(Clock::class); + $clock->expects($this->once())->method('now')->willReturn(1652972091.3958); + + $parser = new RequestHeaderParser($clock); $parser->on('headers', function ($parsedRequest) use (&$request) { $request = $parsedRequest; }); - $connection = $this->getMockBuilder('React\Socket\Connection')->disableOriginalConstructor()->setMethods(array('getLocalAddress', 'getRemoteAddress'))->getMock(); + $connection = $this->getMockBuilder(Connection::class)->disableOriginalConstructor()->setMethods(['getLocalAddress', 'getRemoteAddress'])->getMock(); $connection->expects($this->once())->method('getLocalAddress')->willReturn('tls://127.1.1.1:8000'); $connection->expects($this->once())->method('getRemoteAddress')->willReturn('tls://192.168.1.1:8001'); $parser->handle($connection); - $connection->emit('data', array("GET /foo HTTP/1.0\r\nHost: example.com\r\n\r\n")); + $connection->emit('data', ["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(1652972091, $serverParams['REQUEST_TIME']); + $this->assertEquals(1652972091.3958, $serverParams['REQUEST_TIME_FLOAT']); $this->assertEquals('127.1.1.1', $serverParams['SERVER_ADDR']); $this->assertEquals('8000', $serverParams['SERVER_PORT']); @@ -657,24 +711,27 @@ public function testServerParamsWillBeSetOnHttpRequest() { $request = null; - $parser = new RequestHeaderParser(); + $clock = $this->createMock(Clock::class); + $clock->expects($this->once())->method('now')->willReturn(1652972091.3958); + + $parser = new RequestHeaderParser($clock); $parser->on('headers', function ($parsedRequest) use (&$request) { $request = $parsedRequest; }); - $connection = $this->getMockBuilder('React\Socket\Connection')->disableOriginalConstructor()->setMethods(array('getLocalAddress', 'getRemoteAddress'))->getMock(); + $connection = $this->getMockBuilder(Connection::class)->disableOriginalConstructor()->setMethods(['getLocalAddress', 'getRemoteAddress'])->getMock(); $connection->expects($this->once())->method('getLocalAddress')->willReturn('tcp://127.1.1.1:8000'); $connection->expects($this->once())->method('getRemoteAddress')->willReturn('tcp://192.168.1.1:8001'); $parser->handle($connection); - $connection->emit('data', array("GET /foo HTTP/1.0\r\nHost: example.com\r\n\r\n")); + $connection->emit('data', ["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(1652972091, $serverParams['REQUEST_TIME']); + $this->assertEquals(1652972091.3958, $serverParams['REQUEST_TIME_FLOAT']); $this->assertEquals('127.1.1.1', $serverParams['SERVER_ADDR']); $this->assertEquals('8000', $serverParams['SERVER_PORT']); @@ -687,24 +744,27 @@ public function testServerParamsWillNotSetRemoteAddressForUnixDomainSockets() { $request = null; - $parser = new RequestHeaderParser(); + $clock = $this->createMock(Clock::class); + $clock->expects($this->once())->method('now')->willReturn(1652972091.3958); + + $parser = new RequestHeaderParser($clock); $parser->on('headers', function ($parsedRequest) use (&$request) { $request = $parsedRequest; }); - $connection = $this->getMockBuilder('React\Socket\Connection')->disableOriginalConstructor()->setMethods(array('getLocalAddress', 'getRemoteAddress'))->getMock(); + $connection = $this->getMockBuilder(Connection::class)->disableOriginalConstructor()->setMethods(['getLocalAddress', 'getRemoteAddress'])->getMock(); $connection->expects($this->once())->method('getLocalAddress')->willReturn('unix://./server.sock'); $connection->expects($this->once())->method('getRemoteAddress')->willReturn(null); $parser->handle($connection); - $connection->emit('data', array("GET /foo HTTP/1.0\r\nHost: example.com\r\n\r\n")); + $connection->emit('data', ["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(1652972091, $serverParams['REQUEST_TIME']); + $this->assertEquals(1652972091.3958, $serverParams['REQUEST_TIME_FLOAT']); $this->assertArrayNotHasKey('SERVER_ADDR', $serverParams); $this->assertArrayNotHasKey('SERVER_PORT', $serverParams); @@ -717,21 +777,24 @@ public function testServerParamsWontBeSetOnMissingUrls() { $request = null; - $parser = new RequestHeaderParser(); + $clock = $this->createMock(Clock::class); + $clock->expects($this->once())->method('now')->willReturn(1652972091.3958); + + $parser = new RequestHeaderParser($clock); $parser->on('headers', function ($parsedRequest) use (&$request) { $request = $parsedRequest; }); - $connection = $this->getMockBuilder('React\Socket\Connection')->disableOriginalConstructor()->setMethods(null)->getMock(); + $connection = $this->getMockBuilder(Connection::class)->disableOriginalConstructor()->setMethods(null)->getMock(); $parser->handle($connection); - $connection->emit('data', array("GET /foo HTTP/1.0\r\nHost: example.com\r\n\r\n")); + $connection->emit('data', ["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->assertEquals(1652972091, $serverParams['REQUEST_TIME']); + $this->assertEquals(1652972091.3958, $serverParams['REQUEST_TIME_FLOAT']); $this->assertArrayNotHasKey('SERVER_ADDR', $serverParams); $this->assertArrayNotHasKey('SERVER_PORT', $serverParams); @@ -740,20 +803,78 @@ public function testServerParamsWontBeSetOnMissingUrls() $this->assertArrayNotHasKey('REMOTE_PORT', $serverParams); } + public function testServerParamsWillBeReusedForMultipleRequestsFromSameConnection() + { + $clock = $this->createMock(Clock::class); + $clock->expects($this->exactly(2))->method('now')->willReturn(1652972091.3958); + + $parser = new RequestHeaderParser($clock); + + $connection = $this->getMockBuilder(Connection::class)->disableOriginalConstructor()->setMethods(['getLocalAddress', 'getRemoteAddress'])->getMock(); + $connection->expects($this->once())->method('getLocalAddress')->willReturn('tcp://127.1.1.1:8000'); + $connection->expects($this->once())->method('getRemoteAddress')->willReturn('tcp://192.168.1.1:8001'); + + $parser->handle($connection); + $connection->emit('data', ["GET /foo HTTP/1.0\r\nHost: example.com\r\n\r\n"]); + + $request = null; + $parser->on('headers', function ($parsedRequest) use (&$request) { + $request = $parsedRequest; + }); + + $parser->handle($connection); + $connection->emit('data', ["GET /foo HTTP/1.0\r\nHost: example.com\r\n\r\n"]); + + assert($request instanceof ServerRequestInterface); + $serverParams = $request->getServerParams(); + + $this->assertArrayNotHasKey('HTTPS', $serverParams); + $this->assertEquals(1652972091, $serverParams['REQUEST_TIME']); + $this->assertEquals(1652972091.3958, $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 testServerParamsWillBeRememberedUntilConnectionIsClosed() + { + $clock = $this->createMock(Clock::class); + + $parser = new RequestHeaderParser($clock); + + $connection = $this->getMockBuilder(Connection::class)->disableOriginalConstructor()->setMethods(['getLocalAddress', 'getRemoteAddress'])->getMock(); + + $parser->handle($connection); + $connection->emit('data', ["GET /foo HTTP/1.0\r\nHost: example.com\r\n\r\n"]); + + $ref = new \ReflectionProperty($parser, 'connectionParams'); + $ref->setAccessible(true); + + $this->assertCount(1, $ref->getValue($parser)); + + $connection->emit('close'); + $this->assertEquals([], $ref->getValue($parser)); + } + public function testQueryParmetersWillBeSet() { $request = null; - $parser = new RequestHeaderParser(); + $clock = $this->createMock(Clock::class); + + $parser = new RequestHeaderParser($clock); $parser->on('headers', function ($parsedRequest) use (&$request) { $request = $parsedRequest; }); - $connection = $this->getMockBuilder('React\Socket\Connection')->disableOriginalConstructor()->setMethods(null)->getMock(); + $connection = $this->getMockBuilder(Connection::class)->disableOriginalConstructor()->setMethods(null)->getMock(); $parser->handle($connection); - $connection->emit('data', array("GET /foo.php?hello=world&test=this HTTP/1.0\r\nHost: example.com\r\n\r\n")); + $connection->emit('data', ["GET /foo.php?hello=world&test=this HTTP/1.0\r\nHost: example.com\r\n\r\n"]); $queryParams = $request->getQueryParams(); @@ -764,7 +885,7 @@ public function testQueryParmetersWillBeSet() private function createGetRequest() { $data = "GET / HTTP/1.1\r\n"; - $data .= "Host: example.com:80\r\n"; + $data .= "Host: example.com\r\n"; $data .= "Connection: close\r\n"; $data .= "\r\n"; @@ -774,7 +895,7 @@ private function createGetRequest() private function createAdvancedPostRequest() { $data = "POST /foo?bar=baz HTTP/1.1\r\n"; - $data .= "Host: example.com:80\r\n"; + $data .= "Host: example.com\r\n"; $data .= "User-Agent: react/alpha\r\n"; $data .= "Connection: close\r\n"; $data .= "\r\n"; diff --git a/tests/Io/SenderTest.php b/tests/Io/SenderTest.php new file mode 100644 index 00000000..4154a38d --- /dev/null +++ b/tests/Io/SenderTest.php @@ -0,0 +1,391 @@ +loop = $this->createMock(LoopInterface::class); + } + + public function testCreateFromLoop() + { + $connector = $this->createMock(ConnectorInterface::class); + + $sender = Sender::createFromLoop($this->loop, $connector); + + $this->assertInstanceOf(Sender::class, $sender); + } + + public function testSenderRejectsInvalidUri() + { + $connector = $this->createMock(ConnectorInterface::class); + $connector->expects($this->never())->method('connect'); + + $sender = new Sender(new HttpClient(new ClientConnectionManager($connector, $this->loop))); + + $request = new Request('GET', 'www.google.com'); + + $promise = $sender->send($request); + + $exception = null; + $promise->then(null, function ($e) use (&$exception) { + $exception = $e; + }); + + $this->assertInstanceOf(\InvalidArgumentException::class, $exception); + } + + public function testSenderConnectorRejection() + { + $connector = $this->createMock(ConnectorInterface::class); + $connector->expects($this->once())->method('connect')->willReturn(reject(new \RuntimeException('Rejected'))); + + $sender = new Sender(new HttpClient(new ClientConnectionManager($connector, $this->loop))); + + $request = new Request('GET', 'http://www.google.com/'); + + $promise = $sender->send($request); + + $exception = null; + $promise->then(null, function ($e) use (&$exception) { + $exception = $e; + }); + + $this->assertInstanceOf(\RuntimeException::class, $exception); + } + + public function testSendPostWillAutomaticallySendContentLengthHeader() + { + $client = $this->createMock(HttpClient::class); + $client->expects($this->once())->method('request')->with($this->callback(function (RequestInterface $request) { + return $request->getHeaderLine('Content-Length') === '5'; + }))->willReturn($this->createMock(ClientRequestStream::class)); + + $sender = new Sender($client); + + $request = new Request('POST', 'http://www.google.com/', [], 'hello'); + $sender->send($request); + } + + public function testSendPostWillAutomaticallySendContentLengthZeroHeaderForEmptyRequestBody() + { + $client = $this->createMock(HttpClient::class); + $client->expects($this->once())->method('request')->with($this->callback(function (RequestInterface $request) { + return $request->getHeaderLine('Content-Length') === '0'; + }))->willReturn($this->createMock(ClientRequestStream::class)); + + $sender = new Sender($client); + + $request = new Request('POST', 'http://www.google.com/', [], ''); + $sender->send($request); + } + + public function testSendPostStreamWillAutomaticallySendTransferEncodingChunked() + { + $outgoing = $this->createMock(ClientRequestStream::class); + $outgoing->expects($this->once())->method('write')->with(""); + + $client = $this->createMock(HttpClient::class); + $client->expects($this->once())->method('request')->with($this->callback(function (RequestInterface $request) { + return $request->getHeaderLine('Transfer-Encoding') === 'chunked'; + }))->willReturn($outgoing); + + $sender = new Sender($client); + + $stream = new ThroughStream(); + $request = new Request('POST', 'http://www.google.com/', [], new ReadableBodyStream($stream)); + $sender->send($request); + } + + public function testSendPostStreamWillAutomaticallyPipeChunkEncodeBodyForWriteAndRespectRequestThrottling() + { + $outgoing = $this->createMock(ClientRequestStream::class); + $outgoing->expects($this->once())->method('isWritable')->willReturn(true); + $outgoing->expects($this->exactly(2))->method('write')->withConsecutive([""], ["5\r\nhello\r\n"])->willReturn(false); + + $client = $this->createMock(HttpClient::class); + $client->expects($this->once())->method('request')->willReturn($outgoing); + + $sender = new Sender($client); + + $stream = new ThroughStream(); + $request = new Request('POST', 'http://www.google.com/', [], new ReadableBodyStream($stream)); + $sender->send($request); + + $ret = $stream->write('hello'); + $this->assertFalse($ret); + } + + public function testSendPostStreamWillAutomaticallyPipeChunkEncodeBodyForEnd() + { + $outgoing = $this->createMock(ClientRequestStream::class); + $outgoing->expects($this->once())->method('isWritable')->willReturn(true); + $outgoing->expects($this->exactly(2))->method('write')->withConsecutive([""], ["0\r\n\r\n"])->willReturn(false); + $outgoing->expects($this->once())->method('end')->with(null); + + $client = $this->createMock(HttpClient::class); + $client->expects($this->once())->method('request')->willReturn($outgoing); + + $sender = new Sender($client); + + $stream = new ThroughStream(); + $request = new Request('POST', 'http://www.google.com/', [], new ReadableBodyStream($stream)); + $sender->send($request); + + $stream->end(); + } + + public function testSendPostStreamWillRejectWhenRequestBodyEmitsErrorEvent() + { + $outgoing = $this->createMock(ClientRequestStream::class); + $outgoing->expects($this->once())->method('isWritable')->willReturn(true); + $outgoing->expects($this->once())->method('write')->with("")->willReturn(false); + $outgoing->expects($this->never())->method('end'); + $outgoing->expects($this->once())->method('close'); + + $client = $this->createMock(HttpClient::class); + $client->expects($this->once())->method('request')->willReturn($outgoing); + + $sender = new Sender($client); + + $expected = new \RuntimeException(); + $stream = new ThroughStream(); + $request = new Request('POST', 'http://www.google.com/', [], new ReadableBodyStream($stream)); + $promise = $sender->send($request); + + $stream->emit('error', [$expected]); + + $exception = null; + $promise->then(null, function ($e) use (&$exception) { + $exception = $e; + }); + + $this->assertInstanceOf(\RuntimeException::class, $exception); + $this->assertEquals('Request failed because request body reported an error', $exception->getMessage()); + $this->assertSame($expected, $exception->getPrevious()); + } + + public function testSendPostStreamWillRejectWhenRequestBodyClosesWithoutEnd() + { + $outgoing = $this->createMock(ClientRequestStream::class); + $outgoing->expects($this->once())->method('isWritable')->willReturn(true); + $outgoing->expects($this->once())->method('write')->with("")->willReturn(false); + $outgoing->expects($this->never())->method('end'); + $outgoing->expects($this->once())->method('close'); + + $client = $this->createMock(HttpClient::class); + $client->expects($this->once())->method('request')->willReturn($outgoing); + + $sender = new Sender($client); + + $stream = new ThroughStream(); + $request = new Request('POST', 'http://www.google.com/', [], new ReadableBodyStream($stream)); + $promise = $sender->send($request); + + $stream->close(); + + $exception = null; + $promise->then(null, function ($e) use (&$exception) { + $exception = $e; + }); + + $this->assertInstanceOf(\RuntimeException::class, $exception); + $this->assertEquals('Request failed because request body closed unexpectedly', $exception->getMessage()); + } + + public function testSendPostStreamWillNotRejectWhenRequestBodyClosesAfterEnd() + { + $outgoing = $this->createMock(ClientRequestStream::class); + $outgoing->expects($this->once())->method('isWritable')->willReturn(true); + $outgoing->expects($this->exactly(2))->method('write')->withConsecutive([""], ["0\r\n\r\n"])->willReturn(false); + $outgoing->expects($this->once())->method('end'); + $outgoing->expects($this->never())->method('close'); + + $client = $this->createMock(HttpClient::class); + $client->expects($this->once())->method('request')->willReturn($outgoing); + + $sender = new Sender($client); + + $stream = new ThroughStream(); + $request = new Request('POST', 'http://www.google.com/', [], new ReadableBodyStream($stream)); + $promise = $sender->send($request); + + $stream->end(); + $stream->close(); + + $exception = null; + $promise->then(null, function ($e) use (&$exception) { + $exception = $e; + }); + + $this->assertNull($exception); + } + + public function testSendPostStreamWithExplicitContentLengthWillSendHeaderAsIs() + { + $client = $this->createMock(HttpClient::class); + $client->expects($this->once())->method('request')->with($this->callback(function (RequestInterface $request) { + return $request->getHeaderLine('Content-Length') === '100'; + }))->willReturn($this->createMock(ClientRequestStream::class)); + + $sender = new Sender($client); + + $stream = new ThroughStream(); + $request = new Request('POST', 'http://www.google.com/', ['Content-Length' => '100'], new ReadableBodyStream($stream)); + $sender->send($request); + } + + public function testSendGetWillNotPassContentLengthHeaderForEmptyRequestBody() + { + $client = $this->createMock(HttpClient::class); + $client->expects($this->once())->method('request')->with($this->callback(function (RequestInterface $request) { + return !$request->hasHeader('Content-Length'); + }))->willReturn($this->createMock(ClientRequestStream::class)); + + $sender = new Sender($client); + + $request = new Request('GET', 'http://www.google.com/'); + $sender->send($request); + } + + public function testSendGetWithEmptyBodyStreamWillNotPassContentLengthOrTransferEncodingHeader() + { + $client = $this->createMock(HttpClient::class); + $client->expects($this->once())->method('request')->with($this->callback(function (RequestInterface $request) { + return !$request->hasHeader('Content-Length') && !$request->hasHeader('Transfer-Encoding'); + }))->willReturn($this->createMock(ClientRequestStream::class)); + + $sender = new Sender($client); + + $body = new EmptyBodyStream(); + $request = new Request('GET', 'http://www.google.com/', [], $body); + + $sender->send($request); + } + + public function testSendCustomMethodWillNotPassContentLengthHeaderForEmptyRequestBody() + { + $client = $this->createMock(HttpClient::class); + $client->expects($this->once())->method('request')->with($this->callback(function (RequestInterface $request) { + return !$request->hasHeader('Content-Length'); + }))->willReturn($this->createMock(ClientRequestStream::class)); + + $sender = new Sender($client); + + $request = new Request('CUSTOM', 'http://www.google.com/'); + $sender->send($request); + } + + public function testSendCustomMethodWithExplicitContentLengthZeroWillBePassedAsIs() + { + $client = $this->createMock(HttpClient::class); + $client->expects($this->once())->method('request')->with($this->callback(function (RequestInterface $request) { + return $request->getHeaderLine('Content-Length') === '0'; + }))->willReturn($this->createMock(ClientRequestStream::class)); + + $sender = new Sender($client); + + $request = new Request('CUSTOM', 'http://www.google.com/', ['Content-Length' => '0']); + $sender->send($request); + } + + /** @test */ + public function getRequestWithUserAndPassShouldSendAGetRequestWithBasicAuthorizationHeader() + { + $client = $this->createMock(HttpClient::class); + $client->expects($this->once())->method('request')->with($this->callback(function (RequestInterface $request) { + return $request->getHeaderLine('Authorization') === 'Basic am9objpkdW1teQ=='; + }))->willReturn($this->createMock(ClientRequestStream::class)); + + $sender = new Sender($client); + + $request = new Request('GET', 'http://john:dummy@www.example.com/'); + $sender->send($request); + } + + /** @test */ + public function getRequestWithUserAndPassShouldSendAGetRequestWithGivenAuthorizationHeaderBasicAuthorizationHeader() + { + $client = $this->createMock(HttpClient::class); + $client->expects($this->once())->method('request')->with($this->callback(function (RequestInterface $request) { + return $request->getHeaderLine('Authorization') === 'bearer abc123'; + }))->willReturn($this->createMock(ClientRequestStream::class)); + + $sender = new Sender($client); + + $request = new Request('GET', 'http://john:dummy@www.example.com/', ['Authorization' => 'bearer abc123']); + $sender->send($request); + } + + public function testCancelRequestWillCancelConnector() + { + $promise = new Promise(function () { }, function () { + throw new \RuntimeException(); + }); + + $connector = $this->createMock(ConnectorInterface::class); + $connector->expects($this->once())->method('connect')->willReturn($promise); + + $sender = new Sender(new HttpClient(new ClientConnectionManager($connector, $this->loop))); + + $request = new Request('GET', 'http://www.google.com/'); + + $promise = $sender->send($request); + $promise->cancel(); + + $exception = null; + $promise->then(null, function ($e) use (&$exception) { + $exception = $e; + }); + + $this->assertInstanceOf(\RuntimeException::class, $exception); + } + + public function testCancelRequestWillCloseConnection() + { + $connection = $this->createMock(ConnectionInterface::class); + $connection->expects($this->once())->method('close'); + + $connector = $this->createMock(ConnectorInterface::class); + $connector->expects($this->once())->method('connect')->willReturn(resolve($connection)); + + $sender = new Sender(new HttpClient(new ClientConnectionManager($connector, $this->loop))); + + $request = new Request('GET', 'http://www.google.com/'); + + $promise = $sender->send($request); + $promise->cancel(); + + $exception = null; + $promise->then(null, function ($e) use (&$exception) { + $exception = $e; + }); + + $this->assertInstanceOf(\RuntimeException::class, $exception); + } +} diff --git a/tests/Io/ServerRequestTest.php b/tests/Io/ServerRequestTest.php deleted file mode 100644 index 7a7b241a..00000000 --- a/tests/Io/ServerRequestTest.php +++ /dev/null @@ -1,263 +0,0 @@ -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->assertNull($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 testGetQueryParamsFromConstructorUri() - { - $this->request = new ServerRequest('GET', 'http://localhost/?test=world'); - - $this->assertEquals(array('test' => 'world'), $this->request->getQueryParams()); - } - - public function testWithCookieParams() - { - $request = $this->request->withCookieParams(array('test' => 'world')); - - $this->assertNotSame($request, $this->request); - $this->assertEquals(array('test' => 'world'), $request->getCookieParams()); - } - - public function testGetQueryParamsFromConstructorUriUrlencoded() - { - $this->request = new ServerRequest('GET', 'http://localhost/?test=hello+world%21'); - - $this->assertEquals(array('test' => 'hello world!'), $this->request->getQueryParams()); - } - - public function testWithQueryParams() - { - $request = $this->request->withQueryParams(array('test' => 'world')); - - $this->assertNotSame($request, $this->request); - $this->assertEquals(array('test' => 'world'), $request->getQueryParams()); - } - - public function testWithQueryParamsWithoutSpecialEncoding() - { - $request = $this->request->withQueryParams(array('test' => 'hello world!')); - - $this->assertNotSame($request, $this->request); - $this->assertEquals(array('test' => 'hello 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() - { - $this->request = new ServerRequest( - 'GET', - 'http://localhost/', - array('Cookie' => 'hello=world') - ); - - $cookies = $this->request->getCookieParams(); - $this->assertEquals(array('hello' => 'world'), $cookies); - } - - public function testParseMultipleCookieNameValuePairWillReturnValidArray() - { - $this->request = new ServerRequest( - 'GET', - 'http://localhost/', - array('Cookie' => 'hello=world; test=abc') - ); - - $cookies = $this->request->getCookieParams(); - $this->assertEquals(array('hello' => 'world', 'test' => 'abc'), $cookies); - } - - public function testParseMultipleCookieHeadersAreNotAllowedAndWillReturnEmptyArray() - { - $this->request = new ServerRequest( - 'GET', - 'http://localhost/', - array('Cookie' => array('hello=world', 'test=abc')) - ); - - $cookies = $this->request->getCookieParams(); - $this->assertEquals(array(), $cookies); - } - - public function testMultipleCookiesWithSameNameWillReturnLastValue() - { - $this->request = new ServerRequest( - 'GET', - 'http://localhost/', - array('Cookie' => 'hello=world; hello=abc') - ); - - $cookies = $this->request->getCookieParams(); - $this->assertEquals(array('hello' => 'abc'), $cookies); - } - - public function testOtherEqualSignsWillBeAddedToValueAndWillReturnValidArray() - { - $this->request = new ServerRequest( - 'GET', - 'http://localhost/', - array('Cookie' => 'hello=world=test=php') - ); - - $cookies = $this->request->getCookieParams(); - $this->assertEquals(array('hello' => 'world=test=php'), $cookies); - } - - public function testSingleCookieValueInCookiesReturnsEmptyArray() - { - $this->request = new ServerRequest( - 'GET', - 'http://localhost/', - array('Cookie' => 'world') - ); - - $cookies = $this->request->getCookieParams(); - $this->assertEquals(array(), $cookies); - } - - public function testSingleMutlipleCookieValuesReturnsEmptyArray() - { - $this->request = new ServerRequest( - 'GET', - 'http://localhost/', - array('Cookie' => 'world; test') - ); - - $cookies = $this->request->getCookieParams(); - $this->assertEquals(array(), $cookies); - } - - public function testSingleValueIsValidInMultipleValueCookieWillReturnValidArray() - { - $this->request = new ServerRequest( - 'GET', - 'http://localhost/', - array('Cookie' => 'world; test=php') - ); - - $cookies = $this->request->getCookieParams(); - $this->assertEquals(array('test' => 'php'), $cookies); - } - - public function testUrlEncodingForValueWillReturnValidArray() - { - $this->request = new ServerRequest( - 'GET', - 'http://localhost/', - array('Cookie' => 'hello=world%21; test=100%25%20coverage') - ); - - $cookies = $this->request->getCookieParams(); - $this->assertEquals(array('hello' => 'world!', 'test' => '100% coverage'), $cookies); - } - - public function testUrlEncodingForKeyWillReturnValidArray() - { - $this->request = new ServerRequest( - 'GET', - 'http://localhost/', - array('Cookie' => 'react%3Bphp=is%20great') - ); - - $cookies = $this->request->getCookieParams(); - $this->assertEquals(array('react;php' => 'is great'), $cookies); - } - - public function testCookieWithoutSpaceAfterSeparatorWillBeAccepted() - { - $this->request = new ServerRequest( - 'GET', - 'http://localhost/', - array('Cookie' => 'hello=world;react=php') - ); - - $cookies = $this->request->getCookieParams(); - $this->assertEquals(array('hello' => 'world', 'react' => 'php'), $cookies); - } -} diff --git a/tests/Io/StreamingServerTest.php b/tests/Io/StreamingServerTest.php new file mode 100644 index 00000000..803ff45c --- /dev/null +++ b/tests/Io/StreamingServerTest.php @@ -0,0 +1,3267 @@ +connection = $this->mockConnection(); + + $this->socket = new SocketServerStub(); + } + + + private function mockConnection(array $additionalMethods = []) + { + $connection = $this->getMockBuilder(Connection::class) + ->disableOriginalConstructor() + ->setMethods(array_merge( + [ + 'write', + 'end', + 'close', + 'pause', + 'resume', + 'isReadable', + 'isWritable', + 'getRemoteAddress', + 'getLocalAddress', + 'pipe' + ], + $additionalMethods + )) + ->getMock(); + + $connection->method('isWritable')->willReturn(true); + $connection->method('isReadable')->willReturn(true); + + return $connection; + } + + public function testRequestEventWillNotBeEmittedForIncompleteHeaders() + { + $server = new StreamingServer(Loop::get(), $this->expectCallableNever()); + + $server->listen($this->socket); + $this->socket->emit('connection', [$this->connection]); + + $data = ''; + $data .= "GET / HTTP/1.1\r\n"; + $this->connection->emit('data', [$data]); + } + + public function testRequestEventIsEmitted() + { + $server = new StreamingServer(Loop::get(), $this->expectCallableOnce()); + + $server->listen($this->socket); + $this->socket->emit('connection', [$this->connection]); + + $data = $this->createGetRequest(); + $this->connection->emit('data', [$data]); + } + + public function testRequestEventIsEmittedForArrayCallable() + { + $this->called = null; + $server = new StreamingServer(Loop::get(), [$this, 'helperCallableOnce']); + + $server->listen($this->socket); + $this->socket->emit('connection', [$this->connection]); + + $data = $this->createGetRequest(); + $this->connection->emit('data', [$data]); + + $this->assertEquals(1, $this->called); + } + + public function helperCallableOnce() + { + ++$this->called; + } + + public function testRequestEvent() + { + $i = 0; + $requestAssertion = null; + $server = new StreamingServer(Loop::get(), function (ServerRequestInterface $request) use (&$i, &$requestAssertion) { + $i++; + $requestAssertion = $request; + }); + + $this->connection + ->expects($this->any()) + ->method('getRemoteAddress') + ->willReturn('127.0.0.1:8080'); + + $server->listen($this->socket); + $this->socket->emit('connection', [$this->connection]); + + $data = $this->createGetRequest(); + $this->connection->emit('data', [$data]); + + $serverParams = $requestAssertion->getServerParams(); + + $this->assertSame(1, $i); + $this->assertInstanceOf(ServerRequestInterface::class, $requestAssertion); + $this->assertSame('GET', $requestAssertion->getMethod()); + $this->assertSame('/', $requestAssertion->getRequestTarget()); + $this->assertSame('/', $requestAssertion->getUri()->getPath()); + $this->assertSame([], $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 testRequestEventWithSingleRequestHandlerArray() + { + $i = 0; + $requestAssertion = null; + $server = new StreamingServer(Loop::get(), function (ServerRequestInterface $request) use (&$i, &$requestAssertion) { + $i++; + $requestAssertion = $request; + }); + + $this->connection + ->expects($this->any()) + ->method('getRemoteAddress') + ->willReturn('127.0.0.1:8080'); + + $server->listen($this->socket); + $this->socket->emit('connection', [$this->connection]); + + $data = $this->createGetRequest(); + $this->connection->emit('data', [$data]); + + $serverParams = $requestAssertion->getServerParams(); + + $this->assertSame(1, $i); + $this->assertInstanceOf(ServerRequestInterface::class, $requestAssertion); + $this->assertSame('GET', $requestAssertion->getMethod()); + $this->assertSame('/', $requestAssertion->getRequestTarget()); + $this->assertSame('/', $requestAssertion->getUri()->getPath()); + $this->assertSame([], $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 StreamingServer(Loop::get(), function (ServerRequestInterface $request) use (&$requestAssertion) { + $requestAssertion = $request; + }); + + $server->listen($this->socket); + $this->socket->emit('connection', [$this->connection]); + + $data = "GET / HTTP/1.1\r\nHost: example.com:8080\r\n\r\n"; + $this->connection->emit('data', [$data]); + + $this->assertInstanceOf(ServerRequestInterface::class, $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 StreamingServer(Loop::get(), function (ServerRequestInterface $request) use (&$requestAssertion) { + $requestAssertion = $request; + }); + + $server->listen($this->socket); + $this->socket->emit('connection', [$this->connection]); + + $data = "GET / HTTP/1.1\r\nHost: example.com:443\r\n\r\n"; + $this->connection->emit('data', [$data]); + + $this->assertInstanceOf(ServerRequestInterface::class, $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 StreamingServer(Loop::get(), function (ServerRequestInterface $request) use (&$requestAssertion) { + $requestAssertion = $request; + }); + + $server->listen($this->socket); + $this->socket->emit('connection', [$this->connection]); + + $data = "GET / HTTP/1.1\r\nHost: example.com\r\n\r\n"; + $this->connection->emit('data', [$data]); + + $this->assertInstanceOf(ServerRequestInterface::class, $requestAssertion); + $this->assertSame('GET', $requestAssertion->getMethod()); + $this->assertSame('/', $requestAssertion->getRequestTarget()); + $this->assertSame('/', $requestAssertion->getUri()->getPath()); + $this->assertSame('http://example.com/', (string)$requestAssertion->getUri()); + $this->assertNull($requestAssertion->getUri()->getPort()); + $this->assertSame('example.com', $requestAssertion->getHeaderLine('Host')); + } + + public function testRequestGetHttp10WithoutHostWillBeIgnored() + { + $requestAssertion = null; + $server = new StreamingServer(Loop::get(), function (ServerRequestInterface $request) use (&$requestAssertion) { + $requestAssertion = $request; + }); + + $server->listen($this->socket); + $this->socket->emit('connection', [$this->connection]); + + $data = "GET / HTTP/1.0\r\n\r\n"; + $this->connection->emit('data', [$data]); + + $this->assertInstanceOf(ServerRequestInterface::class, $requestAssertion); + $this->assertSame('GET', $requestAssertion->getMethod()); + $this->assertSame('/', $requestAssertion->getRequestTarget()); + $this->assertSame('/', $requestAssertion->getUri()->getPath()); + $this->assertSame('http://127.0.0.1/', (string)$requestAssertion->getUri()); + $this->assertNull($requestAssertion->getUri()->getPort()); + $this->assertEquals('1.0', $requestAssertion->getProtocolVersion()); + $this->assertSame('', $requestAssertion->getHeaderLine('Host')); + } + + public function testRequestGetHttp11WithoutHostWillReject() + { + $server = new StreamingServer(Loop::get(), 'var_dump'); + $server->on('error', $this->expectCallableOnce()); + + $server->listen($this->socket); + $this->socket->emit('connection', [$this->connection]); + + $data = "GET / HTTP/1.1\r\n\r\n"; + $this->connection->emit('data', [$data]); + } + + public function testRequestOptionsAsterisk() + { + $requestAssertion = null; + $server = new StreamingServer(Loop::get(), function (ServerRequestInterface $request) use (&$requestAssertion) { + $requestAssertion = $request; + }); + + $server->listen($this->socket); + $this->socket->emit('connection', [$this->connection]); + + $data = "OPTIONS * HTTP/1.1\r\nHost: example.com\r\n\r\n"; + $this->connection->emit('data', [$data]); + + $this->assertInstanceOf(ServerRequestInterface::class, $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 StreamingServer(Loop::get(), $this->expectCallableNever()); + $server->on('error', $this->expectCallableOnce()); + + $server->listen($this->socket); + $this->socket->emit('connection', [$this->connection]); + + $data = "GET * HTTP/1.1\r\nHost: example.com\r\n\r\n"; + $this->connection->emit('data', [$data]); + } + + public function testRequestConnectAuthorityForm() + { + $requestAssertion = null; + $server = new StreamingServer(Loop::get(), function (ServerRequestInterface $request) use (&$requestAssertion) { + $requestAssertion = $request; + }); + + $server->listen($this->socket); + $this->socket->emit('connection', [$this->connection]); + + $data = "CONNECT example.com:443 HTTP/1.1\r\nHost: example.com:443\r\n\r\n"; + $this->connection->emit('data', [$data]); + + $this->assertInstanceOf(ServerRequestInterface::class, $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 testRequestConnectWithoutHostWillBePassesAsIs() + { + $requestAssertion = null; + $server = new StreamingServer(Loop::get(), function (ServerRequestInterface $request) use (&$requestAssertion) { + $requestAssertion = $request; + }); + + $server->listen($this->socket); + $this->socket->emit('connection', [$this->connection]); + + $data = "CONNECT example.com:443 HTTP/1.1\r\n\r\n"; + $this->connection->emit('data', [$data]); + + $this->assertInstanceOf(ServerRequestInterface::class, $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->assertFalse($requestAssertion->hasHeader('Host')); + } + + public function testRequestConnectAuthorityFormWithDefaultPortWillBePassedAsIs() + { + $requestAssertion = null; + $server = new StreamingServer(Loop::get(), function (ServerRequestInterface $request) use (&$requestAssertion) { + $requestAssertion = $request; + }); + + $server->listen($this->socket); + $this->socket->emit('connection', [$this->connection]); + + $data = "CONNECT example.com:80 HTTP/1.1\r\nHost: example.com:80\r\n\r\n"; + $this->connection->emit('data', [$data]); + + $this->assertInstanceOf(ServerRequestInterface::class, $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->assertNull($requestAssertion->getUri()->getPort()); + $this->assertSame('example.com:80', $requestAssertion->getHeaderLine('Host')); + } + + public function testRequestConnectAuthorityFormNonMatchingHostWillBePassedAsIs() + { + $requestAssertion = null; + $server = new StreamingServer(Loop::get(), function (ServerRequestInterface $request) use (&$requestAssertion) { + $requestAssertion = $request; + }); + + $server->listen($this->socket); + $this->socket->emit('connection', [$this->connection]); + + $data = "CONNECT example.com:80 HTTP/1.1\r\nHost: other.example.org\r\n\r\n"; + $this->connection->emit('data', [$data]); + + $this->assertInstanceOf(ServerRequestInterface::class, $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->assertNull($requestAssertion->getUri()->getPort()); + $this->assertSame('other.example.org', $requestAssertion->getHeaderLine('Host')); + } + + public function testRequestConnectOriginFormRequestTargetWillReject() + { + $server = new StreamingServer(Loop::get(), $this->expectCallableNever()); + $server->on('error', $this->expectCallableOnce()); + + $server->listen($this->socket); + $this->socket->emit('connection', [$this->connection]); + + $data = "CONNECT / HTTP/1.1\r\nHost: example.com\r\n\r\n"; + $this->connection->emit('data', [$data]); + } + + public function testRequestNonConnectWithAuthorityRequestTargetWillReject() + { + $server = new StreamingServer(Loop::get(), $this->expectCallableNever()); + $server->on('error', $this->expectCallableOnce()); + + $server->listen($this->socket); + $this->socket->emit('connection', [$this->connection]); + + $data = "GET example.com:80 HTTP/1.1\r\nHost: example.com\r\n\r\n"; + $this->connection->emit('data', [$data]); + } + + public function testRequestWithoutHostEventUsesSocketAddress() + { + $requestAssertion = null; + + $server = new StreamingServer(Loop::get(), function (ServerRequestInterface $request) use (&$requestAssertion) { + $requestAssertion = $request; + }); + + $this->connection + ->expects($this->any()) + ->method('getLocalAddress') + ->willReturn('127.0.0.1:80'); + + $server->listen($this->socket); + $this->socket->emit('connection', [$this->connection]); + + $data = "GET /test HTTP/1.0\r\n\r\n"; + $this->connection->emit('data', [$data]); + + $this->assertInstanceOf(ServerRequestInterface::class, $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 StreamingServer(Loop::get(), function (ServerRequestInterface $request) use (&$requestAssertion) { + $requestAssertion = $request; + }); + + $server->listen($this->socket); + $this->socket->emit('connection', [$this->connection]); + + $data = "GET http://example.com/test HTTP/1.1\r\nHost: example.com\r\n\r\n"; + $this->connection->emit('data', [$data]); + + $this->assertInstanceOf(ServerRequestInterface::class, $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 testRequestAbsoluteNonMatchingHostWillBePassedAsIs() + { + $requestAssertion = null; + + $server = new StreamingServer(Loop::get(), function (ServerRequestInterface $request) use (&$requestAssertion) { + $requestAssertion = $request; + }); + + $server->listen($this->socket); + $this->socket->emit('connection', [$this->connection]); + + $data = "GET http://example.com/test HTTP/1.1\r\nHost: other.example.org\r\n\r\n"; + $this->connection->emit('data', [$data]); + + $this->assertInstanceOf(ServerRequestInterface::class, $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('other.example.org', $requestAssertion->getHeaderLine('Host')); + } + + public function testRequestAbsoluteWithoutHostWillReject() + { + $server = new StreamingServer(Loop::get(), $this->expectCallableNever()); + $server->on('error', $this->expectCallableOnce()); + + $server->listen($this->socket); + $this->socket->emit('connection', [$this->connection]); + + $data = "GET http://example.com:8080/test HTTP/1.1\r\n\r\n"; + $this->connection->emit('data', [$data]); + } + + public function testRequestOptionsAsteriskEvent() + { + $requestAssertion = null; + + $server = new StreamingServer(Loop::get(), function (ServerRequestInterface $request) use (&$requestAssertion) { + $requestAssertion = $request; + }); + + $server->listen($this->socket); + $this->socket->emit('connection', [$this->connection]); + + $data = "OPTIONS * HTTP/1.1\r\nHost: example.com\r\n\r\n"; + $this->connection->emit('data', [$data]); + + $this->assertInstanceOf(ServerRequestInterface::class, $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 StreamingServer(Loop::get(), function (ServerRequestInterface $request) use (&$requestAssertion) { + $requestAssertion = $request; + }); + + $server->listen($this->socket); + $this->socket->emit('connection', [$this->connection]); + + $data = "OPTIONS http://example.com HTTP/1.1\r\nHost: example.com\r\n\r\n"; + $this->connection->emit('data', [$data]); + + $this->assertInstanceOf(ServerRequestInterface::class, $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 StreamingServer(Loop::get(), function (ServerRequestInterface $request) { + $request->getBody()->pause(); + }); + + $this->connection->expects($this->once())->method('pause'); + + $server->listen($this->socket); + $this->socket->emit('connection', [$this->connection]); + + $data = "GET / HTTP/1.1\r\n"; + $data .= "Host: example.com\r\n"; + $data .= "Connection: close\r\n"; + $data .= "Content-Length: 5\r\n"; + $data .= "\r\n"; + + $this->connection->emit('data', [$data]); + } + + public function testRequestResumeWillBeForwardedToConnection() + { + $server = new StreamingServer(Loop::get(), function (ServerRequestInterface $request) { + $request->getBody()->resume(); + }); + + $this->connection->expects($this->once())->method('resume'); + + $server->listen($this->socket); + $this->socket->emit('connection', [$this->connection]); + + $data = "GET / HTTP/1.1\r\n"; + $data .= "Host: example.com\r\n"; + $data .= "Connection: close\r\n"; + $data .= "Content-Length: 5\r\n"; + $data .= "\r\n"; + + $this->connection->emit('data', [$data]); + } + + public function testRequestCloseWillNotCloseConnection() + { + $server = new StreamingServer(Loop::get(), function (ServerRequestInterface $request) { + $request->getBody()->close(); + }); + + $this->connection->expects($this->never())->method('close'); + + $server->listen($this->socket); + $this->socket->emit('connection', [$this->connection]); + + $data = $this->createGetRequest(); + $this->connection->emit('data', [$data]); + } + + public function testRequestPauseAfterCloseWillNotBeForwarded() + { + $server = new StreamingServer(Loop::get(), function (ServerRequestInterface $request) { + $request->getBody()->close(); + $request->getBody()->pause(); + }); + + $this->connection->expects($this->never())->method('close'); + $this->connection->expects($this->never())->method('pause'); + + $server->listen($this->socket); + $this->socket->emit('connection', [$this->connection]); + + $data = $this->createGetRequest(); + $this->connection->emit('data', [$data]); + } + + public function testRequestResumeAfterCloseWillNotBeForwarded() + { + $server = new StreamingServer(Loop::get(), function (ServerRequestInterface $request) { + $request->getBody()->close(); + $request->getBody()->resume(); + }); + + $this->connection->expects($this->never())->method('close'); + $this->connection->expects($this->never())->method('resume'); + + $server->listen($this->socket); + $this->socket->emit('connection', [$this->connection]); + + $data = $this->createGetRequest(); + $this->connection->emit('data', [$data]); + } + + public function testRequestEventWithoutBodyWillNotEmitData() + { + $never = $this->expectCallableNever(); + + $server = new StreamingServer(Loop::get(), function (ServerRequestInterface $request) use ($never) { + $request->getBody()->on('data', $never); + }); + + $server->listen($this->socket); + $this->socket->emit('connection', [$this->connection]); + + $data = $this->createGetRequest(); + $this->connection->emit('data', [$data]); + } + + public function testRequestEventWithSecondDataEventWillEmitBodyData() + { + $once = $this->expectCallableOnceWith('incomplete'); + + $server = new StreamingServer(Loop::get(), function (ServerRequestInterface $request) use ($once) { + $request->getBody()->on('data', $once); + }); + + $server->listen($this->socket); + $this->socket->emit('connection', [$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', [$data]); + } + + public function testRequestEventWithPartialBodyWillEmitData() + { + $once = $this->expectCallableOnceWith('incomplete'); + + $server = new StreamingServer(Loop::get(), function (ServerRequestInterface $request) use ($once) { + $request->getBody()->on('data', $once); + }); + + $server->listen($this->socket); + $this->socket->emit('connection', [$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', [$data]); + + $data = ''; + $data .= "incomplete"; + $this->connection->emit('data', [$data]); + } + + public function testResponseContainsServerHeader() + { + $server = new StreamingServer(Loop::get(), function (ServerRequestInterface $request) { + return new Response(); + }); + + $buffer = ''; + + $this->connection + ->expects($this->any()) + ->method('write') + ->willReturnCallback( + function ($data) use (&$buffer) { + $buffer .= $data; + } + ); + + $server->listen($this->socket); + $this->socket->emit('connection', [$this->connection]); + + $data = $this->createGetRequest(); + $this->connection->emit('data', [$data]); + + $this->assertStringContainsString("\r\nServer: ReactPHP/1\r\n", $buffer); + } + + public function testResponsePendingPromiseWillNotSendAnything() + { + $never = $this->expectCallableNever(); + + $server = new StreamingServer(Loop::get(), function (ServerRequestInterface $request) use ($never) { + return new Promise(function () { }, $never); + }); + + $buffer = ''; + + $this->connection + ->expects($this->any()) + ->method('write') + ->willReturnCallback( + function ($data) use (&$buffer) { + $buffer .= $data; + } + ); + + $server->listen($this->socket); + $this->socket->emit('connection', [$this->connection]); + + $data = $this->createGetRequest(); + $this->connection->emit('data', [$data]); + + $this->assertEquals('', $buffer); + } + + public function testResponsePendingPromiseWillBeCancelledIfConnectionCloses() + { + $once = $this->expectCallableOnce(); + + $server = new StreamingServer(Loop::get(), function (ServerRequestInterface $request) use ($once) { + return new Promise(function () { }, $once); + }); + + $buffer = ''; + + $this->connection + ->expects($this->any()) + ->method('write') + ->willReturnCallback( + function ($data) use (&$buffer) { + $buffer .= $data; + } + ); + + $server->listen($this->socket); + $this->socket->emit('connection', [$this->connection]); + + $data = $this->createGetRequest(); + $this->connection->emit('data', [$data]); + $this->connection->emit('close'); + + $this->assertEquals('', $buffer); + } + + public function testResponseBodyStreamAlreadyClosedWillSendEmptyBodyChunkedEncoded() + { + $stream = new ThroughStream(); + $stream->close(); + + $server = new StreamingServer(Loop::get(), function (ServerRequestInterface $request) use ($stream) { + return new Response( + 200, + [], + $stream + ); + }); + + $buffer = ''; + + $this->connection + ->expects($this->any()) + ->method('write') + ->willReturnCallback( + function ($data) use (&$buffer) { + $buffer .= $data; + } + ); + + $server->listen($this->socket); + $this->socket->emit('connection', [$this->connection]); + + $data = "GET / HTTP/1.1\r\nHost: localhost\r\n\r\n"; + $this->connection->emit('data', [$data]); + + $this->assertStringStartsWith("HTTP/1.1 200 OK\r\n", $buffer); + $this->assertStringEndsWith("\r\n\r\n0\r\n\r\n", $buffer); + } + + public function testResponseBodyStreamEndingWillSendEmptyBodyChunkedEncoded() + { + $stream = new ThroughStream(); + + $server = new StreamingServer(Loop::get(), function (ServerRequestInterface $request) use ($stream) { + return new Response( + 200, + [], + $stream + ); + }); + + $buffer = ''; + + $this->connection + ->expects($this->any()) + ->method('write') + ->willReturnCallback( + function ($data) use (&$buffer) { + $buffer .= $data; + } + ); + + $server->listen($this->socket); + $this->socket->emit('connection', [$this->connection]); + + $data = "GET / HTTP/1.1\r\nHost: localhost\r\n\r\n"; + $this->connection->emit('data', [$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 testResponseBodyStreamAlreadyClosedWillSendEmptyBodyPlainHttp10() + { + $stream = new ThroughStream(); + $stream->close(); + + $server = new StreamingServer(Loop::get(), function (ServerRequestInterface $request) use ($stream) { + return new Response( + 200, + [], + $stream + ); + }); + + $buffer = ''; + + $this->connection + ->expects($this->any()) + ->method('write') + ->willReturnCallback( + function ($data) use (&$buffer) { + $buffer .= $data; + } + ); + + $server->listen($this->socket); + $this->socket->emit('connection', [$this->connection]); + + $data = "GET / HTTP/1.0\r\nHost: localhost\r\n\r\n"; + $this->connection->emit('data', [$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 StreamingServer(Loop::get(), function (ServerRequestInterface $request) use ($stream) { + return new Response( + 200, + [], + $stream + ); + }); + + $buffer = ''; + + $this->connection + ->expects($this->any()) + ->method('write') + ->willReturnCallback( + function ($data) use (&$buffer) { + $buffer .= $data; + } + ); + + $this->connection = $this->getMockBuilder(Connection::class) + ->disableOriginalConstructor() + ->setMethods( + [ + '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', [$this->connection]); + + $data = $this->createGetRequest(); + $this->connection->emit('data', [$data]); + } + + public function testResponseBodyStreamWillBeClosedIfConnectionEmitsCloseEvent() + { + $stream = new ThroughStream(); + $stream->on('close', $this->expectCallableOnce()); + + $server = new StreamingServer(Loop::get(), function (ServerRequestInterface $request) use ($stream) { + return new Response( + 200, + [], + $stream + ); + }); + + $server->listen($this->socket); + $this->socket->emit('connection', [$this->connection]); + + $data = $this->createGetRequest(); + $this->connection->emit('data', [$data]); + $this->connection->emit('close'); + } + + public function testResponseUpgradeInResponseCanBeUsedToAdvertisePossibleUpgrade() + { + $server = new StreamingServer(Loop::get(), function (ServerRequestInterface $request) { + return new Response( + 200, + [ + 'date' => '', + 'server' => '', + 'Upgrade' => 'demo' + ], + 'foo' + ); + }); + + $buffer = ''; + + $this->connection + ->expects($this->any()) + ->method('write') + ->willReturnCallback( + function ($data) use (&$buffer) { + $buffer .= $data; + } + ); + + $server->listen($this->socket); + $this->socket->emit('connection', [$this->connection]); + + $data = "GET / HTTP/1.1\r\nHost: localhost\r\n\r\n"; + $this->connection->emit('data', [$data]); + + $this->assertEquals("HTTP/1.1 200 OK\r\nUpgrade: demo\r\nContent-Length: 3\r\n\r\nfoo", $buffer); + } + + public function testResponseUpgradeWishInRequestCanBeIgnoredByReturningNormalResponse() + { + $server = new StreamingServer(Loop::get(), function (ServerRequestInterface $request) { + return new Response( + 200, + [ + 'date' => '', + 'server' => '' + ], + 'foo' + ); + }); + + $buffer = ''; + + $this->connection + ->expects($this->any()) + ->method('write') + ->willReturnCallback( + function ($data) use (&$buffer) { + $buffer .= $data; + } + ); + + $server->listen($this->socket); + $this->socket->emit('connection', [$this->connection]); + + $data = "GET / HTTP/1.1\r\nHost: localhost\r\nUpgrade: demo\r\n\r\n"; + $this->connection->emit('data', [$data]); + + $this->assertEquals("HTTP/1.1 200 OK\r\nContent-Length: 3\r\n\r\nfoo", $buffer); + } + + public function testResponseUpgradeSwitchingProtocolIncludesConnectionUpgradeHeaderWithoutContentLength() + { + $server = new StreamingServer(Loop::get(), function (ServerRequestInterface $request) { + return new Response( + 101, + [ + 'date' => '', + 'server' => '', + 'Upgrade' => 'demo' + ], + 'foo' + ); + }); + + $server->on('error', 'printf'); + + $buffer = ''; + + $this->connection + ->expects($this->any()) + ->method('write') + ->willReturnCallback( + function ($data) use (&$buffer) { + $buffer .= $data; + } + ); + + $server->listen($this->socket); + $this->socket->emit('connection', [$this->connection]); + + $data = "GET / HTTP/1.1\r\nHost: localhost\r\nUpgrade: demo\r\n\r\n"; + $this->connection->emit('data', [$data]); + + $this->assertEquals("HTTP/1.1 101 Switching Protocols\r\nUpgrade: demo\r\nConnection: upgrade\r\n\r\nfoo", $buffer); + } + + public function testResponseUpgradeSwitchingProtocolWithStreamWillPipeDataToConnection() + { + $stream = new ThroughStream(); + + $server = new StreamingServer(Loop::get(), function (ServerRequestInterface $request) use ($stream) { + return new Response( + 101, + [ + 'date' => '', + 'server' => '', + 'Upgrade' => 'demo' + ], + $stream + ); + }); + + $buffer = ''; + + $this->connection + ->expects($this->any()) + ->method('write') + ->willReturnCallback( + function ($data) use (&$buffer) { + $buffer .= $data; + } + ); + + $server->listen($this->socket); + $this->socket->emit('connection', [$this->connection]); + + $data = "GET / HTTP/1.1\r\nHost: localhost\r\nUpgrade: demo\r\n\r\n"; + $this->connection->emit('data', [$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 testResponseConnectMethodStreamWillPipeDataToConnection() + { + $stream = new ThroughStream(); + + $server = new StreamingServer(Loop::get(), function (ServerRequestInterface $request) use ($stream) { + return new Response( + 200, + [], + $stream + ); + }); + + $buffer = ''; + + $this->connection + ->expects($this->any()) + ->method('write') + ->willReturnCallback( + function ($data) use (&$buffer) { + $buffer .= $data; + } + ); + + $server->listen($this->socket); + $this->socket->emit('connection', [$this->connection]); + + $data = "CONNECT example.com:80 HTTP/1.1\r\nHost: example.com:80\r\n\r\n"; + $this->connection->emit('data', [$data]); + + $stream->write('hello'); + $stream->write('world'); + + $this->assertStringEndsWith("\r\n\r\nhelloworld", $buffer); + } + + + public function testResponseConnectMethodStreamWillPipeDataFromConnection() + { + $stream = new ThroughStream(); + + $server = new StreamingServer(Loop::get(), function (ServerRequestInterface $request) use ($stream) { + return new Response( + 200, + [], + $stream + ); + }); + + $server->listen($this->socket); + $this->socket->emit('connection', [$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', [$data]); + } + + public function testResponseContainsSameRequestProtocolVersionAndChunkedBodyForHttp11() + { + $server = new StreamingServer(Loop::get(), function (ServerRequestInterface $request) { + return new Response( + 200, + [], + 'bye' + ); + }); + + $buffer = ''; + + $this->connection + ->expects($this->any()) + ->method('write') + ->willReturnCallback( + function ($data) use (&$buffer) { + $buffer .= $data; + } + ); + + $server->listen($this->socket); + $this->socket->emit('connection', [$this->connection]); + + $data = "GET / HTTP/1.1\r\nHost: localhost\r\n\r\n"; + $this->connection->emit('data', [$data]); + + $this->assertStringContainsString("HTTP/1.1 200 OK\r\n", $buffer); + $this->assertStringContainsString("bye", $buffer); + } + + public function testResponseContainsSameRequestProtocolVersionAndRawBodyForHttp10() + { + $server = new StreamingServer(Loop::get(), function (ServerRequestInterface $request) { + return new Response( + 200, + [], + 'bye' + ); + }); + + $buffer = ''; + + $this->connection + ->expects($this->any()) + ->method('write') + ->willReturnCallback( + function ($data) use (&$buffer) { + $buffer .= $data; + } + ); + + $server->listen($this->socket); + $this->socket->emit('connection', [$this->connection]); + + $data = "GET / HTTP/1.0\r\n\r\n"; + $this->connection->emit('data', [$data]); + + $this->assertStringContainsString("HTTP/1.0 200 OK\r\n", $buffer); + $this->assertStringContainsString("\r\n\r\n", $buffer); + $this->assertStringContainsString("bye", $buffer); + } + + public function testResponseContainsNoResponseBodyForHeadRequest() + { + $server = new StreamingServer(Loop::get(), function (ServerRequestInterface $request) { + return new Response( + 200, + [], + 'bye' + ); + }); + + $buffer = ''; + $this->connection + ->expects($this->any()) + ->method('write') + ->willReturnCallback( + function ($data) use (&$buffer) { + $buffer .= $data; + } + ); + + $server->listen($this->socket); + $this->socket->emit('connection', [$this->connection]); + + $data = "HEAD / HTTP/1.1\r\nHost: localhost\r\n\r\n"; + $this->connection->emit('data', [$data]); + + $this->assertStringContainsString("HTTP/1.1 200 OK\r\n", $buffer); + $this->assertStringContainsString("\r\nContent-Length: 3\r\n", $buffer); + $this->assertStringNotContainsString("bye", $buffer); + } + + public function testResponseContainsNoResponseBodyForHeadRequestWithStreamingResponse() + { + $stream = new ThroughStream(); + $stream->on('close', $this->expectCallableOnce()); + + $server = new StreamingServer(Loop::get(), function (ServerRequestInterface $request) use ($stream) { + return new Response( + 200, + ['Content-Length' => '3'], + $stream + ); + }); + + $buffer = ''; + $this->connection + ->expects($this->any()) + ->method('write') + ->willReturnCallback( + function ($data) use (&$buffer) { + $buffer .= $data; + } + ); + + $server->listen($this->socket); + $this->socket->emit('connection', [$this->connection]); + + $data = "HEAD / HTTP/1.1\r\nHost: localhost\r\n\r\n"; + $this->connection->emit('data', [$data]); + + $this->assertStringContainsString("HTTP/1.1 200 OK\r\n", $buffer); + $this->assertStringContainsString("\r\nContent-Length: 3\r\n", $buffer); + } + + public function testResponseContainsNoResponseBodyAndNoContentLengthForNoContentStatus() + { + $server = new StreamingServer(Loop::get(), function (ServerRequestInterface $request) { + return new Response( + 204, + [], + 'bye' + ); + }); + + $buffer = ''; + $this->connection + ->expects($this->any()) + ->method('write') + ->willReturnCallback( + function ($data) use (&$buffer) { + $buffer .= $data; + } + ); + + $server->listen($this->socket); + $this->socket->emit('connection', [$this->connection]); + + $data = "GET / HTTP/1.1\r\nHost: localhost\r\n\r\n"; + $this->connection->emit('data', [$data]); + + $this->assertStringContainsString("HTTP/1.1 204 No Content\r\n", $buffer); + $this->assertStringNotContainsString("\r\nContent-Length: 3\r\n", $buffer); + $this->assertStringNotContainsString("bye", $buffer); + } + + public function testResponseContainsNoResponseBodyAndNoContentLengthForNoContentStatusResponseWithStreamingBody() + { + $stream = new ThroughStream(); + $stream->on('close', $this->expectCallableOnce()); + + $server = new StreamingServer(Loop::get(), function (ServerRequestInterface $request) use ($stream) { + return new Response( + 204, + ['Content-Length' => '3'], + $stream + ); + }); + + $buffer = ''; + $this->connection + ->expects($this->any()) + ->method('write') + ->willReturnCallback( + function ($data) use (&$buffer) { + $buffer .= $data; + } + ); + + $server->listen($this->socket); + $this->socket->emit('connection', [$this->connection]); + + $data = "HEAD / HTTP/1.1\r\nHost: localhost\r\n\r\n"; + $this->connection->emit('data', [$data]); + + $this->assertStringContainsString("HTTP/1.1 204 No Content\r\n", $buffer); + $this->assertStringNotContainsString("\r\nContent-Length: 3\r\n", $buffer); + } + + public function testResponseContainsNoContentLengthHeaderForNotModifiedStatus() + { + $server = new StreamingServer(Loop::get(), function (ServerRequestInterface $request) { + return new Response( + 304, + [], + '' + ); + }); + + $buffer = ''; + $this->connection + ->expects($this->any()) + ->method('write') + ->willReturnCallback( + function ($data) use (&$buffer) { + $buffer .= $data; + } + ); + + $server->listen($this->socket); + $this->socket->emit('connection', [$this->connection]); + + $data = "GET / HTTP/1.1\r\nHost: localhost\r\n\r\n"; + $this->connection->emit('data', [$data]); + + $this->assertStringContainsString("HTTP/1.1 304 Not Modified\r\n", $buffer); + $this->assertStringNotContainsString("\r\nContent-Length: 0\r\n", $buffer); + } + + public function testResponseContainsExplicitContentLengthHeaderForNotModifiedStatus() + { + $server = new StreamingServer(Loop::get(), function (ServerRequestInterface $request) { + return new Response( + 304, + ['Content-Length' => 3], + '' + ); + }); + + $buffer = ''; + $this->connection + ->expects($this->any()) + ->method('write') + ->willReturnCallback( + function ($data) use (&$buffer) { + $buffer .= $data; + } + ); + + $server->listen($this->socket); + $this->socket->emit('connection', [$this->connection]); + + $data = "GET / HTTP/1.1\r\nHost: localhost\r\n\r\n"; + $this->connection->emit('data', [$data]); + + $this->assertStringContainsString("HTTP/1.1 304 Not Modified\r\n", $buffer); + $this->assertStringContainsString("\r\nContent-Length: 3\r\n", $buffer); + } + + public function testResponseContainsExplicitContentLengthHeaderForHeadRequests() + { + $server = new StreamingServer(Loop::get(), function (ServerRequestInterface $request) { + return new Response( + 200, + ['Content-Length' => 3], + '' + ); + }); + + $buffer = ''; + $this->connection + ->expects($this->any()) + ->method('write') + ->willReturnCallback( + function ($data) use (&$buffer) { + $buffer .= $data; + } + ); + + $server->listen($this->socket); + $this->socket->emit('connection', [$this->connection]); + + $data = "HEAD / HTTP/1.1\r\nHost: localhost\r\n\r\n"; + $this->connection->emit('data', [$data]); + + $this->assertStringContainsString("HTTP/1.1 200 OK\r\n", $buffer); + $this->assertStringContainsString("\r\nContent-Length: 3\r\n", $buffer); + } + + public function testResponseContainsNoResponseBodyForNotModifiedStatus() + { + $server = new StreamingServer(Loop::get(), function (ServerRequestInterface $request) { + return new Response( + 304, + [], + 'bye' + ); + }); + + $buffer = ''; + $this->connection + ->expects($this->any()) + ->method('write') + ->willReturnCallback( + function ($data) use (&$buffer) { + $buffer .= $data; + } + ); + + $server->listen($this->socket); + $this->socket->emit('connection', [$this->connection]); + + $data = "GET / HTTP/1.1\r\nHost: localhost\r\n\r\n"; + $this->connection->emit('data', [$data]); + + $this->assertStringContainsString("HTTP/1.1 304 Not Modified\r\n", $buffer); + $this->assertStringContainsString("\r\nContent-Length: 3\r\n", $buffer); + $this->assertStringNotContainsString("bye", $buffer); + } + + public function testResponseContainsNoResponseBodyForNotModifiedStatusWithStreamingBody() + { + $stream = new ThroughStream(); + $stream->on('close', $this->expectCallableOnce()); + + $server = new StreamingServer(Loop::get(), function (ServerRequestInterface $request) use ($stream) { + return new Response( + 304, + ['Content-Length' => '3'], + $stream + ); + }); + + $buffer = ''; + $this->connection + ->expects($this->any()) + ->method('write') + ->willReturnCallback( + function ($data) use (&$buffer) { + $buffer .= $data; + } + ); + + $server->listen($this->socket); + $this->socket->emit('connection', [$this->connection]); + + $data = "GET / HTTP/1.1\r\nHost: localhost\r\n\r\n"; + $this->connection->emit('data', [$data]); + + $this->assertStringContainsString("HTTP/1.1 304 Not Modified\r\n", $buffer); + $this->assertStringContainsString("\r\nContent-Length: 3\r\n", $buffer); + } + + public function testRequestInvalidHttpProtocolVersionWillEmitErrorAndSendErrorResponse() + { + $error = null; + $server = new StreamingServer(Loop::get(), $this->expectCallableNever()); + $server->on('error', function ($message) use (&$error) { + $error = $message; + }); + + $buffer = ''; + + $this->connection + ->expects($this->any()) + ->method('write') + ->willReturnCallback( + function ($data) use (&$buffer) { + $buffer .= $data; + } + ); + + $server->listen($this->socket); + $this->socket->emit('connection', [$this->connection]); + + $data = "GET / HTTP/1.2\r\nHost: localhost\r\n\r\n"; + $this->connection->emit('data', [$data]); + + $this->assertInstanceOf(\InvalidArgumentException::class, $error); + + $this->assertStringContainsString("HTTP/1.1 505 HTTP Version Not Supported\r\n", $buffer); + $this->assertStringContainsString("\r\n\r\n", $buffer); + $this->assertStringContainsString("Error 505: HTTP Version Not Supported", $buffer); + } + + public function testRequestOverflowWillEmitErrorAndSendErrorResponse() + { + $error = null; + $server = new StreamingServer(Loop::get(), $this->expectCallableNever()); + $server->on('error', function ($message) use (&$error) { + $error = $message; + }); + + $buffer = ''; + + $this->connection + ->expects($this->any()) + ->method('write') + ->willReturnCallback( + function ($data) use (&$buffer) { + $buffer .= $data; + } + ); + + $server->listen($this->socket); + $this->socket->emit('connection', [$this->connection]); + + $data = "GET / HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\nX-DATA: "; + $data .= str_repeat('A', 8193 - strlen($data)) . "\r\n\r\n"; + $this->connection->emit('data', [$data]); + + $this->assertInstanceOf(\OverflowException::class, $error); + + $this->assertStringContainsString("HTTP/1.1 431 Request Header Fields Too Large\r\n", $buffer); + $this->assertStringContainsString("\r\n\r\nError 431: Request Header Fields Too Large", $buffer); + } + + public function testRequestInvalidWillEmitErrorAndSendErrorResponse() + { + $error = null; + $server = new StreamingServer(Loop::get(), $this->expectCallableNever()); + $server->on('error', function ($message) use (&$error) { + $error = $message; + }); + + $buffer = ''; + + $this->connection + ->expects($this->any()) + ->method('write') + ->willReturnCallback( + function ($data) use (&$buffer) { + $buffer .= $data; + } + ); + + $server->listen($this->socket); + $this->socket->emit('connection', [$this->connection]); + + $data = "bad request\r\n\r\n"; + $this->connection->emit('data', [$data]); + + $this->assertInstanceOf(\InvalidArgumentException::class, $error); + + $this->assertStringContainsString("HTTP/1.1 400 Bad Request\r\n", $buffer); + $this->assertStringContainsString("\r\n\r\nError 400: Bad Request", $buffer); + } + + public function testRequestContentLengthBodyDataWillEmitDataEventOnRequestStream() + { + $dataEvent = $this->expectCallableOnceWith('hello'); + $endEvent = $this->expectCallableOnce(); + $closeEvent = $this->expectCallableOnce(); + $errorEvent = $this->expectCallableNever(); + + $server = new StreamingServer(Loop::get(), 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); + }); + + $server->listen($this->socket); + $this->socket->emit('connection', [$this->connection]); + + $data = "GET / HTTP/1.1\r\n"; + $data .= "Host: example.com\r\n"; + $data .= "Connection: close\r\n"; + $data .= "Content-Length: 5\r\n"; + $data .= "\r\n"; + $data .= "hello"; + + $this->connection->emit('data', [$data]); + } + + public function testRequestChunkedTransferEncodingRequestWillEmitDecodedDataEventOnRequestStream() + { + $dataEvent = $this->expectCallableOnceWith('hello'); + $endEvent = $this->expectCallableOnce(); + $closeEvent = $this->expectCallableOnce(); + $errorEvent = $this->expectCallableNever(); + $requestValidation = null; + + $server = new StreamingServer(Loop::get(), 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; + }); + + $server->listen($this->socket); + $this->socket->emit('connection', [$this->connection]); + + $data = "GET / HTTP/1.1\r\n"; + $data .= "Host: example.com\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', [$data]); + + $this->assertEquals('chunked', $requestValidation->getHeaderLine('Transfer-Encoding')); + } + + public function testRequestChunkedTransferEncodingWithAdditionalDataWontBeEmitted() + { + $dataEvent = $this->expectCallableOnceWith('hello'); + $endEvent = $this->expectCallableOnce(); + $closeEvent = $this->expectCallableOnce(); + $errorEvent = $this->expectCallableNever(); + + $server = new StreamingServer(Loop::get(), 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); + }); + + $server->listen($this->socket); + $this->socket->emit('connection', [$this->connection]); + + $data = "GET / HTTP/1.1\r\n"; + $data .= "Host: example.com\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', [$data]); + } + + public function testRequestChunkedTransferEncodingEmpty() + { + $dataEvent = $this->expectCallableNever(); + $endEvent = $this->expectCallableOnce(); + $closeEvent = $this->expectCallableOnce(); + $errorEvent = $this->expectCallableNever(); + + $server = new StreamingServer(Loop::get(), 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); + }); + + $server->listen($this->socket); + $this->socket->emit('connection', [$this->connection]); + + $data = "GET / HTTP/1.1\r\n"; + $data .= "Host: example.com\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', [$data]); + } + + public function testRequestChunkedTransferEncodingHeaderCanBeUpperCase() + { + $dataEvent = $this->expectCallableOnceWith('hello'); + $endEvent = $this->expectCallableOnce(); + $closeEvent = $this->expectCallableOnce(); + $errorEvent = $this->expectCallableNever(); + $requestValidation = null; + + $server = new StreamingServer(Loop::get(), 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; + }); + + $server->listen($this->socket); + $this->socket->emit('connection', [$this->connection]); + + $data = "GET / HTTP/1.1\r\n"; + $data .= "Host: example.com\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', [$data]); + $this->assertEquals('CHUNKED', $requestValidation->getHeaderLine('Transfer-Encoding')); + } + + public function testRequestChunkedTransferEncodingCanBeMixedUpperAndLowerCase() + { + $dataEvent = $this->expectCallableOnceWith('hello'); + $endEvent = $this->expectCallableOnce(); + $closeEvent = $this->expectCallableOnce(); + $errorEvent = $this->expectCallableNever(); + + $server = new StreamingServer(Loop::get(), 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); + }); + + $server->listen($this->socket); + $this->socket->emit('connection', [$this->connection]); + + $data = "GET / HTTP/1.1\r\n"; + $data .= "Host: example.com\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', [$data]); + } + + public function testRequestContentLengthWillEmitDataEventAndEndEventAndAdditionalDataWillBeIgnored() + { + $dataEvent = $this->expectCallableOnceWith('hello'); + $endEvent = $this->expectCallableOnce(); + $closeEvent = $this->expectCallableOnce(); + $errorEvent = $this->expectCallableNever(); + + $server = new StreamingServer(Loop::get(), 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 resolve(new Response()); + }); + + $server->listen($this->socket); + $this->socket->emit('connection', [$this->connection]); + + $data = "GET / HTTP/1.1\r\n"; + $data .= "Host: example.com\r\n"; + $data .= "Connection: close\r\n"; + $data .= "Content-Length: 5\r\n"; + $data .= "\r\n"; + $data .= "hello"; + $data .= "world"; + + $this->connection->emit('data', [$data]); + } + + public function testRequestContentLengthWillEmitDataEventAndEndEventAndAdditionalDataWillBeIgnoredSplitted() + { + $dataEvent = $this->expectCallableOnceWith('hello'); + $endEvent = $this->expectCallableOnce(); + $closeEvent = $this->expectCallableOnce(); + $errorEvent = $this->expectCallableNever(); + + + $server = new StreamingServer(Loop::get(), 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); + }); + + $server->listen($this->socket); + $this->socket->emit('connection', [$this->connection]); + + $data = "GET / HTTP/1.1\r\n"; + $data .= "Host: example.com\r\n"; + $data .= "Connection: close\r\n"; + $data .= "Content-Length: 5\r\n"; + $data .= "\r\n"; + $data .= "hello"; + + $this->connection->emit('data', [$data]); + + $data = "world"; + + $this->connection->emit('data', [$data]); + } + + public function testRequestZeroContentLengthWillEmitEndEvent() + { + + $dataEvent = $this->expectCallableNever(); + $endEvent = $this->expectCallableOnce(); + $closeEvent = $this->expectCallableOnce(); + $errorEvent = $this->expectCallableNever(); + + $server = new StreamingServer(Loop::get(), 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); + }); + + $server->listen($this->socket); + $this->socket->emit('connection', [$this->connection]); + + $data = "GET / HTTP/1.1\r\n"; + $data .= "Host: example.com\r\n"; + $data .= "Connection: close\r\n"; + $data .= "Content-Length: 0\r\n"; + $data .= "\r\n"; + + $this->connection->emit('data', [$data]); + } + + public function testRequestZeroContentLengthWillEmitEndAndAdditionalDataWillBeIgnored() + { + $dataEvent = $this->expectCallableNever(); + $endEvent = $this->expectCallableOnce(); + $closeEvent = $this->expectCallableOnce(); + $errorEvent = $this->expectCallableNever(); + + $server = new StreamingServer(Loop::get(), 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); + }); + + $server->listen($this->socket); + $this->socket->emit('connection', [$this->connection]); + + $data = "GET / HTTP/1.1\r\n"; + $data .= "Host: example.com\r\n"; + $data .= "Connection: close\r\n"; + $data .= "Content-Length: 0\r\n"; + $data .= "\r\n"; + $data .= "hello"; + + $this->connection->emit('data', [$data]); + } + + public function testRequestZeroContentLengthWillEmitEndAndAdditionalDataWillBeIgnoredSplitted() + { + $dataEvent = $this->expectCallableNever(); + $endEvent = $this->expectCallableOnce(); + $closeEvent = $this->expectCallableOnce(); + $errorEvent = $this->expectCallableNever(); + + $server = new StreamingServer(Loop::get(), 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); + }); + + $server->listen($this->socket); + $this->socket->emit('connection', [$this->connection]); + + $data = "GET / HTTP/1.1\r\n"; + $data .= "Host: example.com\r\n"; + $data .= "Connection: close\r\n"; + $data .= "Content-Length: 0\r\n"; + $data .= "\r\n"; + + $this->connection->emit('data', [$data]); + + $data = "hello"; + + $this->connection->emit('data', [$data]); + } + + public function testRequestInvalidChunkHeaderTooLongWillEmitErrorOnRequestStream() + { + $errorEvent = $this->expectCallableOnceWith($this->isInstanceOf(\Exception::class)); + $server = new StreamingServer(Loop::get(), function ($request) use ($errorEvent){ + $request->getBody()->on('error', $errorEvent); + return resolve(new Response()); + }); + + $this->connection->expects($this->never())->method('close'); + + $server->listen($this->socket); + $this->socket->emit('connection', [$this->connection]); + + $data = "GET / HTTP/1.1\r\n"; + $data .= "Host: example.com\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', [$data]); + } + + public function testRequestInvalidChunkBodyTooLongWillEmitErrorOnRequestStream() + { + $errorEvent = $this->expectCallableOnceWith($this->isInstanceOf(\Exception::class)); + $server = new StreamingServer(Loop::get(), function ($request) use ($errorEvent){ + $request->getBody()->on('error', $errorEvent); + }); + + $this->connection->expects($this->never())->method('close'); + + $server->listen($this->socket); + $this->socket->emit('connection', [$this->connection]); + + $data = "GET / HTTP/1.1\r\n"; + $data .= "Host: example.com\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', [$data]); + } + + public function testRequestUnexpectedEndOfRequestWithChunkedTransferConnectionWillEmitErrorOnRequestStream() + { + $errorEvent = $this->expectCallableOnceWith($this->isInstanceOf(\Exception::class)); + $server = new StreamingServer(Loop::get(), function ($request) use ($errorEvent){ + $request->getBody()->on('error', $errorEvent); + }); + + $this->connection->expects($this->never())->method('close'); + + $server->listen($this->socket); + $this->socket->emit('connection', [$this->connection]); + + $data = "GET / HTTP/1.1\r\n"; + $data .= "Host: example.com\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', [$data]); + $this->connection->emit('end'); + } + + public function testRequestInvalidChunkHeaderWillEmitErrorOnRequestStream() + { + $errorEvent = $this->expectCallableOnceWith($this->isInstanceOf(\Exception::class)); + $server = new StreamingServer(Loop::get(), function ($request) use ($errorEvent){ + $request->getBody()->on('error', $errorEvent); + }); + + $this->connection->expects($this->never())->method('close'); + + $server->listen($this->socket); + $this->socket->emit('connection', [$this->connection]); + + $data = "GET / HTTP/1.1\r\n"; + $data .= "Host: example.com\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', [$data]); + } + + public function testRequestUnexpectedEndOfRequestWithContentLengthWillEmitErrorOnRequestStream() + { + $errorEvent = $this->expectCallableOnceWith($this->isInstanceOf(\Exception::class)); + $server = new StreamingServer(Loop::get(), function ($request) use ($errorEvent){ + $request->getBody()->on('error', $errorEvent); + }); + + $this->connection->expects($this->never())->method('close'); + + $server->listen($this->socket); + $this->socket->emit('connection', [$this->connection]); + + $data = "GET / HTTP/1.1\r\n"; + $data .= "Host: example.com\r\n"; + $data .= "Connection: close\r\n"; + $data .= "Content-Length: 500\r\n"; + $data .= "\r\n"; + $data .= "incomplete"; + + $this->connection->emit('data', [$data]); + $this->connection->emit('end'); + } + + public function testRequestWithoutBodyWillEmitEndOnRequestStream() + { + $dataEvent = $this->expectCallableNever(); + $closeEvent = $this->expectCallableOnce(); + $endEvent = $this->expectCallableOnce(); + $errorEvent = $this->expectCallableNever(); + + $server = new StreamingServer(Loop::get(), 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); + }); + + $this->connection->expects($this->never())->method('close'); + + $server->listen($this->socket); + $this->socket->emit('connection', [$this->connection]); + + $data = $this->createGetRequest(); + + $this->connection->emit('data', [$data]); + } + + public function testRequestWithoutDefinedLengthWillIgnoreDataEvent() + { + $dataEvent = $this->expectCallableNever(); + $endEvent = $this->expectCallableOnce(); + $closeEvent = $this->expectCallableOnce(); + $errorEvent = $this->expectCallableNever(); + + $server = new StreamingServer(Loop::get(), 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); + }); + + $server->listen($this->socket); + $this->socket->emit('connection', [$this->connection]); + + $data = $this->createGetRequest(); + $data .= "hello world"; + + $this->connection->emit('data', [$data]); + } + + public function testResponseWithBodyStreamWillUseChunkedTransferEncodingByDefault() + { + $stream = new ThroughStream(); + $server = new StreamingServer(Loop::get(), function (ServerRequestInterface $request) use ($stream) { + return new Response( + 200, + [], + $stream + ); + }); + + $buffer = ''; + $this->connection + ->expects($this->any()) + ->method('write') + ->willReturnCallback( + function ($data) use (&$buffer) { + $buffer .= $data; + } + ); + + $server->listen($this->socket); + $this->socket->emit('connection', [$this->connection]); + + $data = $this->createGetRequest(); + + $this->connection->emit('data', [$data]); + $stream->emit('data', ['hello']); + + $this->assertStringContainsString("Transfer-Encoding: chunked", $buffer); + $this->assertStringContainsString("hello", $buffer); + } + + public function testResponseWithBodyStringWillOverwriteExplicitContentLengthAndTransferEncoding() + { + $server = new StreamingServer(Loop::get(), function (ServerRequestInterface $request) { + return new Response( + 200, + [ + 'Content-Length' => 1000, + 'Transfer-Encoding' => 'chunked' + ], + 'hello' + ); + }); + + $buffer = ''; + $this->connection + ->expects($this->any()) + ->method('write') + ->willReturnCallback( + function ($data) use (&$buffer) { + $buffer .= $data; + } + ); + + $server->listen($this->socket); + $this->socket->emit('connection', [$this->connection]); + + $data = $this->createGetRequest(); + + $this->connection->emit('data', [$data]); + + $this->assertStringNotContainsString("Transfer-Encoding: chunked", $buffer); + $this->assertStringContainsString("Content-Length: 5", $buffer); + $this->assertStringContainsString("hello", $buffer); + } + + public function testResponseContainsResponseBodyWithTransferEncodingChunkedForBodyWithUnknownSize() + { + $body = $this->createMock(StreamInterface::class); + $body->expects($this->once())->method('getSize')->willReturn(null); + $body->expects($this->once())->method('__toString')->willReturn('body'); + + $server = new StreamingServer(Loop::get(), function (ServerRequestInterface $request) use ($body) { + return new Response( + 200, + [], + $body + ); + }); + + $buffer = ''; + $this->connection + ->expects($this->any()) + ->method('write') + ->willReturnCallback( + function ($data) use (&$buffer) { + $buffer .= $data; + } + ); + + $server->listen($this->socket); + $this->socket->emit('connection', [$this->connection]); + + $data = "GET / HTTP/1.1\r\nHost: localhost\r\n\r\n"; + $this->connection->emit('data', [$data]); + + $this->assertStringContainsString("Transfer-Encoding: chunked", $buffer); + $this->assertStringNotContainsString("Content-Length:", $buffer); + $this->assertStringContainsString("body", $buffer); + } + + public function testResponseContainsResponseBodyWithPlainBodyWithUnknownSizeForLegacyHttp10() + { + $body = $this->createMock(StreamInterface::class); + $body->expects($this->once())->method('getSize')->willReturn(null); + $body->expects($this->once())->method('__toString')->willReturn('body'); + + $server = new StreamingServer(Loop::get(), function (ServerRequestInterface $request) use ($body) { + return new Response( + 200, + [], + $body + ); + }); + + $buffer = ''; + $this->connection + ->expects($this->any()) + ->method('write') + ->willReturnCallback( + function ($data) use (&$buffer) { + $buffer .= $data; + } + ); + + $server->listen($this->socket); + $this->socket->emit('connection', [$this->connection]); + + $data = "GET / HTTP/1.0\r\nHost: localhost\r\n\r\n"; + $this->connection->emit('data', [$data]); + + $this->assertStringNotContainsString("Transfer-Encoding: chunked", $buffer); + $this->assertStringNotContainsString("Content-Length:", $buffer); + $this->assertStringContainsString("body", $buffer); + } + + public function testResponseWithCustomTransferEncodingWillBeIgnoredAndUseChunkedTransferEncodingInstead() + { + $stream = new ThroughStream(); + $server = new StreamingServer(Loop::get(), function (ServerRequestInterface $request) use ($stream) { + return new Response( + 200, + [ + 'Transfer-Encoding' => 'custom' + ], + $stream + ); + }); + + $buffer = ''; + $this->connection + ->expects($this->any()) + ->method('write') + ->willReturnCallback( + function ($data) use (&$buffer) { + $buffer .= $data; + } + ); + + $server->listen($this->socket); + $this->socket->emit('connection', [$this->connection]); + + $data = $this->createGetRequest(); + + $this->connection->emit('data', [$data]); + $stream->emit('data', ['hello']); + + $this->assertStringContainsString('Transfer-Encoding: chunked', $buffer); + $this->assertStringNotContainsString('Transfer-Encoding: custom', $buffer); + $this->assertStringContainsString("5\r\nhello\r\n", $buffer); + } + + public function testResponseWithoutExplicitDateHeaderWillAddCurrentDateFromClock() + { + $server = new StreamingServer(Loop::get(), function (ServerRequestInterface $request) { + return new Response(); + }); + + $ref = new \ReflectionProperty($server, 'clock'); + $ref->setAccessible(true); + $clock = $ref->getValue($server); + + $ref = new \ReflectionProperty($clock, 'now'); + $ref->setAccessible(true); + $ref->setValue($clock, 1652972091.3958); + + $buffer = ''; + $this->connection + ->expects($this->any()) + ->method('write') + ->willReturnCallback( + function ($data) use (&$buffer) { + $buffer .= $data; + } + ); + + $server->listen($this->socket); + $this->socket->emit('connection', [$this->connection]); + + $data = $this->createGetRequest(); + + $this->connection->emit('data', [$data]); + + $this->assertStringContainsString("HTTP/1.1 200 OK\r\n", $buffer); + $this->assertStringContainsString("Date: Thu, 19 May 2022 14:54:51 GMT\r\n", $buffer); + $this->assertStringContainsString("\r\n\r\n", $buffer); + } + + public function testResponseWithCustomDateHeaderOverwritesDefault() + { + $server = new StreamingServer(Loop::get(), function (ServerRequestInterface $request) { + return new Response( + 200, + ["Date" => "Tue, 15 Nov 1994 08:12:31 GMT"] + ); + }); + + $buffer = ''; + $this->connection + ->expects($this->any()) + ->method('write') + ->willReturnCallback( + function ($data) use (&$buffer) { + $buffer .= $data; + } + ); + + $server->listen($this->socket); + $this->socket->emit('connection', [$this->connection]); + + $data = $this->createGetRequest(); + + $this->connection->emit('data', [$data]); + + $this->assertStringContainsString("HTTP/1.1 200 OK\r\n", $buffer); + $this->assertStringContainsString("Date: Tue, 15 Nov 1994 08:12:31 GMT\r\n", $buffer); + $this->assertStringContainsString("\r\n\r\n", $buffer); + } + + public function testResponseWithEmptyDateHeaderRemovesDateHeader() + { + $server = new StreamingServer(Loop::get(), function (ServerRequestInterface $request) { + return new Response( + 200, + ['Date' => ''] + ); + }); + + $buffer = ''; + $this->connection + ->expects($this->any()) + ->method('write') + ->willReturnCallback( + function ($data) use (&$buffer) { + $buffer .= $data; + } + ); + + $server->listen($this->socket); + $this->socket->emit('connection', [$this->connection]); + + $data = $this->createGetRequest(); + + $this->connection->emit('data', [$data]); + + $this->assertStringContainsString("HTTP/1.1 200 OK\r\n", $buffer); + $this->assertStringNotContainsString("Date:", $buffer); + $this->assertStringContainsString("\r\n\r\n", $buffer); + } + + public function testResponseCanContainMultipleCookieHeaders() + { + $server = new StreamingServer(Loop::get(), function (ServerRequestInterface $request) { + return new Response( + 200, + [ + 'Set-Cookie' => [ + 'name=test', + 'session=abc' + ], + 'Date' => '', + 'Server' => '' + ] + ); + }); + + $buffer = ''; + $this->connection + ->expects($this->any()) + ->method('write') + ->willReturnCallback( + function ($data) use (&$buffer) { + $buffer .= $data; + } + ); + + $server->listen($this->socket); + $this->socket->emit('connection', [$this->connection]); + + $data = $this->createGetRequest(); + + $this->connection->emit('data', [$data]); + + $this->assertEquals("HTTP/1.1 200 OK\r\nSet-Cookie: name=test\r\nSet-Cookie: session=abc\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", $buffer); + } + + public function testReponseWithExpectContinueRequestContainsContinueWithLaterResponse() + { + $server = new StreamingServer(Loop::get(), function (ServerRequestInterface $request) { + return new Response(); + }); + + $buffer = ''; + $this->connection + ->expects($this->any()) + ->method('write') + ->willReturnCallback( + function ($data) use (&$buffer) { + $buffer .= $data; + } + ); + + $server->listen($this->socket); + $this->socket->emit('connection', [$this->connection]); + + $data = "GET / HTTP/1.1\r\n"; + $data .= "Host: example.com\r\n"; + $data .= "Connection: close\r\n"; + $data .= "Expect: 100-continue\r\n"; + $data .= "\r\n"; + + $this->connection->emit('data', [$data]); + $this->assertStringContainsString("HTTP/1.1 100 Continue\r\n", $buffer); + $this->assertStringContainsString("HTTP/1.1 200 OK\r\n", $buffer); + } + + public function testResponseWithExpectContinueRequestWontSendContinueForHttp10() + { + $server = new StreamingServer(Loop::get(), function (ServerRequestInterface $request) { + return new Response(); + }); + + $buffer = ''; + $this->connection + ->expects($this->any()) + ->method('write') + ->willReturnCallback( + function ($data) use (&$buffer) { + $buffer .= $data; + } + ); + + $server->listen($this->socket); + $this->socket->emit('connection', [$this->connection]); + + $data = "GET / HTTP/1.0\r\n"; + $data .= "Expect: 100-continue\r\n"; + $data .= "\r\n"; + + $this->connection->emit('data', [$data]); + $this->assertStringContainsString("HTTP/1.0 200 OK\r\n", $buffer); + $this->assertStringNotContainsString("HTTP/1.1 100 Continue\r\n\r\n", $buffer); + } + + public function testInvalidCallbackFunctionLeadsToException() + { + $this->expectException(\InvalidArgumentException::class); + new StreamingServer(Loop::get(), 'invalid'); + } + + public function testResponseBodyStreamWillStreamDataWithChunkedTransferEncoding() + { + $input = new ThroughStream(); + + $server = new StreamingServer(Loop::get(), function (ServerRequestInterface $request) use ($input) { + return new Response( + 200, + [], + $input + ); + }); + + $buffer = ''; + $this->connection + ->expects($this->any()) + ->method('write') + ->willReturnCallback( + function ($data) use (&$buffer) { + $buffer .= $data; + } + ); + + $server->listen($this->socket); + $this->socket->emit('connection', [$this->connection]); + + $data = $this->createGetRequest(); + + $this->connection->emit('data', [$data]); + $input->emit('data', ['1']); + $input->emit('data', ['23']); + + $this->assertStringContainsString("HTTP/1.1 200 OK\r\n", $buffer); + $this->assertStringContainsString("\r\n\r\n", $buffer); + $this->assertStringContainsString("1\r\n1\r\n", $buffer); + $this->assertStringContainsString("2\r\n23\r\n", $buffer); + } + + public function testResponseBodyStreamWithContentLengthWillStreamTillLengthWithoutTransferEncoding() + { + $input = new ThroughStream(); + + $server = new StreamingServer(Loop::get(), function (ServerRequestInterface $request) use ($input) { + return new Response( + 200, + ['Content-Length' => 5], + $input + ); + }); + + $buffer = ''; + $this->connection + ->expects($this->any()) + ->method('write') + ->willReturnCallback( + function ($data) use (&$buffer) { + $buffer .= $data; + } + ); + + $server->listen($this->socket); + $this->socket->emit('connection', [$this->connection]); + + $data = $this->createGetRequest(); + + $this->connection->emit('data', [$data]); + $input->emit('data', ['hel']); + $input->emit('data', ['lo']); + + $this->assertStringContainsString("HTTP/1.1 200 OK\r\n", $buffer); + $this->assertStringContainsString("Content-Length: 5\r\n", $buffer); + $this->assertStringNotContainsString("Transfer-Encoding", $buffer); + $this->assertStringContainsString("\r\n\r\n", $buffer); + $this->assertStringContainsString("hello", $buffer); + } + + public function testResponseWithResponsePromise() + { + $server = new StreamingServer(Loop::get(), function (ServerRequestInterface $request) { + return resolve(new Response()); + }); + + $buffer = ''; + $this->connection + ->expects($this->any()) + ->method('write') + ->willReturnCallback( + function ($data) use (&$buffer) { + $buffer .= $data; + } + ); + + $server->listen($this->socket); + $this->socket->emit('connection', [$this->connection]); + + $data = $this->createGetRequest(); + + $this->connection->emit('data', [$data]); + $this->assertStringContainsString("HTTP/1.1 200 OK\r\n", $buffer); + $this->assertStringContainsString("\r\n\r\n", $buffer); + } + + public function testResponseReturnInvalidTypeWillResultInError() + { + $server = new StreamingServer(Loop::get(), 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') + ->willReturnCallback( + function ($data) use (&$buffer) { + $buffer .= $data; + } + ); + + $server->listen($this->socket); + $this->socket->emit('connection', [$this->connection]); + + $data = $this->createGetRequest(); + + $this->connection->emit('data', [$data]); + + $this->assertStringContainsString("HTTP/1.1 500 Internal Server Error\r\n", $buffer); + $this->assertInstanceOf(\RuntimeException::class, $exception); + } + + public function testResponseResolveWrongTypeInPromiseWillResultInError() + { + $server = new StreamingServer(Loop::get(), function (ServerRequestInterface $request) { + return resolve("invalid"); + }); + + $buffer = ''; + $this->connection + ->expects($this->any()) + ->method('write') + ->willReturnCallback( + function ($data) use (&$buffer) { + $buffer .= $data; + } + ); + + $server->listen($this->socket); + $this->socket->emit('connection', [$this->connection]); + + $data = $this->createGetRequest(); + + $this->connection->emit('data', [$data]); + + $this->assertStringContainsString("HTTP/1.1 500 Internal Server Error\r\n", $buffer); + } + + public function testResponseRejectedPromiseWillResultInErrorMessage() + { + $server = new StreamingServer(Loop::get(), 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') + ->willReturnCallback( + function ($data) use (&$buffer) { + $buffer .= $data; + } + ); + + $server->listen($this->socket); + $this->socket->emit('connection', [$this->connection]); + + $data = $this->createGetRequest(); + + $this->connection->emit('data', [$data]); + + $this->assertStringContainsString("HTTP/1.1 500 Internal Server Error\r\n", $buffer); + } + + public function testResponseExceptionInCallbackWillResultInErrorMessage() + { + $server = new StreamingServer(Loop::get(), 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') + ->willReturnCallback( + function ($data) use (&$buffer) { + $buffer .= $data; + } + ); + + $server->listen($this->socket); + $this->socket->emit('connection', [$this->connection]); + + $data = $this->createGetRequest(); + + $this->connection->emit('data', [$data]); + + $this->assertStringContainsString("HTTP/1.1 500 Internal Server Error\r\n", $buffer); + } + + public function testResponseWithContentLengthHeaderForStringBodyOverwritesTransferEncoding() + { + $server = new StreamingServer(Loop::get(), function (ServerRequestInterface $request) { + return new Response( + 200, + ['Transfer-Encoding' => 'chunked'], + 'hello' + ); + }); + + $buffer = ''; + $this->connection + ->expects($this->any()) + ->method('write') + ->willReturnCallback( + function ($data) use (&$buffer) { + $buffer .= $data; + } + ); + + $server->listen($this->socket); + $this->socket->emit('connection', [$this->connection]); + + $data = $this->createGetRequest(); + + $this->connection->emit('data', [$data]); + + $this->assertStringContainsString("HTTP/1.1 200 OK\r\n", $buffer); + $this->assertStringContainsString("Content-Length: 5\r\n", $buffer); + $this->assertStringContainsString("hello", $buffer); + + $this->assertStringNotContainsString("Transfer-Encoding", $buffer); + } + + public function testResponseWillBeHandled() + { + $server = new StreamingServer(Loop::get(), function (ServerRequestInterface $request) { + return new Response(); + }); + + $buffer = ''; + $this->connection + ->expects($this->any()) + ->method('write') + ->willReturnCallback( + function ($data) use (&$buffer) { + $buffer .= $data; + } + ); + + $server->listen($this->socket); + $this->socket->emit('connection', [$this->connection]); + + $data = $this->createGetRequest(); + + $this->connection->emit('data', [$data]); + + $this->assertStringContainsString("HTTP/1.1 200 OK\r\n", $buffer); + } + + public function testResponseExceptionThrowInCallBackFunctionWillResultInErrorMessage() + { + $server = new StreamingServer(Loop::get(), 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') + ->willReturnCallback( + function ($data) use (&$buffer) { + $buffer .= $data; + } + ); + + $server->listen($this->socket); + $this->socket->emit('connection', [$this->connection]); + + $data = $this->createGetRequest(); + + $this->connection->emit('data', [$data]); + + $this->assertInstanceOf(\RuntimeException::class, $exception); + $this->assertStringContainsString("HTTP/1.1 500 Internal Server Error\r\n", $buffer); + $this->assertEquals('hello', $exception->getPrevious()->getMessage()); + } + + public function testResponseThrowableThrowInCallBackFunctionWillResultInErrorMessage() + { + $server = new StreamingServer(Loop::get(), function (ServerRequestInterface $request) { + throw new \Error('hello'); + }); + + $exception = null; + $server->on('error', function (\Exception $ex) use (&$exception) { + $exception = $ex; + }); + + $buffer = ''; + $this->connection + ->expects($this->any()) + ->method('write') + ->willReturnCallback( + function ($data) use (&$buffer) { + $buffer .= $data; + } + ); + + $server->listen($this->socket); + $this->socket->emit('connection', [$this->connection]); + + $data = $this->createGetRequest(); + + try { + $this->connection->emit('data', [$data]); + } catch (\Error $e) { + $this->markTestSkipped( + 'A \Throwable bubbled out of the request callback. ' . + 'This happened most probably due to react/promise:^1.0 being installed ' . + 'which does not support \Throwable.' + ); + } + + $this->assertInstanceOf(\RuntimeException::class, $exception); + $this->assertStringContainsString("HTTP/1.1 500 Internal Server Error\r\n", $buffer); + $this->assertEquals('hello', $exception->getPrevious()->getMessage()); + } + + public function testResponseRejectOfNonExceptionWillResultInErrorMessage() + { + $server = new StreamingServer(Loop::get(), 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') + ->willReturnCallback( + function ($data) use (&$buffer) { + $buffer .= $data; + } + ); + + $server->listen($this->socket); + $this->socket->emit('connection', [$this->connection]); + + $data = $this->createGetRequest(); + + $this->connection->emit('data', [$data]); + + $this->assertStringContainsString("HTTP/1.1 500 Internal Server Error\r\n", $buffer); + $this->assertInstanceOf(\RuntimeException::class, $exception); + } + + public static function provideInvalidResponse() + { + $response = new Response(200, [], '', '1.1', 'OK'); + + yield [ + $response->withStatus(99, 'OK') + ]; + yield [ + $response->withStatus(1000, 'OK') + ]; + yield [ + $response->withStatus(200, "Invald\r\nReason: Yes") + ]; + yield [ + $response->withHeader('Invalid', "Yes\r\n") + ]; + yield [ + $response->withHeader('Invalid', "Yes\n") + ]; + yield [ + $response->withHeader('Invalid', "Yes\r") + ]; + yield [ + $response->withHeader("Inva\r\nlid", 'Yes') + ]; + yield [ + $response->withHeader("Inva\nlid", 'Yes') + ]; + yield [ + $response->withHeader("Inva\rlid", 'Yes') + ]; + yield [ + $response->withHeader('Inva Lid', 'Yes') + ]; + yield [ + $response->withHeader('Inva:Lid', 'Yes') + ]; + yield [ + $response->withHeader('Invalid', "Val\0ue") + ]; + yield [ + $response->withHeader("Inva\0lid", 'Yes') + ]; + } + + /** + * @dataProvider provideInvalidResponse + * @param ResponseInterface $response + */ + public function testInvalidResponseObjectWillResultInErrorMessage(ResponseInterface $response) + { + $server = new StreamingServer(Loop::get(), function (ServerRequestInterface $request) use ($response) { + return $response; + }); + + $exception = null; + $server->on('error', function (\Exception $ex) use (&$exception) { + $exception = $ex; + }); + + $buffer = ''; + $this->connection + ->expects($this->any()) + ->method('write') + ->willReturnCallback( + function ($data) use (&$buffer) { + $buffer .= $data; + } + ); + + $server->listen($this->socket); + $this->socket->emit('connection', [$this->connection]); + + $data = $this->createGetRequest(); + + $this->connection->emit('data', [$data]); + + $this->assertStringContainsString("HTTP/1.1 500 Internal Server Error\r\n", $buffer); + $this->assertInstanceOf(\InvalidArgumentException::class, $exception); + } + + public function testRequestServerRequestParams() + { + $requestValidation = null; + $server = new StreamingServer(Loop::get(), function (ServerRequestInterface $request) use (&$requestValidation) { + $requestValidation = $request; + }); + + $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', [$this->connection]); + + $data = $this->createGetRequest(); + + $this->connection->emit('data', [$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 testRequestQueryParametersWillBeAddedToRequest() + { + $requestValidation = null; + $server = new StreamingServer(Loop::get(), function (ServerRequestInterface $request) use (&$requestValidation) { + $requestValidation = $request; + }); + + $server->listen($this->socket); + $this->socket->emit('connection', [$this->connection]); + + $data = "GET /foo.php?hello=world&test=bar HTTP/1.0\r\n\r\n"; + + $this->connection->emit('data', [$data]); + + $queryParams = $requestValidation->getQueryParams(); + + $this->assertEquals('world', $queryParams['hello']); + $this->assertEquals('bar', $queryParams['test']); + } + + public function testRequestCookieWillBeAddedToServerRequest() + { + $requestValidation = null; + $server = new StreamingServer(Loop::get(), function (ServerRequestInterface $request) use (&$requestValidation) { + $requestValidation = $request; + }); + + $server->listen($this->socket); + $this->socket->emit('connection', [$this->connection]); + + $data = "GET / HTTP/1.1\r\n"; + $data .= "Host: example.com\r\n"; + $data .= "Connection: close\r\n"; + $data .= "Cookie: hello=world\r\n"; + $data .= "\r\n"; + + $this->connection->emit('data', [$data]); + + $this->assertEquals(['hello' => 'world'], $requestValidation->getCookieParams()); + } + + public function testRequestInvalidMultipleCookiesWontBeAddedToServerRequest() + { + $requestValidation = null; + $server = new StreamingServer(Loop::get(), function (ServerRequestInterface $request) use (&$requestValidation) { + $requestValidation = $request; + }); + + $server->listen($this->socket); + $this->socket->emit('connection', [$this->connection]); + + $data = "GET / HTTP/1.1\r\n"; + $data .= "Host: example.com\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', [$data]); + $this->assertEquals([], $requestValidation->getCookieParams()); + } + + public function testRequestCookieWithSeparatorWillBeAddedToServerRequest() + { + $requestValidation = null; + $server = new StreamingServer(Loop::get(), function (ServerRequestInterface $request) use (&$requestValidation) { + $requestValidation = $request; + }); + + $server->listen($this->socket); + $this->socket->emit('connection', [$this->connection]); + + $data = "GET / HTTP/1.1\r\n"; + $data .= "Host: example.com\r\n"; + $data .= "Connection: close\r\n"; + $data .= "Cookie: hello=world; test=abc\r\n"; + $data .= "\r\n"; + + $this->connection->emit('data', [$data]); + $this->assertEquals(['hello' => 'world', 'test' => 'abc'], $requestValidation->getCookieParams()); + } + + public function testRequestCookieWithCommaValueWillBeAddedToServerRequest() + { + $requestValidation = null; + $server = new StreamingServer(Loop::get(), function (ServerRequestInterface $request) use (&$requestValidation) { + $requestValidation = $request; + }); + + $server->listen($this->socket); + $this->socket->emit('connection', [$this->connection]); + + $data = "GET / HTTP/1.1\r\n"; + $data .= "Host: example.com\r\n"; + $data .= "Connection: close\r\n"; + $data .= "Cookie: test=abc,def; hello=world\r\n"; + $data .= "\r\n"; + + $this->connection->emit('data', [$data]); + $this->assertEquals(['test' => 'abc,def', 'hello' => 'world'], $requestValidation->getCookieParams()); + } + + public function testNewConnectionWillInvokeParserOnce() + { + $server = new StreamingServer(Loop::get(), $this->expectCallableNever()); + + $parser = $this->createMock(RequestHeaderParser::class); + $parser->expects($this->once())->method('handle'); + + $ref = new \ReflectionProperty($server, 'parser'); + $ref->setAccessible(true); + $ref->setValue($server, $parser); + + $server->listen($this->socket); + $this->socket->emit('connection', [$this->connection]); + } + + public function testNewConnectionWillInvokeParserOnceAndInvokeRequestHandlerWhenParserIsDoneForHttp10() + { + $request = new ServerRequest('GET', 'http://localhost/', [], '', '1.0'); + + $server = new StreamingServer(Loop::get(), $this->expectCallableOnceWith($request)); + + $parser = $this->createMock(RequestHeaderParser::class); + $parser->expects($this->once())->method('handle'); + + $ref = new \ReflectionProperty($server, 'parser'); + $ref->setAccessible(true); + $ref->setValue($server, $parser); + + $server->listen($this->socket); + $this->socket->emit('connection', [$this->connection]); + + $this->connection->expects($this->once())->method('write'); + $this->connection->expects($this->once())->method('end'); + + // pretend parser just finished parsing + $server->handleRequest($this->connection, $request); + } + + public function testNewConnectionWillInvokeParserOnceAndInvokeRequestHandlerWhenParserIsDoneForHttp11ConnectionClose() + { + $request = new ServerRequest('GET', 'http://localhost/', ['Connection' => 'close']); + + $server = new StreamingServer(Loop::get(), $this->expectCallableOnceWith($request)); + + $parser = $this->createMock(RequestHeaderParser::class); + $parser->expects($this->once())->method('handle'); + + $ref = new \ReflectionProperty($server, 'parser'); + $ref->setAccessible(true); + $ref->setValue($server, $parser); + + $server->listen($this->socket); + $this->socket->emit('connection', [$this->connection]); + + $this->connection->expects($this->once())->method('write'); + $this->connection->expects($this->once())->method('end'); + + // pretend parser just finished parsing + $server->handleRequest($this->connection, $request); + } + + public function testNewConnectionWillInvokeParserOnceAndInvokeRequestHandlerWhenParserIsDoneAndRequestHandlerReturnsConnectionClose() + { + $request = new ServerRequest('GET', 'http://localhost/'); + + $server = new StreamingServer(Loop::get(), function () { + return new Response(200, ['Connection' => 'close']); + }); + + $parser = $this->createMock(RequestHeaderParser::class); + $parser->expects($this->once())->method('handle'); + + $ref = new \ReflectionProperty($server, 'parser'); + $ref->setAccessible(true); + $ref->setValue($server, $parser); + + $server->listen($this->socket); + $this->socket->emit('connection', [$this->connection]); + + $this->connection->expects($this->once())->method('write'); + $this->connection->expects($this->once())->method('end'); + + // pretend parser just finished parsing + $server->handleRequest($this->connection, $request); + } + + public function testNewConnectionWillInvokeParserTwiceAfterInvokingRequestHandlerWhenConnectionCanBeKeptAliveForHttp11Default() + { + $request = new ServerRequest('GET', 'http://localhost/'); + + $server = new StreamingServer(Loop::get(), function () { + return new Response(); + }); + + $parser = $this->createMock(RequestHeaderParser::class); + $parser->expects($this->exactly(2))->method('handle'); + + $ref = new \ReflectionProperty($server, 'parser'); + $ref->setAccessible(true); + $ref->setValue($server, $parser); + + $server->listen($this->socket); + $this->socket->emit('connection', [$this->connection]); + + $this->connection->expects($this->once())->method('write'); + $this->connection->expects($this->never())->method('end'); + + // pretend parser just finished parsing + $server->handleRequest($this->connection, $request); + } + + public function testNewConnectionWillInvokeParserTwiceAfterInvokingRequestHandlerWhenConnectionCanBeKeptAliveForHttp10ConnectionKeepAlive() + { + $request = new ServerRequest('GET', 'http://localhost/', ['Connection' => 'keep-alive'], '', '1.0'); + + $server = new StreamingServer(Loop::get(), function () { + return new Response(); + }); + + $parser = $this->createMock(RequestHeaderParser::class); + $parser->expects($this->exactly(2))->method('handle'); + + $ref = new \ReflectionProperty($server, 'parser'); + $ref->setAccessible(true); + $ref->setValue($server, $parser); + + $server->listen($this->socket); + $this->socket->emit('connection', [$this->connection]); + + $this->connection->expects($this->once())->method('write'); + $this->connection->expects($this->never())->method('end'); + + // pretend parser just finished parsing + $server->handleRequest($this->connection, $request); + } + + public function testNewConnectionWillInvokeParserOnceAfterInvokingRequestHandlerWhenStreamingResponseBodyKeepsStreaming() + { + $request = new ServerRequest('GET', 'http://localhost/'); + + $body = new ThroughStream(); + $server = new StreamingServer(Loop::get(), function () use ($body) { + return new Response(200, [], $body); + }); + + $parser = $this->createMock(RequestHeaderParser::class); + $parser->expects($this->once())->method('handle'); + + $ref = new \ReflectionProperty($server, 'parser'); + $ref->setAccessible(true); + $ref->setValue($server, $parser); + + $server->listen($this->socket); + $this->socket->emit('connection', [$this->connection]); + + $this->connection->expects($this->once())->method('write'); + $this->connection->expects($this->never())->method('end'); + + // pretend parser just finished parsing + $server->handleRequest($this->connection, $request); + } + + public function testNewConnectionWillInvokeParserTwiceAfterInvokingRequestHandlerWhenStreamingResponseBodyEnds() + { + $request = new ServerRequest('GET', 'http://localhost/'); + + $body = new ThroughStream(); + $server = new StreamingServer(Loop::get(), function () use ($body) { + return new Response(200, [], $body); + }); + + $parser = $this->createMock(RequestHeaderParser::class); + $parser->expects($this->exactly(2))->method('handle'); + + $ref = new \ReflectionProperty($server, 'parser'); + $ref->setAccessible(true); + $ref->setValue($server, $parser); + + $server->listen($this->socket); + $this->socket->emit('connection', [$this->connection]); + + $this->connection->expects($this->exactly(2))->method('write'); + $this->connection->expects($this->never())->method('end'); + + // pretend parser just finished parsing + $server->handleRequest($this->connection, $request); + + $this->assertCount(2, $this->connection->listeners('close')); + $body->end(); + $this->assertCount(1, $this->connection->listeners('close')); + } + + public function testCompletingARequestWillRemoveConnectionOnCloseListener() + { + $connection = $this->mockConnection(['removeListener']); + + $request = new ServerRequest('GET', 'http://localhost/'); + + $server = new StreamingServer(Loop::get(), function () { + return resolve(new Response()); + }); + + $server->listen($this->socket); + $this->socket->emit('connection', [$connection]); + + $connection->expects($this->once())->method('removeListener'); + + // pretend parser just finished parsing + $server->handleRequest($connection, $request); + } + + private function createGetRequest() + { + $data = "GET / HTTP/1.1\r\n"; + $data .= "Host: example.com\r\n"; + $data .= "Connection: close\r\n"; + $data .= "\r\n"; + + return $data; + } +} diff --git a/tests/Io/TransactionTest.php b/tests/Io/TransactionTest.php new file mode 100644 index 00000000..3833cfd3 --- /dev/null +++ b/tests/Io/TransactionTest.php @@ -0,0 +1,1055 @@ +makeSenderMock(); + $loop = $this->createMock(LoopInterface::class); + $transaction = new Transaction($sender, $loop); + + $new = $transaction->withOptions(['followRedirects' => false]); + + $this->assertInstanceOf(Transaction::class, $new); + $this->assertNotSame($transaction, $new); + + $ref = new \ReflectionProperty($new, 'followRedirects'); + $ref->setAccessible(true); + + $this->assertFalse($ref->getValue($new)); + } + + public function testWithOptionsDoesNotChangeOriginalInstance() + { + $sender = $this->makeSenderMock(); + $loop = $this->createMock(LoopInterface::class); + $transaction = new Transaction($sender, $loop); + + $transaction->withOptions(['followRedirects' => false]); + + $ref = new \ReflectionProperty($transaction, 'followRedirects'); + $ref->setAccessible(true); + + $this->assertTrue($ref->getValue($transaction)); + } + + public function testWithOptionsNullValueReturnsNewInstanceWithDefaultOption() + { + $sender = $this->makeSenderMock(); + $loop = $this->createMock(LoopInterface::class); + $transaction = new Transaction($sender, $loop); + + $transaction = $transaction->withOptions(['followRedirects' => false]); + $transaction = $transaction->withOptions(['followRedirects' => null]); + + $ref = new \ReflectionProperty($transaction, 'followRedirects'); + $ref->setAccessible(true); + + $this->assertTrue($ref->getValue($transaction)); + } + + public function testTimeoutExplicitOptionWillStartTimeoutTimer() + { + $timer = $this->createMock(TimerInterface::class); + $loop = $this->createMock(LoopInterface::class); + $loop->expects($this->once())->method('addTimer')->with(2, $this->anything())->willReturn($timer); + $loop->expects($this->never())->method('cancelTimer'); + + $request = $this->createMock(RequestInterface::class); + + $sender = $this->createMock(Sender::class); + $sender->expects($this->once())->method('send')->with($request)->willReturn(new Promise(function () { })); + + $transaction = new Transaction($sender, $loop); + $transaction = $transaction->withOptions(['timeout' => 2]); + $promise = $transaction->send($request); + + $this->assertInstanceOf(PromiseInterface::class, $promise); + } + + public function testTimeoutImplicitFromIniWillStartTimeoutTimer() + { + $timer = $this->createMock(TimerInterface::class); + $loop = $this->createMock(LoopInterface::class); + $loop->expects($this->once())->method('addTimer')->with(2, $this->anything())->willReturn($timer); + $loop->expects($this->never())->method('cancelTimer'); + + $request = $this->createMock(RequestInterface::class); + + $sender = $this->createMock(Sender::class); + $sender->expects($this->once())->method('send')->with($request)->willReturn(new Promise(function () { })); + + $transaction = new Transaction($sender, $loop); + + $old = ini_get('default_socket_timeout'); + ini_set('default_socket_timeout', '2'); + $promise = $transaction->send($request); + ini_set('default_socket_timeout', $old); + + $this->assertInstanceOf(PromiseInterface::class, $promise); + } + + public function testTimeoutExplicitOptionWillRejectWhenTimerFires() + { + $timeout = null; + $timer = $this->createMock(TimerInterface::class); + $loop = $this->createMock(LoopInterface::class); + $loop->expects($this->once())->method('addTimer')->with(2, $this->callback(function ($cb) use (&$timeout) { + $timeout = $cb; + return true; + }))->willReturn($timer); + $loop->expects($this->never())->method('cancelTimer'); + + $request = $this->createMock(RequestInterface::class); + + $sender = $this->createMock(Sender::class); + $sender->expects($this->once())->method('send')->with($request)->willReturn(new Promise(function () { })); + + $transaction = new Transaction($sender, $loop); + $transaction = $transaction->withOptions(['timeout' => 2]); + $promise = $transaction->send($request); + + $this->assertNotNull($timeout); + $timeout(); + + $exception = null; + $promise->then(null, function ($e) use (&$exception) { + $exception = $e; + }); + + $this->assertInstanceOf(\RuntimeException::class, $exception); + $this->assertEquals('Request timed out after 2 seconds', $exception->getMessage()); + } + + public function testTimeoutExplicitOptionWillNotStartTimeoutWhenSenderResolvesImmediately() + { + $loop = $this->createMock(LoopInterface::class); + $loop->expects($this->never())->method('addTimer'); + + $request = $this->createMock(RequestInterface::class); + $response = new Response(200, [], ''); + + $sender = $this->createMock(Sender::class); + $sender->expects($this->once())->method('send')->with($request)->willReturn(resolve($response)); + + $transaction = new Transaction($sender, $loop); + $transaction = $transaction->withOptions(['timeout' => 0.001]); + $promise = $transaction->send($request); + + $this->assertInstanceOf(PromiseInterface::class, $promise); + $promise->then($this->expectCallableOnceWith($response)); + } + + public function testTimeoutExplicitOptionWillCancelTimeoutTimerWhenSenderResolvesLaterOn() + { + $timer = $this->createMock(TimerInterface::class); + $loop = $this->createMock(LoopInterface::class); + $loop->expects($this->once())->method('addTimer')->willReturn($timer); + $loop->expects($this->once())->method('cancelTimer')->with($timer); + + $request = $this->createMock(RequestInterface::class); + $response = new Response(200, [], ''); + + $deferred = new Deferred(); + $sender = $this->createMock(Sender::class); + $sender->expects($this->once())->method('send')->with($request)->willReturn($deferred->promise()); + + $transaction = new Transaction($sender, $loop); + $transaction = $transaction->withOptions(['timeout' => 0.001]); + $promise = $transaction->send($request); + + $deferred->resolve($response); + + $this->assertInstanceOf(PromiseInterface::class, $promise); + $promise->then($this->expectCallableOnceWith($response)); + } + + public function testTimeoutExplicitOptionWillNotStartTimeoutWhenSenderRejectsImmediately() + { + $loop = $this->createMock(LoopInterface::class); + $loop->expects($this->never())->method('addTimer'); + + $request = $this->createMock(RequestInterface::class); + $exception = new \RuntimeException(); + + $sender = $this->createMock(Sender::class); + $sender->expects($this->once())->method('send')->with($request)->willReturn(reject($exception)); + + $transaction = new Transaction($sender, $loop); + $transaction = $transaction->withOptions(['timeout' => 0.001]); + $promise = $transaction->send($request); + + $this->assertInstanceOf(PromiseInterface::class, $promise); + $promise->then(null, $this->expectCallableOnceWith($exception)); + } + + public function testTimeoutExplicitOptionWillCancelTimeoutTimerWhenSenderRejectsLaterOn() + { + $timer = $this->createMock(TimerInterface::class); + $loop = $this->createMock(LoopInterface::class); + $loop->expects($this->once())->method('addTimer')->willReturn($timer); + $loop->expects($this->once())->method('cancelTimer')->with($timer); + + $request = $this->createMock(RequestInterface::class); + + $deferred = new Deferred(); + $sender = $this->createMock(Sender::class); + $sender->expects($this->once())->method('send')->with($request)->willReturn($deferred->promise()); + + $transaction = new Transaction($sender, $loop); + $transaction = $transaction->withOptions(['timeout' => 0.001]); + $promise = $transaction->send($request); + + $exception = new \RuntimeException(); + $deferred->reject($exception); + + $this->assertInstanceOf(PromiseInterface::class, $promise); + $promise->then(null, $this->expectCallableOnceWith($exception)); + } + + public function testTimeoutExplicitNegativeWillNotStartTimeoutTimer() + { + $loop = $this->createMock(LoopInterface::class); + $loop->expects($this->never())->method('addTimer'); + + $request = $this->createMock(RequestInterface::class); + + $sender = $this->createMock(Sender::class); + $sender->expects($this->once())->method('send')->with($request)->willReturn(new Promise(function () { })); + + $transaction = new Transaction($sender, $loop); + $transaction = $transaction->withOptions(['timeout' => -1]); + $promise = $transaction->send($request); + + $this->assertInstanceOf(PromiseInterface::class, $promise); + } + + public function testTimeoutExplicitOptionWillNotStartTimeoutTimerWhenRequestBodyIsStreaming() + { + $loop = $this->createMock(LoopInterface::class); + $loop->expects($this->never())->method('addTimer'); + + $stream = new ThroughStream(); + $request = new Request('POST', 'http://example.com/', [], new ReadableBodyStream($stream)); + + $sender = $this->createMock(Sender::class); + $sender->expects($this->once())->method('send')->with($request)->willReturn(new Promise(function () { })); + + $transaction = new Transaction($sender, $loop); + $transaction = $transaction->withOptions(['timeout' => 2]); + $promise = $transaction->send($request); + + $this->assertInstanceOf(PromiseInterface::class, $promise); + } + + public function testTimeoutExplicitOptionWillStartTimeoutTimerWhenStreamingRequestBodyIsAlreadyClosed() + { + $timer = $this->createMock(TimerInterface::class); + $loop = $this->createMock(LoopInterface::class); + $loop->expects($this->once())->method('addTimer')->with(2, $this->anything())->willReturn($timer); + $loop->expects($this->never())->method('cancelTimer'); + + $stream = new ThroughStream(); + $stream->close(); + $request = new Request('POST', 'http://example.com/', [], new ReadableBodyStream($stream)); + + $sender = $this->createMock(Sender::class); + $sender->expects($this->once())->method('send')->with($request)->willReturn(new Promise(function () { })); + + $transaction = new Transaction($sender, $loop); + $transaction = $transaction->withOptions(['timeout' => 2]); + $promise = $transaction->send($request); + + $this->assertInstanceOf(PromiseInterface::class, $promise); + } + + public function testTimeoutExplicitOptionWillStartTimeoutTimerWhenStreamingRequestBodyClosesWhileSenderIsStillPending() + { + $timer = $this->createMock(TimerInterface::class); + $loop = $this->createMock(LoopInterface::class); + $loop->expects($this->once())->method('addTimer')->with(2, $this->anything())->willReturn($timer); + $loop->expects($this->never())->method('cancelTimer'); + + $stream = new ThroughStream(); + $request = new Request('POST', 'http://example.com/', [], new ReadableBodyStream($stream)); + + $sender = $this->createMock(Sender::class); + $sender->expects($this->once())->method('send')->with($request)->willReturn(new Promise(function () { })); + + $transaction = new Transaction($sender, $loop); + $transaction = $transaction->withOptions(['timeout' => 2]); + $promise = $transaction->send($request); + + $stream->close(); + + $this->assertInstanceOf(PromiseInterface::class, $promise); + } + + public function testTimeoutExplicitOptionWillNotStartTimeoutTimerWhenStreamingRequestBodyClosesAfterSenderRejects() + { + $loop = $this->createMock(LoopInterface::class); + $loop->expects($this->never())->method('addTimer'); + + $stream = new ThroughStream(); + $request = new Request('POST', 'http://example.com/', [], new ReadableBodyStream($stream)); + + $deferred = new Deferred(); + $sender = $this->createMock(Sender::class); + $sender->expects($this->once())->method('send')->with($request)->willReturn($deferred->promise()); + + $transaction = new Transaction($sender, $loop); + $transaction = $transaction->withOptions(['timeout' => 2]); + $promise = $transaction->send($request); + + $deferred->reject(new \RuntimeException('Request failed')); + $stream->close(); + + $this->assertInstanceOf(PromiseInterface::class, $promise); + + $promise->then(null, $this->expectCallableOnce()); // avoid reporting unhandled rejection + } + + public function testTimeoutExplicitOptionWillRejectWhenTimerFiresAfterStreamingRequestBodyCloses() + { + $timeout = null; + $timer = $this->createMock(TimerInterface::class); + $loop = $this->createMock(LoopInterface::class); + $loop->expects($this->once())->method('addTimer')->with(2, $this->callback(function ($cb) use (&$timeout) { + $timeout = $cb; + return true; + }))->willReturn($timer); + $loop->expects($this->never())->method('cancelTimer'); + + $stream = new ThroughStream(); + $request = new Request('POST', 'http://example.com/', [], new ReadableBodyStream($stream)); + + $sender = $this->createMock(Sender::class); + $sender->expects($this->once())->method('send')->with($request)->willReturn(new Promise(function () { })); + + $transaction = new Transaction($sender, $loop); + $transaction = $transaction->withOptions(['timeout' => 2]); + $promise = $transaction->send($request); + + $stream->close(); + + $this->assertNotNull($timeout); + $timeout(); + + $exception = null; + $promise->then(null, function ($e) use (&$exception) { + $exception = $e; + }); + + $this->assertInstanceOf(\RuntimeException::class, $exception); + $this->assertEquals('Request timed out after 2 seconds', $exception->getMessage()); + } + + public function testReceivingErrorResponseWillRejectWithResponseException() + { + $request = $this->createMock(RequestInterface::class); + $response = new Response(404); + $loop = $this->createMock(LoopInterface::class); + + // mock sender to resolve promise with the given $response in response to the given $request + $sender = $this->makeSenderMock(); + $sender->expects($this->once())->method('send')->with($request)->willReturn(resolve($response)); + + $transaction = new Transaction($sender, $loop); + $transaction = $transaction->withOptions(['timeout' => -1]); + $promise = $transaction->send($request); + + $exception = null; + $promise->then(null, function ($reason) use (&$exception) { + $exception = $reason; + }); + + assert($exception instanceof ResponseException); + $this->assertEquals(404, $exception->getCode()); + $this->assertSame($response, $exception->getResponse()); + } + + public function testReceivingStreamingBodyWillResolveWithBufferedResponseByDefault() + { + $stream = new ThroughStream(); + Loop::addTimer(0.001, function () use ($stream) { + $stream->emit('data', ['hello world']); + $stream->close(); + }); + + $request = $this->createMock(RequestInterface::class); + $response = new Response(200, [], new ReadableBodyStream($stream)); + + // mock sender to resolve promise with the given $response in response to the given $request + $sender = $this->makeSenderMock(); + $sender->expects($this->once())->method('send')->with($request)->willReturn(resolve($response)); + + $transaction = new Transaction($sender, Loop::get()); + $promise = $transaction->send($request); + + $response = await($promise); + + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals('hello world', (string)$response->getBody()); + } + + public function testReceivingStreamingBodyWithContentLengthExceedingMaximumResponseBufferWillRejectAndCloseResponseStreamImmediately() + { + $stream = new ThroughStream(); + $stream->on('close', $this->expectCallableOnce()); + + $request = $this->createMock(RequestInterface::class); + + $response = new Response(200, ['Content-Length' => '100000000'], new ReadableBodyStream($stream, 100000000)); + + // mock sender to resolve promise with the given $response in response to the given $request + $sender = $this->makeSenderMock(); + $sender->expects($this->once())->method('send')->with($request)->willReturn(resolve($response)); + + $transaction = new Transaction($sender, Loop::get()); + + $promise = $transaction->send($request); + + $exception = null; + $promise->then(null, function ($e) use (&$exception) { + $exception = $e; + }); + + $this->assertFalse($stream->isWritable()); + + assert($exception instanceof \OverflowException); + $this->assertInstanceOf(\OverflowException::class, $exception); + $this->assertEquals('Response body size of 100000000 bytes exceeds maximum of 16777216 bytes', $exception->getMessage()); + $this->assertEquals(defined('SOCKET_EMSGSIZE') ? \SOCKET_EMSGSIZE : 90, $exception->getCode()); + $this->assertNull($exception->getPrevious()); + } + + public function testReceivingStreamingBodyWithContentsExceedingMaximumResponseBufferWillRejectAndCloseResponseStreamWhenBufferExceedsLimit() + { + $stream = new ThroughStream(); + $stream->on('close', $this->expectCallableOnce()); + + $request = $this->createMock(RequestInterface::class); + + $response = new Response(200, [], new ReadableBodyStream($stream)); + + // mock sender to resolve promise with the given $response in response to the given $request + $sender = $this->makeSenderMock(); + $sender->expects($this->once())->method('send')->with($request)->willReturn(resolve($response)); + + $transaction = new Transaction($sender, Loop::get()); + $transaction = $transaction->withOptions(['maximumSize' => 10]); + $promise = $transaction->send($request); + + $exception = null; + $promise->then(null, function ($e) use (&$exception) { + $exception = $e; + }); + + $this->assertTrue($stream->isWritable()); + $stream->write('hello wörld'); + $this->assertFalse($stream->isWritable()); + + assert($exception instanceof \OverflowException); + $this->assertInstanceOf(\OverflowException::class, $exception); + $this->assertEquals('Response body size exceeds maximum of 10 bytes', $exception->getMessage()); + $this->assertEquals(defined('SOCKET_EMSGSIZE') ? \SOCKET_EMSGSIZE : 90, $exception->getCode()); + $this->assertNull($exception->getPrevious()); + } + + public function testReceivingStreamingBodyWillRejectWhenStreamEmitsError() + { + $stream = new ThroughStream(function ($data) { + throw new \UnexpectedValueException('Unexpected ' . $data, 42); + }); + + $request = $this->createMock(RequestInterface::class); + $response = new Response(200, [], new ReadableBodyStream($stream)); + + // mock sender to resolve promise with the given $response in response to the given $request + $sender = $this->makeSenderMock(); + $sender->expects($this->once())->method('send')->with($request)->willReturn(resolve($response)); + + $transaction = new Transaction($sender, Loop::get()); + $promise = $transaction->send($request); + + $exception = null; + $promise->then(null, function ($e) use (&$exception) { + $exception = $e; + }); + + $this->assertTrue($stream->isWritable()); + $stream->write('Foo'); + $this->assertFalse($stream->isWritable()); + + assert($exception instanceof \RuntimeException); + $this->assertInstanceOf(\RuntimeException::class, $exception); + $this->assertEquals('Error while buffering response body: Unexpected Foo', $exception->getMessage()); + $this->assertEquals(42, $exception->getCode()); + $this->assertInstanceOf(\UnexpectedValueException::class, $exception->getPrevious()); + } + + public function testCancelBufferingResponseWillCloseStreamAndReject() + { + $stream = $this->createMock(ReadableStreamInterface::class); + $stream->expects($this->any())->method('isReadable')->willReturn(true); + $stream->expects($this->once())->method('close'); + + $request = $this->createMock(RequestInterface::class); + $response = new Response(200, [], new ReadableBodyStream($stream)); + + // mock sender to resolve promise with the given $response in response to the given $request + $deferred = new Deferred(); + $sender = $this->makeSenderMock(); + $sender->expects($this->once())->method('send')->with($request)->willReturn($deferred->promise()); + + $transaction = new Transaction($sender, Loop::get()); + $promise = $transaction->send($request); + + $deferred->resolve($response); + $promise->cancel(); + + $exception = null; + $promise->then(null, function ($e) use (&$exception) { + $exception = $e; + }); + + assert($exception instanceof \RuntimeException); + $this->assertInstanceOf(\RuntimeException::class, $exception); + $this->assertEquals('Cancelled buffering response body', $exception->getMessage()); + $this->assertEquals(0, $exception->getCode()); + $this->assertNull($exception->getPrevious()); + } + + public function testReceivingStreamingBodyWillResolveWithStreamingResponseIfStreamingIsEnabled() + { + $loop = $this->createMock(LoopInterface::class); + + $request = $this->createMock(RequestInterface::class); + $response = new Response(200, [], new ReadableBodyStream($this->createMock(ReadableStreamInterface::class))); + + // mock sender to resolve promise with the given $response in response to the given $request + $sender = $this->makeSenderMock(); + $sender->expects($this->once())->method('send')->with($request)->willReturn(resolve($response)); + + $transaction = new Transaction($sender, $loop); + $transaction = $transaction->withOptions(['streaming' => true, 'timeout' => -1]); + $promise = $transaction->send($request); + + $response = null; + $promise->then(function ($value) use (&$response) { + $response = $value; + }); + + assert($response instanceof ResponseInterface); + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals('', (string)$response->getBody()); + } + + public function testResponseCode304WithoutLocationWillResolveWithResponseAsIs() + { + $loop = $this->createMock(LoopInterface::class); + + // conditional GET request will respond with 304 (Not Modified + $request = new Request('GET', 'http://example.com/', ['If-None-Match' => '"abc"']); + $response = new Response(304, ['ETag' => '"abc"']); + $sender = $this->makeSenderMock(); + $sender->expects($this->once())->method('send')->with($request)->willReturn(resolve($response)); + + $transaction = new Transaction($sender, $loop); + $transaction = $transaction->withOptions(['timeout' => -1]); + $promise = $transaction->send($request); + + $promise->then($this->expectCallableOnceWith($response)); + } + + public function testCustomRedirectResponseCode333WillFollowLocationHeaderAndSendRedirectedRequest() + { + $loop = $this->createMock(LoopInterface::class); + + // original GET request will respond with custom 333 redirect status code and follow location header + $requestOriginal = new Request('GET', 'http://example.com/'); + $response = new Response(333, ['Location' => 'foo']); + $sender = $this->makeSenderMock(); + $sender->expects($this->exactly(2))->method('send')->withConsecutive( + [$requestOriginal], + [$this->callback(function (RequestInterface $request) { + return $request->getMethod() === 'GET' && (string)$request->getUri() === 'http://example.com/foo'; + })] + )->willReturnOnConsecutiveCalls( + resolve($response), + new Promise(function () { }) + ); + + $transaction = new Transaction($sender, $loop); + $transaction->send($requestOriginal); + } + + public function testFollowingRedirectWithSpecifiedHeaders() + { + $loop = $this->createMock(LoopInterface::class); + + $customHeaders = ['User-Agent' => 'Chrome']; + $requestWithUserAgent = new Request('GET', 'http://example.com/', $customHeaders); + $sender = $this->makeSenderMock(); + + // mock sender to resolve promise with the given $redirectResponse in + // response to the given $requestWithUserAgent + $redirectResponse = new Response(301, ['Location' => 'http://redirect.com/']); + + // mock sender to resolve promise with the given $okResponse in + // response to the given $requestWithUserAgent + $okResponse = new Response(200); + $sender->expects($this->exactly(2))->method('send')->withConsecutive( + [$this->anything()], + [$this->callback(function (RequestInterface $request) { + $this->assertEquals(['Chrome'], $request->getHeader('User-Agent')); + return true; + })] + )->willReturnOnConsecutiveCalls( + resolve($redirectResponse), + resolve($okResponse) + ); + + $transaction = new Transaction($sender, $loop); + $transaction->send($requestWithUserAgent); + } + + public function testRemovingAuthorizationHeaderWhenChangingHostnamesDuringRedirect() + { + $loop = $this->createMock(LoopInterface::class); + + $customHeaders = ['Authorization' => 'secret']; + $requestWithAuthorization = new Request('GET', 'http://example.com/', $customHeaders); + $sender = $this->makeSenderMock(); + + // mock sender to resolve promise with the given $redirectResponse in + // response to the given $requestWithAuthorization + $redirectResponse = new Response(301, ['Location' => 'http://redirect.com/']); + + // mock sender to resolve promise with the given $okResponse in + // response to the given $requestWithAuthorization + $okResponse = new Response(200); + $sender->expects($this->exactly(2))->method('send')->withConsecutive( + [$this->anything()], + [$this->callback(function (RequestInterface $request) { + $this->assertFalse($request->hasHeader('Authorization')); + return true; + })] + )->willReturnOnConsecutiveCalls( + resolve($redirectResponse), + resolve($okResponse) + ); + + $transaction = new Transaction($sender, $loop); + $transaction->send($requestWithAuthorization); + } + + public function testAuthorizationHeaderIsForwardedWhenRedirectingToSameDomain() + { + $loop = $this->createMock(LoopInterface::class); + + $customHeaders = ['Authorization' => 'secret']; + $requestWithAuthorization = new Request('GET', 'http://example.com/', $customHeaders); + $sender = $this->makeSenderMock(); + + // mock sender to resolve promise with the given $redirectResponse in + // response to the given $requestWithAuthorization + $redirectResponse = new Response(301, ['Location' => 'http://example.com/new']); + + // mock sender to resolve promise with the given $okResponse in + // response to the given $requestWithAuthorization + $okResponse = new Response(200); + $sender->expects($this->exactly(2))->method('send')->withConsecutive( + [$this->anything()], + [$this->callback(function (RequestInterface $request) { + $this->assertEquals(['secret'], $request->getHeader('Authorization')); + return true; + })] + )->willReturnOnConsecutiveCalls( + resolve($redirectResponse), + resolve($okResponse) + ); + + $transaction = new Transaction($sender, $loop); + $transaction->send($requestWithAuthorization); + } + + public function testAuthorizationHeaderIsForwardedWhenLocationContainsAuthentication() + { + $loop = $this->createMock(LoopInterface::class); + + $request = new Request('GET', 'http://example.com/'); + $sender = $this->makeSenderMock(); + + // mock sender to resolve promise with the given $redirectResponse in + // response to the given $requestWithAuthorization + $redirectResponse = new Response(301, ['Location' => 'http://user:pass@example.com/new']); + + // mock sender to resolve promise with the given $okResponse in + // response to the given $requestWithAuthorization + $okResponse = new Response(200); + $sender->expects($this->exactly(2))->method('send')->withConsecutive( + [$this->anything()], + [$this->callback(function (RequestInterface $request) { + $this->assertEquals('user:pass', $request->getUri()->getUserInfo()); + $this->assertFalse($request->hasHeader('Authorization')); + return true; + })] + )->willReturnOnConsecutiveCalls( + resolve($redirectResponse), + resolve($okResponse) + ); + + $transaction = new Transaction($sender, $loop); + $transaction->send($request); + } + + public function testSomeRequestHeadersShouldBeRemovedWhenRedirecting() + { + $loop = $this->createMock(LoopInterface::class); + + $customHeaders = [ + 'Content-Type' => 'text/html; charset=utf-8', + 'Content-Length' => '111' + ]; + + $requestWithCustomHeaders = new Request('GET', 'http://example.com/', $customHeaders); + $sender = $this->makeSenderMock(); + + // mock sender to resolve promise with the given $redirectResponse in + // response to the given $requestWithCustomHeaders + $redirectResponse = new Response(301, ['Location' => 'http://example.com/new']); + + // mock sender to resolve promise with the given $okResponse in + // response to the given $requestWithCustomHeaders + $okResponse = new Response(200); + $sender->expects($this->exactly(2))->method('send')->withConsecutive( + [$this->anything()], + [$this->callback(function (RequestInterface $request) { + $this->assertFalse($request->hasHeader('Content-Type')); + $this->assertFalse($request->hasHeader('Content-Length')); + return true; + })] + )->willReturnOnConsecutiveCalls( + resolve($redirectResponse), + resolve($okResponse) + ); + + $transaction = new Transaction($sender, $loop); + $transaction->send($requestWithCustomHeaders); + } + + public function testRequestMethodShouldBeChangedWhenRedirectingWithSeeOther() + { + $loop = $this->createMock(LoopInterface::class); + + $customHeaders = [ + 'Content-Type' => 'text/html; charset=utf-8', + 'Content-Length' => '111' + ]; + + $request = new Request('POST', 'http://example.com/', $customHeaders); + $sender = $this->makeSenderMock(); + + // mock sender to resolve promise with the given $redirectResponse in + // response to the given $request + $redirectResponse = new Response(303, ['Location' => 'http://example.com/new']); + + // mock sender to resolve promise with the given $okResponse in + // response to the given $request + $okResponse = new Response(200); + $sender->expects($this->exactly(2))->method('send')->withConsecutive( + [$this->anything()], + [$this->callback(function (RequestInterface $request) { + $this->assertEquals('GET', $request->getMethod()); + $this->assertFalse($request->hasHeader('Content-Type')); + $this->assertFalse($request->hasHeader('Content-Length')); + return true; + })] + )->willReturnOnConsecutiveCalls( + resolve($redirectResponse), + resolve($okResponse) + ); + + $transaction = new Transaction($sender, $loop); + $transaction->send($request); + } + + public function testRequestMethodAndBodyShouldNotBeChangedWhenRedirectingWith307Or308() + { + $loop = $this->createMock(LoopInterface::class); + + $customHeaders = [ + 'Content-Type' => 'text/html; charset=utf-8', + 'Content-Length' => '111' + ]; + + $request = new Request('POST', 'http://example.com/', $customHeaders, '{"key":"value"}'); + $sender = $this->makeSenderMock(); + + // mock sender to resolve promise with the given $redirectResponse in + // response to the given $request + $redirectResponse = new Response(307, ['Location' => 'http://example.com/new']); + + // mock sender to resolve promise with the given $okResponse in + // response to the given $request + $okResponse = new Response(200); + $sender->expects($this->exactly(2))->method('send')->withConsecutive( + [$this->anything()], + [$this->callback(function (RequestInterface $request) { + $this->assertEquals('POST', $request->getMethod()); + $this->assertEquals('{"key":"value"}', (string)$request->getBody()); + $this->assertEquals( + [ + 'Content-Type' => ['text/html; charset=utf-8'], + 'Content-Length' => ['111'], + 'Host' => ['example.com'] + ], + $request->getHeaders() + ); + return true; + })] + )->willReturnOnConsecutiveCalls( + resolve($redirectResponse), + resolve($okResponse) + ); + + $transaction = new Transaction($sender, $loop); + $transaction->send($request); + } + + public function testRedirectingStreamingBodyWith307Or308ShouldThrowCantRedirectStreamException() + { + $loop = $this->createMock(LoopInterface::class); + + $customHeaders = [ + 'Content-Type' => 'text/html; charset=utf-8', + 'Content-Length' => '111' + ]; + + $stream = new ThroughStream(); + $request = new Request('POST', 'http://example.com/', $customHeaders, new ReadableBodyStream($stream)); + $sender = $this->makeSenderMock(); + + // mock sender to resolve promise with the given $redirectResponse in + // response to the given $request + $redirectResponse = new Response(307, ['Location' => 'http://example.com/new']); + + $sender->expects($this->once())->method('send')->withConsecutive( + [$this->anything()] + )->willReturnOnConsecutiveCalls( + resolve($redirectResponse) + ); + + $transaction = new Transaction($sender, $loop); + $promise = $transaction->send($request); + + $exception = null; + $promise->then(null, function ($reason) use (&$exception) { + $exception = $reason; + }); + + assert($exception instanceof \RuntimeException); + $this->assertEquals('Unable to redirect request with streaming body', $exception->getMessage()); + } + + public function testCancelTransactionWillCancelRequest() + { + $loop = $this->createMock(LoopInterface::class); + + $request = new Request('GET', 'http://example.com/'); + $sender = $this->makeSenderMock(); + + $pending = new Promise(function () { }, $this->expectCallableOnce()); + + // mock sender to return pending promise which should be cancelled when cancelling result + $sender->expects($this->once())->method('send')->willReturn($pending); + + $transaction = new Transaction($sender, $loop); + $promise = $transaction->send($request); + + $promise->cancel(); + } + + public function testCancelTransactionWillCancelTimeoutTimer() + { + $timer = $this->createMock(TimerInterface::class); + $loop = $this->createMock(LoopInterface::class); + $loop->expects($this->once())->method('addTimer')->willReturn($timer); + $loop->expects($this->once())->method('cancelTimer')->with($timer); + + $request = new Request('GET', 'http://example.com/'); + $sender = $this->makeSenderMock(); + + $pending = new Promise(function () { }, function () { throw new \RuntimeException(); }); + + // mock sender to return pending promise which should be cancelled when cancelling result + $sender->expects($this->once())->method('send')->willReturn($pending); + + $transaction = new Transaction($sender, $loop); + $transaction = $transaction->withOptions(['timeout' => 2]); + $promise = $transaction->send($request); + + $promise->cancel(); + } + + public function testCancelTransactionWillCancelRedirectedRequest() + { + $loop = $this->createMock(LoopInterface::class); + + $request = new Request('GET', 'http://example.com/'); + $sender = $this->makeSenderMock(); + + // mock sender to resolve promise with the given $redirectResponse in + $redirectResponse = new Response(301, ['Location' => 'http://example.com/new']); + + $pending = new Promise(function () { }, $this->expectCallableOnce()); + + // mock sender to return pending promise which should be cancelled when cancelling result + $sender->expects($this->exactly(2))->method('send')->withConsecutive( + [$this->anything()], + [$this->anything()] + )->willReturnOnConsecutiveCalls( + resolve($redirectResponse), + $pending + ); + + $transaction = new Transaction($sender, $loop); + $promise = $transaction->send($request); + + $promise->cancel(); + } + + public function testCancelTransactionWillCancelRedirectedRequestAgain() + { + $loop = $this->createMock(LoopInterface::class); + + $request = new Request('GET', 'http://example.com/'); + $sender = $this->makeSenderMock(); + + // mock sender to resolve promise with the given $redirectResponse in + $first = new Deferred(); + + $second = new Promise(function () { }, $this->expectCallableOnce()); + + // mock sender to return pending promise which should be cancelled when cancelling result + $sender->expects($this->exactly(2))->method('send')->withConsecutive( + [$this->anything()], + [$this->anything()] + )->willReturnOnConsecutiveCalls( + $first->promise(), + $second + ); + + $transaction = new Transaction($sender, $loop); + $promise = $transaction->send($request); + + // mock sender to resolve promise with the given $redirectResponse in + $first->resolve(new Response(301, ['Location' => 'http://example.com/new'])); + + $promise->cancel(); + } + + public function testCancelTransactionWillCloseBufferingStream() + { + $loop = $this->createMock(LoopInterface::class); + + $request = new Request('GET', 'http://example.com/'); + $sender = $this->makeSenderMock(); + + $body = new ThroughStream(); + $body->on('close', $this->expectCallableOnce()); + + // mock sender to resolve promise with the given $redirectResponse + $deferred = new Deferred(); + $sender->expects($this->once())->method('send')->willReturn($deferred->promise()); + + $transaction = new Transaction($sender, $loop); + $promise = $transaction->send($request); + + $redirectResponse = new Response(301, ['Location' => 'http://example.com/new'], new ReadableBodyStream($body)); + $deferred->resolve($redirectResponse); + + $promise->cancel(); + } + + public function testCancelTransactionWillCloseBufferingStreamAgain() + { + $loop = $this->createMock(LoopInterface::class); + + $request = new Request('GET', 'http://example.com/'); + $sender = $this->makeSenderMock(); + + $first = new Deferred(); + $sender->expects($this->once())->method('send')->willReturn($first->promise()); + + $transaction = new Transaction($sender, $loop); + $promise = $transaction->send($request); + + $body = new ThroughStream(); + $body->on('close', $this->expectCallableOnce()); + + // mock sender to resolve promise with the given $redirectResponse in + $first->resolve(new Response(301, ['Location' => 'http://example.com/new'], new ReadableBodyStream($body))); + $promise->cancel(); + } + + public function testCancelTransactionShouldCancelSendingPromise() + { + $loop = $this->createMock(LoopInterface::class); + + $request = new Request('GET', 'http://example.com/'); + $sender = $this->makeSenderMock(); + + // mock sender to resolve promise with the given $redirectResponse in + $redirectResponse = new Response(301, ['Location' => 'http://example.com/new']); + + $pending = new Promise(function () { }, $this->expectCallableOnce()); + + // mock sender to return pending promise which should be cancelled when cancelling result + $sender->expects($this->exactly(2))->method('send')->withConsecutive( + [$this->anything()], + [$this->anything()] + )->willReturnOnConsecutiveCalls( + resolve($redirectResponse), + $pending + ); + + $transaction = new Transaction($sender, $loop); + $promise = $transaction->send($request); + + $promise->cancel(); + } + + /** + * @return MockObject + */ + private function makeSenderMock() + { + return $this->createMock(Sender::class); + } +} diff --git a/tests/Io/UploadedFileTest.php b/tests/Io/UploadedFileTest.php index 383b686b..529b75af 100644 --- a/tests/Io/UploadedFileTest.php +++ b/tests/Io/UploadedFileTest.php @@ -2,47 +2,45 @@ namespace React\Tests\Http\Io; +use React\Http\Io\BufferedBody; use React\Http\Io\UploadedFile; Use React\Tests\Http\TestCase; -use RingCentral\Psr7\BufferStream; class UploadedFileTest extends TestCase { - public function failtyErrorProvider() + public static function failtyErrorProvider() { - return array( - array('a'), - array(null), - array(-1), - array(9), - ); + yield ['a']; + yield [null]; + yield [-1]; + yield [9]; } /** * @dataProvider failtyErrorProvider - * @expectedException \InvalidArgumentException - * @expectedExceptionMessage Invalid error code, must be an UPLOAD_ERR_* constant */ public function testFailtyError($error) { - $stream = new BufferStream(); + $stream = new BufferedBody(''); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid error code, must be an UPLOAD_ERR_* constant'); new UploadedFile($stream, 0, $error, 'foo.bar', 'foo/bar'); } - /** - * @expectedException \RuntimeException - * @expectedExceptionMessage Not implemented - */ public function testNoMoveFile() { - $stream = new BufferStream(); + $stream = new BufferedBody(''); $uploadedFile = new UploadedFile($stream, 0, UPLOAD_ERR_OK, 'foo.bar', 'foo/bar'); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Not implemented'); $uploadedFile->moveTo('bar.foo'); } public function testGetters() { - $stream = new BufferStream(); + $stream = new BufferedBody(''); $uploadedFile = new UploadedFile($stream, 0, UPLOAD_ERR_OK, 'foo.bar', 'foo/bar'); self::assertSame($stream, $uploadedFile->getStream()); self::assertSame(0, $uploadedFile->getSize()); @@ -51,14 +49,13 @@ public function testGetters() self::assertSame('foo/bar', $uploadedFile->getClientMediaType()); } - /** - * @expectedException \RuntimeException - * @expectedExceptionMessage Cannot retrieve stream due to upload error - */ public function testGetStreamOnFailedUpload() { - $stream = new BufferStream(); + $stream = new BufferedBody(''); $uploadedFile = new UploadedFile($stream, 0, UPLOAD_ERR_NO_FILE, 'foo.bar', 'foo/bar'); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Cannot retrieve stream due to upload error'); $uploadedFile->getStream(); } } diff --git a/tests/Message/RequestTest.php b/tests/Message/RequestTest.php new file mode 100644 index 00000000..148536a0 --- /dev/null +++ b/tests/Message/RequestTest.php @@ -0,0 +1,66 @@ +getBody(); + $this->assertSame(3, $body->getSize()); + $this->assertEquals('foo', (string) $body); + } + + public function testConstructWithStreamingRequestBodyReturnsBodyWhichImplementsReadableStreamInterfaceWithUnknownSize() + { + $request = new Request( + 'GET', + 'http://localhost/', + [], + new ThroughStream() + ); + + $body = $request->getBody(); + $this->assertInstanceOf(StreamInterface::class, $body); + $this->assertInstanceOf(ReadableStreamInterface::class, $body); + $this->assertNull($body->getSize()); + } + + public function testConstructWithHttpBodyStreamReturnsBodyAsIs() + { + $request = new Request( + 'GET', + 'http://localhost/', + [], + $body = new HttpBodyStream(new ThroughStream(), 100) + ); + + $this->assertSame($body, $request->getBody()); + } + + public function testConstructWithNullBodyThrows() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid request body given'); + new Request( + 'GET', + 'http://localhost/', + [], + null + ); + } +} diff --git a/tests/Message/ResponseExceptionTest.php b/tests/Message/ResponseExceptionTest.php new file mode 100644 index 00000000..b2eaccd3 --- /dev/null +++ b/tests/Message/ResponseExceptionTest.php @@ -0,0 +1,23 @@ +withStatus(404, 'File not found'); + + $e = new ResponseException($response); + + $this->assertEquals(404, $e->getCode()); + $this->assertEquals('HTTP status code 404 (File not found)', $e->getMessage()); + + $this->assertSame($response, $e->getResponse()); + } +} diff --git a/tests/Message/ResponseTest.php b/tests/Message/ResponseTest.php new file mode 100644 index 00000000..6d4acb73 --- /dev/null +++ b/tests/Message/ResponseTest.php @@ -0,0 +1,247 @@ +getBody(); + + /** @var \Psr\Http\Message\StreamInterface $body */ + $this->assertInstanceOf(StreamInterface::class, $body); + $this->assertEquals('hello', (string) $body); + } + + public function testConstructWithStreamingBodyWillReturnReadableBodyStream() + { + $response = new Response(200, [], new ThroughStream()); + $body = $response->getBody(); + + /** @var \Psr\Http\Message\StreamInterface $body */ + $this->assertInstanceOf(StreamInterface::class, $body); + $this->assertInstanceof(ReadableStreamInterface::class, $body); + $this->assertInstanceOf(HttpBodyStream::class, $body); + $this->assertNull($body->getSize()); + } + + public function testConstructWithHttpBodyStreamReturnsBodyAsIs() + { + $response = new Response( + 200, + [], + $body = new HttpBodyStream(new ThroughStream(), 100) + ); + + $this->assertSame($body, $response->getBody()); + } + + public function testFloatBodyWillThrow() + { + $this->expectException(\InvalidArgumentException::class); + new Response(200, [], 1.0); + } + + public function testResourceBodyWillThrow() + { + $this->expectException(\InvalidArgumentException::class); + new Response(200, [], tmpfile()); + } + + public function testWithStatusReturnsNewInstanceWhenStatusIsChanged() + { + $response = new Response(200); + + $new = $response->withStatus(404); + $this->assertNotSame($response, $new); + $this->assertEquals(404, $new->getStatusCode()); + $this->assertEquals('Not Found', $new->getReasonPhrase()); + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals('OK', $response->getReasonPhrase()); + } + + public function testWithStatusReturnsSameInstanceWhenStatusIsUnchanged() + { + $response = new Response(200); + + $new = $response->withStatus(200); + $this->assertSame($response, $new); + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals('OK', $response->getReasonPhrase()); + } + + public function testWithStatusReturnsNewInstanceWhenStatusIsUnchangedButReasonIsChanged() + { + $response = new Response(200); + + $new = $response->withStatus(200, 'Quite Ok'); + $this->assertNotSame($response, $new); + $this->assertEquals(200, $new->getStatusCode()); + $this->assertEquals('Quite Ok', $new->getReasonPhrase()); + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals('OK', $response->getReasonPhrase()); + } + + public function testHtmlMethodReturnsHtmlResponse() + { + $response = Response::html('Hello wörld!'); + + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals('text/html; charset=utf-8', $response->getHeaderLine('Content-Type')); + $this->assertEquals('Hello wörld!', (string) $response->getBody()); + } + + public function testJsonMethodReturnsPrettyPrintedJsonResponse() + { + $response = Response::json(['text' => 'Hello wörld!']); + + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals('application/json', $response->getHeaderLine('Content-Type')); + $this->assertEquals("{\n \"text\": \"Hello wörld!\"\n}\n", (string) $response->getBody()); + } + + public function testJsonMethodReturnsZeroFractionsInJsonResponse() + { + $response = Response::json(1.0); + + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals('application/json', $response->getHeaderLine('Content-Type')); + $this->assertEquals("1.0\n", (string) $response->getBody()); + } + + public function testJsonMethodReturnsJsonTextForSimpleString() + { + $response = Response::json('Hello world!'); + + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals('application/json', $response->getHeaderLine('Content-Type')); + $this->assertEquals("\"Hello world!\"\n", (string) $response->getBody()); + } + + public function testJsonMethodThrowsForInvalidString() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Unable to encode given data as JSON: Malformed UTF-8 characters, possibly incorrectly encoded'); + Response::json("Hello w\xF6rld!"); + } + + public function testPlaintextMethodReturnsPlaintextResponse() + { + $response = Response::plaintext("Hello wörld!\n"); + + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals('text/plain; charset=utf-8', $response->getHeaderLine('Content-Type')); + $this->assertEquals("Hello wörld!\n", (string) $response->getBody()); + } + + public function testXmlMethodReturnsXmlResponse() + { + $response = Response::xml('Hello wörld!'); + + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals('application/xml', $response->getHeaderLine('Content-Type')); + $this->assertEquals('Hello wörld!', (string) $response->getBody()); + } + + public function testParseMessageWithMinimalOkResponse() + { + $response = Response::parseMessage("HTTP/1.1 200 OK\r\n"); + + $this->assertEquals('1.1', $response->getProtocolVersion()); + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals('OK', $response->getReasonPhrase()); + $this->assertEquals([], $response->getHeaders()); + } + + public function testParseMessageWithSimpleOkResponse() + { + $response = Response::parseMessage("HTTP/1.1 200 OK\r\nServer: demo\r\n"); + + $this->assertEquals('1.1', $response->getProtocolVersion()); + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals('OK', $response->getReasonPhrase()); + $this->assertEquals(['Server' => ['demo']], $response->getHeaders()); + } + + public function testParseMessageWithSimpleOkResponseWithCustomReasonPhrase() + { + $response = Response::parseMessage("HTTP/1.1 200 Mostly Okay\r\nServer: demo\r\n"); + + $this->assertEquals('1.1', $response->getProtocolVersion()); + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals('Mostly Okay', $response->getReasonPhrase()); + $this->assertEquals(['Server' => ['demo']], $response->getHeaders()); + } + + public function testParseMessageWithSimpleOkResponseWithEmptyReasonPhraseAppliesDefault() + { + $response = Response::parseMessage("HTTP/1.1 200 \r\nServer: demo\r\n"); + + $this->assertEquals('1.1', $response->getProtocolVersion()); + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals('OK', $response->getReasonPhrase()); + $this->assertEquals(['Server' => ['demo']], $response->getHeaders()); + } + + public function testParseMessageWithSimpleOkResponseWithoutReasonPhraseAndWhitespaceSeparatorAppliesDefault() + { + $response = Response::parseMessage("HTTP/1.1 200\r\nServer: demo\r\n"); + + $this->assertEquals('1.1', $response->getProtocolVersion()); + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals('OK', $response->getReasonPhrase()); + $this->assertEquals(['Server' => ['demo']], $response->getHeaders()); + } + + public function testParseMessageWithHttp10SimpleOkResponse() + { + $response = Response::parseMessage("HTTP/1.0 200 OK\r\nServer: demo\r\n"); + + $this->assertEquals('1.0', $response->getProtocolVersion()); + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals('OK', $response->getReasonPhrase()); + $this->assertEquals(['Server' => ['demo']], $response->getHeaders()); + } + + public function testParseMessageWithHttp10SimpleOkResponseWithLegacyNewlines() + { + $response = Response::parseMessage("HTTP/1.0 200 OK\nServer: demo\r\n"); + + $this->assertEquals('1.0', $response->getProtocolVersion()); + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals('OK', $response->getReasonPhrase()); + $this->assertEquals(['Server' => ['demo']], $response->getHeaders()); + } + + public function testParseMessageWithInvalidHttpProtocolVersion12Throws() + { + $this->expectException(\InvalidArgumentException::class); + Response::parseMessage("HTTP/1.2 200 OK\r\n"); + } + + public function testParseMessageWithInvalidHttpProtocolVersion2Throws() + { + $this->expectException(\InvalidArgumentException::class); + Response::parseMessage("HTTP/2 200 OK\r\n"); + } + + public function testParseMessageWithInvalidStatusCodeUnderflowThrows() + { + $this->expectException(\InvalidArgumentException::class); + Response::parseMessage("HTTP/1.1 99 OK\r\n"); + } + + public function testParseMessageWithInvalidResponseHeaderFieldThrows() + { + $this->expectException(\InvalidArgumentException::class); + Response::parseMessage("HTTP/1.1 200 OK\r\nServer\r\n"); + } +} diff --git a/tests/Message/ServerRequestTest.php b/tests/Message/ServerRequestTest.php new file mode 100644 index 00000000..596ebf47 --- /dev/null +++ b/tests/Message/ServerRequestTest.php @@ -0,0 +1,489 @@ +request = new ServerRequest('GET', 'http://localhost/'); + } + + public function testGetNoAttributes() + { + $this->assertEquals([], $this->request->getAttributes()); + } + + public function testWithAttribute() + { + $request = $this->request->withAttribute('hello', 'world'); + + $this->assertNotSame($request, $this->request); + $this->assertEquals(['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->assertNull($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(['test' => 'nice'], $request->getAttributes()); + } + + public function testGetQueryParamsFromConstructorUri() + { + $this->request = new ServerRequest('GET', 'http://localhost/?test=world'); + + $this->assertEquals(['test' => 'world'], $this->request->getQueryParams()); + } + + public function testWithCookieParams() + { + $request = $this->request->withCookieParams(['test' => 'world']); + + $this->assertNotSame($request, $this->request); + $this->assertEquals(['test' => 'world'], $request->getCookieParams()); + } + + public function testGetQueryParamsFromConstructorUriUrlencoded() + { + $this->request = new ServerRequest('GET', 'http://localhost/?test=hello+world%21'); + + $this->assertEquals(['test' => 'hello world!'], $this->request->getQueryParams()); + } + + public function testWithQueryParams() + { + $request = $this->request->withQueryParams(['test' => 'world']); + + $this->assertNotSame($request, $this->request); + $this->assertEquals(['test' => 'world'], $request->getQueryParams()); + } + + public function testWithQueryParamsWithoutSpecialEncoding() + { + $request = $this->request->withQueryParams(['test' => 'hello world!']); + + $this->assertNotSame($request, $this->request); + $this->assertEquals(['test' => 'hello world!'], $request->getQueryParams()); + } + + public function testWithUploadedFiles() + { + $request = $this->request->withUploadedFiles(['test' => 'world']); + + $this->assertNotSame($request, $this->request); + $this->assertEquals(['test' => 'world'], $request->getUploadedFiles()); + } + + public function testWithParsedBody() + { + $request = $this->request->withParsedBody(['test' => 'world']); + + $this->assertNotSame($request, $this->request); + $this->assertEquals(['test' => 'world'], $request->getParsedBody()); + } + + public function testServerRequestParameter() + { + $body = 'hello=world'; + $request = new ServerRequest( + 'POST', + 'http://127.0.0.1/', + ['Content-Length' => strlen($body)], + $body, + '1.0', + ['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() + { + $this->request = new ServerRequest( + 'GET', + 'http://localhost/', + ['Cookie' => 'hello=world'] + ); + + $cookies = $this->request->getCookieParams(); + $this->assertEquals(['hello' => 'world'], $cookies); + } + + public function testParseMultipleCookieNameValuePairWillReturnValidArray() + { + $this->request = new ServerRequest( + 'GET', + 'http://localhost/', + ['Cookie' => 'hello=world; test=abc'] + ); + + $cookies = $this->request->getCookieParams(); + $this->assertEquals(['hello' => 'world', 'test' => 'abc'], $cookies); + } + + public function testParseMultipleCookieHeadersAreNotAllowedAndWillReturnEmptyArray() + { + $this->request = new ServerRequest( + 'GET', + 'http://localhost/', + ['Cookie' => ['hello=world', 'test=abc']] + ); + + $cookies = $this->request->getCookieParams(); + $this->assertEquals([], $cookies); + } + + public function testMultipleCookiesWithSameNameWillReturnLastValue() + { + $this->request = new ServerRequest( + 'GET', + 'http://localhost/', + ['Cookie' => 'hello=world; hello=abc'] + ); + + $cookies = $this->request->getCookieParams(); + $this->assertEquals(['hello' => 'abc'], $cookies); + } + + public function testOtherEqualSignsWillBeAddedToValueAndWillReturnValidArray() + { + $this->request = new ServerRequest( + 'GET', + 'http://localhost/', + ['Cookie' => 'hello=world=test=php'] + ); + + $cookies = $this->request->getCookieParams(); + $this->assertEquals(['hello' => 'world=test=php'], $cookies); + } + + public function testSingleCookieValueInCookiesReturnsEmptyArray() + { + $this->request = new ServerRequest( + 'GET', + 'http://localhost/', + ['Cookie' => 'world'] + ); + + $cookies = $this->request->getCookieParams(); + $this->assertEquals([], $cookies); + } + + public function testSingleMutlipleCookieValuesReturnsEmptyArray() + { + $this->request = new ServerRequest( + 'GET', + 'http://localhost/', + ['Cookie' => 'world; test'] + ); + + $cookies = $this->request->getCookieParams(); + $this->assertEquals([], $cookies); + } + + public function testSingleValueIsValidInMultipleValueCookieWillReturnValidArray() + { + $this->request = new ServerRequest( + 'GET', + 'http://localhost/', + ['Cookie' => 'world; test=php'] + ); + + $cookies = $this->request->getCookieParams(); + $this->assertEquals(['test' => 'php'], $cookies); + } + + public function testUrlEncodingForValueWillReturnValidArray() + { + $this->request = new ServerRequest( + 'GET', + 'http://localhost/', + ['Cookie' => 'hello=world%21; test=100%25%20coverage'] + ); + + $cookies = $this->request->getCookieParams(); + $this->assertEquals(['hello' => 'world!', 'test' => '100% coverage'], $cookies); + } + + public function testUrlEncodingForKeyWillReturnValidArray() + { + $this->request = new ServerRequest( + 'GET', + 'http://localhost/', + ['Cookie' => 'react%3Bphp=is%20great'] + ); + + $cookies = $this->request->getCookieParams(); + $this->assertEquals(['react%3Bphp' => 'is great'], $cookies); + } + + public function testCookieWithoutSpaceAfterSeparatorWillBeAccepted() + { + $this->request = new ServerRequest( + 'GET', + 'http://localhost/', + ['Cookie' => 'hello=world;react=php'] + ); + + $cookies = $this->request->getCookieParams(); + $this->assertEquals(['hello' => 'world', 'react' => 'php'], $cookies); + } + + public function testConstructWithStringRequestBodyReturnsStringBodyWithAutomaticSize() + { + $request = new ServerRequest( + 'GET', + 'http://localhost/', + [], + 'foo' + ); + + $body = $request->getBody(); + $this->assertSame(3, $body->getSize()); + $this->assertEquals('foo', (string) $body); + } + + public function testConstructWithHttpBodyStreamReturnsBodyAsIs() + { + $request = new ServerRequest( + 'GET', + 'http://localhost/', + [], + $body = new HttpBodyStream(new ThroughStream(), 100) + ); + + $this->assertSame($body, $request->getBody()); + } + + public function testConstructWithStreamingRequestBodyReturnsBodyWhichImplementsReadableStreamInterfaceWithSizeZeroDefault() + { + $request = new ServerRequest( + 'GET', + 'http://localhost/', + [], + new ThroughStream() + ); + + $body = $request->getBody(); + $this->assertInstanceOf(StreamInterface::class, $body); + $this->assertInstanceOf(ReadableStreamInterface::class, $body); + $this->assertSame(0, $body->getSize()); + } + + public function testConstructWithStreamingRequestBodyReturnsBodyWithSizeFromContentLengthHeader() + { + $request = new ServerRequest( + 'GET', + 'http://localhost/', + [ + 'Content-Length' => 100 + ], + new ThroughStream() + ); + + $body = $request->getBody(); + $this->assertInstanceOf(StreamInterface::class, $body); + $this->assertInstanceOf(ReadableStreamInterface::class, $body); + $this->assertSame(100, $body->getSize()); + } + + public function testConstructWithStreamingRequestBodyReturnsBodyWithSizeUnknownForTransferEncodingChunked() + { + $request = new ServerRequest( + 'GET', + 'http://localhost/', + [ + 'Transfer-Encoding' => 'Chunked' + ], + new ThroughStream() + ); + + $body = $request->getBody(); + $this->assertInstanceOf(StreamInterface::class, $body); + $this->assertInstanceOf(ReadableStreamInterface::class, $body); + $this->assertNull($body->getSize()); + } + + public function testConstructWithFloatRequestBodyThrows() + { + $this->expectException(\InvalidArgumentException::class); + new ServerRequest( + 'GET', + 'http://localhost/', + [], + 1.0 + ); + } + + public function testConstructWithResourceRequestBodyThrows() + { + $this->expectException(\InvalidArgumentException::class); + new ServerRequest( + 'GET', + 'http://localhost/', + [], + tmpfile() + ); + } + + public function testParseMessageWithSimpleGetRequest() + { + $request = ServerRequest::parseMessage("GET / HTTP/1.1\r\nHost: example.com\r\n", []); + + $this->assertEquals('GET', $request->getMethod()); + $this->assertEquals('http://example.com/', (string) $request->getUri()); + $this->assertEquals('1.1', $request->getProtocolVersion()); + } + + public function testParseMessageWithHttp10RequestWithoutHost() + { + $request = ServerRequest::parseMessage("GET / HTTP/1.0\r\n", []); + + $this->assertEquals('GET', $request->getMethod()); + $this->assertEquals('http://127.0.0.1/', (string) $request->getUri()); + $this->assertEquals('1.0', $request->getProtocolVersion()); + } + + public function testParseMessageWithOptionsMethodWithAsteriskFormRequestTarget() + { + $request = ServerRequest::parseMessage("OPTIONS * HTTP/1.1\r\nHost: example.com\r\n", []); + + $this->assertEquals('OPTIONS', $request->getMethod()); + $this->assertEquals('*', $request->getRequestTarget()); + $this->assertEquals('1.1', $request->getProtocolVersion()); + $this->assertEquals('http://example.com/', (string) $request->getUri()); + } + + public function testParseMessageWithConnectMethodWithAuthorityFormRequestTarget() + { + $request = ServerRequest::parseMessage("CONNECT example.com:80 HTTP/1.1\r\nHost: example.com:80\r\n", []); + + $this->assertEquals('CONNECT', $request->getMethod()); + $this->assertEquals('example.com:80', $request->getRequestTarget()); + $this->assertEquals('1.1', $request->getProtocolVersion()); + $this->assertEquals('http://example.com/', (string) $request->getUri()); + } + + public function testParseMessageWithInvalidHttp11RequestWithoutHostThrows() + { + $this->expectException(\InvalidArgumentException::class); + ServerRequest::parseMessage("GET / HTTP/1.1\r\n", []); + } + + public function testParseMessageWithInvalidHttpProtocolVersionThrows() + { + $this->expectException(\InvalidArgumentException::class); + ServerRequest::parseMessage("GET / HTTP/1.2\r\n", []); + } + + public function testParseMessageWithInvalidProtocolThrows() + { + $this->expectException(\InvalidArgumentException::class); + ServerRequest::parseMessage("GET / CUSTOM/1.1\r\n", []); + } + + public function testParseMessageWithInvalidHostHeaderWithoutValueThrows() + { + $this->expectException(\InvalidArgumentException::class); + ServerRequest::parseMessage("GET / HTTP/1.1\r\nHost\r\n", []); + } + + public function testParseMessageWithInvalidHostHeaderSyntaxThrows() + { + $this->expectException(\InvalidArgumentException::class); + ServerRequest::parseMessage("GET / HTTP/1.1\r\nHost: ///\r\n", []); + } + + public function testParseMessageWithInvalidHostHeaderWithSchemeThrows() + { + $this->expectException(\InvalidArgumentException::class); + ServerRequest::parseMessage("GET / HTTP/1.1\r\nHost: http://localhost\r\n", []); + } + + public function testParseMessageWithInvalidHostHeaderWithQueryThrows() + { + $this->expectException(\InvalidArgumentException::class); + ServerRequest::parseMessage("GET / HTTP/1.1\r\nHost: localhost?foo\r\n", []); + } + + public function testParseMessageWithInvalidHostHeaderWithFragmentThrows() + { + $this->expectException(\InvalidArgumentException::class); + ServerRequest::parseMessage("GET / HTTP/1.1\r\nHost: localhost#foo\r\n", []); + } + + public function testParseMessageWithInvalidContentLengthHeaderThrows() + { + $this->expectException(\InvalidArgumentException::class); + ServerRequest::parseMessage("GET / HTTP/1.1\r\nHost: localhost\r\nContent-Length:\r\n", []); + } + + public function testParseMessageWithInvalidTransferEncodingHeaderThrows() + { + $this->expectException(\InvalidArgumentException::class); + ServerRequest::parseMessage("GET / HTTP/1.1\r\nHost: localhost\r\nTransfer-Encoding:\r\n", []); + } + + public function testParseMessageWithInvalidBothContentLengthHeaderAndTransferEncodingHeaderThrows() + { + $this->expectException(\InvalidArgumentException::class); + ServerRequest::parseMessage("GET / HTTP/1.1\r\nHost: localhost\r\nContent-Length: 0\r\nTransfer-Encoding: chunked\r\n", []); + } + + public function testParseMessageWithInvalidEmptyHostHeaderWithAbsoluteFormRequestTargetThrows() + { + $this->expectException(\InvalidArgumentException::class); + ServerRequest::parseMessage("GET http://example.com/ HTTP/1.1\r\nHost: \r\n", []); + } + + public function testParseMessageWithInvalidConnectMethodNotUsingAuthorityFormThrows() + { + $this->expectException(\InvalidArgumentException::class); + ServerRequest::parseMessage("CONNECT / HTTP/1.1\r\nHost: localhost\r\n", []); + } + + public function testParseMessageWithInvalidRequestTargetAsteriskFormThrows() + { + $this->expectException(\InvalidArgumentException::class); + ServerRequest::parseMessage("GET * HTTP/1.1\r\nHost: localhost\r\n", []); + } +} diff --git a/tests/Message/UriTest.php b/tests/Message/UriTest.php new file mode 100644 index 00000000..1774953f --- /dev/null +++ b/tests/Message/UriTest.php @@ -0,0 +1,707 @@ +expectException(\InvalidArgumentException::class); + new Uri('///'); + } + + public function testCtorWithInvalidSchemeThrows() + { + $this->expectException(\InvalidArgumentException::class); + new Uri('not+a+scheme://localhost'); + } + + public function testCtorWithInvalidHostThrows() + { + $this->expectException(\InvalidArgumentException::class); + new Uri('http://not a host/'); + } + + public function testCtorWithInvalidPortThrows() + { + $this->expectException(\InvalidArgumentException::class); + new Uri('http://localhost:80000/'); + } + + public static function provideValidUris() + { + yield [ + 'http://localhost/' + ]; + yield [ + 'http://localhost/' + ]; + yield [ + 'http://localhost:8080/' + ]; + yield [ + 'http://127.0.0.1/' + ]; + yield [ + 'http://[::1]:8080/' + ]; + yield [ + 'http://localhost/path' + ]; + yield [ + 'http://localhost/sub/path' + ]; + yield [ + 'http://localhost/with%20space' + ]; + yield [ + 'http://localhost/with%2fslash' + ]; + yield [ + 'http://localhost/?name=Alice' + ]; + yield [ + 'http://localhost/?name=John+Doe' + ]; + yield [ + 'http://localhost/?name=John%20Doe' + ]; + yield [ + 'http://localhost/?name=Alice&age=42' + ]; + yield [ + 'http://localhost/?name=Alice&' + ]; + yield [ + 'http://localhost/?choice=A%26B' + ]; + yield [ + 'http://localhost/?safe=Yes!?' + ]; + yield [ + 'http://localhost/?alias=@home' + ]; + yield [ + 'http://localhost/?assign:=true' + ]; + yield [ + 'http://localhost/?name=' + ]; + yield [ + 'http://localhost/?name' + ]; + yield [ + '' + ]; + yield [ + '/' + ]; + yield [ + '/path' + ]; + yield [ + 'path' + ]; + yield [ + 'http://user@localhost/' + ]; + yield [ + 'http://user@localhost/' + ]; + yield [ + 'http://:pass@localhost/' + ]; + yield [ + 'http://user:pass@localhost/path?query#fragment' + ]; + yield [ + 'http://user%20name:pass%20word@localhost/path%20name?query%20name#frag%20ment' + ]; + yield [ + 'http://docker_container/' + ]; + } + + /** + * @dataProvider provideValidUris + * @param string $string + */ + public function testToStringReturnsOriginalUriGivenToCtor($string) + { + $uri = new Uri($string); + + $this->assertEquals($string, (string) $uri); + } + + public static function provideValidUrisThatWillBeTransformed() + { + yield [ + 'http://localhost:8080/?', + 'http://localhost:8080/' + ]; + yield [ + 'http://localhost:8080/#', + 'http://localhost:8080/' + ]; + yield [ + 'http://localhost:8080/?#', + 'http://localhost:8080/' + ]; + yield [ + 'http://localhost:8080/', + 'http://localhost:8080/' + ]; + yield [ + 'http://localhost:8080/?percent=50%', + 'http://localhost:8080/?percent=50%25' + ]; + yield [ + 'http://user%20name:pass%20word@localhost/path%20name?query%20name#frag%20ment', + 'http://user%20name:pass%20word@localhost/path%20name?query%20name#frag%20ment' + ]; + yield [ + 'HTTP://USER:PASS@LOCALHOST:8080/PATH?QUERY#FRAGMENT', + 'http://USER:PASS@localhost:8080/PATH?QUERY#FRAGMENT' + ]; + } + + /** + * @dataProvider provideValidUrisThatWillBeTransformed + * @param string $string + * @param string $escaped + */ + public function testToStringReturnsTransformedUriFromUriGivenToCtor($string, $escaped = null) + { + $uri = new Uri($string); + + $this->assertEquals($escaped, (string) $uri); + } + + public function testToStringReturnsUriWithPathPrefixedWithSlashWhenPathDoesNotStartWithSlash() + { + $uri = new Uri('http://localhost:8080/'); + $uri = $uri->withPath('path'); + + $this->assertEquals('http://localhost:8080/path', (string) $uri); + } + + public function testWithSchemeReturnsNewInstanceWhenSchemeIsChanged() + { + $uri = new Uri('http://localhost/'); + + $new = $uri->withScheme('https'); + $this->assertNotSame($uri, $new); + $this->assertEquals('https', $new->getScheme()); + $this->assertEquals('http', $uri->getScheme()); + } + + public function testWithSchemeReturnsNewInstanceWithSchemeToLowerCaseWhenSchemeIsChangedWithUpperCase() + { + $uri = new Uri('http://localhost/'); + + $new = $uri->withScheme('HTTPS'); + $this->assertNotSame($uri, $new); + $this->assertEquals('https', $new->getScheme()); + $this->assertEquals('http', $uri->getScheme()); + } + + public function testWithSchemeReturnsNewInstanceWithDefaultPortRemovedWhenSchemeIsChangedToDefaultPortForHttp() + { + $uri = new Uri('https://localhost:80/'); + + $new = $uri->withScheme('http'); + $this->assertNotSame($uri, $new); + $this->assertNull($new->getPort()); + $this->assertEquals(80, $uri->getPort()); + } + + public function testWithSchemeReturnsNewInstanceWithDefaultPortRemovedWhenSchemeIsChangedToDefaultPortForHttps() + { + $uri = new Uri('http://localhost:443/'); + + $new = $uri->withScheme('https'); + $this->assertNotSame($uri, $new); + $this->assertNull($new->getPort()); + $this->assertEquals(443, $uri->getPort()); + } + + public function testWithSchemeReturnsSameInstanceWhenSchemeIsUnchanged() + { + $uri = new Uri('http://localhost/'); + + $new = $uri->withScheme('http'); + $this->assertSame($uri, $new); + $this->assertEquals('http', $uri->getScheme()); + } + + public function testWithSchemeReturnsSameInstanceWhenSchemeToLowerCaseIsUnchanged() + { + $uri = new Uri('http://localhost/'); + + $new = $uri->withScheme('HTTP'); + $this->assertSame($uri, $new); + $this->assertEquals('http', $uri->getScheme()); + } + + public function testWithSchemeThrowsWhenSchemeIsInvalid() + { + $uri = new Uri('http://localhost/'); + + $this->expectException(\InvalidArgumentException::class); + $uri->withScheme('invalid+scheme'); + } + + public function testWithUserInfoReturnsNewInstanceWhenUserInfoIsChangedWithNameAndPassword() + { + $uri = new Uri('http://localhost/'); + + $new = $uri->withUserInfo('user', 'pass'); + $this->assertNotSame($uri, $new); + $this->assertEquals('user:pass', $new->getUserInfo()); + $this->assertEquals('', $uri->getUserInfo()); + } + + public function testWithUserInfoReturnsNewInstanceWhenUserInfoIsChangedWithNameOnly() + { + $uri = new Uri('http://localhost/'); + + $new = $uri->withUserInfo('user'); + $this->assertNotSame($uri, $new); + $this->assertEquals('user', $new->getUserInfo()); + $this->assertEquals('', $uri->getUserInfo()); + } + + public function testWithUserInfoReturnsNewInstanceWhenUserInfoIsChangedWithNameAndEmptyPassword() + { + $uri = new Uri('http://localhost/'); + + $new = $uri->withUserInfo('user', ''); + $this->assertNotSame($uri, $new); + $this->assertEquals('user:', $new->getUserInfo()); + $this->assertEquals('', $uri->getUserInfo()); + } + + public function testWithUserInfoReturnsNewInstanceWhenUserInfoIsChangedWithPasswordOnly() + { + $uri = new Uri('http://localhost/'); + + $new = $uri->withUserInfo('', 'pass'); + $this->assertNotSame($uri, $new); + $this->assertEquals(':pass', $new->getUserInfo()); + $this->assertEquals('', $uri->getUserInfo()); + } + + public function testWithUserInfoReturnsNewInstanceWhenUserInfoIsChangedWithNameAndPasswordEncoded() + { + $uri = new Uri('http://localhost/'); + + $new = $uri->withUserInfo('user:alice', 'pass%20word'); + $this->assertNotSame($uri, $new); + $this->assertEquals('user%3Aalice:pass%20word', $new->getUserInfo()); + $this->assertEquals('', $uri->getUserInfo()); + } + + public function testWithSchemeReturnsSameInstanceWhenSchemeIsUnchangedEmpty() + { + $uri = new Uri('http://localhost/'); + + $new = $uri->withUserInfo(''); + $this->assertSame($uri, $new); + $this->assertEquals('', $uri->getUserInfo()); + } + + public function testWithSchemeReturnsSameInstanceWhenSchemeIsUnchangedWithNameAndPassword() + { + $uri = new Uri('http://user:pass@localhost/'); + + $new = $uri->withUserInfo('user', 'pass'); + $this->assertSame($uri, $new); + $this->assertEquals('user:pass', $uri->getUserInfo()); + } + + public function testWithHostReturnsNewInstanceWhenHostIsChanged() + { + $uri = new Uri('http://localhost/'); + + $new = $uri->withHost('example.com'); + $this->assertNotSame($uri, $new); + $this->assertEquals('example.com', $new->getHost()); + $this->assertEquals('localhost', $uri->getHost()); + } + + public function testWithHostReturnsNewInstanceWhenHostIsChangedWithUnderscore() + { + $uri = new Uri('http://localhost/'); + + $new = $uri->withHost('docker_container'); + $this->assertNotSame($uri, $new); + $this->assertEquals('docker_container', $new->getHost()); + $this->assertEquals('localhost', $uri->getHost()); + } + + public function testWithHostReturnsNewInstanceWithHostToLowerCaseWhenHostIsChangedWithUpperCase() + { + $uri = new Uri('http://localhost/'); + + $new = $uri->withHost('EXAMPLE.COM'); + $this->assertNotSame($uri, $new); + $this->assertEquals('example.com', $new->getHost()); + $this->assertEquals('localhost', $uri->getHost()); + } + + public function testWithHostReturnsNewInstanceWhenHostIsChangedToEmptyString() + { + $uri = new Uri('http://localhost/'); + + $new = $uri->withHost(''); + $this->assertNotSame($uri, $new); + $this->assertEquals('', $new->getHost()); + $this->assertEquals('localhost', $uri->getHost()); + } + + public function testWithHostReturnsSameInstanceWhenHostIsUnchanged() + { + $uri = new Uri('http://localhost/'); + + $new = $uri->withHost('localhost'); + $this->assertSame($uri, $new); + $this->assertEquals('localhost', $uri->getHost()); + } + + public function testWithHostReturnsSameInstanceWhenHostToLowerCaseIsUnchanged() + { + $uri = new Uri('http://localhost/'); + + $new = $uri->withHost('LOCALHOST'); + $this->assertSame($uri, $new); + $this->assertEquals('localhost', $uri->getHost()); + } + + public function testWithHostThrowsWhenHostIsInvalidWithPlus() + { + $uri = new Uri('http://localhost/'); + + $this->expectException(\InvalidArgumentException::class); + $uri->withHost('invalid+host'); + } + + public function testWithHostThrowsWhenHostIsInvalidWithSpace() + { + $uri = new Uri('http://localhost/'); + + $this->expectException(\InvalidArgumentException::class); + $uri->withHost('invalid host'); + } + + public function testWithPortReturnsNewInstanceWhenPortIsChanged() + { + $uri = new Uri('http://localhost/'); + + $new = $uri->withPort(8080); + $this->assertNotSame($uri, $new); + $this->assertEquals(8080, $new->getPort()); + $this->assertNull($uri->getPort()); + } + + public function testWithPortReturnsNewInstanceWithDefaultPortRemovedWhenPortIsChangedToDefaultPortForHttp() + { + $uri = new Uri('http://localhost:8080/'); + + $new = $uri->withPort(80); + $this->assertNotSame($uri, $new); + $this->assertNull($new->getPort()); + $this->assertEquals(8080, $uri->getPort()); + } + + public function testWithPortReturnsNewInstanceWithDefaultPortRemovedWhenPortIsChangedToDefaultPortForHttps() + { + $uri = new Uri('https://localhost:8080/'); + + $new = $uri->withPort(443); + $this->assertNotSame($uri, $new); + $this->assertNull($new->getPort()); + $this->assertEquals(8080, $uri->getPort()); + } + + public function testWithPortReturnsSameInstanceWhenPortIsUnchanged() + { + $uri = new Uri('http://localhost:8080/'); + + $new = $uri->withPort(8080); + $this->assertSame($uri, $new); + $this->assertEquals(8080, $uri->getPort()); + } + + public function testWithPortReturnsSameInstanceWhenPortIsUnchangedDefaultPortForHttp() + { + $uri = new Uri('http://localhost/'); + + $new = $uri->withPort(80); + $this->assertSame($uri, $new); + $this->assertNull($uri->getPort()); + } + + public function testWithPortReturnsSameInstanceWhenPortIsUnchangedDefaultPortForHttps() + { + $uri = new Uri('https://localhost/'); + + $new = $uri->withPort(443); + $this->assertSame($uri, $new); + $this->assertNull($uri->getPort()); + } + + public function testWithPortThrowsWhenPortIsInvalidUnderflow() + { + $uri = new Uri('http://localhost/'); + + $this->expectException(\InvalidArgumentException::class); + $uri->withPort(0); + } + + public function testWithPortThrowsWhenPortIsInvalidOverflow() + { + $uri = new Uri('http://localhost/'); + + $this->expectException(\InvalidArgumentException::class); + $uri->withPort(65536); + } + + public function testWithPathReturnsNewInstanceWhenPathIsChanged() + { + $uri = new Uri('http://localhost/'); + + $new = $uri->withPath('/path'); + $this->assertNotSame($uri, $new); + $this->assertEquals('/path', $new->getPath()); + $this->assertEquals('/', $uri->getPath()); + } + + public function testWithPathReturnsNewInstanceWhenPathIsChangedEncoded() + { + $uri = new Uri('http://localhost/'); + + $new = $uri->withPath('/a new/path%20here!'); + $this->assertNotSame($uri, $new); + $this->assertEquals('/a%20new/path%20here!', $new->getPath()); + $this->assertEquals('/', $uri->getPath()); + } + + public function testWithPathReturnsSameInstanceWhenPathIsUnchanged() + { + $uri = new Uri('http://localhost/path'); + + $new = $uri->withPath('/path'); + $this->assertSame($uri, $new); + $this->assertEquals('/path', $uri->getPath()); + } + + public function testWithPathReturnsSameInstanceWhenPathIsUnchangedEncoded() + { + $uri = new Uri('http://localhost/a%20new/path%20here!'); + + $new = $uri->withPath('/a new/path%20here!'); + $this->assertSame($uri, $new); + $this->assertEquals('/a%20new/path%20here!', $uri->getPath()); + } + + public function testWithQueryReturnsNewInstanceWhenQueryIsChanged() + { + $uri = new Uri('http://localhost/'); + + $new = $uri->withQuery('foo=bar'); + $this->assertNotSame($uri, $new); + $this->assertEquals('foo=bar', $new->getQuery()); + $this->assertEquals('', $uri->getQuery()); + } + + public function testWithQueryReturnsNewInstanceWhenQueryIsChangedEncoded() + { + $uri = new Uri('http://localhost/'); + + $new = $uri->withQuery('foo=a new%20text!'); + $this->assertNotSame($uri, $new); + $this->assertEquals('foo=a%20new%20text!', $new->getQuery()); + $this->assertEquals('', $uri->getQuery()); + } + + public function testWithQueryReturnsSameInstanceWhenQueryIsUnchanged() + { + $uri = new Uri('http://localhost/?foo=bar'); + + $new = $uri->withQuery('foo=bar'); + $this->assertSame($uri, $new); + $this->assertEquals('foo=bar', $uri->getQuery()); + } + + public function testWithQueryReturnsSameInstanceWhenQueryIsUnchangedEncoded() + { + $uri = new Uri('http://localhost/?foo=a%20new%20text!'); + + $new = $uri->withQuery('foo=a new%20text!'); + $this->assertSame($uri, $new); + $this->assertEquals('foo=a%20new%20text!', $uri->getQuery()); + } + + public function testWithFragmentReturnsNewInstanceWhenFragmentIsChanged() + { + $uri = new Uri('http://localhost/'); + + $new = $uri->withFragment('section'); + $this->assertNotSame($uri, $new); + $this->assertEquals('section', $new->getFragment()); + $this->assertEquals('', $uri->getFragment()); + } + + public function testWithFragmentReturnsNewInstanceWhenFragmentIsChangedEncoded() + { + $uri = new Uri('http://localhost/'); + + $new = $uri->withFragment('section new%20text!'); + $this->assertNotSame($uri, $new); + $this->assertEquals('section%20new%20text!', $new->getFragment()); + $this->assertEquals('', $uri->getFragment()); + } + + public function testWithFragmentReturnsSameInstanceWhenFragmentIsUnchanged() + { + $uri = new Uri('http://localhost/#section'); + + $new = $uri->withFragment('section'); + $this->assertSame($uri, $new); + $this->assertEquals('section', $uri->getFragment()); + } + + public function testWithFragmentReturnsSameInstanceWhenFragmentIsUnchangedEncoded() + { + $uri = new Uri('http://localhost/#section%20new%20text!'); + + $new = $uri->withFragment('section new%20text!'); + $this->assertSame($uri, $new); + $this->assertEquals('section%20new%20text!', $uri->getFragment()); + } + + public static function provideResolveUris() + { + yield [ + 'http://localhost/', + '', + 'http://localhost/' + ]; + yield [ + 'http://localhost/', + 'http://example.com/', + 'http://example.com/' + ]; + yield [ + 'http://localhost/', + 'path', + 'http://localhost/path' + ]; + yield [ + 'http://localhost/', + 'path/', + 'http://localhost/path/' + ]; + yield [ + 'http://localhost/', + 'path//', + 'http://localhost/path/' + ]; + yield [ + 'http://localhost/', + 'path', + 'http://localhost/path' + ]; + yield [ + 'http://localhost/a/b', + '/path', + 'http://localhost/path' + ]; + yield [ + 'http://localhost/', + '/a/b/c', + 'http://localhost/a/b/c' + ]; + yield [ + 'http://localhost/a/path', + 'b/c', + 'http://localhost/a/b/c' + ]; + yield [ + 'http://localhost/a/path', + '/b/c', + 'http://localhost/b/c' + ]; + yield [ + 'http://localhost/a/path/', + 'b/c', + 'http://localhost/a/path/b/c' + ]; + yield [ + 'http://localhost/a/path/', + '../b/c', + 'http://localhost/a/b/c' + ]; + yield [ + 'http://localhost/', + '../../../a/b', + 'http://localhost/a/b' + ]; + yield [ + 'http://localhost/path', + '?query', + 'http://localhost/path?query' + ]; + yield [ + 'http://localhost/path', + '#fragment', + 'http://localhost/path#fragment' + ]; + yield [ + 'http://localhost/path', + 'http://localhost/', + 'http://localhost/' + ]; + yield [ + 'http://localhost/path', + 'http://localhost/?query#fragment', + 'http://localhost/?query#fragment' + ]; + yield [ + 'http://localhost/path/?a#fragment', + '?b', + 'http://localhost/path/?b' + ]; + yield [ + 'http://localhost/path', + '//localhost', + 'http://localhost/' + ]; + yield [ + 'http://localhost/path', + '//localhost/a?query', + 'http://localhost/a?query' + ]; + yield [ + 'http://localhost/path', + '//LOCALHOST', + 'http://localhost/' + ]; + } + + /** + * @dataProvider provideResolveUris + * @param string $base + * @param string $rel + * @param string $expected + */ + public function testResolveReturnsResolvedUri($base, $rel, $expected) + { + $uri = Uri::resolve(new Uri($base), new Uri($rel)); + + $this->assertEquals($expected, (string) $uri); + } +} diff --git a/tests/Middleware/LimitConcurrentRequestsMiddlewareTest.php b/tests/Middleware/LimitConcurrentRequestsMiddlewareTest.php index 859b82e7..67656d4c 100644 --- a/tests/Middleware/LimitConcurrentRequestsMiddlewareTest.php +++ b/tests/Middleware/LimitConcurrentRequestsMiddlewareTest.php @@ -4,14 +4,15 @@ use Psr\Http\Message\ServerRequestInterface; use React\Http\Io\HttpBodyStream; -use React\Http\Io\ServerRequest; +use React\Http\Message\Response; +use React\Http\Message\ServerRequest; use React\Http\Middleware\LimitConcurrentRequestsMiddleware; use React\Promise\Deferred; use React\Promise\Promise; +use React\Promise\PromiseInterface; +use React\Stream\ReadableStreamInterface; use React\Stream\ThroughStream; use React\Tests\Http\TestCase; -use React\Promise\PromiseInterface; -use React\Http\Response; final class LimitConcurrentRequestsMiddlewareTest extends TestCase { @@ -64,7 +65,10 @@ public function testLimitOneRequestConcurrently() $this->assertFalse($calledB); $this->assertFalse($calledC); - $limitHandlers($requestB, $nextB); + $promise = $limitHandlers($requestB, $nextB); + + assert($promise instanceof PromiseInterface); + $promise->then(null, $this->expectCallableOnce()); // avoid reporting unhandled rejection $this->assertTrue($calledA); $this->assertFalse($calledB); @@ -79,7 +83,7 @@ public function testLimitOneRequestConcurrently() /** * Ensure resolve frees up a slot */ - $deferredA->resolve(); + $deferredA->resolve(null); $this->assertTrue($calledA); $this->assertTrue($calledB); @@ -88,7 +92,7 @@ public function testLimitOneRequestConcurrently() /** * Ensure reject also frees up a slot */ - $deferredB->reject(); + $deferredB->reject(new \RuntimeException()); $this->assertTrue($calledA); $this->assertTrue($calledB); @@ -107,28 +111,23 @@ public function testReturnsResponseDirectlyFromMiddlewareWhenBelowLimit() $this->assertSame($response, $ret); } - /** - * @expectedException RuntimeException - * @expectedExceptionMessage demo - */ public function testThrowsExceptionDirectlyFromMiddlewareWhenBelowLimit() { $middleware = new LimitConcurrentRequestsMiddleware(1); + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('demo'); $middleware(new ServerRequest('GET', 'https://example.com/'), function () { throw new \RuntimeException('demo'); }); } - /** - * @requires PHP 7 - * @expectedException Error - * @expectedExceptionMessage demo - */ public function testThrowsErrorDirectlyFromMiddlewareWhenBelowLimit() { $middleware = new LimitConcurrentRequestsMiddleware(1); + $this->expectException(\Error::class); + $this->expectExceptionMessage('demo'); $middleware(new ServerRequest('GET', 'https://example.com/'), function () { throw new \Error('demo'); }); @@ -163,16 +162,16 @@ public function testReturnsPendingPromiseFromMiddlewareWhenAboveLimit() public function testStreamDoesNotPauseOrResumeWhenBelowLimit() { - $body = $this->getMockBuilder('React\Http\Io\HttpBodyStream')->disableOriginalConstructor()->getMock(); + $body = $this->createMock(HttpBodyStream::class); $body->expects($this->never())->method('pause'); $body->expects($this->never())->method('resume'); $limitHandlers = new LimitConcurrentRequestsMiddleware(1); - $limitHandlers(new ServerRequest('GET', 'https://example.com/', array(), $body), function () {}); + $limitHandlers(new ServerRequest('GET', 'https://example.com/', [], $body), function () {}); } public function testStreamDoesPauseWhenAboveLimit() { - $body = $this->getMockBuilder('React\Http\Io\HttpBodyStream')->disableOriginalConstructor()->getMock(); + $body = $this->createMock(HttpBodyStream::class); $body->expects($this->once())->method('pause'); $body->expects($this->never())->method('resume'); $limitHandlers = new LimitConcurrentRequestsMiddleware(1); @@ -181,24 +180,27 @@ public function testStreamDoesPauseWhenAboveLimit() return new Promise(function () { }); }); - $limitHandlers(new ServerRequest('GET', 'https://example.com/', array(), $body), function () {}); + $limitHandlers(new ServerRequest('GET', 'https://example.com/', [], $body), function () {}); } public function testStreamDoesPauseAndThenResumeWhenDequeued() { - $body = $this->getMockBuilder('React\Http\Io\HttpBodyStream')->disableOriginalConstructor()->getMock(); + $body = $this->createMock(HttpBodyStream::class); $body->expects($this->once())->method('pause'); $body->expects($this->once())->method('resume'); $limitHandlers = new LimitConcurrentRequestsMiddleware(1); $deferred = new Deferred(); - $limitHandlers(new ServerRequest('GET', 'https://example.com/'), function () use ($deferred) { + $promise = $limitHandlers(new ServerRequest('GET', 'https://example.com/'), function () use ($deferred) { return $deferred->promise(); }); - $limitHandlers(new ServerRequest('GET', 'https://example.com/', array(), $body), function () {}); + assert($promise instanceof PromiseInterface); + $promise->then(null, $this->expectCallableOnce()); // avoid reporting unhandled rejection - $deferred->reject(); + $limitHandlers(new ServerRequest('GET', 'https://example.com/', [], $body), function () {}); + + $deferred->reject(new \RuntimeException()); } public function testReceivesBufferedRequestSameInstance() @@ -206,7 +208,7 @@ public function testReceivesBufferedRequestSameInstance() $request = new ServerRequest( 'POST', 'http://example.com/', - array(), + [], 'hello' ); @@ -225,7 +227,7 @@ public function testReceivesStreamingBodyRequestSameInstanceWhenBelowLimit() $request = new ServerRequest( 'POST', 'http://example.com/', - array(), + [], new HttpBodyStream($stream, 5) ); @@ -247,7 +249,7 @@ public function testReceivesRequestsSequentially() $request = new ServerRequest( 'POST', 'http://example.com/', - array(), + [], 'hello' ); @@ -262,7 +264,7 @@ public function testDoesNotReceiveNextRequestIfHandlerIsPending() $request = new ServerRequest( 'POST', 'http://example.com/', - array(), + [], 'hello' ); @@ -281,16 +283,19 @@ public function testReceivesNextRequestAfterPreviousHandlerIsSettled() $request = new ServerRequest( 'POST', 'http://example.com/', - array(), + [], 'hello' ); $deferred = new Deferred(); $middleware = new LimitConcurrentRequestsMiddleware(1); - $middleware($request, function () use ($deferred) { + $promise = $middleware($request, function () use ($deferred) { return $deferred->promise(); }); + assert($promise instanceof PromiseInterface); + $promise->then(null, $this->expectCallableOnce()); // avoid reporting unhandled rejection + $deferred->reject(new \RuntimeException()); $middleware($request, $this->expectCallableOnceWith($request)); @@ -301,16 +306,19 @@ public function testReceivesNextRequestWhichThrowsAfterPreviousHandlerIsSettled( $request = new ServerRequest( 'POST', 'http://example.com/', - array(), + [], 'hello' ); $deferred = new Deferred(); $middleware = new LimitConcurrentRequestsMiddleware(1); - $middleware($request, function () use ($deferred) { + $promise = $middleware($request, function () use ($deferred) { return $deferred->promise(); }); + assert($promise instanceof PromiseInterface); + $promise->then(null, $this->expectCallableOnce()); // avoid reporting unhandled rejection + $second = $middleware($request, function () { throw new \RuntimeException(); }); @@ -326,7 +334,7 @@ public function testPendingRequestCanBeCancelledAndForwardsCancellationToInnerPr $request = new ServerRequest( 'POST', 'http://example.com/', - array(), + [], 'hello' ); @@ -349,7 +357,7 @@ public function testQueuedRequestCanBeCancelledBeforeItStartsProcessing() $request = new ServerRequest( 'POST', 'http://example.com/', - array(), + [], 'hello' ); @@ -371,7 +379,7 @@ public function testReceivesNextRequestAfterPreviousHandlerIsCancelled() $request = new ServerRequest( 'POST', 'http://example.com/', - array(), + [], 'hello' ); @@ -395,7 +403,7 @@ public function testRejectsWhenQueuedPromiseIsCancelled() $request = new ServerRequest( 'POST', 'http://example.com/', - array(), + [], 'hello' ); @@ -417,7 +425,7 @@ public function testDoesNotInvokeNextHandlersWhenQueuedPromiseIsCancelled() $request = new ServerRequest( 'POST', 'http://example.com/', - array(), + [], 'hello' ); @@ -440,30 +448,33 @@ public function testReceivesStreamingBodyChangesInstanceWithCustomBodyButSameDat $request = new ServerRequest( 'POST', 'http://example.com/', - array(), + [], new HttpBodyStream($stream, 5) ); $middleware = new LimitConcurrentRequestsMiddleware(1); $deferred = new Deferred(); - $middleware(new ServerRequest('GET', 'https://example.com/'), function () use ($deferred) { + $promise = $middleware(new ServerRequest('GET', 'https://example.com/'), function () use ($deferred) { return $deferred->promise(); }); + assert($promise instanceof PromiseInterface); + $promise->then(null, $this->expectCallableOnce()); // avoid reporting unhandled rejection + $req = null; $middleware($request, function (ServerRequestInterface $request) use (&$req) { $req = $request; }); - $deferred->reject(); + $deferred->reject(new \RuntimeException()); $this->assertNotSame($request, $req); - $this->assertInstanceOf('Psr\Http\Message\ServerRequestInterface', $req); + $this->assertInstanceOf(ServerRequestInterface::class, $req); $body = $req->getBody(); - $this->assertInstanceOf('React\Stream\ReadableStreamInterface', $body); - /* @var $body \React\Stream\ReadableStreamInterface */ + $this->assertInstanceOf(ReadableStreamInterface::class, $body); + /* @var $body ReadableStreamInterface */ $this->assertEquals(5, $body->getSize()); @@ -475,15 +486,18 @@ public function testReceivesNextStreamingBodyWithBufferedDataAfterPreviousHandle { $deferred = new Deferred(); $middleware = new LimitConcurrentRequestsMiddleware(1); - $middleware(new ServerRequest('GET', 'http://example.com/'), function () use ($deferred) { + $promise = $middleware(new ServerRequest('GET', 'http://example.com/'), function () use ($deferred) { return $deferred->promise(); }); + assert($promise instanceof PromiseInterface); + $promise->then(null, $this->expectCallableOnce()); // avoid reporting unhandled rejection + $stream = new ThroughStream(); $request = new ServerRequest( 'POST', 'http://example.com/', - array(), + [], new HttpBodyStream($stream, 10) ); @@ -502,15 +516,18 @@ public function testReceivesNextStreamingBodyAndDoesNotEmitDataIfExplicitlyClose { $deferred = new Deferred(); $middleware = new LimitConcurrentRequestsMiddleware(1); - $middleware(new ServerRequest('GET', 'http://example.com/'), function () use ($deferred) { + $promise = $middleware(new ServerRequest('GET', 'http://example.com/'), function () use ($deferred) { return $deferred->promise(); }); + assert($promise instanceof PromiseInterface); + $promise->then(null, $this->expectCallableOnce()); // avoid reporting unhandled rejection + $stream = new ThroughStream(); $request = new ServerRequest( 'POST', 'http://example.com/', - array(), + [], new HttpBodyStream($stream, 10) ); @@ -530,15 +547,18 @@ public function testReceivesNextStreamingBodyAndDoesNotEmitDataIfExplicitlyPause { $deferred = new Deferred(); $middleware = new LimitConcurrentRequestsMiddleware(1); - $middleware(new ServerRequest('GET', 'http://example.com/'), function () use ($deferred) { + $promise = $middleware(new ServerRequest('GET', 'http://example.com/'), function () use ($deferred) { return $deferred->promise(); }); + assert($promise instanceof PromiseInterface); + $promise->then(null, $this->expectCallableOnce()); // avoid reporting unhandled rejection + $stream = new ThroughStream(); $request = new ServerRequest( 'POST', 'http://example.com/', - array(), + [], new HttpBodyStream($stream, 10) ); @@ -558,15 +578,18 @@ public function testReceivesNextStreamingBodyAndDoesEmitDataImmediatelyIfExplici { $deferred = new Deferred(); $middleware = new LimitConcurrentRequestsMiddleware(1); - $middleware(new ServerRequest('GET', 'http://example.com/'), function () use ($deferred) { + $promise = $middleware(new ServerRequest('GET', 'http://example.com/'), function () use ($deferred) { return $deferred->promise(); }); + assert($promise instanceof PromiseInterface); + $promise->then(null, $this->expectCallableOnce()); // avoid reporting unhandled rejection + $stream = new ThroughStream(); $request = new ServerRequest( 'POST', 'http://example.com/', - array(), + [], new HttpBodyStream($stream, 10) ); diff --git a/tests/Middleware/ProcessStack.php b/tests/Middleware/ProcessStack.php index 69bf34a8..22904310 100644 --- a/tests/Middleware/ProcessStack.php +++ b/tests/Middleware/ProcessStack.php @@ -3,7 +3,7 @@ namespace React\Tests\Http\Middleware; use Psr\Http\Message\ServerRequestInterface; -use React\Promise; +use function React\Promise\resolve; final class ProcessStack { @@ -15,7 +15,7 @@ final class ProcessStack public function __invoke(ServerRequestInterface $request, $stack) { $this->callCount++; - return Promise\resolve($stack($request)); + return resolve($stack($request)); } /** diff --git a/tests/Middleware/RequestBodyBufferMiddlewareTest.php b/tests/Middleware/RequestBodyBufferMiddlewareTest.php index 28450f6c..28866c96 100644 --- a/tests/Middleware/RequestBodyBufferMiddlewareTest.php +++ b/tests/Middleware/RequestBodyBufferMiddlewareTest.php @@ -2,16 +2,16 @@ namespace React\Tests\Http\Middleware; -use Clue\React\Block; use Psr\Http\Message\ServerRequestInterface; -use React\EventLoop\Factory; +use React\EventLoop\Loop; +use React\Http\Io\BufferedBody; use React\Http\Io\HttpBodyStream; -use React\Http\Io\ServerRequest; +use React\Http\Message\Response; +use React\Http\Message\ServerRequest; use React\Http\Middleware\RequestBodyBufferMiddleware; -use React\Http\Response; use React\Stream\ThroughStream; use React\Tests\Http\TestCase; -use RingCentral\Psr7\BufferStream; +use function React\Async\await; final class RequestBodyBufferMiddlewareTest extends TestCase { @@ -21,7 +21,7 @@ public function testBufferingResolvesWhenStreamEnds() $serverRequest = new ServerRequest( 'GET', 'https://example.com/', - array(), + [], new HttpBodyStream($stream, 11) ); @@ -46,12 +46,11 @@ public function testAlreadyBufferedResolvesImmediately() { $size = 1024; $body = str_repeat('x', $size); - $stream = new BufferStream(1024); - $stream->write($body); + $stream = new BufferedBody($body); $serverRequest = new ServerRequest( 'GET', 'https://example.com/', - array(), + [], $stream ); @@ -74,7 +73,7 @@ public function testEmptyStreamingResolvesImmediatelyWithEmptyBufferedBody() $serverRequest = new ServerRequest( 'GET', 'https://example.com/', - array(), + [], $body = new HttpBodyStream($stream, 0) ); @@ -97,7 +96,7 @@ public function testEmptyBufferedResolvesImmediatelyWithSameBody() $serverRequest = new ServerRequest( 'GET', 'https://example.com/', - array(), + [], '' ); $body = $serverRequest->getBody(); @@ -116,26 +115,53 @@ function (ServerRequestInterface $request) use (&$exposedRequest) { $this->assertSame($body, $exposedRequest->getBody()); } - public function testKnownExcessiveSizedBodyIsDisgardedTheRequestIsPassedDownToTheNextMiddleware() + public function testClosedStreamResolvesImmediatelyWithEmptyBody() { - $loop = Factory::create(); + $stream = new ThroughStream(); + $stream->close(); + + $serverRequest = new ServerRequest( + 'GET', + 'https://example.com/', + [], + new HttpBodyStream($stream, 2) + ); + $exposedRequest = null; + $buffer = new RequestBodyBufferMiddleware(1); + $buffer( + $serverRequest, + function (ServerRequestInterface $request) use (&$exposedRequest) { + $exposedRequest = $request; + } + ); + + $this->assertSame(0, $exposedRequest->getBody()->getSize()); + $this->assertSame('', $exposedRequest->getBody()->getContents()); + } + + public function testKnownExcessiveSizedBodyIsDiscardedAndRequestIsPassedDownToTheNextMiddleware() + { $stream = new ThroughStream(); - $stream->end('aa'); $serverRequest = new ServerRequest( 'GET', 'https://example.com/', - array(), + [], new HttpBodyStream($stream, 2) ); $buffer = new RequestBodyBufferMiddleware(1); - $response = Block\await($buffer( + + $promise = $buffer( $serverRequest, function (ServerRequestInterface $request) { - return new Response(200, array(), $request->getBody()->getContents()); + return new Response(200, [], $request->getBody()->getContents()); } - ), $loop); + ); + + $stream->end('aa'); + + $response = await($promise); $this->assertSame(200, $response->getStatusCode()); $this->assertSame('', $response->getBody()->getContents()); @@ -143,26 +169,24 @@ function (ServerRequestInterface $request) { public function testKnownExcessiveSizedWithIniLikeSize() { - $loop = Factory::create(); - $stream = new ThroughStream(); - $loop->addTimer(0.001, function () use ($stream) { + Loop::addTimer(0.001, function () use ($stream) { $stream->end(str_repeat('a', 2048)); }); $serverRequest = new ServerRequest( 'GET', 'https://example.com/', - array(), + [], new HttpBodyStream($stream, 2048) ); $buffer = new RequestBodyBufferMiddleware('1K'); - $response = Block\await($buffer( + $response = await($buffer( $serverRequest, function (ServerRequestInterface $request) { - return new Response(200, array(), $request->getBody()->getContents()); + return new Response(200, [], $request->getBody()->getContents()); } - ), $loop); + )); $this->assertSame(200, $response->getStatusCode()); $this->assertSame('', $response->getBody()->getContents()); @@ -173,7 +197,7 @@ public function testAlreadyBufferedExceedingSizeResolvesImmediatelyWithEmptyBody $serverRequest = new ServerRequest( 'GET', 'https://example.com/', - array(), + [], 'hello' ); @@ -192,13 +216,11 @@ function (ServerRequestInterface $request) use (&$exposedRequest) { public function testExcessiveSizeBodyIsDiscardedAndTheRequestIsPassedDownToTheNextMiddleware() { - $loop = Factory::create(); - $stream = new ThroughStream(); $serverRequest = new ServerRequest( 'GET', 'https://example.com/', - array(), + [], new HttpBodyStream($stream, null) ); @@ -206,47 +228,124 @@ public function testExcessiveSizeBodyIsDiscardedAndTheRequestIsPassedDownToTheNe $promise = $buffer( $serverRequest, function (ServerRequestInterface $request) { - return new Response(200, array(), $request->getBody()->getContents()); + return new Response(200, [], $request->getBody()->getContents()); } ); $stream->end('aa'); - $exposedResponse = Block\await($promise->then( + $exposedResponse = await($promise->then( null, $this->expectCallableNever() - ), $loop); + )); $this->assertSame(200, $exposedResponse->getStatusCode()); $this->assertSame('', $exposedResponse->getBody()->getContents()); } - /** - * @expectedException RuntimeException - */ - public function testBufferingErrorThrows() + public function testBufferingRejectsWhenNextHandlerThrowsWhenStreamEnds() { - $loop = Factory::create(); + $stream = new ThroughStream(); + + $serverRequest = new ServerRequest( + 'GET', + 'https://example.com/', + [], + new HttpBodyStream($stream, null) + ); + + $buffer = new RequestBodyBufferMiddleware(100); + $promise = $buffer( + $serverRequest, + function (ServerRequestInterface $request) { + throw new \RuntimeException('Buffered ' . $request->getBody()->getSize(), 42); + } + ); + + $exception = null; + $promise->then(null, function ($e) use (&$exception) { + $exception = $e; + }); + $this->assertTrue($stream->isWritable()); + $stream->end('Foo'); + $this->assertFalse($stream->isWritable()); + + assert($exception instanceof \RuntimeException); + $this->assertInstanceOf(\RuntimeException::class, $exception); + $this->assertEquals('Buffered 3', $exception->getMessage()); + $this->assertEquals(42, $exception->getCode()); + $this->assertNull($exception->getPrevious()); + } + + public function testBufferingRejectsWhenNextHandlerThrowsErrorWhenStreamEnds() + { $stream = new ThroughStream(); + $serverRequest = new ServerRequest( 'GET', 'https://example.com/', - array(), + [], new HttpBodyStream($stream, null) ); - $buffer = new RequestBodyBufferMiddleware(1); + $buffer = new RequestBodyBufferMiddleware(100); $promise = $buffer( $serverRequest, function (ServerRequestInterface $request) { - return $request; + throw new \Error('Buffered ' . $request->getBody()->getSize(), 42); } ); - $stream->emit('error', array(new \RuntimeException())); + $exception = null; + $promise->then(null, function ($e) use (&$exception) { + $exception = $e; + }); + + $this->assertTrue($stream->isWritable()); + $stream->end('Foo'); + $this->assertFalse($stream->isWritable()); + + assert($exception instanceof \Error); + $this->assertInstanceOf(\Error::class, $exception); + $this->assertEquals('Buffered 3', $exception->getMessage()); + $this->assertEquals(42, $exception->getCode()); + $this->assertNull($exception->getPrevious()); + } + + public function testBufferingRejectsWhenStreamEmitsError() + { + $stream = new ThroughStream(function ($data) { + throw new \UnexpectedValueException('Unexpected ' . $data, 42); + }); + + $serverRequest = new ServerRequest( + 'GET', + 'https://example.com/', + [], + new HttpBodyStream($stream, null) + ); + + $buffer = new RequestBodyBufferMiddleware(1); + $promise = $buffer( + $serverRequest, + $this->expectCallableNever() + ); + + $exception = null; + $promise->then(null, function ($e) use (&$exception) { + $exception = $e; + }); + + $this->assertTrue($stream->isWritable()); + $stream->write('Foo'); + $this->assertFalse($stream->isWritable()); - Block\await($promise, $loop); + assert($exception instanceof \RuntimeException); + $this->assertInstanceOf(\RuntimeException::class, $exception); + $this->assertEquals('Error while buffering request body: Unexpected Foo', $exception->getMessage()); + $this->assertEquals(42, $exception->getCode()); + $this->assertInstanceOf(\UnexpectedValueException::class, $exception->getPrevious()); } public function testFullBodyStreamedBeforeCallingNextMiddleware() @@ -257,7 +356,7 @@ public function testFullBodyStreamedBeforeCallingNextMiddleware() $serverRequest = new ServerRequest( 'GET', 'https://example.com/', - array(), + [], new HttpBodyStream($stream, null) ); @@ -274,4 +373,35 @@ public function testFullBodyStreamedBeforeCallingNextMiddleware() $stream->end('aaa'); $this->assertTrue($promiseResolved); } + + public function testCancelBufferingClosesStreamAndRejectsPromise() + { + $stream = new ThroughStream(); + $stream->on('close', $this->expectCallableOnce()); + + $serverRequest = new ServerRequest( + 'GET', + 'https://example.com/', + [], + new HttpBodyStream($stream, 2) + ); + + $buffer = new RequestBodyBufferMiddleware(2); + + $promise = $buffer($serverRequest, $this->expectCallableNever()); + $promise->cancel(); + + $this->assertFalse($stream->isReadable()); + + $exception = null; + $promise->then(null, function ($e) use (&$exception) { + $exception = $e; + }); + + assert($exception instanceof \RuntimeException); + $this->assertInstanceOf(\RuntimeException::class, $exception); + $this->assertEquals('Cancelled buffering request body', $exception->getMessage()); + $this->assertEquals(0, $exception->getCode()); + $this->assertNull($exception->getPrevious()); + } } diff --git a/tests/Middleware/RequestBodyParserMiddlewareTest.php b/tests/Middleware/RequestBodyParserMiddlewareTest.php index 994d562c..b601b478 100644 --- a/tests/Middleware/RequestBodyParserMiddlewareTest.php +++ b/tests/Middleware/RequestBodyParserMiddlewareTest.php @@ -3,7 +3,7 @@ namespace React\Tests\Http\Middleware; use Psr\Http\Message\ServerRequestInterface; -use React\Http\Io\ServerRequest; +use React\Http\Message\ServerRequest; use React\Http\Middleware\RequestBodyParserMiddleware; use React\Tests\Http\TestCase; @@ -15,9 +15,9 @@ public function testFormUrlencodedParsing() $request = new ServerRequest( 'POST', 'https://example.com/', - array( - 'Content-Type' => 'application/x-www-form-urlencoded', - ), + [ + 'Content-Type' => 'application/x-www-form-urlencoded' + ], 'hello=world' ); @@ -30,7 +30,7 @@ function (ServerRequestInterface $request) { ); $this->assertSame( - array('hello' => 'world'), + ['hello' => 'world'], $parsedRequest->getParsedBody() ); $this->assertSame('hello=world', (string)$parsedRequest->getBody()); @@ -42,9 +42,9 @@ public function testFormUrlencodedParsingIgnoresCaseForHeadersButRespectsContent $request = new ServerRequest( 'POST', 'https://example.com/', - array( - 'CONTENT-TYPE' => 'APPLICATION/X-WWW-Form-URLEncoded', - ), + [ + 'CONTENT-TYPE' => 'APPLICATION/X-WWW-Form-URLEncoded' + ], 'Hello=World' ); @@ -57,7 +57,7 @@ function (ServerRequestInterface $request) { ); $this->assertSame( - array('Hello' => 'World'), + ['Hello' => 'World'], $parsedRequest->getParsedBody() ); $this->assertSame('Hello=World', (string)$parsedRequest->getBody()); @@ -69,9 +69,9 @@ public function testFormUrlencodedParsingNestedStructure() $request = new ServerRequest( 'POST', 'https://example.com/', - array( - 'Content-Type' => 'application/x-www-form-urlencoded', - ), + [ + 'Content-Type' => 'application/x-www-form-urlencoded' + ], 'foo=bar&baz[]=cheese&bar[]=beer&bar[]=wine&market[fish]=salmon&market[meat][]=beef&market[meat][]=chicken&market[]=bazaar' ); @@ -84,24 +84,24 @@ function (ServerRequestInterface $request) { ); $this->assertSame( - array( + [ 'foo' => 'bar', - 'baz' => array( + 'baz' => [ 'cheese', - ), - 'bar' => array( + ], + 'bar' => [ 'beer', 'wine', - ), - 'market' => array( + ], + 'market' => [ 'fish' => 'salmon', - 'meat' => array( + 'meat' => [ 'beef', 'chicken', - ), + ], 0 => 'bazaar', - ), - ), + ], + ], $parsedRequest->getParsedBody() ); $this->assertSame('foo=bar&baz[]=cheese&bar[]=beer&bar[]=wine&market[fish]=salmon&market[meat][]=beef&market[meat][]=chicken&market[]=bazaar', (string)$parsedRequest->getBody()); @@ -109,22 +109,15 @@ function (ServerRequestInterface $request) { public function testFormUrlencodedIgnoresBodyWithExcessiveNesting() { - // supported in all Zend PHP versions and HHVM - // ini setting does exist everywhere but HHVM: https://3v4l.org/hXLiK - // HHVM limits to 64 and returns an empty array structure: https://3v4l.org/j3DK2 - if (defined('HHVM_VERSION')) { - $this->markTestSkipped('Not supported on HHVM (limited to depth 64, but keeps empty array structure)'); - } - $allowed = (int)ini_get('max_input_nesting_level'); $middleware = new RequestBodyParserMiddleware(); $request = new ServerRequest( 'POST', 'https://example.com/', - array( - 'Content-Type' => 'application/x-www-form-urlencoded', - ), + [ + 'Content-Type' => 'application/x-www-form-urlencoded' + ], 'hello' . str_repeat('[]', $allowed + 1) . '=world' ); @@ -137,28 +130,22 @@ function (ServerRequestInterface $request) { ); $this->assertSame( - array(), + [], $parsedRequest->getParsedBody() ); } public function testFormUrlencodedTruncatesBodyWithExcessiveLength() { - // supported as of PHP 5.3.11, no HHVM support: https://3v4l.org/PiqnQ - // ini setting already exists in PHP 5.3.9: https://3v4l.org/VF6oV - if (defined('HHVM_VERSION') || PHP_VERSION_ID < 50311) { - $this->markTestSkipped('Not supported on HHVM and PHP < 5.3.11 (unlimited length)'); - } - $allowed = (int)ini_get('max_input_vars'); $middleware = new RequestBodyParserMiddleware(); $request = new ServerRequest( 'POST', 'https://example.com/', - array( - 'Content-Type' => 'application/x-www-form-urlencoded', - ), + [ + 'Content-Type' => 'application/x-www-form-urlencoded' + ], str_repeat('a[]=b&', $allowed + 1) ); @@ -183,9 +170,9 @@ public function testDoesNotParseJsonByDefault() $request = new ServerRequest( 'POST', 'https://example.com/', - array( - 'Content-Type' => 'application/json', - ), + [ + 'Content-Type' => 'application/json' + ], '{"hello":"world"}' ); @@ -217,9 +204,9 @@ public function testMultipartFormDataParsing() $data .= "second\r\n"; $data .= "--$boundary--\r\n"; - $request = new ServerRequest('POST', 'http://example.com/', array( - 'Content-Type' => 'multipart/form-data; boundary=' . $boundary, - ), $data, 1.1); + $request = new ServerRequest('POST', 'http://example.com/', [ + 'Content-Type' => 'multipart/form-data; boundary=' . $boundary + ], $data, 1.1); /** @var ServerRequestInterface $parsedRequest */ $parsedRequest = $middleware( @@ -230,12 +217,12 @@ function (ServerRequestInterface $request) { ); $this->assertSame( - array( - 'users' => array( + [ + 'users' => [ 'one' => 'single', 'two' => 'second', - ), - ), + ], + ], $parsedRequest->getParsedBody() ); $this->assertSame($data, (string)$parsedRequest->getBody()); @@ -243,13 +230,7 @@ function (ServerRequestInterface $request) { public function testMultipartFormDataIgnoresFieldWithExcessiveNesting() { - // supported in all Zend PHP versions and HHVM - // ini setting does exist everywhere but HHVM: https://3v4l.org/hXLiK - // HHVM limits to 64 and otherwise returns an empty array structure - $allowed = (int)ini_get('max_input_nesting_level'); - if ($allowed === 0) { - $allowed = 64; - } + $allowed = (int) ini_get('max_input_nesting_level'); $middleware = new RequestBodyParserMiddleware(); @@ -261,9 +242,9 @@ public function testMultipartFormDataIgnoresFieldWithExcessiveNesting() $data .= "world\r\n"; $data .= "--$boundary--\r\n"; - $request = new ServerRequest('POST', 'http://example.com/', array( - 'Content-Type' => 'multipart/form-data; boundary=' . $boundary, - ), $data, 1.1); + $request = new ServerRequest('POST', 'http://example.com/', [ + 'Content-Type' => 'multipart/form-data; boundary=' . $boundary + ], $data, 1.1); /** @var ServerRequestInterface $parsedRequest */ $parsedRequest = $middleware( @@ -278,12 +259,7 @@ function (ServerRequestInterface $request) { public function testMultipartFormDataTruncatesBodyWithExcessiveLength() { - // ini setting exists in PHP 5.3.9, not in HHVM: https://3v4l.org/VF6oV - // otherwise default to 1000 as implemented within - $allowed = (int)ini_get('max_input_vars'); - if ($allowed === 0) { - $allowed = 1000; - } + $allowed = (int) ini_get('max_input_vars'); $middleware = new RequestBodyParserMiddleware(); @@ -298,9 +274,9 @@ public function testMultipartFormDataTruncatesBodyWithExcessiveLength() } $data .= "--$boundary--\r\n"; - $request = new ServerRequest('POST', 'http://example.com/', array( - 'Content-Type' => 'multipart/form-data; boundary=' . $boundary, - ), $data, 1.1); + $request = new ServerRequest('POST', 'http://example.com/', [ + 'Content-Type' => 'multipart/form-data; boundary=' . $boundary + ], $data, 1.1); /** @var ServerRequestInterface $parsedRequest */ $parsedRequest = $middleware( @@ -319,12 +295,7 @@ function (ServerRequestInterface $request) { public function testMultipartFormDataTruncatesExcessiveNumberOfEmptyFileUploads() { - // ini setting exists in PHP 5.3.9, not in HHVM: https://3v4l.org/VF6oV - // otherwise default to 1000 as implemented within - $allowed = (int)ini_get('max_input_vars'); - if ($allowed === 0) { - $allowed = 1000; - } + $allowed = (int) ini_get('max_input_vars'); $middleware = new RequestBodyParserMiddleware(); @@ -339,9 +310,9 @@ public function testMultipartFormDataTruncatesExcessiveNumberOfEmptyFileUploads( } $data .= "--$boundary--\r\n"; - $request = new ServerRequest('POST', 'http://example.com/', array( - 'Content-Type' => 'multipart/form-data; boundary=' . $boundary, - ), $data, 1.1); + $request = new ServerRequest('POST', 'http://example.com/', [ + 'Content-Type' => 'multipart/form-data; boundary=' . $boundary + ], $data, 1.1); /** @var ServerRequestInterface $parsedRequest */ $parsedRequest = $middleware( diff --git a/tests/Middleware/StreamingRequestMiddlewareTest.php b/tests/Middleware/StreamingRequestMiddlewareTest.php new file mode 100644 index 00000000..c41e86b6 --- /dev/null +++ b/tests/Middleware/StreamingRequestMiddlewareTest.php @@ -0,0 +1,23 @@ +assertSame($response, $ret); + } +} diff --git a/tests/ResponseTest.php b/tests/ResponseTest.php deleted file mode 100644 index 146278f1..00000000 --- a/tests/ResponseTest.php +++ /dev/null @@ -1,21 +0,0 @@ -assertInstanceOf('React\Http\Io\HttpBodyStream', $response->getBody()); - } - - public function testStringBodyWillBePsr7Stream() - { - $response = new Response(200, array(), 'hello'); - $this->assertInstanceOf('RingCentral\Psr7\Stream', $response->getBody()); - } -} diff --git a/tests/ServerTest.php b/tests/ServerTest.php deleted file mode 100644 index e788b8b5..00000000 --- a/tests/ServerTest.php +++ /dev/null @@ -1,185 +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(); - } - - /** - * @expectedException InvalidArgumentException - */ - public function testInvalidCallbackFunctionLeadsToException() - { - new Server('invalid'); - } - - public function testSimpleRequestCallsRequestHandlerOnce() - { - $called = null; - $server = new Server(function (ServerRequestInterface $request) use (&$called) { - ++$called; - }); - - $server->listen($this->socket); - $this->socket->emit('connection', array($this->connection)); - $this->connection->emit('data', array("GET / HTTP/1.0\r\n\r\n")); - - $this->assertSame(1, $called); - } - - /** - * @requires PHP 5.4 - */ - public function testSimpleRequestCallsArrayRequestHandlerOnce() - { - $this->called = null; - $server = new Server(array($this, 'helperCallableOnce')); - - $server->listen($this->socket); - $this->socket->emit('connection', array($this->connection)); - $this->connection->emit('data', array("GET / HTTP/1.0\r\n\r\n")); - - $this->assertSame(1, $this->called); - } - - public function helperCallableOnce() - { - ++$this->called; - } - - public function testSimpleRequestWithMiddlewareArrayProcessesMiddlewareStack() - { - $called = null; - $server = new Server(array( - function (ServerRequestInterface $request, $next) use (&$called) { - $called = 'before'; - $ret = $next($request->withHeader('Demo', 'ok')); - $called .= 'after'; - - return $ret; - }, - function (ServerRequestInterface $request) use (&$called) { - $called .= $request->getHeaderLine('Demo'); - } - )); - - $server->listen($this->socket); - $this->socket->emit('connection', array($this->connection)); - $this->connection->emit('data', array("GET / HTTP/1.0\r\n\r\n")); - - $this->assertSame('beforeokafter', $called); - } - - public function testPostFileUpload() - { - $loop = Factory::create(); - $deferred = new Deferred(); - $server = new Server(function (ServerRequestInterface $request) use ($deferred) { - $deferred->resolve($request); - }); - - $server->listen($this->socket); - $this->socket->emit('connection', array($this->connection)); - - $connection = $this->connection; - $data = $this->createPostFileUploadRequest(); - $loop->addPeriodicTimer(0.01, function ($timer) use ($loop, &$data, $connection) { - $line = array_shift($data); - $connection->emit('data', array($line)); - - if (count($data) === 0) { - $loop->cancelTimer($timer); - } - }); - - $parsedRequest = Block\await($deferred->promise(), $loop); - $this->assertNotEmpty($parsedRequest->getUploadedFiles()); - $this->assertEmpty($parsedRequest->getParsedBody()); - - $files = $parsedRequest->getUploadedFiles(); - - $this->assertTrue(isset($files['file'])); - $this->assertCount(1, $files); - - $this->assertSame('hello.txt', $files['file']->getClientFilename()); - $this->assertSame('text/plain', $files['file']->getClientMediaType()); - $this->assertSame("hello\r\n", (string)$files['file']->getStream()); - } - - public function testForwardErrors() - { - $exception = new \Exception(); - $capturedException = null; - $server = new Server(function () use ($exception) { - return Promise\reject($exception); - }); - $server->on('error', function ($error) use (&$capturedException) { - $capturedException = $error; - }); - - $server->listen($this->socket); - $this->socket->emit('connection', array($this->connection)); - - $data = $this->createPostFileUploadRequest(); - $this->connection->emit('data', array(implode('', $data))); - - $this->assertInstanceOf('RuntimeException', $capturedException); - $this->assertInstanceOf('Exception', $capturedException->getPrevious()); - $this->assertSame($exception, $capturedException->getPrevious()); - } - - private function createPostFileUploadRequest() - { - $boundary = "---------------------------5844729766471062541057622570"; - - $data = array(); - $data[] = "POST / HTTP/1.1\r\n"; - $data[] = "Content-Type: multipart/form-data; boundary=" . $boundary . "\r\n"; - $data[] = "Content-Length: 220\r\n"; - $data[] = "\r\n"; - $data[] = "--$boundary\r\n"; - $data[] = "Content-Disposition: form-data; name=\"file\"; filename=\"hello.txt\"\r\n"; - $data[] = "Content-type: text/plain\r\n"; - $data[] = "\r\n"; - $data[] = "hello\r\n"; - $data[] = "\r\n"; - $data[] = "--$boundary--\r\n"; - - return $data; - } -} diff --git a/tests/StreamingServerTest.php b/tests/StreamingServerTest.php deleted file mode 100644 index 8423e3ea..00000000 --- a/tests/StreamingServerTest.php +++ /dev/null @@ -1,2821 +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() - { - $server = new StreamingServer($this->expectCallableNever()); - - $server->listen($this->socket); - $this->socket->emit('connection', array($this->connection)); - - $data = ''; - $data .= "GET / HTTP/1.1\r\n"; - $this->connection->emit('data', array($data)); - } - - public function testRequestEventIsEmitted() - { - $server = new StreamingServer($this->expectCallableOnce()); - - $server->listen($this->socket); - $this->socket->emit('connection', array($this->connection)); - - $data = $this->createGetRequest(); - $this->connection->emit('data', array($data)); - } - - /** - * @requires PHP 5.4 - */ - public function testRequestEventIsEmittedForArrayCallable() - { - $this->called = null; - $server = new StreamingServer(array($this, 'helperCallableOnce')); - - $server->listen($this->socket); - $this->socket->emit('connection', array($this->connection)); - - $data = $this->createGetRequest(); - $this->connection->emit('data', array($data)); - - $this->assertEquals(1, $this->called); - } - - public function helperCallableOnce() - { - ++$this->called; - } - - public function testRequestEvent() - { - $i = 0; - $requestAssertion = null; - $server = new StreamingServer(function (ServerRequestInterface $request) use (&$i, &$requestAssertion) { - $i++; - $requestAssertion = $request; - }); - - $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(); - $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 testRequestEventWithSingleRequestHandlerArray() - { - $i = 0; - $requestAssertion = null; - $server = new StreamingServer(array(function (ServerRequestInterface $request) use (&$i, &$requestAssertion) { - $i++; - $requestAssertion = $request; - })); - - $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(); - $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 StreamingServer(function (ServerRequestInterface $request) use (&$requestAssertion) { - $requestAssertion = $request; - }); - - $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 StreamingServer(function (ServerRequestInterface $request) use (&$requestAssertion) { - $requestAssertion = $request; - }); - - $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 StreamingServer(function (ServerRequestInterface $request) use (&$requestAssertion) { - $requestAssertion = $request; - }); - - $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->assertNull($requestAssertion->getUri()->getPort()); - $this->assertSame('example.com', $requestAssertion->getHeaderLine('Host')); - } - - public function testRequestOptionsAsterisk() - { - $requestAssertion = null; - $server = new StreamingServer(function (ServerRequestInterface $request) use (&$requestAssertion) { - $requestAssertion = $request; - }); - - $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 StreamingServer($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 StreamingServer(function (ServerRequestInterface $request) use (&$requestAssertion) { - $requestAssertion = $request; - }); - - $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 testRequestConnectWithoutHostWillBeAdded() - { - $requestAssertion = null; - $server = new StreamingServer(function (ServerRequestInterface $request) use (&$requestAssertion) { - $requestAssertion = $request; - }); - - $server->listen($this->socket); - $this->socket->emit('connection', array($this->connection)); - - $data = "CONNECT example.com:443 HTTP/1.1\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 StreamingServer(function (ServerRequestInterface $request) use (&$requestAssertion) { - $requestAssertion = $request; - }); - - $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->assertNull($requestAssertion->getUri()->getPort()); - $this->assertSame('example.com', $requestAssertion->getHeaderLine('Host')); - } - - public function testRequestConnectAuthorityFormNonMatchingHostWillBeOverwritten() - { - $requestAssertion = null; - $server = new StreamingServer(function (ServerRequestInterface $request) use (&$requestAssertion) { - $requestAssertion = $request; - }); - - $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->assertNull($requestAssertion->getUri()->getPort()); - $this->assertSame('example.com', $requestAssertion->getHeaderLine('Host')); - } - - public function testRequestConnectOriginFormRequestTargetWillReject() - { - $server = new StreamingServer($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 StreamingServer($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 StreamingServer(function (ServerRequestInterface $request) use (&$requestAssertion) { - $requestAssertion = $request; - }); - - $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 StreamingServer(function (ServerRequestInterface $request) use (&$requestAssertion) { - $requestAssertion = $request; - }); - - $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 StreamingServer(function (ServerRequestInterface $request) use (&$requestAssertion) { - $requestAssertion = $request; - }); - - $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 StreamingServer(function (ServerRequestInterface $request) use (&$requestAssertion) { - $requestAssertion = $request; - }); - - $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 StreamingServer(function (ServerRequestInterface $request) use (&$requestAssertion) { - $requestAssertion = $request; - }); - - $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 StreamingServer(function (ServerRequestInterface $request) use (&$requestAssertion) { - $requestAssertion = $request; - }); - - $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 StreamingServer(function (ServerRequestInterface $request) { - $request->getBody()->pause(); - }); - - $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 StreamingServer(function (ServerRequestInterface $request) { - $request->getBody()->resume(); - }); - - $this->connection->expects($this->once())->method('resume'); - - $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 testRequestCloseWillNotCloseConnection() - { - $server = new StreamingServer(function (ServerRequestInterface $request) { - $request->getBody()->close(); - }); - - $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 testRequestPauseAfterCloseWillNotBeForwarded() - { - $server = new StreamingServer(function (ServerRequestInterface $request) { - $request->getBody()->close(); - $request->getBody()->pause(); - }); - - $this->connection->expects($this->never())->method('close'); - $this->connection->expects($this->never())->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 StreamingServer(function (ServerRequestInterface $request) { - $request->getBody()->close(); - $request->getBody()->resume(); - }); - - $this->connection->expects($this->never())->method('close'); - $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 StreamingServer(function (ServerRequestInterface $request) use ($never) { - $request->getBody()->on('data', $never); - }); - - $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 StreamingServer(function (ServerRequestInterface $request) use ($once) { - $request->getBody()->on('data', $once); - }); - - $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 StreamingServer(function (ServerRequestInterface $request) use ($once) { - $request->getBody()->on('data', $once); - }); - - $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() - { - $server = new StreamingServer(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 testResponsePendingPromiseWillNotSendAnything() - { - $never = $this->expectCallableNever(); - - $server = new StreamingServer(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 testResponsePendingPromiseWillBeCancelledIfConnectionCloses() - { - $once = $this->expectCallableOnce(); - - $server = new StreamingServer(function (ServerRequestInterface $request) use ($once) { - return new Promise(function () { }, $once); - }); - - $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->connection->emit('close'); - - $this->assertEquals('', $buffer); - } - - public function testRespomseBodyStreamAlreadyClosedWillSendEmptyBodyChunkedEncoded() - { - $stream = new ThroughStream(); - $stream->close(); - - $server = new StreamingServer(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 testResponseBodyStreamEndingWillSendEmptyBodyChunkedEncoded() - { - $stream = new ThroughStream(); - - $server = new StreamingServer(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 testResponseBodyStreamAlreadyClosedWillSendEmptyBodyPlainHttp10() - { - $stream = new ThroughStream(); - $stream->close(); - - $server = new StreamingServer(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 StreamingServer(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 testResponseBodyStreamWillBeClosedIfConnectionEmitsCloseEvent() - { - $stream = new ThroughStream(); - $stream->on('close', $this->expectCallableOnce()); - - $server = new StreamingServer(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 testResponseUpgradeInResponseCanBeUsedToAdvertisePossibleUpgrade() - { - $server = new StreamingServer(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 testResponseUpgradeWishInRequestCanBeIgnoredByReturningNormalResponse() - { - $server = new StreamingServer(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 testResponseUpgradeSwitchingProtocolIncludesConnectionUpgradeHeaderWithoutContentLength() - { - $server = new StreamingServer(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 testResponseUpgradeSwitchingProtocolWithStreamWillPipeDataToConnection() - { - $stream = new ThroughStream(); - - $server = new StreamingServer(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 testResponseConnectMethodStreamWillPipeDataToConnection() - { - $stream = new ThroughStream(); - - $server = new StreamingServer(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 testResponseConnectMethodStreamWillPipeDataFromConnection() - { - $stream = new ThroughStream(); - - $server = new StreamingServer(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 StreamingServer(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 = "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 StreamingServer(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 = "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 StreamingServer(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 StreamingServer(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 StreamingServer(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 StreamingServer($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 StreamingServer($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', 8193 - 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 StreamingServer($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 testRequestContentLengthBodyDataWillEmitDataEventOnRequestStream() - { - $dataEvent = $this->expectCallableOnceWith('hello'); - $endEvent = $this->expectCallableOnce(); - $closeEvent = $this->expectCallableOnce(); - $errorEvent = $this->expectCallableNever(); - - $server = new StreamingServer(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); - }); - - $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 testRequestChunkedTransferEncodingRequestWillEmitDecodedDataEventOnRequestStream() - { - $dataEvent = $this->expectCallableOnceWith('hello'); - $endEvent = $this->expectCallableOnce(); - $closeEvent = $this->expectCallableOnce(); - $errorEvent = $this->expectCallableNever(); - $requestValidation = null; - - $server = new StreamingServer(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; - }); - - $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->assertEquals('chunked', $requestValidation->getHeaderLine('Transfer-Encoding')); - } - - public function testRequestChunkedTransferEncodingWithAdditionalDataWontBeEmitted() - { - $dataEvent = $this->expectCallableOnceWith('hello'); - $endEvent = $this->expectCallableOnce(); - $closeEvent = $this->expectCallableOnce(); - $errorEvent = $this->expectCallableNever(); - - $server = new StreamingServer(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); - }); - - $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 testRequestChunkedTransferEncodingEmpty() - { - $dataEvent = $this->expectCallableNever(); - $endEvent = $this->expectCallableOnce(); - $closeEvent = $this->expectCallableOnce(); - $errorEvent = $this->expectCallableNever(); - - $server = new StreamingServer(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); - }); - - $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 testRequestChunkedTransferEncodingHeaderCanBeUpperCase() - { - $dataEvent = $this->expectCallableOnceWith('hello'); - $endEvent = $this->expectCallableOnce(); - $closeEvent = $this->expectCallableOnce(); - $errorEvent = $this->expectCallableNever(); - $requestValidation = null; - - $server = new StreamingServer(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; - }); - - $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->assertEquals('CHUNKED', $requestValidation->getHeaderLine('Transfer-Encoding')); - } - - public function testRequestChunkedTransferEncodingCanBeMixedUpperAndLowerCase() - { - $dataEvent = $this->expectCallableOnceWith('hello'); - $endEvent = $this->expectCallableOnce(); - $closeEvent = $this->expectCallableOnce(); - $errorEvent = $this->expectCallableNever(); - - $server = new StreamingServer(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); - }); - - $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 testRequestContentLengthWillEmitDataEventAndEndEventAndAdditionalDataWillBeIgnored() - { - $dataEvent = $this->expectCallableOnceWith('hello'); - $endEvent = $this->expectCallableOnce(); - $closeEvent = $this->expectCallableOnce(); - $errorEvent = $this->expectCallableNever(); - - $server = new StreamingServer(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 testRequestContentLengthWillEmitDataEventAndEndEventAndAdditionalDataWillBeIgnoredSplitted() - { - $dataEvent = $this->expectCallableOnceWith('hello'); - $endEvent = $this->expectCallableOnce(); - $closeEvent = $this->expectCallableOnce(); - $errorEvent = $this->expectCallableNever(); - - - $server = new StreamingServer(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); - }); - - $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 testRequestZeroContentLengthWillEmitEndEvent() - { - - $dataEvent = $this->expectCallableNever(); - $endEvent = $this->expectCallableOnce(); - $closeEvent = $this->expectCallableOnce(); - $errorEvent = $this->expectCallableNever(); - - $server = new StreamingServer(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); - }); - - $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 testRequestZeroContentLengthWillEmitEndAndAdditionalDataWillBeIgnored() - { - $dataEvent = $this->expectCallableNever(); - $endEvent = $this->expectCallableOnce(); - $closeEvent = $this->expectCallableOnce(); - $errorEvent = $this->expectCallableNever(); - - $server = new StreamingServer(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); - }); - - $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 testRequestZeroContentLengthWillEmitEndAndAdditionalDataWillBeIgnoredSplitted() - { - $dataEvent = $this->expectCallableNever(); - $endEvent = $this->expectCallableOnce(); - $closeEvent = $this->expectCallableOnce(); - $errorEvent = $this->expectCallableNever(); - - $server = new StreamingServer(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); - }); - - $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 testRequestInvalidChunkHeaderTooLongWillEmitErrorOnRequestStream() - { - $errorEvent = $this->expectCallableOnceWith($this->isInstanceOf('Exception')); - $server = new StreamingServer(function ($request) use ($errorEvent){ - $request->getBody()->on('error', $errorEvent); - return \React\Promise\resolve(new Response()); - }); - - $this->connection->expects($this->never())->method('close'); - - $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 testRequestInvalidChunkBodyTooLongWillEmitErrorOnRequestStream() - { - $errorEvent = $this->expectCallableOnceWith($this->isInstanceOf('Exception')); - $server = new StreamingServer(function ($request) use ($errorEvent){ - $request->getBody()->on('error', $errorEvent); - }); - - $this->connection->expects($this->never())->method('close'); - - $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 testRequestUnexpectedEndOfRequestWithChunkedTransferConnectionWillEmitErrorOnRequestStream() - { - $errorEvent = $this->expectCallableOnceWith($this->isInstanceOf('Exception')); - $server = new StreamingServer(function ($request) use ($errorEvent){ - $request->getBody()->on('error', $errorEvent); - }); - - $this->connection->expects($this->never())->method('close'); - - $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 testRequestInvalidChunkHeaderWillEmitErrorOnRequestStream() - { - $errorEvent = $this->expectCallableOnceWith($this->isInstanceOf('Exception')); - $server = new StreamingServer(function ($request) use ($errorEvent){ - $request->getBody()->on('error', $errorEvent); - }); - - $this->connection->expects($this->never())->method('close'); - - $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 testRequestUnexpectedEndOfRequestWithContentLengthWillEmitErrorOnRequestStream() - { - $errorEvent = $this->expectCallableOnceWith($this->isInstanceOf('Exception')); - $server = new StreamingServer(function ($request) use ($errorEvent){ - $request->getBody()->on('error', $errorEvent); - }); - - $this->connection->expects($this->never())->method('close'); - - $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: 500\r\n"; - $data .= "\r\n"; - $data .= "incomplete"; - - $this->connection->emit('data', array($data)); - $this->connection->emit('end'); - } - - public function testRequestWithoutBodyWillEmitEndOnRequestStream() - { - $dataEvent = $this->expectCallableNever(); - $closeEvent = $this->expectCallableOnce(); - $endEvent = $this->expectCallableOnce(); - $errorEvent = $this->expectCallableNever(); - - $server = new StreamingServer(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); - }); - - $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 StreamingServer(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); - }); - - $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 testResponseWithBodyStreamWillUseChunkedTransferEncodingByDefault() - { - $stream = new ThroughStream(); - $server = new StreamingServer(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 = $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 testResponseWithBodyStringWillOverwriteExplicitContentLengthAndTransferEncoding() - { - $server = new StreamingServer(function (ServerRequestInterface $request) { - return new Response( - 200, - array( - 'Content-Length' => 1000, - '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 = $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 testResponseContainsResponseBodyWithTransferEncodingChunkedForBodyWithUnknownSize() - { - $body = $this->getMockBuilder('Psr\Http\Message\StreamInterface')->getMock(); - $body->expects($this->once())->method('getSize')->willReturn(null); - $body->expects($this->once())->method('__toString')->willReturn('body'); - - $server = new StreamingServer(function (ServerRequestInterface $request) use ($body) { - return new Response( - 200, - array(), - $body - ); - }); - - $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("Transfer-Encoding: chunked", $buffer); - $this->assertNotContains("Content-Length:", $buffer); - $this->assertContains("body", $buffer); - } - - public function testResponseContainsResponseBodyWithPlainBodyWithUnknownSizeForLegacyHttp10() - { - $body = $this->getMockBuilder('Psr\Http\Message\StreamInterface')->getMock(); - $body->expects($this->once())->method('getSize')->willReturn(null); - $body->expects($this->once())->method('__toString')->willReturn('body'); - - $server = new StreamingServer(function (ServerRequestInterface $request) use ($body) { - return new Response( - 200, - array(), - $body - ); - }); - - $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->assertNotContains("Transfer-Encoding: chunked", $buffer); - $this->assertNotContains("Content-Length:", $buffer); - $this->assertContains("body", $buffer); - } - - public function testResponseWithCustomTransferEncodingWillBeIgnoredAndUseChunkedTransferEncodingInstead() - { - $stream = new ThroughStream(); - $server = new StreamingServer(function (ServerRequestInterface $request) use ($stream) { - return new Response( - 200, - array( - 'Transfer-Encoding' => 'custom' - ), - $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 = $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 testResponseWithoutExplicitDateHeaderWillAddCurrentDate() - { - $server = new StreamingServer(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("HTTP/1.1 200 OK\r\n", $buffer); - $this->assertContains("Date:", $buffer); - $this->assertContains("\r\n\r\n", $buffer); - } - - public function testResponseWIthCustomDateHeaderOverwritesDefault() - { - $server = new StreamingServer(function (ServerRequestInterface $request) { - return new Response( - 200, - array("Date" => "Tue, 15 Nov 1994 08:12:31 GMT") - ); - }); - - $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 testResponseWithEmptyDateHeaderRemovesDateHeader() - { - $server = new StreamingServer(function (ServerRequestInterface $request) { - return new Response( - 200, - array('Date' => '') - ); - }); - - $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 testResponseCanContainMultipleCookieHeaders() - { - $server = new StreamingServer(function (ServerRequestInterface $request) { - return new Response( - 200, - array( - 'Set-Cookie' => array( - 'name=test', - 'session=abc' - ), - 'Date' => '', - 'X-Powered-By' => '' - ) - ); - }); - - $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("HTTP/1.1 200 OK\r\nSet-Cookie: name=test\r\nSet-Cookie: session=abc\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", $buffer); - } - - public function testReponseWithExpectContinueRequestContainsContinueWithLaterResponse() - { - $server = new StreamingServer(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.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 testResponseWithExpectContinueRequestWontSendContinueForHttp10() - { - $server = new StreamingServer(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"; - $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); - } - - /** - * @expectedException InvalidArgumentException - */ - public function testInvalidCallbackFunctionLeadsToException() - { - $server = new StreamingServer('invalid'); - } - - public function testResponseBodyStreamWillStreamDataWithChunkedTransferEncoding() - { - $input = new ThroughStream(); - - $server = new StreamingServer(function (ServerRequestInterface $request) use ($input) { - return new Response( - 200, - array(), - $input - ); - }); - - $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 testResponseBodyStreamWithContentLengthWillStreamTillLengthWithoutTransferEncoding() - { - $input = new ThroughStream(); - - $server = new StreamingServer(function (ServerRequestInterface $request) use ($input) { - return new Response( - 200, - array('Content-Length' => 5), - $input - ); - }); - - $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 testResponseWithResponsePromise() - { - $server = new StreamingServer(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 testResponseReturnInvalidTypeWillResultInError() - { - $server = new StreamingServer(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 = $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 testResponseResolveWrongTypeInPromiseWillResultInError() - { - $server = new StreamingServer(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 = $this->createGetRequest(); - - $this->connection->emit('data', array($data)); - - $this->assertContains("HTTP/1.1 500 Internal Server Error\r\n", $buffer); - } - - public function testResponseRejectedPromiseWillResultInErrorMessage() - { - $server = new StreamingServer(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 = $this->createGetRequest(); - - $this->connection->emit('data', array($data)); - - $this->assertContains("HTTP/1.1 500 Internal Server Error\r\n", $buffer); - } - - public function testResponseExceptionInCallbackWillResultInErrorMessage() - { - $server = new StreamingServer(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 = $this->createGetRequest(); - - $this->connection->emit('data', array($data)); - - $this->assertContains("HTTP/1.1 500 Internal Server Error\r\n", $buffer); - } - - public function testResponseWithContentLengthHeaderForStringBodyOverwritesTransferEncoding() - { - $server = new StreamingServer(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 = $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 testResponseWillBeHandled() - { - $server = new StreamingServer(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("HTTP/1.1 200 OK\r\n", $buffer); - } - - public function testResponseExceptionThrowInCallBackFunctionWillResultInErrorMessage() - { - $server = new StreamingServer(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 = $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()); - } - - /** - * @requires PHP 7 - */ - public function testResponseThrowableThrowInCallBackFunctionWillResultInErrorMessage() - { - $server = new StreamingServer(function (ServerRequestInterface $request) { - throw new \Error('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 = $this->createGetRequest(); - - try { - $this->connection->emit('data', array($data)); - } catch (\Error $e) { - $this->markTestSkipped( - 'A \Throwable bubbled out of the request callback. ' . - 'This happened most probably due to react/promise:^1.0 being installed ' . - 'which does not support \Throwable.' - ); - } - - $this->assertInstanceOf('RuntimeException', $exception); - $this->assertContains("HTTP/1.1 500 Internal Server Error\r\n", $buffer); - $this->assertEquals('hello', $exception->getPrevious()->getMessage()); - } - - public function testResponseRejectOfNonExceptionWillResultInErrorMessage() - { - $server = new StreamingServer(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 = $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 testRequestServerRequestParams() - { - $requestValidation = null; - $server = new StreamingServer(function (ServerRequestInterface $request) use (&$requestValidation) { - $requestValidation = $request; - }); - - $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 testRequestQueryParametersWillBeAddedToRequest() - { - $requestValidation = null; - $server = new StreamingServer(function (ServerRequestInterface $request) use (&$requestValidation) { - $requestValidation = $request; - }); - - $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 testRequestCookieWillBeAddedToServerRequest() - { - $requestValidation = null; - $server = new StreamingServer(function (ServerRequestInterface $request) use (&$requestValidation) { - $requestValidation = $request; - }); - - $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 testRequestInvalidMultipleCookiesWontBeAddedToServerRequest() - { - $requestValidation = null; - $server = new StreamingServer(function (ServerRequestInterface $request) use (&$requestValidation) { - $requestValidation = $request; - }); - - $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 testRequestCookieWithSeparatorWillBeAddedToServerRequest() - { - $requestValidation = null; - $server = new StreamingServer(function (ServerRequestInterface $request) use (&$requestValidation) { - $requestValidation = $request; - }); - - $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->connection->emit('data', array($data)); - $this->assertEquals(array('hello' => 'world', 'test' => 'abc'), $requestValidation->getCookieParams()); - } - - public function testRequestCookieWithCommaValueWillBeAddedToServerRequest() { - $requestValidation = null; - $server = new StreamingServer(function (ServerRequestInterface $request) use (&$requestValidation) { - $requestValidation = $request; - }); - - $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: test=abc,def; hello=world\r\n"; - $data .= "\r\n"; - - $this->connection->emit('data', array($data)); - $this->assertEquals(array('test' => 'abc,def', 'hello' => 'world'), $requestValidation->getCookieParams()); - } - - private function createGetRequest() - { - $data = "GET / HTTP/1.1\r\n"; - $data .= "Host: example.com:80\r\n"; - $data .= "Connection: close\r\n"; - $data .= "\r\n"; - - return $data; - } -} diff --git a/tests/TestCase.php b/tests/TestCase.php index 2b6d265d..4df6087f 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -2,20 +2,11 @@ namespace React\Tests\Http; +use PHPUnit\Framework\MockObject\MockBuilder; use PHPUnit\Framework\TestCase as BaseTestCase; class TestCase extends BaseTestCase { - protected function expectCallableExactly($amount) - { - $mock = $this->createCallableMock(); - $mock - ->expects($this->exactly($amount)) - ->method('__invoke'); - - return $mock; - } - protected function expectCallableOnce() { $mock = $this->createCallableMock(); @@ -47,24 +38,14 @@ 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 - ->getMockBuilder('React\Tests\Http\CallableStub') - ->getMock(); + if (method_exists(MockBuilder::class, 'addMethods')) { + // PHPUnit 9+ + return $this->getMockBuilder(\stdClass::class)->addMethods(['__invoke'])->getMock(); + } else { + // legacy PHPUnit + return $this->getMockBuilder(\stdClass::class)->setMethods(['__invoke'])->getMock(); + } } } diff --git a/tests/benchmark-middleware-runner.php b/tests/benchmark-middleware-runner.php index e7df2d93..d330a1b0 100644 --- a/tests/benchmark-middleware-runner.php +++ b/tests/benchmark-middleware-runner.php @@ -2,8 +2,8 @@ use Psr\Http\Message\ServerRequestInterface; use React\Http\Io\MiddlewareRunner; -use React\Http\Io\ServerRequest; -use React\Http\Response; +use React\Http\Message\Response; +use React\Http\Message\ServerRequest; const ITERATIONS = 5000; const MIDDLEWARE_COUNT = 512; @@ -13,11 +13,11 @@ $middleware = function (ServerRequestInterface $request, $next) { return $next($request); }; -$middlewareList = array(); +$middlewareList = []; for ($i = 0; $i < MIDDLEWARE_COUNT; $i++) { $middlewareList[] = $middleware; } -$middlewareList[] = function (ServerRequestInterface $request, $next) { +$middlewareList[] = function (ServerRequestInterface $request) { return new Response(545); }; $middlewareRunner = new MiddlewareRunner($middlewareList); diff --git a/tests/bootstrap.php b/tests/bootstrap.php deleted file mode 100644 index cc300c15..00000000 --- a/tests/bootstrap.php +++ /dev/null @@ -1,7 +0,0 @@ -addPsr4('React\\Tests\\Http\\', __DIR__);