Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

ContentEncodingMiddleware to decompress incoming requests #14

Open
boesing opened this issue Apr 26, 2021 · 5 comments
Open

ContentEncodingMiddleware to decompress incoming requests #14

boesing opened this issue Apr 26, 2021 · 5 comments
Labels
Enhancement New feature or request Help Wanted RFC

Comments

@boesing
Copy link
Member

boesing commented Apr 26, 2021

Feature Request

Q A
New Feature yes
RFC yes
BC Break no

Summary

I recently found out, that request payloads usually won't be compressed, as the client does not "challenge" possible encodings with the server.
After enforcing Content-Encoding: gzip on the client side (because we are requesting an internal API), I also found out that the Webserver (NGINX in our case) is not able to decompress incoming requests so that the application only gets the decompressed content. There is no such configuration without having annoying lua scripts within NGINX and thus, I've created a middleware to handle this.

use Api\Exception\InvalidRequestPayloadException;
use Fig\Http\Message\StatusCodeInterface;
use Mezzio\ProblemDetails\ProblemDetailsResponseFactory;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\StreamFactoryInterface;
use Psr\Http\Message\StreamInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use RuntimeException;
use Safe\Exceptions\ZlibException;
use function sprintf;

final class ContentEncodingDecoderMiddleware implements MiddlewareInterface
{
    private const CONTENT_ENCODING_GZIP = 'gzip';

    /**
     * @var StreamFactoryInterface
     */
    private $streamFactory;

    /**
     * @var ProblemDetailsResponseFactory
     */
    private $responseFactory;

    public function __construct(StreamFactoryInterface $streamFactory, ProblemDetailsResponseFactory $responseFactory)
    {
        $this->streamFactory = $streamFactory;
        $this->responseFactory = $responseFactory;
    }

    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
    {
        if (!$request->hasHeader('Content-Encoding')) {
            return $handler->handle($request);
        }

        $encoding = mb_strtolower($request->getHeaderLine('Content-Encoding'));

        if ($encoding !== self::CONTENT_ENCODING_GZIP) {
            return $this->responseFactory->createResponse(
                $request,
                StatusCodeInterface::STATUS_NOT_ACCEPTABLE,
                sprintf(
                    'Request contains content with an unsupported encoding "%s". Only "%s" is supported!',
                    $encoding,
                    self::CONTENT_ENCODING_GZIP
                ),
                'Unacceptable Content Encoding',
                'https://docs.handyvertrag.check24.de/problem/unacceptable-content-encoding/'
            );
        }

        if ($request->getParsedBody() !== null) {
            throw new RuntimeException(
                'The request already contains a parsed body.'
                . ' Please ensure that the `BodyParamsMiddleware` comes after this middleware!'
            );
        }

        return $handler->handle($request->withBody($this->decode($request->getBody())));
    }

    private function decode(StreamInterface $payload): StreamInterface
    {
        $contents = (string) $payload;

        try {
            $decoded = \Safe\gzuncompress($contents);
        } catch (ZlibException $exception) {
            throw InvalidRequestPayloadException::create($exception);
        }

        return $this->streamFactory->createStream($decoded);
    }
}

This middleware could be modified to handle multiple Content-Encoding header values (gzip, br, e.g.).
Thus, I think we might want to have some kind of ContentDecompressionInterface like:

use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\StreamInterface;

interface ContentDecompressionInterface
{
    public function canDecompress(ServerRequestInterface $request): bool;
    public function decompress(StreamInterface $body): StreamInterface;
}

Having gzip integrated with the middleware per-default would be okay for me.
If anyone needs additional support, the interface can be used to implement all other types of encodings.


I am open to remove the dependency of the BodyParamsMiddleware as this would limit the requests to JSON/Form requests.

Validation of the request methods with an exclude list might be a good idea aswell. Thats what BodyParamsMiddleware does aswell.
Having these methods available as a constant would be something I'd like to see aswell. Maybe thats something we could contribute to fig/http-message-util @weierophinney?

@bcremer
Copy link

bcremer commented Apr 26, 2021

I would like to see a implementation that does not have to unroll the entity stream into memory.

Can this be implemented using a stream filter like here: https://github.com/guzzle/psr7/blob/master/src/InflateStream.php

@weierophinney
Copy link
Contributor

@bcremer I think that's the ultimate goal of the proposal - below the middleware example, @boesing indicates a ContentDecompressionInterface that would consume a stream and return a stream, and there are ways this can be done using PSR-7 already (either through decoration or pulling the resource from one stream to pass to another).

@boesing — I like the idea of using a Strategy pattern here with gzip support by default. And per your remark about the BodyParamsMiddleware coupling, if all this did was to cast a stream to another stream, and inject the result in the request sent to the handler, this middleware would then sit in front of the BodyParamsMiddleware, removing that requirement, and making the entire process opt-in, as it should be.

I'd likely change your ContentDecompressionInterface to instead:

interface ProvideDecompressionStreamInterface
{
    public function canDecompress(ServerRequestInterface $request): bool;
    public function castToDecompressionStream(StreamInterface $body): StreamInterface;
}

to make it more clear that it's recasting the body stream, instead of actually decompressing it.

@boesing
Copy link
Member Author

boesing commented Apr 26, 2021

@weierophinney do you think, adding a constant called NON_PAYLOAD_METHODS or similar would fit in the http-message-util php-fig component? Could be part of the RequestMethodInterface.

@weierophinney
Copy link
Contributor

do you think, adding a constant called NON_PAYLOAD_METHODS or similar would fit in the http-message-util php-fig component? Could be part of the RequestMethodInterface.

I think it would be a good addition - send a PR there, and then ping me with the URL. I have approval status on that repo, so if I approve it, we should be able to get it merged.

@boesing
Copy link
Member Author

boesing commented Apr 26, 2021

Sadly, the http-message-util package is compatible with PHP 5.3 and thus, having a constant representing a list of other constants is not possible...
Is that package affected by the by-law upgrade process as well (such as the several other PSR packages which received an update lately)?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Enhancement New feature or request Help Wanted RFC
Projects
None yet
Development

No branches or pull requests

3 participants