diff --git a/CHANGELOG.md b/CHANGELOG.md index 14c0e60a..64626ed7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,17 +6,24 @@ All notable changes to this project will be documented in this file, in reverse ### Added -- [#52](https://github.com/zendframework/zend-diactoros/pull/52) adds - `Zend\Diactoros\Response\StringResponse`, a factory class for generating - HTML or JSON responses. It contains the static methods: - - `html($html, $status = 200, array $headers = [])` - - `json($data, $status = 200, array $headers = [])` -- [#58](https://github.com/zendframework/zend-diactoros/pull/58) adds - `Zend\Diactoros\Response\EmptyResponse`, a `Zend\Diactoros\Response` extension - for quickly creating empty, read-only responses. -- [#59](https://github.com/zendframework/zend-diactoros/pull/59) adds - `Zend\Diactoros\Response\RedirectResponse`, a `Zend\Diactoros\Response` extension - for quickly creating redirect responses. +- [#52](https://github.com/zendframework/zend-diactoros/pull/52), + [#58](https://github.com/zendframework/zend-diactoros/pull/58), + [#59](https://github.com/zendframework/zend-diactoros/pull/59), and + [#61](https://github.com/zendframework/zend-diactoros/pull/61) create several + custom response types for simplifying response creation: + + - `Zend\Diactoros\Response\HtmlResponse` accepts HTML content via its + constructor, and sets the `Content-Type` to `text/html`. + - `Zend\Diactoros\Response\JsonResponse` accepts data to serialize to JSON via + its constructor, and sets the `Content-Type` to `application/json`. + - `Zend\Diactoros\Response\EmptyResponse` allows creating empty, read-only + responses, with a default status code of 204. + - `Zend\Diactoros\Response\RedirectResponse` allows specifying a URI for the + `Location` header in the constructor, with a default status code of 302. + + Each also accepts an optional status code, and optional headers (which can + also be used to provide an alternate `Content-Type` in the case of the HTML + and JSON responses). ### Deprecated diff --git a/doc/book/custom-responses.md b/doc/book/custom-responses.md index a69414d1..b2bd8584 100644 --- a/doc/book/custom-responses.md +++ b/doc/book/custom-responses.md @@ -20,44 +20,63 @@ Some standard use cases, however, make this un-wieldy: - Returning a redirect response; in this case, you likely just want to specify the target for the `Location` header, and optionally the status code. -Starting with version 1.1, Diactoros offers several custom response types and factories for -simplifying these common tasks. +Starting with version 1.1, Diactoros offers several custom response types for simplifying these +common tasks. -## String responses +## HTML Responses -`Zend\Diactoros\Response\StringResponse` provides factory methods for two standard string response -types: HTML and JSON. - -### HTML - -The `html()` factory will create a response with the provided HTML as a payload, setting the +`Zend\Diactoros\Response\HtmlResponse` allows specifying HTML as a payload, and sets the `Content-Type` header to `text/html` by default: ```php -$response = StringResponse::html($htmlContent); +$response = new HtmlResponse($htmlContent); ``` -The factory allows passing two additional arguments: a status code, and an array of headers. These -allow you to further seed the initial state of the response. +The constructor allows passing two additional arguments: a status code, and an array of headers. +These allow you to further seed the initial state of the response, as well as to override the +`Content-Type` header if desired: + +```php +$response = new HtmlResponse($htmlContent, 200, [ 'Content-Type' => ['application/xhtml+xml']]); +``` Headers must be in the same format as you would provide to the [Response constructor][api.md#response-message]. -### JSON -The `json()` factory accepts a data structure to convert to JSON, and returns a response with the -JSON content and the `Content-Type` header set to `application/json`: +## JSON Responses + +`Zend\Diactoros\Response\JsonResponse` accepts a data structure to convert to JSON, and sets +the `Content-Type` header to `application/json`: ```php -$response = StringResponse::json($data); +$response = new JsonResponse($data); ``` If a null value is provide, an empty JSON object is used for the content. Scalar data is cast to an array before serialization. If providing an object, we recommend implementing [JsonSerializable](http://php.net/JsonSerializable) to ensure your object is correctly serialized. -Just like the `html()` factory, the `json()` factory allows passing two additional arguments — a +Just like the `HtmlResponse`, the `JsonResponse` allows passing two additional arguments — a status code, and an array of headers — to allow you to further seed the initial state of the -response. +response: + +```php +$response = new JsonResponse($data, 200, [ 'Content-Type' => ['application/hal+json']]); +``` + +Finally, `JsonResponse` allows a fourth optional argument, the flags to provide to `json_encode()`. +By default, these are set to `JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP | JSON_HEX_QUOT` (integer +15), providing [RFC 4627](http://tools.ietf.org/html/rfc4627) compliant JSON capable of embedding in +HTML. If you want to specify a different set of flags, use the fourth constructor argument: + +```php +$response = new JsonResponse( + $data, + 200, + [], + JSON_PRETTY_PRINT | JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP | JSON_HEX_QUOT +); +``` ## Empty Responses @@ -154,13 +173,20 @@ extensions; this is done to protect encapsulation and ensure consistency of oper instances. If you don't want to go the extension route (perhaps you don't want another `ResponseInterface` -implementation within your object graph) you can instead create a factory. -[StringResponse](https://github.com/zendframework/zend-diactoros/tree/master/src/Response/StringResponse.php) -provides one such example. We recommend the following semantics: +implementation within your object graph) you can instead create a factory. As an example: ```php -function ($dataOrMessage, $status = 200, array $headers = []); +$plainTextResponse = function ($text, $status = 200, array $headers = []) { + $response = new Response('php://temp', $status, $headers); + $response->getBody()->write($text); + if (! $response->hasHeader('Content-Type')) { + $response = $response->withHeader('Content-Type', 'text/plain'); + } + return $response; +}; + +$response = $plainTextResponse('Hello, world!'); ``` -These ensure consistency of factories, and allow consumers to provide the status and -instance-specific headers on creation. (Obviously, specify different defaults as necessary.) +We recommend following the semantic of providing the status and headers as the final two arguments +for any factory or custom response extensions. diff --git a/phpunit.xml.dist b/phpunit.xml.dist index e27fce61..6de09f62 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -4,4 +4,10 @@ ./test + + + + src + + diff --git a/src/Response/HtmlResponse.php b/src/Response/HtmlResponse.php new file mode 100644 index 00000000..d298ee01 --- /dev/null +++ b/src/Response/HtmlResponse.php @@ -0,0 +1,73 @@ +createBody($html), + $status, + $this->injectContentType('text/html', $headers) + ); + } + + /** + * Create the message body. + * + * @param string|StreamInterface $html + * @return StreamInterface + * @throws InvalidArgumentException if $html is neither a string or stream. + */ + private function createBody($html) + { + if ($html instanceof StreamInterface) { + return $html; + } + + if (! is_string($html)) { + throw new InvalidArgumentException(sprintf( + 'Invalid content (%s) provided to %s', + (is_object($html) ? get_class($html) : gettype($html)), + __CLASS__ + )); + } + + $body = new Stream('php://temp', 'wb+'); + $body->write($html); + return $body; + } +} diff --git a/src/Response/InjectContentTypeTrait.php b/src/Response/InjectContentTypeTrait.php new file mode 100644 index 00000000..00fd2171 --- /dev/null +++ b/src/Response/InjectContentTypeTrait.php @@ -0,0 +1,33 @@ +write($this->jsonEncode($data, $encodingOptions)); + + $headers = $this->injectContentType('application/json', $headers); + + parent::__construct($body, $status, $headers); + } + + /** + * Encode the provided data to JSON. + * + * @param mixed $data + * @param int $encodingOptions + * @return string + * @throws InvalidArgumentException if unable to encode the $data to JSON. + */ + private function jsonEncode($data, $encodingOptions) + { + if (is_resource($data)) { + throw new InvalidArgumentException('Cannot JSON encode resources'); + } + + if ($data === null) { + // Use an ArrayObject to force an empty JSON object. + $data = new ArrayObject(); + } + + if (is_scalar($data)) { + $data = (array) $data; + } + + // Clear json_last_error() + json_encode(null); + + $json = json_encode($data, $encodingOptions); + + if (JSON_ERROR_NONE !== json_last_error()) { + throw new InvalidArgumentException(sprintf( + 'Unable to encode data to JSON in %s: %s', + __CLASS__, + json_last_error_msg() + )); + } + + return $json; + } +} diff --git a/src/Response/RedirectResponse.php b/src/Response/RedirectResponse.php index 621736f9..26911f0a 100644 --- a/src/Response/RedirectResponse.php +++ b/src/Response/RedirectResponse.php @@ -41,7 +41,7 @@ public function __construct($uri, $status = 302, array $headers = []) )); } - $headers['location'] = [$uri]; + $headers['location'] = [(string) $uri]; parent::__construct('php://temp', $status, $headers); } } diff --git a/src/Response/StringResponse.php b/src/Response/StringResponse.php deleted file mode 100644 index db79eb44..00000000 --- a/src/Response/StringResponse.php +++ /dev/null @@ -1,94 +0,0 @@ -getBody()->write($body); - - if (empty($contentType) || $response->hasHeader('content-type')) { - return $response; - } - - return $response->withHeader('content-type', $contentType); - } -} diff --git a/test/Response/HtmlResponseTest.php b/test/Response/HtmlResponseTest.php new file mode 100644 index 00000000..d2e43e15 --- /dev/null +++ b/test/Response/HtmlResponseTest.php @@ -0,0 +1,82 @@ +Uh oh not found'; + + $response = new HtmlResponse($body); + $this->assertSame($body, (string) $response->getBody()); + $this->assertEquals(200, $response->getStatusCode()); + } + + public function testConstructorAllowsPassingStatus() + { + $body = 'Uh oh not found'; + $status = 404; + + $response = new HtmlResponse($body, $status); + $this->assertEquals(404, $response->getStatusCode()); + $this->assertSame($body, (string) $response->getBody()); + } + + public function testConstructorAllowsPassingHeaders() + { + $body = 'Uh oh not found'; + $status = 404; + $headers = [ + 'x-custom' => [ 'foo-bar' ], + ]; + + $response = new HtmlResponse($body, $status, $headers); + $this->assertEquals(['foo-bar'], $response->getHeader('x-custom')); + $this->assertEquals('text/html', $response->getHeaderLine('content-type')); + $this->assertEquals(404, $response->getStatusCode()); + $this->assertSame($body, (string) $response->getBody()); + } + + public function testAllowsStreamsForResponseBody() + { + $stream = $this->prophesize('Psr\Http\Message\StreamInterface'); + $body = $stream->reveal(); + $response = new HtmlResponse($body); + $this->assertSame($body, $response->getBody()); + } + + public function invalidHtmlContent() + { + 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 invalidHtmlContent + * @expectedException InvalidArgumentException + */ + public function testRaisesExceptionforNonStringNonStreamBodyContent($body) + { + $response = new HtmlResponse($body); + } +} diff --git a/test/Response/StringResponseTest.php b/test/Response/JsonResponseTest.php similarity index 54% rename from test/Response/StringResponseTest.php rename to test/Response/JsonResponseTest.php index f02d34da..4d24fe87 100644 --- a/test/Response/StringResponseTest.php +++ b/test/Response/JsonResponseTest.php @@ -10,27 +10,11 @@ namespace ZendTest\Diactoros\Response; use PHPUnit_Framework_TestCase as TestCase; -use Zend\Diactoros\Response\StringResponse; +use Zend\Diactoros\Response\JsonResponse; -class StringResponseTest extends TestCase +class JsonResponseTest extends TestCase { - public function testHtmlConstructor() - { - $body = 'Uh oh not found'; - $status = 404; - $headers = [ - 'x-custom' => [ 'foo-bar' ], - ]; - - $response = StringResponse::html($body, $status, $headers); - $this->assertInstanceOf('Zend\Diactoros\Response', $response); - $this->assertSame($body, (string) $response->getBody()); - $this->assertEquals(404, $response->getStatusCode()); - $this->assertEquals(['foo-bar'], $response->getHeader('x-custom')); - $this->assertEquals('text/html', $response->getHeaderLine('content-type')); - } - - public function testJsonConstructor() + public function testConstructorAcceptsDataAndCreatesJsonEncodedMessageBody() { $data = [ 'nested' => [ @@ -41,17 +25,15 @@ public function testJsonConstructor() ]; $json = '{"nested":{"json":["tree"]}}'; - $response = StringResponse::json($data); - $this->assertInstanceOf('Zend\Diactoros\Response', $response); + $response = new JsonResponse($data); $this->assertEquals(200, $response->getStatusCode()); $this->assertEquals('application/json', $response->getHeaderLine('content-type')); $this->assertSame($json, (string) $response->getBody()); } - public function testNullValuePassedToJsonRendersEmptyJSONObject() + public function testNullValuePassedToConstructorRendersEmptyJsonObjectInBody() { - $response = StringResponse::json(null); - $this->assertInstanceOf('Zend\Diactoros\Response', $response); + $response = new JsonResponse(null); $this->assertEquals(200, $response->getStatusCode()); $this->assertEquals('application/json', $response->getHeaderLine('content-type')); $this->assertSame('{}', (string) $response->getBody()); @@ -74,22 +56,52 @@ public function scalarValuesForJSON() /** * @dataProvider scalarValuesForJSON */ - public function testScalarValuePassedToJsonRendersValueWithinJSONArray($value) + public function testScalarValuePassedToConstructorRendersValueWithinJSONArray($value) { - $response = StringResponse::json($value); - $this->assertInstanceOf('Zend\Diactoros\Response', $response); + $response = new JsonResponse($value); $this->assertEquals(200, $response->getStatusCode()); $this->assertEquals('application/json', $response->getHeaderLine('content-type')); $this->assertSame(json_encode([$value], JSON_UNESCAPED_SLASHES), (string) $response->getBody()); } - public function testContentTypeCanBeOverwritten() + public function testCanProvideStatusCodeToConstructor() { - $data = null; - $json = '{}'; + $response = new JsonResponse(null, 404); + $this->assertEquals(404, $response->getStatusCode()); + } - $response = StringResponse::json($data, 200, ['content-type' => 'foo/json']); - $this->assertSame($json, (string) $response->getBody()); + public function testCanProvideAlternateContentTypeViaHeadersPassedToConstructor() + { + $response = new JsonResponse(null, 200, ['content-type' => 'foo/json']); $this->assertEquals('foo/json', $response->getHeaderLine('content-type')); } + + /** + * @expectedException InvalidArgumentException + */ + public function testJsonErrorHandlingOfResources() + { + // Serializing something that is not serializable. + $resource = fopen('php://memory', 'r'); + new JsonResponse($resource); + } + + public function testJsonErrorHandlingOfBadEmbeddedData() + { + if (version_compare(PHP_VERSION, '5.5', 'lt')) { + $this->markTestSkipped('Skipped as PHP versions prior to 5.5 are noisy about JSON errors'); + } + + if (defined('HHVM_VERSION')) { + $this->markTestSkipped('Skipped as HHVM happily serializes embedded resources'); + } + + // Serializing something that is not serializable. + $data = [ + 'stream' => fopen('php://memory', 'r'), + ]; + + $this->setExpectedException('InvalidArgumentException', 'Unable to encode'); + new JsonResponse($data); + } }