diff --git a/composer.json b/composer.json index ea019a71..fe4c9edd 100644 --- a/composer.json +++ b/composer.json @@ -26,10 +26,26 @@ "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", "phpunit/phpunit": "^7.0", - "squizlabs/php_codesniffer": "^3.2" + "squizlabs/php_codesniffer": "^3.2", + "zendframework/zend-diactoros": "^1.7" + }, + "suggest": { + "middlewares/negotiation": "For acceptable format identification", + "zendframework/zend-diactoros": "For concrete implementation of PSR-7" + }, + "autoload": { + "psr-4": { + "Lcobucci\\ContentNegotiation\\": "src" + } + }, + "autoload-dev": { + "psr-4": { + "Lcobucci\\ContentNegotiation\\Tests\\": "tests" + } } } 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 @@ + + 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 @@ +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/src/Formatter.php b/src/Formatter.php new file mode 100644 index 00000000..1ea6b29c --- /dev/null +++ b/src/Formatter.php @@ -0,0 +1,14 @@ +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/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); + } + }, + ] + ); + } +} 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)); + } +}