From f3f05f6cc4bef3338dbfb6eb340da4fb1c5ba1e1 Mon Sep 17 00:00:00 2001 From: Igor Scheller Date: Sat, 11 Aug 2018 15:05:55 +0200 Subject: [PATCH] Make Engelsystem\Http\Response PSR-7 compatible --- src/Http/MessageTrait.php | 235 ++++++++++++++++++ src/Http/Psr7ServiceProvider.php | 3 +- src/Http/Response.php | 68 ++++- src/helpers.php | 16 +- tests/Unit/HelpersTest.php | 28 +++ tests/Unit/Http/MessageTraitTest.php | 159 ++++++++++++ tests/Unit/Http/Psr7ServiceProviderTest.php | 4 - tests/Unit/Http/ResponseTest.php | 30 +++ .../Http/Stub/MessageTraitImplementation.php | 12 + 9 files changed, 537 insertions(+), 18 deletions(-) create mode 100644 src/Http/MessageTrait.php create mode 100644 tests/Unit/Http/MessageTraitTest.php create mode 100644 tests/Unit/Http/Stub/MessageTraitImplementation.php diff --git a/src/Http/MessageTrait.php b/src/Http/MessageTrait.php new file mode 100644 index 00000000..fa3a1459 --- /dev/null +++ b/src/Http/MessageTrait.php @@ -0,0 +1,235 @@ +setProtocolVersion($version); + return $new; + } + + /** + * Retrieves all message header values. + * + * The keys represent the header name as it will be sent over the wire, and + * each value is an array of strings associated with the header. + * + * // Represent the headers as a string + * foreach ($message->getHeaders() as $name => $values) { + * echo $name . ": " . implode(", ", $values); + * } + * + * // Emit headers iteratively: + * foreach ($message->getHeaders() as $name => $values) { + * foreach ($values as $value) { + * header(sprintf('%s: %s', $name, $value), false); + * } + * } + * + * While header names are not case-sensitive, getHeaders() will preserve the + * exact case in which headers were originally specified. + * + * @return string[][] Returns an associative array of the message's headers. Each + * key MUST be a header name, and each value MUST be an array of strings + * for that header. + */ + public function getHeaders() + { + return $this->headers->allPreserveCase(); + } + + /** + * Checks if a header exists by the given case-insensitive name. + * + * @param string $name Case-insensitive header field name. + * @return bool Returns true if any header names match the given header + * name using a case-insensitive string comparison. Returns false if + * no matching header name is found in the message. + */ + public function hasHeader($name) + { + return $this->headers->has($name); + } + + /** + * Retrieves a message header value by the given case-insensitive name. + * + * This method returns an array of all the header values of the given + * case-insensitive header name. + * + * If the header does not appear in the message, this method MUST return an + * empty array. + * + * @param string $name Case-insensitive header field name. + * @return string[] An array of string values as provided for the given + * header. If the header does not appear in the message, this method MUST + * return an empty array. + */ + public function getHeader($name) + { + return $this->headers->get($name, null, false); + } + + /** + * Retrieves a comma-separated string of the values for a single header. + * + * This method returns all of the header values of the given + * case-insensitive header name as a string concatenated together using + * a comma. + * + * NOTE: Not all header values may be appropriately represented using + * comma concatenation. For such headers, use getHeader() instead + * and supply your own delimiter when concatenating. + * + * If the header does not appear in the message, this method MUST return + * an empty string. + * + * @param string $name Case-insensitive header field name. + * @return string A string of values as provided for the given header + * concatenated together using a comma. If the header does not appear in + * the message, this method MUST return an empty string. + */ + public function getHeaderLine($name) + { + return implode(',', $this->getHeader($name)); + } + + /** + * Return an instance with the provided value replacing the specified header. + * + * While header names are case-insensitive, the casing of the header will + * be preserved by this function, and returned from getHeaders(). + * + * This method MUST be implemented in such a way as to retain the + * immutability of the message, and MUST return an instance that has the + * new and/or updated header and value. + * + * @param string $name Case-insensitive header field name. + * @param string|string[] $value Header value(s). + * @return static + * @throws \InvalidArgumentException for invalid header names or values. + */ + public function withHeader($name, $value) + { + $new = clone $this; + $new->headers->set($name, $value); + + return $new; + } + + /** + * Return an instance with the specified header appended with the given value. + * + * Existing values for the specified header will be maintained. The new + * value(s) will be appended to the existing list. If the header did not + * exist previously, it will be added. + * + * This method MUST be implemented in such a way as to retain the + * immutability of the message, and MUST return an instance that has the + * new header and/or value. + * + * @param string $name Case-insensitive header field name to add. + * @param string|string[] $value Header value(s). + * @return static + * @throws \InvalidArgumentException for invalid header names or values. + */ + public function withAddedHeader($name, $value) + { + $new = clone $this; + $new->headers->set($name, $value, false); + + return $new; + } + + /** + * Return an instance without the specified header. + * + * Header resolution MUST be done without case-sensitivity. + * + * This method MUST be implemented in such a way as to retain the + * immutability of the message, and MUST return an instance that removes + * the named header. + * + * @param string $name Case-insensitive header field name to remove. + * @return static + */ + public function withoutHeader($name) + { + $new = clone $this; + $new->headers->remove($name); + + return $new; + } + + /** + * Gets the body of the message. + * + * @return StreamInterface Returns the body as a stream. + */ + public function getBody() + { + $stream = new Stream('php://memory', 'wb+'); + $stream->write($this->getContent()); + $stream->rewind(); + + return $stream; + } + + /** + * Return an instance with the specified message body. + * + * The body MUST be a StreamInterface object. + * + * This method MUST be implemented in such a way as to retain the + * immutability of the message, and MUST return a new instance that has the + * new body stream. + * + * @param StreamInterface $body Body. + * @return static + * @throws \InvalidArgumentException When the body is not valid. + */ + public function withBody(StreamInterface $body) + { + $new = clone $this; + $new->setContent($body); + + return $new; + } +} diff --git a/src/Http/Psr7ServiceProvider.php b/src/Http/Psr7ServiceProvider.php index ff7c13ee..4a3c6583 100644 --- a/src/Http/Psr7ServiceProvider.php +++ b/src/Http/Psr7ServiceProvider.php @@ -24,8 +24,7 @@ class Psr7ServiceProvider extends ServiceProvider /** @var Response $response */ $response = $this->app->get('response'); - $psr7response = $psr7Factory->createResponse($response); - $this->app->instance('psr7.response', $psr7response); + $this->app->instance('psr7.response', $response); $this->app->bind(ResponseInterface::class, 'psr7.response'); } } diff --git a/src/Http/Response.php b/src/Http/Response.php index 70698fd5..9db6fa83 100644 --- a/src/Http/Response.php +++ b/src/Http/Response.php @@ -2,8 +2,74 @@ namespace Engelsystem\Http; +use Psr\Http\Message\ResponseInterface; use Symfony\Component\HttpFoundation\Response as SymfonyResponse; -class Response extends SymfonyResponse +class Response extends SymfonyResponse implements ResponseInterface { + use MessageTrait; + + /** + * Return an instance with the specified status code and, optionally, reason phrase. + * + * If no reason phrase is specified, implementations MAY choose to default + * to the RFC 7231 or IANA recommended reason phrase for the response's + * status code. + * + * This method MUST be implemented in such a way as to retain the + * immutability of the message, and MUST return an instance that has the + * updated status and reason phrase. + * + * @link http://tools.ietf.org/html/rfc7231#section-6 + * @link http://www.iana.org/assignments/http-status-codes/http-status-codes.xhtml + * @param int $code The 3-digit integer result code to set. + * @param string $reasonPhrase The reason phrase to use with the + * provided status code; if none is provided, implementations MAY + * use the defaults as suggested in the HTTP specification. + * @return static + * @throws \InvalidArgumentException For invalid status code arguments. + */ + public function withStatus($code, $reasonPhrase = '') + { + $new = clone $this; + $new->setStatusCode($code, !empty($reasonPhrase) ? $reasonPhrase : null); + + return $new; + } + + /** + * Gets the response reason phrase associated with the status code. + * + * Because a reason phrase is not a required element in a response + * status line, the reason phrase value MAY be null. Implementations MAY + * choose to return the default RFC 7231 recommended reason phrase (or those + * listed in the IANA HTTP Status Code Registry) for the response's + * status code. + * + * @link http://tools.ietf.org/html/rfc7231#section-6 + * @link http://www.iana.org/assignments/http-status-codes/http-status-codes.xhtml + * @return string Reason phrase; must return an empty string if none present. + */ + public function getReasonPhrase() + { + return $this->statusText; + } + + /** + * Return an instance with the specified content. + * + * This method MUST be implemented in such a way as to retain the + * immutability of the message, and MUST return an instance that has the + * updated status and reason phrase. + * + * @param mixed $content Content that can be cast to string + * @return static + */ + public function withContent($content) + { + $new = clone $this; + $new->setContent($content); + + return $new; + } } diff --git a/src/helpers.php b/src/helpers.php index 2a90dcde..01fb10bd 100644 --- a/src/helpers.php +++ b/src/helpers.php @@ -4,11 +4,10 @@ use Engelsystem\Application; use Engelsystem\Config\Config; use Engelsystem\Http\Request; +use Engelsystem\Http\Response; use Engelsystem\Renderer\Renderer; use Engelsystem\Routing\UrlGenerator; -use Psr\Http\Message\ResponseInterface; use Symfony\Component\HttpFoundation\Session\SessionInterface; -use Zend\Diactoros\Stream; /** * Get the global app instance @@ -86,21 +85,16 @@ function request($key = null, $default = null) * @param string $content * @param int $status * @param array $headers - * @return ResponseInterface + * @return Response */ function response($content = '', $status = 200, $headers = []) { - /** @var ResponseInterface $response */ + /** @var Response $response */ $response = app('psr7.response'); - - /** @var Stream $stream */ - $stream = app()->make(Stream::class, ['stream' => 'php://memory', 'mode' => 'wb+']); - $stream->write($content); - $stream->rewind(); - $response = $response - ->withBody($stream) + ->withContent($content) ->withStatus($status); + foreach ($headers as $key => $value) { $response = $response->withAddedHeader($key, $value); } diff --git a/tests/Unit/HelpersTest.php b/tests/Unit/HelpersTest.php index 43c29c84..82030169 100644 --- a/tests/Unit/HelpersTest.php +++ b/tests/Unit/HelpersTest.php @@ -6,6 +6,7 @@ use Engelsystem\Application; use Engelsystem\Config\Config; use Engelsystem\Container\Container; use Engelsystem\Http\Request; +use Engelsystem\Http\Response; use Engelsystem\Renderer\Renderer; use Engelsystem\Routing\UrlGenerator; use PHPUnit\Framework\TestCase; @@ -126,6 +127,33 @@ class HelpersTest extends TestCase $this->assertEquals('requestValue', request('requestKey')); } + /** + * @covers \response + */ + public function testResponse() + { + /** @var MockObject|Response $response */ + $response = $this->getMockBuilder(Response::class)->getMock(); + $this->getAppMock('psr7.response', $response); + + $response->expects($this->once()) + ->method('withContent') + ->with('Lorem Ipsum?') + ->willReturn($response); + + $response->expects($this->once()) + ->method('withStatus') + ->with(501) + ->willReturn($response); + + $response->expects($this->exactly(2)) + ->method('withAddedHeader') + ->withConsecutive(['lor', 'em'], ['foo', 'bar']) + ->willReturn($response); + + $this->assertEquals($response, response('Lorem Ipsum?', 501, ['lor' => 'em', 'foo' => 'bar',])); + } + /** * @covers \session */ diff --git a/tests/Unit/Http/MessageTraitTest.php b/tests/Unit/Http/MessageTraitTest.php new file mode 100644 index 00000000..46076a67 --- /dev/null +++ b/tests/Unit/Http/MessageTraitTest.php @@ -0,0 +1,159 @@ +assertInstanceOf(MessageInterface::class, $message); + $this->assertInstanceOf(SymfonyResponse::class, $message); + } + + /** + * @covers \Engelsystem\Http\MessageTrait::getProtocolVersion + * @covers \Engelsystem\Http\MessageTrait::withProtocolVersion + */ + public function testGetProtocolVersion() + { + $message = new MessageTraitImplementation(); + $newMessage = $message->withProtocolVersion('0.1'); + $this->assertNotEquals($message, $newMessage); + $this->assertEquals('0.1', $newMessage->getProtocolVersion()); + } + + /** + * @covers \Engelsystem\Http\MessageTrait::getHeaders + */ + public function testGetHeaders() + { + $message = new MessageTraitImplementation(); + $newMessage = $message->withHeader('Foo', 'bar'); + + $this->assertNotEquals($message, $newMessage); + $this->assertArraySubset(['Foo' => ['bar']], $newMessage->getHeaders()); + + $newMessage = $message->withHeader('lorem', ['ipsum', 'dolor']); + $this->assertArraySubset(['lorem' => ['ipsum', 'dolor']], $newMessage->getHeaders()); + } + + /** + * @covers \Engelsystem\Http\MessageTrait::hasHeader + */ + public function testHasHeader() + { + $message = new MessageTraitImplementation(); + $this->assertFalse($message->hasHeader('test')); + + $newMessage = $message->withHeader('test', '12345'); + $this->assertTrue($newMessage->hasHeader('Test')); + $this->assertTrue($newMessage->hasHeader('test')); + } + + /** + * @covers \Engelsystem\Http\MessageTrait::getHeader + */ + public function testGetHeader() + { + $message = new MessageTraitImplementation(); + $newMessage = $message->withHeader('foo', 'bar'); + + $this->assertEquals(['bar'], $newMessage->getHeader('Foo')); + $this->assertEquals([], $newMessage->getHeader('LoremIpsum')); + } + + /** + * @covers \Engelsystem\Http\MessageTrait::getHeaderLine + */ + public function testGetHeaderLine() + { + $message = new MessageTraitImplementation(); + $newMessage = $message->withHeader('foo', ['bar', 'bla']); + + $this->assertEquals('', $newMessage->getHeaderLine('Lorem-Ipsum')); + $this->assertEquals('bar,bla', $newMessage->getHeaderLine('Foo')); + } + + /** + * @covers \Engelsystem\Http\MessageTrait::withHeader + */ + public function testWithHeader() + { + $message = new MessageTraitImplementation(); + $newMessage = $message->withHeader('foo', 'bar'); + + $this->assertNotEquals($message, $newMessage); + $this->assertArraySubset(['foo' => ['bar']], $newMessage->getHeaders()); + + $newMessage = $newMessage->withHeader('Foo', ['lorem', 'ipsum']); + $this->assertArraySubset(['Foo' => ['lorem', 'ipsum']], $newMessage->getHeaders()); + } + + /** + * @covers \Engelsystem\Http\MessageTrait::withAddedHeader + */ + public function testWithAddedHeader() + { + $message = new MessageTraitImplementation(); + $newMessage = $message->withHeader('foo', 'bar'); + + $this->assertNotEquals($message, $newMessage); + $this->assertArraySubset(['foo' => ['bar']], $newMessage->getHeaders()); + + $newMessage = $newMessage->withAddedHeader('Foo', ['lorem', 'ipsum']); + $this->assertArraySubset(['Foo' => ['bar', 'lorem', 'ipsum']], $newMessage->getHeaders()); + } + + /** + * @covers \Engelsystem\Http\MessageTrait::withoutHeader + */ + public function testWithoutHeader() + { + $message = (new MessageTraitImplementation())->withHeader('foo', 'bar'); + $this->assertTrue($message->hasHeader('foo')); + + $newMessage = $message->withoutHeader('Foo'); + $this->assertNotEquals($message, $newMessage); + $this->assertFalse($newMessage->hasHeader('foo')); + } + + /** + * @covers \Engelsystem\Http\MessageTrait::getBody + */ + public function testGetBody() + { + $message = (new MessageTraitImplementation())->setContent('Foo bar!'); + $body = $message->getBody(); + + $this->assertInstanceOf(StreamInterface::class, $body); + $this->assertEquals('Foo bar!', $body->getContents()); + } + + /** + * @covers \Engelsystem\Http\MessageTrait::withBody + */ + public function testWithBody() + { + /** @var Stream $stream */ + $stream = new Stream('php://memory', 'wb+'); + $stream->write('Test content'); + $stream->rewind(); + + $message = new MessageTraitImplementation(); + $newMessage = $message->withBody($stream); + + $this->assertNotEquals($message, $newMessage); + $this->assertEquals('Test content', $newMessage->getContent()); + } +} diff --git a/tests/Unit/Http/Psr7ServiceProviderTest.php b/tests/Unit/Http/Psr7ServiceProviderTest.php index b4c3c042..a09e9572 100644 --- a/tests/Unit/Http/Psr7ServiceProviderTest.php +++ b/tests/Unit/Http/Psr7ServiceProviderTest.php @@ -54,10 +54,6 @@ class Psr7ServiceProviderTest extends ServiceProviderTest ->method('createRequest') ->with($request) ->willReturn($psr7request); - $psr7Factory->expects($this->once()) - ->method('createResponse') - ->with($response) - ->willReturn($psr7response); $serviceProvider = new Psr7ServiceProvider($app); $serviceProvider->register(); diff --git a/tests/Unit/Http/ResponseTest.php b/tests/Unit/Http/ResponseTest.php index 6bedf5c1..f6c24767 100644 --- a/tests/Unit/Http/ResponseTest.php +++ b/tests/Unit/Http/ResponseTest.php @@ -4,6 +4,7 @@ namespace Engelsystem\Test\Unit\Http; use Engelsystem\Http\Response; use PHPUnit\Framework\TestCase; +use Psr\Http\Message\ResponseInterface; use Symfony\Component\HttpFoundation\Response as SymfonyResponse; class ResponseTest extends TestCase @@ -15,5 +16,34 @@ class ResponseTest extends TestCase { $response = new Response(); $this->assertInstanceOf(SymfonyResponse::class, $response); + $this->assertInstanceOf(ResponseInterface::class, $response); + } + + /** + * @covers \Engelsystem\Http\Response::withStatus + * @covers \Engelsystem\Http\Response::getReasonPhrase + */ + public function testWithStatus() + { + $response = new Response(); + $newResponse = $response->withStatus(503); + $this->assertNotEquals($response, $newResponse); + $this->assertNotEquals('', $newResponse->getReasonPhrase()); + $this->assertEquals(503, $newResponse->getStatusCode()); + + $newResponse = $response->withStatus(503, 'Foo'); + $this->assertEquals('Foo', $newResponse->getReasonPhrase()); + } + + /** + * @covers \Engelsystem\Http\Response::withContent + */ + public function testWithContent() + { + $response = new Response(); + $newResponse = $response->withContent('Lorem Ipsum?'); + + $this->assertNotEquals($response, $newResponse); + $this->assertEquals('Lorem Ipsum?', $newResponse->getContent()); } } diff --git a/tests/Unit/Http/Stub/MessageTraitImplementation.php b/tests/Unit/Http/Stub/MessageTraitImplementation.php new file mode 100644 index 00000000..d78fd0b2 --- /dev/null +++ b/tests/Unit/Http/Stub/MessageTraitImplementation.php @@ -0,0 +1,12 @@ +