From e39194e9f6b34eef7cb10b1e86df7c5e3b9b4ba0 Mon Sep 17 00:00:00 2001 From: Evgenii Alekseev Date: Mon, 23 Mar 2026 23:55:39 +0200 Subject: [PATCH] feat: etag support --- docs/ahriman.web.middlewares.rst | 8 ++ src/ahriman/web/middlewares/etag_handler.py | 61 +++++++++++++ src/ahriman/web/web.py | 2 + .../web/middlewares/test_etag_handler.py | 85 +++++++++++++++++++ 4 files changed, 156 insertions(+) create mode 100644 src/ahriman/web/middlewares/etag_handler.py create mode 100644 tests/ahriman/web/middlewares/test_etag_handler.py diff --git a/docs/ahriman.web.middlewares.rst b/docs/ahriman.web.middlewares.rst index 8d05beaf..3b3bbdbf 100644 --- a/docs/ahriman.web.middlewares.rst +++ b/docs/ahriman.web.middlewares.rst @@ -12,6 +12,14 @@ ahriman.web.middlewares.auth\_handler module :no-undoc-members: :show-inheritance: +ahriman.web.middlewares.etag\_handler module +-------------------------------------------- + +.. automodule:: ahriman.web.middlewares.etag_handler + :members: + :no-undoc-members: + :show-inheritance: + ahriman.web.middlewares.exception\_handler module ------------------------------------------------- diff --git a/src/ahriman/web/middlewares/etag_handler.py b/src/ahriman/web/middlewares/etag_handler.py new file mode 100644 index 00000000..556bacfa --- /dev/null +++ b/src/ahriman/web/middlewares/etag_handler.py @@ -0,0 +1,61 @@ +# +# Copyright (c) 2021-2026 ahriman team. +# +# This file is part of ahriman +# (see https://github.com/arcan1s/ahriman). +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +import hashlib + +from aiohttp import ETag +from aiohttp.typedefs import Middleware +from aiohttp.web import HTTPNotModified, Request, Response, StreamResponse, middleware + +from ahriman.web.middlewares import HandlerType + + +__all__ = ["etag_handler"] + + +def etag_handler() -> Middleware: + """ + middleware to handle ETag header for conditional requests. It computes ETag from the response body + and returns 304 Not Modified if the client sends a matching ``If-None-Match`` header + + Returns: + Middleware: built middleware + + Raises: + HTTPNotModified: if content matches ``If-None-Match`` header sent + """ + @middleware + async def handle(request: Request, handler: HandlerType) -> StreamResponse: + response = await handler(request) + + if not isinstance(response, Response) or not isinstance(response.body, bytes): + return response + + if request.method not in ("GET", "HEAD"): + return response + + etag = ETag(value=hashlib.md5(response.body, usedforsecurity=False).hexdigest()) + response.etag = etag + + if request.if_none_match is not None and etag in request.if_none_match: + raise HTTPNotModified(headers={"ETag": response.headers["ETag"]}) + + return response + + return handle diff --git a/src/ahriman/web/web.py b/src/ahriman/web/web.py index 630b7b0e..142f2508 100644 --- a/src/ahriman/web/web.py +++ b/src/ahriman/web/web.py @@ -38,6 +38,7 @@ from ahriman.models.repository_id import RepositoryId from ahriman.web.apispec.info import setup_apispec from ahriman.web.cors import setup_cors from ahriman.web.keys import AuthKey, ConfigurationKey, SpawnKey, WatcherKey, WorkersKey +from ahriman.web.middlewares.etag_handler import etag_handler from ahriman.web.middlewares.exception_handler import exception_handler from ahriman.web.middlewares.metrics_handler import metrics_handler from ahriman.web.middlewares.request_id_handler import request_id_handler @@ -181,6 +182,7 @@ def setup_server(configuration: Configuration, spawner: Spawn, repositories: lis application.middlewares.append(normalize_path_middleware(append_slash=False, remove_slash=True)) application.middlewares.append(request_id_handler()) application.middlewares.append(exception_handler(application.logger)) + application.middlewares.append(etag_handler()) application.middlewares.append(metrics_handler()) application.logger.info("setup routes") diff --git a/tests/ahriman/web/middlewares/test_etag_handler.py b/tests/ahriman/web/middlewares/test_etag_handler.py new file mode 100644 index 00000000..45afcc0a --- /dev/null +++ b/tests/ahriman/web/middlewares/test_etag_handler.py @@ -0,0 +1,85 @@ +import hashlib +import pytest + +from aiohttp import ETag +from aiohttp.web import HTTPNotModified, Response, StreamResponse +from unittest.mock import AsyncMock + +from ahriman.web.middlewares.etag_handler import etag_handler + + +async def test_etag_handler() -> None: + """ + must set ETag header on GET responses + """ + request = pytest.helpers.request("", "", "GET") + request.if_none_match = None + request_handler = AsyncMock(return_value=Response(body=b"hello")) + + handler = etag_handler() + result = await handler(request, request_handler) + assert result.etag is not None + + +async def test_etag_handler_not_modified() -> None: + """ + must raise NotModified when ETag matches If-None-Match + """ + body = b"hello" + request = pytest.helpers.request("", "", "GET") + request.if_none_match = (ETag(value=hashlib.md5(body, usedforsecurity=False).hexdigest()),) + request_handler = AsyncMock(return_value=Response(body=body)) + + handler = etag_handler() + with pytest.raises(HTTPNotModified): + await handler(request, request_handler) + + +async def test_etag_handler_no_match() -> None: + """ + must return full response when ETag does not match If-None-Match + """ + request = pytest.helpers.request("", "", "GET") + request.if_none_match = (ETag(value="outdated"),) + request_handler = AsyncMock(return_value=Response(body=b"hello")) + + handler = etag_handler() + result = await handler(request, request_handler) + assert result.status == 200 + assert result.etag is not None + + +async def test_etag_handler_skip_post() -> None: + """ + must skip ETag for non-GET/HEAD methods + """ + request = pytest.helpers.request("", "", "POST") + request_handler = AsyncMock(return_value=Response(body=b"hello")) + + handler = etag_handler() + result = await handler(request, request_handler) + assert result.etag is None + + +async def test_etag_handler_skip_no_body() -> None: + """ + must skip ETag for responses without body + """ + request = pytest.helpers.request("", "", "GET") + request_handler = AsyncMock(return_value=Response()) + + handler = etag_handler() + result = await handler(request, request_handler) + assert result.etag is None + + +async def test_etag_handler_skip_stream() -> None: + """ + must skip ETag for streaming responses + """ + request = pytest.helpers.request("", "", "GET") + request_handler = AsyncMock(return_value=StreamResponse()) + + handler = etag_handler() + result = await handler(request, request_handler) + assert "ETag" not in result.headers