diff --git a/README.md b/README.md index be3f76f5..c364d55a 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,12 @@ urlpatterns = winter.django.create_urls(app) - 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. +- Output processors are gone: `register_output_processor_resolver`, `IOutputProcessor`, `IOutputProcessorResolver`, + `register_output_processor` and `winter.django.output_template`. An API method returns data, encoded as JSON + (`winter.json.register_encoder` adds the encoder of a type), or a `winter.web.Response`, e.g. a rendered template: + `Response(HTTPStatus.OK, render_to_string('report.html', context).encode(), {'content-type': 'text/html'})`. +- The `meta` of a `Page` has no `previous` and `next` links any more, and `winter.web.pagination.utils` with + `get_previous_page_url` and `get_next_page_url` is gone. - The request pipeline takes the injector from the application. `winter.application.set_injector` is needed only by `winter.sqlalchemy`, `winter.ddd` and `winter.messaging`. - `winter.django.create_urls` installs the Django throttling identity on its first call: replace it with diff --git a/docs/architecture.md b/docs/architecture.md index 5313599a..e583a452 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, output processors, throttling configuration, OpenAPI inspectors) + handlers, interceptors, response header serializers, 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` (output processors, response header - serializers, native response types, throttling, OpenAPI inspectors) are module singletons, and + behavioural registries of `winter.web` and `winter.openapi` (response header serializers, + 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 output processors, serializers and inspectors on first use. In the + singletons with the standard serializers and 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.py b/tests/api/api_with_response.py index a3dcaf03..0db93702 100644 --- a/tests/api/api_with_response.py +++ b/tests/api/api_with_response.py @@ -5,20 +5,6 @@ import winter.web from winter.web import Request from winter.web import Response -from winter.web.output_processor import IOutputProcessor -from winter.web.output_processor import register_output_processor - - -class PlainTextProcessor(IOutputProcessor): - """Renders the method result as text/plain instead of JSON.""" - - def process_output(self, output: str, request: Request) -> Response: - return Response(status_code=HTTPStatus.OK, body=output.encode(), headers={'content-type': 'text/plain'}) - - -def plain_text(func): - """Same shape as winter.django.output_template, but with an adapter-independent processor.""" - return register_output_processor(func, PlainTextProcessor()) @winter.web.route('with-response/') @@ -28,11 +14,6 @@ class APIWithResponse: def raw(self) -> Response: return Response(status_code=HTTPStatus.ACCEPTED, body=b'raw bytes', headers={'content-type': 'text/plain'}) - @winter.web.route_get('greeting/') - @plain_text - def greeting(self) -> str: - return 'Hello, Winter' - @winter.web.route_get('request-info/') def request_info(self, request: Request, django_request: HttpRequest) -> dict: return { diff --git a/tests/api/django/__init__.py b/tests/api/django/__init__.py deleted file mode 100644 index fb4dec65..00000000 --- a/tests/api/django/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .api_with_output_template import APIWithOutputTemplate diff --git a/tests/api/django/api_with_output_template.py b/tests/api/django/api_with_output_template.py deleted file mode 100644 index a8dd39af..00000000 --- a/tests/api/django/api_with_output_template.py +++ /dev/null @@ -1,14 +0,0 @@ -from typing import Any -from typing import Dict - -import winter.web -import winter.django - - -@winter.web.route('with-output-template/') -class APIWithOutputTemplate: - - @winter.web.route_get('{?name}') - @winter.django.output_template('hello.txt') - def greet(self, name: str) -> Dict[str, Any]: - return {'name': name} diff --git a/tests/conftest.py b/tests/conftest.py index 5458a845..df626d11 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,5 +1,3 @@ -from pathlib import Path - import django import pytest from injector import CallableProvider @@ -14,13 +12,11 @@ def pytest_configure(): from django.conf import settings - app_dir = Path(__file__) settings.configure( ROOT_URLCONF='tests.urls', TEMPLATES=[ { 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [app_dir.parent.name + '/templates'], 'APP_DIRS': True, 'OPTIONS': { 'debug': True, # We want template errors to raise diff --git a/tests/django/test_api_with_output_template.py b/tests/django/test_api_with_output_template.py deleted file mode 100644 index 74e9509c..00000000 --- a/tests/django/test_api_with_output_template.py +++ /dev/null @@ -1,8 +0,0 @@ -from http import HTTPStatus - - -def test_api_with_output_template(api_client): - response = api_client.get('/with-output-template/?name=John') - - assert response.status_code == HTTPStatus.OK - assert response.content == b'Hello, John!' diff --git a/tests/openapi/test_api_request_and_response_spec.py b/tests/openapi/test_api_request_and_response_spec.py index 07dff3df..431a8142 100644 --- a/tests/openapi/test_api_request_and_response_spec.py +++ b/tests/openapi/test_api_request_and_response_spec.py @@ -349,11 +349,9 @@ class RequestBodyWithUndefined: 'properties': { 'limit': {'format': 'int32', 'nullable': True, 'type': 'integer'}, 'offset': {'format': 'int32', 'nullable': True, 'type': 'integer'}, - 'next': {'nullable': True, 'type': 'string'}, - 'previous': {'nullable': True, 'type': 'string'}, 'total_count': {'format': 'int32', 'type': 'integer'}, }, - 'required': ['total_count', 'limit', 'offset', 'previous', 'next'], + 'required': ['total_count', 'limit', 'offset'], 'title': 'PageMetaOfNestedDataclass', 'type': 'object', }, @@ -399,10 +397,8 @@ class RequestBodyWithUndefined: 'extra': {'type': 'string'}, 'limit': {'format': 'int32', 'nullable': True, 'type': 'integer'}, 'offset': {'format': 'int32', 'nullable': True, 'type': 'integer'}, - 'next': {'nullable': True, 'type': 'string'}, - 'previous': {'nullable': True, 'type': 'string'}, 'total_count': {'format': 'int32', 'type': 'integer'}}, - 'required': ['total_count', 'limit', 'offset', 'previous', 'next', 'extra'], + 'required': ['total_count', 'limit', 'offset', 'extra'], 'title': 'PageMetaOfInteger', 'type': 'object', }, @@ -1008,12 +1004,10 @@ def method_2(self) -> Page[str]: 'PageMetaOfString': { 'properties': { 'limit': {'format': 'int32', 'nullable': True, 'type': 'integer'}, - 'next': {'nullable': True, 'type': 'string'}, 'offset': {'format': 'int32', 'nullable': True, 'type': 'integer'}, - 'previous': {'nullable': True, 'type': 'string'}, 'total_count': {'format': 'int32', 'type': 'integer'}, }, - 'required': ['total_count', 'limit', 'offset', 'previous', 'next'], + 'required': ['total_count', 'limit', 'offset'], 'title': 'PageMetaOfString', 'type': 'object'}, 'PageOfString': { diff --git a/tests/templates/hello.txt b/tests/templates/hello.txt deleted file mode 100644 index 272c22e1..00000000 --- a/tests/templates/hello.txt +++ /dev/null @@ -1 +0,0 @@ -Hello, {{ name }}! \ No newline at end of file diff --git a/tests/web/test_response.py b/tests/web/test_response.py index 6c550c5d..4c6c5a7f 100644 --- a/tests/web/test_response.py +++ b/tests/web/test_response.py @@ -9,14 +9,6 @@ def test_raw_response_bypasses_json_encoding(api_client): assert response.headers['content-type'] == 'text/plain' -def test_output_processor_may_return_a_response(api_client): - response = api_client.get('/with-response/greeting/') - - assert response.status_code == HTTPStatus.OK - assert response.content == b'Hello, Winter' - assert response.headers['content-type'] == 'text/plain' - - def test_request_describes_the_http_request(api_client): response = api_client.get('/with-response/request-info/?x=1') diff --git a/tests/web/test_simple_api.py b/tests/web/test_simple_api.py index 3af2105d..364692a6 100644 --- a/tests/web/test_simple_api.py +++ b/tests/web/test_simple_api.py @@ -22,13 +22,11 @@ def test_get_response_entity(api_client): } -@pytest.mark.parametrize(('limit', 'offset', 'expected_previous', 'expected_next'), ( - (1, 1, '/winter-simple/page-response/?limit=1', '/winter-simple/page-response/?limit=1&offset=2'), - (None, None, None, None), - (2, 3, '/winter-simple/page-response/?limit=2&offset=1', '/winter-simple/page-response/?limit=2&offset=5'), - (9, 3, '/winter-simple/page-response/?limit=9', None), +@pytest.mark.parametrize(('limit', 'offset'), ( + (1, 1), + (None, None), )) -def test_page_response(api_client, limit, offset, expected_previous, expected_next): +def test_page_response(api_client, limit, offset): url = f'/winter-simple/page-response/?' query_params = [] if limit is not None: @@ -42,8 +40,6 @@ def test_page_response(api_client, limit, offset, expected_previous, expected_ne 'meta': { 'limit': limit, 'offset': offset, - 'next': expected_next, - 'previous': expected_previous, 'total_count': 10, }, } @@ -62,8 +58,6 @@ def test_custom_page_response(api_client): 'meta': { 'limit': 2, 'offset': 2, - 'next': '/winter-simple/custom-page-response/?limit=2&offset=4', - 'previous': '/winter-simple/custom-page-response/?limit=2', 'total_count': 10, 'extra': 456, }, diff --git a/winter/django/__init__.py b/winter/django/__init__.py index ef195470..325a6acd 100644 --- a/winter/django/__init__.py +++ b/winter/django/__init__.py @@ -1,5 +1,4 @@ from . import json_encoders # noqa: F401 registers the Django lazy string encoder -from .output_template import output_template from .request import DjangoRequest from .reverse import reverse from .throttling import django_user_identity diff --git a/winter/django/output_template.py b/winter/django/output_template.py deleted file mode 100644 index 6aa828d3..00000000 --- a/winter/django/output_template.py +++ /dev/null @@ -1,24 +0,0 @@ -from django.shortcuts import render - -from winter.web import Request -from winter.web.output_processor import IOutputProcessor -from winter.web.output_processor import register_output_processor -from .request import DjangoRequest - - -class TemplateRenderer(IOutputProcessor): - - def __init__(self, template_name: str): - self._template_name = template_name - - def process_output(self, output, request: Request): - assert isinstance(request, DjangoRequest), 'output_template works with the Django adapter only' - return render(request.django_request, self._template_name, context=output) - - -def output_template(template_name: str): - def wrapper(func): - output_processor = TemplateRenderer(template_name) - return register_output_processor(func, output_processor) - - return wrapper diff --git a/winter/json/__init__.py b/winter/json/__init__.py index c392cdcf..12bd52fc 100644 --- a/winter/json/__init__.py +++ b/winter/json/__init__.py @@ -2,4 +2,5 @@ from .decoder import json_decode from .decoder import json_decoder from .encoder import JSONEncoder +from .encoder import register_encoder from .undefined import Undefined diff --git a/winter/openapi/inspectors/page_inspector.py b/winter/openapi/inspectors/page_inspector.py index b03313f0..2f092efe 100644 --- a/winter/openapi/inspectors/page_inspector.py +++ b/winter/openapi/inspectors/page_inspector.py @@ -26,8 +26,6 @@ def create_dataclass(page_type: Type) -> Type: 'total_count': int, 'limit': Optional[int], 'offset': Optional[int], - 'previous': Optional[str], - 'next': Optional[str], **{extra_field.name: extra_field.type for extra_field in extra_fields}, }, }, diff --git a/winter/web/__init__.py b/winter/web/__init__.py index 3af32435..2f5ba80d 100644 --- a/winter/web/__init__.py +++ b/winter/web/__init__.py @@ -6,7 +6,6 @@ from .interceptor import Interceptor from .media_type import InvalidMediaTypeException from .media_type import MediaType -from .output_processor import register_output_processor_resolver from .query_parameters import map_query_parameter from .query_parameters import query_parameters from .request_body_annotation import request_body diff --git a/winter/web/initialization.py b/winter/web/initialization.py index 82e4c142..5f1e6cf5 100644 --- a/winter/web/initialization.py +++ b/winter/web/initialization.py @@ -1,5 +1,3 @@ -from .output_processor import register_output_processor_resolver -from .pagination.page_processor_resolver import PageOutputProcessorResolver from .response_header_serializer import response_headers_serializer from .response_header_serializers import DateTimeResponseHeaderSerializer from .response_header_serializers import LastModifiedResponseHeaderSerializer @@ -9,7 +7,7 @@ def initialize(): """ - Registers the standard output processors and response header serializers of winter.web on the first call. + 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. """ @@ -20,6 +18,5 @@ def initialize(): def _initialize_process(): - register_output_processor_resolver(PageOutputProcessorResolver()) response_headers_serializer.add_serializer(DateTimeResponseHeaderSerializer()) response_headers_serializer.add_serializer(LastModifiedResponseHeaderSerializer()) diff --git a/winter/web/output_processor.py b/winter/web/output_processor.py deleted file mode 100644 index 610fe27f..00000000 --- a/winter/web/output_processor.py +++ /dev/null @@ -1,61 +0,0 @@ -import abc -import dataclasses -from typing import Any -from typing import Callable -from typing import List -from typing import Optional - - -from winter.web.request import Request -from winter.components import ComponentMethod -from winter.components import annotate - - -class IOutputProcessor(abc.ABC): - """Process API method returned value so that it can be put to HttpResponse body. - Common usage is to serializer some DTO to dict.""" - - @abc.abstractmethod - def process_output(self, output, request: Request): # pragma: no cover - return output - - -@dataclasses.dataclass -class OutputProcessorAnnotation: - output_processor: IOutputProcessor - - -class IOutputProcessorResolver(abc.ABC): - """ - Resolves IOutputProcessor for a given body type. - Due to python dynamic typing it's called after every API request. - """ - - @abc.abstractmethod - def is_supported(self, body: Any) -> bool: # pragma: no cover - return False - - @abc.abstractmethod - def get_processor(self, body: Any) -> IOutputProcessor: # pragma: no cover - pass - - -_registered_resolvers: List[IOutputProcessorResolver] = [] - - -def register_output_processor(method: Callable, output_processor: IOutputProcessor): - return annotate(OutputProcessorAnnotation(output_processor), single=True)(method) - - -def register_output_processor_resolver(output_processor_resolver: IOutputProcessorResolver): - _registered_resolvers.append(output_processor_resolver) - - -def get_output_processor(method: ComponentMethod, body: Any) -> Optional[IOutputProcessor]: - output_processor_annotation = method.annotations.get_one_or_none(OutputProcessorAnnotation) - if output_processor_annotation is not None: - return output_processor_annotation.output_processor - for resolver in _registered_resolvers: - if resolver.is_supported(body): - return resolver.get_processor(body) - return None diff --git a/winter/web/pagination/__init__.py b/winter/web/pagination/__init__.py index ae67e8e4..59d5b4ef 100644 --- a/winter/web/pagination/__init__.py +++ b/winter/web/pagination/__init__.py @@ -1,2 +1,3 @@ +from . import page_encoder # noqa: F401 registers the JSON encoder of Page from .limits import limits from .order_by import order_by diff --git a/winter/web/pagination/page_encoder.py b/winter/web/pagination/page_encoder.py new file mode 100644 index 00000000..d62aa973 --- /dev/null +++ b/winter/web/pagination/page_encoder.py @@ -0,0 +1,24 @@ +import dataclasses + +from winter.json import register_encoder +from winter.pagination import Page + +_PAGE_FIELDS = dataclasses.fields(Page) + + +@register_encoder +def page_encoder(page: Page) -> dict: + """The fields that a subclass of Page adds go to the meta.""" + return { + 'meta': { + 'total_count': page.total_count, + 'limit': page.position.limit, + 'offset': page.position.offset, + **{ + field.name: getattr(page, field.name) + for field in dataclasses.fields(page) + if field not in _PAGE_FIELDS + }, + }, + 'objects': page.items, + } diff --git a/winter/web/pagination/page_processor.py b/winter/web/pagination/page_processor.py deleted file mode 100644 index 57cedcbb..00000000 --- a/winter/web/pagination/page_processor.py +++ /dev/null @@ -1,29 +0,0 @@ -import dataclasses -from typing import Dict - - -from winter.web.request import Request -from winter.pagination import Page -from winter.web.output_processor import IOutputProcessor -from .utils import get_next_page_url -from .utils import get_previous_page_url - - -class PageProcessor(IOutputProcessor): - - def process_output(self, output: Page, request: Request) -> Dict: - extra_fields = set(dataclasses.fields(output)) - set(dataclasses.fields(Page)) - return { - 'meta': { - 'total_count': output.total_count, - 'limit': output.position.limit, - 'offset': output.position.offset, - 'previous': get_previous_page_url(output, request), - 'next': get_next_page_url(output, request), - **{ - extra_field.name: getattr(output, extra_field.name) - for extra_field in extra_fields - }, - }, - 'objects': output.items, - } diff --git a/winter/web/pagination/page_processor_resolver.py b/winter/web/pagination/page_processor_resolver.py deleted file mode 100644 index 1df767de..00000000 --- a/winter/web/pagination/page_processor_resolver.py +++ /dev/null @@ -1,17 +0,0 @@ -from typing import Any - -from winter.pagination import Page -from winter.web.output_processor import IOutputProcessorResolver -from .page_processor import PageProcessor - - -class PageOutputProcessorResolver(IOutputProcessorResolver): - - def __init__(self): - self._page_processor = PageProcessor() - - def is_supported(self, body: Any) -> bool: - return isinstance(body, Page) - - def get_processor(self, body: Any) -> PageProcessor: - return self._page_processor diff --git a/winter/web/pagination/utils.py b/winter/web/pagination/utils.py deleted file mode 100644 index e9554096..00000000 --- a/winter/web/pagination/utils.py +++ /dev/null @@ -1,47 +0,0 @@ -from typing import Optional - -from furl import furl - -from winter.web.request import Request -from winter.pagination import Page - - -def get_previous_page_url(page: Page, request: Request) -> Optional[str]: - offset = page.position.offset - limit = page.position.limit - - if not offset or limit is None: - return None - - url = furl(request.full_path) - url.query.set([('limit', limit)]) - - previous_offset = offset - limit - - if previous_offset <= 0: - url.query.remove('offset') - else: - url.query.set([('offset', previous_offset)]) - - return url.tostr() - - -def get_next_page_url(page: Page, request: Request) -> Optional[str]: - offset = page.position.offset or 0 - limit = page.position.limit - total = page.total_count - - if limit is None: - return None - - next_offset = offset + limit - - if next_offset >= total: - return None - - url = furl(request.full_path) - url.query.set([ - ('limit', limit), - ('offset', next_offset), - ]) - return url.tostr() diff --git a/winter/web/request_handler.py b/winter/web/request_handler.py index c06d7ba0..0f38fd8b 100644 --- a/winter/web/request_handler.py +++ b/winter/web/request_handler.py @@ -13,7 +13,6 @@ from .exceptions import ExceptionHandlers from .exceptions import ThrottleException from .exceptions.handlers import exception_argument_name -from .output_processor import get_output_processor from .pagination.page_position_argument_resolver import PagePositionArgumentResolver from .path_parameters_argument_resolver import PathParametersArgumentResolver from .query_parameters.query_parameter_argument_resolver import QueryParameterArgumentResolver @@ -71,7 +70,7 @@ def handle_request( ) -> Response: """ Run the whole handling pipeline for a matched route of the application: throttling, its interceptors, argument - resolution, the API method, its exception handlers, output processing and JSON encoding. + resolution, the API method, its exception handlers and JSON encoding. Framework-agnostic: adapters build the Request from their own request object and send the returned Response. The request is handled in an injector of its own, a child of the application's one, that binds the Request and the request values of the adapter, such as its own request object. Request-scoped values live in it. @@ -134,12 +133,6 @@ def _to_response(request: Request, result: Any, method: ComponentMethod, respons body = result status_code = get_response_status(request.method, method) - output_processor = get_output_processor(method, body) - if output_processor is not None: - body = output_processor.process_output(body, request) - if isinstance(body, Response): - return Response(status_code=body.status_code, body=body.body, headers={**headers, **body.headers}) - if _is_native(body): return Response(status_code=status_code, body=body, headers=headers) @@ -153,8 +146,8 @@ def _to_response(request: Request, result: Any, method: ComponentMethod, respons def register_native_response_type(type_: type): """ - Adapters register the response types of their framework here. An API method or an output processor - may return such an object, and it reaches the adapter untouched. + Adapters register the response types of their framework here. An API method may return such an object, + and it reaches the adapter untouched. """ global _native_response_types _native_response_types = _native_response_types + (type_,)