diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index a3ef30c..8f0eb4b 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -10,18 +10,15 @@ jobs: fail-fast: true matrix: os: [ubuntu-latest] - php: [8.1, 8.2, 8.3, 8.4] - laravel: [10.*, 11.*] + php: [8.2, 8.3, 8.4] + laravel: [11.*, 12.*] dependency-version: [prefer-stable] include: - - laravel: 10.* - testbench: 8.* - - laravel: 11.* testbench: 9.* - exclude: - - laravel: 11.* - php: 8.1 + + - laravel: 12.* + testbench: 10.* name: P${{ matrix.php }} - L${{ matrix.laravel }} - ${{ matrix.dependency-version }} - ${{ matrix.os }} @@ -33,7 +30,7 @@ jobs: uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php }} - extensions: dom, curl, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, intl, exif, zstd, brotli + extensions: dom, curl, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, intl, exif, zstd, brotli, lz4 coverage: pcov - name: 🏗 Get composer cache directory diff --git a/.gitignore b/.gitignore index fe63056..07c5ae8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ /vendor/ +/ext/ /node_modules composer.lock coverage diff --git a/README.md b/README.md index b4e3fd1..14c36db 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ Then add the following to you `app/Http/Kernel.php` as a global middleware: */ protected $middleware = [ // ... - \OpenSoutheners\LaravelVaporResponseCompression\ResponseCompression::class, + \OpenSoutheners\LaravelResponseCompression\ResponseCompression::class, ]; ``` diff --git a/composer.json b/composer.json index ceb1c7f..2635bf9 100644 --- a/composer.json +++ b/composer.json @@ -1,5 +1,5 @@ { - "name": "open-southeners/laravel-vapor-response-compression", + "name": "open-southeners/laravel-response-compression", "description": "Add server-side response compression with a range of different algorithms (Gzip, brotli, deflate...)", "license": "MIT", "keywords": [ @@ -10,7 +10,9 @@ "response-compression", "gzip-compression", "deflate-compression", - "brotli-compression" + "brotli-compression", + "ztsd-compression", + "lz4-compression" ], "authors": [ { @@ -20,24 +22,25 @@ } ], "require": { - "php": "^7.2 || ^8.0", - "ext-zlib": "^7.2 || ^8.0" + "php": "^8.2", + "illuminate/http": "^11.0 || ^12.0", + "illuminate/support": "^11.0 || ^12.0" }, "require-dev": { - "larastan/larastan": "^2.0", - "orchestra/testbench": "^8.0 || ^9.0", - "phpstan/phpstan": "^1.0", - "phpunit/phpunit": "^10.0" + "larastan/larastan": "^3.0", + "orchestra/testbench": "^9.0 || ^10.0", + "phpstan/phpstan": "^2.0", + "phpunit/phpunit": "^11.0" }, "minimum-stability": "stable", "autoload": { "psr-4": { - "OpenSoutheners\\LaravelVaporResponseCompression\\": "src" + "OpenSoutheners\\LaravelResponseCompression\\": "src" } }, "autoload-dev": { "psr-4": { - "OpenSoutheners\\LaravelVaporResponseCompression\\Tests\\": "tests" + "OpenSoutheners\\LaravelResponseCompression\\Tests\\": "tests" } }, "config": { @@ -46,7 +49,7 @@ "extra": { "laravel": { "providers": [ - "OpenSoutheners\\LaravelVaporResponseCompression\\ServiceProvider" + "OpenSoutheners\\LaravelResponseCompression\\ServiceProvider" ] } } diff --git a/config/response-compression.php b/config/response-compression.php index 178cbe5..c67806c 100644 --- a/config/response-compression.php +++ b/config/response-compression.php @@ -15,6 +15,7 @@ 'enable' => env('RESPONSE_COMPRESSION_ENABLE', true), + // Threshold size in bytes from where the compression will be applied to responses // @see https://docs.aws.amazon.com/apigateway/latest/developerguide/limits.html#http-api-quotas 'threshold' => 10000, @@ -31,6 +32,9 @@ // @see https://www.php.net/manual/en/function.gzdeflate.php 'deflate' => 9, + + // @see Maximum level is 12 at the moment of this written + 'lz4' => 4, ], diff --git a/phpunit.coverage.dist.xml b/phpunit.coverage.dist.xml index cb85c7d..457a217 100644 --- a/phpunit.coverage.dist.xml +++ b/phpunit.coverage.dist.xml @@ -1,6 +1,25 @@ - - + + + + src + + + src/ServiceProvider.php + + + @@ -15,13 +34,4 @@ - - - ./src - - - src/ServiceProvider.php - src/CompressionEncoding.php - - diff --git a/src/CompressionEncoding.php b/src/CompressionEncoding.php index 62bb24e..cb5d0b4 100644 --- a/src/CompressionEncoding.php +++ b/src/CompressionEncoding.php @@ -1,14 +1,66 @@ + */ + public static function listSupported(): array + { + $supportedList = []; + + foreach (self::cases() as $case) { + if ($function = $case->isSupported()) { + $supportedList[$case->value] = $function; + } + } + + return $supportedList; + } + + /** + * Check if compression encoding is supported by this system in case not it returns null. + * + * @return callable-string|null + */ + public function isSupported(): ?string + { + $reflector = new ReflectionEnumBackedCase(self::class, $this->name); + + $attributes = $reflector->getAttributes(EncoderAsFunction::class); + + if (count($attributes) === 0) { + return null; + } + + /** @var \OpenSoutheners\LaravelResponseCompression\EncoderAsFunction $attribute */ + $attribute = $attributes[0]->newInstance(); + + if (function_exists($attribute->name)) { + return $attribute->name; + } + + return null; + } } diff --git a/src/EncoderAsFunction.php b/src/EncoderAsFunction.php new file mode 100644 index 0000000..79b1d59 --- /dev/null +++ b/src/EncoderAsFunction.php @@ -0,0 +1,14 @@ +shouldCompressResponse($response) && $compressionAlgorithm !== null) { [$algo, $function] = $compressionAlgorithm; - $response->setContent( - call_user_func( - $function, - $response->getContent(), - config("response-compression.level.{$algo}", 9) - ) + /** @var string $compressedContent */ + $compressedContent = call_user_func( + $function, + $response->getContent(), + config("response-compression.level.{$algo}", 9) ); + + $response->setContent($compressedContent); - $response->headers->add([ + $responseHeaders = [ 'Content-Encoding' => $algo, - 'X-Vapor-Base64-Encode' => 'True', - ]); + ]; + + if (getenv('VAPOR_SSM_PATH')) { + $responseHeaders['X-Vapor-Base64-Encode'] = 'True'; + } + + $response->headers->add($responseHeaders); } }); } @@ -41,15 +49,26 @@ public function handle($request, Closure $next) /** * Determine if response should be compressed. * - * @param \Illuminate\Http\Response|\Symfony\Component\HttpFoundation\Response $response + * @param \Symfony\Component\HttpFoundation\Response $response */ protected function shouldCompressResponse($response): bool { - return ! $response instanceof BinaryFileResponse - && ! $response instanceof StreamedResponse - && ! $response->headers->has('Content-Encoding') - && config('response-compression.enable', true) - && strlen($response->getContent() ?: '') >= config('response-compression.threshold', 10000); + if ( + $response instanceof BinaryFileResponse + || $response instanceof StreamedResponse + || !config('response-compression.enable', true) + ) { + return false; + } + + if ( + ! $response->headers->has('Content-Encoding') + && strlen($response->getContent() ?: '') >= config('response-compression.threshold', 10000) + ) { + return true; + } + + return false; } /** @@ -60,24 +79,14 @@ protected function shouldCompressResponse($response): bool */ protected function shouldCompressUsing($request): ?array { - $requestEncodings = $request->getEncodings(); - - if (in_array(CompressionEncoding::ZSTANDARD, $requestEncodings) && function_exists('zstd_compress')) { - return [CompressionEncoding::ZSTANDARD, 'zstd_compress']; - } + $supportedList = CompressionEncoding::listSupported(); - if (in_array(CompressionEncoding::BROTLI, $requestEncodings) && function_exists('brotli_compress')) { - return [CompressionEncoding::BROTLI, 'brotli_compress']; - } - - if (in_array(CompressionEncoding::GZIP, $requestEncodings) && function_exists('gzencode')) { - return [CompressionEncoding::GZIP, 'gzencode']; - } + $fromSupportedList = array_intersect($request->getEncodings(), array_keys($supportedList)); - if (in_array(CompressionEncoding::DEFLATE, $requestEncodings) && function_exists('gzdeflate')) { - return [CompressionEncoding::DEFLATE, 'gzdeflate']; + if ($fromSupportedList[0] ?? false) { + return [$fromSupportedList[0], $supportedList[$fromSupportedList[0]]]; } - + return null; } } diff --git a/src/ServiceProvider.php b/src/ServiceProvider.php index e3a5298..96774b3 100644 --- a/src/ServiceProvider.php +++ b/src/ServiceProvider.php @@ -1,6 +1,6 @@ assertIsArray($supportedList); + $this->assertArrayHasKey(CompressionEncoding::Deflate->value, $supportedList); + $this->assertEmpty(array_diff( + array_map(fn ($case) => $case->value, CompressionEncoding::cases()), + array_keys($supportedList), + )); + } +} diff --git a/tests/ResponseCompressionTest.php b/tests/ResponseCompressionTest.php index a2b25c8..58327c3 100644 --- a/tests/ResponseCompressionTest.php +++ b/tests/ResponseCompressionTest.php @@ -1,10 +1,10 @@ middleware(ResponseCompression::class); } - + public function testClientGetRawResponseWhenThresholdNotReached() { $this->withoutExceptionHandling(); - $response = $this->get('/light', ['Accept-Encoding' => CompressionEncoding::GZIP]); + $response = $this->get('/light', ['Accept-Encoding' => CompressionEncoding::Gzip->value]); $response->assertHeaderMissing('Content-Encoding'); @@ -49,7 +49,7 @@ public function testClientGetRawResponseWhenNotEnabled() $this->withoutExceptionHandling(); - $response = $this->get('/heavy', ['Accept-Encoding' => CompressionEncoding::GZIP]); + $response = $this->get('/heavy', ['Accept-Encoding' => CompressionEncoding::Gzip->value]); $response->assertHeaderMissing('Content-Encoding'); @@ -61,24 +61,33 @@ public function testClientGetRawResponseWhenNotEnabled() public function testClientGetResponseCompressedWhenThresholdReached() { - $response = $this->get('/heavy', ['Accept-Encoding' => CompressionEncoding::GZIP]); + $response = $this->get('/heavy', ['Accept-Encoding' => CompressionEncoding::Gzip->value]); - $response->assertHeader('Content-Encoding', CompressionEncoding::GZIP); + $response->assertHeader('Content-Encoding', CompressionEncoding::Gzip->value); $this->assertEquals( gzdecode($response->getContent()), json_encode(['content' => $this->heavyResponseContent]) ); } + + public function testClientGetResponseWithVaporHeaderWhenWithinVapor() + { + putenv('VAPOR_SSM_PATH=1'); + + $response = $this->get('/heavy', ['Accept-Encoding' => CompressionEncoding::Deflate->value]); + + $response->assertHeader('X-Vapor-Base64-Encode', 'True'); + } public function testClientGetResponseInThePreferredEncoding() { - $response = $this->get('/heavy', ['Accept-Encoding' => CompressionEncoding::DEFLATE]); + $response = $this->get('/heavy', ['Accept-Encoding' => implode(', ', [CompressionEncoding::Gzip->value, CompressionEncoding::Deflate->value])]); - $response->assertHeader('Content-Encoding', CompressionEncoding::DEFLATE); + $response->assertHeader('Content-Encoding', CompressionEncoding::Gzip->value); $this->assertEquals( - gzinflate($response->getContent()), + gzdecode($response->getContent()), json_encode(['content' => $this->heavyResponseContent]) ); } @@ -100,12 +109,12 @@ public function testClientGetResponseWithContentEncodingHeaderAlreadyAttachedRec Route::get('/heavy-ignored', function () { return response()->json([ 'content' => $this->heavyResponseContent, - ], 200, ['Content-Encoding' => CompressionEncoding::DEFLATE]); + ], 200, ['Content-Encoding' => CompressionEncoding::Deflate->value]); })->middleware(ResponseCompression::class); - $response = $this->get('/heavy-ignored', ['Accept-Encoding' => CompressionEncoding::GZIP]); + $response = $this->get('/heavy-ignored', ['Accept-Encoding' => CompressionEncoding::Gzip->value]); - $response->assertHeader('Content-Encoding', CompressionEncoding::DEFLATE); + $response->assertHeader('Content-Encoding', CompressionEncoding::Deflate->value); $this->assertEquals( $response->json(), @@ -121,7 +130,7 @@ public function testClientGetStreamDownloadResponseReceivesRawResponse() }); })->middleware(ResponseCompression::class); - $response = $this->get('/download', ['Accept-Encoding' => CompressionEncoding::DEFLATE]); + $response = $this->get('/download', ['Accept-Encoding' => CompressionEncoding::Deflate->value]); $response->assertHeaderMissing('Content-Encoding'); @@ -133,9 +142,9 @@ public function testClientGetStreamDownloadResponseReceivesRawResponse() public function testClientGetResponseUsingZstandardEncoding() { - $response = $this->get('/heavy', ['Accept-Encoding' => CompressionEncoding::ZSTANDARD]); + $response = $this->get('/heavy', ['Accept-Encoding' => CompressionEncoding::Zstandard->value]); - $response->assertHeader('Content-Encoding', CompressionEncoding::ZSTANDARD); + $response->assertHeader('Content-Encoding', CompressionEncoding::Zstandard->value); $this->assertEquals( zstd_uncompress($response->getContent()), @@ -145,13 +154,25 @@ public function testClientGetResponseUsingZstandardEncoding() public function testClientGetResponseUsingBrotliEncoding() { - $response = $this->get('/heavy', ['Accept-Encoding' => CompressionEncoding::BROTLI]); + $response = $this->get('/heavy', ['Accept-Encoding' => CompressionEncoding::Brotli->value]); - $response->assertHeader('Content-Encoding', CompressionEncoding::BROTLI); + $response->assertHeader('Content-Encoding', CompressionEncoding::Brotli->value); $this->assertEquals( brotli_uncompress($response->getContent()), json_encode(['content' => $this->heavyResponseContent]) ); } + + public function testClientGetResponseUsingLz4Encoding() + { + $response = $this->get('/heavy', ['Accept-Encoding' => CompressionEncoding::Lz4->value]); + + $response->assertHeader('Content-Encoding', CompressionEncoding::Lz4->value); + + $this->assertEquals( + lz4_uncompress($response->getContent()), + json_encode(['content' => $this->heavyResponseContent]) + ); + } } diff --git a/tests/TestCase.php b/tests/TestCase.php index cf01a59..70d421c 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -1,6 +1,6 @@