From 3db5545b1eefae9ac61d8af6c7250366287b006e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Cobucci?= Date: Sun, 25 Mar 2018 12:57:28 +0200 Subject: [PATCH 1/5] Fix PHPCS configuration To remove the `RequireOneLinePropertyDocComment` sniff. --- phpcs.xml.dist | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/phpcs.xml.dist b/phpcs.xml.dist index baa7a2d3..2c43594a 100644 --- a/phpcs.xml.dist +++ b/phpcs.xml.dist @@ -10,7 +10,9 @@ src tests - + + + @@ -25,4 +27,6 @@ + + From 393bec182ea92c898103da44db62de2dfe8f3d29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Cobucci?= Date: Sun, 25 Mar 2018 13:24:41 +0200 Subject: [PATCH 2/5] Setup composer autoloader --- composer.json | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/composer.json b/composer.json index ea019a71..d243600b 100644 --- a/composer.json +++ b/composer.json @@ -31,5 +31,15 @@ "phpstan/phpstan-strict-rules": "^0.10@dev", "phpunit/phpunit": "^7.0", "squizlabs/php_codesniffer": "^3.2" + }, + "autoload": { + "psr-4": { + "Lcobucci\\ContentNegotiation\\": "src" + } + }, + "autoload-dev": { + "psr-4": { + "Lcobucci\\ContentNegotiation\\Tests\\": "tests" + } } } From 6642066308f5bdc8e53730ba0b6a0d13eb57443d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Cobucci?= Date: Sun, 25 Mar 2018 13:38:40 +0200 Subject: [PATCH 3/5] Add definition of a formatter Which is meant to be used to format the content attached to the response. --- src/ContentCouldNotBeFormatted.php | 10 ++++++++++ src/Formatter.php | 14 ++++++++++++++ 2 files changed, 24 insertions(+) create mode 100644 src/ContentCouldNotBeFormatted.php create mode 100644 src/Formatter.php diff --git a/src/ContentCouldNotBeFormatted.php b/src/ContentCouldNotBeFormatted.php new file mode 100644 index 00000000..a112f1f2 --- /dev/null +++ b/src/ContentCouldNotBeFormatted.php @@ -0,0 +1,10 @@ + Date: Sun, 25 Mar 2018 14:17:17 +0200 Subject: [PATCH 4/5] Add unformatted response implementation --- composer.json | 6 +- src/UnformattedResponse.php | 167 +++++++++++++++++++++++ tests/PersonDto.php | 23 ++++ tests/UnformattedResponseTest.php | 211 ++++++++++++++++++++++++++++++ 4 files changed, 406 insertions(+), 1 deletion(-) create mode 100644 src/UnformattedResponse.php create mode 100644 tests/PersonDto.php create mode 100644 tests/UnformattedResponseTest.php diff --git a/composer.json b/composer.json index d243600b..13a492b6 100644 --- a/composer.json +++ b/composer.json @@ -30,7 +30,11 @@ "phpstan/phpstan-phpunit": "^0.10@dev", "phpstan/phpstan-strict-rules": "^0.10@dev", "phpunit/phpunit": "^7.0", - "squizlabs/php_codesniffer": "^3.2" + "squizlabs/php_codesniffer": "^3.2", + "zendframework/zend-diactoros": "^1.7" + }, + "suggest": { + "zendframework/zend-diactoros": "For concrete implementation of PSR-7" }, "autoload": { "psr-4": { diff --git a/src/UnformattedResponse.php b/src/UnformattedResponse.php new file mode 100644 index 00000000..cb2b8109 --- /dev/null +++ b/src/UnformattedResponse.php @@ -0,0 +1,167 @@ +decoratedResponse = $decoratedResponse; + $this->unformattedContent = $unformattedContent; + } + + /** + * @return mixed + */ + public function getUnformattedContent() + { + return $this->unformattedContent; + } + + /** + * {@inheritdoc} + */ + public function getProtocolVersion() + { + return $this->decoratedResponse->getProtocolVersion(); + } + + /** + * {@inheritdoc} + */ + public function withProtocolVersion($version) + { + return new self( + $this->decoratedResponse->withProtocolVersion($version), + $this->unformattedContent + ); + } + + /** + * {@inheritdoc} + */ + public function getHeaders() + { + return $this->decoratedResponse->getHeaders(); + } + + /** + * {@inheritdoc} + */ + public function hasHeader($name) + { + return $this->decoratedResponse->hasHeader($name); + } + + /** + * {@inheritdoc} + */ + public function getHeader($name) + { + return $this->decoratedResponse->getHeader($name); + } + + /** + * {@inheritdoc} + */ + public function getHeaderLine($name) + { + return $this->decoratedResponse->getHeaderLine($name); + } + + /** + * {@inheritdoc} + */ + public function withHeader($name, $value) + { + return new self( + $this->decoratedResponse->withHeader($name, $value), + $this->unformattedContent + ); + } + + /** + * {@inheritdoc} + */ + public function withAddedHeader($name, $value) + { + return new self( + $this->decoratedResponse->withAddedHeader($name, $value), + $this->unformattedContent + ); + } + + /** + * {@inheritdoc} + */ + public function withoutHeader($name) + { + return new self( + $this->decoratedResponse->withoutHeader($name), + $this->unformattedContent + ); + } + + /** + * {@inheritdoc} + */ + public function getBody() + { + return $this->decoratedResponse->getBody(); + } + + /** + * {@inheritdoc} + */ + public function withBody(StreamInterface $body) + { + return new self( + $this->decoratedResponse->withBody($body), + $this->unformattedContent + ); + } + + /** + * {@inheritdoc} + */ + public function getStatusCode() + { + return $this->decoratedResponse->getStatusCode(); + } + + /** + * {@inheritdoc} + */ + public function withStatus($code, $reasonPhrase = '') + { + return new self( + $this->decoratedResponse->withStatus($code, $reasonPhrase), + $this->unformattedContent + ); + } + + /** + * {@inheritdoc} + */ + public function getReasonPhrase() + { + return $this->decoratedResponse->getReasonPhrase(); + } +} diff --git a/tests/PersonDto.php b/tests/PersonDto.php new file mode 100644 index 00000000..f5524cec --- /dev/null +++ b/tests/PersonDto.php @@ -0,0 +1,23 @@ +id = $id; + $this->name = $name; + } +} diff --git a/tests/UnformattedResponseTest.php b/tests/UnformattedResponseTest.php new file mode 100644 index 00000000..0825f692 --- /dev/null +++ b/tests/UnformattedResponseTest.php @@ -0,0 +1,211 @@ +getUnformattedContent()); + } + + /** + * @test + * + * @covers ::__construct + * @covers ::getProtocolVersion() + */ + public function getProtocolVersionShouldReturnTheSameValueAsTheDecoratedObject(): void + { + $this->assertGetterReturn('getProtocolVersion'); + } + + /** + * @test + * + * @covers ::__construct + * @covers ::getHeaders() + */ + public function getHeadersShouldReturnTheSameValueAsTheDecoratedObject(): void + { + $this->assertGetterReturn('getHeaders'); + } + + /** + * @test + * + * @covers ::__construct + * @covers ::hasHeader() + */ + public function hasHeaderShouldReturnTheSameValueAsTheDecoratedObject(): void + { + $this->assertGetterReturn('hasHeader', 'Content-Type'); + } + + /** + * @test + * + * @covers ::__construct + * @covers ::getHeader() + */ + public function getHeaderShouldReturnTheSameValueAsTheDecoratedObject(): void + { + $this->assertGetterReturn('getHeader', 'Content-Type'); + } + + /** + * @test + * + * @covers ::__construct + * @covers ::getHeaderLine() + */ + public function getHeaderLineShouldReturnTheSameValueAsTheDecoratedObject(): void + { + $this->assertGetterReturn('getHeaderLine', 'Content-Type'); + } + + /** + * @test + * + * @covers ::__construct + * @covers ::getBody() + */ + public function getBodyShouldReturnTheSameValueAsTheDecoratedObject(): void + { + $this->assertGetterReturn('getBody'); + } + + /** + * @test + * + * @covers ::__construct + * @covers ::getStatusCode() + */ + public function getStatusCodeShouldReturnTheSameValueAsTheDecoratedObject(): void + { + $this->assertGetterReturn('getStatusCode'); + } + + /** + * @test + * + * @covers ::__construct + * @covers ::getReasonPhrase() + */ + public function getReasonPhraseShouldReturnTheSameValueAsTheDecoratedObject(): void + { + $this->assertGetterReturn('getReasonPhrase'); + } + + /** + * @param mixed ...$arguments + */ + private function assertGetterReturn(string $method, ...$arguments): void + { + $decoratedResponse = new Response(); + $response = new UnformattedResponse($decoratedResponse, new PersonDto(1, 'Testing')); + + self::assertSame($decoratedResponse->$method(...$arguments), $response->$method(...$arguments)); + } + + /** + * @test + * + * @covers ::__construct + * @covers ::withProtocolVersion() + */ + public function withProtocolVersionShouldReturnANewInstanceWithTheModifiedDecoratedObject(): void + { + $this->assertSetterReturn('withProtocolVersion', '2'); + } + + /** + * @test + * + * @covers ::__construct + * @covers ::withHeader() + */ + public function withHeaderShouldReturnANewInstanceWithTheModifiedDecoratedObject(): void + { + $this->assertSetterReturn('withHeader', 'Content-Type', 'application/json'); + } + + /** + * @test + * + * @covers ::__construct + * @covers ::withAddedHeader() + */ + public function withAddedHeaderShouldReturnANewInstanceWithTheModifiedDecoratedObject(): void + { + $this->assertSetterReturn('withAddedHeader', 'Content-Type', 'application/json'); + } + + /** + * @test + * + * @covers ::__construct + * @covers ::withoutHeader() + */ + public function withoutHeaderShouldReturnANewInstanceWithTheModifiedDecoratedObject(): void + { + $this->assertSetterReturn('withoutHeader', 'Content-Type'); + } + + /** + * @test + * + * @covers ::__construct + * @covers ::withBody() + */ + public function withBodyShouldReturnANewInstanceWithTheModifiedDecoratedObject(): void + { + $this->assertSetterReturn('withBody', new Stream('php://temp', 'wb+')); + } + + /** + * @test + * + * @covers ::__construct + * @covers ::withStatus() + */ + public function withStatusShouldReturnANewInstanceWithTheModifiedDecoratedObject(): void + { + $this->assertSetterReturn('withStatus', 202); + } + + /** + * @param mixed ...$arguments + */ + private function assertSetterReturn(string $method, ...$arguments): void + { + $decoratedResponse = new Response(); + $dto = new PersonDto(1, 'Testing'); + + $response = new UnformattedResponse($decoratedResponse, $dto); + $expected = new UnformattedResponse( + $decoratedResponse->$method(...$arguments), + $dto + ); + + self::assertEquals($expected, $response->$method(...$arguments)); + } +} From c17c3a9b48c7c7cf0f6c0104bec0b72791df92cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Cobucci?= Date: Sun, 25 Mar 2018 15:08:45 +0200 Subject: [PATCH 5/5] Add middleware implementation --- composer.json | 2 + src/ContentTypeMiddleware.php | 113 ++++++++++++++++++ tests/ContentTypeMiddlewareTest.php | 177 ++++++++++++++++++++++++++++ 3 files changed, 292 insertions(+) create mode 100644 src/ContentTypeMiddleware.php create mode 100644 tests/ContentTypeMiddlewareTest.php diff --git a/composer.json b/composer.json index 13a492b6..fe4c9edd 100644 --- a/composer.json +++ b/composer.json @@ -26,6 +26,7 @@ "require-dev": { "doctrine/coding-standard": "^4.0", "infection/infection": "^0.8", + "middlewares/negotiation": "^1.0", "phpstan/phpstan": "^0.10@dev", "phpstan/phpstan-phpunit": "^0.10@dev", "phpstan/phpstan-strict-rules": "^0.10@dev", @@ -34,6 +35,7 @@ "zendframework/zend-diactoros": "^1.7" }, "suggest": { + "middlewares/negotiation": "For acceptable format identification", "zendframework/zend-diactoros": "For concrete implementation of PSR-7" }, "autoload": { diff --git a/src/ContentTypeMiddleware.php b/src/ContentTypeMiddleware.php new file mode 100644 index 00000000..d4d1e50b --- /dev/null +++ b/src/ContentTypeMiddleware.php @@ -0,0 +1,113 @@ +negotiator = $negotiator; + $this->formatters = $formatters; + $this->streamFactory = $streamFactory; + } + + /** + * @param mixed[] $formats + * @param Formatter[] $formatters + */ + public static function fromRecommendedSettings( + array $formats, + array $formatters, + ?callable $streamFactory = null + ): self { + return new self( + new ContentType($formats), + $formatters, + function () use ($streamFactory): StreamInterface { + return $streamFactory !== null ? $streamFactory() : new Stream('php://temp', 'wb+'); + } + ); + } + + /** + * {@inheritdoc} + * + * @throws ContentCouldNotBeFormatted + */ + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + $response = $this->negotiator->process($request, $handler); + + if (! $response instanceof UnformattedResponse) { + return $response; + } + + $contentType = $this->extractContentType($response->getHeaderLine('Content-Type')); + $formatter = $this->formatters[$contentType] ?? null; + + return $this->formatResponse($response, $formatter); + } + + private function extractContentType(string $contentType): string + { + $charsetSeparatorPosition = strpos($contentType, ';'); + + if ($charsetSeparatorPosition === false) { + return $contentType; + } + + return substr($contentType, 0, $charsetSeparatorPosition); + } + + /** + * @throws ContentCouldNotBeFormatted + */ + private function formatResponse(UnformattedResponse $response, ?Formatter $formatter): ResponseInterface + { + /** @var StreamInterface $body */ + $body = ($this->streamFactory)(); + $response = $response->withBody($body); + + if ($formatter === null) { + return $response->withStatus(StatusCodeInterface::STATUS_NOT_ACCEPTABLE); + } + + $body->write($formatter->format($response->getUnformattedContent())); + $body->rewind(); + + return $response; + } +} diff --git a/tests/ContentTypeMiddlewareTest.php b/tests/ContentTypeMiddlewareTest.php new file mode 100644 index 00000000..c55c7c6f --- /dev/null +++ b/tests/ContentTypeMiddlewareTest.php @@ -0,0 +1,177 @@ +createMiddleware(); + $response = $middleware->process(new ServerRequest(), $this->createRequestHandler(new EmptyResponse())); + + self::assertInstanceOf(EmptyResponse::class, $response); + self::assertSame(StatusCodeInterface::STATUS_NO_CONTENT, $response->getStatusCode()); + self::assertSame('application/json; charset=UTF-8', $response->getHeaderLine('Content-Type')); + } + + /** + * @test + * + * @covers ::__construct() + * @covers ::fromRecommendedSettings() + * @covers ::process() + * @covers ::extractContentType() + * @covers ::formatResponse() + * + * @uses \Lcobucci\ContentNegotiation\UnformattedResponse + */ + public function processShouldReturnAResponseWithErrorWhenFormatterWasNotFound(): void + { + $middleware = $this->createMiddleware(); + + $response = $middleware->process( + (new ServerRequest())->withAddedHeader('Accept', 'text/plain'), + $this->createRequestHandler( + new UnformattedResponse(new Response(), new PersonDto(1, 'Testing')) + ) + ); + + self::assertInstanceOf(UnformattedResponse::class, $response); + self::assertSame(StatusCodeInterface::STATUS_NOT_ACCEPTABLE, $response->getStatusCode()); + self::assertSame('text/plain; charset=UTF-8', $response->getHeaderLine('Content-Type')); + } + + /** + * @test + * + * @covers ::__construct() + * @covers ::fromRecommendedSettings() + * @covers ::process() + * @covers ::extractContentType() + * @covers ::formatResponse() + * + * @uses \Lcobucci\ContentNegotiation\UnformattedResponse + */ + public function processShouldReturnAResponseWithFormattedContent(): void + { + $middleware = $this->createMiddleware(); + + $response = $middleware->process( + new ServerRequest(), + $this->createRequestHandler( + new UnformattedResponse(new Response(), new PersonDto(1, 'Testing')) + ) + ); + + self::assertInstanceOf(UnformattedResponse::class, $response); + self::assertSame(StatusCodeInterface::STATUS_OK, $response->getStatusCode()); + self::assertSame('application/json; charset=UTF-8', $response->getHeaderLine('Content-Type')); + self::assertSame('{"id":1,"name":"Testing"}', (string) $response->getBody()); + } + + /** + * @test + * + * @covers ::__construct() + * @covers ::fromRecommendedSettings() + * @covers ::process() + * @covers ::extractContentType() + * @covers ::formatResponse() + * + * @uses \Lcobucci\ContentNegotiation\UnformattedResponse + */ + public function processShouldReturnAResponseWithFormattedContentEvenWithoutForcingTheCharset(): void + { + $middleware = $this->createMiddleware(false); + + $response = $middleware->process( + new ServerRequest(), + $this->createRequestHandler( + new UnformattedResponse(new Response(), new PersonDto(1, 'Testing')) + ) + ); + + self::assertInstanceOf(UnformattedResponse::class, $response); + self::assertSame(StatusCodeInterface::STATUS_OK, $response->getStatusCode()); + self::assertSame('application/json', $response->getHeaderLine('Content-Type')); + self::assertSame('{"id":1,"name":"Testing"}', (string) $response->getBody()); + } + + private function createRequestHandler(ResponseInterface $response): RequestHandlerInterface + { + return new class($response) implements RequestHandlerInterface + { + /** + * @var ResponseInterface + */ + private $response; + + public function __construct(ResponseInterface $response) + { + $this->response = $response; + } + + /** + * {@inheritdoc} + */ + public function handle(ServerRequestInterface $request): ResponseInterface + { + return $this->response; + } + }; + } + + private function createMiddleware(bool $forceCharset = true): ContentTypeMiddleware + { + return ContentTypeMiddleware::fromRecommendedSettings( + [ + 'json' => [ + 'extension' => ['json'], + 'mime-type' => ['application/json', 'text/json', 'application/x-json'], + 'charset' => $forceCharset, + ], + 'txt' => [ + 'extension' => ['txt'], + 'mime-type' => ['text/plain'], + 'charset' => $forceCharset, + ], + ], + [ + 'application/json' => new class implements Formatter + { + /** + * {@inheritdoc} + */ + public function format($content): string + { + return (string) json_encode($content); + } + }, + ] + ); + } +}