diff --git a/CHANGELOG.md b/CHANGELOG.md index 890a0e4..a7e3b40 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,25 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +## [3.0.0] - 2021-07-14 + +### Added + +- Brotli & ZStd Compression are now support when the correspoding php extensions are avaiable + +### Changed + +- Generic CompressEncoder middleware with with pluggable Compressors now replaces the individual `GzipEncoder` and `DefalteEncoder`. + +### Deprecated + +- The `GzipEncoder` and the `DeflateEncoder` are now deprecated, as they are + just shims for backwards compatibility. Please replace them with `CompressEncoder` + +### Removed + +- The `Encoder` class has been removed, as its no longer functional. + ## [2.1.1] - 2020-12-03 ### Added - Support for PHP 8.0 diff --git a/README.md b/README.md index c15036d..95565ac 100644 --- a/README.md +++ b/README.md @@ -5,18 +5,28 @@ ![Testing][ico-ga] [![Total Downloads][ico-downloads]][link-downloads] -Middleware to encode the response body to `gzip` or `deflate` if the `Accept-Encoding` header is present and adds the `Content-Encoding` header. This package is splitted into the following components: +Middleware to encode the response body using any of `gzip`, `deflate`, `brotli` or `zstd` compression (where available) if the `Accept-Encoding` header is present and can be matched. The `Content-Encoding` header is added when the body has been compressed. This package is split into the following components: -* [GzipEncoder](#gzipencoder) -* [DeflateEncoder](#deflateencoder) +* [CompressEncoder](#compressencoder) +* [BrotliCompressor](#brotlicompressor) +* [DeflateCompressor](#deflatecompressor) +* [GzipCompressor](#gzipcompressor) +* [DeflateCompressor](#deflatecompressor) +* (deprecated) [GzipEncoder](#gzipencoder-deprecated) +* (deprecated) [DeflateEncoder](#deflateencoder-deprecated) You can use the component `ContentEncoding` in the [middlewares/negotiation](https://github.com/middlewares/negotiation#contentencoding) to negotiate the encoding to use. ## Requirements * PHP >= 7.2 -* A [PSR-7 http library](https://github.com/middlewares/awesome-psr15-middlewares#psr-7-implementations) -* A [PSR-15 middleware dispatcher](https://github.com/middlewares/awesome-psr15-middlewares#dispatcher) +* A [PSR-7 HTTP Library](https://github.com/middlewares/awesome-psr15-middlewares#psr-7-implementations) +* A [PSR-15 Middleware Dispatcher](https://github.com/middlewares/awesome-psr15-middlewares#dispatcher) + +### Optional Requirements + +* [PHP Brotli Extension](https://github.com/kjdev/php-ext-brotli) +* [PHP ZStd Extension](https://github.com/kjdev/php-ext-zstd) ## Installation @@ -26,63 +36,155 @@ This package is installable and autoloadable via Composer as [middlewares/encode composer require middlewares/encoder ``` -## GzipEncoder +## Upgrading from v2.x or earlier -Compress the response body to GZIP format using [gzencode](http://php.net/manual/en/function.gzencode.php) and add the header `Content-Encoding: gzip`. +When upgrading its advised to switch from using the deprecated GzipEncoder and or DeflateEncoder directly, to using the +CompressEncoder middleware. -**Note:** The response body is encoded only if the header contains the value `gzip` in the header `Accept-Encoding`. +```diff +Dispatcher::run([ +... +- new Middlewares\GzipEncoder(), +- new Middlewares\DeflateEncoder(), ++ new Middlewares\CompressEncoder(), +... +]); +``` + +_This configuration will try ZStd, Brotli, GZip, and then Deflate, in that order, if available (php extensions are +loaded for zstd and or brotli)._ + +## CompressEncoder + +Compress the response body to matching `Accept-Encoding` format format using the first matching Compressor (by default +`zstd`, `brotli`, `gzip` and `defalate` are tried, in that order, where php extentions are available) after which the +`Content-Encoding` header will be added to the output. + +**Note:** The response body is encoded only if the `Accept-Encoding` header contains an available compression type, the +Content-Type is considered compressible, and there is no `Content-Encoding` header. ```php Dispatcher::run([ - new Middlewares\GzipEncoder(), + new Middlewares\CompressEncoder(), ]); ``` -Optionally, you can provide a `Psr\Http\Message\StreamFactoryInterface` that will be used to create the response body. If it's not defined, [Middleware\Utils\Factory](https://github.com/middlewares/utils#factory) will be used to detect it automatically. +#### Optional Parameters: + +- You can provide a `Psr\Http\Message\StreamFactoryInterface` that will be used to create the response body. +If it's not defined, [Middleware\Utils\Factory](https://github.com/middlewares/utils#factory) will be used a default. ```php $streamFactory = new MyOwnStreamFactory(); -$encoder = new Middlewares\GzipEncoder($streamFactory); +$encoder = new Middlewares\CompressEncoder($streamFactory); ``` -## DeflateEncoder +- You can also provide your own list of Compressors (that implement the `Middlewares\CompressorInterface`). For example: -Compress the response body to Deflate format using [gzdeflate](http://php.net/manual/en/function.gzdeflate.php) and add the header `Content-Encoding: deflate`. +```php +$encoder = new Middlewares\CompressEncoder(null, [ + new MyProject\LzmaCompressor(), + new Middlewares\GZipCompressor($level = 9), +]); -**Note:** The response body is encoded only if the header contains the value `deflate` in the header `Accept-Encoding`. +``` + +### Only compress specific Content-Types + +This option allows the overriding of the default patterns used to detect what resources are already compressed. + +The default pattern detects the following mime types `text/*`, `application/json`, `image/svg+xml` and empty content +types as compressible. If the pattern begins with a forward slash `/` it is tested as a regular expression, otherwise +its is treated as a case-insensitive string comparison. ```php Dispatcher::run([ - new Middlewares\DeflateEncoder(), + (new Middlewares\CompressEncoder()) + ->contentType( + '/^application\/pdf$/', // Regular Expression + 'text/csv' // Text Pattern + ) ]); ``` -Optionally, you can provide a `Psr\Http\Message\StreamFactoryInterface` that will be used to create the response body. If it's not defined, [Middleware\Utils\Factory](https://github.com/middlewares/utils#factory) will be used to detect it automatically. +--- + +### BrotliCompressor + +The brotli compressor is used where the `Accept-Encoding` includes `br` and can be configured with a custom compression +level, via a constructor parameter. ```php -$streamFactory = new MyOwnStreamFactory(); +$encoder = new Middlewares\CompressEncoder(null, [ + new Middlewares\BrotliCompressor($level = 1), +]); +``` +### DeflateCompressor + +The deflate compressor is used where the `Accept-Encoding` includes `deflate` and can be configured with a custom compression +level, via a constructor parameter. -$encoder = new Middlewares\DeflateEncoder($streamFactory); +```php +$encoder = new Middlewares\CompressEncoder(null, [ + new Middlewares\DeflateCompressor($level = 1), +]); ``` -## Common Options +### GzipCompressor -### contentType +The gzip compressor is used where the `Accept-Encoding` includes `gzip` and can be configured with a custom compression +level, via a constructor parameter. -This option allows the overring of the default patterns used to detect what resources are already compressed. +```php +$encoder = new Middlewares\CompressEncoder(null, [ + new Middlewares\GzipCompressor($level = 1), +]); +``` -The default pattern detects the following mime types `text/*`, `application/json`, `image/svg+xml` and empty content types as compressible. If the pattern begins with a forward slash `/` it is tested as a regular expression, otherwise its is case-insensitive string comparison. +### ZStdCompressor + +The gzip compressor is used where the `Accept-Encoding` includes `zstd` and can be configured with a custom compression +level, via a constructor parameter. + +```php +$encoder = new Middlewares\CompressEncoder(null, [ + new Middlewares\ZStdCompressor($level = 1), +]); +``` + +## GzipEncoder (deprecated) + +**Please note, this is provided for backward compatibility only** + +Compress the response body to GZIP format using [gzencode](http://php.net/manual/en/function.gzencode.php) and add the header `Content-Encoding: gzip`. + +**Note:** The response body is encoded only if the header contains the value `gzip` in the header `Accept-Encoding`. ```php Dispatcher::run([ - (new Middlewares\DeflateEncoder()) - ->contentType( - '/^application\/pdf$/', // Regular Expression - 'text/csv' // Text Pattern - ) + new Middlewares\GzipEncoder(), ]); ``` + +Optionally, you can provide a `Psr\Http\Message\StreamFactoryInterface` as above. + +## DeflateEncoder (deprecated) + +**Please note, this is provided for backward compatibility only** + +Compress the response body to Deflate format using [gzdeflate](http://php.net/manual/en/function.gzdeflate.php) and add the header `Content-Encoding: deflate`. + +**Note:** The response body is encoded only if the header contains the value `deflate` in the header `Accept-Encoding`. + +```php +Dispatcher::run([ + new Middlewares\DeflateEncoder(), +]); +``` + +Optionally, you can provide a `Psr\Http\Message\StreamFactoryInterface` as above. + --- Please see [CHANGELOG](CHANGELOG.md) for more information about recent changes and [CONTRIBUTING](CONTRIBUTING.md) for contributing details. diff --git a/composer.json b/composer.json index 3cb95fd..a3d45ee 100644 --- a/composer.json +++ b/composer.json @@ -20,7 +20,7 @@ "require": { "ext-zlib": "*", "php": "^7.2 || ^8.0", - "middlewares/utils": "^3.0", + "middlewares/utils": "^4.0", "psr/http-server-middleware": "^1.0" }, "require-dev": { @@ -29,7 +29,12 @@ "friendsofphp/php-cs-fixer": "^2.0", "squizlabs/php_codesniffer": "^3.0", "oscarotero/php-cs-fixer-config": "^1.0", - "phpstan/phpstan": "^0.12" + "phpstan/phpstan": "^0.12", + "vimeo/psalm": "^4.8" + }, + "suggest": { + "ext-brotli": "Enable Brotli Compression", + "ext-zstd": "Enable ZStd Compression" }, "autoload": { "psr-4": { diff --git a/psalm.xml b/psalm.xml new file mode 100644 index 0000000..3240886 --- /dev/null +++ b/psalm.xml @@ -0,0 +1,15 @@ + + + + + + + + + diff --git a/src/BrotliCompressor.php b/src/BrotliCompressor.php new file mode 100644 index 0000000..75344e9 --- /dev/null +++ b/src/BrotliCompressor.php @@ -0,0 +1,35 @@ +level = $level; + } + + public function name(): string + { + return 'br'; + } + + public function compress(string $input): string + { + $out = \brotli_compress($input, $this->level); + if ($out === false) { + throw new \RuntimeException('Error occurred while compressing output'); + } + return $out; + } +} diff --git a/src/Encoder.php b/src/CompressEncoder.php similarity index 61% rename from src/Encoder.php rename to src/CompressEncoder.php index 69269d6..4f428e9 100644 --- a/src/Encoder.php +++ b/src/CompressEncoder.php @@ -4,18 +4,14 @@ namespace Middlewares; use Middlewares\Utils\Factory; +use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\StreamFactoryInterface; use Psr\Http\Server\RequestHandlerInterface; -abstract class Encoder +class CompressEncoder { - /** - * @var string - */ - protected $encoding; - /** * @var StreamFactoryInterface */ @@ -26,9 +22,20 @@ abstract class Encoder */ private $patterns = ['/^(image\/svg\\+xml|text\/.*|application\/json)(;.*)?$/']; - public function __construct(StreamFactoryInterface $streamFactory = null) + /** + * @var CompressorInterface[] + */ + private $compressors; + + /** + * CompressEncoder constructor. + * @param StreamFactoryInterface|null $streamFactory + * @param CompressorInterface[]|null $compressors + */ + public function __construct(StreamFactoryInterface $streamFactory = null, array $compressors = null) { $this->streamFactory = $streamFactory ?: Factory::getStreamFactory(); + $this->compressors = $compressors ?: $this->allAvailableCompressors(); } /** @@ -38,11 +45,12 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface { $response = $handler->handle($request); - if (stripos($request->getHeaderLine('Accept-Encoding'), $this->encoding) !== false + $compressor = $this->getCompressor($request); + if ($compressor && !$response->hasHeader('Content-Encoding') && $this->isCompressible($response) ) { - $stream = $this->streamFactory->createStream($this->encode((string) $response->getBody())); + $stream = $this->streamFactory->createStream($compressor->compress((string) $response->getBody())); $vary = array_filter(array_map('trim', explode(',', $response->getHeaderLine('Vary')))); if (!in_array('Accept-Encoding', $vary, true)) { @@ -50,7 +58,7 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface } return $response - ->withHeader('Content-Encoding', $this->encoding) + ->withHeader('Content-Encoding', $compressor->name()) ->withHeader('Vary', implode(',', $vary)) ->withBody($stream); } @@ -58,11 +66,6 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface return $response; } - /** - * Encode the body content. - */ - abstract protected function encode(string $content): string; - /** * Sets the list of compressible content-type patterns. * If pattern begins with '/' treat as regular expression @@ -73,6 +76,17 @@ public function contentType(string ...$patterns): self return $this; } + private function getCompressor(RequestInterface $request): ?CompressorInterface + { + $acceptEncoding = $request->getHeaderLine('Accept-Encoding'); + foreach ($this->compressors as $comp) { + if (stripos($acceptEncoding, $comp->name()) !== false) { + return $comp; + } + } + return null; + } + private function isCompressible(ResponseInterface $response): bool { $contentType = $response->getHeaderLine('Content-Type') ?: 'text/html'; @@ -89,4 +103,21 @@ private function isCompressible(ResponseInterface $response): bool } return false; } + + /** + * @return CompressorInterface[] + */ + private function allAvailableCompressors(): array + { + $o = []; + if (function_exists('zstd_compress')) { + $o[] = new ZStdCompressor(); + } + if (function_exists('brotli_compress')) { + $o[] = new BrotliCompressor(); + } + $o[] = new GzipCompressor(); + $o[] = new DeflateCompressor(); + return $o; + } } diff --git a/src/CompressorInterface.php b/src/CompressorInterface.php new file mode 100644 index 0000000..93fca52 --- /dev/null +++ b/src/CompressorInterface.php @@ -0,0 +1,22 @@ +level = $level; + } + + public function name(): string + { + return 'deflate'; + } + + public function compress(string $input): string + { + $out = gzdeflate($input, $this->level); + if ($out === false) { + throw new \RuntimeException('Error occurred while compressing output'); + } + return $out; + } +} diff --git a/src/DeflateEncoder.php b/src/DeflateEncoder.php index f63bd7c..532d664 100644 --- a/src/DeflateEncoder.php +++ b/src/DeflateEncoder.php @@ -3,20 +3,16 @@ namespace Middlewares; +use Psr\Http\Message\StreamFactoryInterface; use Psr\Http\Server\MiddlewareInterface; -class DeflateEncoder extends Encoder implements MiddlewareInterface +/*** + * @deprecated Replace with CompressEncoder + */ +class DeflateEncoder extends CompressEncoder implements MiddlewareInterface { - /** - * @var string - */ - protected $encoding = 'deflate'; - - /** - * {@inheritdoc} - */ - protected function encode(string $content): string + public function __construct(StreamFactoryInterface $streamFactory = null) { - return (string) gzdeflate($content); + parent::__construct($streamFactory, [new DeflateCompressor()]); } } diff --git a/src/GzipCompressor.php b/src/GzipCompressor.php new file mode 100644 index 0000000..330fae7 --- /dev/null +++ b/src/GzipCompressor.php @@ -0,0 +1,34 @@ +level = $level; + } + + public function name(): string + { + return 'gzip'; + } + + public function compress(string $input): string + { + $out = gzencode($input, $this->level); + if ($out === false) { + throw new \RuntimeException('Error occurred while compressing output'); + } + return $out; + } +} diff --git a/src/GzipEncoder.php b/src/GzipEncoder.php index dd7724f..98bdd34 100644 --- a/src/GzipEncoder.php +++ b/src/GzipEncoder.php @@ -3,20 +3,17 @@ namespace Middlewares; +use Psr\Http\Message\StreamFactoryInterface; use Psr\Http\Server\MiddlewareInterface; -class GzipEncoder extends Encoder implements MiddlewareInterface -{ - /** - * @var string - */ - protected $encoding = 'gzip'; +/*** + * @deprecated Replace with CompressEncoder + */ - /** - * {@inheritdoc} - */ - protected function encode(string $content): string +class GzipEncoder extends CompressEncoder implements MiddlewareInterface +{ + public function __construct(StreamFactoryInterface $streamFactory = null) { - return (string) gzencode($content); + parent::__construct($streamFactory, [new GzipCompressor()]); } } diff --git a/src/ZStdCompressor.php b/src/ZStdCompressor.php new file mode 100644 index 0000000..3398be0 --- /dev/null +++ b/src/ZStdCompressor.php @@ -0,0 +1,35 @@ +level = $level; + } + + public function name(): string + { + return 'zstd'; + } + + public function compress(string $input): string + { + $out = \zstd_compress($input, $this->level); + if ($out === false) { + throw new \RuntimeException('Error occurred while compressing output'); + } + return $out; + } +} diff --git a/tests/EncoderTest.php b/tests/EncoderTest.php index b8e9756..c06032d 100644 --- a/tests/EncoderTest.php +++ b/tests/EncoderTest.php @@ -123,7 +123,6 @@ function () { return self::makeResponse('text/html', 'html'); }, ], $request); - $this->assertEquals( true, $response->hasHeader('Content-Encoding'),