Repository: Neoteroi/BlackSheep
Pull request #325 (2023-04-08 21:03:45)

Add gzip compression middleware
Add GzipMiddleware class that compresses response body if request Accept-Encoding header contains 'gzip'

Add GzipMiddleware for app_1, app_2, app_3, app_4 integration tests servers

Add unit tests for GzipMiddleware

RobertoPrevato: Hi @tyzhnenko 
Thank You for your contribution, I appreciate this very much! I thought many times to adding something similar but always postponed that. 

I have two recommendations:
- because gzipping is a CPU bound operation, it is best to use an executor ([run_in_executor](https://docs.python.org/3/library/asyncio-eventloop.html#asyncio.loop.run_in_executor)) when compressing, as the zipping process _might_ block the event loop (depending on the size of the content being zipped). Maybe the GzipMiddleware constructor might accept an optional ThreadPoolExecutor. So by default it would use the default one, and optionally a dedicated thread pool.
- the second recommendation would be to somehow support specifying which content types should be zipped, as some contents should not be zipped. NGINX for example compresses by default only text/html. I would check only for substrings because there are many variants of MIME types in some cases.

Something like:

```python
handled_content_types = {
    b"json",
    b"xml",
    b"yaml",
    b"html",
    b"text/plain",
    b"application/javascript",
    b"text/css",
}

def handle_content_type(self, content_type: bytes) -> bool:
    return any(fragment in content_type.lower() for fragment in self.handled_content_type)

# handle request only if the content type should be handled
```

If you don´t have more time to spare, we can merge sooner and I can add these small changes before releasing to PyPi. 😄 
tyzhnenko: @RobertoPrevato take a look, please

I've addressed all suggestions. If you have any other ideas or suggestions please share them with me.
RobertoPrevato: @tyzhnenko it looks great! We can merge it soon, I only have two final remarks about a typo and a small change on the executor side.
tyzhnenko: > @tyzhnenko it looks great! We can merge it soon, I only have a few final remarks about a typo and a small change on the executor side.

@RobertoPrevato Sure. Just put your remarks somewhere and I gonna handle them
RobertoPrevato: > > @tyzhnenko it looks great! We can merge it soon, I only have a few final remarks about a typo and a small change on the executor side.
> 
> @RobertoPrevato Sure. Just put your remarks somewhere and I gonna handle them

Thanks - I forgot to submit the review.
codecov-commenter: ## [Codecov](https://codecov.io/gh/Neoteroi/BlackSheep/pull/325?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Neoteroi) Report
Patch coverage: **`94.11`**% and project coverage change: **`-0.04`** :warning:
> Comparison is base [(`1de19c6`)](https://codecov.io/gh/Neoteroi/BlackSheep/commit/1de19c61ac1759cfe0bb0cb543be1df65e3d8c19?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Neoteroi) 97.91% compared to head [(`0283443`)](https://codecov.io/gh/Neoteroi/BlackSheep/pull/325?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Neoteroi) 97.88%.

:mega: This organization is not using Codecov’s [GitHub App Integration](https://github.com/apps/codecov). We recommend you install it so Codecov can continue to function properly for your repositories. [Learn more](https://about.codecov.io/blog/codecov-is-updating-its-github-integration/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Neoteroi)

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main     #325      +/-   ##
==========================================
- Coverage   97.91%   97.88%   -0.04%     
==========================================
  Files          64       65       +1     
  Lines        6053     6104      +51     
==========================================
+ Hits         5927     5975      +48     
- Misses        126      129       +3     
```


| [Impacted Files](https://codecov.io/gh/Neoteroi/BlackSheep/pull/325?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Neoteroi) | Coverage Δ | |
|---|---|---|
| [blacksheep/server/gzip.py](https://codecov.io/gh/Neoteroi/BlackSheep/pull/325?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Neoteroi#diff-YmxhY2tzaGVlcC9zZXJ2ZXIvZ3ppcC5weQ==) | `94.11% <94.11%> (ø)` | |

Help us with your feedback. Take ten seconds to tell us [how you rate us](https://about.codecov.io/nps?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Neoteroi). Have a feature suggestion? [Share it here.](https://app.codecov.io/gh/feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Neoteroi)

</details>

[:umbrella: View full report in Codecov by Sentry](https://codecov.io/gh/Neoteroi/BlackSheep/pull/325?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Neoteroi).   
:loudspeaker: Do you have feedback about the report comment? [Let us know in this issue](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Neoteroi).

RobertoPrevato: @tyzhnenko Thank You for your contribution - I merge this and release it to PyPi as soon as possible. In the next days I'll focus on updating the documentation for version 2. I'm also looking into Copier to add a CLI to blacksheep to scaffold project templates.

Patch:
diff --git a/blacksheep/server/gzip.py b/blacksheep/server/gzip.py
new file mode 100644
index 0000000..10fab7b
--- /dev/null
+++ b/blacksheep/server/gzip.py
@@ -0,0 +1,152 @@
+import asyncio
+import gzip
+from concurrent.futures import Executor
+from typing import Awaitable, Callable, Iterable, List, Optional
+
+from blacksheep import Content, Request, Response
+from blacksheep.server.application import Application
+from blacksheep.server.normalization import ensure_response
+
+
+class GzipMiddleware:
+    """
+    The gzip compression middleware for all requests with a body larger than
+    the specified minimum size and with the "gzip" encoding in the "Accept-Encoding"
+    header. The middleware runs compression asynchronously in a separate executor.
+
+    Parameters
+    ----------
+    min_size: int
+        The minimum size of the response body to compress.
+    comp_level: int
+        The compression level to use.
+    handled_types: Optional[Iterable[bytes]]
+        The list of content types to compress.
+    executor: Executor
+        The executor instance to use for compression. If not specified, a
+        default executor is used. If you specify an executor, you are responsible
+        for shutting it down.
+    """
+
+    handled_types: List[bytes] = [
+        b"json",
+        b"xml",
+        b"yaml",
+        b"html",
+        b"text/plain",
+        b"application/javascript",
+        b"text/css",
+        b"text/csv",
+    ]
+
+    def __init__(
+        self,
+        min_size: int = 500,
+        comp_level: int = 5,
+        handled_types: Optional[Iterable[bytes]] = None,
+        executor: Optional[Executor] = None,
+    ):
+        self.min_size = min_size
+        self.comp_level = comp_level
+        self._executor = executor
+
+        if handled_types is not None:
+            self.handled_types = self._normalize_types(handled_types)
+
+    def _normalize_types(self, types: Iterable[bytes]) -> List[bytes]:
+        """
+        Normalizes the types to bytes.
+        """
+        normalized_types = []
+        for _type in types:
+            if isinstance(_type, str):
+                normalized_types.append(_type.encode("ascii"))
+            else:
+                normalized_types.append(_type)
+        return normalized_types
+
+    def should_handle(self, request: Request, response: Response) -> bool:
+        """
+        Returns True if the response should be compressed.
+        """
+
+        def _is_handled_type(content_type) -> bool:
+            content_type = content_type.lower()
+            return any(_type in content_type for _type in self.handled_types)
+
+        def is_handled_encoding() -> bool:
+            return request.headers is not None and b"gzip" in (
+                request.headers.get_single(b"accept-encoding") or ""
+            )
+
+        def is_handled_response_content() -> bool:
+            if response is None or response.content is None:
+                return False
+
+            body_pass: bool = (
+                response.content.body is not None
+                and len(response.content.body) > self.min_size
+            )
+
+            content_type_pass: bool = (
+                response.content.type is not None
+                and _is_handled_type(response.content.type)
+            )
+
+            return all(
+                (
+                    body_pass,
+                    content_type_pass,
+                )
+            )
+
+        return all(
+            (
+                is_handled_encoding(),
+                is_handled_response_content(),
+            )
+        )
+
+    async def __call__(
+        self, request: Request, handler: Callable[[Request], Awaitable[Response]]
+    ) -> Response:
+        response = ensure_response(await handler(request))
+
+        if response is None or response.content is None:
+            return response
+
+        if not self.should_handle(request, response):
+            return response
+
+        loop = asyncio.get_running_loop()
+        compressed_body = await loop.run_in_executor(
+            self._executor,
+            gzip.compress,
+            response.content.body,
+            self.comp_level,
+        )
+
+        response.with_content(
+            Content(
+                content_type=response.content.type,
+                data=compressed_body,
+            )
+        )
+        response.add_header(b"content-encoding", b"gzip")
+        return response
+
+
+def use_gzip_compression(
+    app: Application,
+    handler: Optional[GzipMiddleware] = None,
+) -> GzipMiddleware:
+    """
+    Configures the application to use gzip compression for all responses with gzip
+    in accept-encoding header.
+    """
+    if handler is None:
+        handler = GzipMiddleware()
+
+    app.middlewares.append(handler)
+
+    return handler


Test patch:
diff --git a/itests/app_1.py b/itests/app_1.py
index 7fe41b5..37f4478 100644
--- a/itests/app_1.py
+++ b/itests/app_1.py
@@ -17,10 +17,12 @@ from blacksheep import (
     json,
     text,
 )
+from blacksheep.server.gzip import use_gzip_compression
 from itests.utils import CrashTest, ensure_folder
 
 app = Application(show_error_details=True)
 
+use_gzip_compression(app)
 
 static_folder_path = pathlib.Path(__file__).parent.absolute() / "static"
 
diff --git a/itests/app_2.py b/itests/app_2.py
index 6bebc5b..d928302 100644
--- a/itests/app_2.py
+++ b/itests/app_2.py
@@ -30,6 +30,7 @@ from blacksheep.server.bindings import (
     FromServices,
 )
 from blacksheep.server.controllers import APIController
+from blacksheep.server.gzip import use_gzip_compression
 from blacksheep.server.openapi.common import (
     ContentInfo,
     EndpointDocs,
@@ -48,6 +49,7 @@ from itests.utils import CrashTest
 
 app_2 = Application()
 
+use_gzip_compression(app_2)
 
 controllers_router = RoutesRegistry()
 app_2.controllers_router = controllers_router
diff --git a/itests/app_3.py b/itests/app_3.py
index 7c23218..849b7e8 100644
--- a/itests/app_3.py
+++ b/itests/app_3.py
@@ -1,11 +1,14 @@
 import uvicorn
 
 from blacksheep.server import Application
+from blacksheep.server.gzip import use_gzip_compression
 from blacksheep.server.responses import json
 
 application = Application(show_error_details=True)
 app_3 = Application(show_error_details=True)
 
+use_gzip_compression(app_3)
+
 
 @application.router.get("/foo")
 def handle_foo():
diff --git a/itests/app_4.py b/itests/app_4.py
index cb47c31..358cd55 100644
--- a/itests/app_4.py
+++ b/itests/app_4.py
@@ -9,6 +9,7 @@ import uvicorn
 from blacksheep import JSONContent, Response
 from blacksheep.server import Application
 from blacksheep.server.bindings import FromJSON
+from blacksheep.server.gzip import use_gzip_compression
 from blacksheep.server.responses import json
 from blacksheep.server.websocket import WebSocket
 from blacksheep.settings.json import default_json_dumps, json_settings
@@ -69,6 +70,8 @@ def configure_json_settings():
 
 app_4 = Application(show_error_details=True)
 
+use_gzip_compression(app_4)
+
 
 @dataclasses.dataclass
 class MyData:
diff --git a/tests/test_gzip.py b/tests/test_gzip.py
new file mode 100644
index 0000000..5781387
--- /dev/null
+++ b/tests/test_gzip.py
@@ -0,0 +1,129 @@
+import gzip
+
+import pytest
+
+from blacksheep.server.gzip import GzipMiddleware
+from blacksheep.testing.helpers import get_example_scope
+from blacksheep.testing.messages import MockReceive, MockSend
+
+
+@pytest.mark.parametrize(
+    "comp_level, comp_size",
+    (zip(range(0, 10), (468, 283, 283, 283, 282, 282, 282, 282, 282, 282))),
+)
+@pytest.mark.asyncio
+async def test_gzip_output(app, comp_level, comp_size):
+    return_value = (
+        "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do "
+        "eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim "
+        "veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo "
+        "consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum "
+        "dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, "
+        "sunt in culpa qui officia deserunt mollit anim id est laborum."
+    )
+
+    @app.router.get("/")
+    async def home():
+        return return_value
+
+    app.middlewares.append(GzipMiddleware(min_size=0, comp_level=comp_level))
+
+    await app.start()
+
+    await app(
+        get_example_scope(
+            "GET",
+            "/",
+        ),
+        MockReceive([]),
+        MockSend(),
+    )
+
+    response = app.response
+    assert response.status == 200
+    assert response.content.length == comp_size
+    assert gzip.decompress(response.content.body) == return_value.encode("ascii")
+    assert response.headers.get_single(b"content-encoding") == b"gzip"
+
+
+@pytest.mark.asyncio
+async def test_skip_gzip_small_output(app):
+    @app.router.get("/")
+    async def home():
+        return "Hello, World"
+
+    app.middlewares.append(GzipMiddleware(min_size=16))
+
+    await app.start()
+
+    await app(
+        get_example_scope(
+            "GET",
+            "/",
+        ),
+        MockReceive([]),
+        MockSend(),
+    )
+
+    response = app.response
+    assert response.status == 200
+    assert response.content.body == b"Hello, World"
+    assert response.content.length == 12
+    with pytest.raises(ValueError):
+        assert response.headers.get_single(b"content-encoding")
+
+
+@pytest.mark.asyncio
+async def test_skip_gzip_output_without_header(app):
+    @app.router.get("/")
+    async def home():
+        return "Hello, World"
+
+    app.middlewares.append(GzipMiddleware(min_size=0))
+
+    await app.start()
+
+    await app(
+        get_example_scope(
+            "GET",
+            "/",
+            accept_encoding=b"deflate",
+        ),
+        MockReceive([]),
+        MockSend(),
+    )
+
+    response = app.response
+    assert response.status == 200
+    assert response.content.body == b"Hello, World"
+    assert response.content.length == 12
+    with pytest.raises(ValueError):
+        assert response.headers.get_single(b"content-encoding")
+
+
+@pytest.mark.asyncio
+async def test_skip_gzip_output_for_unhandled_type(app):
+    @app.router.get("/")
+    async def home():
+        return "Hello, World"
+
+    app.middlewares.append(GzipMiddleware(min_size=0, handled_types=[b"text/html"]))
+
+    await app.start()
+
+    await app(
+        get_example_scope(
+            "GET",
+            "/",
+            accept_encoding=b"deflate",
+        ),
+        MockReceive([]),
+        MockSend(),
+    )
+
+    response = app.response
+    assert response.status == 200
+    assert response.content.length == 12
+    assert response.content.body == b"Hello, World"
+    with pytest.raises(ValueError):
+        assert response.headers.get_single(b"content-encoding")