feat: dumbo compress helper (#38)

* refactor: add missing response interface

* feat(compress): adding compress helper

* Apply suggestions from code review

Co-authored-by: Jamie Barton <jamie@notrab.dev>

* refactor: add brotli compress suggestion

* revert brotli for now

---------

Co-authored-by: Jamie Barton <jamie@notrab.dev>
This commit is contained in:
Imam Ali Mustofa 2024-09-04 20:16:46 +07:00 committed by GitHub
parent 038db48f75
commit b558a2b1ef
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 208 additions and 1 deletions

2
composer.lock generated
View File

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "0c0bda01f8c04d2e20e328eeb6eff878", "content-hash": "3015b9d542341a20dd43db046eb7c4e7",
"packages": [ "packages": [
{ {
"name": "guzzlehttp/psr7", "name": "guzzlehttp/psr7",

View File

@ -0,0 +1,23 @@
# Compress Example
This example demonstrates how to compress responses using gzip or deflate in Dumbo.
## Running the Example
1. Install dependencies:
```bash
composer install
```
2. Start the server:
```bash
composer start
```
3. Make a request
```bash
curl -H "Accept-Encoding: gzip" --compressed -i http://localhost:8000
```

View File

@ -0,0 +1,18 @@
{
"require": {
"notrab/dumbo": "@dev"
},
"repositories": [
{
"type": "path",
"url": "../../"
}
],
"scripts": {
"start": [
"Composer\\Config::disableProcessTimeout",
"php -S localhost:8000 -t ."
]
},
"prefer-stable": false
}

View File

@ -0,0 +1,21 @@
<?php
require "vendor/autoload.php";
use Dumbo\Dumbo;
use Dumbo\Helpers\Compress;
$app = new Dumbo();
$app->use(
Compress::compress([
"threshold" => 1024, // Minimum size to compress (bytes)
"encoding" => "gzip", // Preferred encoding (gzip or deflate)
])
);
$app->get("/", function ($c) {
return $c->json(["message" => "Hello, Dumbo!"]);
});
$app->run();

144
src/Helpers/Compress.php Normal file
View File

@ -0,0 +1,144 @@
<?php
namespace Dumbo\Helpers;
use Dumbo\Context;
use GuzzleHttp\Psr7\Utils;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\StreamInterface;
class Compress
{
private const STR_REGEX = '/^\s*(?:text\/[^;\s]+|application\/(?:javascript|json|xml|xml-dtd|ecmascript|dart|postscript|rtf|tar|toml|vnd\.dart|vnd\.ms-fontobject|vnd\.ms-opentype|wasm|x-httpd-php|x-javascript|x-ns-proxy-autoconfig|x-sh|x-tar|x-virtualbox-hdd|x-virtualbox-ova|x-virtualbox-ovf|x-virtualbox-vbox|x-virtualbox-vdi|x-virtualbox-vhd|x-virtualbox-vmdk|x-www-form-urlencoded)|font\/(?:otf|ttf)|image\/(?:bmp|vnd\.adobe\.photoshop|vnd\.microsoft\.icon|vnd\.ms-dds|x-icon|x-ms-bmp)|message\/rfc822|model\/gltf-binary|x-shader\/x-fragment|x-shader\/x-vertex|[^;\s]+?\+(?:json|text|xml|yaml))(?:[;\s]|$)/i';
/**
* Create a middleware that compresses the response body using the specified encoding
* if the response size is greater than the given threshold.
*
* @param array{
* threshold?: int,
* allowedEncodings?: string[],
* encoding?: string
* } $options Configuration options
* @return callable The middleware
*/
public static function compress(array $options = []): callable
{
$threshold = $options["threshold"] ?? 1024;
$allowedEncodings = ["gzip", "deflate"];
$encoding = $options["encoding"] ?? null;
return self::compressor($threshold, $allowedEncodings, $encoding);
}
/**
* Create a middleware that compresses the response body using the specified encoding
* if the response size is greater than the given threshold.
*
* @param int $threshold The minimum response size in bytes to compress
* @param array $allowedEncodings The list of allowed encodings
* @param ?string $encoding The selected encoding, or null to auto-detect
* @return callable The middleware
*/
private static function compressor(
int $threshold,
array $allowedEncodings,
?string $encoding
): callable {
return function (Context $ctx, callable $next) use (
$threshold,
$allowedEncodings,
$encoding
) {
$next($ctx);
$response = $ctx->getResponse();
$contentLength = $response->getHeaderLine("Content-Length");
if (
$response->hasHeader("Content-Encoding") ||
$ctx->req->method() === "HEAD" ||
($contentLength && (int) $contentLength < $threshold) ||
!self::shouldCompress($response) ||
!self::shouldTransform($response)
) {
return $response;
}
$acceptedEncodings = array_map(
"trim",
explode(",", $ctx->req->header("Accept-Encoding"))
);
if (!$encoding) {
foreach ($allowedEncodings as $enc) {
if (in_array($enc, $acceptedEncodings)) {
$encoding = $enc;
break;
}
}
}
if (!$encoding || !$response->getBody()) {
return $response;
}
$compressedBody = self::performCompression(
$response->getBody(),
$encoding
);
$response = $response
->withBody($compressedBody)
->withoutHeader("Content-Length")
->withHeader("Content-Encoding", $encoding);
return $response;
};
}
/**
* Check if the response should be compressed based on the Content-Type header
*
* @param ResponseInterface $response The response to check
* @return bool Whether the response should be compressed
*/
private static function shouldCompress(ResponseInterface $response): bool
{
$type = $response->getHeaderLine("Content-Type");
return preg_match(self::STR_REGEX, $type);
}
/**
* Check if the response should be transformed (e.g. compressed) based on the
* Cache-Control header
*
* @param ResponseInterface $response The response to check
* @return bool Whether the response should be transformed
*/
private static function shouldTransform(ResponseInterface $response): bool
{
$cacheControl = $response->getHeaderLine("Cache-Control");
return !preg_match(
'/(?:^|,)\s*?no-transform\s*?(?:,|$)/i',
$cacheControl
);
}
/**
* Perform the actual compression of the response body using the specified encoding
*
* @param string $body The response body to compress
* @param string $encoding The encoding to use for compression
*
* @return string|StreamInterface The compressed response body
*/
private static function performCompression(
string $body,
string $encoding
): StreamInterface {
return match ($encoding) {
"deflate" => Utils::streamFor(gzdeflate($body)),
"gzip" => Utils::streamFor(gzencode($body)),
default => $body,
};
}
}

View File

@ -4,6 +4,7 @@ namespace Dumbo;
use FastRoute\Dispatcher; use FastRoute\Dispatcher;
use FastRoute\RouteCollector; use FastRoute\RouteCollector;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\ServerRequestInterface;
/** /**