diff --git a/README.md b/README.md index c364d55a..603e3439 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,10 @@ urlpatterns = winter.django.create_urls(app) startup instead of on the first request. - An argument named `response_headers` of an exception handler no longer receives the headers of the response: declare a `ResponseHeader[T]` with `@winter.web.response_header`, as in an API method. +- A response header is a `str`, an `int` or a `datetime`, the types HTTP has a format for: `ResponseHeader[T]` of + another type fails with `TypeError` on import, so a UUID or an enum is set as a `str` (`str(uid)`, `status.value`). + `set` checks the value against `T`. A `datetime` goes as an HTTP-date in GMT in every header, not as ISO 8601, and + needs a time zone. `response_headers_serializer` and `ResponseHeaderSerializer` are gone. - Interceptors belong to applications: `WebApplication(..., interceptors=[AuthorizationInterceptor, AuditInterceptor])` lists their classes in the order they run. The injector of each request creates them, so their dependencies go to `__init__`. `interceptor_registry` and `InterceptorRegistry` are gone. diff --git a/docs/architecture.md b/docs/architecture.md index e583a452..f109e4fd 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -68,7 +68,7 @@ at least two of them. subpackage can later ship as its own distribution from its own repository without any import path changing. 2. **Framework behaviour is never process-global.** Every behavioural registry (exception - handlers, interceptors, response header serializers, throttling configuration, OpenAPI inspectors) + handlers, interceptors, throttling configuration, OpenAPI inspectors) belongs to the application object an adapter builds from, such as `winter.web.WebApplication`, and the values that its methods receive come from its injector, so two applications can live in one process. @@ -85,13 +85,13 @@ at least two of them. - **Framework behaviour is still process-global.** A `winter.web.WebApplication` holds the code of a web application, its injector and its interceptors. The adapters derive its exception handlers from its definitions and handle each of its requests in a child of its injector. The other - behavioural registries of `winter.web` and `winter.openapi` (response header serializers, - native response types, throttling, OpenAPI inspectors) are module singletons, and + behavioural registries of `winter.web` and `winter.openapi` (native response types, throttling, + OpenAPI inspectors) are module singletons, and `winter.sqlalchemy`, `winter.ddd` and `winter.messaging` take the injector from the service locator `winter.application.get_injector`. -- **Models are initialised once per process.** `winter.web.initialization.initialize`, the Django - adapter's `_initialize_process` and `winter.openapi.initialization.initialize` fill those module - singletons with the standard serializers and inspectors on first use. In the +- **Models are initialised once per process.** The Django adapter's `_initialize_process` and + `winter.openapi.initialization.initialize` fill those module singletons with the Django entries and + the standard inspectors on first use. In the target, a `WebApplication` is given these registries like its interceptors, and they start with the standard entries. Building from an application, an adapter adds its own entries (the native response type, the throttling identity) to that application only. The diff --git a/tests/api/api_with_response_headers.py b/tests/api/api_with_response_headers.py index f5a2c1c1..bf35fae8 100644 --- a/tests/api/api_with_response_headers.py +++ b/tests/api/api_with_response_headers.py @@ -1,7 +1,4 @@ import datetime -from uuid import UUID - -import pytz import winter.web from winter.web import ResponseHeader @@ -23,21 +20,10 @@ def int_header(self, header: ResponseHeader[int]) -> str: return 'OK' @winter.web.response_header('x-header', 'header') - @winter.web.route_get('datetime-isoformat-header/{?now}') - def datetime_isoformat_header(self, now: float, header: ResponseHeader[datetime.datetime]) -> str: - header.set(datetime.datetime.fromtimestamp(now)) - return 'OK' - - @winter.web.response_header('Last-Modified', 'header') - @winter.web.route_get('last-modified-header/{?now}') - def last_modified_header(self, now: float, header: ResponseHeader[datetime.datetime]) -> str: - header.set(datetime.datetime.fromtimestamp(now).astimezone(pytz.timezone('Asia/Novosibirsk'))) - return 'OK' - - @winter.web.response_header('x-header', 'header') - @winter.web.route_get('uuid-header/{?uid}') - def uuid_header(self, uid: UUID, header: ResponseHeader[UUID]) -> str: - header.set(uid) + @winter.web.route_get('datetime-header/') + def datetime_header(self, header: ResponseHeader[datetime.datetime]) -> str: + novosibirsk = datetime.timezone(datetime.timedelta(hours=7)) + header.set(datetime.datetime(2026, 10, 27, 16, 54, 30, 123456, tzinfo=novosibirsk)) return 'OK' @winter.web.response_header('x-header1', 'header1') diff --git a/tests/web/test_response_header.py b/tests/web/test_response_header.py index 48a21a73..cfed7bf1 100644 --- a/tests/web/test_response_header.py +++ b/tests/web/test_response_header.py @@ -1,72 +1,106 @@ import datetime +import enum import uuid from http import HTTPStatus -import pytz +import pytest from winter.web import ResponseHeader def test_response_header_sets_header(): headers = {} - header = ResponseHeader[uuid.UUID](headers, 'My-Header') - uid = uuid.uuid4() + header = ResponseHeader[str](headers, 'My-Header') # Act - header.set(uid) + header.set('value') - assert headers['my-header'] == uid + assert headers == {'my-header': 'value'} -def test_str_response_header(api_client): +@pytest.mark.parametrize('value_type, name', [ + (uuid.UUID, 'UUID'), + (bool, 'bool'), + (datetime.date, 'date'), + (list[int], 'list[int]'), +]) +def test_response_header_of_type_without_header_format_is_rejected(value_type, name): # Act - response = api_client.get('/with-response-headers/str-header/') + with pytest.raises(TypeError) as exception_info: + ResponseHeader[value_type] - assert response.status_code == HTTPStatus.OK - assert response.json() == 'OK' - assert response.headers['x-header'] == 'test header' + # Assert + assert str(exception_info.value) == f'ResponseHeader[{name}]: a response header is str, int or datetime' -def test_int_response_header(api_client): +@pytest.mark.parametrize('value, message', [ + ('30', "retry-after: '30' is not int"), + (True, 'retry-after: True is not int'), +]) +def test_response_header_rejects_value_of_other_type(value, message): + header = ResponseHeader[int]({}, 'Retry-After') + # Act - response = api_client.get('/with-response-headers/int-header/') + with pytest.raises(TypeError) as exception_info: + header.set(value) + + # Assert + assert str(exception_info.value) == message - assert response.status_code == HTTPStatus.OK - assert response.json() == 'OK' - assert response.headers['x-header'] == '123' +class _Status(enum.StrEnum): + ACTIVE = 'active' -def test_datetime_isoformat_response_header(api_client): - now = datetime.datetime.now() + +def test_response_header_rejects_enum(): + header = ResponseHeader[str]({}, 'X-Status') # Act - response = api_client.get(f'/with-response-headers/datetime-isoformat-header/?now={now.timestamp()}') + with pytest.raises(TypeError) as exception_info: + header.set(_Status.ACTIVE) - assert response.status_code == HTTPStatus.OK - assert response.json() == 'OK' - assert response.headers['x-header'] == now.isoformat() + # Assert + assert str(exception_info.value) == "x-status: an enum is set by its value, not <_Status.ACTIVE: 'active'>" -def test_last_modified_response_header(api_client): - now = datetime.datetime.now() +def test_response_header_rejects_datetime_without_time_zone(): + header = ResponseHeader[datetime.datetime]({}, 'Last-Modified') # Act - response = api_client.get(f'/with-response-headers/last-modified-header/?now={now.timestamp()}') + with pytest.raises(ValueError) as exception_info: + header.set(datetime.datetime(2026, 10, 27, 9, 54)) + + # Assert + assert str(exception_info.value) == ( + 'last-modified: a datetime without a time zone has no HTTP-date, give it tzinfo' + ) + + +def test_str_response_header(api_client): + # Act + response = api_client.get('/with-response-headers/str-header/') assert response.status_code == HTTPStatus.OK assert response.json() == 'OK' - assert response.headers['last-modified'] == now.astimezone(pytz.utc).strftime('%a, %d %b %Y %X GMT') + assert response.headers['x-header'] == 'test header' + +def test_int_response_header(api_client): + # Act + response = api_client.get('/with-response-headers/int-header/') + + assert response.status_code == HTTPStatus.OK + assert response.json() == 'OK' + assert response.headers['x-header'] == '123' -def test_uuid_response_header(api_client): - uid = uuid.uuid4() +def test_datetime_response_header_is_http_date_in_gmt(api_client): # Act - response = api_client.get(f'/with-response-headers/uuid-header/?uid={uid}') + response = api_client.get('/with-response-headers/datetime-header/') assert response.status_code == HTTPStatus.OK assert response.json() == 'OK' - assert response.headers['x-header'] == str(uid) + assert response.headers['x-header'] == 'Tue, 27 Oct 2026 09:54:30 GMT' def test_two_response_headers(api_client): diff --git a/winter/django/view.py b/winter/django/view.py index f80aeea0..01eede15 100644 --- a/winter/django/view.py +++ b/winter/django/view.py @@ -8,7 +8,6 @@ from django.urls import URLPattern from django.urls import re_path -import winter.web.initialization from winter.web import Response from winter.web import WebApplication from winter.web import handle_request @@ -29,7 +28,6 @@ def create_urls(app: WebApplication) -> List[URLPattern]: """URL patterns of the routes of an application, relative to where they are included in the urlpatterns.""" - winter.web.initialization.initialize() _initialize_process() routes = find_routes(app) exception_handlers = find_exception_handlers(app) diff --git a/winter/web/__init__.py b/winter/web/__init__.py index 2f5ba80d..3244581e 100644 --- a/winter/web/__init__.py +++ b/winter/web/__init__.py @@ -18,7 +18,6 @@ from .response_entity import ResponseEntity from .response_header_annotation import ResponseHeader from .response_header_annotation import response_header -from .response_header_serializer import response_headers_serializer from .response_status_annotation import response_status from .routing import route from .routing import route_delete diff --git a/winter/web/exceptions/problem_handling.py b/winter/web/exceptions/problem_handling.py index 06a72107..c99c7680 100644 --- a/winter/web/exceptions/problem_handling.py +++ b/winter/web/exceptions/problem_handling.py @@ -37,7 +37,7 @@ def handle( content_type_header: ResponseHeader[str], **kwargs, ) -> return_type_class: - content_type_header.set(MediaType.APPLICATION_PROBLEM_JSON) + content_type_header.set(str(MediaType.APPLICATION_PROBLEM_JSON)) return exception_mapper.to_response_body(request, exception, handling_info) handler_class_name = exception_class.__name__ + 'Handler' diff --git a/winter/web/initialization.py b/winter/web/initialization.py deleted file mode 100644 index 5f1e6cf5..00000000 --- a/winter/web/initialization.py +++ /dev/null @@ -1,22 +0,0 @@ -from .response_header_serializer import response_headers_serializer -from .response_header_serializers import DateTimeResponseHeaderSerializer -from .response_header_serializers import LastModifiedResponseHeaderSerializer - -_process_initialized = False - - -def initialize(): - """ - Registers the standard response header serializers of winter.web on the first call. - Adapters call it before building their routes. - Process-global until these registries belong to an application: docs/architecture.md, Current deviations. - """ - global _process_initialized - if not _process_initialized: - _initialize_process() - _process_initialized = True - - -def _initialize_process(): - response_headers_serializer.add_serializer(DateTimeResponseHeaderSerializer()) - response_headers_serializer.add_serializer(LastModifiedResponseHeaderSerializer()) diff --git a/winter/web/request_handler.py b/winter/web/request_handler.py index 0f38fd8b..39c76b01 100644 --- a/winter/web/request_handler.py +++ b/winter/web/request_handler.py @@ -24,7 +24,6 @@ from .response import Response from .response_entity import ResponseEntity from .response_header_resolver import ResponseHeaderArgumentResolver -from .response_header_serializer import response_headers_serializer from .routing import Route from .throttling import create_throttle_class from .web_application import WebApplication @@ -76,7 +75,7 @@ def handle_request( and the request values of the adapter, such as its own request object. Request-scoped values live in it. """ method = route.method - response_headers: dict[str, Any] = {} + response_headers: dict[str, str] = {} injector = create_request_injector(app.injector, request, request_values) throttle = create_throttle_class(route) @@ -109,7 +108,7 @@ def _handle_exception( method: ComponentMethod, exception: Exception, request: Request, - response_headers: dict[str, Any], + response_headers: dict[str, str], injector: Injector, ) -> Any: context = {exception_argument_name(method): exception} @@ -118,11 +117,7 @@ def _handle_exception( return method(handler, **arguments) -def _to_response(request: Request, result: Any, method: ComponentMethod, response_headers: dict[str, Any]) -> Response: - headers = { - name: response_headers_serializer.serialize(value, name) - for name, value in response_headers.items() - } +def _to_response(request: Request, result: Any, method: ComponentMethod, headers: dict[str, str]) -> Response: if isinstance(result, Response): return Response(status_code=result.status_code, body=result.body, headers={**headers, **result.headers}) diff --git a/winter/web/response_header_annotation.py b/winter/web/response_header_annotation.py index 17cb2c96..0aeb1336 100644 --- a/winter/web/response_header_annotation.py +++ b/winter/web/response_header_annotation.py @@ -1,14 +1,20 @@ -from typing import Any +import datetime +import enum +from email.utils import format_datetime from typing import MutableMapping import dataclasses from winter.components import annotate_method +_VALUE_TYPES = (str, int, datetime.datetime) + class _ResponseHeaderMeta(type): def __getitem__(self, value_type): - assert isinstance(value_type, type), 'value_type must be a type' + if value_type not in _VALUE_TYPES: + name = value_type.__qualname__ if isinstance(value_type, type) else repr(value_type) + raise TypeError(f'{self.__name__}[{name}]: a response header is str, int or datetime') return _ResponseHeaderMeta.__new__( type(self), f'{self.__name__}[{value_type.__name__}]', @@ -40,9 +46,10 @@ def wrapper(func_or_method): class ResponseHeader(metaclass=_ResponseHeaderMeta): + """A header of the response: a str as is, an int as a number, a datetime as an HTTP-date in GMT.""" _value_type = str - def __init__(self, headers: MutableMapping[str, Any], header_name: str): + def __init__(self, headers: MutableMapping[str, str], header_name: str): self._headers = headers self._header_name = header_name.lower() @@ -50,5 +57,15 @@ def __repr__(self): return f'{self.__class__.__name__}({self._header_name!r})' def set(self, value): - # TODO: add value validation against self._value_type (https://github.com/mofr/winter/issues/136) - self._headers[self._header_name] = value + if isinstance(value, enum.Enum): + raise TypeError(f'{self._header_name}: an enum is set by its value, not {value!r}') + if isinstance(value, bool) or not isinstance(value, self._value_type): + raise TypeError(f'{self._header_name}: {value!r} is not {self._value_type.__name__}') + if isinstance(value, datetime.datetime): + if value.utcoffset() is None: + raise ValueError( + f'{self._header_name}: a datetime without a time zone has no HTTP-date, give it tzinfo', + ) + self._headers[self._header_name] = format_datetime(value.astimezone(datetime.timezone.utc), usegmt=True) + else: + self._headers[self._header_name] = str(value) diff --git a/winter/web/response_header_serializer.py b/winter/web/response_header_serializer.py deleted file mode 100644 index d129f1de..00000000 --- a/winter/web/response_header_serializer.py +++ /dev/null @@ -1,49 +0,0 @@ -import abc -from abc import abstractmethod -from typing import List -from typing import Optional -from typing import Type - - -class ResponseHeaderSerializer(abc.ABC): - """ResponseHeaderSerializer interface is used to convert different response header values to string.""" - - @abstractmethod - def is_supported(self, header_name: str, value_type: Optional[Type] = None) -> bool: # pragma: no cover - pass - - @abstractmethod - def serialize(self, value, header_name: str) -> str: # pragma: no cover - pass - - -class ResponseHeadersSerializer: - def __init__(self): - self._serializers: List[ResponseHeaderSerializer] = [] - - def add_serializer(self, serializer: ResponseHeaderSerializer): - self._serializers.append(serializer) - - def serialize(self, value, header_name: str) -> str: - serializer = self._get_serializer(header_name) - if serializer is not None: - return serializer.serialize(value, header_name) - - serializer = self._get_serializer(header_name, type(value)) - if serializer is not None: - return serializer.serialize(value, header_name) - - return str(value) - - def _get_serializer( - self, - header_name: str, - value_type: Optional[Type] = None, - ) -> Optional[ResponseHeaderSerializer]: - for serializer in self._serializers: - if serializer.is_supported(header_name, value_type): - return serializer - return None - - -response_headers_serializer = ResponseHeadersSerializer() diff --git a/winter/web/response_header_serializers.py b/winter/web/response_header_serializers.py deleted file mode 100644 index 81c59299..00000000 --- a/winter/web/response_header_serializers.py +++ /dev/null @@ -1,26 +0,0 @@ -import datetime -from typing import Optional -from typing import Type - -from .response_header_serializer import ResponseHeaderSerializer - - -class DateTimeResponseHeaderSerializer(ResponseHeaderSerializer): - def is_supported(self, header_name: str, value_type: Optional[Type] = None) -> bool: - return value_type == datetime.datetime - - def serialize(self, value, header_name: str) -> str: - assert isinstance(value, datetime.datetime) - return value.isoformat() - - -class LastModifiedResponseHeaderSerializer(ResponseHeaderSerializer): - HEADER_NAME = 'last-modified' - - def is_supported(self, header_name: str, value_type: Optional[Type] = None) -> bool: - return header_name == self.HEADER_NAME and (value_type is None or value_type == datetime.datetime) - - def serialize(self, value, header_name: str) -> str: - assert header_name == self.HEADER_NAME - assert isinstance(value, datetime.datetime) - return value.astimezone(datetime.timezone.utc).strftime('%a, %d %b %Y %X GMT')