From e8dbd5cf759409a3a731dfdd6c30dfe877b42eb5 Mon Sep 17 00:00:00 2001 From: Cees-Jan Kiewiet Date: Sat, 18 Apr 2026 22:24:26 +0200 Subject: [PATCH 1/2] Support PSR-7 v2 alongside v1 --- composer.json | 4 +- src/Io/AbstractMessage.php | 6 + src/Io/AbstractRequest.php | 5 + src/Io/BufferedBody.php | 5 + src/Io/EmptyBodyStream.php | 5 + src/Io/HttpBodyStream.php | 5 + src/Io/ReadableBodyStream.php | 5 + src/Io/UploadedFile.php | 5 + src/Io/V2/AbstractMessage.php | 179 +++++++++++++ src/Io/V2/AbstractRequest.php | 163 ++++++++++++ src/Io/V2/BufferedBody.php | 186 ++++++++++++++ src/Io/V2/EmptyBodyStream.php | 149 +++++++++++ src/Io/V2/HttpBodyStream.php | 189 ++++++++++++++ src/Io/V2/ReadableBodyStream.php | 158 ++++++++++++ src/Io/V2/UploadedFile.php | 137 ++++++++++ src/Message/Request.php | 5 + src/Message/Response.php | 9 +- src/Message/ServerRequest.php | 5 + src/Message/Uri.php | 5 + src/Message/V2/Request.php | 66 +++++ src/Message/V2/Response.php | 421 +++++++++++++++++++++++++++++++ src/Message/V2/ServerRequest.php | 340 +++++++++++++++++++++++++ src/Message/V2/Uri.php | 344 +++++++++++++++++++++++++ tests/Io/EmptyBodyStreamTest.php | 8 +- tests/Io/HttpBodyStreamTest.php | 16 +- 25 files changed, 2413 insertions(+), 7 deletions(-) create mode 100644 src/Io/V2/AbstractMessage.php create mode 100644 src/Io/V2/AbstractRequest.php create mode 100644 src/Io/V2/BufferedBody.php create mode 100644 src/Io/V2/EmptyBodyStream.php create mode 100644 src/Io/V2/HttpBodyStream.php create mode 100644 src/Io/V2/ReadableBodyStream.php create mode 100644 src/Io/V2/UploadedFile.php create mode 100644 src/Message/V2/Request.php create mode 100644 src/Message/V2/Response.php create mode 100644 src/Message/V2/ServerRequest.php create mode 100644 src/Message/V2/Uri.php diff --git a/composer.json b/composer.json index 87e3de8f..2c5737f6 100644 --- a/composer.json +++ b/composer.json @@ -29,14 +29,14 @@ "php": ">=7.1", "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/http-proxy-react": "^1.10", "clue/reactphp-ssh-proxy": "^1.4", "clue/socks-react": "^1.4", "phpunit/phpunit": "^9.6 || ^8.5 || ^7.5", diff --git a/src/Io/AbstractMessage.php b/src/Io/AbstractMessage.php index 232a5442..62d61ab9 100644 --- a/src/Io/AbstractMessage.php +++ b/src/Io/AbstractMessage.php @@ -5,6 +5,9 @@ use Psr\Http\Message\MessageInterface; use Psr\Http\Message\StreamInterface; + +$reflectionMethod = new \ReflectionMethod('\Psr\Http\Message\MessageInterface', 'withProtocolVersion'); +if (PHP_VERSION_ID < 70000 || !$reflectionMethod->hasReturnType()) { /** * [Internal] Abstract HTTP message base class (PSR-7) * @@ -170,3 +173,6 @@ public function withBody(StreamInterface $body) return $message; } } +} else { + class_alias(__NAMESPACE__ . '\\V2\\AbstractMessage', __NAMESPACE__ . '\\AbstractMessage'); +} diff --git a/src/Io/AbstractRequest.php b/src/Io/AbstractRequest.php index 1182f7ab..27f0b380 100644 --- a/src/Io/AbstractRequest.php +++ b/src/Io/AbstractRequest.php @@ -7,6 +7,8 @@ use Psr\Http\Message\UriInterface; use React\Http\Message\Uri; +$reflectionMethod = new \ReflectionMethod('\Psr\Http\Message\RequestInterface', 'withRequestTarget'); +if (PHP_VERSION_ID < 70000 || !$reflectionMethod->hasReturnType()) { /** * [Internal] Abstract HTTP request base class (PSR-7) * @@ -154,3 +156,6 @@ public function withUri(UriInterface $uri, $preserveHost = false) return $request; } } +} else { + class_alias(__NAMESPACE__ . '\\V2\\AbstractRequest', __NAMESPACE__ . '\\AbstractRequest'); +} diff --git a/src/Io/BufferedBody.php b/src/Io/BufferedBody.php index 9b1d9887..0b8d1e51 100644 --- a/src/Io/BufferedBody.php +++ b/src/Io/BufferedBody.php @@ -4,6 +4,8 @@ use Psr\Http\Message\StreamInterface; +$reflectionMethod = new \ReflectionMethod('\Psr\Http\Message\StreamInterface', 'eof'); +if (PHP_VERSION_ID < 70000 || !$reflectionMethod->hasReturnType()) { /** * [Internal] PSR-7 message body implementation using an in-memory buffer * @@ -177,3 +179,6 @@ public function getMetadata($key = null) return $key === null ? [] : null; } } +} else { + class_alias(__NAMESPACE__ . '\\V2\\BufferedBody', __NAMESPACE__ . '\\BufferedBody'); +} diff --git a/src/Io/EmptyBodyStream.php b/src/Io/EmptyBodyStream.php index 7f9c8ad0..e191e01b 100644 --- a/src/Io/EmptyBodyStream.php +++ b/src/Io/EmptyBodyStream.php @@ -8,6 +8,8 @@ use React\Stream\Util; use React\Stream\WritableStreamInterface; +$reflectionMethod = new \ReflectionMethod('\Psr\Http\Message\StreamInterface', 'eof'); +if (PHP_VERSION_ID < 70000 || !$reflectionMethod->hasReturnType()) { /** * [Internal] Bridge between an empty StreamInterface from PSR-7 and ReadableStreamInterface from ReactPHP * @@ -140,3 +142,6 @@ public function getMetadata($key = null) return ($key === null) ? [] : null; } } +} else { + class_alias(__NAMESPACE__ . '\\V2\\EmptyBodyStream', __NAMESPACE__ . '\\EmptyBodyStream'); +} diff --git a/src/Io/HttpBodyStream.php b/src/Io/HttpBodyStream.php index 8be9b854..c3a60ce7 100644 --- a/src/Io/HttpBodyStream.php +++ b/src/Io/HttpBodyStream.php @@ -8,6 +8,8 @@ use React\Stream\Util; use React\Stream\WritableStreamInterface; +$reflectionMethod = new \ReflectionMethod('\Psr\Http\Message\StreamInterface', 'eof'); +if (PHP_VERSION_ID < 70000 || !$reflectionMethod->hasReturnType()) { /** * [Internal] Bridge between StreamInterface from PSR-7 and ReadableStreamInterface from ReactPHP * @@ -180,3 +182,6 @@ public function handleEnd() } } } +} else { + class_alias(__NAMESPACE__ . '\\V2\\HttpBodyStream', __NAMESPACE__ . '\\HttpBodyStream'); +} diff --git a/src/Io/ReadableBodyStream.php b/src/Io/ReadableBodyStream.php index 9a8bd105..daf73894 100644 --- a/src/Io/ReadableBodyStream.php +++ b/src/Io/ReadableBodyStream.php @@ -8,6 +8,8 @@ use React\Stream\Util; use React\Stream\WritableStreamInterface; +$reflectedMethod = new \ReflectionMethod('\Psr\Http\Message\StreamInterface','eof'); +if (!(PHP_VERSION_ID >= 70000 && $reflectedMethod->hasReturnType())) { /** * @internal */ @@ -149,3 +151,6 @@ public function handleEnd() $this->close(); } } +} else { + class_alias(__NAMESPACE__ . '\\V2\\ReadableBodyStream', __NAMESPACE__ . '\\ReadableBodyStream'); +} diff --git a/src/Io/UploadedFile.php b/src/Io/UploadedFile.php index b0d0dd98..f96a7103 100644 --- a/src/Io/UploadedFile.php +++ b/src/Io/UploadedFile.php @@ -7,6 +7,8 @@ use InvalidArgumentException; use RuntimeException; +$reflectionMethod = new \ReflectionMethod('\Psr\Http\Message\UploadedFileInterface', 'getStream'); +if (PHP_VERSION_ID < 70000 || !$reflectionMethod->hasReturnType()) { /** * [Internal] Implementation of the PSR-7 `UploadedFileInterface` * @@ -128,3 +130,6 @@ public function getClientMediaType() return $this->mediaType; } } +} else { + class_alias(__NAMESPACE__ . '\\V2\\UploadedFile', __NAMESPACE__ . '\\UploadedFile'); +} diff --git a/src/Io/V2/AbstractMessage.php b/src/Io/V2/AbstractMessage.php new file mode 100644 index 00000000..ca6ca4c3 --- /dev/null +++ b/src/Io/V2/AbstractMessage.php @@ -0,0 +1,179 @@ +@,;:\\\"\/\[\]?={}\x00-\x20\x7F]++):[\x20\x09]*+((?:[\x20\x09]*+[\x21-\x7E\x80-\xFF]++)*+)[\x20\x09]*+[\r]?+\n/m'; + + /** @var array */ + private $headers = []; + + /** @var array */ + private $headerNamesLowerCase = []; + + /** @var string */ + private $protocolVersion; + + /** @var StreamInterface */ + private $body; + + /** + * @param string $protocolVersion + * @param array $headers + * @param StreamInterface $body + */ + protected function __construct($protocolVersion, array $headers, StreamInterface $body) + { + foreach ($headers as $name => $value) { + if ($value !== []) { + if (\is_array($value)) { + foreach ($value as &$one) { + $one = (string) $one; + } + } else { + $value = [(string) $value]; + } + + $lower = \strtolower($name); + if (isset($this->headerNamesLowerCase[$lower])) { + $value = \array_merge($this->headers[$this->headerNamesLowerCase[$lower]], $value); + unset($this->headers[$this->headerNamesLowerCase[$lower]]); + } + + $this->headers[$name] = $value; + $this->headerNamesLowerCase[$lower] = $name; + } + } + + $this->protocolVersion = (string) $protocolVersion; + $this->body = $body; + } + + public function getProtocolVersion(): 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]] : []; + } + + public function getHeaderLine(string $name): string + { + return \implode(', ', $this->getHeader($name)); + } + + public function withHeader(string $name, $value): MessageInterface + { + if ($value === []) { + return $this->withoutHeader($name); + } elseif (\is_array($value)) { + foreach ($value as &$one) { + $one = (string) $one; + } + } else { + $value = [(string) $value]; + } + + $lower = \strtolower($name); + if (isset($this->headerNamesLowerCase[$lower]) && $this->headerNamesLowerCase[$lower] === (string) $name && $this->headers[$this->headerNamesLowerCase[$lower]] === $value) { + return $this; + } + + $message = clone $this; + if (isset($message->headerNamesLowerCase[$lower])) { + unset($message->headers[$message->headerNamesLowerCase[$lower]]); + } + + $message->headers[$name] = $value; + $message->headerNamesLowerCase[$lower] = $name; + + return $message; + } + + public function withAddedHeader(string $name, $value): MessageInterface + { + if ($value === []) { + return $this; + } + + return $this->withHeader($name, \array_merge($this->getHeader($name), \is_array($value) ? $value : [$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..7ecaa184 --- /dev/null +++ b/src/Io/V2/AbstractRequest.php @@ -0,0 +1,163 @@ + $headers + * @param StreamInterface $body + * @param string unknown $protocolVersion + */ + protected function __construct( + $method, + $uri, + array $headers, + StreamInterface $body, + $protocolVersion + ) { + if (\is_string($uri)) { + $uri = new Uri($uri); + } elseif (!$uri instanceof UriInterface) { + throw new \InvalidArgumentException( + 'Argument #2 ($uri) expected string|Psr\Http\Message\UriInterface' + ); + } + + // assign default `Host` request header from URI unless already given explicitly + $host = $uri->getHost(); + if ($host !== '') { + foreach ($headers as $name => $value) { + if (\strtolower($name) === 'host' && $value !== []) { + $host = ''; + break; + } + } + if ($host !== '') { + $port = $uri->getPort(); + if ($port !== null && (!($port === 80 && $uri->getScheme() === 'http') || !($port === 443 && $uri->getScheme() === 'https'))) { + $host .= ':' . $port; + } + + $headers = ['Host' => $host] + $headers; + } + } + + parent::__construct($protocolVersion, $headers, $body); + + $this->method = $method; + $this->uri = $uri; + } + + public function getRequestTarget(): 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..00bc714d --- /dev/null +++ b/src/Io/V2/BufferedBody.php @@ -0,0 +1,186 @@ +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 ? [] : null; + } +} diff --git a/src/Io/V2/EmptyBodyStream.php b/src/Io/V2/EmptyBodyStream.php new file mode 100644 index 00000000..43c8b90e --- /dev/null +++ b/src/Io/V2/EmptyBodyStream.php @@ -0,0 +1,149 @@ +closed; + } + + public function pause() + { + // NOOP + } + + public function resume() + { + // NOOP + } + + public function pipe(WritableStreamInterface $dest, array $options = []) + { + 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) ? [] : null; + } +} diff --git a/src/Io/V2/HttpBodyStream.php b/src/Io/V2/HttpBodyStream.php new file mode 100644 index 00000000..7b2c6598 --- /dev/null +++ b/src/Io/V2/HttpBodyStream.php @@ -0,0 +1,189 @@ +input = $input; + $this->size = $size; + + $this->input->on('data', [$this, 'handleData']); + $this->input->on('end', [$this, 'handleEnd']); + $this->input->on('error', [$this, 'handleError']); + $this->input->on('close', [$this, 'close']); + } + + public function isReadable(): 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 = []) + { + 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', [$data]); + } + + /** @internal */ + public function handleError(\Exception $e) + { + $this->emit('error', [$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..e3430cac --- /dev/null +++ b/src/Io/V2/ReadableBodyStream.php @@ -0,0 +1,158 @@ +input = $input; + $this->size = $size; + + $input->on('data', function ($data) use ($size) { + $this->emit('data', [$data]); + + $this->position += \strlen($data); + if ($size !== null && $this->position >= $size) { + $this->handleEnd(); + } + }); + $input->on('error', function ($error) { + $this->emit('error', [$error]); + $this->close(); + }); + $input->on('end', [$this, 'handleEnd']); + $input->on('close', [$this, 'close']); + } + + public function close(): 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 = []) + { + 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) ? [] : null; + } + + /** @internal */ + public function handleEnd() + { + if ($this->position !== $this->size && $this->size !== null) { + $this->emit('error', [new \UnderflowException('Unexpected end of response body after ' . $this->position . '/' . $this->size . ' bytes')]); + } else { + $this->emit('end'); + } + + $this->close(); + } +} diff --git a/src/Io/V2/UploadedFile.php b/src/Io/V2/UploadedFile.php new file mode 100644 index 00000000..ce13de8b --- /dev/null +++ b/src/Io/V2/UploadedFile.php @@ -0,0 +1,137 @@ +stream = $stream; + $this->size = $size; + + if (!\is_int($error) || !\in_array($error, [ + \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 fdba39f5..2bf2fe8c 100644 --- a/src/Message/Request.php +++ b/src/Message/Request.php @@ -10,6 +10,8 @@ use React\Http\Io\ReadableBodyStream; use React\Stream\ReadableStreamInterface; +$reflectionMethod = new \ReflectionMethod('\Psr\Http\Message\StreamInterface', 'eof'); +if (PHP_VERSION_ID < 70000 || !$reflectionMethod->hasReturnType()) { /** * Respresents an outgoing HTTP request message. * @@ -55,3 +57,6 @@ public function __construct( parent::__construct($method, $url, $headers, $body, $version); } } +} else { + class_alias(__NAMESPACE__ . '\\V2\\Request', __NAMESPACE__ . '\\Request'); +} diff --git a/src/Message/Response.php b/src/Message/Response.php index 93557fab..0b6e941c 100644 --- a/src/Message/Response.php +++ b/src/Message/Response.php @@ -10,6 +10,8 @@ use React\Http\Io\HttpBodyStream; use React\Stream\ReadableStreamInterface; +$reflectionMethod = new \ReflectionMethod('\Psr\Http\Message\ResponseInterface', 'getStatusCode'); +if (PHP_VERSION_ID < 70000 || !$reflectionMethod->hasReturnType()) { /** * Represents an outgoing server response message. * @@ -257,9 +259,9 @@ public static function xml($xml) /** * @var bool - * @see self::$phrasesMap + * @see static::$phrasesMap */ - private static $phrasesInitialized = false; + protected static $phrasesInitialized = false; /** * Map of standard HTTP status codes to standard reason phrases. @@ -410,3 +412,6 @@ public static function parseMessage($message) ); } } +} else { + class_alias(__NAMESPACE__ . '\\V2\\Response', __NAMESPACE__ . '\\Response'); +} diff --git a/src/Message/ServerRequest.php b/src/Message/ServerRequest.php index da0d76ab..292566a3 100644 --- a/src/Message/ServerRequest.php +++ b/src/Message/ServerRequest.php @@ -10,6 +10,8 @@ use React\Http\Io\HttpBodyStream; use React\Stream\ReadableStreamInterface; +$reflectionMethod = new \ReflectionMethod('\Psr\Http\Message\ServerRequestInterface', 'getServerParams'); +if (PHP_VERSION_ID < 70000 || !$reflectionMethod->hasReturnType()) { /** * Respresents an incoming server request message. * @@ -329,3 +331,6 @@ public static function parseMessage($message, array $serverParams) return $request; } } +} else { + class_alias(__NAMESPACE__ . '\\V2\\ServerRequest', __NAMESPACE__ . '\\ServerRequest'); +} diff --git a/src/Message/Uri.php b/src/Message/Uri.php index 661f90c4..36cb2829 100644 --- a/src/Message/Uri.php +++ b/src/Message/Uri.php @@ -4,6 +4,8 @@ use Psr\Http\Message\UriInterface; +$reflectionMethod = new \ReflectionMethod('\Psr\Http\Message\UriInterface', 'getScheme'); +if (PHP_VERSION_ID < 70000 || !$reflectionMethod->hasReturnType()) { /** * Respresents a URI (or URL). * @@ -345,3 +347,6 @@ private static function removeDotSegments($path) return '/' . \implode('/', $segments) . ($path !== '/' && \substr($path, -1) === '/' ? '/' : ''); } } +} else { + class_alias(__NAMESPACE__ . '\\V2\\Uri', __NAMESPACE__ . '\\Uri'); +} diff --git a/src/Message/V2/Request.php b/src/Message/V2/Request.php new file mode 100644 index 00000000..802b75b9 --- /dev/null +++ b/src/Message/V2/Request.php @@ -0,0 +1,66 @@ + Internally, this implementation builds on top of a base class which is + * considered an implementation detail that may change in the future. + * + * @see RequestInterface + * + * @internal + * + * @deprecated Do not reference this FQCN directly. Not a runtime deprecation: + * when psr/http-message v2 is installed this class is the + * implementation behind the public name. Marked deprecated so + * tooling suggests the aliased class instead (see sibling file). + * @see \React\Http\Io\Request + * @see ../Request.php + */ +final class Request extends AbstractRequest implements RequestInterface +{ + /** + * @param string $method HTTP method for the request. + * @param string|UriInterface $url URL for the request. + * @param array $headers Headers for the message. + * @param string|ReadableStreamInterface|StreamInterface $body Message body. + * @param string $version HTTP protocol version. + * @throws \InvalidArgumentException for an invalid URL or body + */ + public function __construct( + $method, + $url, + array $headers = [], + $body = '', + $version = '1.1' + ) { + if (\is_string($body)) { + $body = new BufferedBody($body); + } elseif ($body instanceof ReadableStreamInterface && !$body instanceof StreamInterface) { + $body = new ReadableBodyStream($body); + } elseif (!$body instanceof StreamInterface) { + throw new \InvalidArgumentException('Invalid request body given'); + } + + parent::__construct($method, $url, $headers, $body, $version); + } +} diff --git a/src/Message/V2/Response.php b/src/Message/V2/Response.php new file mode 100644 index 00000000..e82be729 --- /dev/null +++ b/src/Message/V2/Response.php @@ -0,0 +1,421 @@ + '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 + * + * @internal + * + * @deprecated Do not reference this FQCN directly. Not a runtime deprecation: + * when psr/http-message v2 is installed this class is the + * implementation behind the public name. Marked deprecated so + * tooling suggests the aliased class instead (see sibling file). + * @see \React\Http\Io\Response + * @see ../Response.php + */ +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 static + */ + public static function html($html) + { + return new static(static::STATUS_OK, ['Content-Type' => 'text/html; charset=utf-8'], $html); + } + + /** + * Create a JSON response + * + * ```php + * $response = React\Http\Message\Response::json(['name' => 'Alice']); + * ``` + * + * This is a convenient shortcut method that returns the equivalent of this: + * + * ``` + * $response = new React\Http\Message\Response( + * React\Http\Message\Response::STATUS_OK, + * [ + * 'Content-Type' => 'application/json' + * ], + * json_encode( + * ['name' => 'Alice'], + * JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRESERVE_ZERO_FRACTION + * ) . "\n" + * ); + * ``` + * + * This method always returns a response with a `200 OK` status code and + * the appropriate `Content-Type` response header for the given structured + * data encoded as a JSON text. + * + * The given structured data will be encoded as a JSON text. Any `string` + * values in the data must be encoded in UTF-8 (Unicode). If the encoding + * fails, this method will throw an `InvalidArgumentException`. + * + * By default, the given structured data will be encoded with the flags as + * shown above. This includes pretty printing and preserving zero fractions + * for `float` values to ease debugging. It is assumed any additional data + * overhead is usually compensated by using HTTP response compression. + * + * If you want to use a different status code or custom HTTP response + * headers, you can manipulate the returned response object using the + * provided PSR-7 methods or directly instantiate a custom HTTP response + * object using the `Response` constructor: + * + * ```php + * $response = React\Http\Message\Response::json( + * ['error' => 'Invalid user name given'] + * )->withStatus(React\Http\Message\Response::STATUS_BAD_REQUEST); + * ``` + * + * @param mixed $data + * @return static + * @throws \InvalidArgumentException when encoding fails + */ + public static function json($data) + { + $json = \json_encode( + $data, + \JSON_PRETTY_PRINT | \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE | \JSON_PRESERVE_ZERO_FRACTION + ); + + if ($json === false) { + throw new \InvalidArgumentException( + 'Unable to encode given data as JSON: ' . \json_last_error_msg(), + \json_last_error() + ); + } + + return new static(static::STATUS_OK, ['Content-Type' => 'application/json'], $json . "\n"); + } + + /** + * Create a plaintext response + * + * ```php + * $response = React\Http\Message\Response::plaintext("Hello wörld!\n"); + * ``` + * + * This is a convenient shortcut method that returns the equivalent of this: + * + * ``` + * $response = new React\Http\Message\Response( + * React\Http\Message\Response::STATUS_OK, + * [ + * 'Content-Type' => 'text/plain; charset=utf-8' + * ], + * "Hello wörld!\n" + * ); + * ``` + * + * This method always returns a response with a `200 OK` status code and + * the appropriate `Content-Type` response header for the given plaintext + * string encoded in UTF-8 (Unicode). It's generally recommended to end the + * given plaintext string with a trailing newline. + * + * If you want to use a different status code or custom HTTP response + * headers, you can manipulate the returned response object using the + * provided PSR-7 methods or directly instantiate a custom HTTP response + * object using the `Response` constructor: + * + * ```php + * $response = React\Http\Message\Response::plaintext( + * "Error: Invalid user name given.\n" + * )->withStatus(React\Http\Message\Response::STATUS_BAD_REQUEST); + * ``` + * + * @param string $text + * @return static + */ + public static function plaintext($text) + { + return new static(static::STATUS_OK, ['Content-Type' => 'text/plain; charset=utf-8'], $text); + } + + /** + * Create an XML response + * + * ```php + * $xml = << + * + * Hello wörld! + * + * + * XML; + * + * $response = React\Http\Message\Response::xml($xml); + * ``` + * + * This is a convenient shortcut method that returns the equivalent of this: + * + * ``` + * $response = new React\Http\Message\Response( + * React\Http\Message\Response::STATUS_OK, + * [ + * 'Content-Type' => 'application/xml' + * ], + * $xml + * ); + * ``` + * + * This method always returns a response with a `200 OK` status code and + * the appropriate `Content-Type` response header for the given XML source + * string. It's generally recommended to use UTF-8 (Unicode) and specify + * this as part of the leading XML declaration and to end the given XML + * source string with a trailing newline. + * + * If you want to use a different status code or custom HTTP response + * headers, you can manipulate the returned response object using the + * provided PSR-7 methods or directly instantiate a custom HTTP response + * object using the `Response` constructor: + * + * ```php + * $response = React\Http\Message\Response::xml( + * "Invalid user name given.\n" + * )->withStatus(React\Http\Message\Response::STATUS_BAD_REQUEST); + * ``` + * + * @param string $xml + * @return static + */ + public static function xml($xml) + { + return new static(static::STATUS_OK, ['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 = [ + 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 = [], + $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 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 = []; + if (!\preg_match('#^HTTP/(?\d\.\d) (?\d{3})(?: (?[^\r\n]*+))?[\r]?+\n#m', $message, $start)) { + throw new \InvalidArgumentException('Unable to parse invalid status-line'); + } + + // only support HTTP/1.1 and HTTP/1.0 requests + if ($start['version'] !== '1.1' && $start['version'] !== '1.0') { + throw new \InvalidArgumentException('Received response with invalid protocol version'); + } + + // check number of valid header fields matches number of lines + status line + $matches = []; + $n = \preg_match_all(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 = []; + foreach ($matches as $match) { + $headers[$match[1]][] = $match[2]; + } + + return new static( + (int) $start['status'], + $headers, + '', + $start['version'], + $start['reason'] ?? '' + ); + } +} diff --git a/src/Message/V2/ServerRequest.php b/src/Message/V2/ServerRequest.php new file mode 100644 index 00000000..ba087dae --- /dev/null +++ b/src/Message/V2/ServerRequest.php @@ -0,0 +1,340 @@ + Internally, this implementation builds on top of a base class which is + * considered an implementation detail that may change in the future. + * + * @see ServerRequestInterface + * + * @internal + * + * @deprecated Do not reference this FQCN directly. Not a runtime deprecation: + * when psr/http-message v2 is installed this class is the + * implementation behind the public name. Marked deprecated so + * tooling suggests the aliased class instead (see sibling file). + * @see \React\Http\Io\ServerRequest + * @see ../ServerRequest.php + */ +final class ServerRequest extends AbstractRequest implements ServerRequestInterface +{ + private $attributes = []; + + private $serverParams; + private $fileParams = []; + private $cookies = []; + private $queryParams = []; + private $parsedBody; + + /** + * @param string $method HTTP method for the request. + * @param string|UriInterface $url URL for the request. + * @param array $headers Headers for the message. + * @param string|ReadableStreamInterface|StreamInterface $body Message body. + * @param string $version HTTP protocol version. + * @param array $serverParams server-side parameters + * @throws \InvalidArgumentException for an invalid URL or body + */ + public function __construct( + $method, + $url, + array $headers = [], + $body = '', + $version = '1.1', + $serverParams = [] + ) { + if (\is_string($body)) { + $body = new BufferedBody($body); + } elseif ($body instanceof ReadableStreamInterface && !$body instanceof StreamInterface) { + $temp = new 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 = []; + + 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 = []; + if (!\preg_match('#^(?[^ ]+) (?[^ ]+) HTTP/(?\d\.\d)#m', $message, $start)) { + throw new \InvalidArgumentException('Unable to parse invalid request-line'); + } + + // only support HTTP/1.1 and HTTP/1.0 requests + if ($start['version'] !== '1.1' && $start['version'] !== '1.0') { + throw new \InvalidArgumentException('Received request with invalid protocol version', Response::STATUS_VERSION_NOT_SUPPORTED); + } + + // check number of valid header fields matches number of lines + request line + $matches = []; + $n = \preg_match_all(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 = []; + 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..f2057600 --- /dev/null +++ b/src/Message/V2/Uri.php @@ -0,0 +1,344 @@ +scheme = \strtolower($parts['scheme']); + } + + if (isset($parts['user'])) { + $this->userInfo = $this->encode($parts['user'], \PHP_URL_USER) . (isset($parts['pass']) ? ':' . $this->encode($parts['pass'], \PHP_URL_PASS) : ''); + } + + if (isset($parts['host'])) { + $this->host = \strtolower($parts['host']); + } + + if (isset($parts['port']) && !(($parts['port'] === 80 && $this->scheme === 'http') || ($parts['port'] === 443 && $this->scheme === 'https'))) { + $this->port = $parts['port']; + } + + if (isset($parts['path'])) { + $this->path = $this->encode($parts['path'], \PHP_URL_PATH); + } + + if (isset($parts['query'])) { + $this->query = $this->encode($parts['query'], \PHP_URL_QUERY); + } + + if (isset($parts['fragment'])) { + $this->fragment = $this->encode($parts['fragment'], \PHP_URL_FRAGMENT); + } + } + + public function getScheme(): 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], $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 = []; + 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 4ee92364..81f8381b 100644 --- a/tests/Io/EmptyBodyStreamTest.php +++ b/tests/Io/EmptyBodyStreamTest.php @@ -127,7 +127,13 @@ public function testIsReadableReturnsFalseWhenAlreadyClosed() public function testSeek() { - $this->expectException(\BadMethodCallException::class); + $reflectionMethod = new \ReflectionMethod('\Psr\Http\Message\StreamInterface', 'eof'); + if (PHP_VERSION_ID < 70000 || !$reflectionMethod->hasReturnType()) { + $this->expectException(\BadMethodCallException::class); + } else { + $this->expectException(\TypeError::class); + } + $this->bodyStream->seek(''); } diff --git a/tests/Io/HttpBodyStreamTest.php b/tests/Io/HttpBodyStreamTest.php index c246bd96..8ebd97fb 100644 --- a/tests/Io/HttpBodyStreamTest.php +++ b/tests/Io/HttpBodyStreamTest.php @@ -132,7 +132,13 @@ public function testWrite() public function testRead() { - $this->expectException(\BadMethodCallException::class); + $reflectionMethod = new \ReflectionMethod('\Psr\Http\Message\StreamInterface', 'eof'); + if (PHP_VERSION_ID < 70000 || !$reflectionMethod->hasReturnType()) { + $this->expectException(\BadMethodCallException::class); + } else { + $this->expectException(\TypeError::class); + } + $this->bodyStream->read(''); } @@ -153,7 +159,13 @@ public function testIsReadable() public function testSeek() { - $this->expectException(\BadMethodCallException::class); + $reflectionMethod = new \ReflectionMethod('\Psr\Http\Message\StreamInterface', 'eof'); + if (PHP_VERSION_ID < 70000 || !$reflectionMethod->hasReturnType()) { + $this->expectException(\BadMethodCallException::class); + } else { + $this->expectException(\TypeError::class); + } + $this->bodyStream->seek(''); } From 84a73db1860a2f1377cb1cab951f2e5e470473c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Sun, 1 Nov 2026 01:47:12 +0100 Subject: [PATCH 2/2] Use typed main classes for PSR-7 v2 instead of separate `V2` classes --- composer.json | 5 +- src/Io/AbstractMessage.php | 34 +++++----- ...ractMessage.php => AbstractMessage.v1.php} | 26 +++----- src/Io/AbstractRequest.php | 25 +++---- ...ractRequest.php => AbstractRequest.v1.php} | 18 ++--- src/Io/BufferedBody.php | 41 ++++++------ .../BufferedBody.php => BufferedBody.v1.php} | 20 ++---- src/Io/EmptyBodyStream.php | 41 ++++++------ ...yBodyStream.php => EmptyBodyStream.v1.php} | 18 ++--- src/Io/HttpBodyStream.php | 41 ++++++------ ...tpBodyStream.php => HttpBodyStream.v1.php} | 18 ++--- src/Io/ReadableBodyStream.php | 41 +++++++----- ...dyStream.php => ReadableBodyStream.v1.php} | 22 +++---- src/Io/UploadedFile.php | 25 +++---- .../UploadedFile.php => UploadedFile.v1.php} | 16 ++--- src/Message/Request.php | 7 +- src/Message/Response.php | 23 ++++--- .../{V2/Response.php => Response.v1.php} | 66 ++++++++----------- src/Message/ServerRequest.php | 37 ++++++----- ...ServerRequest.php => ServerRequest.v1.php} | 28 +++----- src/Message/Uri.php | 51 +++++++------- src/Message/{V2/Uri.php => Uri.v1.php} | 44 +++++++------ src/Message/V2/Request.php | 66 ------------------- tests/Io/EmptyBodyStreamTest.php | 5 +- tests/Io/HttpBodyStreamTest.php | 9 +-- 25 files changed, 320 insertions(+), 407 deletions(-) rename src/Io/{V2/AbstractMessage.php => AbstractMessage.v1.php} (83%) rename src/Io/{V2/AbstractRequest.php => AbstractRequest.v1.php} (86%) rename src/Io/{V2/BufferedBody.php => BufferedBody.v1.php} (85%) rename src/Io/{V2/EmptyBodyStream.php => EmptyBodyStream.v1.php} (82%) rename src/Io/{V2/HttpBodyStream.php => HttpBodyStream.v1.php} (85%) rename src/Io/{V2/ReadableBodyStream.php => ReadableBodyStream.v1.php} (82%) rename src/Io/{V2/UploadedFile.php => UploadedFile.v1.php} (82%) rename src/Message/{V2/Response.php => Response.v1.php} (86%) rename src/Message/{V2/ServerRequest.php => ServerRequest.v1.php} (93%) rename src/Message/{V2/Uri.php => Uri.v1.php} (89%) delete mode 100644 src/Message/V2/Request.php diff --git a/composer.json b/composer.json index 2c5737f6..1f05ff9a 100644 --- a/composer.json +++ b/composer.json @@ -47,7 +47,10 @@ "autoload": { "psr-4": { "React\\Http\\": "src/" - } + }, + "exclude-from-classmap": [ + "src/**.v1.php" + ] }, "autoload-dev": { "psr-4": { diff --git a/src/Io/AbstractMessage.php b/src/Io/AbstractMessage.php index 62d61ab9..aade0f15 100644 --- a/src/Io/AbstractMessage.php +++ b/src/Io/AbstractMessage.php @@ -5,14 +5,18 @@ use Psr\Http\Message\MessageInterface; use Psr\Http\Message\StreamInterface; +if (!(new \ReflectionParameter([MessageInterface::class, 'withProtocolVersion'], 0))->hasType()) { + // load legacy class for psr/http-message v1.0 without native parameter types + require __DIR__ . '/AbstractMessage.v1.php'; +} else { + // use class with native types like `withProtocolVersion(string $version): MessageInterface` -$reflectionMethod = new \ReflectionMethod('\Psr\Http\Message\MessageInterface', 'withProtocolVersion'); -if (PHP_VERSION_ID < 70000 || !$reflectionMethod->hasReturnType()) { /** - * [Internal] Abstract HTTP message base class (PSR-7) + * [Internal] Abstract HTTP message base class (PSR-7 v2) * * @internal * @see MessageInterface + * @see AbstractMessage.v1.php (PSR-7 v1) */ abstract class AbstractMessage implements MessageInterface { @@ -68,12 +72,12 @@ protected function __construct($protocolVersion, array $headers, StreamInterface $this->body = $body; } - public function getProtocolVersion() + public function getProtocolVersion(): string { return $this->protocolVersion; } - public function withProtocolVersion($version) + public function withProtocolVersion(string $version): MessageInterface { if ((string) $version === $this->protocolVersion) { return $this; @@ -85,28 +89,28 @@ public function withProtocolVersion($version) return $message; } - public function getHeaders() + public function getHeaders(): array { return $this->headers; } - public function hasHeader($name) + public function hasHeader(string $name): bool { return isset($this->headerNamesLowerCase[\strtolower($name)]); } - public function getHeader($name) + public function getHeader(string $name): array { $lower = \strtolower($name); return isset($this->headerNamesLowerCase[$lower]) ? $this->headers[$this->headerNamesLowerCase[$lower]] : []; } - public function getHeaderLine($name) + public function getHeaderLine(string $name): string { return \implode(', ', $this->getHeader($name)); } - public function withHeader($name, $value) + public function withHeader(string $name, $value): MessageInterface { if ($value === []) { return $this->withoutHeader($name); @@ -134,7 +138,7 @@ public function withHeader($name, $value) return $message; } - public function withAddedHeader($name, $value) + public function withAddedHeader(string $name, $value): MessageInterface { if ($value === []) { return $this; @@ -143,7 +147,7 @@ public function withAddedHeader($name, $value) return $this->withHeader($name, \array_merge($this->getHeader($name), \is_array($value) ? $value : [$value])); } - public function withoutHeader($name) + public function withoutHeader(string $name): MessageInterface { $lower = \strtolower($name); if (!isset($this->headerNamesLowerCase[$lower])) { @@ -156,12 +160,12 @@ public function withoutHeader($name) return $message; } - public function getBody() + public function getBody(): StreamInterface { return $this->body; } - public function withBody(StreamInterface $body) + public function withBody(StreamInterface $body): MessageInterface { if ($body === $this->body) { return $this; @@ -173,6 +177,4 @@ public function withBody(StreamInterface $body) return $message; } } -} else { - class_alias(__NAMESPACE__ . '\\V2\\AbstractMessage', __NAMESPACE__ . '\\AbstractMessage'); } diff --git a/src/Io/V2/AbstractMessage.php b/src/Io/AbstractMessage.v1.php similarity index 83% rename from src/Io/V2/AbstractMessage.php rename to src/Io/AbstractMessage.v1.php index ca6ca4c3..2d5e440e 100644 --- a/src/Io/V2/AbstractMessage.php +++ b/src/Io/AbstractMessage.v1.php @@ -1,22 +1,16 @@ protocolVersion; } - public function withProtocolVersion(string $version): MessageInterface + public function withProtocolVersion($version): MessageInterface { if ((string) $version === $this->protocolVersion) { return $this; @@ -94,23 +88,23 @@ public function getHeaders(): array return $this->headers; } - public function hasHeader(string $name): bool + public function hasHeader($name): bool { return isset($this->headerNamesLowerCase[\strtolower($name)]); } - public function getHeader(string $name): array + public function getHeader($name): array { $lower = \strtolower($name); return isset($this->headerNamesLowerCase[$lower]) ? $this->headers[$this->headerNamesLowerCase[$lower]] : []; } - public function getHeaderLine(string $name): string + public function getHeaderLine($name): string { return \implode(', ', $this->getHeader($name)); } - public function withHeader(string $name, $value): MessageInterface + public function withHeader($name, $value): MessageInterface { if ($value === []) { return $this->withoutHeader($name); @@ -138,7 +132,7 @@ public function withHeader(string $name, $value): MessageInterface return $message; } - public function withAddedHeader(string $name, $value): MessageInterface + public function withAddedHeader($name, $value): MessageInterface { if ($value === []) { return $this; @@ -147,7 +141,7 @@ public function withAddedHeader(string $name, $value): MessageInterface return $this->withHeader($name, \array_merge($this->getHeader($name), \is_array($value) ? $value : [$value])); } - public function withoutHeader(string $name): MessageInterface + public function withoutHeader($name): MessageInterface { $lower = \strtolower($name); if (!isset($this->headerNamesLowerCase[$lower])) { diff --git a/src/Io/AbstractRequest.php b/src/Io/AbstractRequest.php index 27f0b380..038d8b53 100644 --- a/src/Io/AbstractRequest.php +++ b/src/Io/AbstractRequest.php @@ -7,13 +7,18 @@ use Psr\Http\Message\UriInterface; use React\Http\Message\Uri; -$reflectionMethod = new \ReflectionMethod('\Psr\Http\Message\RequestInterface', 'withRequestTarget'); -if (PHP_VERSION_ID < 70000 || !$reflectionMethod->hasReturnType()) { +if (!(new \ReflectionParameter([RequestInterface::class, 'withMethod'], 0))->hasType()) { + // load legacy class for psr/http-message v1.0 without native parameter types + require __DIR__ . '/AbstractRequest.v1.php'; +} else { + // use class with native types like `withMethod(string $method): RequestInterface` + /** - * [Internal] Abstract HTTP request base class (PSR-7) + * [Internal] Abstract HTTP request base class (PSR-7 v2) * * @internal * @see RequestInterface + * @see AbstractRequest.v1.php (PSR-7 v1) */ abstract class AbstractRequest extends AbstractMessage implements RequestInterface { @@ -73,7 +78,7 @@ protected function __construct( $this->uri = $uri; } - public function getRequestTarget() + public function getRequestTarget(): string { if ($this->requestTarget !== null) { return $this->requestTarget; @@ -90,7 +95,7 @@ public function getRequestTarget() return $target; } - public function withRequestTarget($requestTarget) + public function withRequestTarget(string $requestTarget): RequestInterface { if ((string) $requestTarget === $this->requestTarget) { return $this; @@ -102,12 +107,12 @@ public function withRequestTarget($requestTarget) return $request; } - public function getMethod() + public function getMethod(): string { return $this->method; } - public function withMethod($method) + public function withMethod(string $method): RequestInterface { if ((string) $method === $this->method) { return $this; @@ -119,12 +124,12 @@ public function withMethod($method) return $request; } - public function getUri() + public function getUri(): UriInterface { return $this->uri; } - public function withUri(UriInterface $uri, $preserveHost = false) + public function withUri(UriInterface $uri, bool $preserveHost = false): RequestInterface { if ($uri === $this->uri) { return $this; @@ -156,6 +161,4 @@ public function withUri(UriInterface $uri, $preserveHost = false) return $request; } } -} else { - class_alias(__NAMESPACE__ . '\\V2\\AbstractRequest', __NAMESPACE__ . '\\AbstractRequest'); } diff --git a/src/Io/V2/AbstractRequest.php b/src/Io/AbstractRequest.v1.php similarity index 86% rename from src/Io/V2/AbstractRequest.php rename to src/Io/AbstractRequest.v1.php index 7ecaa184..e4d64234 100644 --- a/src/Io/V2/AbstractRequest.php +++ b/src/Io/AbstractRequest.v1.php @@ -1,6 +1,6 @@ requestTarget) { return $this; @@ -112,7 +106,7 @@ public function getMethod(): string return $this->method; } - public function withMethod(string $method): RequestInterface + public function withMethod($method): RequestInterface { if ((string) $method === $this->method) { return $this; @@ -129,7 +123,7 @@ public function getUri(): UriInterface return $this->uri; } - public function withUri(UriInterface $uri, bool $preserveHost = false): RequestInterface + public function withUri(UriInterface $uri, $preserveHost = false): RequestInterface { if ($uri === $this->uri) { return $this; diff --git a/src/Io/BufferedBody.php b/src/Io/BufferedBody.php index 0b8d1e51..2b87f334 100644 --- a/src/Io/BufferedBody.php +++ b/src/Io/BufferedBody.php @@ -4,12 +4,17 @@ use Psr\Http\Message\StreamInterface; -$reflectionMethod = new \ReflectionMethod('\Psr\Http\Message\StreamInterface', 'eof'); -if (PHP_VERSION_ID < 70000 || !$reflectionMethod->hasReturnType()) { +if (!(new \ReflectionParameter([StreamInterface::class, 'seek'], 0))->hasType()) { + // load legacy class for psr/http-message v1.0 without native parameter types + require __DIR__ . '/BufferedBody.v1.php'; +} else { + // use class with native types like `seek(int $offset, int $whence = \SEEK_SET): void` + /** - * [Internal] PSR-7 message body implementation using an in-memory buffer + * [Internal] PSR-7 v2 message body implementation using an in-memory buffer * * @internal + * @see BufferedBody.v1.php (PSR-7 v1) */ class BufferedBody implements StreamInterface { @@ -25,7 +30,7 @@ public function __construct($buffer) $this->buffer = $buffer; } - public function __toString() + public function __toString(): string { if ($this->closed) { return ''; @@ -36,7 +41,7 @@ public function __toString() return $this->getContents(); } - public function close() + public function close(): void { $this->buffer = ''; $this->position = 0; @@ -50,12 +55,12 @@ public function detach() return null; } - public function getSize() + public function getSize(): ?int { return $this->closed ? null : \strlen($this->buffer); } - public function tell() + public function tell(): int { if ($this->closed) { throw new \RuntimeException('Unable to tell position of closed stream'); @@ -64,17 +69,17 @@ public function tell() return $this->position; } - public function eof() + public function eof(): bool { return $this->position >= \strlen($this->buffer); } - public function isSeekable() + public function isSeekable(): bool { return !$this->closed; } - public function seek($offset, $whence = \SEEK_SET) + public function seek(int $offset, int $whence = \SEEK_SET): void { if ($this->closed) { throw new \RuntimeException('Unable to seek on closed stream'); @@ -98,17 +103,17 @@ public function seek($offset, $whence = \SEEK_SET) } } - public function rewind() + public function rewind(): void { $this->seek(0); } - public function isWritable() + public function isWritable(): bool { return !$this->closed; } - public function write($string) + public function write(string $string): int { if ($this->closed) { throw new \RuntimeException('Unable to write to closed stream'); @@ -129,12 +134,12 @@ public function write($string) return $len; } - public function isReadable() + public function isReadable(): bool { return !$this->closed; } - public function read($length) + public function read(int $length): string { if ($this->closed) { throw new \RuntimeException('Unable to read from closed stream'); @@ -158,7 +163,7 @@ public function read($length) return \substr($this->buffer, $pos, $length); } - public function getContents() + public function getContents(): string { if ($this->closed) { throw new \RuntimeException('Unable to read from closed stream'); @@ -174,11 +179,9 @@ public function getContents() return \substr($this->buffer, $pos); } - public function getMetadata($key = null) + public function getMetadata(?string $key = null) { return $key === null ? [] : null; } } -} else { - class_alias(__NAMESPACE__ . '\\V2\\BufferedBody', __NAMESPACE__ . '\\BufferedBody'); } diff --git a/src/Io/V2/BufferedBody.php b/src/Io/BufferedBody.v1.php similarity index 85% rename from src/Io/V2/BufferedBody.php rename to src/Io/BufferedBody.v1.php index 00bc714d..0245b269 100644 --- a/src/Io/V2/BufferedBody.php +++ b/src/Io/BufferedBody.v1.php @@ -1,20 +1,14 @@ closed; } - public function seek(int $offset, int $whence = \SEEK_SET): void + public function seek($offset, $whence = \SEEK_SET): void { if ($this->closed) { throw new \RuntimeException('Unable to seek on closed stream'); @@ -113,7 +107,7 @@ public function isWritable(): bool return !$this->closed; } - public function write(string $string): int + public function write($string): int { if ($this->closed) { throw new \RuntimeException('Unable to write to closed stream'); @@ -139,7 +133,7 @@ public function isReadable(): bool return !$this->closed; } - public function read(int $length): string + public function read($length): string { if ($this->closed) { throw new \RuntimeException('Unable to read from closed stream'); @@ -179,7 +173,7 @@ public function getContents(): string return \substr($this->buffer, $pos); } - public function getMetadata(?string $key = null) + public function getMetadata($key = null) { return $key === null ? [] : null; } diff --git a/src/Io/EmptyBodyStream.php b/src/Io/EmptyBodyStream.php index e191e01b..2a7def00 100644 --- a/src/Io/EmptyBodyStream.php +++ b/src/Io/EmptyBodyStream.php @@ -8,10 +8,14 @@ use React\Stream\Util; use React\Stream\WritableStreamInterface; -$reflectionMethod = new \ReflectionMethod('\Psr\Http\Message\StreamInterface', 'eof'); -if (PHP_VERSION_ID < 70000 || !$reflectionMethod->hasReturnType()) { +if (!(new \ReflectionParameter([StreamInterface::class, 'seek'], 0))->hasType()) { + // load legacy class for psr/http-message v1.0 without native parameter types + require __DIR__ . '/EmptyBodyStream.v1.php'; +} else { + // use class with native types like `seek(int $offset, int $whence = SEEK_SET): void` + /** - * [Internal] Bridge between an empty StreamInterface from PSR-7 and ReadableStreamInterface from ReactPHP + * [Internal] Bridge between an empty StreamInterface from PSR-7 v2 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`, @@ -26,12 +30,13 @@ * @see StreamInterface * @see ReadableStreamInterface * @internal + * @see EmptyBodyStream.v1.php (PSR-7 v1) */ class EmptyBodyStream extends EventEmitter implements StreamInterface, ReadableStreamInterface { private $closed = false; - public function isReadable() + public function isReadable(): bool { return !$this->closed; } @@ -53,7 +58,7 @@ public function pipe(WritableStreamInterface $dest, array $options = []) return $dest; } - public function close() + public function close(): void { if ($this->closed) { return; @@ -65,13 +70,13 @@ public function close() $this->removeAllListeners(); } - public function getSize() + public function getSize(): ?int { return 0; } /** @ignore */ - public function __toString() + public function __toString(): string { return ''; } @@ -83,65 +88,63 @@ public function detach() } /** @ignore */ - public function tell() + public function tell(): int { throw new \BadMethodCallException(); } /** @ignore */ - public function eof() + public function eof(): bool { throw new \BadMethodCallException(); } /** @ignore */ - public function isSeekable() + public function isSeekable(): bool { return false; } /** @ignore */ - public function seek($offset, $whence = SEEK_SET) + public function seek(int $offset, int $whence = SEEK_SET): void { throw new \BadMethodCallException(); } /** @ignore */ - public function rewind() + public function rewind(): void { throw new \BadMethodCallException(); } /** @ignore */ - public function isWritable() + public function isWritable(): bool { return false; } /** @ignore */ - public function write($string) + public function write(string $string): int { throw new \BadMethodCallException(); } /** @ignore */ - public function read($length) + public function read(int $length): string { throw new \BadMethodCallException(); } /** @ignore */ - public function getContents() + public function getContents(): string { return ''; } /** @ignore */ - public function getMetadata($key = null) + public function getMetadata(?string $key = null) { return ($key === null) ? [] : null; } } -} else { - class_alias(__NAMESPACE__ . '\\V2\\EmptyBodyStream', __NAMESPACE__ . '\\EmptyBodyStream'); } diff --git a/src/Io/V2/EmptyBodyStream.php b/src/Io/EmptyBodyStream.v1.php similarity index 82% rename from src/Io/V2/EmptyBodyStream.php rename to src/Io/EmptyBodyStream.v1.php index 43c8b90e..9ae4cf5f 100644 --- a/src/Io/V2/EmptyBodyStream.php +++ b/src/Io/EmptyBodyStream.v1.php @@ -1,6 +1,6 @@ hasReturnType()) { +if (!(new \ReflectionParameter([StreamInterface::class, 'seek'], 0))->hasType()) { + // load legacy class for psr/http-message v1.0 without native parameter types + require __DIR__ . '/HttpBodyStream.v1.php'; +} else { + // use class with native types like `seek(int $offset, int $whence = SEEK_SET): void` + /** - * [Internal] Bridge between StreamInterface from PSR-7 and ReadableStreamInterface from ReactPHP + * [Internal] Bridge between StreamInterface from PSR-7 v2 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 @@ -25,6 +29,7 @@ * @see StreamInterface * @see ReadableStreamInterface * @internal + * @see HttpBodyStream.v1.php (PSR-7 v1) */ class HttpBodyStream extends EventEmitter implements StreamInterface, ReadableStreamInterface { @@ -47,7 +52,7 @@ public function __construct(ReadableStreamInterface $input, $size) $this->input->on('close', [$this, 'close']); } - public function isReadable() + public function isReadable(): bool { return !$this->closed && $this->input->isReadable(); } @@ -69,7 +74,7 @@ public function pipe(WritableStreamInterface $dest, array $options = []) return $dest; } - public function close() + public function close(): void { if ($this->closed) { return; @@ -83,13 +88,13 @@ public function close() $this->removeAllListeners(); } - public function getSize() + public function getSize(): ?int { return $this->size; } /** @ignore */ - public function __toString() + public function __toString(): string { return ''; } @@ -101,61 +106,61 @@ public function detach() } /** @ignore */ - public function tell() + public function tell(): int { throw new \BadMethodCallException(); } /** @ignore */ - public function eof() + public function eof(): bool { throw new \BadMethodCallException(); } /** @ignore */ - public function isSeekable() + public function isSeekable(): bool { return false; } /** @ignore */ - public function seek($offset, $whence = SEEK_SET) + public function seek(int $offset, int $whence = SEEK_SET): void { throw new \BadMethodCallException(); } /** @ignore */ - public function rewind() + public function rewind(): void { throw new \BadMethodCallException(); } /** @ignore */ - public function isWritable() + public function isWritable(): bool { return false; } /** @ignore */ - public function write($string) + public function write(string $string): int { throw new \BadMethodCallException(); } /** @ignore */ - public function read($length) + public function read(int $length): string { throw new \BadMethodCallException(); } /** @ignore */ - public function getContents() + public function getContents(): string { return ''; } /** @ignore */ - public function getMetadata($key = null) + public function getMetadata(?string $key = null) { return null; } @@ -182,6 +187,4 @@ public function handleEnd() } } } -} else { - class_alias(__NAMESPACE__ . '\\V2\\HttpBodyStream', __NAMESPACE__ . '\\HttpBodyStream'); } diff --git a/src/Io/V2/HttpBodyStream.php b/src/Io/HttpBodyStream.v1.php similarity index 85% rename from src/Io/V2/HttpBodyStream.php rename to src/Io/HttpBodyStream.v1.php index 7b2c6598..4d583766 100644 --- a/src/Io/V2/HttpBodyStream.php +++ b/src/Io/HttpBodyStream.v1.php @@ -1,6 +1,6 @@ = 70000 && $reflectedMethod->hasReturnType())) { +if (!(new \ReflectionParameter([StreamInterface::class, 'seek'], 0))->hasType()) { + // load legacy class for psr/http-message v1.0 without native parameter types + require __DIR__ . '/ReadableBodyStream.v1.php'; +} else { + // use class with native types like `seek(int $offset, int $whence = SEEK_SET): void` + /** + * [Internal] Bridge between ReadableStreamInterface from ReactPHP and StreamInterface from PSR-7 v2 + * * @internal + * @see ReadableBodyStream.v1.php (PSR-7 v1) */ class ReadableBodyStream extends EventEmitter implements ReadableStreamInterface, StreamInterface { @@ -41,7 +48,7 @@ public function __construct(ReadableStreamInterface $input, $size = null) $input->on('close', [$this, 'close']); } - public function close() + public function close(): void { if (!$this->closed) { $this->closed = true; @@ -52,7 +59,7 @@ public function close() } } - public function isReadable() + public function isReadable(): bool { return $this->input->isReadable(); } @@ -74,12 +81,12 @@ public function pipe(WritableStreamInterface $dest, array $options = []) return $dest; } - public function eof() + public function eof(): bool { return !$this->isReadable(); } - public function __toString() + public function __toString(): string { return ''; } @@ -89,52 +96,52 @@ public function detach() throw new \BadMethodCallException(); } - public function getSize() + public function getSize(): ?int { return $this->size; } - public function tell() + public function tell(): int { throw new \BadMethodCallException(); } - public function isSeekable() + public function isSeekable(): bool { return false; } - public function seek($offset, $whence = SEEK_SET) + public function seek(int $offset, int $whence = SEEK_SET): void { throw new \BadMethodCallException(); } - public function rewind() + public function rewind(): void { throw new \BadMethodCallException(); } - public function isWritable() + public function isWritable(): bool { return false; } - public function write($string) + public function write(string $string): int { throw new \BadMethodCallException(); } - public function read($length) + public function read(int $length): string { throw new \BadMethodCallException(); } - public function getContents() + public function getContents(): string { throw new \BadMethodCallException(); } - public function getMetadata($key = null) + public function getMetadata(?string $key = null) { return ($key === null) ? [] : null; } @@ -151,6 +158,4 @@ public function handleEnd() $this->close(); } } -} else { - class_alias(__NAMESPACE__ . '\\V2\\ReadableBodyStream', __NAMESPACE__ . '\\ReadableBodyStream'); } diff --git a/src/Io/V2/ReadableBodyStream.php b/src/Io/ReadableBodyStream.v1.php similarity index 82% rename from src/Io/V2/ReadableBodyStream.php rename to src/Io/ReadableBodyStream.v1.php index e3430cac..990ff3fd 100644 --- a/src/Io/V2/ReadableBodyStream.php +++ b/src/Io/ReadableBodyStream.v1.php @@ -1,6 +1,6 @@ size; } - public function tell():int + public function tell(): int { throw new \BadMethodCallException(); } @@ -109,7 +105,7 @@ public function isSeekable(): bool return false; } - public function seek(int $offset, int $whence = SEEK_SET): void + public function seek($offset, $whence = SEEK_SET): void { throw new \BadMethodCallException(); } @@ -124,12 +120,12 @@ public function isWritable(): bool return false; } - public function write(string $string): int + public function write($string): int { throw new \BadMethodCallException(); } - public function read(int $length): string + public function read($length): string { throw new \BadMethodCallException(); } @@ -139,7 +135,7 @@ public function getContents(): string throw new \BadMethodCallException(); } - public function getMetadata(?string $key = null) + public function getMetadata($key = null) { return ($key === null) ? [] : null; } diff --git a/src/Io/UploadedFile.php b/src/Io/UploadedFile.php index f96a7103..fa2f0ea6 100644 --- a/src/Io/UploadedFile.php +++ b/src/Io/UploadedFile.php @@ -7,10 +7,14 @@ use InvalidArgumentException; use RuntimeException; -$reflectionMethod = new \ReflectionMethod('\Psr\Http\Message\UploadedFileInterface', 'getStream'); -if (PHP_VERSION_ID < 70000 || !$reflectionMethod->hasReturnType()) { +if (!(new \ReflectionParameter([UploadedFileInterface::class, 'moveTo'], 0))->hasType()) { + // load legacy class for psr/http-message v1.0 without native parameter types + require __DIR__ . '/UploadedFile.v1.php'; +} else { + // use class with native types like `moveTo(string $targetPath): void` + /** - * [Internal] Implementation of the PSR-7 `UploadedFileInterface` + * [Internal] Implementation of the PSR-7 v2 `UploadedFileInterface` * * This is used internally to represent each incoming file upload. * @@ -19,6 +23,7 @@ * * @see UploadedFileInterface * @internal + * @see UploadedFile.v1.php (PSR-7 v1) */ final class UploadedFile implements UploadedFileInterface { @@ -81,7 +86,7 @@ public function __construct(StreamInterface $stream, $size, $error, $filename, $ /** * {@inheritdoc} */ - public function getStream() + public function getStream(): StreamInterface { if ($this->error !== \UPLOAD_ERR_OK) { throw new RuntimeException('Cannot retrieve stream due to upload error'); @@ -93,7 +98,7 @@ public function getStream() /** * {@inheritdoc} */ - public function moveTo($targetPath) + public function moveTo(string $targetPath): void { throw new RuntimeException('Not implemented'); } @@ -101,7 +106,7 @@ public function moveTo($targetPath) /** * {@inheritdoc} */ - public function getSize() + public function getSize(): ?int { return $this->size; } @@ -109,7 +114,7 @@ public function getSize() /** * {@inheritdoc} */ - public function getError() + public function getError(): int { return $this->error; } @@ -117,7 +122,7 @@ public function getError() /** * {@inheritdoc} */ - public function getClientFilename() + public function getClientFilename(): ?string { return $this->filename; } @@ -125,11 +130,9 @@ public function getClientFilename() /** * {@inheritdoc} */ - public function getClientMediaType() + public function getClientMediaType(): ?string { return $this->mediaType; } } -} else { - class_alias(__NAMESPACE__ . '\\V2\\UploadedFile', __NAMESPACE__ . '\\UploadedFile'); } diff --git a/src/Io/V2/UploadedFile.php b/src/Io/UploadedFile.v1.php similarity index 82% rename from src/Io/V2/UploadedFile.php rename to src/Io/UploadedFile.v1.php index ce13de8b..375164c0 100644 --- a/src/Io/V2/UploadedFile.php +++ b/src/Io/UploadedFile.v1.php @@ -1,6 +1,6 @@ hasReturnType()) { /** - * Respresents an outgoing HTTP request message. + * Represents an outgoing HTTP request message (PSR-7). * * This class implements the * [PSR-7 `RequestInterface`](https://www.php-fig.org/psr/psr-7/#32-psrhttpmessagerequestinterface) @@ -57,6 +55,3 @@ public function __construct( parent::__construct($method, $url, $headers, $body, $version); } } -} else { - class_alias(__NAMESPACE__ . '\\V2\\Request', __NAMESPACE__ . '\\Request'); -} diff --git a/src/Message/Response.php b/src/Message/Response.php index 0b6e941c..0ce55bca 100644 --- a/src/Message/Response.php +++ b/src/Message/Response.php @@ -10,10 +10,14 @@ use React\Http\Io\HttpBodyStream; use React\Stream\ReadableStreamInterface; -$reflectionMethod = new \ReflectionMethod('\Psr\Http\Message\ResponseInterface', 'getStatusCode'); -if (PHP_VERSION_ID < 70000 || !$reflectionMethod->hasReturnType()) { +if (!(new \ReflectionParameter([ResponseInterface::class, 'withStatus'], 0))->hasType()) { + // load legacy class for psr/http-message v1.0 without native parameter types + require __DIR__ . '/Response.v1.php'; +} else { + // use class with native types like `withStatus(int $code, string $reasonPhrase = ''): ResponseInterface` + /** - * Represents an outgoing server response message. + * Represents an outgoing server response message (PSR-7 v2). * * ```php * $response = new React\Http\Message\Response( @@ -41,6 +45,7 @@ * considered an implementation detail that may change in the future. * * @see \Psr\Http\Message\ResponseInterface + * @see Response.v1.php (PSR-7 v1) */ final class Response extends AbstractMessage implements ResponseInterface, StatusCodeInterface { @@ -259,9 +264,9 @@ public static function xml($xml) /** * @var bool - * @see static::$phrasesMap + * @see self::$phrasesMap */ - protected static $phrasesInitialized = false; + private static $phrasesInitialized = false; /** * Map of standard HTTP status codes to standard reason phrases. @@ -321,12 +326,12 @@ public function __construct( $this->reasonPhrase = ($reason !== '' && $reason !== null) ? (string) $reason : self::getReasonPhraseForStatusCode($status); } - public function getStatusCode() + public function getStatusCode(): int { return $this->statusCode; } - public function withStatus($code, $reasonPhrase = '') + public function withStatus(int $code, string $reasonPhrase = ''): ResponseInterface { if ((string) $reasonPhrase === '') { $reasonPhrase = self::getReasonPhraseForStatusCode($code); @@ -343,7 +348,7 @@ public function withStatus($code, $reasonPhrase = '') return $response; } - public function getReasonPhrase() + public function getReasonPhrase(): string { return $this->reasonPhrase; } @@ -412,6 +417,4 @@ public static function parseMessage($message) ); } } -} else { - class_alias(__NAMESPACE__ . '\\V2\\Response', __NAMESPACE__ . '\\Response'); } diff --git a/src/Message/V2/Response.php b/src/Message/Response.v1.php similarity index 86% rename from src/Message/V2/Response.php rename to src/Message/Response.v1.php index e82be729..175f4e93 100644 --- a/src/Message/V2/Response.php +++ b/src/Message/Response.v1.php @@ -1,6 +1,6 @@ 'text/html; charset=utf-8'], $html); + return new self(self::STATUS_OK, ['Content-Type' => 'text/html; charset=utf-8'], $html); } /** @@ -149,7 +141,7 @@ public static function html($html) * ``` * * @param mixed $data - * @return static + * @return self * @throws \InvalidArgumentException when encoding fails */ public static function json($data) @@ -166,7 +158,7 @@ public static function json($data) ); } - return new static(static::STATUS_OK, ['Content-Type' => 'application/json'], $json . "\n"); + return new self(self::STATUS_OK, ['Content-Type' => 'application/json'], $json . "\n"); } /** @@ -205,11 +197,11 @@ public static function json($data) * ``` * * @param string $text - * @return static + * @return self */ public static function plaintext($text) { - return new static(static::STATUS_OK, ['Content-Type' => 'text/plain; charset=utf-8'], $text); + return new self(self::STATUS_OK, ['Content-Type' => 'text/plain; charset=utf-8'], $text); } /** @@ -257,16 +249,16 @@ public static function plaintext($text) * ``` * * @param string $xml - * @return static + * @return self */ public static function xml($xml) { - return new static(static::STATUS_OK, ['Content-Type' => 'application/xml'], $xml); + return new self(self::STATUS_OK, ['Content-Type' => 'application/xml'], $xml); } /** * @var bool - * @see static::$phrasesMap + * @see self::$phrasesMap */ private static $phrasesInitialized = false; @@ -276,12 +268,12 @@ public static function xml($xml) * 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 + * and all caps words). See `self::STATUS_*` for all possible status code * constants. * * @var array - * @see static::STATUS_* - * @see static::getReasonPhraseForStatusCode() + * @see self::STATUS_* + * @see self::getReasonPhraseForStatusCode() */ private static $phrasesMap = [ 200 => 'OK', @@ -300,7 +292,7 @@ public static function xml($xml) private $reasonPhrase; /** - * @param int $status HTTP status code (e.g. 200/404), see `static::STATUS_*` constants + * @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) @@ -325,7 +317,7 @@ public function __construct( parent::__construct($version, $headers, $body); $this->statusCode = (int) $status; - $this->reasonPhrase = ($reason !== '' && $reason !== null) ? (string) $reason : static::getReasonPhraseForStatusCode($status); + $this->reasonPhrase = ($reason !== '' && $reason !== null) ? (string) $reason : self::getReasonPhraseForStatusCode($status); } public function getStatusCode(): int @@ -333,10 +325,10 @@ public function getStatusCode(): int return $this->statusCode; } - public function withStatus(int $code, string $reasonPhrase = ''): ResponseInterface + public function withStatus($code, $reasonPhrase = ''): ResponseInterface { if ((string) $reasonPhrase === '') { - $reasonPhrase = static::getReasonPhraseForStatusCode($code); + $reasonPhrase = self::getReasonPhraseForStatusCode($code); } if ($this->statusCode === (int) $code && $this->reasonPhrase === (string) $reasonPhrase) { @@ -361,20 +353,20 @@ public function getReasonPhrase(): string */ private static function getReasonPhraseForStatusCode($code) { - if (!static::$phrasesInitialized) { - static::$phrasesInitialized = true; + if (!self::$phrasesInitialized) { + self::$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` + // 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(static::$phrasesMap[$value]) && \strpos($name, 'STATUS_') === 0) { - static::$phrasesMap[$value] = \ucwords(\strtolower(\str_replace('_', ' ', \substr($name, 7)))); + if (!isset(self::$phrasesMap[$value]) && \strpos($name, 'STATUS_') === 0) { + self::$phrasesMap[$value] = \ucwords(\strtolower(\str_replace('_', ' ', \substr($name, 7)))); } } } - return static::$phrasesMap[$code] ?? ''; + return self::$phrasesMap[$code] ?? ''; } /** @@ -382,7 +374,7 @@ private static function getReasonPhraseForStatusCode($code) * * @internal * @param string $message - * @return static + * @return self * @throws \InvalidArgumentException if given $message is not a valid HTTP response message */ public static function parseMessage($message) @@ -399,7 +391,7 @@ public static function parseMessage($message) // check number of valid header fields matches number of lines + status line $matches = []; - $n = \preg_match_all(static::REGEX_HEADERS, $message, $matches, \PREG_SET_ORDER); + $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'); } @@ -410,7 +402,7 @@ public static function parseMessage($message) $headers[$match[1]][] = $match[2]; } - return new static( + return new self( (int) $start['status'], $headers, '', diff --git a/src/Message/ServerRequest.php b/src/Message/ServerRequest.php index 292566a3..ddb80988 100644 --- a/src/Message/ServerRequest.php +++ b/src/Message/ServerRequest.php @@ -10,10 +10,14 @@ use React\Http\Io\HttpBodyStream; use React\Stream\ReadableStreamInterface; -$reflectionMethod = new \ReflectionMethod('\Psr\Http\Message\ServerRequestInterface', 'getServerParams'); -if (PHP_VERSION_ID < 70000 || !$reflectionMethod->hasReturnType()) { +if (!(new \ReflectionParameter([ServerRequestInterface::class, 'withAttribute'], 0))->hasType()) { + // load legacy class for psr/http-message v1.0 without native parameter types + require __DIR__ . '/ServerRequest.v1.php'; +} else { + // use class with native types like `withAttribute(string $name, $value): ServerRequestInterface` + /** - * Respresents an incoming server request message. + * Represents an incoming server request message (PSR-7 v2). * * This class implements the * [PSR-7 `ServerRequestInterface`](https://www.php-fig.org/psr/psr-7/#321-psrhttpmessageserverrequestinterface) @@ -30,6 +34,7 @@ * considered an implementation detail that may change in the future. * * @see ServerRequestInterface + * @see ServerRequest.v1.php (PSR-7 v1) */ final class ServerRequest extends AbstractRequest implements ServerRequestInterface { @@ -89,41 +94,41 @@ public function __construct( } } - public function getServerParams() + public function getServerParams(): array { return $this->serverParams; } - public function getCookieParams() + public function getCookieParams(): array { return $this->cookies; } - public function withCookieParams(array $cookies) + public function withCookieParams(array $cookies): ServerRequestInterface { $new = clone $this; $new->cookies = $cookies; return $new; } - public function getQueryParams() + public function getQueryParams(): array { return $this->queryParams; } - public function withQueryParams(array $query) + public function withQueryParams(array $query): ServerRequestInterface { $new = clone $this; $new->queryParams = $query; return $new; } - public function getUploadedFiles() + public function getUploadedFiles(): array { return $this->fileParams; } - public function withUploadedFiles(array $uploadedFiles) + public function withUploadedFiles(array $uploadedFiles): ServerRequestInterface { $new = clone $this; $new->fileParams = $uploadedFiles; @@ -135,19 +140,19 @@ public function getParsedBody() return $this->parsedBody; } - public function withParsedBody($data) + public function withParsedBody($data): ServerRequestInterface { $new = clone $this; $new->parsedBody = $data; return $new; } - public function getAttributes() + public function getAttributes(): array { return $this->attributes; } - public function getAttribute($name, $default = null) + public function getAttribute(string $name, $default = null) { if (!\array_key_exists($name, $this->attributes)) { return $default; @@ -155,14 +160,14 @@ public function getAttribute($name, $default = null) return $this->attributes[$name]; } - public function withAttribute($name, $value) + public function withAttribute(string $name, $value): ServerRequestInterface { $new = clone $this; $new->attributes[$name] = $value; return $new; } - public function withoutAttribute($name) + public function withoutAttribute(string $name): ServerRequestInterface { $new = clone $this; unset($new->attributes[$name]); @@ -331,6 +336,4 @@ public static function parseMessage($message, array $serverParams) return $request; } } -} else { - class_alias(__NAMESPACE__ . '\\V2\\ServerRequest', __NAMESPACE__ . '\\ServerRequest'); } diff --git a/src/Message/V2/ServerRequest.php b/src/Message/ServerRequest.v1.php similarity index 93% rename from src/Message/V2/ServerRequest.php rename to src/Message/ServerRequest.v1.php index ba087dae..f818263b 100644 --- a/src/Message/V2/ServerRequest.php +++ b/src/Message/ServerRequest.v1.php @@ -1,6 +1,6 @@ getHeaderLine('Content-Length'); if (\strtolower($temp->getHeaderLine('Transfer-Encoding')) === 'chunked') { $size = null; @@ -154,7 +146,7 @@ public function getAttributes(): array return $this->attributes; } - public function getAttribute(string $name, $default = null) + public function getAttribute($name, $default = null) { if (!\array_key_exists($name, $this->attributes)) { return $default; @@ -162,14 +154,14 @@ public function getAttribute(string $name, $default = null) return $this->attributes[$name]; } - public function withAttribute(string $name, $value): ServerRequestInterface + public function withAttribute($name, $value): ServerRequestInterface { $new = clone $this; $new->attributes[$name] = $value; return $new; } - public function withoutAttribute(string $name): ServerRequestInterface + public function withoutAttribute($name): ServerRequestInterface { $new = clone $this; unset($new->attributes[$name]); @@ -205,7 +197,7 @@ private function parseCookie($cookie) * @internal * @param string $message * @param array $serverParams - * @return static + * @return self * @throws \InvalidArgumentException if given $message is not a valid HTTP request message */ public static function parseMessage($message, array $serverParams) @@ -223,7 +215,7 @@ public static function parseMessage($message, array $serverParams) // check number of valid header fields matches number of lines + request line $matches = []; - $n = \preg_match_all(static::REGEX_HEADERS, $message, $matches, \PREG_SET_ORDER); + $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'); } @@ -277,7 +269,7 @@ public static function parseMessage($message, array $serverParams) } } - $request = new static( + $request = new self( $start['method'], $uri, $headers, diff --git a/src/Message/Uri.php b/src/Message/Uri.php index 36cb2829..a8802548 100644 --- a/src/Message/Uri.php +++ b/src/Message/Uri.php @@ -4,10 +4,14 @@ use Psr\Http\Message\UriInterface; -$reflectionMethod = new \ReflectionMethod('\Psr\Http\Message\UriInterface', 'getScheme'); -if (PHP_VERSION_ID < 70000 || !$reflectionMethod->hasReturnType()) { +if (!(new \ReflectionParameter([UriInterface::class, 'withScheme'], 0))->hasType()) { + // load legacy class for psr/http-message v1.0 without native parameter types + require __DIR__ . '/Uri.v1.php'; +} else { + // use class with native types like `withScheme(string $scheme): UriInterface` + /** - * Respresents a URI (or URL). + * Represents a URI or URL (PSR-7 v2). * * This class implements the * [PSR-7 `UriInterface`](https://www.php-fig.org/psr/psr-7/#35-psrhttpmessageuriinterface). @@ -17,6 +21,7 @@ * also use this class with other HTTP implementations and for tests. * * @see UriInterface + * @see Uri.v1.php (PSR-7 v1) */ final class Uri implements UriInterface { @@ -81,12 +86,12 @@ public function __construct($uri) } } - public function getScheme() + public function getScheme(): string { return $this->scheme; } - public function getAuthority() + public function getAuthority(): string { if ($this->host === '') { return ''; @@ -95,37 +100,37 @@ public function getAuthority() return ($this->userInfo !== '' ? $this->userInfo . '@' : '') . $this->host . ($this->port !== null ? ':' . $this->port : ''); } - public function getUserInfo() + public function getUserInfo(): string { return $this->userInfo; } - public function getHost() + public function getHost(): string { return $this->host; } - public function getPort() + public function getPort(): ?int { return $this->port; } - public function getPath() + public function getPath(): string { return $this->path; } - public function getQuery() + public function getQuery(): string { return $this->query; } - public function getFragment() + public function getFragment(): string { return $this->fragment; } - public function withScheme($scheme) + public function withScheme(string $scheme): UriInterface { $scheme = \strtolower($scheme); if ($scheme === $this->scheme) { @@ -146,7 +151,7 @@ public function withScheme($scheme) return $new; } - public function withUserInfo($user, $password = null) + 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) { @@ -159,7 +164,7 @@ public function withUserInfo($user, $password = null) return $new; } - public function withHost($host) + public function withHost(string $host): UriInterface { $host = \strtolower($host); if ($host === $this->host) { @@ -176,7 +181,7 @@ public function withHost($host) return $new; } - public function withPort($port) + public function withPort(?int $port): UriInterface { $port = $port === null ? null : (int) $port; if (($port === 80 && $this->scheme === 'http') || ($port === 443 && $this->scheme === 'https')) { @@ -197,7 +202,7 @@ public function withPort($port) return $new; } - public function withPath($path) + public function withPath(string $path): UriInterface { $path = $this->encode($path, \PHP_URL_PATH); if ($path === $this->path) { @@ -210,7 +215,7 @@ public function withPath($path) return $new; } - public function withQuery($query) + public function withQuery(string $query): UriInterface { $query = $this->encode($query, \PHP_URL_QUERY); if ($query === $this->query) { @@ -223,7 +228,7 @@ public function withQuery($query) return $new; } - public function withFragment($fragment) + public function withFragment(string $fragment): UriInterface { $fragment = $this->encode($fragment, \PHP_URL_FRAGMENT); if ($fragment === $this->fragment) { @@ -236,7 +241,7 @@ public function withFragment($fragment) return $new; } - public function __toString() + public function __toString(): string { $uri = ''; if ($this->scheme !== '') { @@ -272,7 +277,7 @@ public function __toString() * @param int $component * @return string */ - private function encode($part, $component) + 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', @@ -292,7 +297,7 @@ function (array $match) { * @return UriInterface * @throws void */ - public static function resolve(UriInterface $base, UriInterface $rel) + public static function resolve(UriInterface $base, UriInterface $rel): UriInterface { if ($rel->getScheme() !== '') { return $rel->getPath() === '' ? $rel : $rel->withPath(self::removeDotSegments($rel->getPath())); @@ -334,7 +339,7 @@ public static function resolve(UriInterface $base, UriInterface $rel) * @param string $path * @return string */ - private static function removeDotSegments($path) + private static function removeDotSegments(string $path): string { $segments = []; foreach (\explode('/', $path) as $segment) { @@ -347,6 +352,4 @@ private static function removeDotSegments($path) return '/' . \implode('/', $segments) . ($path !== '/' && \substr($path, -1) === '/' ? '/' : ''); } } -} else { - class_alias(__NAMESPACE__ . '\\V2\\Uri', __NAMESPACE__ . '\\Uri'); } diff --git a/src/Message/V2/Uri.php b/src/Message/Uri.v1.php similarity index 89% rename from src/Message/V2/Uri.php rename to src/Message/Uri.v1.php index f2057600..6ec3ce86 100644 --- a/src/Message/V2/Uri.php +++ b/src/Message/Uri.v1.php @@ -1,11 +1,11 @@ fragment; } - public function withScheme(string $scheme): UriInterface + public function withScheme($scheme): UriInterface { $scheme = \strtolower($scheme); if ($scheme === $this->scheme) { @@ -153,7 +145,7 @@ public function withScheme(string $scheme): UriInterface return $new; } - public function withUserInfo(string $user, ?string $password = null): UriInterface + public function withUserInfo($user, $password = null): UriInterface { $userInfo = $this->encode($user, \PHP_URL_USER) . ($password !== null ? ':' . $this->encode($password, \PHP_URL_PASS) : ''); if ($userInfo === $this->userInfo) { @@ -166,7 +158,7 @@ public function withUserInfo(string $user, ?string $password = null): UriInterfa return $new; } - public function withHost(string $host): UriInterface + public function withHost($host): UriInterface { $host = \strtolower($host); if ($host === $this->host) { @@ -183,7 +175,7 @@ public function withHost(string $host): UriInterface return $new; } - public function withPort(?int $port): UriInterface + public function withPort($port): UriInterface { $port = $port === null ? null : (int) $port; if (($port === 80 && $this->scheme === 'http') || ($port === 443 && $this->scheme === 'https')) { @@ -204,7 +196,7 @@ public function withPort(?int $port): UriInterface return $new; } - public function withPath(string $path): UriInterface + public function withPath($path): UriInterface { $path = $this->encode($path, \PHP_URL_PATH); if ($path === $this->path) { @@ -217,7 +209,7 @@ public function withPath(string $path): UriInterface return $new; } - public function withQuery(string $query): UriInterface + public function withQuery($query): UriInterface { $query = $this->encode($query, \PHP_URL_QUERY); if ($query === $this->query) { @@ -230,7 +222,7 @@ public function withQuery(string $query): UriInterface return $new; } - public function withFragment(string $fragment): UriInterface + public function withFragment($fragment): UriInterface { $fragment = $this->encode($fragment, \PHP_URL_FRAGMENT); if ($fragment === $this->fragment) { @@ -274,6 +266,11 @@ public function __toString(): string return $uri; } + /** + * @param string $part + * @param int $component + * @return string + */ private function encode(string $part, int $component): string { return \preg_replace_callback( @@ -289,12 +286,15 @@ function (array $match) { * [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): UriInterface { if ($rel->getScheme() !== '') { - return $rel->getPath() === '' ? $rel : $rel->withPath(static::removeDotSegments($rel->getPath())); + return $rel->getPath() === '' ? $rel : $rel->withPath(self::removeDotSegments($rel->getPath())); } $reset = false; @@ -316,7 +316,7 @@ public static function resolve(UriInterface $base, UriInterface $rel): UriInterf } } $reset = true; - $new = $new->withPath(static::removeDotSegments($start . $path)); + $new = $new->withPath(self::removeDotSegments($start . $path)); } if ($reset || $rel->getQuery() !== '') { $reset = true; @@ -329,6 +329,10 @@ public static function resolve(UriInterface $base, UriInterface $rel): UriInterf return $new; } + /** + * @param string $path + * @return string + */ private static function removeDotSegments(string $path): string { $segments = []; diff --git a/src/Message/V2/Request.php b/src/Message/V2/Request.php deleted file mode 100644 index 802b75b9..00000000 --- a/src/Message/V2/Request.php +++ /dev/null @@ -1,66 +0,0 @@ - Internally, this implementation builds on top of a base class which is - * considered an implementation detail that may change in the future. - * - * @see RequestInterface - * - * @internal - * - * @deprecated Do not reference this FQCN directly. Not a runtime deprecation: - * when psr/http-message v2 is installed this class is the - * implementation behind the public name. Marked deprecated so - * tooling suggests the aliased class instead (see sibling file). - * @see \React\Http\Io\Request - * @see ../Request.php - */ -final class Request extends AbstractRequest implements RequestInterface -{ - /** - * @param string $method HTTP method for the request. - * @param string|UriInterface $url URL for the request. - * @param array $headers Headers for the message. - * @param string|ReadableStreamInterface|StreamInterface $body Message body. - * @param string $version HTTP protocol version. - * @throws \InvalidArgumentException for an invalid URL or body - */ - public function __construct( - $method, - $url, - array $headers = [], - $body = '', - $version = '1.1' - ) { - if (\is_string($body)) { - $body = new BufferedBody($body); - } elseif ($body instanceof ReadableStreamInterface && !$body instanceof StreamInterface) { - $body = new ReadableBodyStream($body); - } elseif (!$body instanceof StreamInterface) { - throw new \InvalidArgumentException('Invalid request body given'); - } - - parent::__construct($method, $url, $headers, $body, $version); - } -} diff --git a/tests/Io/EmptyBodyStreamTest.php b/tests/Io/EmptyBodyStreamTest.php index 81f8381b..f6c74968 100644 --- a/tests/Io/EmptyBodyStreamTest.php +++ b/tests/Io/EmptyBodyStreamTest.php @@ -2,6 +2,7 @@ namespace React\Tests\Http\Io; +use Psr\Http\Message\StreamInterface; use React\Http\Io\EmptyBodyStream; use React\Stream\WritableStreamInterface; use React\Tests\Http\TestCase; @@ -127,8 +128,8 @@ public function testIsReadableReturnsFalseWhenAlreadyClosed() public function testSeek() { - $reflectionMethod = new \ReflectionMethod('\Psr\Http\Message\StreamInterface', 'eof'); - if (PHP_VERSION_ID < 70000 || !$reflectionMethod->hasReturnType()) { + $reflectionParameter = new \ReflectionParameter([StreamInterface::class, 'seek'], 0); + if (!$reflectionParameter->hasType()) { $this->expectException(\BadMethodCallException::class); } else { $this->expectException(\TypeError::class); diff --git a/tests/Io/HttpBodyStreamTest.php b/tests/Io/HttpBodyStreamTest.php index 8ebd97fb..f046e4a4 100644 --- a/tests/Io/HttpBodyStreamTest.php +++ b/tests/Io/HttpBodyStreamTest.php @@ -2,6 +2,7 @@ namespace React\Tests\Http\Io; +use Psr\Http\Message\StreamInterface; use React\Http\Io\HttpBodyStream; use React\Stream\ReadableStreamInterface; use React\Stream\ThroughStream; @@ -132,8 +133,8 @@ public function testWrite() public function testRead() { - $reflectionMethod = new \ReflectionMethod('\Psr\Http\Message\StreamInterface', 'eof'); - if (PHP_VERSION_ID < 70000 || !$reflectionMethod->hasReturnType()) { + $reflectionParameter = new \ReflectionParameter([StreamInterface::class, 'seek'], 0); + if (!$reflectionParameter->hasType()) { $this->expectException(\BadMethodCallException::class); } else { $this->expectException(\TypeError::class); @@ -159,8 +160,8 @@ public function testIsReadable() public function testSeek() { - $reflectionMethod = new \ReflectionMethod('\Psr\Http\Message\StreamInterface', 'eof'); - if (PHP_VERSION_ID < 70000 || !$reflectionMethod->hasReturnType()) { + $reflectionParameter = new \ReflectionParameter([StreamInterface::class, 'seek'], 0); + if (!$reflectionParameter->hasType()) { $this->expectException(\BadMethodCallException::class); } else { $this->expectException(\TypeError::class);