diff --git a/composer.json b/composer.json index 738d3ecf..0cbffd6d 100644 --- a/composer.json +++ b/composer.json @@ -35,6 +35,7 @@ "ext-dom": "*", "ext-libxml": "*", "http-interop/http-factory-tests": "^0.5.0", + "mikey179/vfsstream": "^1.6", "php-http/psr7-integration-tests": "dev-master", "phpunit/phpunit": "^7.5.18", "zendframework/zend-coding-standard": "~1.0.0" diff --git a/composer.lock b/composer.lock index 1e073ae8..a826a5de 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "1bac61830d3bc9c8234592861c735f58", + "content-hash": "5b844a9db9c1c0aa3478cae9cd0ec7e9", "packages": [ { "name": "psr/http-factory", @@ -210,6 +210,52 @@ ], "time": "2018-07-31T18:30:29+00:00" }, + { + "name": "mikey179/vfsstream", + "version": "v1.6.8", + "source": { + "type": "git", + "url": "https://github.com/bovigo/vfsStream.git", + "reference": "231c73783ebb7dd9ec77916c10037eff5a2b6efe" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/bovigo/vfsStream/zipball/231c73783ebb7dd9ec77916c10037eff5a2b6efe", + "reference": "231c73783ebb7dd9ec77916c10037eff5a2b6efe", + "shasum": "" + }, + "require": { + "php": ">=5.3.0" + }, + "require-dev": { + "phpunit/phpunit": "^4.5|^5.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.6.x-dev" + } + }, + "autoload": { + "psr-0": { + "org\\bovigo\\vfs\\": "src/main/php" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Frank Kleine", + "homepage": "http://frankkleine.de/", + "role": "Developer" + } + ], + "description": "Virtual file system to mock the real file system in unit tests.", + "homepage": "http://vfs.bovigo.org/", + "time": "2019-10-30T15:31:00+00:00" + }, { "name": "myclabs/deep-copy", "version": "1.9.3", diff --git a/docs/book/v2/custom-responses.md b/docs/book/v2/custom-responses.md index 38bf4ef3..e1f93574 100644 --- a/docs/book/v2/custom-responses.md +++ b/docs/book/v2/custom-responses.md @@ -132,6 +132,34 @@ $response = new Zend\Diactoros\Response\JsonResponse( ); ``` +## CSV Responses + +`Zend\Diactoros\Response\CsvResponse` creates a plain text response. It sets the +`Content-Type` header to `text/csv` by default: + +```php +$csvContent = << ['zend-diactoros']] +); + ## Empty Responses Many API actions allow returning empty responses: diff --git a/src/Response/CsvResponse.php b/src/Response/CsvResponse.php new file mode 100644 index 00000000..7e89350c --- /dev/null +++ b/src/Response/CsvResponse.php @@ -0,0 +1,84 @@ +prepareDownloadHeaders($filename, $headers); + } + + parent::__construct( + $this->createBody($text), + $status, + $this->injectContentType('text/csv; charset=utf-8', $headers) + ); + } + + /** + * Create the CSV message body. + * + * @param string|StreamInterface $text + * @return StreamInterface + * @throws Exception\InvalidArgumentException if $text is neither a string or stream. + */ + private function createBody($text) : StreamInterface + { + if ($text instanceof StreamInterface) { + return $text; + } + + if (! is_string($text)) { + throw new Exception\InvalidArgumentException(sprintf( + 'Invalid CSV content (%s) provided to %s', + (is_object($text) ? get_class($text) : gettype($text)), + __CLASS__ + )); + } + + $body = new Stream('php://temp', 'wb+'); + $body->write($text); + $body->rewind(); + return $body; + } +} diff --git a/src/Response/DownloadResponse.php b/src/Response/DownloadResponse.php new file mode 100644 index 00000000..b1f69800 --- /dev/null +++ b/src/Response/DownloadResponse.php @@ -0,0 +1,79 @@ +filename = $filename; + $this->contentType = $contentType; + + parent::__construct( + $this->createBody($body), + $status, + $this->prepareDownloadHeaders($headers) + ); + } + + /** + * @param string|StreamInterface $content + * @return StreamInterface + * @throws InvalidArgumentException if $body is neither a string nor a Stream + */ + private function createBody($content): StreamInterface + { + if ($content instanceof StreamInterface) { + return $content; + } + + if (!is_string($content)) { + throw new InvalidArgumentException(sprintf( + 'Invalid content (%s) provided to %s', + (is_object($content) ? get_class($content) : gettype($content)), + __CLASS__ + )); + } + + $body = new Stream('php://temp', 'wb+'); + $body->write($content); + $body->rewind(); + return $body; + } +} diff --git a/src/Response/DownloadResponseTrait.php b/src/Response/DownloadResponseTrait.php new file mode 100644 index 00000000..da738d55 --- /dev/null +++ b/src/Response/DownloadResponseTrait.php @@ -0,0 +1,113 @@ +overridesDownloadHeaders($this->downloadResponseHeaders, $headers)) { + throw new InvalidArgumentException( + sprintf( + 'Cannot override download headers (%s) when download response is being sent', + implode(', ', $this->downloadResponseHeaders) + ) + ); + } + + return array_merge( + $headers, + $this->getDownloadHeaders(), + [ + 'content-disposition' => sprintf('attachment; filename=%s', $this->filename), + 'content-type' => $this->contentType, + ] + ); + } +} diff --git a/test/Response/CsvResponseTest.php b/test/Response/CsvResponseTest.php new file mode 100644 index 00000000..276468a3 --- /dev/null +++ b/test/Response/CsvResponseTest.php @@ -0,0 +1,103 @@ +assertSame(self::VALID_CSV_BODY, (string) $response->getBody()); + $this->assertSame(200, $response->getStatusCode()); + } + + public function testConstructorAllowsPassingStatus() + { + $status = 404; + + $response = new CsvResponse(self::VALID_CSV_BODY, $status); + $this->assertSame(404, $response->getStatusCode()); + $this->assertSame(self::VALID_CSV_BODY, (string) $response->getBody()); + } + + public function testConstructorAllowsPassingHeaders() + { + $status = 404; + $headers = [ + 'x-custom' => [ 'foo-bar' ], + ]; + $filename = ''; + + $response = new CsvResponse(self::VALID_CSV_BODY, $status, $filename, $headers); + $this->assertSame(['foo-bar'], $response->getHeader('x-custom')); + $this->assertSame('text/csv; charset=utf-8', $response->getHeaderLine('content-type')); + $this->assertSame(404, $response->getStatusCode()); + $this->assertSame(self::VALID_CSV_BODY, (string) $response->getBody()); + } + + public function testAllowsStreamsForResponseBody() + { + $stream = $this->prophesize(StreamInterface::class); + $body = $stream->reveal(); + $response = new CsvResponse($body); + $this->assertSame($body, $response->getBody()); + } + + public function invalidContent() + { + return [ + 'null' => [null], + 'true' => [true], + 'false' => [false], + 'zero' => [0], + 'int' => [1], + 'zero-float' => [0.0], + 'float' => [1.1], + 'array' => [['php://temp']], + 'object' => [(object) ['php://temp']], + ]; + } + + /** + * @dataProvider invalidContent + */ + public function testRaisesExceptionforNonStringNonStreamBodyContent($body) + { + $this->expectException(InvalidArgumentException::class); + + new CsvResponse($body); + } + + /** + * @group 115 + */ + public function testConstructorRewindsBodyStream() + { + $response = new CsvResponse(self::VALID_CSV_BODY); + + $actual = $response->getBody()->getContents(); + $this->assertSame(self::VALID_CSV_BODY, $actual); + } +} diff --git a/test/Response/DownloadResponseTest.php b/test/Response/DownloadResponseTest.php new file mode 100644 index 00000000..f8ab3fe0 --- /dev/null +++ b/test/Response/DownloadResponseTest.php @@ -0,0 +1,123 @@ + [ + 'empty.csv' => "", + 'valid.csv' => $validCSVString, + 'non-readable-file.csv' => vfsStream::newFile('non-readable-file.csv', 0000) + ] + ]; + $this->root = vfsStream::setup('root', null, $directoryStructure); + } + + public function testCanCreateResponseFromString() + { + $body = file_get_contents($this->root->url() . '/files/valid.csv'); + $response = new DownloadResponse($body); + $this->assertInstanceOf(Response::class, $response); + $this->assertEquals($body, (string) $response->getBody()); + $this->assertEquals(619, $response->getBody()->getSize()); + $this->assertHasValidResponseHeaders($response); + $this->assertSame('attachment; filename=download', $response->getHeaderLine('content-disposition')); + } + + public function testCanCreateResponseFromFilename() + { + $body = new Stream($this->root->url() . '/files/valid.csv'); + $response = new DownloadResponse($body); + $this->assertInstanceOf(Response::class, $response); + $this->assertEquals( + file_get_contents($this->root->url() . '/files/valid.csv'), + (string) $response->getBody() + ); + $this->assertHasValidResponseHeaders($response); + $this->assertSame('attachment; filename=download', $response->getHeaderLine('content-disposition')); + } + + public function testCanSendResponseWithCustomFilename() + { + $body = new Stream($this->root->url() . '/files/valid.csv'); + $response = new DownloadResponse($body, 200, 'valid.csv'); + $this->assertInstanceOf(Response::class, $response); + $this->assertEquals( + file_get_contents($this->root->url() . '/files/valid.csv'), + (string) $response->getBody() + ); + $this->assertHasValidResponseHeaders($response, 'valid.csv'); + $this->assertSame('attachment; filename=valid.csv', $response->getHeaderLine('content-disposition')); + } + + public function testCanSendResponseWithCustomContentType() + { + $body = new Stream($this->root->url() . '/files/valid.csv'); + $response = new DownloadResponse($body, 200, 'valid.csv', 'text/csv'); + $this->assertInstanceOf(Response::class, $response); + $this->assertEquals( + file_get_contents($this->root->url() . '/files/valid.csv'), + (string) $response->getBody() + ); + $this->assertHasValidResponseHeaders($response, 'valid.csv', 'text/csv'); + $this->assertSame('attachment; filename=valid.csv', $response->getHeaderLine('content-disposition')); + } + + /** + * @param DownloadResponse $response + * @param string $filename + * @param string $contentType + */ + private function assertHasValidResponseHeaders( + DownloadResponse $response, + $filename = 'download', + $contentType = 'application/octet-stream' + ) : void { + $requiredHeaders = [ + 'cache-control' => 'must-revalidate', + 'content-description' => 'File Transfer', + 'content-disposition' => sprintf('attachment; filename=%s', $filename), + 'content-transfer-encoding' => 'Binary', + 'content-type' => $contentType, + 'expires' => '0', + 'pragma' => 'Public' + ]; + foreach ($requiredHeaders as $header => $value) { + $this->assertTrue($response->hasHeader($header)); + $this->assertSame($value, $response->getHeaderLine($header)); + } + } +}