Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
12 changes: 6 additions & 6 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down
22 changes: 4 additions & 18 deletions tests/api/api_with_response_headers.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,4 @@
import datetime
from uuid import UUID

import pytz

import winter.web
from winter.web import ResponseHeader
Expand All @@ -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')
Expand Down
92 changes: 63 additions & 29 deletions tests/web/test_response_header.py
Original file line number Diff line number Diff line change
@@ -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):
Expand Down
2 changes: 0 additions & 2 deletions winter/django/view.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down
1 change: 0 additions & 1 deletion winter/web/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion winter/web/exceptions/problem_handling.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
22 changes: 0 additions & 22 deletions winter/web/initialization.py

This file was deleted.

11 changes: 3 additions & 8 deletions winter/web/request_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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}
Expand All @@ -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})

Expand Down
27 changes: 22 additions & 5 deletions winter/web/response_header_annotation.py
Original file line number Diff line number Diff line change
@@ -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__}]',
Expand Down Expand Up @@ -40,15 +46,26 @@ 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()

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)
Loading
Loading