diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b0005012..69f5556c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,6 +9,7 @@ jobs: name: PHPUnit (PHP ${{ matrix.php }}) runs-on: ubuntu-24.04 strategy: + fail-fast: false matrix: php: - 8.4 diff --git a/composer.json b/composer.json index 4234210a..5b75214e 100644 --- a/composer.json +++ b/composer.json @@ -29,17 +29,16 @@ "php": ">=5.3.0", "evenement/evenement": "^3.0 || ^2.0 || ^1.0", "fig/http-message-util": "^1.1", - "psr/http-message": "^1.0", + "psr/http-message": "^2.0 || ^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 || ^5.7 || ^4.8.36", + "phpunit/phpunit": "^9.6 || ^8.5 || ^5.7 || ^4.8.36", "react/async": "^4.2 || ^3 || ^2", "react/promise-stream": "^1.4", "react/promise-timer": "^1.11" diff --git a/src/Io/AbstractMessage.php b/src/Io/AbstractMessage.php index a0706bb1..6d4c88f7 100644 --- a/src/Io/AbstractMessage.php +++ b/src/Io/AbstractMessage.php @@ -2,171 +2,14 @@ namespace React\Http\Io; -use Psr\Http\Message\MessageInterface; -use Psr\Http\Message\StreamInterface; +use Composer\InstalledVersions; -/** - * [Internal] Abstract HTTP message base class (PSR-7) - * - * @internal - * @see MessageInterface - */ -abstract class AbstractMessage implements MessageInterface -{ - /** - * [Internal] Regex used to match all request header fields into an array, thanks to @kelunik for checking the HTTP specs and coming up with this regex - * - * @internal - * @var string - */ - const REGEX_HEADERS = '/^([^()<>@,;:\\\"\/\[\]?={}\x00-\x20\x7F]++):[\x20\x09]*+((?:[\x20\x09]*+[\x21-\x7E\x80-\xFF]++)*+)[\x20\x09]*+[\r]?+\n/m'; - - /** @var array */ - private $headers = array(); - - /** @var array */ - private $headerNamesLowerCase = array(); - - /** @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 !== array()) { - if (\is_array($value)) { - foreach ($value as &$one) { - $one = (string) $one; - } - } else { - $value = array((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) +if (version_compare(InstalledVersions::getVersion('psr/http-message'), '2.0.0', '<')) { + abstract class AbstractMessage extends V1\AbstractMessage { - $lower = \strtolower($name); - return isset($this->headerNamesLowerCase[$lower]) ? $this->headers[$this->headerNamesLowerCase[$lower]] : array(); } - - public function getHeaderLine($name) - { - return \implode(', ', $this->getHeader($name)); - } - - public function withHeader($name, $value) +} else { + abstract class AbstractMessage extends V2\AbstractMessage { - if ($value === array()) { - return $this->withoutHeader($name); - } elseif (\is_array($value)) { - foreach ($value as &$one) { - $one = (string) $one; - } - } else { - $value = array((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 === array()) { - return $this; - } - - return $this->withHeader($name, \array_merge($this->getHeader($name), \is_array($value) ? $value : array($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 index f32307f7..aab3b28e 100644 --- a/src/Io/AbstractRequest.php +++ b/src/Io/AbstractRequest.php @@ -2,155 +2,14 @@ namespace React\Http\Io; -use Psr\Http\Message\RequestInterface; -use Psr\Http\Message\StreamInterface; -use Psr\Http\Message\UriInterface; -use React\Http\Message\Uri; +use Composer\InstalledVersions; -/** - * [Internal] Abstract HTTP request base class (PSR-7) - * - * @internal - * @see RequestInterface - */ -abstract class AbstractRequest extends AbstractMessage implements RequestInterface -{ - /** @var ?string */ - private $requestTarget; - - /** @var string */ - private $method; - - /** @var UriInterface */ - private $uri; - - /** - * @param string $method - * @param string|UriInterface $uri - * @param array $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 !== array()) { - $host = ''; - break; - } - } - if ($host !== '') { - $port = $uri->getPort(); - if ($port !== null && (!($port === 80 && $uri->getScheme() === 'http') || !($port === 443 && $uri->getScheme() === 'https'))) { - $host .= ':' . $port; - } - - $headers = array('Host' => $host) + $headers; - } - } - - parent::__construct($protocolVersion, $headers, $body); - - $this->method = $method; - $this->uri = $uri; - } - - public function getRequestTarget() +if (version_compare(InstalledVersions::getVersion('psr/http-message'), '2.0.0', '<')) { + abstract class AbstractRequest extends V1\AbstractRequest { - 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() +} else { + abstract class AbstractRequest extends V2\AbstractRequest { - 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 index 4a4d8393..370a9b90 100644 --- a/src/Io/BufferedBody.php +++ b/src/Io/BufferedBody.php @@ -2,178 +2,14 @@ namespace React\Http\Io; -use Psr\Http\Message\StreamInterface; +use Composer\InstalledVersions; -/** - * [Internal] PSR-7 message body implementation using an in-memory buffer - * - * @internal - */ -class BufferedBody implements StreamInterface -{ - private $buffer = ''; - private $position = 0; - private $closed = false; - - /** - * @param string $buffer - */ - public function __construct($buffer) - { - $this->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() +if (version_compare(InstalledVersions::getVersion('psr/http-message'), '2.0.0', '<')) { + class BufferedBody extends V1\BufferedBody { - 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) +} else { + class BufferedBody extends V2\BufferedBody { - return $key === null ? array() : null; } } diff --git a/src/Io/EmptyBodyStream.php b/src/Io/EmptyBodyStream.php index 5056219c..e26b1004 100644 --- a/src/Io/EmptyBodyStream.php +++ b/src/Io/EmptyBodyStream.php @@ -2,141 +2,14 @@ namespace React\Http\Io; -use Evenement\EventEmitter; -use Psr\Http\Message\StreamInterface; -use React\Stream\ReadableStreamInterface; -use React\Stream\Util; -use React\Stream\WritableStreamInterface; +use Composer\InstalledVersions; -/** - * [Internal] Bridge between an empty StreamInterface from PSR-7 and ReadableStreamInterface from ReactPHP - * - * This class is used in the server to represent an empty body stream of an - * incoming response from the client. This is similar to the `HttpBodyStream`, - * but is specifically designed for the common case of having an empty message - * body. - * - * Note that this is an internal class only and nothing you should usually care - * about. See the `StreamInterface` and `ReadableStreamInterface` for more - * details. - * - * @see HttpBodyStream - * @see StreamInterface - * @see ReadableStreamInterface - * @internal - */ -class EmptyBodyStream extends EventEmitter implements StreamInterface, ReadableStreamInterface -{ - private $closed = false; - - public function isReadable() - { - return !$this->closed; - } - - public function pause() - { - // NOOP - } - - public function resume() +if (version_compare(InstalledVersions::getVersion('psr/http-message'), '2.0.0', '<')) { + class EmptyBodyStream extends V1\EmptyBodyStream { - // NOOP } - - public function pipe(WritableStreamInterface $dest, array $options = array()) - { - Util::pipe($this, $dest, $options); - - return $dest; - } - - public function close() - { - if ($this->closed) { - return; - } - - $this->closed = true; - - $this->emit('close'); - $this->removeAllListeners(); - } - - public function getSize() - { - return 0; - } - - /** @ignore */ - public function __toString() - { - return ''; - } - - /** @ignore */ - public function detach() - { - return null; - } - - /** @ignore */ - public function tell() - { - throw new \BadMethodCallException(); - } - - /** @ignore */ - public function eof() - { - throw new \BadMethodCallException(); - } - - /** @ignore */ - public function isSeekable() - { - return false; - } - - /** @ignore */ - public function seek($offset, $whence = SEEK_SET) - { - throw new \BadMethodCallException(); - } - - /** @ignore */ - public function rewind() - { - throw new \BadMethodCallException(); - } - - /** @ignore */ - public function isWritable() - { - return false; - } - - /** @ignore */ - public function write($string) - { - throw new \BadMethodCallException(); - } - - /** @ignore */ - public function read($length) - { - throw new \BadMethodCallException(); - } - - /** @ignore */ - public function getContents() - { - return ''; - } - - /** @ignore */ - public function getMetadata($key = null) +} else { + class EmptyBodyStream extends V2\EmptyBodyStream { - return ($key === null) ? array() : null; } } diff --git a/src/Io/HttpBodyStream.php b/src/Io/HttpBodyStream.php index 25d15a18..f2f73a9f 100644 --- a/src/Io/HttpBodyStream.php +++ b/src/Io/HttpBodyStream.php @@ -2,181 +2,14 @@ namespace React\Http\Io; -use Evenement\EventEmitter; -use Psr\Http\Message\StreamInterface; -use React\Stream\ReadableStreamInterface; -use React\Stream\Util; -use React\Stream\WritableStreamInterface; +use Composer\InstalledVersions; -/** - * [Internal] Bridge between StreamInterface from PSR-7 and ReadableStreamInterface from ReactPHP - * - * This class is used in the server to stream the body of an incoming response - * from the client. This allows us to stream big amounts of data without having - * to buffer this data. Similarly, this used to stream the body of an outgoing - * request body to the client. The data will be sent directly to the client. - * - * Note that this is an internal class only and nothing you should usually care - * about. See the `StreamInterface` and `ReadableStreamInterface` for more - * details. - * - * @see StreamInterface - * @see ReadableStreamInterface - * @internal - */ -class HttpBodyStream extends EventEmitter implements StreamInterface, ReadableStreamInterface -{ - public $input; - private $closed = false; - private $size; - - /** - * @param ReadableStreamInterface $input Stream data from $stream as a body of a PSR-7 object4 - * @param int|null $size size of the data body - */ - 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')); - } - - public function isReadable() - { - return !$this->closed && $this->input->isReadable(); - } - - public function pause() - { - $this->input->pause(); - } - - public function resume() - { - $this->input->resume(); - } - - public function pipe(WritableStreamInterface $dest, array $options = array()) - { - Util::pipe($this, $dest, $options); - - return $dest; - } - - public function close() +if (version_compare(InstalledVersions::getVersion('psr/http-message'), '2.0.0', '<')) { + class HttpBodyStream extends V1\HttpBodyStream { - if ($this->closed) { - return; - } - - $this->closed = true; - - $this->input->close(); - - $this->emit('close'); - $this->removeAllListeners(); } - - public function getSize() - { - return $this->size; - } - - /** @ignore */ - public function __toString() - { - return ''; - } - - /** @ignore */ - public function detach() - { - return null; - } - - /** @ignore */ - public function tell() - { - throw new \BadMethodCallException(); - } - - /** @ignore */ - public function eof() - { - throw new \BadMethodCallException(); - } - - /** @ignore */ - public function isSeekable() - { - return false; - } - - /** @ignore */ - public function seek($offset, $whence = SEEK_SET) - { - throw new \BadMethodCallException(); - } - - /** @ignore */ - public function rewind() - { - throw new \BadMethodCallException(); - } - - /** @ignore */ - public function isWritable() - { - return false; - } - - /** @ignore */ - public function write($string) - { - throw new \BadMethodCallException(); - } - - /** @ignore */ - public function read($length) - { - throw new \BadMethodCallException(); - } - - /** @ignore */ - public function getContents() - { - return ''; - } - - /** @ignore */ - public function getMetadata($key = null) - { - return null; - } - - /** @internal */ - public function handleData($data) - { - $this->emit('data', array($data)); - } - - /** @internal */ - public function handleError(\Exception $e) - { - $this->emit('error', array($e)); - $this->close(); - } - - /** @internal */ - public function handleEnd() +} else { + class HttpBodyStream extends V2\HttpBodyStream { - if (!$this->closed) { - $this->emit('end'); - $this->close(); - } } } diff --git a/src/Io/ReadableBodyStream.php b/src/Io/ReadableBodyStream.php index daef45f9..0e405c95 100644 --- a/src/Io/ReadableBodyStream.php +++ b/src/Io/ReadableBodyStream.php @@ -2,152 +2,14 @@ namespace React\Http\Io; -use Evenement\EventEmitter; -use Psr\Http\Message\StreamInterface; -use React\Stream\ReadableStreamInterface; -use React\Stream\Util; -use React\Stream\WritableStreamInterface; +use Composer\InstalledVersions; -/** - * @internal - */ -class ReadableBodyStream extends EventEmitter implements ReadableStreamInterface, StreamInterface -{ - private $input; - private $position = 0; - private $size; - private $closed = false; - - public function __construct(ReadableStreamInterface $input, $size = null) - { - $this->input = $input; - $this->size = $size; - - $that = $this; - $pos =& $this->position; - $input->on('data', function ($data) use ($that, &$pos, $size) { - $that->emit('data', array($data)); - - $pos += \strlen($data); - if ($size !== null && $pos >= $size) { - $that->handleEnd(); - } - }); - $input->on('error', function ($error) use ($that) { - $that->emit('error', array($error)); - $that->close(); - }); - $input->on('end', array($that, 'handleEnd')); - $input->on('close', array($that, 'close')); - } - - public function close() - { - if (!$this->closed) { - $this->closed = true; - $this->input->close(); - - $this->emit('close'); - $this->removeAllListeners(); - } - } - - public function isReadable() - { - return $this->input->isReadable(); - } - - public function pause() +if (version_compare(InstalledVersions::getVersion('psr/http-message'), '2.0.0', '<')) { + class ReadableBodyStream extends V1\ReadableBodyStream { - $this->input->pause(); } - - public function resume() +} else { + class ReadableBodyStream extends V2\ReadableBodyStream { - $this->input->resume(); - } - - public function pipe(WritableStreamInterface $dest, array $options = array()) - { - Util::pipe($this, $dest, $options); - - return $dest; - } - - public function eof() - { - return !$this->isReadable(); - } - - public function __toString() - { - return ''; - } - - public function detach() - { - throw new \BadMethodCallException(); - } - - public function getSize() - { - return $this->size; - } - - public function tell() - { - throw new \BadMethodCallException(); - } - - public function isSeekable() - { - return false; - } - - public function seek($offset, $whence = SEEK_SET) - { - throw new \BadMethodCallException(); - } - - public function rewind() - { - throw new \BadMethodCallException(); - } - - public function isWritable() - { - return false; - } - - public function write($string) - { - throw new \BadMethodCallException(); - } - - public function read($length) - { - throw new \BadMethodCallException(); - } - - public function getContents() - { - throw new \BadMethodCallException(); - } - - public function getMetadata($key = null) - { - return ($key === null) ? array() : null; - } - - /** @internal */ - public function handleEnd() - { - if ($this->position !== $this->size && $this->size !== null) { - $this->emit('error', array(new \UnderflowException('Unexpected end of response body after ' . $this->position . '/' . $this->size . ' bytes'))); - } else { - $this->emit('end'); - } - - $this->close(); } } diff --git a/src/Io/UploadedFile.php b/src/Io/UploadedFile.php index f2a6c9e7..2cb84a6e 100644 --- a/src/Io/UploadedFile.php +++ b/src/Io/UploadedFile.php @@ -2,129 +2,14 @@ namespace React\Http\Io; -use Psr\Http\Message\StreamInterface; -use Psr\Http\Message\UploadedFileInterface; -use InvalidArgumentException; -use RuntimeException; +use Composer\InstalledVersions; -/** - * [Internal] Implementation of the PSR-7 `UploadedFileInterface` - * - * This is used internally to represent each incoming file upload. - * - * Note that this is an internal class only and nothing you should usually care - * about. See the `UploadedFileInterface` for more details. - * - * @see UploadedFileInterface - * @internal - */ -final class UploadedFile implements UploadedFileInterface -{ - /** - * @var StreamInterface - */ - private $stream; - - /** - * @var int - */ - private $size; - - /** - * @var int - */ - private $error; - - /** - * @var string - */ - private $filename; - - /** - * @var string - */ - private $mediaType; - - /** - * @param StreamInterface $stream - * @param int $size - * @param int $error - * @param string $filename - * @param string $mediaType - */ - public function __construct(StreamInterface $stream, $size, $error, $filename, $mediaType) - { - $this->stream = $stream; - $this->size = $size; - - if (!\is_int($error) || !\in_array($error, array( - \UPLOAD_ERR_OK, - \UPLOAD_ERR_INI_SIZE, - \UPLOAD_ERR_FORM_SIZE, - \UPLOAD_ERR_PARTIAL, - \UPLOAD_ERR_NO_FILE, - \UPLOAD_ERR_NO_TMP_DIR, - \UPLOAD_ERR_CANT_WRITE, - \UPLOAD_ERR_EXTENSION, - ))) { - throw new InvalidArgumentException( - 'Invalid error code, must be an UPLOAD_ERR_* constant' - ); - } - $this->error = $error; - $this->filename = $filename; - $this->mediaType = $mediaType; - } - - /** - * {@inheritdoc} - */ - public function getStream() +if (version_compare(InstalledVersions::getVersion('psr/http-message'), '2.0.0', '<')) { + final class UploadedFile extends V1\UploadedFile { - if ($this->error !== \UPLOAD_ERR_OK) { - throw new RuntimeException('Cannot retrieve stream due to upload error'); - } - - return $this->stream; } - - /** - * {@inheritdoc} - */ - public function moveTo($targetPath) - { - throw new RuntimeException('Not implemented'); - } - - /** - * {@inheritdoc} - */ - public function getSize() - { - return $this->size; - } - - /** - * {@inheritdoc} - */ - public function getError() - { - return $this->error; - } - - /** - * {@inheritdoc} - */ - public function getClientFilename() - { - return $this->filename; - } - - /** - * {@inheritdoc} - */ - public function getClientMediaType() +} else { + final class UploadedFile extends V2\UploadedFile { - return $this->mediaType; } } diff --git a/src/Io/V1/AbstractMessage.php b/src/Io/V1/AbstractMessage.php new file mode 100644 index 00000000..85b4fece --- /dev/null +++ b/src/Io/V1/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 = array(); + + /** @var array */ + private $headerNamesLowerCase = array(); + + /** @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 !== array()) { + if (\is_array($value)) { + foreach ($value as &$one) { + $one = (string) $one; + } + } else { + $value = array((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]] : array(); + } + + public function getHeaderLine($name) + { + return \implode(', ', $this->getHeader($name)); + } + + public function withHeader($name, $value) + { + if ($value === array()) { + return $this->withoutHeader($name); + } elseif (\is_array($value)) { + foreach ($value as &$one) { + $one = (string) $one; + } + } else { + $value = array((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 === array()) { + return $this; + } + + return $this->withHeader($name, \array_merge($this->getHeader($name), \is_array($value) ? $value : array($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/V1/AbstractRequest.php b/src/Io/V1/AbstractRequest.php new file mode 100644 index 00000000..3218e350 --- /dev/null +++ b/src/Io/V1/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 !== array()) { + $host = ''; + break; + } + } + if ($host !== '') { + $port = $uri->getPort(); + if ($port !== null && (!($port === 80 && $uri->getScheme() === 'http') || !($port === 443 && $uri->getScheme() === 'https'))) { + $host .= ':' . $port; + } + + $headers = array('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/V1/BufferedBody.php b/src/Io/V1/BufferedBody.php new file mode 100644 index 00000000..135b8c08 --- /dev/null +++ b/src/Io/V1/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 ? array() : null; + } +} diff --git a/src/Io/V1/EmptyBodyStream.php b/src/Io/V1/EmptyBodyStream.php new file mode 100644 index 00000000..c6ed142c --- /dev/null +++ b/src/Io/V1/EmptyBodyStream.php @@ -0,0 +1,142 @@ +closed; + } + + public function pause() + { + // NOOP + } + + public function resume() + { + // NOOP + } + + public function pipe(WritableStreamInterface $dest, array $options = array()) + { + Util::pipe($this, $dest, $options); + + return $dest; + } + + public function close() + { + if ($this->closed) { + return; + } + + $this->closed = true; + + $this->emit('close'); + $this->removeAllListeners(); + } + + public function getSize() + { + return 0; + } + + /** @ignore */ + public function __toString() + { + return ''; + } + + /** @ignore */ + public function detach() + { + return null; + } + + /** @ignore */ + public function tell() + { + throw new \BadMethodCallException(); + } + + /** @ignore */ + public function eof() + { + throw new \BadMethodCallException(); + } + + /** @ignore */ + public function isSeekable() + { + return false; + } + + /** @ignore */ + public function seek($offset, $whence = SEEK_SET) + { + throw new \BadMethodCallException(); + } + + /** @ignore */ + public function rewind() + { + throw new \BadMethodCallException(); + } + + /** @ignore */ + public function isWritable() + { + return false; + } + + /** @ignore */ + public function write($string) + { + throw new \BadMethodCallException(); + } + + /** @ignore */ + public function read($length) + { + throw new \BadMethodCallException(); + } + + /** @ignore */ + public function getContents() + { + return ''; + } + + /** @ignore */ + public function getMetadata($key = null) + { + return ($key === null) ? array() : null; + } +} diff --git a/src/Io/V1/HttpBodyStream.php b/src/Io/V1/HttpBodyStream.php new file mode 100644 index 00000000..2685dd5e --- /dev/null +++ b/src/Io/V1/HttpBodyStream.php @@ -0,0 +1,182 @@ +input = $input; + $this->size = $size; + + $this->input->on('data', array($this, 'handleData')); + $this->input->on('end', array($this, 'handleEnd')); + $this->input->on('error', array($this, 'handleError')); + $this->input->on('close', array($this, 'close')); + } + + public function isReadable() + { + return !$this->closed && $this->input->isReadable(); + } + + public function pause() + { + $this->input->pause(); + } + + public function resume() + { + $this->input->resume(); + } + + public function pipe(WritableStreamInterface $dest, array $options = array()) + { + Util::pipe($this, $dest, $options); + + return $dest; + } + + public function close() + { + if ($this->closed) { + return; + } + + $this->closed = true; + + $this->input->close(); + + $this->emit('close'); + $this->removeAllListeners(); + } + + public function getSize() + { + return $this->size; + } + + /** @ignore */ + public function __toString() + { + return ''; + } + + /** @ignore */ + public function detach() + { + return null; + } + + /** @ignore */ + public function tell() + { + throw new \BadMethodCallException(); + } + + /** @ignore */ + public function eof() + { + throw new \BadMethodCallException(); + } + + /** @ignore */ + public function isSeekable() + { + return false; + } + + /** @ignore */ + public function seek($offset, $whence = SEEK_SET) + { + throw new \BadMethodCallException(); + } + + /** @ignore */ + public function rewind() + { + throw new \BadMethodCallException(); + } + + /** @ignore */ + public function isWritable() + { + return false; + } + + /** @ignore */ + public function write($string) + { + throw new \BadMethodCallException(); + } + + /** @ignore */ + public function read($length) + { + throw new \BadMethodCallException(); + } + + /** @ignore */ + public function getContents() + { + return ''; + } + + /** @ignore */ + public function getMetadata($key = null) + { + return null; + } + + /** @internal */ + public function handleData($data) + { + $this->emit('data', array($data)); + } + + /** @internal */ + public function handleError(\Exception $e) + { + $this->emit('error', array($e)); + $this->close(); + } + + /** @internal */ + public function handleEnd() + { + if (!$this->closed) { + $this->emit('end'); + $this->close(); + } + } +} diff --git a/src/Io/V1/ReadableBodyStream.php b/src/Io/V1/ReadableBodyStream.php new file mode 100644 index 00000000..e65f9f6c --- /dev/null +++ b/src/Io/V1/ReadableBodyStream.php @@ -0,0 +1,153 @@ +input = $input; + $this->size = $size; + + $that = $this; + $pos =& $this->position; + $input->on('data', function ($data) use ($that, &$pos, $size) { + $that->emit('data', array($data)); + + $pos += \strlen($data); + if ($size !== null && $pos >= $size) { + $that->handleEnd(); + } + }); + $input->on('error', function ($error) use ($that) { + $that->emit('error', array($error)); + $that->close(); + }); + $input->on('end', array($that, 'handleEnd')); + $input->on('close', array($that, 'close')); + } + + public function close() + { + if (!$this->closed) { + $this->closed = true; + $this->input->close(); + + $this->emit('close'); + $this->removeAllListeners(); + } + } + + public function isReadable() + { + return $this->input->isReadable(); + } + + public function pause() + { + $this->input->pause(); + } + + public function resume() + { + $this->input->resume(); + } + + public function pipe(WritableStreamInterface $dest, array $options = array()) + { + Util::pipe($this, $dest, $options); + + return $dest; + } + + public function eof() + { + return !$this->isReadable(); + } + + public function __toString() + { + return ''; + } + + public function detach() + { + throw new \BadMethodCallException(); + } + + public function getSize() + { + return $this->size; + } + + public function tell() + { + throw new \BadMethodCallException(); + } + + public function isSeekable() + { + return false; + } + + public function seek($offset, $whence = SEEK_SET) + { + throw new \BadMethodCallException(); + } + + public function rewind() + { + throw new \BadMethodCallException(); + } + + public function isWritable() + { + return false; + } + + public function write($string) + { + throw new \BadMethodCallException(); + } + + public function read($length) + { + throw new \BadMethodCallException(); + } + + public function getContents() + { + throw new \BadMethodCallException(); + } + + public function getMetadata($key = null) + { + return ($key === null) ? array() : null; + } + + /** @internal */ + public function handleEnd() + { + if ($this->position !== $this->size && $this->size !== null) { + $this->emit('error', array(new \UnderflowException('Unexpected end of response body after ' . $this->position . '/' . $this->size . ' bytes'))); + } else { + $this->emit('end'); + } + + $this->close(); + } +} diff --git a/src/Io/V1/UploadedFile.php b/src/Io/V1/UploadedFile.php new file mode 100644 index 00000000..c74bc21f --- /dev/null +++ b/src/Io/V1/UploadedFile.php @@ -0,0 +1,130 @@ +stream = $stream; + $this->size = $size; + + if (!\is_int($error) || !\in_array($error, array( + \UPLOAD_ERR_OK, + \UPLOAD_ERR_INI_SIZE, + \UPLOAD_ERR_FORM_SIZE, + \UPLOAD_ERR_PARTIAL, + \UPLOAD_ERR_NO_FILE, + \UPLOAD_ERR_NO_TMP_DIR, + \UPLOAD_ERR_CANT_WRITE, + \UPLOAD_ERR_EXTENSION, + ))) { + throw new InvalidArgumentException( + 'Invalid error code, must be an UPLOAD_ERR_* constant' + ); + } + $this->error = $error; + $this->filename = $filename; + $this->mediaType = $mediaType; + } + + /** + * {@inheritdoc} + */ + public function getStream() + { + if ($this->error !== \UPLOAD_ERR_OK) { + throw new RuntimeException('Cannot retrieve stream due to upload error'); + } + + return $this->stream; + } + + /** + * {@inheritdoc} + */ + public function moveTo($targetPath) + { + throw new RuntimeException('Not implemented'); + } + + /** + * {@inheritdoc} + */ + public function getSize() + { + return $this->size; + } + + /** + * {@inheritdoc} + */ + public function getError() + { + return $this->error; + } + + /** + * {@inheritdoc} + */ + public function getClientFilename() + { + return $this->filename; + } + + /** + * {@inheritdoc} + */ + public function getClientMediaType() + { + return $this->mediaType; + } +} diff --git a/src/Io/V2/AbstractMessage.php b/src/Io/V2/AbstractMessage.php new file mode 100644 index 00000000..914c4728 --- /dev/null +++ b/src/Io/V2/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 = array(); + + /** @var array */ + private $headerNamesLowerCase = array(); + + /** @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 !== array()) { + if (\is_array($value)) { + foreach ($value as &$one) { + $one = (string) $one; + } + } else { + $value = array((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(): string + { + return $this->protocolVersion; + } + + public function withProtocolVersion(string $version): MessageInterface + { + if ((string) $version === $this->protocolVersion) { + return $this; + } + + $message = clone $this; + $message->protocolVersion = (string) $version; + + return $message; + } + + public function getHeaders(): array + { + return $this->headers; + } + + public function hasHeader(string $name): bool + { + return isset($this->headerNamesLowerCase[\strtolower($name)]); + } + + public function getHeader(string $name): array + { + $lower = \strtolower($name); + return isset($this->headerNamesLowerCase[$lower]) ? $this->headers[$this->headerNamesLowerCase[$lower]] : array(); + } + + public function getHeaderLine(string $name): string + { + return \implode(', ', $this->getHeader($name)); + } + + public function withHeader(string $name, $value): MessageInterface + { + if ($value === array()) { + return $this->withoutHeader($name); + } elseif (\is_array($value)) { + foreach ($value as &$one) { + $one = (string) $one; + } + } else { + $value = array((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(string $name, $value): MessageInterface + { + if ($value === array()) { + return $this; + } + + return $this->withHeader($name, \array_merge($this->getHeader($name), \is_array($value) ? $value : array($value))); + } + + public function withoutHeader(string $name): MessageInterface + { + $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(): StreamInterface + { + return $this->body; + } + + public function withBody(StreamInterface $body): MessageInterface + { + if ($body === $this->body) { + return $this; + } + + $message = clone $this; + $message->body = $body; + + return $message; + } +} diff --git a/src/Io/V2/AbstractRequest.php b/src/Io/V2/AbstractRequest.php new file mode 100644 index 00000000..8d38f2c9 --- /dev/null +++ b/src/Io/V2/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 !== array()) { + $host = ''; + break; + } + } + if ($host !== '') { + $port = $uri->getPort(); + if ($port !== null && (!($port === 80 && $uri->getScheme() === 'http') || !($port === 443 && $uri->getScheme() === 'https'))) { + $host .= ':' . $port; + } + + $headers = array('Host' => $host) + $headers; + } + } + + parent::__construct($protocolVersion, $headers, $body); + + $this->method = $method; + $this->uri = $uri; + } + + public function getRequestTarget(): string + { + 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(string $requestTarget): RequestInterface + { + if ((string) $requestTarget === $this->requestTarget) { + return $this; + } + + $request = clone $this; + $request->requestTarget = (string) $requestTarget; + + return $request; + } + + public function getMethod(): string + { + return $this->method; + } + + public function withMethod(string $method): RequestInterface + { + if ((string) $method === $this->method) { + return $this; + } + + $request = clone $this; + $request->method = (string) $method; + + return $request; + } + + public function getUri(): UriInterface + { + return $this->uri; + } + + public function withUri(UriInterface $uri, bool $preserveHost = false): RequestInterface + { + 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/V2/BufferedBody.php b/src/Io/V2/BufferedBody.php new file mode 100644 index 00000000..acb69342 --- /dev/null +++ b/src/Io/V2/BufferedBody.php @@ -0,0 +1,179 @@ +buffer = $buffer; + } + + public function __toString(): string + { + if ($this->closed) { + return ''; + } + + $this->seek(0); + + return $this->getContents(); + } + + public function close(): void + { + $this->buffer = ''; + $this->position = 0; + $this->closed = true; + } + + public function detach() + { + $this->close(); + + return null; + } + + public function getSize(): ?int + { + return $this->closed ? null : \strlen($this->buffer); + } + + public function tell(): int + { + if ($this->closed) { + throw new \RuntimeException('Unable to tell position of closed stream'); + } + + return $this->position; + } + + public function eof(): bool + { + return $this->position >= \strlen($this->buffer); + } + + public function isSeekable(): bool + { + return !$this->closed; + } + + public function seek(int $offset, int $whence = \SEEK_SET): void + { + 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(): void + { + $this->seek(0); + } + + public function isWritable(): bool + { + return !$this->closed; + } + + public function write(string $string): int + { + 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(): bool + { + return !$this->closed; + } + + public function read(int $length): string + { + 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(): string + { + 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(?string $key = null) + { + return $key === null ? array() : null; + } +} diff --git a/src/Io/V2/EmptyBodyStream.php b/src/Io/V2/EmptyBodyStream.php new file mode 100644 index 00000000..5c65d755 --- /dev/null +++ b/src/Io/V2/EmptyBodyStream.php @@ -0,0 +1,142 @@ +closed; + } + + public function pause() + { + // NOOP + } + + public function resume() + { + // NOOP + } + + public function pipe(WritableStreamInterface $dest, array $options = array()) + { + Util::pipe($this, $dest, $options); + + return $dest; + } + + public function close(): void + { + if ($this->closed) { + return; + } + + $this->closed = true; + + $this->emit('close'); + $this->removeAllListeners(); + } + + public function getSize(): ?int + { + return 0; + } + + /** @ignore */ + public function __toString(): string + { + return ''; + } + + /** @ignore */ + public function detach() + { + return null; + } + + /** @ignore */ + public function tell(): int + { + throw new \BadMethodCallException(); + } + + /** @ignore */ + public function eof(): bool + { + throw new \BadMethodCallException(); + } + + /** @ignore */ + public function isSeekable(): bool + { + return false; + } + + /** @ignore */ + public function seek(int $offset, int $whence = SEEK_SET): void + { + throw new \BadMethodCallException(); + } + + /** @ignore */ + public function rewind(): void + { + throw new \BadMethodCallException(); + } + + /** @ignore */ + public function isWritable(): bool + { + return false; + } + + /** @ignore */ + public function write(string $string): int + { + throw new \BadMethodCallException(); + } + + /** @ignore */ + public function read(int $length): string + { + throw new \BadMethodCallException(); + } + + /** @ignore */ + public function getContents(): string + { + return ''; + } + + /** @ignore */ + public function getMetadata($key = null) + { + return ($key === null) ? array() : null; + } +} diff --git a/src/Io/V2/HttpBodyStream.php b/src/Io/V2/HttpBodyStream.php new file mode 100644 index 00000000..e3dbb5f5 --- /dev/null +++ b/src/Io/V2/HttpBodyStream.php @@ -0,0 +1,182 @@ +input = $input; + $this->size = $size; + + $this->input->on('data', array($this, 'handleData')); + $this->input->on('end', array($this, 'handleEnd')); + $this->input->on('error', array($this, 'handleError')); + $this->input->on('close', array($this, 'close')); + } + + public function isReadable(): bool + { + return !$this->closed && $this->input->isReadable(); + } + + public function pause() + { + $this->input->pause(); + } + + public function resume() + { + $this->input->resume(); + } + + public function pipe(WritableStreamInterface $dest, array $options = array()) + { + Util::pipe($this, $dest, $options); + + return $dest; + } + + public function close(): void + { + if ($this->closed) { + return; + } + + $this->closed = true; + + $this->input->close(); + + $this->emit('close'); + $this->removeAllListeners(); + } + + public function getSize(): ?int + { + return $this->size; + } + + /** @ignore */ + public function __toString(): string + { + return ''; + } + + /** @ignore */ + public function detach() + { + return null; + } + + /** @ignore */ + public function tell(): int + { + throw new \BadMethodCallException(); + } + + /** @ignore */ + public function eof(): bool + { + throw new \BadMethodCallException(); + } + + /** @ignore */ + public function isSeekable(): bool + { + return false; + } + + /** @ignore */ + public function seek(int $offset, int $whence = SEEK_SET): void + { + throw new \BadMethodCallException(); + } + + /** @ignore */ + public function rewind(): void + { + throw new \BadMethodCallException(); + } + + /** @ignore */ + public function isWritable(): bool + { + return false; + } + + /** @ignore */ + public function write(string $string): int + { + throw new \BadMethodCallException(); + } + + /** @ignore */ + public function read(int $length): string + { + throw new \BadMethodCallException(); + } + + /** @ignore */ + public function getContents(): string + { + return ''; + } + + /** @ignore */ + public function getMetadata($key = null) + { + return null; + } + + /** @internal */ + public function handleData($data) + { + $this->emit('data', array($data)); + } + + /** @internal */ + public function handleError(\Exception $e) + { + $this->emit('error', array($e)); + $this->close(); + } + + /** @internal */ + public function handleEnd() + { + if (!$this->closed) { + $this->emit('end'); + $this->close(); + } + } +} diff --git a/src/Io/V2/ReadableBodyStream.php b/src/Io/V2/ReadableBodyStream.php new file mode 100644 index 00000000..1c22abc8 --- /dev/null +++ b/src/Io/V2/ReadableBodyStream.php @@ -0,0 +1,153 @@ +input = $input; + $this->size = $size; + + $that = $this; + $pos =& $this->position; + $input->on('data', function ($data) use ($that, &$pos, $size) { + $that->emit('data', array($data)); + + $pos += \strlen($data); + if ($size !== null && $pos >= $size) { + $that->handleEnd(); + } + }); + $input->on('error', function ($error) use ($that) { + $that->emit('error', array($error)); + $that->close(); + }); + $input->on('end', array($that, 'handleEnd')); + $input->on('close', array($that, 'close')); + } + + public function close(): void + { + if (!$this->closed) { + $this->closed = true; + $this->input->close(); + + $this->emit('close'); + $this->removeAllListeners(); + } + } + + public function isReadable(): bool + { + return $this->input->isReadable(); + } + + public function pause() + { + $this->input->pause(); + } + + public function resume() + { + $this->input->resume(); + } + + public function pipe(WritableStreamInterface $dest, array $options = array()) + { + Util::pipe($this, $dest, $options); + + return $dest; + } + + public function eof(): bool + { + return !$this->isReadable(); + } + + public function __toString(): string + { + return ''; + } + + public function detach() + { + throw new \BadMethodCallException(); + } + + public function getSize(): ?int + { + return $this->size; + } + + public function tell():int + { + throw new \BadMethodCallException(); + } + + public function isSeekable(): bool + { + return false; + } + + public function seek(int $offset, int $whence = SEEK_SET): void + { + throw new \BadMethodCallException(); + } + + public function rewind(): void + { + throw new \BadMethodCallException(); + } + + public function isWritable(): bool + { + return false; + } + + public function write(string $string): int + { + throw new \BadMethodCallException(); + } + + public function read(int $length): string + { + throw new \BadMethodCallException(); + } + + public function getContents(): string + { + throw new \BadMethodCallException(); + } + + public function getMetadata(?string $key = null) + { + return ($key === null) ? array() : null; + } + + /** @internal */ + public function handleEnd() + { + if ($this->position !== $this->size && $this->size !== null) { + $this->emit('error', array(new \UnderflowException('Unexpected end of response body after ' . $this->position . '/' . $this->size . ' bytes'))); + } else { + $this->emit('end'); + } + + $this->close(); + } +} diff --git a/src/Io/V2/UploadedFile.php b/src/Io/V2/UploadedFile.php new file mode 100644 index 00000000..3127fc57 --- /dev/null +++ b/src/Io/V2/UploadedFile.php @@ -0,0 +1,130 @@ +stream = $stream; + $this->size = $size; + + if (!\is_int($error) || !\in_array($error, array( + \UPLOAD_ERR_OK, + \UPLOAD_ERR_INI_SIZE, + \UPLOAD_ERR_FORM_SIZE, + \UPLOAD_ERR_PARTIAL, + \UPLOAD_ERR_NO_FILE, + \UPLOAD_ERR_NO_TMP_DIR, + \UPLOAD_ERR_CANT_WRITE, + \UPLOAD_ERR_EXTENSION, + ))) { + throw new InvalidArgumentException( + 'Invalid error code, must be an UPLOAD_ERR_* constant' + ); + } + $this->error = $error; + $this->filename = $filename; + $this->mediaType = $mediaType; + } + + /** + * {@inheritdoc} + */ + public function getStream(): StreamInterface + { + if ($this->error !== \UPLOAD_ERR_OK) { + throw new RuntimeException('Cannot retrieve stream due to upload error'); + } + + return $this->stream; + } + + /** + * {@inheritdoc} + */ + public function moveTo(string $targetPath): void + { + throw new RuntimeException('Not implemented'); + } + + /** + * {@inheritdoc} + */ + public function getSize(): ?int + { + return $this->size; + } + + /** + * {@inheritdoc} + */ + public function getError(): int + { + return $this->error; + } + + /** + * {@inheritdoc} + */ + public function getClientFilename(): ?string + { + return $this->filename; + } + + /** + * {@inheritdoc} + */ + public function getClientMediaType(): ?string + { + return $this->mediaType; + } +} diff --git a/src/Message/Request.php b/src/Message/Request.php index 3de8c1b3..e7b33ca1 100644 --- a/src/Message/Request.php +++ b/src/Message/Request.php @@ -2,56 +2,14 @@ namespace React\Http\Message; -use Psr\Http\Message\RequestInterface; -use Psr\Http\Message\StreamInterface; -use Psr\Http\Message\UriInterface; -use React\Http\Io\AbstractRequest; -use React\Http\Io\BufferedBody; -use React\Http\Io\ReadableBodyStream; -use React\Stream\ReadableStreamInterface; +use Composer\InstalledVersions; -/** - * Respresents 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. - * - * @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 = array(), - $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); +if (version_compare(InstalledVersions::getVersion('psr/http-message'), '2.0.0', '<')) { + final class Request extends V1\Request + { + } +} else { + final class Request extends V2\Request + { } } diff --git a/src/Message/Response.php b/src/Message/Response.php index fa6366ed..a50c3119 100644 --- a/src/Message/Response.php +++ b/src/Message/Response.php @@ -2,413 +2,70 @@ namespace React\Http\Message; -use Fig\Http\Message\StatusCodeInterface; -use Psr\Http\Message\ResponseInterface; -use Psr\Http\Message\StreamInterface; -use React\Http\Io\AbstractMessage; -use React\Http\Io\BufferedBody; -use React\Http\Io\HttpBodyStream; -use React\Stream\ReadableStreamInterface; +use Composer\InstalledVersions; -/** - * Represents an outgoing server response message. - * - * ```php - * $response = new React\Http\Message\Response( - * React\Http\Message\Response::STATUS_OK, - * array( - * '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 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) +if (version_compare(InstalledVersions::getVersion('psr/http-message'), '2.0.0', '<')) { + final class Response extends V1\Response { - return new self(self::STATUS_OK, array('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 (PHP 5.4+) and preserving - * zero fractions for `float` values (PHP 5.6.6+) 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, - (\defined('JSON_PRETTY_PRINT') ? \JSON_PRETTY_PRINT | \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE : 0) | (\defined('JSON_PRESERVE_ZERO_FRACTION') ? \JSON_PRESERVE_ZERO_FRACTION : 0) + /** + * @var bool + * @see static::$phrasesMap + */ + protected 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 `static::STATUS_*` for all possible status code + * constants. + * + * @var array + * @see static::STATUS_* + * @see static::getReasonPhraseForStatusCode() + */ + protected static $phrasesMap = array( + 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' ); - - // throw on error, now `false` but used to be `(string) "null"` before PHP 5.5 - if ($json === false || (\PHP_VERSION_ID < 50500 && \json_last_error() !== \JSON_ERROR_NONE)) { - throw new \InvalidArgumentException( - 'Unable to encode given data as JSON' . (\function_exists('json_last_error_msg') ? ': ' . \json_last_error_msg() : ''), - \json_last_error() - ); - } - - return new self(self::STATUS_OK, array('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) +} else { + final class Response extends V2\Response { - return new self(self::STATUS_OK, array('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, array('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 = array( - 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 = array(), - $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 isset(self::$phrasesMap[$code]) ? 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 = array(); - 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 = array(); - $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 = array(); - foreach ($matches as $match) { - $headers[$match[1]][] = $match[2]; - } - - return new self( - (int) $start['status'], - $headers, - '', - $start['version'], - isset($start['reason']) ? $start['reason'] : '' + /** + * @var bool + * @see static::$phrasesMap + */ + protected 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 `static::STATUS_*` for all possible status code + * constants. + * + * @var array + * @see static::STATUS_* + * @see static::getReasonPhraseForStatusCode() + */ + protected static $phrasesMap = array( + 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' ); } } diff --git a/src/Message/ServerRequest.php b/src/Message/ServerRequest.php index 32a0f62f..628d1904 100644 --- a/src/Message/ServerRequest.php +++ b/src/Message/ServerRequest.php @@ -2,330 +2,14 @@ namespace React\Http\Message; -use Psr\Http\Message\ServerRequestInterface; -use Psr\Http\Message\StreamInterface; -use Psr\Http\Message\UriInterface; -use React\Http\Io\AbstractRequest; -use React\Http\Io\BufferedBody; -use React\Http\Io\HttpBodyStream; -use React\Stream\ReadableStreamInterface; +use Composer\InstalledVersions; -/** - * Respresents 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. - * - * @see ServerRequestInterface - */ -final class ServerRequest extends AbstractRequest implements ServerRequestInterface -{ - private $attributes = array(); - - private $serverParams; - private $fileParams = array(); - private $cookies = array(); - private $queryParams = array(); - 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 = array(), - $body = '', - $version = '1.1', - $serverParams = array() - ) { - 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() +if (version_compare(InstalledVersions::getVersion('psr/http-message'), '2.0.0', '<')) { + final class ServerRequest extends V1\ServerRequest { - 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() +} else { + final class ServerRequest extends V2\ServerRequest { - 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 = array(); - - 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 = array(); - 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 = array(); - $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 = array(); - 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($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 index 1eaf24fe..470fd77a 100644 --- a/src/Message/Uri.php +++ b/src/Message/Uri.php @@ -2,355 +2,14 @@ namespace React\Http\Message; -use Psr\Http\Message\UriInterface; +use Composer\InstalledVersions; -/** - * Respresents 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. - * - * @see UriInterface - */ -final class Uri implements UriInterface -{ - /** @var string */ - private $scheme = ''; - - /** @var string */ - private $userInfo = ''; - - /** @var string */ - private $host = ''; - - /** @var ?int */ - private $port = null; - - /** @var string */ - private $path = ''; - - /** @var string */ - private $query = ''; - - /** @var string */ - private $fragment = ''; - - /** - * @param string $uri - * @throws \InvalidArgumentException if given $uri is invalid - */ - public function __construct($uri) - { - // @codeCoverageIgnoreStart - if (\PHP_VERSION_ID < 50407 && \strpos($uri, '//') === 0) { - // @link https://3v4l.org/UrAQP - $parts = \parse_url('http:' . $uri); - unset($parts['schema']); - } else { - $parts = \parse_url($uri); - } - // @codeCoverageIgnoreEnd - - if ($parts === false || (isset($parts['scheme']) && !\preg_match('#^[a-z]+$#i', $parts['scheme'])) || (isset($parts['host']) && \preg_match('#[\s%+]#', $parts['host']))) { - throw new \InvalidArgumentException('Invalid URI given'); - } - - if (isset($parts['scheme'])) { - $this->scheme = \strtolower($parts['scheme']); - } - - if (isset($parts['user']) || isset($parts['pass'])) { - $this->userInfo = $this->encode(isset($parts['user']) ? $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) +if (version_compare(InstalledVersions::getVersion('psr/http-message'), '2.0.0', '<')) { + final class Uri extends V1\Uri { - $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], isset($userInfo[1]) ? $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) +} else { + final class Uri extends V2\Uri { - $segments = array(); - 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/Message/V1/Request.php b/src/Message/V1/Request.php new file mode 100644 index 00000000..0959491c --- /dev/null +++ b/src/Message/V1/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 + */ +abstract 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 = array(), + $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/V1/Response.php b/src/Message/V1/Response.php new file mode 100644 index 00000000..4f5d5ac7 --- /dev/null +++ b/src/Message/V1/Response.php @@ -0,0 +1,385 @@ + '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 + */ +abstract 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 static + */ + public static function html($html) + { + return new static(static::STATUS_OK, array('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 (PHP 5.4+) and preserving + * zero fractions for `float` values (PHP 5.6.6+) 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 static + * @throws \InvalidArgumentException when encoding fails + */ + public static function json($data) + { + $json = @\json_encode( + $data, + (\defined('JSON_PRETTY_PRINT') ? \JSON_PRETTY_PRINT | \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE : 0) | (\defined('JSON_PRESERVE_ZERO_FRACTION') ? \JSON_PRESERVE_ZERO_FRACTION : 0) + ); + + // throw on error, now `false` but used to be `(string) "null"` before PHP 5.5 + if ($json === false || (\PHP_VERSION_ID < 50500 && \json_last_error() !== \JSON_ERROR_NONE)) { + throw new \InvalidArgumentException( + 'Unable to encode given data as JSON' . (\function_exists('json_last_error_msg') ? ': ' . \json_last_error_msg() : ''), + \json_last_error() + ); + } + + return new static(static::STATUS_OK, array('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 static + */ + public static function plaintext($text) + { + return new static(static::STATUS_OK, array('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 static + */ + public static function xml($xml) + { + return new static(static::STATUS_OK, array('Content-Type' => 'application/xml'), $xml); + } + + /** @var int */ + private $statusCode; + + /** @var string */ + private $reasonPhrase; + + /** + * @param int $status HTTP status code (e.g. 200/404), see `static::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 = array(), + $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 : static::getReasonPhraseForStatusCode($status); + } + + public function getStatusCode() + { + return $this->statusCode; + } + + public function withStatus($code, $reasonPhrase = '') + { + if ((string) $reasonPhrase === '') { + $reasonPhrase = static::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 (!static::$phrasesInitialized) { + static::$phrasesInitialized = true; + + // map all `static::STATUS_` constants from status code to reason phrase + // e.g. `static::STATUS_NOT_FOUND = 404` will be mapped to `404 Not Found` + $ref = new \ReflectionClass(__CLASS__); + foreach ($ref->getConstants() as $name => $value) { + if (!isset(static::$phrasesMap[$value]) && \strpos($name, 'STATUS_') === 0) { + static::$phrasesMap[$value] = \ucwords(\strtolower(\str_replace('_', ' ', \substr($name, 7)))); + } + } + } + + return isset(static::$phrasesMap[$code]) ? static::$phrasesMap[$code] : ''; + } + + /** + * [Internal] Parse incoming HTTP protocol message + * + * @internal + * @param string $message + * @return static + * @throws \InvalidArgumentException if given $message is not a valid HTTP response message + */ + public static function parseMessage($message) + { + $start = array(); + 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 = array(); + $n = \preg_match_all(static::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 = array(); + foreach ($matches as $match) { + $headers[$match[1]][] = $match[2]; + } + + return new static( + (int) $start['status'], + $headers, + '', + $start['version'], + isset($start['reason']) ? $start['reason'] : '' + ); + } +} diff --git a/src/Message/V1/ServerRequest.php b/src/Message/V1/ServerRequest.php new file mode 100644 index 00000000..0d188793 --- /dev/null +++ b/src/Message/V1/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 + */ +abstract class ServerRequest extends AbstractRequest implements ServerRequestInterface +{ + private $attributes = array(); + + private $serverParams; + private $fileParams = array(); + private $cookies = array(); + private $queryParams = array(); + 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 = array(), + $body = '', + $version = '1.1', + $serverParams = array() + ) { + if (\is_string($body)) { + $body = new BufferedBody($body); + } elseif ($body instanceof ReadableStreamInterface && !$body instanceof StreamInterface) { + $temp = new static($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 = array(); + + 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 static + * @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 = array(); + 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 = array(); + $n = \preg_match_all(static::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 = array(); + 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($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 static( + $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/V1/Uri.php b/src/Message/V1/Uri.php new file mode 100644 index 00000000..30705583 --- /dev/null +++ b/src/Message/V1/Uri.php @@ -0,0 +1,356 @@ +scheme = \strtolower($parts['scheme']); + } + + if (isset($parts['user']) || isset($parts['pass'])) { + $this->userInfo = $this->encode(isset($parts['user']) ? $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(static::removeDotSegments($rel->getPath())); + } + + $reset = false; + $new = $base; + if ($rel->getAuthority() !== '') { + $reset = true; + $userInfo = \explode(':', $rel->getUserInfo(), 2); + $new = $base->withUserInfo($userInfo[0], isset($userInfo[1]) ? $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(static::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 = array(); + 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/Message/V2/Request.php b/src/Message/V2/Request.php new file mode 100644 index 00000000..76d798a7 --- /dev/null +++ b/src/Message/V2/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 + */ +abstract 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 = array(), + $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/V2/Response.php b/src/Message/V2/Response.php new file mode 100644 index 00000000..e6e65356 --- /dev/null +++ b/src/Message/V2/Response.php @@ -0,0 +1,414 @@ + '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 + */ +abstract 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 static + */ + public static function html($html) + { + return new static(static::STATUS_OK, array('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 (PHP 5.4+) and preserving + * zero fractions for `float` values (PHP 5.6.6+) 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 static + * @throws \InvalidArgumentException when encoding fails + */ + public static function json($data) + { + $json = @\json_encode( + $data, + (\defined('JSON_PRETTY_PRINT') ? \JSON_PRETTY_PRINT | \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE : 0) | (\defined('JSON_PRESERVE_ZERO_FRACTION') ? \JSON_PRESERVE_ZERO_FRACTION : 0) + ); + + // throw on error, now `false` but used to be `(string) "null"` before PHP 5.5 + if ($json === false || (\PHP_VERSION_ID < 50500 && \json_last_error() !== \JSON_ERROR_NONE)) { + throw new \InvalidArgumentException( + 'Unable to encode given data as JSON' . (\function_exists('json_last_error_msg') ? ': ' . \json_last_error_msg() : ''), + \json_last_error() + ); + } + + return new static(static::STATUS_OK, array('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 static + */ + public static function plaintext($text) + { + return new static(static::STATUS_OK, array('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 static + */ + public static function xml($xml) + { + return new static(static::STATUS_OK, array('Content-Type' => 'application/xml'), $xml); + } + + /** + * @var bool + * @see static::$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 `static::STATUS_*` for all possible status code + * constants. + * + * @var array + * @see static::STATUS_* + * @see static::getReasonPhraseForStatusCode() + */ + private static $phrasesMap = array( + 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 `static::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 = array(), + $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 : static::getReasonPhraseForStatusCode($status); + } + + public function getStatusCode(): int + { + return $this->statusCode; + } + + public function withStatus(int $code, string $reasonPhrase = ''): ResponseInterface + { + if ((string) $reasonPhrase === '') { + $reasonPhrase = static::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(): string + { + 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 (!static::$phrasesInitialized) { + static::$phrasesInitialized = true; + + // map all `static::STATUS_` constants from status code to reason phrase + // e.g. `static::STATUS_NOT_FOUND = 404` will be mapped to `404 Not Found` + $ref = new \ReflectionClass(__CLASS__); + foreach ($ref->getConstants() as $name => $value) { + if (!isset(static::$phrasesMap[$value]) && \strpos($name, 'STATUS_') === 0) { + static::$phrasesMap[$value] = \ucwords(\strtolower(\str_replace('_', ' ', \substr($name, 7)))); + } + } + } + + return isset(static::$phrasesMap[$code]) ? static::$phrasesMap[$code] : ''; + } + + /** + * [Internal] Parse incoming HTTP protocol message + * + * @internal + * @param string $message + * @return static + * @throws \InvalidArgumentException if given $message is not a valid HTTP response message + */ + public static function parseMessage($message) + { + $start = array(); + 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 = array(); + $n = \preg_match_all(static::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 = array(); + foreach ($matches as $match) { + $headers[$match[1]][] = $match[2]; + } + + return new static( + (int) $start['status'], + $headers, + '', + $start['version'], + isset($start['reason']) ? $start['reason'] : '' + ); + } +} diff --git a/src/Message/V2/ServerRequest.php b/src/Message/V2/ServerRequest.php new file mode 100644 index 00000000..8acb6c0e --- /dev/null +++ b/src/Message/V2/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 + */ +abstract class ServerRequest extends AbstractRequest implements ServerRequestInterface +{ + private $attributes = array(); + + private $serverParams; + private $fileParams = array(); + private $cookies = array(); + private $queryParams = array(); + 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 = array(), + $body = '', + $version = '1.1', + $serverParams = array() + ) { + if (\is_string($body)) { + $body = new BufferedBody($body); + } elseif ($body instanceof ReadableStreamInterface && !$body instanceof StreamInterface) { + $temp = new static($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(): array + { + return $this->serverParams; + } + + public function getCookieParams(): array + { + return $this->cookies; + } + + public function withCookieParams(array $cookies): ServerRequestInterface + { + $new = clone $this; + $new->cookies = $cookies; + return $new; + } + + public function getQueryParams(): array + { + return $this->queryParams; + } + + public function withQueryParams(array $query): ServerRequestInterface + { + $new = clone $this; + $new->queryParams = $query; + return $new; + } + + public function getUploadedFiles(): array + { + return $this->fileParams; + } + + public function withUploadedFiles(array $uploadedFiles): ServerRequestInterface + { + $new = clone $this; + $new->fileParams = $uploadedFiles; + return $new; + } + + public function getParsedBody() + { + return $this->parsedBody; + } + + public function withParsedBody($data): ServerRequestInterface + { + $new = clone $this; + $new->parsedBody = $data; + return $new; + } + + public function getAttributes(): array + { + return $this->attributes; + } + + public function getAttribute(string $name, $default = null) + { + if (!\array_key_exists($name, $this->attributes)) { + return $default; + } + return $this->attributes[$name]; + } + + public function withAttribute(string $name, $value): ServerRequestInterface + { + $new = clone $this; + $new->attributes[$name] = $value; + return $new; + } + + public function withoutAttribute(string $name): ServerRequestInterface + { + $new = clone $this; + unset($new->attributes[$name]); + return $new; + } + + /** + * @param string $cookie + * @return array + */ + private function parseCookie($cookie) + { + $cookieArray = \explode(';', $cookie); + $result = array(); + + 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 static + * @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 = array(); + 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 = array(); + $n = \preg_match_all(static::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 = array(); + 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($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 static( + $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/V2/Uri.php b/src/Message/V2/Uri.php new file mode 100644 index 00000000..94612775 --- /dev/null +++ b/src/Message/V2/Uri.php @@ -0,0 +1,344 @@ +scheme = \strtolower($parts['scheme']); + } + + if (isset($parts['user']) || isset($parts['pass'])) { + $this->userInfo = $this->encode(isset($parts['user']) ? $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(): string + { + return $this->scheme; + } + + public function getAuthority(): string + { + if ($this->host === '') { + return ''; + } + + return ($this->userInfo !== '' ? $this->userInfo . '@' : '') . $this->host . ($this->port !== null ? ':' . $this->port : ''); + } + + public function getUserInfo(): string + { + return $this->userInfo; + } + + public function getHost(): string + { + return $this->host; + } + + public function getPort(): ?int + { + return $this->port; + } + + public function getPath(): string + { + return $this->path; + } + + public function getQuery(): string + { + return $this->query; + } + + public function getFragment(): string + { + return $this->fragment; + } + + public function withScheme(string $scheme): UriInterface + { + $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(string $user, ?string $password = null): UriInterface + { + $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(string $host): UriInterface + { + $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(?int $port): UriInterface + { + $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(string $path): UriInterface + { + $path = $this->encode($path, \PHP_URL_PATH); + if ($path === $this->path) { + return $this; + } + + $new = clone $this; + $new->path = $path; + + return $new; + } + + public function withQuery(string $query): UriInterface + { + $query = $this->encode($query, \PHP_URL_QUERY); + if ($query === $this->query) { + return $this; + } + + $new = clone $this; + $new->query = $query; + + return $new; + } + + public function withFragment(string $fragment): UriInterface + { + $fragment = $this->encode($fragment, \PHP_URL_FRAGMENT); + if ($fragment === $this->fragment) { + return $this; + } + + $new = clone $this; + $new->fragment = $fragment; + + return $new; + } + + public function __toString(): string + { + $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; + } + + private function encode(string $part, int $component): string + { + 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 + * @throws void + */ + public static function resolve(UriInterface $base, UriInterface $rel): UriInterface + { + if ($rel->getScheme() !== '') { + return $rel->getPath() === '' ? $rel : $rel->withPath(static::removeDotSegments($rel->getPath())); + } + + $reset = false; + $new = $base; + if ($rel->getAuthority() !== '') { + $reset = true; + $userInfo = \explode(':', $rel->getUserInfo(), 2); + $new = $base->withUserInfo($userInfo[0], isset($userInfo[1]) ? $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(static::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; + } + + private static function removeDotSegments(string $path): string + { + $segments = array(); + 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/tests/Io/EmptyBodyStreamTest.php b/tests/Io/EmptyBodyStreamTest.php index 8430239d..2a314c0e 100644 --- a/tests/Io/EmptyBodyStreamTest.php +++ b/tests/Io/EmptyBodyStreamTest.php @@ -2,6 +2,7 @@ namespace React\Tests\Http\Io; +use Composer\InstalledVersions; use React\Http\Io\EmptyBodyStream; use React\Tests\Http\TestCase; @@ -126,7 +127,12 @@ public function testIsReadableReturnsFalseWhenAlreadyClosed() public function testSeek() { - $this->setExpectedException('BadMethodCallException'); + if (version_compare(InstalledVersions::getVersion('psr/http-message'), '2.0.0', '<')) { + $this->setExpectedException('BadMethodCallException'); + } else { + $this->setExpectedException('TypeError'); + } + $this->bodyStream->seek(''); } diff --git a/tests/Io/HttpBodyStreamTest.php b/tests/Io/HttpBodyStreamTest.php index db21dcf8..f7c2d3bc 100644 --- a/tests/Io/HttpBodyStreamTest.php +++ b/tests/Io/HttpBodyStreamTest.php @@ -2,6 +2,7 @@ namespace React\Tests\Http\Io; +use Composer\InstalledVersions; use React\Http\Io\HttpBodyStream; use React\Stream\ThroughStream; use React\Tests\Http\TestCase; @@ -130,7 +131,12 @@ public function testWrite() public function testRead() { - $this->setExpectedException('BadMethodCallException'); + if (version_compare(InstalledVersions::getVersion('psr/http-message'), '2.0.0', '<')) { + $this->setExpectedException('BadMethodCallException'); + } else { + $this->setExpectedException('TypeError'); + } + $this->bodyStream->read(''); } @@ -151,7 +157,12 @@ public function testIsReadable() public function testSeek() { - $this->setExpectedException('BadMethodCallException'); + if (version_compare(InstalledVersions::getVersion('psr/http-message'), '2.0.0', '<')) { + $this->setExpectedException('BadMethodCallException'); + } else { + $this->setExpectedException('TypeError'); + } + $this->bodyStream->seek(''); }