Repository: Neoteroi/BlackSheep
Pull request #564 (2025-05-19 18:55:14)

Add support for PyPy
- Add support for [`PyPy`](https://pypy.org/), adding a pure-Python fallback for all Cython modules.
- Make `httptools` an optional dependency, to support `PyPy`.
- Modify the HTTP Client implementation in BlackSheep to not be tightly coupled with `httptools` for the response parsing logic, and include built-in support for `h11`.
- **MINOR BREAKING CHANGE**. Since `httptools` is not installed by default, the BlackSheep client requires either `httptools` or `h11` to be installed.
- Upgrade dependencies (see `pyproject.toml` for more information).
- Include a pure-Python wheel in the distribution package.


Patch:
diff --git a/.flake8 b/.flake8
index 3f0c993..fbae66c 100644
--- a/.flake8
+++ b/.flake8
@@ -6,8 +6,10 @@ per-file-ignores =
     blacksheep/__init__.py:F401
     blacksheep/client/__init__.py:F401
     blacksheep/server/__init__.py:F401
+    blacksheep/url.py:E501
     tests/*:E501
     itests/*:E501
+    itests/conftest.py:F401,F403
 
 exclude =
     env
diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
index 8775f52..8ac1389 100644
--- a/.github/workflows/main.yml
+++ b/.github/workflows/main.yml
@@ -64,7 +64,7 @@ jobs:
 
       - name: Run tests
         run: |
-          pytest --doctest-modules --junitxml=junit/pytest-results-${{ matrix.os }}-${{ matrix.python-version }}.xml --cov=$PROJECT_NAME --cov-report=xml
+          pytest
 
       - name: Run linters
         run: |
@@ -95,19 +95,38 @@ jobs:
           pip install -r requirements.txt
           pip install black==25.1.0 isort==6.0.1 flake8==7.2.0
 
+      - name: Run tests for Pure-Python Code
+        run: |
+          echo "Running unit tests..."
+          pytest tests
+
+          echo "Running integration tests..."
+          export APP_DEFAULT_ROUTER=false
+          pytest itests
+
       - name: Compile Cython extensions
         run: |
           make compile
 
-      - name: Run tests
+      - name: Run tests with Cython Code
         run: |
-          pytest --doctest-modules --junitxml=junit/pytest-results-${{ matrix.os }}-${{ matrix.python-version }}.xml --cov=$PROJECT_NAME --cov-report=xml
+          echo "Running unit tests..."
+          pytest tests
+
+          echo "Running integration tests..."
+          export APP_DEFAULT_ROUTER=false
+          pytest itests
 
       - name: Run tests with Pydantic v1
         run: |
           echo "[*] The previous tests used Pydantic v2, now running with v1"
           pip install -U "pydantic<2"
-          pytest
+          echo "Running unit tests..."
+          pytest tests
+
+          echo "Running integration tests..."
+          export APP_DEFAULT_ROUTER=false
+          pytest itests
 
       - name: Run linters
         run: |
@@ -133,33 +152,22 @@ jobs:
             APP_DEFAULT_ROUTER=false ASGI_SERVER=hypercorn pytest itests/test_server.py
           fi
 
-      - name: Upload pytest test results
-        uses: actions/upload-artifact@v4
-        with:
-          name: pytest-results-${{ matrix.os }}-${{ matrix.python-version }}
-          path: junit/pytest-results-${{ matrix.os }}-${{ matrix.python-version }}.xml
-        if: always()
-
-      - name: Codecov
-        run: |
-          bash <(curl -s https://codecov.io/bash)
-
       - name: Install distribution dependencies
         run: pip install --upgrade twine setuptools wheel
-        if: matrix.os == 'ubuntu-latest' && matrix.python-version == 3.11
+        if: matrix.os == 'ubuntu-latest' && matrix.python-version == 3.12
 
       - name: Create distribution package
         run: python setup.py sdist
-        if: matrix.os == 'ubuntu-latest' && matrix.python-version == 3.11
+        if: matrix.os == 'ubuntu-latest' && matrix.python-version == 3.12
 
       - name: Upload distribution package
         uses: actions/upload-artifact@v4
         with:
           name: dist-ubuntu-no-wheels
           path: dist
-        if: matrix.os == 'ubuntu-latest' && matrix.python-version == 3.11
+        if: matrix.os == 'ubuntu-latest' && matrix.python-version == 3.12
 
-  build-wheels:
+  build-cpython-wheels:
     needs: build
     if: github.event_name == 'release' || github.event_name == 'workflow_dispatch'
     runs-on: ${{ matrix.os }}
@@ -215,9 +223,55 @@ jobs:
           name: dist-${{ matrix.os }}-${{ matrix.python-version }}
           path: dist
 
+  build-pypy-wheels:
+    needs: build
+    if: github.event_name == 'release' || github.event_name == 'workflow_dispatch'
+    runs-on: ${{ matrix.os }}
+    strategy:
+      fail-fast: false
+      matrix:
+        python-version: ["pypy-3.11"]
+        os: [ubuntu-latest]
+
+    steps:
+      - uses: actions/checkout@v4
+        with:
+          fetch-depth: 9
+
+      - name: Set up Python ${{ matrix.python-version }}
+        uses: actions/setup-python@v5
+        with:
+          python-version: ${{ matrix.python-version }}
+
+      - name: Install dependencies
+        run: |
+          pip install -r requirements.pypy.txt
+
+      - name: Run tests with PyPy
+        run: |
+          echo "Running unit tests..."
+          pytest tests
+
+          echo "Running integration tests..."
+          export APP_DEFAULT_ROUTER=false
+          pytest itests
+
+      - name: Install build dependencies
+        run: |
+          pip install --upgrade build
+
+      - name: Build wheels
+        run: |
+          python -m build
+
+      - uses: actions/upload-artifact@v4
+        with:
+          name: dist-${{ matrix.os }}-${{ matrix.python-version }}
+          path: dist
+
   publish:
     runs-on: ubuntu-latest
-    needs: [build, build-wheels]
+    needs: [build, build-cpython-wheels, build-pypy-wheels]
     if: github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.publish_artifacts == 'Y')
     steps:
       - name: Download a distribution artifact
diff --git a/.github/workflows/perf.yml b/.github/workflows/perf.yml
index e567149..686a1d5 100644
--- a/.github/workflows/perf.yml
+++ b/.github/workflows/perf.yml
@@ -51,8 +51,46 @@ jobs:
 
       - name: Install dependencies for benchmark
         run: |
-          cd perf
-          pip install -r req.txt
+          pip install memory-profiler==0.61.0 psutil==7.0.0
+
+      - name: Run benchmark
+        shell: bash
+        run: |
+          export PYTHONPATH="."
+          python perf/main.py --times 5
+
+      - name: Upload results
+        uses: actions/upload-artifact@v4
+        with:
+          name: benchmark-results-${{ matrix.os }}-${{ matrix.python-version }}
+          path: benchmark_results
+
+  perf-tests-pypy:
+    strategy:
+      fail-fast: false
+      matrix:
+        python-version: ["pypy-3.11"]
+        os: [ubuntu-latest, macos-latest, windows-latest]
+    runs-on: ${{ matrix.os }}
+
+    steps:
+      - uses: actions/checkout@v4
+        with:
+          fetch-depth: 9
+          submodules: false
+
+      - name: Use Python ${{ matrix.python-version }}
+        uses: actions/setup-python@v5
+        with:
+          python-version: ${{ matrix.python-version }}
+
+      - name: Install dependencies
+        run: |
+          pip install -r requirements.pypy.txt
+
+      - name: Install dependencies for benchmark
+        run: |
+          pip install memory-profiler==0.61.0 psutil==7.0.0
 
       - name: Run benchmark
         shell: bash
@@ -68,7 +106,7 @@ jobs:
 
   genreport:
     runs-on: ubuntu-latest
-    needs: [perf-tests]
+    needs: [perf-tests, perf-tests-pypy]
     steps:
       - uses: actions/checkout@v4
         with:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6fb699d..5019880 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,12 +5,30 @@ All notable changes to this project will be documented in this file.
 The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
 and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
-## [2.3.1] - 2025-??-?? :horse_racing:
-
+## [2.3.1a1] - 2025-05-20 :racehorse:
+
+> [!NOTE]
+> This is an alpha release to test published wheels before publishing a normal
+> release.
+
+- Add support for [`PyPy`](https://pypy.org/), adding a pure-Python fallback
+  for all `Cython` modules.
+- Fix [#539](https://github.com/Neoteroi/BlackSheep/issues/539). Make
+  `httptools` an optional dependency, to support [`PyPy`](https://pypy.org/).
+- Modify the HTTP Client implementation in BlackSheep to
+  not be tightly coupled with `httptools` for the response parsing logic, and
+  include built-in support for `h11`.
+- **MINOR BREAKING CHANGE**. Since `httptools` is not installed by default, the
+  BlackSheep **client** requires either `httptools` or `h11` to be installed, as it
+  does not implement its own parsing logic for HTTP responses. This affects
+  only the HTTP client.
+- Upgrade dependencies (see `pyproject.toml` for more information).
+- Include a pure-Python wheel in the distribution package, and run tests
+  with **PyPy 3.11**.
 - Fix [#559](https://github.com/Neoteroi/BlackSheep/issues/559), which is a
   performance regression introduced in `2.3.0`. Remove support for Pydantic v1
-  `validate_arguments` decorator (added in 2.3.0). Pydantic's v2
-  `validate_call` supports async and does not require specific code.
+  `validate_arguments` decorator (added in 2.3.0), which caused the regression.
+  Pydantic's v2 `validate_call` supports async and does not require specific code.
 
 ## [2.3.0] - 2025-05-10 :sun_behind_small_cloud:
 
diff --git a/README.md b/README.md
index 2bd2c59..844b79d 100644
--- a/README.md
+++ b/README.md
@@ -1,7 +1,6 @@
 [![Build](https://github.com/Neoteroi/BlackSheep/workflows/Main/badge.svg)](https://github.com/Neoteroi/BlackSheep/actions)
 [![pypi](https://img.shields.io/pypi/v/BlackSheep.svg?color=blue)](https://pypi.org/project/BlackSheep/)
 [![versions](https://img.shields.io/pypi/pyversions/blacksheep.svg)](https://github.com/robertoprevato/blacksheep)
-[![codecov](https://codecov.io/gh/Neoteroi/BlackSheep/branch/master/graph/badge.svg?token=Nzi29L0Eg1)](https://codecov.io/gh/Neoteroi/BlackSheep)
 [![license](https://img.shields.io/github/license/Neoteroi/blacksheep.svg)](https://github.com/Neoteroi/blacksheep/blob/main/LICENSE) [![Join the chat at https://gitter.im/Neoteroi/BlackSheep](https://badges.gitter.im/Neoteroi/BlackSheep.svg)](https://gitter.im/Neoteroi/BlackSheep?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) [![documentation](https://img.shields.io/badge/📖-docs-purple)](https://www.neoteroi.dev/blacksheep/)
 
 # BlackSheep
diff --git a/blacksheep/__init__.py b/blacksheep/__init__.py
index 2fbfc88..cd079cc 100644
--- a/blacksheep/__init__.py
+++ b/blacksheep/__init__.py
@@ -4,7 +4,7 @@ used types to reduce the verbosity of the imports statements.
 """
 
 __author__ = "Roberto Prevato <roberto.prevato@gmail.com>"
-__version__ = "2.3.1"
+__version__ = "2.3.1a1"
 
 from .contents import Content as Content
 from .contents import FormContent as FormContent
diff --git a/blacksheep/baseapp.py b/blacksheep/baseapp.py
new file mode 100644
index 0000000..8162150
--- /dev/null
+++ b/blacksheep/baseapp.py
@@ -0,0 +1,176 @@
+import http
+import logging
+
+from .contents import Content, TextContent
+from .exceptions import HTTPException, InternalServerError, NotFound
+from .messages import Response
+from .utils import get_class_instance_hierarchy
+
+try:
+    from pydantic import ValidationError
+except ImportError:
+    ValidationError = None
+
+
+async def handle_not_found(app, request, http_exception):
+    return Response(404, content=TextContent("Resource not found"))
+
+
+async def handle_internal_server_error(app, request, exception):
+    return Response(500, content=TextContent("Internal Server Error"))
+
+
+async def handle_bad_request(app, request, http_exception):
+    if getattr(http_exception, "__context__", None) is not None and callable(
+        getattr(http_exception.__context__, "json", None)
+    ):
+        return Response(
+            http_exception.status,
+            content=Content(
+                b"application/json", http_exception.__context__.json().encode("utf8")
+            ),
+        )
+    return Response(400, content=TextContent(f"Bad Request: {str(http_exception)}"))
+
+
+async def _default_pydantic_validation_error_handler(app, request, error):
+    return Response(
+        400, content=Content(b"application/json", error.json(indent=4).encode("utf-8"))
+    )
+
+
+async def common_http_exception_handler(app, request, http_exception):
+    return Response(
+        http_exception.status,
+        content=TextContent(http.HTTPStatus(http_exception.status).phrase),
+    )
+
+
+def get_logger():
+    logger = logging.getLogger("blacksheep.server")
+    logger.setLevel(logging.INFO)
+    return logger
+
+
+class BaseApplication:
+    def __init__(self, show_error_details, router):
+        self.router = router
+        self.exceptions_handlers = self.init_exceptions_handlers()
+        self.show_error_details = show_error_details
+        self.logger = get_logger()
+
+    def init_exceptions_handlers(self):
+        default_handlers = {404: handle_not_found, 400: handle_bad_request}
+        if ValidationError is not None:
+            default_handlers[ValidationError] = (
+                _default_pydantic_validation_error_handler
+            )
+        return default_handlers
+
+    async def log_unhandled_exc(self, request, exc):
+        self.logger.error(
+            'Unhandled exception - "%s %s"',
+            request.method,
+            request.url.value.decode(),
+            exc_info=exc,
+        )
+
+    async def log_handled_exc(self, request, exc):
+        if isinstance(exc, HTTPException):
+            self.logger.info(
+                'HTTP %s - "%s %s". %s',
+                exc.status,
+                request.method,
+                request.url.value.decode(),
+                str(exc),
+            )
+        else:
+            self.logger.info(
+                'Handled error: "%s %s". %s',
+                request.method,
+                request.url.value.decode(),
+                str(exc),
+            )
+
+    async def handle(self, request):
+        route = self.router.get_match(request)
+        if route:
+            request.route_values = route.values
+            try:
+                response = await route.handler(request)
+            except Exception as exc:
+                response = await self.handle_request_handler_exception(request, exc)
+        else:
+            not_found_handler = (
+                self.get_http_exception_handler(NotFound()) or handle_not_found
+            )
+            response = await not_found_handler(self, request, None)
+            if not response:
+                response = Response(404)
+        return response or Response(204)
+
+    async def handle_request_handler_exception(self, request, exc):
+        if isinstance(exc, HTTPException):
+            await self.log_handled_exc(request, exc)
+            return await self.handle_http_exception(request, exc)
+        if self.is_handled_exception(exc):
+            await self.log_handled_exc(request, exc)
+        else:
+            await self.log_unhandled_exc(request, exc)
+        return await self.handle_exception(request, exc)
+
+    def get_http_exception_handler(self, http_exception):
+        handler = self.get_exception_handler(http_exception, stop_at=HTTPException)
+        if handler:
+            return handler
+        return self.exceptions_handlers.get(
+            getattr(http_exception, "status", None), common_http_exception_handler
+        )
+
+    def is_handled_exception(self, exception):
+        for class_type in get_class_instance_hierarchy(exception):
+            if class_type in self.exceptions_handlers:
+                return True
+        return False
+
+    def get_exception_handler(self, exception, stop_at):
+        for class_type in get_class_instance_hierarchy(exception):
+            if stop_at is not None and stop_at is class_type:
+                return None
+            if class_type in self.exceptions_handlers:
+                return self.exceptions_handlers[class_type]
+        return None
+
+    async def handle_internal_server_error(self, request, exc):
+        if self.show_error_details:
+            return self.server_error_details_handler.produce_response(request, exc)
+        error = InternalServerError(exc)
+        internal_server_error_handler = self.get_http_exception_handler(error)
+        try:
+            return await internal_server_error_handler(self, request, error)
+        except Exception:
+            self.logger.exception(
+                "An exception occurred while trying to apply the configured "
+                "Internal Server Error handler!"
+            )
+        return Response(500, content=TextContent("Internal Server Error"))
+
+    async def _apply_exception_handler(self, request, exc, exception_handler):
+        try:
+            return await exception_handler(self, request, exc)
+        except Exception as server_ex:
+            return await self.handle_exception(request, server_ex)
+
+    async def handle_http_exception(self, request, http_exception):
+        exception_handler = self.get_http_exception_handler(http_exception)
+        if exception_handler:
+            return await self._apply_exception_handler(
+                request, http_exception, exception_handler
+            )
+        return await self.handle_exception(request, http_exception)
+
+    async def handle_exception(self, request, exc):
+        exception_handler = self.get_exception_handler(exc, None)
+        if exception_handler:
+            return await self._apply_exception_handler(request, exc, exception_handler)
+        return await self.handle_internal_server_error(request, exc)
diff --git a/blacksheep/client/connection.py b/blacksheep/client/connection.py
index bac8709..a335019 100644
--- a/blacksheep/client/connection.py
+++ b/blacksheep/client/connection.py
@@ -1,13 +1,12 @@
 import asyncio
 import ssl
 import weakref
-from typing import Optional
+from typing import Optional, Protocol
 
 import certifi
-import httptools
-from httptools import HttpParserCallbackError, HttpParserError
 
 from blacksheep import Content, Request, Response
+from blacksheep.client.parser import get_default_parser
 from blacksheep.scribe import (
     is_small_request,
     request_has_body,
@@ -27,6 +26,17 @@ INSECURE_SSLCONTEXT.check_hostname = False
 INSECURE_SSLCONTEXT.verify_mode = ssl.CERT_NONE
 
 
+class HTTPResponseParserProtocol(Protocol):
+    """
+    Required protocol for classes that can parse HTTP Responses.
+    """
+
+    def __init__(self, connection) -> None: ...
+    def feed_data(self, data: bytes) -> None: ...
+    def get_status_code(self) -> int: ...
+    def reset(self) -> None: ...
+
+
 class IncomingContent(Content):
     def __init__(self, content_type: bytes):
         super().__init__(content_type, b"")
@@ -157,7 +167,9 @@ class ClientConnection(asyncio.Protocol):
         "_upgraded",
     )
 
-    def __init__(self, loop, pool) -> None:
+    def __init__(
+        self, loop, pool, parser_impl: Optional[HTTPResponseParserProtocol] = None
+    ) -> None:
         self.loop = loop
         self.pool = weakref.ref(pool)
         self.transport = None
@@ -171,7 +183,12 @@ class ClientConnection(asyncio.Protocol):
         self.request_timeout = 20
         self.headers = []
         self.response = None
-        self.parser = httptools.HttpResponseParser(self)  # type: ignore
+        # Use the provided parser implementation, default to a built-in implementation
+        # otherwise
+        if parser_impl is not None:
+            self.parser = parser_impl
+        else:
+            self.parser = get_default_parser(self)
         self._connection_lost = False
         self._pending_task = None
         self._can_release = False
@@ -184,7 +201,12 @@ class ClientConnection(asyncio.Protocol):
         self.writing_paused = False
         self.writable.set()
         self.expect_100_continue = False
-        self.parser = httptools.HttpResponseParser(self)  # type: ignore
+        # Reset parser implementation if possible, else re-instantiate
+        try:
+            self.parser.reset()
+        except Exception:
+            # TODO: raise exception if a different type was provided
+            self.parser = get_default_parser(self)
         self._connection_lost = False
         self._pending_task = None
         self._can_release = False
@@ -308,12 +330,9 @@ class ClientConnection(asyncio.Protocol):
     def data_received(self, data: bytes) -> None:
         try:
             self.parser.feed_data(data)
-        except HttpParserCallbackError:
-            self.close()
-            raise
-        except HttpParserError as pex:
+        except Exception as exc:
             self.close()
-            raise InvalidResponseFromServer(pex)
+            raise InvalidResponseFromServer(exc)
 
     def connection_lost(self, exc) -> None:
         self._connection_lost = True
diff --git a/blacksheep/client/exceptions.py b/blacksheep/client/exceptions.py
index d91dd8a..4b75d45 100644
--- a/blacksheep/client/exceptions.py
+++ b/blacksheep/client/exceptions.py
@@ -1,7 +1,11 @@
 from blacksheep import URL
 
 
-class InvalidResponseException(Exception):
+class ClientException(Exception):
+    """Base class for BlackSheep Client Exceptions."""
+
+
+class InvalidResponseException(ClientException):
     def __init__(self, message, response):
         super().__init__(message)
         self.response = response
@@ -51,9 +55,9 @@ class MaximumRedirectsExceededError(InvalidResponseException):
         )
 
 
-class UnsupportedRedirect(Exception):
+class UnsupportedRedirect(ClientException):
     """
-    Exception risen when the client cannot handle a redirect;
+    Exception raised when the client cannot handle a redirect;
     for example if the redirect is to a URN (not a URL). In such case,
     we don't follow the redirect and return the response with location: the
     caller can handle it.
diff --git a/blacksheep/client/parser.py b/blacksheep/client/parser.py
new file mode 100644
index 0000000..b5e8ebd
--- /dev/null
+++ b/blacksheep/client/parser.py
@@ -0,0 +1,78 @@
+try:
+    import httptools
+except ImportError:
+    httptools = None
+
+
+try:
+    import h11
+except ImportError:
+    h11 = None
+
+
+if httptools is not None:
+
+    class HTTPToolsResponseParser:
+        def __init__(self, connection) -> None:
+            self.connection = connection
+            self._parser = httptools.HttpResponseParser(connection)
+
+        def feed_data(self, data: bytes) -> None:
+            self._parser.feed_data(data)
+
+        def get_status_code(self) -> int:
+            return self._parser.get_status_code()
+
+        def reset(self) -> None:
+            self._parser = httptools.HttpResponseParser(self.connection)
+
+
+if h11 is not None:
+
+    class H11ResponseParser:
+        def __init__(self, connection) -> None:
+            self.connection = connection
+            self._conn = h11.Connection(h11.CLIENT)
+            self._status_code = None
+            self._headers = []
+            self._complete = False
+
+        def feed_data(self, data: bytes) -> None:
+            self._conn.receive_data(data)
+            while True:
+                event = self._conn.next_event()
+                if event is h11.NEED_DATA:
+                    break
+                if isinstance(event, h11.Response):
+                    self._status_code = event.status_code
+                    self._headers = [(k, v) for k, v in event.headers]
+                    self.connection.headers = self._headers
+                    self.connection.on_headers_complete()
+                elif isinstance(event, h11.Data):
+                    self.connection.on_body(event.data)
+                elif isinstance(event, h11.EndOfMessage):
+                    self._complete = True
+                    self.connection.on_message_complete()
+                # Ignore other events
+
+        def get_status_code(self) -> int:
+            return self._status_code or 0
+
+        def reset(self) -> None:
+            self._conn = h11.Connection(h11.CLIENT)
+            self._status_code = None
+            self._headers = []
+            self._complete = False
+
+
+def get_default_parser(client_connection):
+    if httptools is not None:
+        return HTTPToolsResponseParser(client_connection)
+
+    if h11 is not None:
+        return H11ResponseParser(client_connection)
+
+    raise RuntimeError(
+        "Missing Python dependencies to provide a default HTTP Response parser. "
+        "Install either h11 or httptools."
+    )
diff --git a/blacksheep/contents.py b/blacksheep/contents.py
new file mode 100644
index 0000000..f735c49
--- /dev/null
+++ b/blacksheep/contents.py
@@ -0,0 +1,277 @@
+import uuid
+from collections.abc import MutableSequence
+from inspect import isasyncgenfunction
+from typing import Dict, List, Optional, Tuple, Union
+from urllib.parse import parse_qsl, quote_plus
+
+from blacksheep.settings.json import json_settings
+
+from .exceptions import MessageAborted
+
+
+class Content:
+    def __init__(self, content_type: bytes, data: bytes):
+        self.type = content_type
+        self.body = data
+        self.length = len(data)
+
+    async def read(self):
+        return self.body
+
+
+class StreamedContent(Content):
+    def __init__(self, content_type: bytes, data_provider, data_length: int = -1):
+        self.type = content_type
+        self.body = None
+        self.length = data_length
+        self.generator = data_provider
+        if not isasyncgenfunction(data_provider):
+            raise ValueError("Data provider must be an async generator")
+
+    async def read(self):
+        value = bytearray()
+        async for chunk in self.generator():
+            value.extend(chunk)
+        self.body = bytes(value)
+        self.length = len(self.body)
+        return self.body
+
+    async def stream(self):
+        async for chunk in self.generator():
+            yield chunk
+
+    async def get_parts(self):
+        async for chunk in self.generator():
+            yield chunk
+
+
+class ASGIContent(Content):
+    def __init__(self, receive):
+        self.type = None
+        self.body = None
+        self.length = -1
+        self.receive = receive
+
+    def dispose(self):
+        self.receive = None
+        self.body = None
+
+    async def stream(self):
+        while True:
+            message = await self.receive()
+            if message.get("type") == "http.disconnect":
+                raise MessageAborted()
+            yield message.get("body", b"")
+            if not message.get("more_body"):
+                break
+        yield b""
+
+    async def read(self):
+        if self.body is not None:
+            return self.body
+        value = bytearray()
+        while True:
+            message = await self.receive()
+            if message.get("type") == "http.disconnect":
+                raise MessageAborted()
+            value.extend(message.get("body", b""))
+            if not message.get("more_body"):
+                break
+        self.body = bytes(value)
+        self.length = len(self.body)
+        return self.body
+
+
+class TextContent(Content):
+    def __init__(self, text: str):
+        super().__init__(b"text/plain; charset=utf-8", text.encode("utf8"))
+
+
+class HTMLContent(Content):
+    def __init__(self, html: str):
+        super().__init__(b"text/html; charset=utf-8", html.encode("utf8"))
+
+
+class JSONContent(Content):
+    def __init__(self, data, dumps=json_settings.dumps):
+        super().__init__(b"application/json", dumps(data).encode("utf8"))
+
+
+def parse_www_form_urlencoded(content: str) -> dict:
+    data = {}
+    for key, value in parse_qsl(content):
+        if key in data:
+            if isinstance(data[key], str):
+                data[key] = [data[key], value]
+            else:
+                data[key].append(value)
+        else:
+            data[key] = value
+    return data
+
+
+def parse_www_form(content: str) -> dict:
+    return parse_www_form_urlencoded(content)
+
+
+def try_decode(value: bytes, encoding: str):
+    try:
+        return value.decode(encoding or "utf8")
+    except Exception:
+        return value
+
+
+def multiparts_to_dictionary(parts: list) -> dict:
+    data = {}
+    for part in parts:
+        key = part.name.decode("utf8")
+        charset = part.charset.encode() if part.charset else None
+        if part.file_name:
+            if key in data:
+                data[key].append(part)
+            else:
+                data[key] = [part]
+        else:
+            if key in data:
+                if isinstance(data[key], list):
+                    data[key].append(try_decode(part.data, charset))
+                else:
+                    data[key] = [data[key], try_decode(part.data, charset)]
+            else:
+                data[key] = try_decode(part.data, charset)
+    return data
+
+
+def write_multipart_part(part, destination: bytearray):
+    destination.extend(b'Content-Disposition: form-data; name="')
+    destination.extend(part.name)
+    destination.extend(b'"')
+    if part.file_name:
+        destination.extend(b'; filename="')
+        destination.extend(part.file_name)
+        destination.extend(b'"\r\n')
+    if part.content_type:
+        destination.extend(b"Content-Type: ")
+        destination.extend(part.content_type)
+    destination.extend(b"\r\n\r\n")
+    destination.extend(part.data)
+    destination.extend(b"\r\n")
+
+
+def write_www_form_urlencoded(data: Union[dict, list]) -> bytes:
+    if isinstance(data, list):
+        values = data
+    else:
+        values = data.items()
+    contents = []
+    for key, value in values:
+        if isinstance(value, MutableSequence):
+            for item in value:
+                contents.append(quote_plus(key) + "=" + quote_plus(str(item)))
+        else:
+            contents.append(quote_plus(key) + "=" + quote_plus(str(value)))
+    return ("&".join(contents)).encode("utf8")
+
+
+class FormContent(Content):
+    def __init__(self, data: Union[Dict[str, str], List[Tuple[str, str]]]):
+        super().__init__(
+            b"application/x-www-form-urlencoded", write_www_form_urlencoded(data)
+        )
+
+
+class FormPart:
+    def __init__(
+        self,
+        name: bytes,
+        data: bytes,
+        content_type: Optional[bytes] = None,
+        file_name: Optional[bytes] = None,
+        charset: Optional[bytes] = None,
+    ):
+        self.name = name
+        self.data = data
+        self.file_name = file_name
+        self.content_type = content_type
+        self.charset = charset
+
+    def __eq__(self, other):
+        if isinstance(other, FormPart):
+            return (
+                other.name == self.name
+                and other.file_name == self.file_name
+                and other.content_type == self.content_type
+                and other.charset == self.charset
+                and other.data == self.data
+            )
+        if other is None:
+            return False
+        return NotImplemented
+
+    def __repr__(self):
+        return f"<FormPart {self.name} - at {id(self)}>"
+
+
+class MultiPartFormData(Content):
+    def __init__(self, parts: list):
+        self.parts = parts
+        self.boundary = b"------" + str(uuid.uuid4()).replace("-", "").encode()
+        super().__init__(
+            b"multipart/form-data; boundary=" + self.boundary,
+            write_multipart_form_data(self),
+        )
+
+
+def write_multipart_form_data(data: "MultiPartFormData") -> bytes:
+    contents = bytearray()
+    for part in data.parts:
+        contents.extend(b"--")
+        contents.extend(data.boundary)
+        contents.extend(b"\r\n")
+        write_multipart_part(part, contents)
+    contents.extend(b"--")
+    contents.extend(data.boundary)
+    contents.extend(b"--\r\n")
+    return bytes(contents)
+
+
+class ServerSentEvent:
+    """
+    Represents a single event of a Server-sent event communication, to be used
+    in a asynchronous generator.
+    """
+
+    def __init__(
+        self,
+        data,
+        event: Optional[str] = None,
+        id: Optional[str] = None,
+        retry: Optional[int] = -1,
+        comment: Optional[str] = None,
+    ):
+        self.data = data
+        self.event = event
+        self.id = id
+        self.retry = retry
+        self.comment = comment
+
+    def write_data(self) -> str:
+        return json_settings.dumps(self.data)
+
+    def __repr__(self):
+        return f"ServerSentEvent({self.data})"
+
+
+class TextServerSentEvent(ServerSentEvent):
+    def __init__(
+        self,
+        data: str,
+        event: Optional[str] = None,
+        id: Optional[str] = None,
+        retry: Optional[int] = -1,
+        comment: Optional[str] = None,
+    ):
+        super().__init__(data, event, id, retry, comment)
+
+    def write_data(self) -> str:
+        return self.data.replace("\r", "\\r").replace("\n", "\\n")
diff --git a/blacksheep/cookies.py b/blacksheep/cookies.py
new file mode 100644
index 0000000..e6fc030
--- /dev/null
+++ b/blacksheep/cookies.py
@@ -0,0 +1,213 @@
+from datetime import datetime
+from email.utils import parsedate_to_datetime
+from enum import Enum
+from typing import Optional
+from urllib.parse import quote, unquote
+
+
+class CookieSameSiteMode(Enum):
+    UNDEFINED = 0
+    STRICT = 1
+    LAX = 2
+    NONE = 3
+
+
+def datetime_to_cookie_format(value: datetime) -> bytes:
+    return value.strftime("%a, %d %b %Y %H:%M:%S GMT").encode()
+
+
+def datetime_from_cookie_format(value: bytes) -> datetime:
+    return parsedate_to_datetime(value.decode()).replace(tzinfo=None)
+
+
+class CookieError(Exception):
+    def __init__(self, message: str):
+        super().__init__(message)
+
+
+class CookieValueExceedsMaximumLength(CookieError):
+    def __init__(self):
+        super().__init__(
+            "The length of the cookie value exceeds the maximum "
+            "length of 4096 bytes, and it would be ignored or truncated "
+            "by clients. See: https://tools.ietf.org/html/rfc6265#section-6.1"
+        )
+
+
+class Cookie:
+    def __init__(
+        self,
+        name: str,
+        value: str,
+        expires: Optional[datetime] = None,
+        domain: Optional[str] = None,
+        path: Optional[str] = None,
+        http_only: bool = False,
+        secure: bool = False,
+        max_age: int = -1,
+        same_site: CookieSameSiteMode = CookieSameSiteMode.UNDEFINED,
+    ):
+        self.name = name
+        self.value = value
+        self.expires = expires
+        self.domain = domain
+        self.path = path
+        self.http_only = http_only
+        self.secure = secure
+        self.max_age = max_age
+        self.same_site = same_site
+
+    @property
+    def name(self):
+        return self._name
+
+    @name.setter
+    def name(self, value):
+        if not value:
+            raise ValueError("A cookie name is required")
+        self._name = value
+
+    @property
+    def value(self):
+        return self._value
+
+    @value.setter
+    def value(self, value):
+        if value and len(value.encode()) > 4096:
+            raise CookieValueExceedsMaximumLength()
+        self._value = value
+
+    def clone(self):
+        return Cookie(
+            self.name,
+            self.value,
+            self.expires,
+            self.domain,
+            self.path,
+            self.http_only,
+            self.secure,
+            self.max_age,
+            self.same_site,
+        )
+
+    def __eq__(self, other):
+        if isinstance(other, str):
+            return other == self.value
+        if isinstance(other, bytes):
+            return other.decode() == self.value
+        if isinstance(other, Cookie):
+            return other.name == self.name and other.value == self.value
+        return NotImplemented
+
+    def __repr__(self):
+        return f"<Cookie {self.name}: {self.value}>"
+
+
+def split_value(raw_value: bytes, separator: bytes):
+    try:
+        index = raw_value.index(separator)
+    except ValueError:
+        # this is the situation of flags, e.g. httponly and secure
+        return b"", raw_value
+    return raw_value[:index], raw_value[index + 1 :]
+
+
+def same_site_mode_from_bytes(raw_value: bytes) -> CookieSameSiteMode:
+    if not raw_value:
+        return CookieSameSiteMode.UNDEFINED
+    raw_value_lower = raw_value.lower()
+    if raw_value_lower == b"strict":
+        return CookieSameSiteMode.STRICT
+    if raw_value_lower == b"lax":
+        return CookieSameSiteMode.LAX
+    if raw_value_lower == b"none":
+        return CookieSameSiteMode.NONE
+    return CookieSameSiteMode.UNDEFINED
+
+
+def parse_cookie(raw_value: bytes) -> Cookie:
+    eq = b"="
+    parts = raw_value.split(b"; ")
+    if len(parts) == 0:
+        # only name=value pair
+        try:
+            name, value = split_value(raw_value, eq)
+        except ValueError:
+            raise ValueError(f"Invalid name=value fragment: {parts[0]}")
+        else:
+            return Cookie(name.decode(), value.decode())
+    if len(parts) == 1:
+        # some set a cookie with a separator without space
+        parts = raw_value.split(b";")
+    try:
+        name, value = split_value(parts[0], eq)
+    except ValueError:
+        raise ValueError(f"Invalid name=value fragment: {parts[0]}")
+    if b" " in value and value.startswith(b'"'):
+        value = value.strip(b'"')
+    expires = None
+    domain = None
+    path = None
+    http_only = False
+    secure = False
+    max_age = -1
+    same_site = None
+    for part in parts:
+        if eq in part:
+            k, v = split_value(part, eq)
+            lower_k = k.lower()
+            if lower_k == b"expires":
+                expires = v
+            elif lower_k == b"domain":
+                domain = v
+            elif lower_k == b"path":
+                path = v
+            elif lower_k == b"max-age":
+                max_age = int(v)
+            elif lower_k == b"samesite":
+                same_site = v
+        else:
+            lower_part = part.lower()
+            if lower_part == b"httponly":
+                http_only = True
+            if lower_part == b"secure":
+                secure = True
+    return Cookie(
+        unquote(name.decode()),
+        unquote(value.decode()),
+        datetime_from_cookie_format(expires) if expires else None,
+        domain.decode() if domain else None,
+        path.decode() if path else None,
+        http_only,
+        secure,
+        max_age,
+        same_site_mode_from_bytes(same_site),
+    )
+
+
+def write_cookie_for_response(cookie: Cookie) -> bytes:
+    parts = []
+    parts.append(quote(cookie.name).encode() + b"=" + quote(cookie.value).encode())
+    if cookie.expires:
+        parts.append(b"Expires=" + datetime_to_cookie_format(cookie.expires))
+    if cookie.max_age > -1:
+        parts.append(b"Max-Age=" + str(cookie.max_age).encode())
+    if cookie.domain:
+        parts.append(b"Domain=" + cookie.domain.encode())
+    if cookie.path:
+        parts.append(b"Path=" + cookie.path.encode())
+    if cookie.http_only:
+        parts.append(b"HttpOnly")
+    if (
+        cookie.secure
+        or cookie.same_site == CookieSameSiteMode.STRICT
+        or cookie.same_site == CookieSameSiteMode.NONE
+    ):
+        parts.append(b"Secure")
+    if cookie.same_site == CookieSameSiteMode.STRICT:
+        parts.append(b"SameSite=Strict")
+    if cookie.same_site == CookieSameSiteMode.LAX:
+        parts.append(b"SameSite=Lax")
+    if cookie.same_site == CookieSameSiteMode.NONE:
+        parts.append(b"SameSite=None")
+    return b"; ".join(parts)
diff --git a/blacksheep/exceptions.py b/blacksheep/exceptions.py
new file mode 100644
index 0000000..232d911
--- /dev/null
+++ b/blacksheep/exceptions.py
@@ -0,0 +1,79 @@
+class InvalidOperation(Exception):
+    def __init__(self, message: str, inner_exception=None):
+        super().__init__(message)
+        self.inner_exception = inner_exception
+
+
+class HTTPException(Exception):
+    def __init__(self, status: int, message: str = "HTTP exception"):
+        super().__init__(message)
+        self.status = status
+
+
+class BadRequest(HTTPException):
+    def __init__(self, message=None):
+        super().__init__(400, message or "Bad request")
+
+
+class BadRequestFormat(BadRequest):
+    def __init__(self, message: str, inner_exception=None):
+        super().__init__(message)
+        self.inner_exception = inner_exception
+
+
+class FailedRequestError(HTTPException):
+    def __init__(self, status: int, data: str) -> None:
+        super().__init__(
+            status,
+            f"The response status code does not indicate success: {status}. "
+            "Response body: {data}",
+        )
+        self.data = data
+
+
+class NotFound(HTTPException):
+    def __init__(self, message=None):
+        super().__init__(404, message or "Not found")
+
+
+class Unauthorized(HTTPException):
+    def __init__(self, message=None):
+        super().__init__(401, message or "Unauthorized")
+
+
+class Forbidden(HTTPException):
+    def __init__(self, message=None):
+        super().__init__(403, message or "Forbidden")
+
+
+class Conflict(HTTPException):
+    def __init__(self, message=None):
+        super().__init__(409, message or "Conflict")
+
+
+class RangeNotSatisfiable(HTTPException):
+    def __init__(self):
+        super().__init__(416, "Range not satisfiable")
+
+
+class InternalServerError(HTTPException):
+    def __init__(self, source_error: Exception = None):
+        super().__init__(500, "Internal server error")
+        self.source_error = source_error
+
+
+class NotImplementedByServer(HTTPException):
+    def __init__(self):
+        super().__init__(501, "Not implemented by server")
+
+
+class InvalidArgument(Exception):
+    def __init__(self, message: str):
+        super().__init__(message)
+
+
+class MessageAborted(Exception):
+    def __init__(self):
+        super().__init__(
+            "The message was aborted before the client sent its whole content."
+        )
diff --git a/blacksheep/headers.py b/blacksheep/headers.py
new file mode 100644
index 0000000..1c37e3e
--- /dev/null
+++ b/blacksheep/headers.py
@@ -0,0 +1,172 @@
+from collections.abc import Mapping, MutableSequence
+from typing import Dict, List, Tuple, Union
+
+
+class Header:
+    def __init__(self, name: bytes, value: bytes):
+        self.name = name
+        self.value = value
+
+    def __repr__(self):
+        return f"<Header {self.name}: {self.value}>"
+
+    def __iter__(self):
+        yield self.name
+        yield self.value
+
+    def __eq__(self, other):
+        if isinstance(other, Header):
+            return other.name.lower() == self.name.lower() and other.value == self.value
+        return NotImplemented
+
+
+class Headers:
+    def __init__(self, values: List[Tuple[bytes, bytes]] = None):
+        if values is None:
+            values = []
+        self.values = values
+
+    def get(self, name: bytes) -> Tuple[bytes, ...]:
+        results = []
+        name = name.lower()
+        for header in self.values:
+            if header[0].lower() == name:
+                results.append(header[1])
+        return tuple(results)
+
+    def get_tuples(self, name: bytes) -> List[Tuple[bytes, bytes]]:
+        results = []
+        name = name.lower()
+        for header in self.values:
+            if header[0].lower() == name:
+                results.append(header)
+        return results
+
+    def get_first(self, key: bytes) -> Union[bytes, None]:
+        key = key.lower()
+        for header in self.values:
+            if header[0].lower() == key:
+                return header[1]
+        return None
+
+    def get_single(self, key: bytes) -> bytes:
+        results = self.get(key)
+        if len(results) > 1:
+            raise ValueError("Headers contains more than one header with the given key")
+        if len(results) < 1:
+            raise ValueError("Headers does not contain one header with the given key")
+        return results[0]
+
+    def merge(self, values: List[Tuple[bytes, bytes]]):
+        for header in values:
+            if header is None:
+                continue
+            self.values.append(header)
+
+    def update(self, values: Dict[bytes, bytes]):
+        for key, value in values.items():
+            self[key] = value
+
+    def items(self):
+        yield from self.values
+
+    def clone(self):
+        values = []
+        for name, value in self.values:
+            values.append((name, value))
+        return Headers(values)
+
+    def add_many(self, values: Union[Dict[bytes, bytes], List[Tuple[bytes, bytes]]]):
+        if isinstance(values, MutableSequence):
+            for item in values:
+                self.add(*item)
+            return
+        if isinstance(values, Mapping):
+            for key, value in values.items():
+                self.add(key, value)
+            return
+        raise ValueError("values must be Dict[bytes, bytes] or List[Header]")
+
+    @staticmethod
+    def _add_to_instance(instance, other):
+        if isinstance(other, Headers):
+            for value in other:
+                instance.add(*value)
+            return instance
+        if isinstance(other, Header):
+            instance.add(other.name, other.value)
+            return instance
+        if isinstance(other, tuple):
+            if len(other) != 2:
+                raise ValueError(f"Cannot add, an invalid tuple {str(other)}.")
+            instance.add(*other)
+            return instance
+        if isinstance(other, MutableSequence):
+            for value in other:
+                if isinstance(value, tuple) and len(value) == 2:
+                    instance.add(*value)
+                else:
+                    raise ValueError(
+                        "The sequence contains invalid elements: cannot add "
+                        f"{str(value)} to {instance.__class__.__name__}"
+                    )
+            return instance
+        return NotImplemented
+
+    def __add__(self, other):
+        return self._add_to_instance(self.clone(), other)
+
+    def __radd__(self, other):
+        return self._add_to_instance(self.clone(), other)
+
+    def __iadd__(self, other):
+        return self._add_to_instance(self, other)
+
+    def __iter__(self):
+        yield from self.values
+
+    def __setitem__(self, key: bytes, value: bytes):
+        self.set(key, value)
+
+    def __getitem__(self, item: bytes):
+        return self.get(item)
+
+    def keys(self) -> Tuple[bytes, ...]:
+        results = []
+        for name, value in self.values:
+            if name not in results:
+                results.append(name)
+        return tuple(results)
+
+    def add(self, name: bytes, value: bytes):
+        self.values.append((name, value))
+
+    def set(self, name: bytes, value: bytes):
+        if self.contains(name):
+            self.remove(name)
+        self.add(name, value)
+
+    def remove(self, key: bytes):
+        to_remove = []
+        key = key.lower()
+        for item in self.values:
+            if item[0].lower() == key:
+                to_remove.append(item)
+        for item in to_remove:
+            self.values.remove(item)
+
+    def contains(self, key: bytes) -> bool:
+        key = key.lower()
+        for name, value in self.values:
+            if name.lower() == key:
+                return True
+        return False
+
+    def __delitem__(self, key: bytes):
+        self.remove(key)
+
+    def __contains__(self, key: bytes) -> bool:
+        return self.contains(key)
+
+    def __repr__(self):
+        return f"<Headers {self.values}>"
diff --git a/blacksheep/messages.py b/blacksheep/messages.py
new file mode 100644
index 0000000..fa53886
--- /dev/null
+++ b/blacksheep/messages.py
@@ -0,0 +1,578 @@
+import asyncio
+import http
+import re
+from datetime import timedelta
+from json.decoder import JSONDecodeError
+from typing import TYPE_CHECKING, Optional
+from urllib.parse import parse_qs, quote, unquote, urlencode
+
+import charset_normalizer
+
+from blacksheep.multipart import parse_multipart
+from blacksheep.settings.json import json_settings
+from blacksheep.utils.time import utcnow
+
+from .contents import (
+    ASGIContent,
+    Content,
+    multiparts_to_dictionary,
+    parse_www_form_urlencoded,
+)
+from .cookies import Cookie, parse_cookie, split_value, write_cookie_for_response
+from .exceptions import BadRequest, BadRequestFormat, FailedRequestError, MessageAborted
+from .headers import Headers
+from .url import URL, build_absolute_url
+
+if TYPE_CHECKING:
+    from blacksheep.sessions import Session
+
+_charset_rx = re.compile(rb"charset=([^;]+)\s", re.I)
+
+
+def parse_charset(value: bytes):
+    m = _charset_rx.match(value)
+    if m:
+        return m.group(1).decode("utf8")
+    return None
+
+
+async def _read_stream(request):
+    async for _ in request.content.stream():
+        pass
+
+
+async def _call_soon(coro):
+    task = asyncio.create_task(coro)
+    asyncio.get_event_loop().call_soon(task.cancel)
+    try:
+        return await task
+    except asyncio.CancelledError:
+        return None
+
+
+class Message:
+    def __init__(self, headers):
+        self._raw_headers = headers or []
+
+    @property
+    def headers(self):
+        key = "_headers"
+        if key in self.__dict__:
+            return self.__dict__[key]
+        self.__dict__[key] = Headers(self._raw_headers)
+        return self.__dict__[key]
+
+    def with_content(self, content: Content):
+        self.content = content
+        return self
+
+    def get_first_header(self, key: bytes):
+        key = key.lower()
+        for header in self._raw_headers:
+            if header[0].lower() == key:
+                return header[1]
+
+    def get_headers(self, key: bytes):
+        results = []
+        key = key.lower()
+        for header in self._raw_headers:
+            if header[0].lower() == key:
+                results.append(header[1])
+        return results
+
+    def init_prop(self, name: str, value):
+        try:
+            getattr(self, name)
+        except AttributeError:
+            setattr(self, name, value)
+
+    def get_headers_tuples(self, key: bytes):
+        results = []
+        key = key.lower()
+        for header in self._raw_headers:
+            if header[0].lower() == key:
+                results.append(header)
+        return results
+
+    def get_single_header(self, key: bytes):
+        results = self.get_headers(key)
+        if len(results) > 1:
+            raise ValueError("Headers contains more than one header with the given key")
+        if len(results) < 1:
+            raise ValueError("Headers does not contain one header with the given key")
+        return results[0]
+
+    def remove_header(self, key: bytes):
+        to_remove = []
+        key = key.lower()
+        for header in self._raw_headers:
+            if header[0].lower() == key:
+                to_remove.append(header)
+        for header in to_remove:
+            self._raw_headers.remove(header)
+
+    def remove_headers(self, headers):
+        for header in headers:
+            self._raw_headers.remove(header)
+
+    def _has_header(self, key: bytes):
+        key = key.lower()
+        for existing_key, existing_value in self._raw_headers:
+            if existing_key.lower() == key:
+                return True
+        return False
+
+    def has_header(self, key: bytes):
+        return self._has_header(key)
+
+    def _add_header(self, key: bytes, value: bytes):
+        self._raw_headers.append((key, value))
+
+    def _add_header_if_missing(self, key: bytes, value: bytes):
+        if not self._has_header(key):
+            self._raw_headers.append((key, value))
+
+    def add_header(self, key: bytes, value: bytes):
+        self._raw_headers.append((key, value))
+
+    def set_header(self, key: bytes, value: bytes):
+        self.remove_header(key)
+        self._raw_headers.append((key, value))
+
+    def content_type(self):
+        if hasattr(self, "content") and self.content and self.content.type:
+            return self.content.type
+        return self.get_first_header(b"content-type")
+
+    async def read(self):
+        if hasattr(self, "content") and self.content:
+            return await self.content.read()
+        return None
+
+    async def stream(self):
+        if hasattr(self, "content") and self.content:
+            async for chunk in self.content.stream():
+                yield chunk
+        else:
+            yield None
+
+    async def text(self):
+        body = await self.read()
+        if body is None:
+            return ""
+        try:
+            return body.decode(self.charset)
+        except UnicodeDecodeError:
+            if self.charset != "ISO-8859-1":
+                try:
+                    return body.decode("ISO-8859-1")
+                except UnicodeDecodeError:
+                    return body.decode(charset_normalizer.detect(body)["encoding"])
+
+    async def form(self):
+        content_type_value = self.content_type()
+        if not content_type_value:
+            return None
+        if b"application/x-www-form-urlencoded" in content_type_value:
+            text = await self.text()
+            return parse_www_form_urlencoded(text)
+        if b"multipart/form-data;" in content_type_value:
+            body = await self.read()
+            return multiparts_to_dictionary(list(parse_multipart(body)))
+        return None
+
+    async def multipart(self):
+        content_type_value = self.content_type()
+        if not content_type_value:
+            return None
+        if b"multipart/form-data;" in content_type_value:
+            body = await self.read()
+            return list(parse_multipart(body))
+        return None
+
+    def declares_content_type(self, type: bytes):
+        content_type = self.content_type()
+        if not content_type:
+            return False
+        if type.lower() in content_type.lower():
+            return True
+        return False
+
+    def declares_json(self):
+        return self.declares_content_type(b"json")
+
+    def declares_xml(self):
+        return self.declares_content_type(b"xml")
+
+    async def files(self, name=None):
+        if isinstance(name, str):
+            name = name.encode("ascii")
+        content_type = self.content_type()
+        if not content_type or b"multipart/form-data;" not in content_type:
+            return []
+        data = await self.multipart()
+        if name:
+            return [part for part in data if part.file_name and part.name == name]
+        return [part for part in data if part.file_name]
+
+    async def json(self, loads=json_settings.loads):
+        if not self.declares_json():
+            return None
+        text = await self.text()
+        if text is None or text == "":
+            return None
+        try:
+            return loads(text)
+        except JSONDecodeError as decode_error:
+            content_type = self.content_type()
+            if content_type and b"json" in content_type:
+                raise BadRequestFormat(
+                    f"Declared Content-Type is {content_type.decode()} but "
+                    f"the content cannot be parsed as JSON.",
+                    decode_error,
+                )
+            raise BadRequestFormat("Cannot parse content as JSON", decode_error)
+
+    def has_body(self):
+        content = getattr(self, "content", None)
+        if not content or content.length == 0:
+            return False
+        return True
+
+    @property
+    def charset(self):
+        content_type = self.content_type()
+        if content_type:
+            return parse_charset(content_type) or "utf8"
+        return "utf8"
+
+
+def method_without_body(method: str):
+    return method in ("GET", "HEAD", "TRACE")
+
+
+class Request(Message):
+    def __init__(self, method: str, url: bytes, headers):
+        _url = URL(url) if url else None
+        self._raw_headers = headers or []
+        self.method = method
+        self._url = _url
+        self._session = None
+        if _url:
+            self._path = _url.path
+            self._raw_query = _url.query
+        else:
+            self._path = None
+            self._raw_query = None
+        self.scope = None
+        self.content: Optional[Content] = None
+
+    @property
+    def identity(self):
+        return self.__dict__.get("_user")
+
+    @identity.setter
+    def identity(self, value):
+        self.__dict__["_user"] = value
+
+    @property
+    def user(self):
+        return self.__dict__.get("_user")
+
+    @user.setter
+    def user(self, value):
+        self.__dict__["_user"] = value
+
+    @property
+    def scheme(self) -> str:
+        return self.__dict__.get("scheme") or (
+            self.scope.get("scheme", "") if self.scope else ""
+        )
+
+    @scheme.setter
+    def scheme(self, value: str):
+        self.__dict__["scheme"] = value
+
+    @property
+    def host(self) -> str:
+        if not self.__dict__.get("host"):
+            if self._url is not None and self._url.is_absolute:
+                self.__dict__["host"] = (
+                    self._url.host.decode()
+                    if isinstance(self._url.host, bytes)
+                    else self._url.host
+                )
+            else:
+                host_header = self.get_first_header(b"host")
+                if host_header is None:
+                    raise BadRequest("Missing Host header")
+                self.__dict__["host"] = host_header.decode()
+        return self.__dict__["host"]
+
+    @host.setter
+    def host(self, value: str) -> None:
+        self.__dict__["host"] = value
+
+    @property
+    def path(self) -> str:
+        return self._path.decode("utf8") if self._path else ""
+
+    @property
+    def base_path(self) -> str:
+        try:
+            return self.__dict__["base_path"]
+        except KeyError:
+            try:
+                return self.scope.get("root_path", "")
+            except AttributeError:
+                return ""
+
+    @base_path.setter
+    def base_path(self, value: str):
+        self.__dict__["base_path"] = value
+
+    @property
+    def client_ip(self) -> str:
+        if getattr(self, "scope", None) is None:
+            return ""
+        client = self.scope.get("client", ("", 0))
+        return client[0]
+
+    @property
+    def original_client_ip(self) -> str:
+        if "original_client_ip" in self.__dict__:
+            return self.__dict__["original_client_ip"]
+        return self.client_ip
+
+    @original_client_ip.setter
+    def original_client_ip(self, value: str):
+        self.__dict__["original_client_ip"] = value
+
+    @property
+    def session(self):
+        if self._session is None:
+            raise TypeError(
+                "A session is not configured for this request, activate "
+                "sessions using `app.use_sessions` method."
+            )
+        return self._session
+
+    @session.setter
+    def session(self, value: "Session"):
+        self._session = value
+
+    @classmethod
+    def incoming(cls, method: str, path: bytes, query: bytes, headers):
+        request = cls(method, None, headers)
+        request._path = path
+        request._raw_query = query
+        return request
+
+    @property
+    def query(self):
+        if self._raw_query:
+            return parse_qs(self._raw_query.decode("utf8"))
+        return {}
+
+    @query.setter
+    def query(self, value):
+        raw_query = urlencode(value, True).encode("utf8")
+        self._raw_query = raw_query
+        self.url = self.url.with_query(raw_query)
+
+    @property
+    def url(self):
+        if self._url:
+            return self._url
+        if self._raw_query:
+            self._url = URL(self._path + b"?" + self._raw_query)
+        else:
+            self._url = URL(self._path)
+        return self._url
+
+    @url.setter
+    def url(self, value):
+        if value:
+            if isinstance(value, bytes):
+                _url = URL(value)
+            elif isinstance(value, str):
+                _url = URL(value.encode("utf8"))
+            elif isinstance(value, URL):
+                _url = value
+            else:
+                raise TypeError("Invalid value type, expected bytes, str, or URL")
+        else:
+            _url = None
+        if _url:
+            self._path = _url.path
+            self._raw_query = _url.query
+        else:
+            self._path = None
+            self._raw_query = None
+        self._url = _url
+        self.__dict__["host"] = None
+        self.remove_header(b"host")
+
+    def __repr__(self):
+        return f"<Request {self.method} {self.url.value.decode()}>"
+
+    @property
+    def cookies(self):
+        cookies = {}
+        cookies_headers = self.get_headers(b"cookie")
+        if cookies_headers:
+            for header in cookies_headers:
+                pairs = header.split(b"; ")
+                for fragment in pairs:
+                    try:
+                        name, value = split_value(fragment, b"=")
+                    except ValueError:
+                        pass
+                    else:
+                        cookies[unquote(name.decode())] = unquote(
+                            value.rstrip(b"; ").decode()
+                        )
+        return cookies
+
+    def get_cookie(self, name: str):
+        return self.cookies.get(name)
+
+    def set_cookie(self, name: str, value: str):
+        new_value = (quote(name) + "=" + quote(value)).encode()
+        existing_cookie = self.get_first_header(b"cookie")
+        if existing_cookie:
+            self.set_header(b"cookie", existing_cookie + b";" + new_value)
+        else:
+            self._raw_headers.append((b"cookie", new_value))
+
+    @property
+    def etag(self):
+        return self.get_first_header(b"etag")
+
+    @property
+    def if_none_match(self):
+        return self.get_first_header(b"if-none-match")
+
+    def expect_100_continue(self):
+        value = self.get_first_header(b"expect")
+        if value and value.lower() == b"100-continue":
+            return True
+        return False
+
+    async def is_disconnected(self):
+        if not isinstance(self.content, ASGIContent):
+            raise TypeError(
+                "This method is only supported when a request is bound to "
+                "an instance of ASGIContent and to an ASGI "
+                "request/response cycle."
+            )
+        self.init_prop("_is_disconnected", False)
+        if self._is_disconnected is True:
+            return True
+        try:
+            await _call_soon(_read_stream(self))
+        except MessageAborted:
+            self._is_disconnected = True
+        return self._is_disconnected
+
+
+class Response(Message):
+    def __init__(self, status: int, headers=None, content: Content = None):
+        self._raw_headers = headers or []
+        self.status = status
+        self.content = content
+
+    def __repr__(self):
+        return f"<Response {self.status}>"
+
+    @property
+    def cookies(self):
+        return self.get_cookies()
+
+    @property
+    def reason(self) -> str:
+        return http.HTTPStatus(self.status).phrase
+
+    def get_cookies(self):
+        cookies = {}
+        set_cookies_headers = self.get_headers(b"set-cookie")
+        if set_cookies_headers:
+            for value in set_cookies_headers:
+                cookie = parse_cookie(value)
+                cookies[cookie.name] = cookie
+        return cookies
+
+    def get_cookie(self, name: str):
+        set_cookies_headers = self.get_headers(b"set-cookie")
+        if set_cookies_headers:
+            for value in set_cookies_headers:
+                cookie = parse_cookie(value)
+                if cookie.name == name:
+                    return cookie
+        return None
+
+    def set_cookie(self, cookie: Cookie):
+        self._raw_headers.append((b"set-cookie", write_cookie_for_response(cookie)))
+
+    def set_cookies(self, cookies):
+        for cookie in cookies:
+            self.set_cookie(cookie)
+
+    def unset_cookie(self, name: str):
+        self.set_cookie(Cookie(name, "", utcnow() - timedelta(days=365)))
+
+    def remove_cookie(self, name: str):
+        to_remove = []
+        set_cookies_headers = self.get_headers_tuples(b"set-cookie")
+        if set_cookies_headers:
+            for value in set_cookies_headers:
+                cookie = parse_cookie(value[1])
+                if cookie.name == name:
+                    to_remove.append(value)
+        self.remove_headers(to_remove)
+
+    def is_redirect(self):
+        return self.status in {301, 302, 303, 307, 308}
+
+    async def raise_for_status(self):
+        if not (200 <= self.status < 300):
+            raise FailedRequestError(self.status, await self.text())
+
+
+def is_cors_request(request: "Request"):
+    return bool(request.get_first_header(b"Origin"))
+
+
+def is_cors_preflight_request(request: "Request"):
+    if request.method != "OPTIONS" or not is_cors_request(request):
+        return False
+    next_request_method = request.get_first_header(b"Access-Control-Request-Method")
+    return bool(next_request_method)
+
+
+def ensure_bytes(value):
+    if isinstance(value, str):
+        return value.encode()
+    if isinstance(value, bytes):
+        return value
+    raise ValueError("Input value must be bytes or str")
+
+
+def get_request_absolute_url(request: "Request"):
+    if request.url.is_absolute:
+        return request.url
+    return build_absolute_url(
+        ensure_bytes(request.scheme),
+        ensure_bytes(request.host),
+        ensure_bytes(request.base_path),
+        request._path,
+    )
+
+
+def get_absolute_url_to_path(request: "Request", path: str):
+    return build_absolute_url(
+        ensure_bytes(request.scheme),
+        ensure_bytes(request.host),
+        ensure_bytes(request.base_path),
+        ensure_bytes(path),
+    )
diff --git a/blacksheep/scribe.py b/blacksheep/scribe.py
new file mode 100644
index 0000000..d7fc7e2
--- /dev/null
+++ b/blacksheep/scribe.py
@@ -0,0 +1,332 @@
+import http
+import re
+
+from .contents import Content, StreamedContent
+from .cookies import Cookie, write_cookie_for_response
+from .messages import Request, Response
+
+MAX_RESPONSE_CHUNK_SIZE = 61440  # 64kb
+
+
+# Header writing utilities
+def write_header(header):
+    return header[0] + b": " + header[1] + b"\r\n"
+
+
+def write_headers(headers):
+    value = bytearray()
+    for header in headers:
+        value.extend(write_header(header))
+    return bytes(value)
+
+
+def extend_data_with_headers(headers, data: bytearray):
+    for header in headers:
+        data.extend(write_header(header))
+
+
+def _get_status_line(status_code: int):
+    try:
+        return (
+            b"HTTP/1.1 "
+            + str(status_code).encode()
+            + b" "
+            + http.HTTPStatus(status_code).phrase.encode()
+            + b"\r\n"
+        )
+    except ValueError:
+        return b"HTTP/1.1 " + str(status_code).encode() + b"\r\n"
+
+
+STATUS_LINES = {
+    status_code: _get_status_line(status_code) for status_code in range(100, 600)
+}
+
+
+def get_status_line(status: int):
+    return STATUS_LINES[status]
+
+
+def write_request_method(request: Request):
+    return request.method.encode()
+
+
+def write_request_uri(request: Request):
+    url = request.url
+    p = url.path or b"/"
+    if url.query:
+        return p + b"?" + url.query
+    return p
+
+
+def ensure_host_header(request: Request):
+    if request.url.host:
+        request._add_header_if_missing(b"host", request.url.host)
+
+
+def should_use_chunked_encoding(content: Content):
+    return content.length < 0
+
+
+def set_headers_for_response_content(message: Response):
+    content = message.content
+    if not content:
+        message._add_header(b"content-length", b"0")
+        return
+    message._add_header(b"content-type", content.type or b"application/octet-stream")
+    if should_use_chunked_encoding(content):
+        message._add_header(b"transfer-encoding", b"chunked")
+    else:
+        message._add_header(b"content-length", str(content.length).encode())
+
+
+def set_headers_for_content(message):
+    content = message.content
+    if not content:
+        message._add_header_if_missing(b"content-length", b"0")
+        return
+    message._add_header_if_missing(
+        b"content-type", content.type or b"application/octet-stream"
+    )
+    if should_use_chunked_encoding(content):
+        message._add_header_if_missing(b"transfer-encoding", b"chunked")
+    else:
+        message._add_header_if_missing(b"content-length", str(content.length).encode())
+
+
+def write_response_cookie(cookie: Cookie):
+    return write_cookie_for_response(cookie)
+
+
+async def write_chunks(http_content: Content):
+    async for chunk in http_content.get_parts():
+        yield (hex(len(chunk)))[2:].encode() + b"\r\n" + chunk + b"\r\n"
+    yield b"0\r\n\r\n"
+
+
+def is_small_response(response: Response):
+    content = response.content
+    if not content:
+        return True
+    if (
+        content.length > 0
+        and content.length < MAX_RESPONSE_CHUNK_SIZE
+        and content.body is not None
+    ):
+        return True
+    return False
+
+
+def is_small_request(request: Request):
+    content = request.content
+    if not content:
+        return True
+    if (
+        content.length > 0
+        and content.length < MAX_RESPONSE_CHUNK_SIZE
+        and content.body is not None
+    ):
+        return True
+    return False
+
+
+def request_has_body(request: Request):
+    content = request.content
+    if not content or content.length == 0:
+        return False
+    return True
+
+
+def write_request_without_body(request: Request):
+    data = bytearray()
+    data.extend(
+        write_request_method(request)
+        + b" "
+        + write_request_uri(request)
+        + b" HTTP/1.1\r\n"
+    )
+    ensure_host_header(request)
+    extend_data_with_headers(request._raw_headers, data)
+    data.extend(b"\r\n")
+    return bytes(data)
+
+
+def write_small_request(request: Request):
+    data = bytearray()
+    data.extend(
+        write_request_method(request)
+        + b" "
+        + write_request_uri(request)
+        + b" HTTP/1.1\r\n"
+    )
+    ensure_host_header(request)
+    set_headers_for_content(request)
+    extend_data_with_headers(request._raw_headers, data)
+    data.extend(b"\r\n")
+    if request.content:
+        data.extend(request.content.body)
+    return bytes(data)
+
+
+def write_small_response(response: Response):
+    data = bytearray()
+    data.extend(STATUS_LINES[response.status])
+    set_headers_for_content(response)
+    extend_data_with_headers(response._raw_headers, data)
+    data.extend(b"\r\n")
+    if response.content:
+        data.extend(response.content.body)
+    return bytes(data)
+
+
+def py_write_small_response(response: Response):
+    return write_small_response(response)
+
+
+def py_write_small_request(request: Request):
+    return write_small_request(request)
+
+
+async def write_request_body_only(request: Request):
+    content = request.content
+    if content:
+        if should_use_chunked_encoding(content):
+            async for chunk in write_chunks(content):
+                yield chunk
+        elif isinstance(content, StreamedContent):
+            async for chunk in content.get_parts():
+                yield chunk
+        else:
+            data = content.body
+            if content.length > MAX_RESPONSE_CHUNK_SIZE:
+                for chunk in get_chunks(data):
+                    yield chunk
+            else:
+                yield data
+    else:
+        raise ValueError("Missing request content")
+
+
+async def write_request(request: Request):
+    ensure_host_header(request)
+    set_headers_for_content(request)
+    yield write_request_method(request) + b" " + write_request_uri(
+        request
+    ) + b" HTTP/1.1\r\n" + write_headers(request._raw_headers) + b"\r\n"
+    content = request.content
+    if content:
+        if should_use_chunked_encoding(content):
+            async for chunk in write_chunks(content):
+                yield chunk
+        elif isinstance(content, StreamedContent):
+            async for chunk in content.get_parts():
+                yield chunk
+        else:
+            data = content.body
+            if content.length > MAX_RESPONSE_CHUNK_SIZE:
+                for chunk in get_chunks(data):
+                    yield chunk
+            else:
+                yield data
+
+
+def get_chunks(data: bytes):
+    for i in range(0, len(data), MAX_RESPONSE_CHUNK_SIZE):
+        yield data[i : i + MAX_RESPONSE_CHUNK_SIZE]
+    yield b""
+
+
+async def write_response_content(response: Response):
+    content = response.content
+    if content:
+        if should_use_chunked_encoding(content):
+            async for chunk in write_chunks(content):
+                yield chunk
+        elif isinstance(content, StreamedContent):
+            async for chunk in content.get_parts():
+                yield chunk
+        else:
+            data = content.body
+            if content.length > MAX_RESPONSE_CHUNK_SIZE:
+                for chunk in get_chunks(data):
+                    yield chunk
+            else:
+                yield data
+
+
+async def write_response(response: Response):
+    set_headers_for_content(response)
+    yield STATUS_LINES[response.status] + write_headers(response._raw_headers) + b"\r\n"
+    async for chunk in write_response_content(response):
+        yield chunk
+
+
+async def send_asgi_response(response: Response, send):
+    content = response.content
+    set_headers_for_response_content(response)
+    await send(
+        {
+            "type": "http.response.start",
+            "status": response.status,
+            "headers": response._raw_headers,
+        }
+    )
+    if content:
+        if content.length < 0 or isinstance(content, StreamedContent):
+            closing_chunk = False
+            async for chunk in content.get_parts():
+                if not chunk:
+                    closing_chunk = True
+                await send(
+                    {
+                        "type": "http.response.body",
+                        "body": chunk,
+                        "more_body": bool(chunk),
+                    }
+                )
+            if not closing_chunk:
+                await send(
+                    {"type": "http.response.body", "body": b"", "more_body": False}
+                )
+        else:
+            if content.length > MAX_RESPONSE_CHUNK_SIZE:
+                for chunk in get_chunks(content.body):
+                    await send(
+                        {
+                            "type": "http.response.body",
+                            "body": chunk,
+                            "more_body": bool(chunk),
+                        }
+                    )
+            else:
+                await send(
+                    {
+                        "type": "http.response.body",
+                        "body": content.body,
+                        "more_body": False,
+                    }
+                )
+    else:
+        await send({"type": "http.response.body", "body": b""})
+
+
+_NEW_LINES_RX = re.compile("\r\n|\n")
+
+
+def write_sse(event):
+    value = bytearray()
+    if event.id:
+        value.extend(b"id: " + _NEW_LINES_RX.sub("", event.id).encode("utf8") + b"\r\n")
+    if event.comment:
+        for part in _NEW_LINES_RX.split(event.comment):
+            value.extend(b": " + part.encode("utf8") + b"\r\n")
+    if event.event:
+        value.extend(
+            b"event: " + _NEW_LINES_RX.sub("", event.event).encode("utf8") + b"\r\n"
+        )
+    if event.data:
+        value.extend(b"data: " + event.write_data().encode("utf8") + b"\r\n")
+    if event.retry > -1:
+        value.extend(b"retry: " + str(event.retry).encode() + b"\r\n")
+    value.extend(b"\r\n")
+    return bytes(value)
diff --git a/blacksheep/server/application.py b/blacksheep/server/application.py
index f79565e..ada1137 100644
--- a/blacksheep/server/application.py
+++ b/blacksheep/server/application.py
@@ -717,12 +717,18 @@ class Application(BaseApplication):
             logging.exception("Exception while handling WebSocket")
             # If WebSocket connection accepted, close
             # the connection using WebSocket Internal error code.
-            if ws.accepted:
-                await ws.close(1011, reason=format_reason(str(exc)))
-            else:
-                # Otherwise, just close the connection, the ASGI server
-                # will anyway respond 403 to the client.
-                await ws.close()
+            try:
+                if ws.accepted:
+                    await ws.close(1011, reason=format_reason(str(exc)))
+                else:
+                    # Otherwise, just close the connection, the ASGI server
+                    # will anyway respond 403 to the client.
+                    await ws.close()
+            except RuntimeError:
+                logging.exception(
+                    "RuntimeError while closing WebSocket. "
+                    "This can happen when the socket is already closed."
+                )
 
     def instantiate_request(self, scope, receive) -> Request:
         request = Request.incoming(
diff --git a/blacksheep/url.py b/blacksheep/url.py
new file mode 100644
index 0000000..501ea26
--- /dev/null
+++ b/blacksheep/url.py
@@ -0,0 +1,141 @@
+from urllib.parse import urlparse
+
+
+class InvalidURL(Exception):
+    def __init__(self, message: str):
+        super().__init__(message)
+
+
+def valid_schema(schema):
+    if schema and schema != "https" and schema != "http":
+        raise InvalidURL(f"Expected http or https schema; got instead {schema}")
+
+
+class URL:
+    def __init__(self, value: bytes):
+        if not value:
+            raise InvalidURL("Input empty or null.")
+        try:
+            if value and value[0] == 46:  # ord('.') == 46
+                value = b"/" + value
+            s = value.decode()
+            parsed = urlparse(s)
+        except Exception:
+            raise InvalidURL(f"The value cannot be parsed as URL ({value.decode()})")
+        schema = parsed.scheme
+        valid_schema(schema)
+        self.value = value or b""
+        self.schema = schema.encode() if schema else None
+        self.host = parsed.hostname.encode() if parsed.hostname else None
+        self.port = parsed.port or 0
+        self.path = parsed.path.encode() or b""
+        self.query = parsed.query.encode() if parsed.query else None
+        self.fragment = parsed.fragment.encode() if parsed.fragment else None
+        self.is_absolute = bool(parsed.scheme)
+
+    def __repr__(self):
+        return f"<URL {self.value}>"
+
+    def __str__(self):
+        return self.value.decode()
+
+    def join(self, other):
+        if isinstance(other, bytes):
+            other = URL(other)
+        if other.is_absolute:
+            raise ValueError(
+                f"Cannot concatenate to an absolute URL ({self.value} + {other.value})"
+            )
+        if self.query or self.fragment:
+            raise ValueError(
+                "Cannot concatenate a URL with query or fragment to another URL portion"
+            )
+        first_part = self.value
+        other_part = other.value
+        if first_part and other_part and first_part[-1] == 47 and other_part[0] == 47:
+            return URL(first_part[:-1] + other_part)
+        return URL(first_part + other_part)
+
+    def base_url(self):
+        if not self.is_absolute:
+            raise ValueError(
+                "This URL is relative. Cannot extract a base URL (without path)."
+            )
+        base_url = f"{self.schema.decode()}://{self.host.decode()}"  # type: ignore
+        if self.port != 0:
+            if (self.schema == b"http" and self.port != 80) or (
+                self.schema == b"https" and self.port != 443
+            ):
+                base_url = f"{base_url}:{self.port}"
+        return URL(base_url.encode())
+
+    def with_host(self, host: bytes):
+        if not self.is_absolute:
+            raise TypeError("Cannot generate a URL from a partial URL")
+        query = b"?" + self.query if self.query else b""
+        fragment = b"#" + self.fragment if self.fragment else b""
+        url = f"{self.schema}://{host.decode()}{self.path}{query.decode()}{fragment.decode()}"
+        return URL(url.encode())
+
+    def with_query(self, query: bytes):
+        query = b"?" + query if query else b""
+        fragment = b"#" + self.fragment if self.fragment else b""
+        if self.is_absolute:
+            url = f"{self.schema}://{self.host}{self.path}{query.decode()}{fragment.decode()}"
+            return URL(url.encode())
+        url = f"{self.path}{query.decode()}{fragment.decode()}"
+        return URL(url.encode())
+
+    def with_scheme(self, schema: bytes):
+        s = self.value.decode()
+        if isinstance(schema, bytes):
+            schema_str = schema.decode()
+        else:
+            schema_str = schema
+        idx = s.find("://")
+        if idx == -1:
+            raise InvalidURL("Malformed URL for scheme replacement")
+        # Ensure s is str, schema_str is str
+        new_url = str(schema_str) + str(s[idx:])
+        return URL(new_url.encode())
+
+    def __add__(self, other):
+        if isinstance(other, bytes):
+            return self.join(URL(other))
+        if isinstance(other, URL):
+            return self.join(other)
+        return NotImplemented
+
+    def __eq__(self, other):
+        if isinstance(other, URL):
+            return self.value == other.value
+        return NotImplemented
+
+
+def build_absolute_url(scheme: bytes, host: bytes, base_path: bytes, path: bytes):
+    scheme_str = scheme.decode() if isinstance(scheme, bytes) else scheme
+    valid_schema(scheme_str)
+    url = (
+        str(scheme_str)
+        + "://"
+        + host.decode()
+        + ("/" if base_path else "")
+        + base_path.lstrip(b"/").rstrip(b"/").decode()
+        + ("/" if path else "")
+        + path.lstrip(b"/").decode()
+    )
+    return URL(url.encode())
+
+
+def join_prefix(prefix: str, path: str) -> str:
+    if not prefix:
+        return path
+    if not prefix.startswith("/"):
+        prefix = "/" + prefix
+    if not path:
+        return prefix + "/"
+    if prefix[-1] == "/" and path[0] == "/":
+        return prefix + path[1:]
+    if prefix[-1] != "/" and path[0] != "/":
+        return prefix + "/" + path
+    return prefix + path
diff --git a/perf/benchmarks/url.py b/perf/benchmarks/url.py
index 55cb20f..f120fa7 100644
--- a/perf/benchmarks/url.py
+++ b/perf/benchmarks/url.py
@@ -12,7 +12,7 @@ def test_url_instantiate():
     url = URL(b"https://www.neoteroi.dev/blacksheep/?super=yes#some-hash")
     assert url.value == b"https://www.neoteroi.dev/blacksheep/?super=yes#some-hash"
     assert url.host == b"www.neoteroi.dev"
-    assert url.port == 0
+    assert url.port in {0, 443}
     assert url.path == b"/blacksheep/"
     assert url.query == b"super=yes"
     assert url.is_absolute is True
diff --git a/perf/genreport.py b/perf/genreport.py
index 81e565c..99ce398 100644
--- a/perf/genreport.py
+++ b/perf/genreport.py
@@ -9,7 +9,6 @@ import json
 import os
 import subprocess
 
-import matplotlib.pyplot as plt
 import pandas as pd
 
 
@@ -64,14 +63,16 @@ def create_comparison_table(results):
     for result in results:
         commit = result.get("git_info", {}).get("commit_hash", "unknown")[:8]
         date = result.get("git_info", {}).get("commit_date", "unknown")
-
+        system_info = result["system_info"]
+        python_version = system_info["python_version"]
+        python_implementation = system_info.get("python_implementation", "")
+        if python_implementation:
+            python_version = python_version + " " + python_implementation
         row = {
             "timestamp": result.get("timestamp", "unknown"),
             "commit": _try_get_git_tag(commit),
             "date": date,
-            "python_version": result.get("system_info", {}).get(
-                "python_version", "unknown"
-            ),
+            "python_version": python_version,
             "platform": result.get("system_info", {}).get("platform", "unknown"),
             "branch": result.get("git_info", {}).get("branch", "unknown"),
         }
@@ -124,38 +125,6 @@ def _aggregate(df: pd.DataFrame, by: str):
     return aggregated_df
 
 
-def plot_performance_trends(df, output_file="performance_trends.png"):
-    """Create a plot showing performance trends"""
-    plt.figure(figsize=(12, 8))
-
-    # Plot time metrics
-    time_cols = [col for col in df.columns if col.endswith("_avg_ms")]
-    for col in time_cols:
-        plt.subplot(2, 1, 1)
-        plt.plot(df["date"], df[col], marker="o", label=col)
-
-    plt.title("Performance Trends Across Commits")
-    plt.ylabel("Time (ms)")
-    plt.xticks(rotation=45)
-    plt.legend()
-    plt.grid(True)
-
-    # Plot memory metrics
-    mem_cols = [col for col in df.columns if col.endswith("_peak_mb")]
-    for col in mem_cols:
-        plt.subplot(2, 1, 2)
-        plt.plot(df["date"], df[col], marker="s", label=col)
-
-    plt.ylabel("Memory (MB)")
-    plt.xticks(rotation=45)
-    plt.legend()
-    plt.grid(True)
-
-    plt.tight_layout()
-    plt.savefig(output_file)
-    print(f"Plot saved to {output_file}")
-
-
 def _set_conditional_formatting(df, worksheet, max_row):
     all_cols = [
         col for col in df.columns if col.endswith("_avg_ms") or col.endswith("_peak_mb")
diff --git a/perf/historyrun.py b/perf/historyrun.py
index 055bb4a..612f9cb 100644
--- a/perf/historyrun.py
+++ b/perf/historyrun.py
@@ -18,6 +18,7 @@ See also the perfhistory.yml GitHub Workflow.
 
 import argparse
 import logging
+import platform
 import os
 import shutil
 import subprocess
@@ -88,6 +89,14 @@ def gitcontext():
 
 
 def make_compile():
+    python_implementation = platform.python_implementation()
+    if python_implementation != "CPython":
+        logger.info(
+            "Skipping compilation because the command is not running for CPython. "
+            "Implementat: %s",
+            python_implementation,
+        )
+        return
     logger.info("Compiling BlackSheep extensions")
     subprocess.check_output(["make", "compile"], universal_newlines=True)
 
diff --git a/perf/main.py b/perf/main.py
index 806b54f..39d9b50 100644
--- a/perf/main.py
+++ b/perf/main.py
@@ -5,7 +5,6 @@ Refer to the README for details.
 
 import argparse
 import asyncio
-import gc
 import importlib
 import inspect
 import json
@@ -128,6 +127,7 @@ def get_system_info():
         "cpu_count": os.cpu_count(),
         "memory_total": psutil.virtual_memory().total,
         "python_version": sys.version.split()[0],
+        "python_implementation": platform.python_implementation(),
         "blacksheep_version": blacksheep_version,
         "platform": platform.platform(),
     }
diff --git a/perf/req.txt b/perf/req.txt
index 1a9b6a0..4060a32 100644
--- a/perf/req.txt
+++ b/perf/req.txt
@@ -1,6 +1,4 @@
-matplotlib==3.10.1
 memory-profiler==0.61.0
 pandas==2.2.3
 psutil==7.0.0
-line_profiler==4.2.0
 XlsxWriter==3.2.3
diff --git a/pyproject.toml b/pyproject.toml
index 668e92f..6f050aa 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -23,11 +23,10 @@ classifiers = [
 ]
 keywords = ["blacksheep", "web framework", "asyncio"]
 dependencies = [
-    "httptools>=0.5",
-    "certifi>=2022.9.24",
-    "charset-normalizer~=3.4.0",
+    "certifi>=2025.4.26",
+    "charset-normalizer~=3.4.2",
     "guardpost>=1.0.2",
-    "rodi~=2.0.2",
+    "rodi~=2.0.8",
     "essentials>=1.1.4,<2.0",
     "essentials-openapi>=1.2.0,<2.0",
     "python-dateutil~=2.9.0",
@@ -44,11 +43,13 @@ version = { attr = "blacksheep.__version__" }
 [project.optional-dependencies]
 jinja = ["Jinja2~=3.1.6"]
 full = [
-    "cryptography>=44.0.1,<45.0.0",
-    "PyJWT~=2.9.0",
-    "websockets~=13.1",
+    "cryptography>=45.0.2,<46.0.0",
+    "PyJWT~=2.10.1",
+    "websockets~=15.0.1",
     "Jinja2~=3.1.6",
 ]
+cython = ["httptools>=0.6.4"]
+purepython = ["h11==0.16.0"]
 
 [project.urls]
 "Homepage" = "https://github.com/Neoteroi/BlackSheep"
diff --git a/requirements.pypy.txt b/requirements.pypy.txt
new file mode 100644
index 0000000..281582d
--- /dev/null
+++ b/requirements.pypy.txt
@@ -0,0 +1,24 @@
+certifi>=2025.4.26
+charset-normalizer~=3.4.2
+guardpost>=1.0.2
+rodi~=2.0.2
+essentials>=1.1.4,<2.0
+essentials-openapi>=1.2.0,<2.0
+python-dateutil~=2.9.0
+itsdangerous~=2.2.0
+cryptography>=45.0.2,<46.0.0
+h11==0.16.0
+Jinja2~=3.1.6
+PyJWT==2.10.1
+python-dateutil==2.9.0.post0
+websockets~=15.0.1
+# For tests:
+Flask[async]==3.1.1
+Hypercorn==0.17.3
+requests==2.32.3
+pytest==8.3.5
+pytest-asyncio==0.26.0
+pytest-cov==6.1.1
+uvicorn==0.34.2
+pydantic==2.11.4
+pydantic_core==2.33.2
diff --git a/requirements.txt b/requirements.txt
index 744288e..f8aa356 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,59 +1,27 @@
-annotated-types==0.7.0
-asgiref==3.8.1
-attrs==24.2.0
-blinker==1.8.2
-certifi==2024.12.14
-cffi==1.17.1
-charset-normalizer==3.4.0
-click==8.1.7
-coverage==7.6.1
-cryptography==44.0.1
-Cython==3.0.12
-essentials==1.1.5
-essentials-openapi>=1.2.0
-Flask==3.0.3
-gevent==24.11.1
-greenlet==3.1.1
-guardpost==1.0.2
+certifi>=2025.4.26
+charset-normalizer~=3.4.2
+guardpost>=1.0.2
+rodi~=2.0.2
+essentials>=1.1.4,<2.0
+essentials-openapi>=1.2.0,<2.0
+python-dateutil~=2.9.0
+itsdangerous~=2.2.0
+cryptography>=45.0.2,<46.0.0
 h11==0.16.0
-h2==4.1.0
-hpack==4.0.0
-httptools==0.6.4
-hypercorn==0.14.4
-hyperframe==6.0.1
-idna==3.10
-iniconfig==2.0.0
-itsdangerous==2.2.0
-Jinja2==3.1.6
-MarkupSafe
-mccabe==0.7.0
-packaging==24.2
-pluggy==1.5.0
-priority==2.0.0
-pycparser==2.22
-pydantic==2.11.3
-pydantic_core==2.33.1
-PyJWT==2.9.0
-pyparsing==3.1.4
-pytest==8.3.4
-pytest-asyncio==0.21.1
-pytest-cov==5.0.0
+Jinja2~=3.1.6
+PyJWT==2.10.1
 python-dateutil==2.9.0.post0
-PyYAML==6.0.2
-regex==2024.11.6
+websockets~=15.0.1
+httptools==0.6.4
+# For tests:
+Cython==3.0.12
+setuptools==80.7.1
+Flask[async]==3.1.1
+Hypercorn==0.17.3
 requests==2.32.3
-rodi==2.0.6
-setuptools==75.3.0
-six==1.17.0
-toml==0.10.2
-tomli==2.2.1
-typing-extensions==4.12.2
-urllib3==2.2.3
-uvicorn==0.33.0
-wcwidth==0.2.13
-websockets==13.1
-Werkzeug==3.0.6
-wsproto==1.2.0
-zope.event==5.0
-zope.interface==7.2
-build==1.2.2.post1
+pytest==8.3.5
+pytest-asyncio==0.26.0
+pytest-cov==6.1.1
+uvicorn==0.34.2
+pydantic==2.11.4
+pydantic_core==2.33.2
diff --git a/setup.py b/setup.py
index 82a6e3c..95fc9ce 100644
--- a/setup.py
+++ b/setup.py
@@ -1,10 +1,23 @@
+"""
+This file is used to specify Python extensions, which are used when using Cython.
+Extensions are used only if the current runtime is CPython and only if there is not an
+environment variable: `BLACKSHEEP_NO_EXTENSIONS=1`.
+The logic is to support PyPy. See:
+https://github.com/Neoteroi/BlackSheep/issues/539#issuecomment-2888631226
+"""
+
+import os
 from setuptools import Extension, setup
+import platform
 
 COMPILE_ARGS = ["-O2"]
 
+# Check for environment variable to skip extensions
+skip_ext = os.environ.get("BLACKSHEEP_NO_EXTENSIONS", "0") == "1"
+
 
-setup(
-    ext_modules=[
+if platform.python_implementation() == "CPython" and not skip_ext:
+    ext_modules = [
         Extension(
             "blacksheep.url",
             ["blacksheep/url.c"],
@@ -46,4 +59,7 @@ setup(
             extra_compile_args=COMPILE_ARGS,
         ),
     ]
-)
+else:
+    ext_modules = []
+
+setup(ext_modules=ext_modules)


Test patch:
diff --git a/itests/README.md b/itests/README.md
index 0ca287e..77e4f6f 100644
--- a/itests/README.md
+++ b/itests/README.md
@@ -1,1 +1,10 @@
 # Integration tests
+
+If tests fail because servers fail to start, try starting them manually:
+
+```bash
+# from the root of the repository
+export PYTHONPATH="."
+export APP_DEFAULT_ROUTER=false
+python itests/app_1.py
+```
diff --git a/itests/app_4.py b/itests/app_4.py
index 062d2b8..448498c 100644
--- a/itests/app_4.py
+++ b/itests/app_4.py
@@ -16,8 +16,7 @@ from blacksheep.server.openapi.v3 import OpenAPIHandler
 from blacksheep.server.responses import json
 from blacksheep.server.websocket import WebSocket
 from blacksheep.settings.json import default_json_dumps, json_settings
-
-from .utils import get_test_files_url
+from itests.utils import get_test_files_url
 
 SINGLE_PID = None
 
diff --git a/itests/client_fixtures.py b/itests/client_fixtures.py
index 81b666a..76c3edc 100644
--- a/itests/client_fixtures.py
+++ b/itests/client_fixtures.py
@@ -1,4 +1,5 @@
 import asyncio
+import multiprocessing
 import os
 import pathlib
 from multiprocessing import Process
@@ -12,20 +13,14 @@ from itests.utils import get_sleep_time
 
 from .flask_app import app
 
+multiprocessing.set_start_method("spawn", force=True)
+
 
 def get_static_path(file_name):
     static_folder_path = pathlib.Path(__file__).parent.absolute() / "static"
     return os.path.join(str(static_folder_path), file_name.lstrip("/"))
 
 
-@pytest.fixture(scope="session")
-def event_loop():
-    """Create an instance of the default event loop for all test cases."""
-    loop = asyncio.get_event_loop_policy().new_event_loop()
-    yield loop
-    loop.close()
-
-
 @pytest.fixture(scope="module")
 def server_host():
     return "127.0.0.1"
@@ -41,7 +36,7 @@ def server_url(server_host, server_port):
     return f"http://{server_host}:{server_port}"
 
 
-@pytest.fixture(scope="module")
+@pytest.fixture(scope="function")
 def session(server_url, event_loop):
     # It is important to pass the instance of ConnectionPools,
     # to ensure that the connections are reused and closed
@@ -54,7 +49,7 @@ def session(server_url, event_loop):
     asyncio.run(session.close())
 
 
-@pytest.fixture(scope="module")
+@pytest.fixture(scope="function")
 def session_alt(event_loop):
     session = ClientSession(
         loop=event_loop,
diff --git a/itests/conftest.py b/itests/conftest.py
new file mode 100644
index 0000000..c5fbc90
--- /dev/null
+++ b/itests/conftest.py
@@ -0,0 +1,5 @@
+import os
+
+from itests.client_fixtures import *
+
+os.environ["APP_DEFAULT_ROUTER"] = "false"
diff --git a/itests/flask_app.py b/itests/flask_app.py
index 4b380e1..55f59c6 100644
--- a/itests/flask_app.py
+++ b/itests/flask_app.py
@@ -172,4 +172,4 @@ def get_well_known_jwks_configuration():
 
 
 if __name__ == "__main__":
-    app.run(host="0.0.0.0", port=44778, debug=True)
+    app.run(host="0.0.0.0", port=44777, debug=True)
diff --git a/itests/server_fixtures.py b/itests/server_fixtures.py
index 38ae99f..dfd03c5 100644
--- a/itests/server_fixtures.py
+++ b/itests/server_fixtures.py
@@ -1,4 +1,5 @@
 import asyncio
+import multiprocessing
 import os
 import socket
 from multiprocessing import Process
@@ -15,6 +16,8 @@ from .app_3 import app_3
 from .app_4 import app_4, configure_json_settings
 from .utils import ClientSession, get_sleep_time
 
+multiprocessing.set_start_method("spawn", force=True)
+
 
 @pytest.fixture(scope="module")
 def server_host():
diff --git a/itests/test_auth_oidc.py b/itests/test_auth_oidc.py
index 53ec936..aff270c 100644
--- a/itests/test_auth_oidc.py
+++ b/itests/test_auth_oidc.py
@@ -7,6 +7,7 @@ import json
 import re
 from datetime import datetime
 from typing import Any, List, Optional, Tuple
+from unittest.mock import AsyncMock
 from urllib.parse import parse_qs, urlencode
 
 import pytest
@@ -44,22 +45,14 @@ from blacksheep.testing.messages import MockReceive, MockSend
 from blacksheep.url import URL
 from blacksheep.utils.aio import FailedRequestError
 from blacksheep.utils.time import utcnow
+from itests import client_fixtures  # noqa
 from tests.test_auth import get_token
 from tests.test_auth_cookie import get_auth_cookie
 from tests.utils.application import FakeApplication
 
-from .client_fixtures import *  # NoQA
-
 MOCKED_AUTHORITY = "http://127.0.0.1:44777/oidc"
 
 
-try:
-    from unittest.mock import AsyncMock
-except ImportError:
-    # Python 3.7
-    from mock import AsyncMock
-
-
 @pytest.fixture
 def app():
     return FakeApplication()
diff --git a/itests/test_server.py b/itests/test_server.py
index 50d941b..518cf59 100644
--- a/itests/test_server.py
+++ b/itests/test_server.py
@@ -10,7 +10,7 @@ import pytest
 import websockets
 import yaml
 from requests.exceptions import ReadTimeout
-from websockets.exceptions import ConnectionClosedError, InvalidStatusCode
+from websockets.exceptions import ConnectionClosedError, InvalidStatus
 
 from .client_fixtures import get_static_path
 from .server_fixtures import *  # NoQA
@@ -912,11 +912,11 @@ async def test_websocket(server_host, server_port_4, route, data):
 async def test_websocket_auth(server_host, server_port_2, route):
     uri = f"ws://{server_host}:{server_port_2}/{route}"
 
-    with pytest.raises(InvalidStatusCode) as error:
+    with pytest.raises(InvalidStatus) as error:
         async with websockets.connect(uri):
             pass
 
-    assert error.value.status_code == 403
+    assert "403" in str(error.value)
     assert "server rejected" in str(error.value)
 
 
@@ -928,5 +928,5 @@ async def test_websocket_server_error(server_host, server_port_2):
             async for _message in ws:
                 pass
 
-    assert error.value.code == 1011
-    assert error.value.reason == "Server error"
+    assert error.value.rcvd.code == 1011  # type: ignore
+    assert error.value.rcvd.reason == "Server error"  # type: ignore
diff --git a/itests/test_utils_aio.py b/itests/test_utils_aio.py
index 4486ae9..4a24098 100644
--- a/itests/test_utils_aio.py
+++ b/itests/test_utils_aio.py
@@ -8,8 +8,6 @@ from blacksheep.utils.aio import (
     _try_parse_content_as_json,
 )
 
-from .client_fixtures import *  # NoQA
-
 
 async def test_http_handler_fetch_plain_text(server_url):
     http_handler = HTTPHandler()
diff --git a/itests/utils.py b/itests/utils.py
index b90af5f..ff7c51b 100644
--- a/itests/utils.py
+++ b/itests/utils.py
@@ -68,11 +68,9 @@ def get_file_bytes(file_path):
 
 
 def get_sleep_time():
-    # when starting a server process,
-    # a longer sleep time is necessary on Windows
-    if os.name == "nt":
-        return 1.5
-    return 0.5
+    # Return the number of seconds to wait for a test server process to start.
+    # 2 seconds is a wait time that works most times.
+    return 2
 
 
 def get_test_files_url(url: str):
diff --git a/tests/client/test_connection.py b/tests/client/test_connection.py
index f0f46aa..e9211df 100644
--- a/tests/client/test_connection.py
+++ b/tests/client/test_connection.py
@@ -3,7 +3,6 @@ from asyncio import TimeoutError
 from typing import AsyncIterable, List
 
 import pytest
-from httptools.parser.errors import HttpParserCallbackError, HttpParserError
 
 from blacksheep import JSONContent, Request, StreamedContent
 from blacksheep.client.connection import (
@@ -37,14 +36,7 @@ def get_example_headers():
     ]
 
 
-@pytest.fixture(scope="module")
-def event_loop():
-    loop = asyncio.get_event_loop_policy().new_event_loop()
-    yield loop
-    loop.close()
-
-
-@pytest.fixture(scope="module")
+@pytest.fixture(scope="function")
 def pool(event_loop):
     pool = ConnectionPool(event_loop, b"http", b"foo.com", 80, None, max_size=2)
     yield pool
@@ -190,11 +182,11 @@ def test_upgraded_connection_does_not_return_to_pool(event_loop):
 def test_connection_gets_closed_on_callback_error(connection):
     class FakeParser:
         def feed_data(self, data: bytes):
-            raise HttpParserCallbackError()
+            raise RuntimeError()
 
     connection.parser = FakeParser()
 
-    with pytest.raises(HttpParserCallbackError):
+    with pytest.raises(InvalidResponseFromServer):
         connection.data_received(b"boom")
 
     assert connection.open is False
@@ -203,7 +195,7 @@ def test_connection_gets_closed_on_callback_error(connection):
 def test_connection_throws_invalid_response_from_server_on_parser_error(connection):
     class FakeParser:
         def feed_data(self, data: bytes):
-            raise HttpParserError()
+            raise RuntimeError()
 
     connection.parser = FakeParser()
 
diff --git a/tests/test_url.py b/tests/test_url.py
index e971fa4..cd88fc7 100644
--- a/tests/test_url.py
+++ b/tests/test_url.py
@@ -11,10 +11,19 @@ def test_empty_url():
 def test_absolute_url():
     url = URL(b"https://robertoprevato.github.io?foo=power&hello=world")
 
-    assert url.path is None
+    # The following assertion looks weird because of the behavior of different libraries
+    # as BlackSheep relies on other libraries to do the actual parsing:
+    # YARL gives path "/", httptools None, urlli urlparse ""
+    #
+    # YARL in my opinion gives wrong results for path and port. Gives "443" which is
+    # the default for the scheme even though no port is specified in the URL (I would
+    # expect it to give 443 only if explicitly set such as in domain:443); and for the
+    # path it gives "/" when no path is specified (domain/ and domain are not exactly
+    # the same).
+    assert url.path in {b"", b"/", None}
     assert url.schema == b"https"
     assert url.host == b"robertoprevato.github.io"
-    assert url.port == 0
+    assert url.port in {443, 0}
     assert url.query == b"foo=power&hello=world"
     assert url.fragment is None
     assert url.is_absolute is True