From 1bbd7f921f6a762852c9566742c20efba4672ac2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Fri, 18 Nov 2022 16:40:33 +0100 Subject: [PATCH 1/2] Update `Response` class to build on top of abstract message class --- src/Message/Response.php | 102 ++++++++++++++++++++++++++++--- tests/Io/StreamingServerTest.php | 4 +- tests/Message/ResponseTest.php | 33 ++++++++++ 3 files changed, 128 insertions(+), 11 deletions(-) diff --git a/src/Message/Response.php b/src/Message/Response.php index edd6245b..c50d0cee 100644 --- a/src/Message/Response.php +++ b/src/Message/Response.php @@ -3,11 +3,12 @@ namespace React\Http\Message; use Fig\Http\Message\StatusCodeInterface; +use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\StreamInterface; use React\Http\Io\BufferedBody; use React\Http\Io\HttpBodyStream; use React\Stream\ReadableStreamInterface; -use RingCentral\Psr7\Response as Psr7Response; +use RingCentral\Psr7\MessageTrait; /** * Represents an outgoing server response message. @@ -40,7 +41,7 @@ * * @see \Psr\Http\Message\ResponseInterface */ -final class Response extends Psr7Response implements StatusCodeInterface +final class Response extends MessageTrait implements ResponseInterface, StatusCodeInterface { /** * Create an HTML response @@ -257,6 +258,41 @@ public static function xml($xml) return new self(self::STATUS_OK, array('Content-Type' => 'application/xml'), $xml); } + /** + * @var bool + * @see self::$phrasesMap + */ + private static $phrasesInitialized = false; + + /** + * Map of standard HTTP status codes to standard reason phrases. + * + * This map will be fully populated with all standard reason phrases on + * first access. By default, it only contains a subset of HTTP status codes + * that have a custom mapping to reason phrases (such as those with dashes + * and all caps words). See `self::STATUS_*` for all possible status code + * constants. + * + * @var array + * @see self::STATUS_* + * @see self::getReasonPhraseForStatusCode() + */ + private static $phrasesMap = array( + 200 => 'OK', + 203 => 'Non-Authoritative Information', + 207 => 'Multi-Status', + 226 => 'IM Used', + 414 => 'URI Too Large', + 418 => 'I\'m a teapot', + 505 => 'HTTP Version Not Supported' + ); + + /** @var int */ + private $statusCode; + + /** @var string */ + private $reasonPhrase; + /** * @param int $status HTTP status code (e.g. 200/404), see `self::STATUS_*` constants * @param array $headers additional response headers @@ -280,12 +316,60 @@ public function __construct( throw new \InvalidArgumentException('Invalid response body given'); } - parent::__construct( - $status, - $headers, - $body, - $version, - $reason - ); + $this->protocol = (string) $version; + $this->setHeaders($headers); + $this->stream = $body; + + $this->statusCode = (int) $status; + $this->reasonPhrase = ($reason !== '' && $reason !== null) ? (string) $reason : self::getReasonPhraseForStatusCode($status); + } + + public function getStatusCode() + { + return $this->statusCode; + } + + public function withStatus($code, $reasonPhrase = '') + { + if ((string) $reasonPhrase === '') { + $reasonPhrase = self::getReasonPhraseForStatusCode($code); + } + + if ($this->statusCode === (int) $code && $this->reasonPhrase === (string) $reasonPhrase) { + return $this; + } + + $response = clone $this; + $response->statusCode = (int) $code; + $response->reasonPhrase = (string) $reasonPhrase; + + return $response; + } + + public function getReasonPhrase() + { + return $this->reasonPhrase; + } + + /** + * @param int $code + * @return string default reason phrase for given status code or empty string if unknown + */ + private static function getReasonPhraseForStatusCode($code) + { + if (!self::$phrasesInitialized) { + self::$phrasesInitialized = true; + + // map all `self::STATUS_` constants from status code to reason phrase + // e.g. `self::STATUS_NOT_FOUND = 404` will be mapped to `404 Not Found` + $ref = new \ReflectionClass(__CLASS__); + foreach ($ref->getConstants() as $name => $value) { + if (!isset(self::$phrasesMap[$value]) && \strpos($name, 'STATUS_') === 0) { + self::$phrasesMap[$value] = \ucwords(\strtolower(\str_replace('_', ' ', \substr($name, 7)))); + } + } + } + + return isset(self::$phrasesMap[$code]) ? self::$phrasesMap[$code] : ''; } } diff --git a/tests/Io/StreamingServerTest.php b/tests/Io/StreamingServerTest.php index a578797e..64566ddc 100644 --- a/tests/Io/StreamingServerTest.php +++ b/tests/Io/StreamingServerTest.php @@ -1567,9 +1567,9 @@ function ($data) use (&$buffer) { $this->assertInstanceOf('InvalidArgumentException', $error); - $this->assertContainsString("HTTP/1.1 505 HTTP Version not supported\r\n", $buffer); + $this->assertContainsString("HTTP/1.1 505 HTTP Version Not Supported\r\n", $buffer); $this->assertContainsString("\r\n\r\n", $buffer); - $this->assertContainsString("Error 505: HTTP Version not supported", $buffer); + $this->assertContainsString("Error 505: HTTP Version Not Supported", $buffer); } public function testRequestOverflowWillEmitErrorAndSendErrorResponse() diff --git a/tests/Message/ResponseTest.php b/tests/Message/ResponseTest.php index ed21cdc2..88b56945 100644 --- a/tests/Message/ResponseTest.php +++ b/tests/Message/ResponseTest.php @@ -54,6 +54,39 @@ public function testResourceBodyWillThrow() new Response(200, array(), tmpfile()); } + public function testWithStatusReturnsNewInstanceWhenStatusIsChanged() + { + $response = new Response(200); + + $new = $response->withStatus(404); + $this->assertNotSame($response, $new); + $this->assertEquals(404, $new->getStatusCode()); + $this->assertEquals('Not Found', $new->getReasonPhrase()); + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals('OK', $response->getReasonPhrase()); + } + + public function testWithStatusReturnsSameInstanceWhenStatusIsUnchanged() + { + $response = new Response(200); + + $new = $response->withStatus(200); + $this->assertSame($response, $new); + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals('OK', $response->getReasonPhrase()); + } + + public function testWithStatusReturnsNewInstanceWhenStatusIsUnchangedButReasonIsChanged() + { + $response = new Response(200); + + $new = $response->withStatus(200, 'Quite Ok'); + $this->assertNotSame($response, $new); + $this->assertEquals(200, $new->getStatusCode()); + $this->assertEquals('Quite Ok', $new->getReasonPhrase()); + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals('OK', $response->getReasonPhrase()); + } public function testHtmlMethodReturnsHtmlResponse() { From 518ca68ca8f03f61e9e662dedb1ce2383307a677 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Fri, 15 Sep 2023 20:08:07 +0200 Subject: [PATCH 2/2] Add internal `AbstractMessage` base class (PSR-7) --- README.md | 3 +- src/Io/AbstractMessage.php | 164 +++++++++++++++++ src/Message/Response.php | 11 +- tests/Io/AbstractMessageTest.php | 222 ++++++++++++++++++++++++ tests/Io/MiddlewareRunnerTest.php | 2 +- tests/Message/ResponseExceptionTest.php | 2 +- 6 files changed, 393 insertions(+), 11 deletions(-) create mode 100644 src/Io/AbstractMessage.php create mode 100644 tests/Io/AbstractMessageTest.php diff --git a/README.md b/README.md index 955e0a99..31d0430a 100644 --- a/README.md +++ b/README.md @@ -2448,8 +2448,7 @@ constants with the `STATUS_*` prefix. For instance, the `200 OK` and `404 Not Found` status codes can used as `Response::STATUS_OK` and `Response::STATUS_NOT_FOUND` respectively. -> Internally, this implementation builds on top of an existing incoming - response message and only adds required streaming support. This base class is +> Internally, this implementation builds on top of a base class which is considered an implementation detail that may change in the future. ##### html() diff --git a/src/Io/AbstractMessage.php b/src/Io/AbstractMessage.php new file mode 100644 index 00000000..8523d6cd --- /dev/null +++ b/src/Io/AbstractMessage.php @@ -0,0 +1,164 @@ + */ + private $headers = array(); + + /** @var array */ + private $headerNamesLowerCase = array(); + + /** @var string */ + private $protocolVersion; + + /** @var StreamInterface */ + private $body; + + /** + * @param string $protocolVersion + * @param array $headers + * @param StreamInterface $body + */ + protected function __construct($protocolVersion, array $headers, StreamInterface $body) + { + foreach ($headers as $name => $value) { + if ($value !== array()) { + if (\is_array($value)) { + foreach ($value as &$one) { + $one = (string) $one; + } + } else { + $value = array((string) $value); + } + + $lower = \strtolower($name); + if (isset($this->headerNamesLowerCase[$lower])) { + $value = \array_merge($this->headers[$this->headerNamesLowerCase[$lower]], $value); + unset($this->headers[$this->headerNamesLowerCase[$lower]]); + } + + $this->headers[$name] = $value; + $this->headerNamesLowerCase[$lower] = $name; + } + } + + $this->protocolVersion = (string) $protocolVersion; + $this->body = $body; + } + + public function getProtocolVersion() + { + return $this->protocolVersion; + } + + public function withProtocolVersion($version) + { + if ((string) $version === $this->protocolVersion) { + return $this; + } + + $message = clone $this; + $message->protocolVersion = (string) $version; + + return $message; + } + + public function getHeaders() + { + return $this->headers; + } + + public function hasHeader($name) + { + return isset($this->headerNamesLowerCase[\strtolower($name)]); + } + + public function getHeader($name) + { + $lower = \strtolower($name); + return isset($this->headerNamesLowerCase[$lower]) ? $this->headers[$this->headerNamesLowerCase[$lower]] : array(); + } + + public function getHeaderLine($name) + { + return \implode(', ', $this->getHeader($name)); + } + + public function withHeader($name, $value) + { + if ($value === array()) { + return $this->withoutHeader($name); + } elseif (\is_array($value)) { + foreach ($value as &$one) { + $one = (string) $one; + } + } else { + $value = array((string) $value); + } + + $lower = \strtolower($name); + if (isset($this->headerNamesLowerCase[$lower]) && $this->headerNamesLowerCase[$lower] === (string) $name && $this->headers[$this->headerNamesLowerCase[$lower]] === $value) { + return $this; + } + + $message = clone $this; + if (isset($message->headerNamesLowerCase[$lower])) { + unset($message->headers[$message->headerNamesLowerCase[$lower]]); + } + + $message->headers[$name] = $value; + $message->headerNamesLowerCase[$lower] = $name; + + return $message; + } + + public function withAddedHeader($name, $value) + { + if ($value === array()) { + return $this; + } + + return $this->withHeader($name, \array_merge($this->getHeader($name), \is_array($value) ? $value : array($value))); + } + + public function withoutHeader($name) + { + $lower = \strtolower($name); + if (!isset($this->headerNamesLowerCase[$lower])) { + return $this; + } + + $message = clone $this; + unset($message->headers[$message->headerNamesLowerCase[$lower]], $message->headerNamesLowerCase[$lower]); + + return $message; + } + + public function getBody() + { + return $this->body; + } + + public function withBody(StreamInterface $body) + { + if ($body === $this->body) { + return $this; + } + + $message = clone $this; + $message->body = $body; + + return $message; + } +} diff --git a/src/Message/Response.php b/src/Message/Response.php index c50d0cee..95c82ec8 100644 --- a/src/Message/Response.php +++ b/src/Message/Response.php @@ -5,10 +5,10 @@ use Fig\Http\Message\StatusCodeInterface; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\StreamInterface; +use React\Http\Io\AbstractMessage; use React\Http\Io\BufferedBody; use React\Http\Io\HttpBodyStream; use React\Stream\ReadableStreamInterface; -use RingCentral\Psr7\MessageTrait; /** * Represents an outgoing server response message. @@ -35,13 +35,12 @@ * `404 Not Found` status codes can used as `Response::STATUS_OK` and * `Response::STATUS_NOT_FOUND` respectively. * - * > Internally, this implementation builds on top of an existing incoming - * response message and only adds required streaming support. This base class is + * > Internally, this implementation builds on top a base class which is * considered an implementation detail that may change in the future. * * @see \Psr\Http\Message\ResponseInterface */ -final class Response extends MessageTrait implements ResponseInterface, StatusCodeInterface +final class Response extends AbstractMessage implements ResponseInterface, StatusCodeInterface { /** * Create an HTML response @@ -316,9 +315,7 @@ public function __construct( throw new \InvalidArgumentException('Invalid response body given'); } - $this->protocol = (string) $version; - $this->setHeaders($headers); - $this->stream = $body; + parent::__construct($version, $headers, $body); $this->statusCode = (int) $status; $this->reasonPhrase = ($reason !== '' && $reason !== null) ? (string) $reason : self::getReasonPhraseForStatusCode($status); diff --git a/tests/Io/AbstractMessageTest.php b/tests/Io/AbstractMessageTest.php new file mode 100644 index 00000000..9e2c7d32 --- /dev/null +++ b/tests/Io/AbstractMessageTest.php @@ -0,0 +1,222 @@ + $headers + * @param StreamInterface $body + */ + public function __construct($protocolVersion, array $headers, StreamInterface $body) + { + return parent::__construct($protocolVersion, $headers, $body); + } +} + +class AbstractMessageTest extends TestCase +{ + public function testWithProtocolVersionReturnsNewInstanceWhenProtocolVersionIsChanged() + { + $message = new MessageMock( + '1.1', + array(), + $this->getMockBuilder('Psr\Http\Message\StreamInterface')->getMock() + ); + + $new = $message->withProtocolVersion('1.0'); + $this->assertNotSame($message, $new); + $this->assertEquals('1.0', $new->getProtocolVersion()); + $this->assertEquals('1.1', $message->getProtocolVersion()); + } + + public function testWithProtocolVersionReturnsSameInstanceWhenProtocolVersionIsUnchanged() + { + $message = new MessageMock( + '1.1', + array(), + $this->getMockBuilder('Psr\Http\Message\StreamInterface')->getMock() + ); + + $new = $message->withProtocolVersion('1.1'); + $this->assertSame($message, $new); + $this->assertEquals('1.1', $message->getProtocolVersion()); + } + + public function testHeaderWithStringValue() + { + $message = new MessageMock( + '1.1', + array( + 'Content-Type' => 'text/plain' + ), + $this->getMockBuilder('Psr\Http\Message\StreamInterface')->getMock() + ); + + $this->assertEquals(array('Content-Type' => array('text/plain')), $message->getHeaders()); + + $this->assertEquals(array('text/plain'), $message->getHeader('Content-Type')); + $this->assertEquals(array('text/plain'), $message->getHeader('CONTENT-type')); + + $this->assertEquals('text/plain', $message->getHeaderLine('Content-Type')); + $this->assertEquals('text/plain', $message->getHeaderLine('CONTENT-Type')); + + $this->assertTrue($message->hasHeader('Content-Type')); + $this->assertTrue($message->hasHeader('content-TYPE')); + + $new = $message->withHeader('Content-Type', 'text/plain'); + $this->assertSame($message, $new); + + $new = $message->withHeader('Content-Type', array('text/plain')); + $this->assertSame($message, $new); + + $new = $message->withHeader('content-type', 'text/plain'); + $this->assertNotSame($message, $new); + $this->assertEquals(array('content-type' => array('text/plain')), $new->getHeaders()); + $this->assertEquals(array('Content-Type' => array('text/plain')), $message->getHeaders()); + + $new = $message->withHeader('Content-Type', 'text/html'); + $this->assertNotSame($message, $new); + $this->assertEquals(array('Content-Type' => array('text/html')), $new->getHeaders()); + $this->assertEquals(array('Content-Type' => array('text/plain')), $message->getHeaders()); + + $new = $message->withHeader('Content-Type', array('text/html')); + $this->assertNotSame($message, $new); + $this->assertEquals(array('Content-Type' => array('text/html')), $new->getHeaders()); + $this->assertEquals(array('Content-Type' => array('text/plain')), $message->getHeaders()); + + $new = $message->withAddedHeader('Content-Type', array()); + $this->assertSame($message, $new); + + $new = $message->withoutHeader('Content-Type'); + $this->assertNotSame($message, $new); + $this->assertEquals(array(), $new->getHeaders()); + $this->assertEquals(array('Content-Type' => array('text/plain')), $message->getHeaders()); + } + + public function testHeaderWithMultipleValues() + { + $message = new MessageMock( + '1.1', + array( + 'Set-Cookie' => array( + 'a=1', + 'b=2' + ) + ), + $this->getMockBuilder('Psr\Http\Message\StreamInterface')->getMock() + ); + + $this->assertEquals(array('Set-Cookie' => array('a=1', 'b=2')), $message->getHeaders()); + + $this->assertEquals(array('a=1', 'b=2'), $message->getHeader('Set-Cookie')); + $this->assertEquals(array('a=1', 'b=2'), $message->getHeader('Set-Cookie')); + + $this->assertEquals('a=1, b=2', $message->getHeaderLine('Set-Cookie')); + $this->assertEquals('a=1, b=2', $message->getHeaderLine('Set-Cookie')); + + $this->assertTrue($message->hasHeader('Set-Cookie')); + $this->assertTrue($message->hasHeader('Set-Cookie')); + + $new = $message->withHeader('Set-Cookie', array('a=1', 'b=2')); + $this->assertSame($message, $new); + + $new = $message->withHeader('Set-Cookie', array('a=1', 'b=2', 'c=3')); + $this->assertNotSame($message, $new); + $this->assertEquals(array('Set-Cookie' => array('a=1', 'b=2', 'c=3')), $new->getHeaders()); + $this->assertEquals(array('Set-Cookie' => array('a=1', 'b=2')), $message->getHeaders()); + + $new = $message->withAddedHeader('Set-Cookie', array()); + $this->assertSame($message, $new); + + $new = $message->withAddedHeader('Set-Cookie', 'c=3'); + $this->assertNotSame($message, $new); + $this->assertEquals(array('Set-Cookie' => array('a=1', 'b=2', 'c=3')), $new->getHeaders()); + $this->assertEquals(array('Set-Cookie' => array('a=1', 'b=2')), $message->getHeaders()); + + $new = $message->withAddedHeader('Set-Cookie', array('c=3')); + $this->assertNotSame($message, $new); + $this->assertEquals(array('Set-Cookie' => array('a=1', 'b=2', 'c=3')), $new->getHeaders()); + $this->assertEquals(array('Set-Cookie' => array('a=1', 'b=2')), $message->getHeaders()); + } + + public function testHeaderWithEmptyValue() + { + $message = new MessageMock( + '1.1', + array( + 'Content-Type' => array() + ), + $this->getMockBuilder('Psr\Http\Message\StreamInterface')->getMock() + ); + + $this->assertEquals(array(), $message->getHeaders()); + + $this->assertEquals(array(), $message->getHeader('Content-Type')); + $this->assertEquals('', $message->getHeaderLine('Content-Type')); + $this->assertFalse($message->hasHeader('Content-Type')); + + $new = $message->withHeader('Empty', array()); + $this->assertSame($message, $new); + $this->assertFalse($new->hasHeader('Empty')); + + $new = $message->withAddedHeader('Empty', array()); + $this->assertSame($message, $new); + $this->assertFalse($new->hasHeader('Empty')); + + $new = $message->withoutHeader('Empty'); + $this->assertSame($message, $new); + $this->assertFalse($new->hasHeader('Empty')); + } + + public function testHeaderWithMultipleValuesAcrossMixedCaseNamesInConstructorMergesAllValuesWithNameFromLastNonEmptyValue() + { + $message = new MessageMock( + '1.1', + array( + 'SET-Cookie' => 'a=1', + 'set-cookie' => array('b=2'), + 'set-COOKIE' => array() + ), + $this->getMockBuilder('Psr\Http\Message\StreamInterface')->getMock() + ); + + $this->assertEquals(array('set-cookie' => array('a=1', 'b=2')), $message->getHeaders()); + $this->assertEquals(array('a=1', 'b=2'), $message->getHeader('Set-Cookie')); + } + + public function testWithBodyReturnsNewInstanceWhenBodyIsChanged() + { + $body = $this->getMockBuilder('Psr\Http\Message\StreamInterface')->getMock(); + $message = new MessageMock( + '1.1', + array(), + $body + ); + + $body2 = $this->getMockBuilder('Psr\Http\Message\StreamInterface')->getMock(); + $new = $message->withBody($body2); + $this->assertNotSame($message, $new); + $this->assertSame($body2, $new->getBody()); + $this->assertSame($body, $message->getBody()); + } + + public function testWithBodyReturnsSameInstanceWhenBodyIsUnchanged() + { + $body = $this->getMockBuilder('Psr\Http\Message\StreamInterface')->getMock(); + $message = new MessageMock( + '1.1', + array(), + $body + ); + + $new = $message->withBody($body); + $this->assertSame($message, $new); + $this->assertEquals($body, $message->getBody()); + } +} diff --git a/tests/Io/MiddlewareRunnerTest.php b/tests/Io/MiddlewareRunnerTest.php index ac836f03..762d7bdb 100644 --- a/tests/Io/MiddlewareRunnerTest.php +++ b/tests/Io/MiddlewareRunnerTest.php @@ -6,12 +6,12 @@ use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use React\Http\Io\MiddlewareRunner; +use React\Http\Message\Response; use React\Http\Message\ServerRequest; use React\Promise; use React\Promise\PromiseInterface; use React\Tests\Http\Middleware\ProcessStack; use React\Tests\Http\TestCase; -use RingCentral\Psr7\Response; final class MiddlewareRunnerTest extends TestCase { diff --git a/tests/Message/ResponseExceptionTest.php b/tests/Message/ResponseExceptionTest.php index 33eeea9e..b2eaccd3 100644 --- a/tests/Message/ResponseExceptionTest.php +++ b/tests/Message/ResponseExceptionTest.php @@ -2,9 +2,9 @@ namespace React\Tests\Http\Message; +use React\Http\Message\Response; use React\Http\Message\ResponseException; use PHPUnit\Framework\TestCase; -use RingCentral\Psr7\Response; class ResponseExceptionTest extends TestCase {