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));
+ }
+}