diff --git a/CHANGELOG.md b/CHANGELOG.md index b901594..dd0e6ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,19 @@ ## 2.1.3 under development +- New #107: Add `DataStream` (@vjik) +- New #107: Add `FormatterInterface` and implementations: `HtmlFormatter`, `JsonFormatter`, `PlainTextFormatter`, + `XmlFormatter` (@vjik) +- New #107: Add `DataResponseFactoryInterface` and implementations: `DataResponseFactory`, `FormattedResponseFactory`, + `HtmlResponseFactory`, `JsonResponseFactory`, `PlainTextResponseFactory`, `XmlResponseFactory` (@vjik) +- New #107: Add middlewares: `XmlDataResponseMiddleware`, `HtmlDataResponseMiddleware`, `JsonDataResponseMiddleware`, + `PlainTextDataResponseMiddleware` and `DataResponseMiddleware` (@vjik) +- New #107: Add `ContentNegotiatorResponseFactory` and `ContentNegotiatorDataResponseMiddleware` (@vjik) +- Chg #107: Deprecate `DataResponse`, `DataResponseFactory`, `DataResponseFactoryInterface`, + `DataResponseFormatterInterface`, `ResponseContentTrait`, `HtmlDataResponseFormatter`, + `JsonDataResponseFormatter`, `PlainTextDataResponseFormatter`, `XmlDataResponseFormatter`, `ContentNegotiator`, + `FormatDataResponse`, `FormatDataResponseAsHtml`, `FormatDataResponseAsJson`, `FormatDataResponseAsPlainText`, + `FormatDataResponseAsXml` (@vjik) - Enh #106: Explicitly import classes, functions, and constants in "use" section (@mspirkov) - Enh #108: Remove unnecessary files from Composer package (@mspirkov) diff --git a/README.md b/README.md index fcd1bb4..b71f112 100644 --- a/README.md +++ b/README.md @@ -32,99 +32,131 @@ composer require yiisoft/data-response ## General usage -The package provides `DataResponseFactory` class that, given a [PSR-17](https://www.php-fig.org/psr/psr-17/) -response factory, is able to create data response. +### Response Factories -Data response contains raw data to be processed later. +The package provides response factories that create [PSR-7](https://www.php-fig.org/psr/psr-7/) responses +with `DataStream` body. The data is formatted lazily when the response body is read. ```php -use Yiisoft\DataResponse\DataResponseFactory; +use Yiisoft\DataResponse\ResponseFactory\JsonResponseFactory; +use Yiisoft\DataResponse\Formatter\JsonFormatter; /** * @var Psr\Http\Message\ResponseFactoryInterface $responseFactory */ -$factory = new DataResponseFactory($responseFactory); -$dataResponse = $factory->createResponse('test'); -$dataResponse - ->getBody() - ->rewind(); +$factory = new JsonResponseFactory($responseFactory, new JsonFormatter()); +$response = $factory->createResponse(['key' => 'value']); -echo $dataResponse - ->getBody() - ->getContents(); // "test" +$response->getBody()->rewind(); +echo $response->getBody()->getContents(); // {"key":"value"} +echo $response->getHeaderLine('Content-Type'); // application/json; charset=UTF-8 ``` -### Formatters +The following response factories are available: -Formatter purpose is to format a data response. In the following example we format data as JSON. +- `JsonResponseFactory` — creates responses with JSON-formatted body; +- `XmlResponseFactory` — creates responses with XML-formatted body; +- `HtmlResponseFactory` — creates responses with HTML-formatted body; +- `PlainTextResponseFactory` — creates responses with plain text body; +- `DataResponseFactory` — creates responses without a predefined formatter, use middleware to format. -```php -use Yiisoft\DataResponse\DataResponseFactory; -use Yiisoft\DataResponse\Formatter\JsonDataResponseFormatter; +### Middleware -/** - * @var Psr\Http\Message\ResponseFactoryInterface $responseFactory - */ +The package provides [PSR-15](https://www.php-fig.org/psr/psr-15/) middleware that formats `DataStream` responses +without a predefined formatter. + +```php +use Yiisoft\DataResponse\Middleware\JsonDataResponseMiddleware; +use Yiisoft\DataResponse\Formatter\JsonFormatter; -$factory = new DataResponseFactory($responseFactory); -$dataResponse = $factory->createResponse('test'); -$dataResponse = $dataResponse->withResponseFormatter(new JsonDataResponseFormatter()); -$dataResponse - ->getBody() - ->rewind(); - -echo $dataResponse->getHeader('Content-Type'); // ["application/json; charset=UTF-8"] -echo $dataResponse - ->getBody() - ->getContents(); // "test" +$middleware = new JsonDataResponseMiddleware(new JsonFormatter()); ``` -The following formatters are available: +The following middleware are available: -- `HtmlDataResponseFormatter` -- `JsonDataResponseFormatter` -- `XmlDataResponseFormatter` -- `PlainTextDataResponseFormatter` +- `HtmlDataResponseMiddleware` +- `JsonDataResponseMiddleware` +- `XmlDataResponseMiddleware` +- `PlainTextDataResponseMiddleware` -### Middleware +### Content Negotiation -The package provides a [PSR-15](https://www.php-fig.org/psr/psr-15/) middleware that is able to format a data response. +The package provides content negotiation via middleware and response factory. -```php -use Yiisoft\DataResponse\Middleware\FormatDataResponse; -use Yiisoft\DataResponse\Formatter\JsonDataResponseFormatter; +#### Middleware -$middleware = (new FormatDataResponse(new JsonDataResponseFormatter())); -//$middleware->process($request, $handler); +`ContentNegotiatorDataResponseMiddleware` selects a formatter based on the request's `Accept` header: + +```php +use Yiisoft\DataResponse\Formatter\HtmlFormatter; +use Yiisoft\DataResponse\Formatter\XmlFormatter; +use Yiisoft\DataResponse\Formatter\JsonFormatter; +use Yiisoft\DataResponse\Middleware\ContentNegotiatorDataResponseMiddleware; + +$middleware = new ContentNegotiatorDataResponseMiddleware( + formatters: [ + 'text/html' => new HtmlFormatter(), + 'application/xml' => new XmlFormatter(), + 'application/json' => new JsonFormatter(), + ], + fallback: new JsonFormatter(), +); ``` -Also, the package provides [PSR-15](https://www.php-fig.org/psr/psr-15/) middleware for content negotiation: +The `fallback` parameter also accepts a `RequestHandlerInterface`, for example `NotAcceptableRequestHandler` +to return a 406 response when no formatter matches. + +#### Response Factory + +`ContentNegotiatorResponseFactory` selects a response factory based on the request's `Accept` header: ```php -use Yiisoft\DataResponse\Formatter\HtmlDataResponseFormatter; -use Yiisoft\DataResponse\Formatter\XmlDataResponseFormatter; -use Yiisoft\DataResponse\Formatter\JsonDataResponseFormatter; -use Yiisoft\DataResponse\Middleware\ContentNegotiator; - -$middleware = new ContentNegotiator([ - 'text/html' => new HtmlDataResponseFormatter(), - 'application/xml' => new XmlDataResponseFormatter(), - 'application/json' => new JsonDataResponseFormatter(), -]); +use Yiisoft\DataResponse\ResponseFactory\ContentNegotiatorResponseFactory; +use Yiisoft\DataResponse\ResponseFactory\JsonResponseFactory; +use Yiisoft\DataResponse\ResponseFactory\XmlResponseFactory; + +/** + * @var JsonResponseFactory $jsonResponseFactory + * @var XmlResponseFactory $xmlResponseFactory + */ + +$factory = new ContentNegotiatorResponseFactory( + factories: [ + 'application/json' => $jsonResponseFactory, + 'application/xml' => $xmlResponseFactory, + ], + fallback: $jsonResponseFactory, +); + +$response = $factory->createResponse($request, ['key' => 'value']); ``` -You can override middlewares with method `withContentFormatters()`: +The `fallback` parameter also accepts a `RequestHandlerInterface`, for example `NotAcceptableRequestHandler` +to return a 406 response when no factory matches. + +### DataStream + +`DataStream` is a [PSR-7](https://www.php-fig.org/psr/psr-7/) stream that lazily formats data. +It wraps raw data and a formatter, and performs formatting only when the stream is read. ```php -$middleware->withContentFormatters([ - 'application/xml' => new XmlDataResponseFormatter(), - 'application/json' => new JsonDataResponseFormatter(), -]); +use Yiisoft\DataResponse\DataStream\DataStream; +use Yiisoft\DataResponse\Formatter\JsonFormatter; +use Yiisoft\DataResponse\Formatter\XmlFormatter; + +$stream = new DataStream(['key' => 'value'], new JsonFormatter()); + +echo (string) $stream; // {"key":"value"} + +// You can change the data or formatter dynamically +$stream->changeData(['new' => 'data']); +$stream->changeFormatter(new XmlFormatter()); ``` ## Documentation +- [Deprecated classes](docs/deprecated.md) - [Internals](docs/internals.md) If you need help or have a question, the [Yii Forum](https://forum.yiiframework.com/c/yii-3-0/63) is a good place for that. diff --git a/composer.json b/composer.json index 8948df7..4e72518 100644 --- a/composer.json +++ b/composer.json @@ -46,7 +46,8 @@ "rector/rector": "^2.1.4", "roave/infection-static-analysis-plugin": "^1.35", "vimeo/psalm": "^5.26.1 || ^6.8.0", - "yiisoft/di": "^1.4" + "yiisoft/di": "^1.4", + "yiisoft/test-support": "^3.2" }, "autoload": { "psr-4": { @@ -82,6 +83,6 @@ }, "scripts": { "require-checker": "composer-require-checker", - "test": "phpunit --testdox --no-interaction" + "test": "phpunit --testdox" } } diff --git a/config/di-web.php b/config/di-web.php index 4721d77..660f8b0 100644 --- a/config/di-web.php +++ b/config/di-web.php @@ -2,21 +2,55 @@ declare(strict_types=1); -use Yiisoft\DataResponse\DataResponseFactory; -use Yiisoft\DataResponse\DataResponseFactoryInterface; +use Yiisoft\DataResponse\DataResponseFactory as DeprecatedDataResponseFactory; +use Yiisoft\DataResponse\DataResponseFactoryInterface as DeprecatedDataResponseFactoryInterface; use Yiisoft\DataResponse\DataResponseFormatterInterface; use Yiisoft\DataResponse\Formatter\HtmlDataResponseFormatter; +use Yiisoft\DataResponse\Formatter\HtmlFormatter; +use Yiisoft\DataResponse\Formatter\JsonFormatter; +use Yiisoft\DataResponse\Formatter\XmlFormatter; use Yiisoft\DataResponse\Middleware\ContentNegotiator; +use Yiisoft\DataResponse\Middleware\ContentNegotiatorDataResponseMiddleware; +use Yiisoft\DataResponse\NotAcceptableRequestHandler; +use Yiisoft\DataResponse\ResponseFactory\ContentNegotiatorResponseFactory; +use Yiisoft\DataResponse\ResponseFactory\DataResponseFactory; +use Yiisoft\DataResponse\ResponseFactory\DataResponseFactoryInterface; +use Yiisoft\DataResponse\ResponseFactory\HtmlResponseFactory; +use Yiisoft\DataResponse\ResponseFactory\JsonResponseFactory; +use Yiisoft\DataResponse\ResponseFactory\XmlResponseFactory; use Yiisoft\Definitions\DynamicReferencesArray; +use Yiisoft\Definitions\Reference; +use Yiisoft\Definitions\ReferencesArray; /* @var $params array */ return [ DataResponseFormatterInterface::class => HtmlDataResponseFormatter::class, - DataResponseFactoryInterface::class => DataResponseFactory::class, + DeprecatedDataResponseFactoryInterface::class => DeprecatedDataResponseFactory::class, ContentNegotiator::class => [ '__construct()' => [ 'contentFormatters' => DynamicReferencesArray::from($params['yiisoft/data-response']['contentFormatters']), ], ], + DataResponseFactoryInterface::class => DataResponseFactory::class, + ContentNegotiatorDataResponseMiddleware::class => [ + '__construct()' => [ + 'formatters' => ReferencesArray::from([ + 'text/html' => HtmlFormatter::class, + 'application/xml' => XmlFormatter::class, + 'application/json' => JsonFormatter::class, + ]), + 'fallback' => Reference::to(NotAcceptableRequestHandler::class), + ], + ], + ContentNegotiatorResponseFactory::class => [ + '__construct()' => [ + 'factories' => DynamicReferencesArray::from([ + 'text/html' => HtmlResponseFactory::class, + 'application/xml' => XmlResponseFactory::class, + 'application/json' => JsonResponseFactory::class, + ]), + 'fallback' => Reference::to(NotAcceptableRequestHandler::class), + ], + ], ]; diff --git a/docs/deprecated.md b/docs/deprecated.md new file mode 100644 index 0000000..753353e --- /dev/null +++ b/docs/deprecated.md @@ -0,0 +1,97 @@ +# Deprecated + +> [!WARNING] +> The classes described in this document are deprecated and will be removed in a future version. + +## General usage + +The package provides `DataResponseFactory` class that, given a [PSR-17](https://www.php-fig.org/psr/psr-17/) +response factory, is able to create data response. + +Data response contains raw data to be processed later. + +```php +use Yiisoft\DataResponse\DataResponseFactory; + +/** + * @var Psr\Http\Message\ResponseFactoryInterface $responseFactory + */ + +$factory = new DataResponseFactory($responseFactory); +$dataResponse = $factory->createResponse('test'); +$dataResponse + ->getBody() + ->rewind(); + +echo $dataResponse + ->getBody() + ->getContents(); // "test" +``` + +## Formatters + +Formatter purpose is to format a data response. In the following example we format data as JSON. + +```php +use Yiisoft\DataResponse\DataResponseFactory; +use Yiisoft\DataResponse\Formatter\JsonDataResponseFormatter; + +/** + * @var Psr\Http\Message\ResponseFactoryInterface $responseFactory + */ + +$factory = new DataResponseFactory($responseFactory); +$dataResponse = $factory->createResponse('test'); +$dataResponse = $dataResponse->withResponseFormatter(new JsonDataResponseFormatter()); +$dataResponse + ->getBody() + ->rewind(); + +echo $dataResponse->getHeader('Content-Type'); // ["application/json; charset=UTF-8"] +echo $dataResponse + ->getBody() + ->getContents(); // "test" +``` + +The following formatters are available: + +- `HtmlDataResponseFormatter` +- `JsonDataResponseFormatter` +- `XmlDataResponseFormatter` +- `PlainTextDataResponseFormatter` + +## Middleware + +The package provides a [PSR-15](https://www.php-fig.org/psr/psr-15/) middleware that is able to format a data response. + +```php +use Yiisoft\DataResponse\Middleware\FormatDataResponse; +use Yiisoft\DataResponse\Formatter\JsonDataResponseFormatter; + +$middleware = (new FormatDataResponse(new JsonDataResponseFormatter())); +//$middleware->process($request, $handler); +``` + +Also, the package provides [PSR-15](https://www.php-fig.org/psr/psr-15/) middleware for content negotiation: + +```php +use Yiisoft\DataResponse\Formatter\HtmlDataResponseFormatter; +use Yiisoft\DataResponse\Formatter\XmlDataResponseFormatter; +use Yiisoft\DataResponse\Formatter\JsonDataResponseFormatter; +use Yiisoft\DataResponse\Middleware\ContentNegotiator; + +$middleware = new ContentNegotiator([ + 'text/html' => new HtmlDataResponseFormatter(), + 'application/xml' => new XmlDataResponseFormatter(), + 'application/json' => new JsonDataResponseFormatter(), +]); +``` + +You can override middlewares with method `withContentFormatters()`: + +```php +$middleware->withContentFormatters([ + 'application/xml' => new XmlDataResponseFormatter(), + 'application/json' => new JsonDataResponseFormatter(), +]); +``` diff --git a/src/DataResponse.php b/src/DataResponse.php index e9b7e3e..6d75d69 100644 --- a/src/DataResponse.php +++ b/src/DataResponse.php @@ -9,6 +9,7 @@ use Psr\Http\Message\StreamFactoryInterface; use Psr\Http\Message\StreamInterface; use RuntimeException; +use Yiisoft\DataResponse\DataStream\DataStream; use Yiisoft\DataResponse\Formatter\JsonDataResponseFormatter; use function ftruncate; @@ -24,6 +25,10 @@ * * For example, `['name' => 'Dmitriy']` to be formatted as JSON using * {@see JsonDataResponseFormatter} when {@see DataResponse::getBody()} is called. + * + * @deprecated Use {@see DataStream} instead. + * + * @psalm-suppress DeprecatedInterface */ final class DataResponse implements ResponseInterface { diff --git a/src/DataResponseFactory.php b/src/DataResponseFactory.php index fb9268a..d943804 100644 --- a/src/DataResponseFactory.php +++ b/src/DataResponseFactory.php @@ -10,6 +10,10 @@ /** * DataResponseFactory creates an instance of the data response {@see DataResponse}. + * + * @deprecated Use {@see ResponseFactory\DataResponseFactory} instead. + * + * @psalm-suppress DeprecatedInterface, DeprecatedClass */ final class DataResponseFactory implements DataResponseFactoryInterface { diff --git a/src/DataResponseFactoryInterface.php b/src/DataResponseFactoryInterface.php index 9cefd3b..5dda38f 100644 --- a/src/DataResponseFactoryInterface.php +++ b/src/DataResponseFactoryInterface.php @@ -8,6 +8,10 @@ /** * `DataResponseFactoryInterface` is the interface that should be implemented by data response factory classes. + * + * @deprecated Use {@see ResponseFactory\DataResponseFactoryInterface} instead. + * + * @psalm-suppress DeprecatedClass */ interface DataResponseFactoryInterface { diff --git a/src/DataResponseFormatterInterface.php b/src/DataResponseFormatterInterface.php index 127ab69..47d73fe 100644 --- a/src/DataResponseFormatterInterface.php +++ b/src/DataResponseFormatterInterface.php @@ -5,9 +5,14 @@ namespace Yiisoft\DataResponse; use Psr\Http\Message\ResponseInterface; +use Yiisoft\DataResponse\Formatter\FormatterInterface; /** * DataResponseFormatterInterface is the interface that should be implemented by data response formatters. + * + * @deprecated Use {@see FormatterInterface} instead. + * + * @psalm-suppress DeprecatedClass */ interface DataResponseFormatterInterface { diff --git a/src/DataStream/DataStream.php b/src/DataStream/DataStream.php new file mode 100644 index 0000000..a6a0b81 --- /dev/null +++ b/src/DataStream/DataStream.php @@ -0,0 +1,164 @@ +getFormatted(); + } + + /** + * Checks whether a formatter has been set. + * + * @return bool Whether a formatter is set. + */ + public function hasFormatter(): bool + { + return $this->formatter !== null; + } + + /** + * Changes the formatter. + * + * @param FormatterInterface $formatter The new formatter. + */ + public function changeFormatter(FormatterInterface $formatter): void + { + $this->formatter = $formatter; + $this->resetState(); + } + + /** + * Changes the data. + * + * @param mixed $data The new data. + */ + public function changeData(mixed $data): void + { + $this->data = $data; + $this->resetState(); + } + + public function close(): void + { + $this->getFormatted()->close(); + } + + public function detach() + { + return $this->getFormatted()->detach(); + } + + public function getSize(): ?int + { + return $this->getFormatted()->getSize(); + } + + public function tell(): int + { + return $this->getFormatted()->tell(); + } + + public function eof(): bool + { + return $this->getFormatted()->eof(); + } + + public function isSeekable(): bool + { + return $this->getFormatted()->isSeekable(); + } + + public function seek(int $offset, int $whence = SEEK_SET): void + { + $this->getFormatted()->seek($offset, $whence); + } + + public function rewind(): void + { + $this->getFormatted()->rewind(); + } + + public function isWritable(): bool + { + return $this->getFormatted()->isWritable(); + } + + public function write(string $string): int + { + return $this->getFormatted()->write($string); + } + + public function isReadable(): bool + { + return $this->getFormatted()->isReadable(); + } + + public function read(int $length): string + { + return $this->getFormatted()->read($length); + } + + public function getContents(): string + { + return $this->getFormatted()->getContents(); + } + + public function getMetadata(?string $key = null) + { + return $this->getFormatted()->getMetadata($key); + } + + private function getFormatted(): StreamInterface + { + if ($this->formatted !== null) { + return $this->formatted; + } + + $formatter = $this->formatter ?? $this->fallbackFormatter; + $content = $formatter->formatData($this->data); + + $this->formatted = $content instanceof StreamInterface + ? $content + : new StringStream($content); + + return $this->formatted; + } + + private function resetState(): void + { + if ($this->formatted !== null) { + $this->formatted->close(); + $this->formatted = null; + } + } +} diff --git a/src/DataStream/StringStream.php b/src/DataStream/StringStream.php new file mode 100644 index 0000000..7a9d128 --- /dev/null +++ b/src/DataStream/StringStream.php @@ -0,0 +1,166 @@ +content; + } + + public function close(): void + { + $this->closed = true; + } + + public function detach() + { + $this->close(); + return null; + } + + public function getSize(): int + { + return $this->getContentSize(); + } + + public function tell(): int + { + if ($this->closed) { + throw new RuntimeException('Stream is closed.'); + } + + return $this->position; + } + + public function eof(): bool + { + return $this->closed || $this->position >= $this->getContentSize(); + } + + public function isSeekable(): bool + { + return !$this->closed; + } + + public function seek(int $offset, int $whence = SEEK_SET): void + { + if ($this->closed) { + throw new RuntimeException('Stream is closed.'); + } + + $size = $this->getContentSize(); + + $newPosition = match ($whence) { + SEEK_SET => $offset, + SEEK_CUR => $this->position + $offset, + SEEK_END => $size + $offset, + default => throw new RuntimeException('Invalid whence value.'), + }; + + if ($newPosition < 0 || $newPosition > $size) { + throw new RuntimeException('Invalid seek position.'); + } + + $this->position = $newPosition; + } + + public function rewind(): void + { + $this->seek(0); + } + + public function isWritable(): bool + { + return false; + } + + public function write(string $string): int + { + throw new RuntimeException('Stream is not writable.'); + } + + public function isReadable(): bool + { + return !$this->closed; + } + + public function read(int $length): string + { + if ($this->closed) { + throw new RuntimeException('Stream is closed.'); + } + + if ($length < 0) { + throw new RuntimeException('Length must be non-negative.'); + } + + if ($this->position >= $this->getContentSize()) { + return ''; + } + + $data = substr($this->content, $this->position, $length); + $this->position += strlen($data); + + return $data; + } + + public function getContents(): string + { + return $this->read( + $this->getContentSize() - $this->position, + ); + } + + public function getMetadata(?string $key = null) + { + if ($this->closed) { + return $key === null ? [] : null; + } + + $metadata = [ + 'eof' => $this->eof(), + 'seekable' => $this->isSeekable(), + ]; + + if ($key === null) { + return $metadata; + } + + return $metadata[$key] ?? null; + } + + private function getContentSize(): int + { + if ($this->size === null) { + $this->size = strlen($this->content); + } + + return $this->size; + } +} diff --git a/src/Formatter/DataEncodingException.php b/src/Formatter/DataEncodingException.php new file mode 100644 index 0000000..e51ecf7 --- /dev/null +++ b/src/Formatter/DataEncodingException.php @@ -0,0 +1,15 @@ +withHeader(Header::CONTENT_TYPE, "$this->contentType; charset=$this->encoding"); + } +} diff --git a/src/Formatter/JsonDataResponseFormatter.php b/src/Formatter/JsonDataResponseFormatter.php index 4268ec4..fc37106 100644 --- a/src/Formatter/JsonDataResponseFormatter.php +++ b/src/Formatter/JsonDataResponseFormatter.php @@ -16,6 +16,10 @@ /** * `JsonDataResponseFormatter` formats the response data as JSON. + * + * @deprecated Use {@see DataStream} with {@see JsonFormatter} instead. + * + * @psalm-suppress DeprecatedTrait, DeprecatedInterface */ final class JsonDataResponseFormatter implements DataResponseFormatterInterface { diff --git a/src/Formatter/JsonFormatter.php b/src/Formatter/JsonFormatter.php new file mode 100644 index 0000000..c8858e5 --- /dev/null +++ b/src/Formatter/JsonFormatter.php @@ -0,0 +1,44 @@ +options); + } catch (JsonException $e) { + throw new DataEncodingException($e->getMessage(), previous: $e); + } + } + + public function formatResponse(ResponseInterface $response): ResponseInterface + { + return $response->withHeader(Header::CONTENT_TYPE, "$this->contentType; charset=$this->encoding"); + } +} diff --git a/src/Formatter/PlainTextDataResponseFormatter.php b/src/Formatter/PlainTextDataResponseFormatter.php index d2cac40..9e5e617 100644 --- a/src/Formatter/PlainTextDataResponseFormatter.php +++ b/src/Formatter/PlainTextDataResponseFormatter.php @@ -16,6 +16,10 @@ /** * `PlainTextDataResponseFormatter` formats the response data as plain text. + * + * @deprecated Use {@see DataStream} with {@see PlainTextFormatter} instead. + * + * @psalm-suppress DeprecatedTrait, DeprecatedInterface */ final class PlainTextDataResponseFormatter implements DataResponseFormatterInterface { diff --git a/src/Formatter/PlainTextFormatter.php b/src/Formatter/PlainTextFormatter.php new file mode 100644 index 0000000..72e54cb --- /dev/null +++ b/src/Formatter/PlainTextFormatter.php @@ -0,0 +1,52 @@ +withHeader(Header::CONTENT_TYPE, "$this->contentType; charset=$this->encoding"); + } +} diff --git a/src/Formatter/XmlDataInterface.php b/src/Formatter/XmlDataInterface.php index 86ed959..c399773 100644 --- a/src/Formatter/XmlDataInterface.php +++ b/src/Formatter/XmlDataInterface.php @@ -5,7 +5,7 @@ namespace Yiisoft\DataResponse\Formatter; /** - * XmlFormatDataInterface provides methods used when formatting objects {@see XmlDataResponseFormatter} as XML data. + * Interface provides methods used when formatting objects {@see XmlFormatter} as XML data. */ interface XmlDataInterface { diff --git a/src/Formatter/XmlDataResponseFormatter.php b/src/Formatter/XmlDataResponseFormatter.php index 3b68baf..d7a1c33 100644 --- a/src/Formatter/XmlDataResponseFormatter.php +++ b/src/Formatter/XmlDataResponseFormatter.php @@ -22,6 +22,10 @@ /** * `XmlDataResponseFormatter` formats the response data as XML. + * + * @deprecated Use {@see DataStream} with {@see XmlFormatter} instead. + * + * @psalm-suppress DeprecatedTrait, DeprecatedInterface */ final class XmlDataResponseFormatter implements DataResponseFormatterInterface { diff --git a/src/Formatter/XmlFormatter.php b/src/Formatter/XmlFormatter.php new file mode 100644 index 0000000..d757ba0 --- /dev/null +++ b/src/Formatter/XmlFormatter.php @@ -0,0 +1,219 @@ +version, $this->encoding); + + if (empty($this->rootTag)) { + $this->buildXml($dom, $dom, $data); + } else { + $root = new DOMElement($this->rootTag); + $dom->appendChild($root); + $this->buildXml($dom, $root, $data); + } + + return (string) $dom->saveXML(); + } + + public function formatResponse(ResponseInterface $response): ResponseInterface + { + return $response->withHeader(Header::CONTENT_TYPE, "$this->contentType; charset=$this->encoding"); + } + + /** + * Builds the data to use in XML. + * + * @param DOMDocument $dom The root DOM document. + * @param DOMDocument|DOMElement $element The current DOM element being processed. + * @param mixed $data Data for building XML. + */ + private function buildXml(DOMDocument $dom, $element, mixed $data): void + { + if (empty($data)) { + return; + } + + if (is_array($data) || ($data instanceof Traversable && !($data instanceof XmlDataInterface))) { + /** @var int|string $name */ + foreach ($data as $name => $value) { + if (is_object($value)) { + $this->buildObject($dom, $element, $value, $name); + continue; + } + + $child = $this->safeCreateDomElement($dom, $name); + $element->appendChild($child); + + if (is_array($value)) { + $this->buildXml($dom, $child, $value); + continue; + } + + /** @psalm-var scalar $value */ + + $this->setScalarValueToDomElement($child, $value); + } + + return; + } + + if (is_object($data)) { + $this->buildObject($dom, $element, $data); + return; + } + + /** @psalm-var scalar $data */ + + $this->setScalarValueToDomElement($element, $data); + } + + /** + * Builds the object to use in XML. + * + * @param DOMDocument $dom The root DOM document. + * @param DOMDocument|DOMElement $element The current DOM element being processed. + * @param object $object To build. + * @param int|string|null $tagName The tag name. + */ + private function buildObject(DOMDocument $dom, $element, object $object, $tagName = null): void + { + if ($object instanceof XmlDataInterface) { + $child = $this->safeCreateDomElement($dom, $object->xmlTagName()); + + foreach ($object->xmlTagAttributes() as $name => $value) { + $child->setAttribute($name, $value); + } + + $element->appendChild($child); + $this->buildXml($dom, $child, $object->xmlData()); + return; + } + + $child = $this->safeCreateDomElement($dom, $tagName); + $element->appendChild($child); + + if ($object instanceof Traversable) { + $this->buildXml($dom, $child, $object); + return; + } + + $data = []; + + /** + * @var string $property + */ + foreach ($object as $property => $value) { + $data[$property] = $value; + } + + $this->buildXml($dom, $child, $data); + } + + /** + * Safely creates a DOMElement instance by the specified tag name if the tag name is not empty, + * is not integer, and is valid. Otherwise {@see DEFAULT_ITEM_TAG_NAME} value is used. + * + * @see https://stackoverflow.com/questions/2519845/how-to-check-if-string-is-a-valid-xml-element-name/2519943#2519943 + * + * @param DOMDocument $dom The root DOM document. + * @param int|string|null $tagName The tag name. + * + * @return DOMElement + */ + private function safeCreateDomElement(DOMDocument $dom, $tagName): DOMElement + { + if (empty($tagName) || is_int($tagName)) { + return $dom->createElement(self::DEFAULT_ITEM_TAG_NAME); + } + + try { + /** @var DOMElement */ + return $dom->createElement($tagName); + } catch (DOMException) { + return $dom->createElement(self::DEFAULT_ITEM_TAG_NAME); + } + } + + /** + * Sets the scalar value to DOM Element instance if the value is not empty. + * + * @param DOMDocument|DOMElement $element The current DOM element being processed. + * @param bool|float|int|string|null $value + */ + private function setScalarValueToDomElement($element, $value): void + { + $value = $this->formatScalarValue($value); + + if ($value !== '') { + $element->appendChild(new DOMText($value)); + } + } + + /** + * Formats scalar value for use in XML node. + * + * @param bool|float|int|string|null $value To format. + * + * @return string The string representation of the value. + */ + private function formatScalarValue($value): string + { + if ($value === true) { + return 'true'; + } + + if ($value === false) { + return 'false'; + } + + if (is_float($value)) { + return NumericHelper::normalize($value); + } + + return (string) $value; + } +} diff --git a/src/Middleware/AbstractDataResponseMiddleware.php b/src/Middleware/AbstractDataResponseMiddleware.php new file mode 100644 index 0000000..e1f77dd --- /dev/null +++ b/src/Middleware/AbstractDataResponseMiddleware.php @@ -0,0 +1,41 @@ +handle($request); + + $body = $response->getBody(); + if (!$body instanceof DataStream || $body->hasFormatter()) { + return $response; + } + + $body->changeFormatter($this->formatter); + return $this->formatter->formatResponse($response); + } +} diff --git a/src/Middleware/ContentNegotiator.php b/src/Middleware/ContentNegotiator.php index c03973e..ea1e187 100644 --- a/src/Middleware/ContentNegotiator.php +++ b/src/Middleware/ContentNegotiator.php @@ -21,6 +21,10 @@ * ContentNegotiator supports response format negotiation. * * @link https://developer.mozilla.org/en-US/docs/Web/HTTP/Content_negotiation + * + * @deprecated Use {@see ContentNegotiatorDataResponseMiddleware} instead. + * + * @psalm-suppress DeprecatedInterface */ final class ContentNegotiator implements MiddlewareInterface { diff --git a/src/Middleware/ContentNegotiatorDataResponseMiddleware.php b/src/Middleware/ContentNegotiatorDataResponseMiddleware.php new file mode 100644 index 0000000..32259ee --- /dev/null +++ b/src/Middleware/ContentNegotiatorDataResponseMiddleware.php @@ -0,0 +1,97 @@ + new JsonFormatter(), 'application/xml' => new XmlFormatter()]`. + * @param FormatterInterface|RequestHandlerInterface|null $fallback Formatter or request handler + * to use when no match is found. If `null`, the response is returned unmodified. + * + * @psalm-param array $formatters + */ + public function __construct( + private readonly array $formatters = [], + private readonly FormatterInterface|RequestHandlerInterface|null $fallback = null, + ) { + $this->checkFormatters($formatters); + } + + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + $accepted = HeaderValueHelper::getSortedAcceptTypes( + $request->getHeader('Accept'), + ); + + $response = $handler->handle($request); + $body = $response->getBody(); + if (!$body instanceof DataStream || $body->hasFormatter()) { + return $response; + } + + foreach ($accepted as $accept) { + foreach ($this->formatters as $contentType => $formatter) { + if (str_contains($accept, $contentType)) { + $body->changeFormatter($formatter); + return $formatter->formatResponse($response); + } + } + } + + if ($this->fallback === null) { + return $response; + } + + if ($this->fallback instanceof RequestHandlerInterface) { + return $this->fallback->handle($request); + } + + $body->changeFormatter($this->fallback); + return $this->fallback->formatResponse($response); + } + + private function checkFormatters(array $formatters): void + { + foreach ($formatters as $contentType => $formatter) { + if (!is_string($contentType)) { + throw new RuntimeException( + sprintf( + 'Invalid formatter content type. A string is expected, "%s" is received.', + gettype($contentType), + ), + ); + } + + if (!($formatter instanceof FormatterInterface)) { + throw new RuntimeException( + sprintf( + 'Invalid formatter. A "%s" instance is expected, "%s" is received.', + FormatterInterface::class, + get_debug_type($formatter), + ), + ); + } + } + } +} diff --git a/src/Middleware/DataResponseMiddleware.php b/src/Middleware/DataResponseMiddleware.php new file mode 100644 index 0000000..c518fb0 --- /dev/null +++ b/src/Middleware/DataResponseMiddleware.php @@ -0,0 +1,13 @@ +responseFactory->createResponse(Status::NOT_ACCEPTABLE); + } +} diff --git a/src/ResponseContentTrait.php b/src/ResponseContentTrait.php index ce73b7d..964ed36 100644 --- a/src/ResponseContentTrait.php +++ b/src/ResponseContentTrait.php @@ -9,6 +9,8 @@ /** * ResponseContentTrait provides methods for manipulating the response content. + * + * @deprecated */ trait ResponseContentTrait { @@ -16,6 +18,8 @@ trait ResponseContentTrait * Returns a new instance with the specified content type. * * @param string $contentType The content type. For example, "text/html". + * + * @psalm-suppress DeprecatedClass */ public function withContentType(string $contentType): self { @@ -28,6 +32,8 @@ public function withContentType(string $contentType): self * Returns a new instance with the specified encoding. * * @param string $encoding The encoding. For example, "UTF-8". + * + * @psalm-suppress DeprecatedClass */ public function withEncoding(string $encoding): self { diff --git a/src/ResponseFactory/AbstractFormattedResponseFactory.php b/src/ResponseFactory/AbstractFormattedResponseFactory.php new file mode 100644 index 0000000..327169a --- /dev/null +++ b/src/ResponseFactory/AbstractFormattedResponseFactory.php @@ -0,0 +1,41 @@ +formatter); + $response = $this->responseFactory + ->createResponse($code, $reasonPhrase) + ->withBody($body); + return $this->formatter->formatResponse($response); + } +} diff --git a/src/ResponseFactory/ContentNegotiatorResponseFactory.php b/src/ResponseFactory/ContentNegotiatorResponseFactory.php new file mode 100644 index 0000000..b5a832b --- /dev/null +++ b/src/ResponseFactory/ContentNegotiatorResponseFactory.php @@ -0,0 +1,96 @@ + $jsonFactory, 'application/xml' => $xmlFactory]`. + * @param DataResponseFactoryInterface|RequestHandlerInterface $fallback Factory or request handler + * to use when no match is found. + * + * @psalm-param array $factories + */ + public function __construct( + private readonly array $factories, + private readonly DataResponseFactoryInterface|RequestHandlerInterface $fallback, + ) { + $this->checkFactories($factories); + } + + /** + * Creates an HTTP response using a factory selected based on the request's `Accept` header. + * + * @param ServerRequestInterface $request The request to extract the `Accept` header from. + * @param mixed $data The response data to be included in the response body. + * @param int $code The HTTP status code for the response. + * @param string $reasonPhrase The reason phrase associated with the status code. + * + * @return ResponseInterface The created HTTP response. + */ + public function createResponse( + ServerRequestInterface $request, + mixed $data = null, + int $code = Status::OK, + string $reasonPhrase = '', + ): ResponseInterface { + $accepted = HeaderValueHelper::getSortedAcceptTypes( + $request->getHeader('Accept'), + ); + + foreach ($accepted as $accept) { + foreach ($this->factories as $contentType => $factory) { + if (str_contains($accept, $contentType)) { + return $factory->createResponse($data, $code, $reasonPhrase); + } + } + } + + if ($this->fallback instanceof RequestHandlerInterface) { + return $this->fallback->handle($request); + } + + return $this->fallback->createResponse($data, $code, $reasonPhrase); + } + + private function checkFactories(array $factories): void + { + foreach ($factories as $contentType => $factory) { + if (!is_string($contentType)) { + throw new RuntimeException( + sprintf( + 'Invalid factory content type. A string is expected, "%s" is received.', + gettype($contentType), + ), + ); + } + + if (!($factory instanceof DataResponseFactoryInterface)) { + throw new RuntimeException( + sprintf( + 'Invalid factory. A "%s" instance is expected, "%s" is received.', + DataResponseFactoryInterface::class, + get_debug_type($factory), + ), + ); + } + } + } +} diff --git a/src/ResponseFactory/DataResponseFactory.php b/src/ResponseFactory/DataResponseFactory.php new file mode 100644 index 0000000..0beb09e --- /dev/null +++ b/src/ResponseFactory/DataResponseFactory.php @@ -0,0 +1,41 @@ +fallbackFormatter); + return $this->responseFactory + ->createResponse($code, $reasonPhrase) + ->withBody($body); + } +} diff --git a/src/ResponseFactory/DataResponseFactoryInterface.php b/src/ResponseFactory/DataResponseFactoryInterface.php new file mode 100644 index 0000000..1724c99 --- /dev/null +++ b/src/ResponseFactory/DataResponseFactoryInterface.php @@ -0,0 +1,30 @@ +createContainer('web'); - $dataResponseFormatter = $container->get(DataResponseFormatterInterface::class); $dataResponseFactory = $container->get(DataResponseFactoryInterface::class); + $contentNegotiatorDataResponseMiddleware = $container->get(ContentNegotiatorDataResponseMiddleware::class); + $contentNegotiatorResponseFactory = $container->get(ContentNegotiatorResponseFactory::class); + + $this->assertInstanceOf(DataResponseFactory::class, $dataResponseFactory); + $this->assertInstanceOf(ContentNegotiatorDataResponseMiddleware::class, $contentNegotiatorDataResponseMiddleware); + $this->assertInstanceOf(ContentNegotiatorResponseFactory::class, $contentNegotiatorResponseFactory); + } + + public function testDiWebDeprecated(): void + { + $container = $this->createContainer('web'); + + $dataResponseFormatter = $container->get(DataResponseFormatterInterface::class); + $dataResponseFactory = $container->get(DeprecatedDataResponseFactoryInterface::class); $contentNegotiator = $container->get(ContentNegotiator::class); $this->assertInstanceOf(HtmlDataResponseFormatter::class, $dataResponseFormatter); - $this->assertInstanceOf(DataResponseFactory::class, $dataResponseFactory); + $this->assertInstanceOf(DeprecatedDataResponseFactory::class, $dataResponseFactory); $this->assertInstanceOf(ContentNegotiator::class, $contentNegotiator); } diff --git a/tests/DataStream/DataStreamTest.php b/tests/DataStream/DataStreamTest.php new file mode 100644 index 0000000..ec682f4 --- /dev/null +++ b/tests/DataStream/DataStreamTest.php @@ -0,0 +1,444 @@ +assertSame('test data', (string) $stream); + $this->assertFalse($stream->hasFormatter()); + } + + public function testFormatter(): void + { + $stream = new DataStream('test', new JsonFormatter()); + + $this->assertTrue($stream->hasFormatter()); + $this->assertSame('"test"', (string) $stream); + } + + public function testFallbackFormatter(): void + { + $stream = new DataStream( + 'test', + fallbackFormatter: new JsonFormatter(), + ); + + $this->assertFalse($stream->hasFormatter()); + $this->assertSame('"test"', (string) $stream); + } + + public function testChangeFormatter(): void + { + $formatter = new JsonFormatter(); + $stream = new DataStream('test'); + + $stream->changeFormatter($formatter); + + $this->assertTrue($stream->hasFormatter()); + $this->assertSame('"test"', (string) $stream); + } + + public function testCloseStreamOnChangeFormatter(): void + { + $formatted = new StringStream(); + $stream = new DataStream('hello', new StubFormatter($formatted)); + + $stream->getContents(); + $stream->changeFormatter(new JsonFormatter()); + + $this->assertTrue($formatted->isClosed()); + $this->assertFalse($formatted->isDetached()); + } + + public function testChangeData(): void + { + $stream = new DataStream('hello'); + + $stream->changeData('world'); + + $this->assertSame('world', (string) $stream); + } + + public function testCloseStreamOnChangeData(): void + { + $formatted = new StringStream(); + $stream = new DataStream('hello', new StubFormatter($formatted)); + + $stream->getContents(); + $stream->changeData('world'); + + $this->assertTrue($formatted->isClosed()); + $this->assertFalse($formatted->isDetached()); + } + + public function testClose(): void + { + $formatted = new StringStream(); + $stream = new DataStream('data', new StubFormatter($formatted)); + + $stream->close(); + + $this->assertTrue($formatted->isClosed()); + } + + public function testDetach(): void + { + $formatted = new StringStream(); + $stream = new DataStream('data', new StubFormatter($formatted)); + + $stream->detach(); + + $this->assertTrue($formatted->isDetached()); + } + + public function testGetSize(): void + { + $formatted = new StringStream('hello'); + $stream = new DataStream('data', new StubFormatter($formatted)); + + $result = $stream->getSize(); + + $this->assertSame(5, $result); + } + + public function testTell(): void + { + $formatted = new StringStream('hello', position: 3); + $stream = new DataStream('data', new StubFormatter($formatted)); + + $result = $stream->tell(); + + $this->assertSame(3, $result); + } + + #[TestWith([true, 5])] + #[TestWith([false, 3])] + public function testEof(bool $expected, int $position): void + { + $formatted = new StringStream('hello', position: $position); + $stream = new DataStream('data', new StubFormatter($formatted)); + + $result = $stream->eof(); + + $this->assertSame($expected, $result); + } + + #[TestWith([true])] + #[TestWith([false])] + public function testIsSeekable(bool $expected): void + { + $formatted = new StringStream(seekable: $expected); + $stream = new DataStream('data', new StubFormatter($formatted)); + + $result = $stream->isSeekable(); + + $this->assertSame($expected, $result); + } + + public function testSeekSet(): void + { + $formatted = new StringStream('hello'); + $stream = new DataStream('data', new StubFormatter($formatted)); + + $stream->seek(3); + + $this->assertSame(3, $formatted->getPosition()); + } + + public function testSeekCur(): void + { + $formatted = new StringStream('hello', position: 1); + $stream = new DataStream('data', new StubFormatter($formatted)); + + $stream->seek(3, SEEK_CUR); + + $this->assertSame(4, $formatted->getPosition()); + } + + public function testRewind(): void + { + $formatted = new StringStream('hello', position: 3); + $stream = new DataStream('data', new StubFormatter($formatted)); + + $stream->rewind(); + + $this->assertSame(0, $formatted->getPosition()); + } + + #[TestWith([true])] + #[TestWith([false])] + public function testIsWritable(bool $expected): void + { + $formatted = new StringStream(writable: $expected); + $stream = new DataStream('data', new StubFormatter($formatted)); + + $result = $stream->isWritable(); + + $this->assertSame($expected, $result); + } + + public function testWrite(): void + { + $formatted = new StringStream('hello', position: 5); + $stream = new DataStream('data', new StubFormatter($formatted)); + + $result = $stream->write(', world'); + + $this->assertSame(7, $result); + $this->assertSame('hello, world', (string) $formatted); + } + + #[TestWith([true])] + #[TestWith([false])] + public function testIsReadable(bool $expected): void + { + $formatted = new StringStream(readable: $expected); + $stream = new DataStream('data', new StubFormatter($formatted)); + + $result = $stream->isReadable(); + + $this->assertSame($expected, $result); + } + + public function testRead(): void + { + $formatted = new StringStream('abcdef'); + $stream = new DataStream('data', new StubFormatter($formatted)); + + $this->assertSame('ab', $stream->read(2)); + $this->assertSame('cde', $stream->read(3)); + } + + public function testGetContents(): void + { + $formatted = new StringStream('hello'); + $stream = new DataStream('data', new StubFormatter($formatted)); + + $result = $stream->getContents(); + + $this->assertSame('hello', $result); + } + + public function testGetMetadata(): void + { + $formatted = new StringStream('hello', metadata: ['foo' => 'bar']); + $stream = new DataStream('data', new StubFormatter($formatted)); + + $result = $stream->getMetadata(); + + $this->assertSame(['foo' => 'bar'], $result); + } + + public function testGetMetadataWithKey(): void + { + $formatted = new StringStream('hello', metadata: ['foo' => 'bar']); + $stream = new DataStream('data', new StubFormatter($formatted)); + + $this->assertSame('bar', $stream->getMetadata('foo')); + $this->assertNull($stream->getMetadata('not-exists')); + } + + public function testStringData(): void + { + $stream = new DataStream('test'); + + $this->assertSame('test', (string) $stream); + $this->assertSame('test', $stream->getContents()); + $this->assertSame(4, $stream->getSize()); + $this->assertSame( + [ + 'eof' => true, + 'seekable' => true, + ], + $stream->getMetadata(), + ); + $this->assertTrue($stream->getMetadata('eof')); + $this->assertTrue($stream->getMetadata('seekable')); + $this->assertNull($stream->getMetadata('not-exists')); + $this->assertFalse($stream->isWritable()); + $this->assertTrue($stream->isReadable()); + $this->assertTrue($stream->isSeekable()); + } + + public function testTellInClosedStreamWithStringData(): void + { + $stream = new DataStream('test'); + $stream->close(); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Stream is closed.'); + $stream->tell(); + } + + public function testSeekInClosedStreamWithStringData(): void + { + $stream = new DataStream('test'); + $stream->close(); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Stream is closed.'); + $stream->seek(0); + } + + public function testRewindInClosedStreamWithStringData(): void + { + $stream = new DataStream('test'); + $stream->close(); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Stream is closed.'); + $stream->seek(0); + } + + public function testReadInClosedStreamWithStringData(): void + { + $stream = new DataStream('test'); + $stream->close(); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Stream is closed.'); + $stream->read(5); + } + + public function testDetachWithStringData(): void + { + $stream = new DataStream('test'); + + $result = $stream->detach(); + + $this->assertNull($result); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Stream is closed.'); + $stream->read(5); + } + + public function testTellWithStringData(): void + { + $stream = new DataStream('test'); + + $this->assertSame(0, $stream->tell()); + + $stream->read(2); + $this->assertSame(2, $stream->tell()); + + $stream->read(100); + $this->assertSame(4, $stream->tell()); + } + + public function testSeekSetWithStringData(): void + { + $stream = new DataStream('test'); + + $stream->seek(2); + $result = $stream->read(2); + + $this->assertSame('st', $result); + } + + public function testSeekCurWithStringData(): void + { + $stream = new DataStream('abcdef'); + $stream->read(1); + + $stream->seek(2, SEEK_CUR); + $result = $stream->read(2); + + $this->assertSame('de', $result); + } + + public function testSeekEndWithStringData(): void + { + $stream = new DataStream('abcdefg'); + + $stream->seek(-3, SEEK_END); + $result = $stream->read(3); + + $this->assertSame('efg', $result); + } + + public function testInvalidWhenceWithStringData(): void + { + $stream = new DataStream('test'); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Invalid whence value.'); + $stream->seek(1, 9); + } + + #[TestWith([-5])] + #[TestWith([100])] + public function testInvalidOffsetWithStringData(int $value): void + { + $stream = new DataStream('test'); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Invalid seek position.'); + $stream->seek($value); + } + + public function testGetMetadataInClosedStreamWithStringData(): void + { + $stream = new DataStream('test'); + $stream->close(); + + $this->assertSame([], $stream->getMetadata()); + $this->assertSame(null, $stream->getMetadata('eof')); + } + + public function testWriteWithStringData(): void + { + $stream = new DataStream('test'); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Stream is not writable.'); + $stream->write('hello'); + } + + public function testReadNegativeValueWithStringData(): void + { + $stream = new DataStream('test'); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Length must be non-negative.'); + $stream->read(-1); + } + + public function testReadOverValueWithStringData(): void + { + $stream = new DataStream('test'); + $stream->getContents(); + + $result = $stream->read(2); + + $this->assertSame('', $result); + } + + public function testRewindWithStringData(): void + { + $stream = new DataStream('test'); + $stream->getContents(); + + $stream->rewind(); + $result = $stream->read(2); + + $this->assertSame('te', $result); + } +} diff --git a/tests/Formatter/HtmlFormatterTest.php b/tests/Formatter/HtmlFormatterTest.php new file mode 100644 index 0000000..8c605b0 --- /dev/null +++ b/tests/Formatter/HtmlFormatterTest.php @@ -0,0 +1,104 @@ + ['', null]; + yield 'string' => ['test', 'test']; + yield 'empty string' => ['', '']; + yield 'integer' => ['42', 42]; + yield 'float' => ['3.14', 3.14]; + yield 'bool true' => ['1', true]; + yield 'bool false' => ['', false]; + yield 'stringable object' => [ + 'stringable content', + new class implements Stringable { + public function __toString(): string + { + return 'stringable content'; + } + }, + ]; + } + + #[DataProvider('dataFormatData')] + public function testFormatData(string $expected, mixed $data): void + { + $formatter = new HtmlFormatter(); + + $result = $formatter->formatData($data); + + $this->assertSame($expected, $result); + } + + public static function dataFormatDataWithUnsupportedValue(): iterable + { + yield 'array' => [['test']]; + yield 'non-stringable object' => [new stdClass()]; + yield 'resource' => [fopen('php://memory', 'r')]; + } + + #[DataProvider('dataFormatDataWithUnsupportedValue')] + public function testFormatDataWithUnsupportedValue(mixed $data): void + { + $formatter = new HtmlFormatter(); + + $this->expectException(DataEncodingException::class); + $this->expectExceptionMessage( + 'Data must be either a scalar value, null, or a stringable object. ' . get_debug_type($data) . ' given.', + ); + $formatter->formatData($data); + } + + public function testFormatResponse(): void + { + $formatter = new HtmlFormatter(); + + $response = $formatter->formatResponse(new Response()); + + $this->assertSame('text/html; charset=UTF-8', $response->getHeaderLine(Header::CONTENT_TYPE)); + } + + public function testFormatResponseWithCustomContentType(): void + { + $formatter = new HtmlFormatter(contentType: 'text/xhtml'); + + $response = $formatter->formatResponse(new Response()); + + $this->assertSame('text/xhtml; charset=UTF-8', $response->getHeaderLine(Header::CONTENT_TYPE)); + } + + public function testFormatResponseWithCustomEncoding(): void + { + $formatter = new HtmlFormatter(encoding: 'ISO-8859-1'); + + $response = $formatter->formatResponse(new Response()); + + $this->assertSame('text/html; charset=ISO-8859-1', $response->getHeaderLine(Header::CONTENT_TYPE)); + } + + public function testFormatResponseReplacesExistingContentTypeHeader(): void + { + $formatter = new HtmlFormatter(); + + $response = $formatter->formatResponse( + (new Response())->withHeader(Header::CONTENT_TYPE, 'text/plain'), + ); + + $this->assertSame('text/html; charset=UTF-8', $response->getHeaderLine(Header::CONTENT_TYPE)); + } +} diff --git a/tests/Formatter/JsonFormatterTest.php b/tests/Formatter/JsonFormatterTest.php new file mode 100644 index 0000000..488bcb1 --- /dev/null +++ b/tests/Formatter/JsonFormatterTest.php @@ -0,0 +1,109 @@ + ['null', null]; + yield 'string' => ['"test"', 'test']; + yield 'empty string' => ['""', '']; + yield 'integer' => ['42', 42]; + yield 'float' => ['3.14', 3.14]; + yield 'bool true' => ['true', true]; + yield 'bool false' => ['false', false]; + yield 'array' => ['["a","b"]', ['a', 'b']]; + yield 'associative array' => ['{"key":"value"}', ['key' => 'value']]; + yield 'object' => ['{"property":"value"}', (object) ['property' => 'value']]; + yield 'unicode' => ['"тест"', 'тест']; + yield 'slashes' => ['"/path/to/file"', '/path/to/file']; + } + + #[DataProvider('dataFormatData')] + public function testFormatData(string $expected, mixed $data): void + { + $formatter = new JsonFormatter(); + + $result = $formatter->formatData($data); + + $this->assertSame($expected, $result); + } + + public function testFormatDataWithUnsupportedValue(): void + { + $formatter = new JsonFormatter(); + $resource = fopen('php://memory', 'r'); + + $this->expectException(DataEncodingException::class); + $formatter->formatData($resource); + } + + public function testFormatDataWithCustomOptions(): void + { + $formatter = new JsonFormatter(options: JSON_FORCE_OBJECT); + + $result = $formatter->formatData(['a', 'b']); + + $this->assertSame('{"0":"a","1":"b"}', $result); + } + + public function testFormatDataWithPrettyPrint(): void + { + $formatter = new JsonFormatter(options: JSON_PRETTY_PRINT); + + $result = $formatter->formatData(['key' => 'value']); + + $this->assertSame("{\n \"key\": \"value\"\n}", $result); + } + + public function testFormatResponse(): void + { + $formatter = new JsonFormatter(); + + $response = $formatter->formatResponse(new Response()); + + $this->assertSame('application/json; charset=UTF-8', $response->getHeaderLine(Header::CONTENT_TYPE)); + } + + public function testFormatResponseWithCustomContentType(): void + { + $formatter = new JsonFormatter(contentType: 'application/vnd.api+json'); + + $response = $formatter->formatResponse(new Response()); + + $this->assertSame('application/vnd.api+json; charset=UTF-8', $response->getHeaderLine(Header::CONTENT_TYPE)); + } + + public function testFormatResponseWithCustomEncoding(): void + { + $formatter = new JsonFormatter(encoding: 'ISO-8859-1'); + + $response = $formatter->formatResponse(new Response()); + + $this->assertSame('application/json; charset=ISO-8859-1', $response->getHeaderLine(Header::CONTENT_TYPE)); + } + + public function testFormatResponseReplacesExistingContentTypeHeader(): void + { + $formatter = new JsonFormatter(); + + $response = $formatter->formatResponse( + (new Response())->withHeader(Header::CONTENT_TYPE, 'text/plain'), + ); + + $this->assertSame('application/json; charset=UTF-8', $response->getHeaderLine(Header::CONTENT_TYPE)); + } +} diff --git a/tests/Formatter/PlainTextFormatterTest.php b/tests/Formatter/PlainTextFormatterTest.php new file mode 100644 index 0000000..46bd94a --- /dev/null +++ b/tests/Formatter/PlainTextFormatterTest.php @@ -0,0 +1,104 @@ + ['', null]; + yield 'string' => ['test', 'test']; + yield 'empty string' => ['', '']; + yield 'integer' => ['42', 42]; + yield 'float' => ['3.14', 3.14]; + yield 'bool true' => ['1', true]; + yield 'bool false' => ['', false]; + yield 'stringable object' => [ + 'stringable content', + new class implements Stringable { + public function __toString(): string + { + return 'stringable content'; + } + }, + ]; + } + + #[DataProvider('dataFormatData')] + public function testFormatData(string $expected, mixed $data): void + { + $formatter = new PlainTextFormatter(); + + $result = $formatter->formatData($data); + + $this->assertSame($expected, $result); + } + + public static function dataFormatDataWithUnsupportedValue(): iterable + { + yield 'array' => [['test']]; + yield 'non-stringable object' => [new stdClass()]; + yield 'resource' => [fopen('php://memory', 'r')]; + } + + #[DataProvider('dataFormatDataWithUnsupportedValue')] + public function testFormatDataWithUnsupportedValue(mixed $data): void + { + $formatter = new PlainTextFormatter(); + + $this->expectException(DataEncodingException::class); + $this->expectExceptionMessage( + 'Data must be either a scalar value, null, or a stringable object. ' . get_debug_type($data) . ' given.', + ); + $formatter->formatData($data); + } + + public function testFormatResponse(): void + { + $formatter = new PlainTextFormatter(); + + $response = $formatter->formatResponse(new Response()); + + $this->assertSame('text/plain; charset=UTF-8', $response->getHeaderLine(Header::CONTENT_TYPE)); + } + + public function testFormatResponseWithCustomContentType(): void + { + $formatter = new PlainTextFormatter(contentType: 'text/csv'); + + $response = $formatter->formatResponse(new Response()); + + $this->assertSame('text/csv; charset=UTF-8', $response->getHeaderLine(Header::CONTENT_TYPE)); + } + + public function testFormatResponseWithCustomEncoding(): void + { + $formatter = new PlainTextFormatter(encoding: 'ISO-8859-1'); + + $response = $formatter->formatResponse(new Response()); + + $this->assertSame('text/plain; charset=ISO-8859-1', $response->getHeaderLine(Header::CONTENT_TYPE)); + } + + public function testFormatResponseReplacesExistingContentTypeHeader(): void + { + $formatter = new PlainTextFormatter(); + + $response = $formatter->formatResponse( + (new Response())->withHeader(Header::CONTENT_TYPE, 'application/json'), + ); + + $this->assertSame('text/plain; charset=UTF-8', $response->getHeaderLine(Header::CONTENT_TYPE)); + } +} diff --git a/tests/Formatter/XmlFormatterTest.php b/tests/Formatter/XmlFormatterTest.php new file mode 100644 index 0000000..34812bf --- /dev/null +++ b/tests/Formatter/XmlFormatterTest.php @@ -0,0 +1,240 @@ + [null]; + yield 'empty array' => [[]]; + yield 'empty string' => ['']; + yield 'zero' => [0]; + yield 'false' => [false]; + } + + #[DataProvider('dataFormatDataWithEmptyData')] + public function testFormatDataWithEmptyData(mixed $data): void + { + $formatter = new XmlFormatter(); + + $result = $formatter->formatData($data); + + $this->assertSame('', $result); + } + + public static function dataFormatData(): iterable + { + yield 'string' => [ + self::xml('test'), + 'test', + ]; + yield 'integer' => [ + self::xml('42'), + 42, + ]; + yield 'float' => [ + self::xml('3.14'), + 3.14, + ]; + yield 'bool true' => [ + self::xml('true'), + true, + ]; + yield 'simple array' => [ + self::xml('ab'), + ['a', 'b'], + ]; + yield 'associative array' => [ + self::xml('value'), + ['key' => 'value'], + ]; + yield 'nested array' => [ + self::xml('value'), + ['parent' => ['child' => 'value']], + ]; + yield 'nested empty array' => [ + self::xml(''), + ['parent' => []], + ]; + yield 'mixed array' => [ + self::xml('test5true'), + ['name' => 'test', 'count' => 5, 'active' => true], + ]; + yield 'bool false in array' => [ + self::xml('false'), + ['enabled' => false], + ]; + yield 'invalid xml tag name' => [ + self::xml('value'), + ['1invalid' => 'value'], + ]; + yield 'object' => [ + self::xml('test42'), + (object) ['name' => 'test', 'value' => 42], + ]; + yield 'traversable' => [ + self::xml('abc'), + new ArrayIterator(['a', 'b', 'c']), + ]; + yield 'traversable in array' => [ + self::xml('ab'), + ['items' => new ArrayIterator(['a', 'b'])], + ]; + yield 'XmlDataInterface' => [ + self::xml('value'), + new class implements XmlDataInterface { + public function xmlTagName(): string + { + return 'custom'; + } + + public function xmlTagAttributes(): array + { + return ['id' => '1', 'type' => 'test']; + } + + public function xmlData(): array + { + return ['name' => 'value']; + } + }, + ]; + yield 'nested XmlDataInterface' => [ + self::xml('nested'), + [ + 'items' => [ + new class implements XmlDataInterface { + public function xmlTagName(): string + { + return 'inner'; + } + + public function xmlTagAttributes(): array + { + return []; + } + + public function xmlData(): array + { + return ['value' => 'nested']; + } + }, + ], + ], + ]; + } + + #[DataProvider('dataFormatData')] + public function testFormatData(string $expected, mixed $data): void + { + $formatter = new XmlFormatter(); + + $result = $formatter->formatData($data); + + $this->assertSame($expected, $result); + } + + public function testFormatDataWithCustomRootTag(): void + { + $formatter = new XmlFormatter(rootTag: 'data'); + + $result = $formatter->formatData(['key' => 'value']); + + $this->assertSame( + self::xml('value'), + $result, + ); + } + + public function testFormatDataWithEmptyRootTag(): void + { + $formatter = new XmlFormatter(rootTag: ''); + + $result = $formatter->formatData(['key' => 'value']); + + $this->assertSame( + self::xml('value'), + $result, + ); + } + + public function testFormatDataWithCustomVersion(): void + { + $formatter = new XmlFormatter(version: '1.1'); + + $result = $formatter->formatData(['key' => 'value']); + + $this->assertSame( + self::xml('value', '1.1'), + $result, + ); + } + + public function testFormatDataWithCustomEncoding(): void + { + $formatter = new XmlFormatter(encoding: 'ISO-8859-1'); + + $result = $formatter->formatData(['key' => 'value']); + + $this->assertSame( + self::xml('value', encoding: 'ISO-8859-1'), + $result, + ); + } + + public function testFormatResponse(): void + { + $formatter = new XmlFormatter(); + + $response = $formatter->formatResponse(new Response()); + + $this->assertSame('application/xml; charset=UTF-8', $response->getHeaderLine(Header::CONTENT_TYPE)); + } + + public function testFormatResponseWithCustomContentType(): void + { + $formatter = new XmlFormatter(contentType: 'text/xml'); + + $response = $formatter->formatResponse(new Response()); + + $this->assertSame('text/xml; charset=UTF-8', $response->getHeaderLine(Header::CONTENT_TYPE)); + } + + public function testFormatResponseWithCustomEncoding(): void + { + $formatter = new XmlFormatter(encoding: 'ISO-8859-1'); + + $response = $formatter->formatResponse(new Response()); + + $this->assertSame('application/xml; charset=ISO-8859-1', $response->getHeaderLine(Header::CONTENT_TYPE)); + } + + public function testFormatResponseReplacesExistingContentTypeHeader(): void + { + $formatter = new XmlFormatter(); + + $response = $formatter->formatResponse( + (new Response())->withHeader(Header::CONTENT_TYPE, 'text/plain'), + ); + + $this->assertSame('application/xml; charset=UTF-8', $response->getHeaderLine(Header::CONTENT_TYPE)); + } + + private static function xml(string $content, string $version = '1.0', string $encoding = 'UTF-8'): string + { + return sprintf('%s', $version, $encoding, "\n" . $content . "\n"); + } +} diff --git a/tests/Middleware/ContentNegotiatorDataResponseMiddlewareTest.php b/tests/Middleware/ContentNegotiatorDataResponseMiddlewareTest.php new file mode 100644 index 0000000..c1f9440 --- /dev/null +++ b/tests/Middleware/ContentNegotiatorDataResponseMiddlewareTest.php @@ -0,0 +1,167 @@ + 'value']); + $response = (new Response())->withBody($dataStream); + $middleware = new ContentNegotiatorDataResponseMiddleware([ + 'application/json' => new JsonFormatter(), + 'application/xml' => new XmlFormatter(), + ]); + + $result = $middleware->process( + (new ServerRequest())->withHeader(Header::ACCEPT, 'application/json'), + new StubRequestHandler($response), + ); + + $this->assertSame('{"key":"value"}', (string) $result->getBody()); + $this->assertSame('application/json; charset=UTF-8', $result->getHeaderLine(Header::CONTENT_TYPE)); + } + + public function testProcessWithMultipleAcceptHeaders(): void + { + $dataStream = new DataStream(['key' => 'value']); + $response = (new Response())->withBody($dataStream); + $middleware = new ContentNegotiatorDataResponseMiddleware([ + 'application/json' => new JsonFormatter(), + 'application/xml' => new XmlFormatter(), + ]); + + $result = $middleware->process( + (new ServerRequest())->withHeader( + Header::ACCEPT, + 'text/html, application/xml;q=0.9, application/json;q=0.8', + ), + new StubRequestHandler($response), + ); + + $this->assertSame('application/xml; charset=UTF-8', $result->getHeaderLine(Header::CONTENT_TYPE)); + } + + public function testProcessWithNoMatchingAcceptHeaderUsesFallback(): void + { + $dataStream = new DataStream('test content'); + $response = (new Response())->withBody($dataStream); + $middleware = new ContentNegotiatorDataResponseMiddleware( + ['application/json' => new JsonFormatter()], + new PlainTextFormatter(), + ); + + $result = $middleware->process( + (new ServerRequest())->withHeader(Header::ACCEPT, 'text/html'), + new StubRequestHandler($response), + ); + + $this->assertSame('test content', (string) $result->getBody()); + $this->assertSame('text/plain; charset=UTF-8', $result->getHeaderLine(Header::CONTENT_TYPE)); + } + + public function testProcessWithNoMatchingAcceptHeaderAndNoFallback(): void + { + $dataStream = new DataStream(['key' => 'value']); + $response = (new Response())->withBody($dataStream); + $middleware = new ContentNegotiatorDataResponseMiddleware([ + 'application/json' => new JsonFormatter(), + ]); + + $result = $middleware->process( + (new ServerRequest())->withHeader(Header::ACCEPT, 'text/html'), + new StubRequestHandler($response), + ); + + $this->assertSame($response, $result); + } + + public function testProcessWithNonDataStreamBody(): void + { + $stream = (new StreamFactory())->createStream('plain content'); + $response = (new Response())->withBody($stream); + $middleware = new ContentNegotiatorDataResponseMiddleware([ + 'application/json' => new JsonFormatter(), + ]); + + $result = $middleware->process( + (new ServerRequest())->withHeader(Header::ACCEPT, 'application/json'), + new StubRequestHandler($response), + ); + + $this->assertSame($response, $result); + } + + public function testProcessWithDataStreamWithFormatter(): void + { + $dataStream = new DataStream(['key' => 'value'], new HtmlFormatter()); + $response = (new Response())->withBody($dataStream); + $middleware = new ContentNegotiatorDataResponseMiddleware([ + 'application/json' => new JsonFormatter(), + ]); + + $result = $middleware->process( + (new ServerRequest())->withHeader(Header::ACCEPT, 'application/json'), + new StubRequestHandler($response), + ); + + $this->assertSame($response, $result); + } + + public function testConstructorWithInvalidContentType(): void + { + $formatters = [new JsonFormatter()]; + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Invalid formatter content type. A string is expected, "integer" is received.'); + new ContentNegotiatorDataResponseMiddleware($formatters); + } + + public function testConstructorWithInvalidFormatter(): void + { + $formatters = [ + 'application/json' => new stdClass(), + ]; + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage( + 'Invalid formatter. A "Yiisoft\DataResponse\Formatter\FormatterInterface" instance is expected, "stdClass" is received.', + ); + new ContentNegotiatorDataResponseMiddleware($formatters); + } + + public function testProcessWithNoMatchingAcceptHeaderUsesRequestHandlerFallback(): void + { + $dataStream = new DataStream(['key' => 'value']); + $response = (new Response())->withBody($dataStream); + $fallbackResponse = (new Response())->withStatus(406); + $middleware = new ContentNegotiatorDataResponseMiddleware( + ['application/json' => new JsonFormatter()], + new StubRequestHandler($fallbackResponse), + ); + + $result = $middleware->process( + (new ServerRequest())->withHeader(Header::ACCEPT, 'text/html'), + new StubRequestHandler($response), + ); + + $this->assertSame($fallbackResponse, $result); + } +} diff --git a/tests/Middleware/DataResponseMiddlewareTest.php b/tests/Middleware/DataResponseMiddlewareTest.php new file mode 100644 index 0000000..8b4f664 --- /dev/null +++ b/tests/Middleware/DataResponseMiddlewareTest.php @@ -0,0 +1,64 @@ + 'value']; + $dataStream = new DataStream($data); + $response = (new Response())->withBody($dataStream); + $middleware = new DataResponseMiddleware(new JsonFormatter()); + + $result = $middleware->process( + new ServerRequest(), + new StubRequestHandler($response), + ); + + $this->assertSame('{"key":"value"}', (string) $result->getBody()); + $this->assertSame('application/json; charset=UTF-8', $result->getHeaderLine(Header::CONTENT_TYPE)); + } + + public function testProcessWithDataStreamWithFormatter(): void + { + $data = ['key' => 'value']; + $dataStream = new DataStream($data, new PlainTextFormatter()); + $response = (new Response())->withBody($dataStream); + $middleware = new DataResponseMiddleware(new JsonFormatter()); + + $result = $middleware->process( + new ServerRequest(), + new StubRequestHandler($response), + ); + + $this->assertSame($response, $result); + } + + public function testProcessWithNonDataStreamBody(): void + { + $stream = (new StreamFactory())->createStream('plain content'); + $response = (new Response())->withBody($stream); + $middleware = new DataResponseMiddleware(new JsonFormatter()); + + $result = $middleware->process( + new ServerRequest(), + new StubRequestHandler($response), + ); + + $this->assertSame($response, $result); + } +} diff --git a/tests/Middleware/HtmlDataResponseMiddlewareTest.php b/tests/Middleware/HtmlDataResponseMiddlewareTest.php new file mode 100644 index 0000000..7ba1621 --- /dev/null +++ b/tests/Middleware/HtmlDataResponseMiddlewareTest.php @@ -0,0 +1,31 @@ +withBody($dataStream); + $middleware = new HtmlDataResponseMiddleware(); + + $result = $middleware->process( + new ServerRequest(), + new StubRequestHandler($response), + ); + + $this->assertSame('test content', (string) $result->getBody()); + $this->assertSame('text/html; charset=UTF-8', $result->getHeaderLine(Header::CONTENT_TYPE)); + } +} diff --git a/tests/Middleware/JsonDataResponseMiddlewareTest.php b/tests/Middleware/JsonDataResponseMiddlewareTest.php new file mode 100644 index 0000000..0815402 --- /dev/null +++ b/tests/Middleware/JsonDataResponseMiddlewareTest.php @@ -0,0 +1,31 @@ + 'value']); + $response = (new Response())->withBody($dataStream); + $middleware = new JsonDataResponseMiddleware(); + + $result = $middleware->process( + new ServerRequest(), + new StubRequestHandler($response), + ); + + $this->assertSame('{"key":"value"}', (string) $result->getBody()); + $this->assertSame('application/json; charset=UTF-8', $result->getHeaderLine(Header::CONTENT_TYPE)); + } +} diff --git a/tests/Middleware/PlainTextDataResponseMiddlewareTest.php b/tests/Middleware/PlainTextDataResponseMiddlewareTest.php new file mode 100644 index 0000000..243c9c9 --- /dev/null +++ b/tests/Middleware/PlainTextDataResponseMiddlewareTest.php @@ -0,0 +1,31 @@ +withBody($dataStream); + $middleware = new PlainTextDataResponseMiddleware(); + + $result = $middleware->process( + new ServerRequest(), + new StubRequestHandler($response), + ); + + $this->assertSame('test content', (string) $result->getBody()); + $this->assertSame('text/plain; charset=UTF-8', $result->getHeaderLine(Header::CONTENT_TYPE)); + } +} diff --git a/tests/Middleware/XmlDataResponseMiddlewareTest.php b/tests/Middleware/XmlDataResponseMiddlewareTest.php new file mode 100644 index 0000000..6251c0f --- /dev/null +++ b/tests/Middleware/XmlDataResponseMiddlewareTest.php @@ -0,0 +1,38 @@ + 'value']); + $response = (new Response())->withBody($dataStream); + $middleware = new XmlDataResponseMiddleware(); + + $result = $middleware->process( + new ServerRequest(), + new StubRequestHandler($response), + ); + + $this->assertSame( + << + value + + XML, + (string) $result->getBody(), + ); + $this->assertSame('application/xml; charset=UTF-8', $result->getHeaderLine(Header::CONTENT_TYPE)); + } +} diff --git a/tests/NotAcceptableRequestHandlerTest.php b/tests/NotAcceptableRequestHandlerTest.php new file mode 100644 index 0000000..05ae8d2 --- /dev/null +++ b/tests/NotAcceptableRequestHandlerTest.php @@ -0,0 +1,21 @@ +handle($this->createRequest()); + + $this->assertSame(Status::NOT_ACCEPTABLE, $response->getStatusCode()); + } +} diff --git a/tests/ResponseFactory/ContentNegotiatorResponseFactoryTest.php b/tests/ResponseFactory/ContentNegotiatorResponseFactoryTest.php new file mode 100644 index 0000000..566bd9a --- /dev/null +++ b/tests/ResponseFactory/ContentNegotiatorResponseFactoryTest.php @@ -0,0 +1,140 @@ + new JsonResponseFactory($responseFactory, new JsonFormatter()), + 'application/xml' => new XmlResponseFactory($responseFactory, new XmlFormatter()), + ], + new PlainTextResponseFactory($responseFactory, new PlainTextFormatter()), + ); + + $request = (new ServerRequest())->withHeader(Header::ACCEPT, 'application/json'); + $response = $factory->createResponse($request, ['key' => 'value']); + + $this->assertSame('{"key":"value"}', (string) $response->getBody()); + $this->assertSame('application/json; charset=UTF-8', $response->getHeaderLine(Header::CONTENT_TYPE)); + } + + public function testCreateResponseWithMultipleAcceptHeaders(): void + { + $responseFactory = new ResponseFactory(); + $factory = new ContentNegotiatorResponseFactory( + [ + 'application/json' => new JsonResponseFactory($responseFactory, new JsonFormatter()), + 'application/xml' => new XmlResponseFactory($responseFactory, new XmlFormatter()), + ], + new PlainTextResponseFactory($responseFactory, new PlainTextFormatter()), + ); + + $request = (new ServerRequest())->withHeader( + Header::ACCEPT, + 'text/html, application/xml;q=0.9, application/json;q=0.8', + ); + $response = $factory->createResponse($request, ['key' => 'value']); + + $this->assertSame('application/xml; charset=UTF-8', $response->getHeaderLine(Header::CONTENT_TYPE)); + } + + public function testCreateResponseWithNoMatchingAcceptHeaderUsesFallback(): void + { + $responseFactory = new ResponseFactory(); + $factory = new ContentNegotiatorResponseFactory( + [ + 'application/json' => new JsonResponseFactory($responseFactory, new JsonFormatter()), + ], + new PlainTextResponseFactory($responseFactory, new PlainTextFormatter()), + ); + + $request = (new ServerRequest())->withHeader(Header::ACCEPT, 'text/html'); + $response = $factory->createResponse($request, 'test content'); + + $this->assertSame('test content', (string) $response->getBody()); + $this->assertSame('text/plain; charset=UTF-8', $response->getHeaderLine(Header::CONTENT_TYPE)); + } + + public function testCreateResponseWithCustomStatusCode(): void + { + $responseFactory = new ResponseFactory(); + $factory = new ContentNegotiatorResponseFactory( + [ + 'application/json' => new JsonResponseFactory($responseFactory, new JsonFormatter()), + ], + new PlainTextResponseFactory($responseFactory, new PlainTextFormatter()), + ); + + $request = (new ServerRequest())->withHeader(Header::ACCEPT, 'application/json'); + $response = $factory->createResponse($request, ['error' => 'not found'], Status::NOT_FOUND, 'Not Found'); + + $this->assertSame(Status::NOT_FOUND, $response->getStatusCode()); + $this->assertSame('Not Found', $response->getReasonPhrase()); + } + + public function testConstructorWithInvalidContentType(): void + { + $responseFactory = new ResponseFactory(); + $factories = [new JsonResponseFactory($responseFactory, new JsonFormatter())]; + $fallbackFactory = new PlainTextResponseFactory($responseFactory, new PlainTextFormatter()); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Invalid factory content type. A string is expected, "integer" is received.'); + new ContentNegotiatorResponseFactory($factories, $fallbackFactory); + } + + public function testConstructorWithInvalidFactory(): void + { + $responseFactory = new ResponseFactory(); + $factories = [ + 'application/json' => new stdClass(), + ]; + $fallbackFactory = new PlainTextResponseFactory($responseFactory, new PlainTextFormatter()); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage( + 'Invalid factory. A "Yiisoft\DataResponse\ResponseFactory\DataResponseFactoryInterface" instance is expected, "stdClass" is received.', + ); + new ContentNegotiatorResponseFactory($factories, $fallbackFactory); + } + + public function testCreateResponseWithNoMatchingAcceptHeaderUsesRequestHandlerFallback(): void + { + $responseFactory = new ResponseFactory(); + $fallbackResponse = (new Response())->withStatus(406); + $factory = new ContentNegotiatorResponseFactory( + [ + 'application/json' => new JsonResponseFactory($responseFactory, new JsonFormatter()), + ], + new StubRequestHandler($fallbackResponse), + ); + + $request = (new ServerRequest())->withHeader(Header::ACCEPT, 'text/html'); + $response = $factory->createResponse($request, 'test content'); + + $this->assertSame($fallbackResponse, $response); + } +} diff --git a/tests/ResponseFactory/DataResponseFactoryTest.php b/tests/ResponseFactory/DataResponseFactoryTest.php new file mode 100644 index 0000000..ab39a40 --- /dev/null +++ b/tests/ResponseFactory/DataResponseFactoryTest.php @@ -0,0 +1,65 @@ +createResponse(); + + $this->assertInstanceOf(DataStream::class, $response->getBody()); + $this->assertSame(Status::OK, $response->getStatusCode()); + $this->assertSame('OK', $response->getReasonPhrase()); + } + + public function testCreateResponseWithData(): void + { + $factory = new DataResponseFactory(new ResponseFactory()); + + $response = $factory->createResponse('hello'); + + $body = $response->getBody(); + $this->assertInstanceOf(DataStream::class, $body); + $this->assertSame('hello', (string) $body); + } + + public function testCreateResponseWithCustomStatusCode(): void + { + $factory = new DataResponseFactory(new ResponseFactory()); + + $response = $factory->createResponse(null, Status::CREATED); + + $this->assertSame(Status::CREATED, $response->getStatusCode()); + } + + public function testCreateResponseWithReasonPhrase(): void + { + $factory = new DataResponseFactory(new ResponseFactory()); + + $response = $factory->createResponse(null, Status::BAD_REQUEST, 'Custom Reason'); + + $this->assertSame(Status::BAD_REQUEST, $response->getStatusCode()); + $this->assertSame('Custom Reason', $response->getReasonPhrase()); + } + + public function testCreateResponseWithCustomFallbackFormatter(): void + { + $factory = new DataResponseFactory(new ResponseFactory(), new JsonFormatter()); + + $response = $factory->createResponse(['key' => 'value']); + + $this->assertSame('{"key":"value"}', (string) $response->getBody()); + } +} diff --git a/tests/ResponseFactory/HtmlResponseFactoryTest.php b/tests/ResponseFactory/HtmlResponseFactoryTest.php new file mode 100644 index 0000000..cea40ba --- /dev/null +++ b/tests/ResponseFactory/HtmlResponseFactoryTest.php @@ -0,0 +1,36 @@ +createResponse('test content'); + + $this->assertSame('test content', (string) $response->getBody()); + $this->assertSame('text/html; charset=UTF-8', $response->getHeaderLine(Header::CONTENT_TYPE)); + $this->assertSame(Status::OK, $response->getStatusCode()); + } + + public function testCreateResponseWithCustomStatusCode(): void + { + $factory = new HtmlResponseFactory(new ResponseFactory(), new HtmlFormatter()); + + $response = $factory->createResponse('error', Status::NOT_FOUND, 'Not Found'); + + $this->assertSame(Status::NOT_FOUND, $response->getStatusCode()); + $this->assertSame('Not Found', $response->getReasonPhrase()); + } +} diff --git a/tests/ResponseFactory/JsonResponseFactoryTest.php b/tests/ResponseFactory/JsonResponseFactoryTest.php new file mode 100644 index 0000000..9ba08a9 --- /dev/null +++ b/tests/ResponseFactory/JsonResponseFactoryTest.php @@ -0,0 +1,36 @@ +createResponse(['key' => 'value']); + + $this->assertSame('{"key":"value"}', (string) $response->getBody()); + $this->assertSame('application/json; charset=UTF-8', $response->getHeaderLine(Header::CONTENT_TYPE)); + $this->assertSame(Status::OK, $response->getStatusCode()); + } + + public function testCreateResponseWithCustomStatusCode(): void + { + $factory = new JsonResponseFactory(new ResponseFactory(), new JsonFormatter()); + + $response = $factory->createResponse(['error' => 'not found'], Status::NOT_FOUND, 'Not Found'); + + $this->assertSame(Status::NOT_FOUND, $response->getStatusCode()); + $this->assertSame('Not Found', $response->getReasonPhrase()); + } +} diff --git a/tests/ResponseFactory/PlainTextResponseFactoryTest.php b/tests/ResponseFactory/PlainTextResponseFactoryTest.php new file mode 100644 index 0000000..572f68c --- /dev/null +++ b/tests/ResponseFactory/PlainTextResponseFactoryTest.php @@ -0,0 +1,36 @@ +createResponse('test content'); + + $this->assertSame('test content', (string) $response->getBody()); + $this->assertSame('text/plain; charset=UTF-8', $response->getHeaderLine(Header::CONTENT_TYPE)); + $this->assertSame(Status::OK, $response->getStatusCode()); + } + + public function testCreateResponseWithCustomStatusCode(): void + { + $factory = new PlainTextResponseFactory(new ResponseFactory(), new PlainTextFormatter()); + + $response = $factory->createResponse('error', Status::NOT_FOUND, 'Not Found'); + + $this->assertSame(Status::NOT_FOUND, $response->getStatusCode()); + $this->assertSame('Not Found', $response->getReasonPhrase()); + } +} diff --git a/tests/ResponseFactory/XmlResponseFactoryTest.php b/tests/ResponseFactory/XmlResponseFactoryTest.php new file mode 100644 index 0000000..795546f --- /dev/null +++ b/tests/ResponseFactory/XmlResponseFactoryTest.php @@ -0,0 +1,41 @@ +createResponse(['key' => 'value']); + + $expected = << + value + + XML; + $this->assertSame($expected, (string) $response->getBody()); + $this->assertSame('application/xml; charset=UTF-8', $response->getHeaderLine(Header::CONTENT_TYPE)); + $this->assertSame(Status::OK, $response->getStatusCode()); + } + + public function testCreateResponseWithCustomStatusCode(): void + { + $factory = new XmlResponseFactory(new ResponseFactory(), new XmlFormatter()); + + $response = $factory->createResponse(['error' => 'not found'], Status::NOT_FOUND, 'Not Found'); + + $this->assertSame(Status::NOT_FOUND, $response->getStatusCode()); + $this->assertSame('Not Found', $response->getReasonPhrase()); + } +} diff --git a/tests/Support/StubFormatter.php b/tests/Support/StubFormatter.php new file mode 100644 index 0000000..a5fe002 --- /dev/null +++ b/tests/Support/StubFormatter.php @@ -0,0 +1,26 @@ +formattedData; + } + + public function formatResponse(ResponseInterface $response): ResponseInterface + { + return $response; + } +} diff --git a/tests/Support/StubRequestHandler.php b/tests/Support/StubRequestHandler.php new file mode 100644 index 0000000..1fa800a --- /dev/null +++ b/tests/Support/StubRequestHandler.php @@ -0,0 +1,21 @@ +response; + } +}