Repository: Neoteroi/BlackSheep
Pull request #322 (2023-03-19 20:11:21)

Add features for 2.0a4 🦩
- `@app.lifespan` handling
- Features to handle `cache-control`
- Features to handle `cache-control` for the default page when serving static files

codecov-commenter: ## [Codecov](https://codecov.io/gh/Neoteroi/BlackSheep/pull/322?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Neoteroi) Report
Patch coverage: **`69.56`**% and project coverage change: **`-0.63`** :warning:
> Comparison is base [(`349c3eb`)](https://codecov.io/gh/Neoteroi/BlackSheep/commit/349c3ebcb10527665e90ed7892e70ed111c0e290?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Neoteroi) 97.93% compared to head [(`8b1ee88`)](https://codecov.io/gh/Neoteroi/BlackSheep/pull/322?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Neoteroi) 97.30%.

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

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


```diff
@@            Coverage Diff             @@
##             main     #322      +/-   ##
==========================================
- Coverage   97.93%   97.30%   -0.63%     
==========================================
  Files          61       62       +1     
  Lines        5803     5903     +100     
==========================================
+ Hits         5683     5744      +61     
- Misses        120      159      +39     
```


| [Impacted Files](https://codecov.io/gh/Neoteroi/BlackSheep/pull/322?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Neoteroi) | Coverage Δ | |
|---|---|---|
| [blacksheep/server/headers/cache.py](https://codecov.io/gh/Neoteroi/BlackSheep/pull/322?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Neoteroi#diff-YmxhY2tzaGVlcC9zZXJ2ZXIvaGVhZGVycy9jYWNoZS5weQ==) | `51.38% <51.38%> (ø)` | |
| [blacksheep/server/application.py](https://codecov.io/gh/Neoteroi/BlackSheep/pull/322?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Neoteroi#diff-YmxhY2tzaGVlcC9zZXJ2ZXIvYXBwbGljYXRpb24ucHk=) | `93.08% <100.00%> (+0.34%)` | :arrow_up: |
| [blacksheep/server/files/\_\_init\_\_.py](https://codecov.io/gh/Neoteroi/BlackSheep/pull/322?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Neoteroi#diff-YmxhY2tzaGVlcC9zZXJ2ZXIvZmlsZXMvX19pbml0X18ucHk=) | `100.00% <100.00%> (ø)` | |
| [blacksheep/server/files/dynamic.py](https://codecov.io/gh/Neoteroi/BlackSheep/pull/322?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Neoteroi#diff-YmxhY2tzaGVlcC9zZXJ2ZXIvZmlsZXMvZHluYW1pYy5weQ==) | `100.00% <100.00%> (ø)` | |

... and [7 files with indirect coverage changes](https://codecov.io/gh/Neoteroi/BlackSheep/pull/322/indirect-changes?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Neoteroi)

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

</details>

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


Patch:
diff --git a/.gitignore b/.gitignore
index 1a5d1ef..2f40dd8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -124,4 +124,4 @@ itests/out/*
 example.html
 example.jpg
 nice-cat.jpg
-venvp310
+venv*
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 122579e..22286ce 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,52 @@ 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.0a4] - 2023-03-19 :flamingo:
+
+- Adds `@app.lifespan` to support registering objects that must be initialized
+  at application start, and disposed at application shutdown.
+  The solution supports registering as many objects as desired.
+- Adds features to handle `cache-control` response headers: a decorator for
+  request handlers and a middleware to set a default value for all `GET`
+  requests resulting in responses with status `200`.
+- Adds features to control `cache-control` header for the default document
+  (e.g. `index.html`) when serving static files;
+  see [issue 297](https://github.com/Neoteroi/BlackSheep/issues/297).
+- Fixes bug in `sessions` that prevented updating the session data when using
+  the `set` and `__delitem__` methods;
+  [scottrutherford](https://github.com/scottrutherford)'s contribution.
+
+`@app.lifespan` example:
+
+```python
+from blacksheep import Application
+from blacksheep.client.session import ClientSession
+
+app = Application()
+
+
+@app.lifespan
+async def register_http_client():
+    async with ClientSession() as client:
+        print("HTTP client created and registered as singleton")
+        app.services.register(ClientSession, instance=client)
+        yield
+
+    print("HTTP client disposed")
+
+
+@app.router.get("/")
+async def home(http_client: ClientSession):
+    print(http_client)
+    return {"ok": True, "client_instance_id": id(http_client)}
+
+
+if __name__ == "__main__":
+    import uvicorn
+
+    uvicorn.run(app, host="127.0.0.1", port=44777, log_level="debug", lifespan="on")
+```
+
 ## [2.0a3] - 2023-03-12 🥐
 
 - Refactors the `ClientSession` to own by default a connections pool, if none
diff --git a/blacksheep/server/application.py b/blacksheep/server/application.py
index 4e4d8ac..753d7d5 100644
--- a/blacksheep/server/application.py
+++ b/blacksheep/server/application.py
@@ -1,4 +1,5 @@
 import logging
+from contextlib import asynccontextmanager
 from pathlib import Path
 from typing import (
     Any,
@@ -47,6 +48,7 @@ from blacksheep.server.controllers import router as controllers_router
 from blacksheep.server.cors import CORSPolicy, CORSStrategy, get_cors_middleware
 from blacksheep.server.env import EnvironmentSettings
 from blacksheep.server.errors import ServerErrorDetailsHandler
+from blacksheep.server.files import DefaultFileOptions
 from blacksheep.server.files.dynamic import serve_files_dynamic
 from blacksheep.server.normalization import normalize_handler, normalize_middleware
 from blacksheep.server.responses import _ensure_bytes
@@ -105,9 +107,9 @@ class ApplicationEvent:
 
         return decorator
 
-    async def fire(self, *args: Any, **keywargs: Any) -> None:
+    async def fire(self, *args: Any, **kwargs: Any) -> None:
         for handler in self._handlers:
-            await handler(self.context, *args, **keywargs)
+            await handler(self.context, *args, **kwargs)
 
 
 class ApplicationSyncEvent(ApplicationEvent):
@@ -443,6 +445,37 @@ class Application(BaseApplication):
 
         return decorator
 
+    def lifespan(self, callback):
+        """
+        Registers an async generator, or async context manager, to be entered at
+        application start, and exited at application shutdown. This is syntactic sugar
+        alternative to handling application start and stop events directly. It can be
+        useful to handle objects that need to be initialized and disposed, like HTTP
+        clients that use connection pools, or files that needs to be open following the
+        lifespan of the application.
+        """
+        if not hasattr(callback, "__aenter__"):
+            callback = asynccontextmanager(callback)
+
+        obj = None
+
+        @self.on_start
+        async def register_aenter(_):
+            nonlocal obj
+            try:
+                obj = callback(self)
+            except TypeError:
+                obj = callback()
+            await obj.__aenter__()
+
+        @self.on_stop
+        async def register_aexit(_):
+            nonlocal obj
+            if obj is not None:
+                await obj.__aexit__(None, None, None)
+
+        return callback
+
     def serve_files(
         self,
         source_folder: Union[str, Path],
@@ -454,6 +487,7 @@ class Application(BaseApplication):
         index_document: Optional[str] = "index.html",
         fallback_document: Optional[str] = None,
         allow_anonymous: bool = True,
+        default_file_options: Optional[DefaultFileOptions] = None,
     ):
         """
         Configures dynamic file serving from a given folder, relative to the server cwd.
@@ -473,6 +507,8 @@ class Application(BaseApplication):
             fallback_document: Optional file name, for a document to serve when a
             response would be otherwise 404 Not Found; e.g. use this to serve SPA that
             use HTML5 History API for client side routing.
+            default_file_options: Optional options to serve the default file
+            (index.html)
         """
         serve_files_dynamic(
             self.router,
@@ -485,6 +521,7 @@ class Application(BaseApplication):
             index_document=index_document,
             fallback_document=fallback_document,
             anonymous_access=allow_anonymous,
+            default_file_options=default_file_options,
         )
 
     def _apply_middlewares_in_routes(self):
diff --git a/blacksheep/server/files/__init__.py b/blacksheep/server/files/__init__.py
index df1df42..65b9494 100644
--- a/blacksheep/server/files/__init__.py
+++ b/blacksheep/server/files/__init__.py
@@ -1,6 +1,7 @@
 import uuid
+from dataclasses import dataclass
 from pathlib import Path
-from typing import AsyncIterable, Callable, Optional, Set
+from typing import AsyncIterable, Callable, Optional, Set, Union
 
 from blacksheep import Request, Response, StreamedContent
 from blacksheep.common.files.asyncfs import FilesHandler
@@ -8,6 +9,24 @@ from blacksheep.common.files.info import FileInfo
 from blacksheep.common.files.pathsutils import get_mime_type_from_name
 from blacksheep.exceptions import BadRequest, InvalidArgument, RangeNotSatisfiable
 from blacksheep.ranges import InvalidRangeValue, Range, RangePart
+from blacksheep.server.headers.cache import CacheControlHeaderValue
+
+
+@dataclass
+class DefaultFileOptions:
+    """
+    Options to configure how the default file (by default index.html) is served to
+    clients.
+    """
+
+    on_response: Optional[Callable[[Request, Response], None]] = None
+    cache_control: Optional[CacheControlHeaderValue] = None
+
+    def handle(self, request: Request, response: Response):
+        if self.cache_control:
+            response.set_header(b"cache-control", self.cache_control.value)
+        if self.on_response:
+            self.on_response(request, response)
 
 
 def _get_content_range_value(part: RangePart, file_size: int) -> bytes:
@@ -174,7 +193,7 @@ def get_default_extensions() -> Set[str]:
     }
 
 
-def validate_source_path(source_folder: str) -> None:
+def validate_source_path(source_folder: Union[str, Path]) -> None:
     source_folder_path = Path(source_folder)
 
     if not source_folder_path.exists():
diff --git a/blacksheep/server/files/dynamic.py b/blacksheep/server/files/dynamic.py
index 4e22114..397777b 100644
--- a/blacksheep/server/files/dynamic.py
+++ b/blacksheep/server/files/dynamic.py
@@ -1,7 +1,7 @@
 import html
 import os
 from pathlib import Path
-from typing import Awaitable, Callable, Dict, Iterable, Optional, Sequence, Set
+from typing import Awaitable, Callable, Dict, Iterable, Optional, Sequence, Set, Union
 from urllib.parse import unquote
 
 from blacksheep import HTMLContent, Request, Response
@@ -9,16 +9,20 @@ from blacksheep.common.files.asyncfs import FilesHandler
 from blacksheep.common.files.pathsutils import get_file_extension_from_name
 from blacksheep.exceptions import NotFound
 from blacksheep.server.authorization import allow_anonymous
+from blacksheep.server.files import (
+    DefaultFileOptions,
+    get_default_extensions,
+    get_response_for_file,
+    validate_source_path,
+)
 from blacksheep.server.resources import get_resource_file_content
 from blacksheep.server.routing import Route, Router
 from blacksheep.utils import join_fragments
 
-from . import get_default_extensions, get_response_for_file, validate_source_path
-
 
 def get_files_to_serve(
     source_folder: Path, extensions: Set[str], root_folder: Optional[Path] = None
-) -> Iterable[Dict[str, str]]:
+) -> Iterable[Dict[str, Union[str, bool]]]:
     assert source_folder.exists(), "The source folder path must exist"
     assert source_folder.is_dir(), "The source folder path must be a directory"
 
@@ -107,6 +111,7 @@ def get_response_for_resource_path(
     extensions: Set[str],
     root_path: str,
     index_document: Optional[str],
+    default_file_options: Optional[DefaultFileOptions] = None,
 ) -> Response:
     resource_path = os.path.join(source_folder_name, tail)
 
@@ -133,7 +138,7 @@ def get_response_for_resource_path(
         else:
             if index_document is not None:
                 # try returning the default index document
-                return get_response_for_resource_path(
+                response = get_response_for_resource_path(
                     request,
                     index_document,
                     files_list_html,
@@ -146,6 +151,11 @@ def get_response_for_resource_path(
                     root_path,
                     None,
                 )
+
+                if default_file_options:
+                    default_file_options.handle(request, response)
+
+                return response
             raise NotFound()
 
     file_extension = get_file_extension_from_name(resource_path)
@@ -165,6 +175,7 @@ def get_files_route_handler(
     root_path: str,
     index_document: Optional[str],
     fallback_document: Optional[str],
+    default_file_options: Optional[DefaultFileOptions] = None,
 ) -> Callable[[Request], Awaitable[Response]]:
     files_list_html = get_resource_file_content("fileslist.html")
     source_folder_full_path = os.path.abspath(str(source_folder_name))
@@ -186,12 +197,13 @@ def get_files_route_handler(
                 extensions,
                 root_path,
                 index_document,
+                default_file_options=default_file_options,
             )
         except NotFound:
             if fallback_document is None:
                 raise
 
-            return get_response_for_resource_path(
+            response = get_response_for_resource_path(
                 request,
                 fallback_document,
                 files_list_html,
@@ -203,8 +215,14 @@ def get_files_route_handler(
                 extensions,
                 root_path,
                 None,
+                default_file_options=default_file_options,
             )
 
+            if default_file_options and index_document == fallback_document:
+                default_file_options.handle(request, response)
+
+            return response
+
     return static_files_handler
 
 
@@ -221,7 +239,7 @@ def get_static_files_route(path_prefix: str) -> bytes:
 def serve_files_dynamic(
     router: Router,
     files_handler: FilesHandler,
-    source_folder: str,
+    source_folder: Union[str, Path],
     *,
     discovery: bool,
     cache_time: int,
@@ -230,6 +248,7 @@ def serve_files_dynamic(
     index_document: Optional[str],
     fallback_document: Optional[str],
     anonymous_access: bool = True,
+    default_file_options: Optional[DefaultFileOptions] = None,
 ) -> None:
     """
     Configures a route to serve files dynamically, using the given files handler and
@@ -249,6 +268,7 @@ def serve_files_dynamic(
         root_path,
         index_document,
         fallback_document,
+        default_file_options,
     )
 
     if anonymous_access:
diff --git a/blacksheep/server/headers/__init__.py b/blacksheep/server/headers/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/blacksheep/server/headers/cache.py b/blacksheep/server/headers/cache.py
new file mode 100644
index 0000000..f5cfcc4
--- /dev/null
+++ b/blacksheep/server/headers/cache.py
@@ -0,0 +1,364 @@
+"""
+This module provides functions to configure Cache-Control headers for responses and
+request handlers, including a decorator and a middleware.
+"""
+
+import inspect
+from functools import wraps
+from typing import Optional
+
+from blacksheep import Request, Response
+from blacksheep.normalization import copy_special_attributes
+from blacksheep.server.normalization import ensure_response
+
+
+def write_cache_control_response_header(
+    *,
+    max_age: Optional[int] = None,
+    shared_max_age: Optional[int] = None,
+    no_cache: Optional[bool] = None,
+    no_store: Optional[bool] = None,
+    must_revalidate: Optional[bool] = None,
+    proxy_revalidate: Optional[bool] = None,
+    private: Optional[bool] = None,
+    public: Optional[bool] = None,
+    must_understand: Optional[bool] = None,
+    no_transform: Optional[bool] = None,
+    immutable: Optional[bool] = None,
+    stale_while_revalidate: Optional[int] = None,
+    stale_if_error: Optional[int] = None,
+) -> bytes:
+    """
+    Writes the value of a Cache-Control response header, applying the given directives.
+
+    Parameters
+    ----------
+    max_age: int | None
+        max age in seconds
+    shared_max_age: int | None
+        optional shared max age in seconds
+    no_cache: bool | None
+        enables no-cache
+    no_store: bool | None
+        enables no-store
+    must_revalidate: bool | None
+        enables must-revalidate
+    proxy_revalidate: bool | None
+        enables proxy-revalidate
+    private: bool | None
+        enables private
+    public: bool | None
+        enables public
+    must_understand: bool | None
+        enables must-understand
+    no_transform: bool | None
+        enables no-transform
+    immutable: bool | None
+        enables immutable
+    stale_while_revalidate: int | None
+        enables stale-while-revalidate, in seconds
+    stale_if_error: int | None
+        enables stale-if-error, in seconds
+
+    For detailed information on Cache-Control header, please refer to:
+    https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control
+    """
+    value = bytearray()
+
+    if private and public:
+        raise ValueError("A cache cannot be private and public at the same time.")
+
+    def extend(part: bytes):
+        if len(value):
+            value.extend(b", ")
+        value.extend(part)
+
+    if max_age is not None:
+        extend(f"max-age={max_age}".encode("ascii"))
+
+    if shared_max_age is not None:
+        extend(f"s-maxage={shared_max_age}".encode("ascii"))
+
+    if no_cache:
+        extend(b"no-cache")
+
+    if must_revalidate:
+        extend(b"must-revalidate")
+
+    if proxy_revalidate:
+        extend(b"proxy-revalidate")
+
+    if no_store:
+        extend(b"no-store")
+
+    if private:
+        extend(b"private")
+
+    if public:
+        extend(b"public")
+
+    if must_understand:
+        extend(b"must-understand")
+
+    if no_transform:
+        extend(b"no-transform")
+
+    if immutable:
+        extend(b"immutable")
+
+    if stale_while_revalidate is not None:
+        extend(f"stale-while-revalidate={stale_while_revalidate}".encode("ascii"))
+
+    if stale_if_error is not None:
+        extend(f"stale-if-error={stale_if_error}".encode("ascii"))
+
+    return bytes(value)
+
+
+class CacheControlHeaderValue:
+    """
+    Class used to represent a Cache-Control response header value.
+
+    Parameters
+    ----------
+    max_age: int | None
+        max age in seconds
+    shared_max_age: int | None
+        optional shared max age in seconds
+    no_cache: bool | None
+        enables no-cache
+    no_store: bool | None
+        enables no-store
+    must_revalidate: bool | None
+        enables must-revalidate
+    proxy_revalidate: bool | None
+        enables proxy-revalidate
+    private: bool | None
+        enables private
+    public: bool | None
+        enables public
+    must_understand: bool | None
+        enables must-understand
+    no_transform: bool | None
+        enables no-transform
+    immutable: bool | None
+        enables immutable
+    stale_while_revalidate: int | None
+        enables stale-while-revalidate, in seconds
+    stale_if_error: int | None
+        enables stale-if-error, in seconds
+
+    For detailed information on Cache-Control header, please refer to:
+    https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control
+    """
+
+    def __init__(
+        self,
+        *,
+        max_age: Optional[int] = None,
+        shared_max_age: Optional[int] = None,
+        no_cache: Optional[bool] = None,
+        no_store: Optional[bool] = None,
+        must_revalidate: Optional[bool] = None,
+        proxy_revalidate: Optional[bool] = None,
+        private: Optional[bool] = None,
+        public: Optional[bool] = None,
+        must_understand: Optional[bool] = None,
+        no_transform: Optional[bool] = None,
+        immutable: Optional[bool] = None,
+        stale_while_revalidate: Optional[int] = None,
+        stale_if_error: Optional[int] = None,
+    ) -> None:
+        self.value: bytes = write_cache_control_response_header(
+            max_age=max_age,
+            shared_max_age=shared_max_age,
+            no_cache=no_cache,
+            no_store=no_store,
+            must_revalidate=must_revalidate,
+            proxy_revalidate=proxy_revalidate,
+            private=private,
+            public=public,
+            must_understand=must_understand,
+            no_transform=no_transform,
+            immutable=immutable,
+            stale_while_revalidate=stale_while_revalidate,
+            stale_if_error=stale_if_error,
+        )
+
+
+def cache_control(
+    max_age: Optional[int] = None,
+    shared_max_age: Optional[int] = None,
+    no_cache: Optional[bool] = None,
+    no_store: Optional[bool] = None,
+    must_revalidate: Optional[bool] = None,
+    proxy_revalidate: Optional[bool] = None,
+    private: Optional[bool] = None,
+    public: Optional[bool] = None,
+    must_understand: Optional[bool] = None,
+    no_transform: Optional[bool] = None,
+    immutable: Optional[bool] = None,
+    stale_while_revalidate: Optional[int] = None,
+    stale_if_error: Optional[int] = None,
+):
+    """
+    Cache control decorator, applying a Cache-Control header to all successful
+    responses.
+
+    Parameters
+    ----------
+    max_age: int | None
+        max age in seconds
+    shared_max_age: int | None
+        optional shared max age in seconds
+    no_cache: bool | None
+        enables no-cache
+    no_store: bool | None
+        enables no-store
+    must_revalidate: bool | None
+        enables must-revalidate
+    proxy_revalidate: bool | None
+        enables proxy-revalidate
+    private: bool | None
+        enables private
+    public: bool | None
+        enables public
+    must_understand: bool | None
+        enables must-understand
+    no_transform: bool | None
+        enables no-transform
+    immutable: bool | None
+        enables immutable
+    stale_while_revalidate: int | None
+        enables stale-while-revalidate, in seconds
+    stale_if_error: int | None
+        enables stale-if-error, in seconds
+
+    For detailed information on Cache-Control header, please refer to:
+    https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control
+    """
+    header_value: bytes = write_cache_control_response_header(
+        max_age=max_age,
+        shared_max_age=shared_max_age,
+        no_cache=no_cache,
+        no_store=no_store,
+        must_revalidate=must_revalidate,
+        proxy_revalidate=proxy_revalidate,
+        private=private,
+        public=public,
+        must_understand=must_understand,
+        no_transform=no_transform,
+        immutable=immutable,
+        stale_while_revalidate=stale_while_revalidate,
+        stale_if_error=stale_if_error,
+    )
+
+    def decorator(next_handler):
+        if inspect.iscoroutinefunction(next_handler):
+
+            @wraps(next_handler)
+            async def async_wrapped(*args, **kwargs):
+                response = ensure_response(await next_handler(*args, **kwargs))
+                response.add_header(b"cache-control", header_value)
+                return response
+
+            return async_wrapped
+        else:
+
+            @wraps(next_handler)
+            def wrapped(*args, **kwargs):
+                response = ensure_response(next_handler(*args, **kwargs))
+                response.add_header(b"cache-control", header_value)
+                return response
+
+            copy_special_attributes(next_handler, wrapped)
+            return wrapped
+
+    return decorator
+
+
+class CacheControlMiddleware:
+    """
+    The Cache-Control middleware lets configure a Cache-Control header globally, for
+    all GET requests resulting in responses with status code 200 that don't have a
+    cache-control header defined.
+
+    Parameters
+    ----------
+    max_age: int | None
+        max age in seconds
+    shared_max_age: int | None
+        optional shared max age in seconds
+    no_cache: bool | None
+        enables no-cache
+    no_store: bool | None
+        enables no-store
+    must_revalidate: bool | None
+        enables must-revalidate
+    proxy_revalidate: bool | None
+        enables proxy-revalidate
+    private: bool | None
+        enables private
+    public: bool | None
+        enables public
+    must_understand: bool | None
+        enables must-understand
+    no_transform: bool | None
+        enables no-transform
+    immutable: bool | None
+        enables immutable
+    stale_while_revalidate: int | None
+        enables stale-while-revalidate, in seconds
+    stale_if_error: int | None
+        enables stale-if-error, in seconds
+    """
+
+    def __init__(
+        self,
+        *,
+        max_age: Optional[int] = None,
+        shared_max_age: Optional[int] = None,
+        no_cache: Optional[bool] = None,
+        no_store: Optional[bool] = None,
+        must_revalidate: Optional[bool] = None,
+        proxy_revalidate: Optional[bool] = None,
+        private: Optional[bool] = None,
+        public: Optional[bool] = None,
+        must_understand: Optional[bool] = None,
+        no_transform: Optional[bool] = None,
+        immutable: Optional[bool] = None,
+        stale_while_revalidate: Optional[int] = None,
+        stale_if_error: Optional[int] = None,
+    ) -> None:
+        self._header_value: bytes = write_cache_control_response_header(
+            max_age=max_age,
+            shared_max_age=shared_max_age,
+            no_cache=no_cache,
+            no_store=no_store,
+            must_revalidate=must_revalidate,
+            proxy_revalidate=proxy_revalidate,
+            private=private,
+            public=public,
+            must_understand=must_understand,
+            no_transform=no_transform,
+            immutable=immutable,
+            stale_while_revalidate=stale_while_revalidate,
+            stale_if_error=stale_if_error,
+        )
+
+    def should_handle(self, request: Request, response: Response) -> bool:
+        """
+        Returns a value indicating whether the Cache-Control header should be set in
+        the given response object, that was created for the given request.
+        """
+        return request.method == "GET" and response.status == 200
+
+    async def __call__(self, request, handler):
+        response = await handler(request)
+        if (
+            self.should_handle(request, response)
+            and not response.headers[b"cache-control"]
+        ):
+            response.add_header(b"cache-control", self._header_value)
+
+        return response
diff --git a/setup.py b/setup.py
index 61a7c54..ac84dac 100644
--- a/setup.py
+++ b/setup.py
@@ -11,7 +11,7 @@ COMPILE_ARGS = ["-O2"]
 
 setup(
     name="blacksheep",
-    version="2.0a3",
+    version="2.0a4",
     description="Fast web framework for Python asyncio",
     long_description=readme(),
     long_description_content_type="text/markdown",
@@ -39,6 +39,7 @@ setup(
         "blacksheep.server.authentication",
         "blacksheep.server.authorization",
         "blacksheep.server.files",
+        "blacksheep.server.headers",
         "blacksheep.server.remotes",
         "blacksheep.server.res",
         "blacksheep.server.openapi",


Test patch:
diff --git a/tests/files2/example xml b/tests/files2/example.xml
similarity index 100%
rename from tests/files2/example xml
rename to tests/files2/example.xml
diff --git a/tests/test_application.py b/tests/test_application.py
index de39ff0..53e8aee 100644
--- a/tests/test_application.py
+++ b/tests/test_application.py
@@ -4108,3 +4108,28 @@ async def test_pep_593(app):
         {"name": "Ren", "age": None},
         {"name": "Stimpy", "age": 3},
     ]
+
+
+@pytest.mark.asyncio
+async def test_lifespan_event(app: Application):
+    initialized = False
+    disposed = False
+
+    @app.lifespan
+    async def some_async_gen():
+        nonlocal initialized
+        nonlocal disposed
+
+        initialized = True
+        yield
+        disposed = True
+
+    await app.start()
+
+    assert initialized is True
+    assert disposed is False
+
+    await app.stop()
+
+    assert initialized is True
+    assert disposed is True
diff --git a/tests/test_files_serving.py b/tests/test_files_serving.py
index 3fe8ad7..1c5bd44 100644
--- a/tests/test_files_serving.py
+++ b/tests/test_files_serving.py
@@ -2,16 +2,18 @@ from asyncio import AbstractEventLoop
 from datetime import datetime
 from pathlib import Path
 from typing import List
+from unittest.mock import create_autospec
 
 import pkg_resources
 import pytest
 from essentials.folders import get_file_extension
 
-from blacksheep import Request
+from blacksheep import Application, Request
 from blacksheep.common.files.asyncfs import FileContext, FilesHandler
 from blacksheep.exceptions import BadRequest, InvalidArgument
 from blacksheep.ranges import Range, RangePart
 from blacksheep.server.files import (
+    DefaultFileOptions,
     FileInfo,
     RangeNotSatisfiable,
     _get_requested_range,
@@ -21,6 +23,7 @@ from blacksheep.server.files import (
 )
 from blacksheep.server.files.dynamic import get_response_for_file
 from blacksheep.server.files.static import get_response_for_static_content
+from blacksheep.server.headers.cache import CacheControlHeaderValue
 from blacksheep.server.responses import text
 from blacksheep.testing.helpers import get_example_scope
 from blacksheep.testing.messages import MockReceive, MockSend
@@ -842,3 +845,95 @@ async def test_app_404_handler_static_files_not_found(app):
     assert response.status == 404
     assert called is True
     assert response_text == "Example"
+
+
+@pytest.mark.asyncio
+async def test_serve_files_index_html_options(files2_index_contents, app: Application):
+    def on_response(request, response):
+        ...
+
+    mock = create_autospec(on_response, return_value=None)
+
+    index_options = DefaultFileOptions(
+        on_response=mock,
+        cache_control=CacheControlHeaderValue(no_cache=True, no_store=True),
+    )
+
+    app.serve_files(get_folder_path("files2"), default_file_options=index_options)
+
+    await app.start()
+
+    scope = get_example_scope("GET", "/", [])
+    await app(
+        scope,
+        MockReceive(),
+        MockSend(),
+    )
+
+    assert mock.call_count == 1
+
+    response = app.response
+    assert response.status == 200
+    assert files2_index_contents == await response.read()
+    assert response.headers[b"cache-control"] == (b"no-cache, no-store",)
+
+    scope = get_example_scope("GET", "/scripts/main.js", [])
+    await app(
+        scope,
+        MockReceive(),
+        MockSend(),
+    )
+
+    assert mock.call_count == 1
+    response = app.response
+    assert response.status == 200
+    assert response.headers[b"cache-control"] != (b"no-cache, no-store",)
+
+
+@pytest.mark.asyncio
+async def test_serve_files_index_html_options_fallback(
+    files2_index_contents, app: Application
+):
+    def on_response(request, response):
+        ...
+
+    mock = create_autospec(on_response, return_value=None)
+
+    index_options = DefaultFileOptions(
+        on_response=mock,
+        cache_control=CacheControlHeaderValue(no_cache=True, no_store=True),
+    )
+
+    app.serve_files(
+        get_folder_path("files2"),
+        fallback_document="index.html",
+        default_file_options=index_options,
+    )
+
+    await app.start()
+
+    scope = get_example_scope("GET", "/not-existent-file", [])
+    await app(
+        scope,
+        MockReceive(),
+        MockSend(),
+    )
+
+    assert mock.call_count == 1
+
+    response = app.response
+    assert response.status == 200
+    assert files2_index_contents == await response.read()
+    assert response.headers[b"cache-control"] == (b"no-cache, no-store",)
+
+    scope = get_example_scope("GET", "/scripts/main.js", [])
+    await app(
+        scope,
+        MockReceive(),
+        MockSend(),
+    )
+
+    assert mock.call_count == 1
+    response = app.response
+    assert response.status == 200
+    assert response.headers[b"cache-control"] != (b"no-cache, no-store",)