├── samples ├── __init__.py └── sample_winter_api.py ├── tests ├── messaging │ ├── __init__.py │ └── events.py ├── routing │ ├── __init__.py │ ├── test_reverse.py │ └── test_path_parameters.py ├── api │ ├── notes_api │ │ ├── __init__.py │ │ ├── api_2.py │ │ └── api_1.py │ ├── package │ │ ├── subpackage │ │ │ ├── __init__.py │ │ │ ├── api_3.py │ │ │ └── api_4.py │ │ ├── api_1.py │ │ └── api_2.py │ ├── django │ │ ├── __init__.py │ │ └── api_with_output_template.py │ ├── api_with_media_types_routing.py │ ├── api_with_requested_headers.py │ ├── __init__.py │ ├── swagger_ui.py │ ├── api_with_path_parameters.py │ ├── api_with_pagination.py │ ├── api_with_request_data.py │ ├── api_with_throttling.py │ ├── api_with_query_parameters.py │ ├── api_with_response_headers.py │ ├── simple_api.py │ └── api_with_exceptions.py ├── templates │ └── hello.txt ├── winter_sqlalchemy │ ├── test_injector.py │ ├── test_sqla_sort.py │ └── test_paginate.py ├── winter_ddd │ ├── test_domain_event_dispatcher_fixture │ │ ├── __init__.py │ │ ├── subpackage │ │ │ ├── __init__.py │ │ │ └── handler2.py │ │ ├── events.py │ │ └── handler1.py │ ├── test_aggregate_root.py │ ├── test_domain_event_dispatcher.py │ └── test_domain_events.py ├── __init__.py ├── core │ ├── test_injection.py │ ├── json │ │ └── test_undefined.py │ ├── test_application.py │ ├── test_component_method_argument.py │ ├── test_module_discovery.py │ ├── test_component_method.py │ └── test_component.py ├── urls.py ├── user.py ├── utils.py ├── web │ ├── test_autodiscovery.py │ ├── test_interceptors.py │ ├── interceptors.py │ ├── test_response_entity.py │ ├── test_response_status.py │ ├── test_urls.py │ ├── test_request_header.py │ ├── test_media_type.py │ └── test_response_header.py ├── winter_django │ ├── test_api_with_output_template.py │ └── test_view.py ├── test_version.py ├── data │ ├── test_repository.py │ └── pagination │ │ ├── test_page.py │ │ └── test_sort.py ├── winter_openapi │ ├── inspectors │ │ └── test_route_parameter_inspector.py │ ├── test_swagger_ui.py │ ├── test_add_url_segment_as_tag.py │ ├── test_validators.py │ ├── test_metadata_spec.py │ ├── test_generator.py │ └── test_api_with_media_types_spec.py ├── test_api_with_media_types_routing.py ├── middleware.py ├── apps.py ├── conftest.py └── test_argument_resolver.py ├── winter_ddd ├── domain_event.py ├── domain_event_handler.py ├── __init__.py ├── aggregate_root.py ├── domain_events.py ├── domain_event_subscription.py └── domain_event_dispatcher.py ├── MANIFEST.in ├── winter ├── data │ ├── __init__.py │ ├── pagination │ │ ├── __init__.py │ │ ├── page_position.py │ │ ├── page.py │ │ └── sort.py │ ├── exceptions.py │ └── repository.py ├── web │ ├── throttling │ │ ├── exceptions.py │ │ ├── __init__.py │ │ ├── redis_throttling_configuration.py │ │ ├── redis_throttling_client.py │ │ └── throttling.py │ ├── pagination │ │ ├── __init__.py │ │ ├── order_by_annotation.py │ │ ├── parse_sort.py │ │ ├── page_processor_resolver.py │ │ ├── parse_order.py │ │ ├── check_sort.py │ │ ├── order_by.py │ │ ├── page_processor.py │ │ ├── utils.py │ │ └── limits.py │ ├── query_parameters │ │ ├── query_parameter.py │ │ ├── __init__.py │ │ ├── map_query_parameter.py │ │ ├── map_query_parameter_annotation.py │ │ ├── query_parameters_annotation.py │ │ ├── query_parameters_argument_resolver.py │ │ └── query_parameter_argument_resolver.py │ ├── exceptions │ │ ├── problem_annotation.py │ │ ├── problem_handling_info.py │ │ ├── exception_mapper.py │ │ ├── exception_handler_generator.py │ │ ├── __init__.py │ │ ├── problem.py │ │ ├── exceptions.py │ │ └── raises.py │ ├── routing │ │ ├── reverse.py │ │ ├── __init__.py │ │ ├── route_annotation.py │ │ ├── route.py │ │ └── routing.py │ ├── response_entity.py │ ├── response_status_annotation.py │ ├── interceptor.py │ ├── exception_handlers.py │ ├── request_header_annotation.py │ ├── autodiscovery.py │ ├── default_response_status.py │ ├── request_body_annotation.py │ ├── response_header_serializers.py │ ├── response_header_resolver.py │ ├── path_parameters_argument_resolver.py │ ├── request_body_resolver.py │ ├── response_header_serializer.py │ ├── response_header_annotation.py │ ├── request_header_resolver.py │ ├── urls.py │ ├── output_processor.py │ └── argument_resolver.py ├── messaging │ ├── event.py │ ├── event_handler.py │ ├── __init__.py │ ├── event_publisher.py │ ├── simple_event_publisher.py │ ├── event_subscription.py │ ├── event_subscription_registry.py │ └── event_dispacher.py ├── core │ ├── utils │ │ ├── __init__.py │ │ ├── beautify_string.py │ │ ├── positive_integer.py │ │ ├── nested_types.py │ │ └── typing.py │ ├── json │ │ ├── __init__.py │ │ └── undefined.py │ ├── injection.py │ ├── __init__.py │ ├── application.py │ ├── docstring.py │ ├── component_method_argument.py │ ├── module_discovery.py │ ├── annotation_decorator.py │ ├── component.py │ ├── annotations.py │ └── component_method.py └── __init__.py ├── .flake8 ├── winter_sqlalchemy ├── __init__.py └── query.py ├── winter_openapi ├── annotations │ ├── __init__.py │ └── global_exception.py ├── inspection │ ├── __init__.py │ ├── data_types.py │ ├── data_formats.py │ ├── type_info.py │ └── inspection.py ├── inspectors │ ├── __init__.py │ ├── route_parameters_inspector.py │ ├── path_parameters_inspector.py │ └── page_inspector.py ├── validators.py ├── __init__.py └── swagger_ui.py ├── winter_django ├── __init__.py ├── http_request_argument_resolver.py └── output_template.py ├── .github ├── workflows │ ├── required-checks.yml │ ├── upload-package.yml │ └── testing.yml └── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md ├── .pre-commit-config.yaml ├── CONTRIBUTING.md ├── .coveragerc ├── LICENSE ├── .gitignore └── pyproject.toml /samples/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/messaging/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/routing/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/api/notes_api/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/api/package/subpackage/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/templates/hello.txt: -------------------------------------------------------------------------------- 1 | Hello, {{ name }}! -------------------------------------------------------------------------------- /tests/winter_sqlalchemy/test_injector.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /winter_ddd/domain_event.py: -------------------------------------------------------------------------------- 1 | class DomainEvent: 2 | pass 3 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | recursive-include requirements * 3 | -------------------------------------------------------------------------------- /tests/winter_ddd/test_domain_event_dispatcher_fixture/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /winter/data/__init__.py: -------------------------------------------------------------------------------- 1 | from .repository import CRUDRepository 2 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | default_app_config = 'tests.apps.TestAppConfig' 2 | -------------------------------------------------------------------------------- /tests/winter_ddd/test_domain_event_dispatcher_fixture/subpackage/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/api/django/__init__.py: -------------------------------------------------------------------------------- 1 | from .api_with_output_template import APIWithOutputTemplate 2 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 120 3 | per-file-ignores = 4 | __init__.py:F401 5 | -------------------------------------------------------------------------------- /winter/web/throttling/exceptions.py: -------------------------------------------------------------------------------- 1 | class ThrottlingMisconfigurationException(Exception): 2 | pass 3 | -------------------------------------------------------------------------------- /winter_sqlalchemy/__init__.py: -------------------------------------------------------------------------------- 1 | from .query import paginate 2 | from .query import sort 3 | from .repository import sqla_crud 4 | -------------------------------------------------------------------------------- /winter/messaging/event.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | 4 | @dataclass(frozen=True) 5 | class Event: 6 | pass 7 | -------------------------------------------------------------------------------- /tests/core/test_injection.py: -------------------------------------------------------------------------------- 1 | from winter.core import get_injector 2 | 3 | 4 | def test_get_injector(): 5 | injector = get_injector() 6 | assert injector is not None 7 | -------------------------------------------------------------------------------- /winter/web/pagination/__init__.py: -------------------------------------------------------------------------------- 1 | from .limits import limits 2 | from .order_by import order_by 3 | from .page_position_argument_resolver import PagePositionArgumentResolver 4 | -------------------------------------------------------------------------------- /winter/web/query_parameters/query_parameter.py: -------------------------------------------------------------------------------- 1 | import dataclasses 2 | 3 | 4 | @dataclasses.dataclass 5 | class QueryParameter: 6 | name: str 7 | map_to: str 8 | explode: bool 9 | -------------------------------------------------------------------------------- /winter/data/pagination/__init__.py: -------------------------------------------------------------------------------- 1 | from .page import Page 2 | from .page_position import PagePosition 3 | from .sort import Order 4 | from .sort import Sort 5 | from .sort import SortDirection 6 | -------------------------------------------------------------------------------- /tests/api/package/api_1.py: -------------------------------------------------------------------------------- 1 | import winter 2 | 3 | 4 | @winter.route('api_1/') 5 | class API1: 6 | @winter.route_get('') 7 | def method_1(self) -> int: # pragma: no cover 8 | return 1 9 | -------------------------------------------------------------------------------- /winter_openapi/annotations/__init__.py: -------------------------------------------------------------------------------- 1 | from .global_exception import GlobalExceptionAnnotation 2 | from .global_exception import global_exception 3 | from .global_exception import register_global_exception 4 | -------------------------------------------------------------------------------- /tests/api/package/subpackage/api_3.py: -------------------------------------------------------------------------------- 1 | import winter 2 | 3 | 4 | @winter.route('api_3/') 5 | class API3: 6 | @winter.route_get('') 7 | def method_4(self) -> int: # pragma: no cover 8 | return 4 9 | -------------------------------------------------------------------------------- /winter/core/utils/__init__.py: -------------------------------------------------------------------------------- 1 | from .beautify_string import camel_to_human 2 | from .nested_types import TypeWrapper 3 | from .nested_types import has_nested_type 4 | from .positive_integer import PositiveInteger 5 | -------------------------------------------------------------------------------- /winter/core/json/__init__.py: -------------------------------------------------------------------------------- 1 | from .decoder import JSONDecodeException 2 | from .decoder import json_decode 3 | from .decoder import json_decoder 4 | from .encoder import JSONEncoder 5 | from .undefined import Undefined 6 | -------------------------------------------------------------------------------- /winter/core/utils/beautify_string.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | pattern = re.compile(r'(? str: 7 | return pattern.sub(separator, value).lower() 8 | -------------------------------------------------------------------------------- /winter_ddd/domain_event_handler.py: -------------------------------------------------------------------------------- 1 | from winter.core import annotate 2 | 3 | 4 | class DomainEventHandlerAnnotation: 5 | pass 6 | 7 | 8 | domain_event_handler = annotate(DomainEventHandlerAnnotation(), single=True) 9 | -------------------------------------------------------------------------------- /tests/urls.py: -------------------------------------------------------------------------------- 1 | from winter.web import find_package_routes 2 | from winter_django import create_django_urls_from_routes 3 | 4 | routes = find_package_routes('tests.api') 5 | urlpatterns = create_django_urls_from_routes(routes) 6 | -------------------------------------------------------------------------------- /tests/winter_ddd/test_domain_event_dispatcher_fixture/events.py: -------------------------------------------------------------------------------- 1 | from winter_ddd import DomainEvent 2 | 3 | 4 | class DomainEvent1(DomainEvent): 5 | pass 6 | 7 | 8 | class DomainEvent2(DomainEvent): 9 | pass 10 | -------------------------------------------------------------------------------- /winter/messaging/event_handler.py: -------------------------------------------------------------------------------- 1 | from winter.core import annotate 2 | 3 | 4 | class EventHandlerAnnotation: 5 | pass 6 | 7 | 8 | annotation = EventHandlerAnnotation() 9 | event_handler = annotate(annotation, single=True) 10 | -------------------------------------------------------------------------------- /tests/user.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | 4 | class User: 5 | def __init__(self, pk=None): 6 | self.pk = pk if pk is not None else uuid.uuid4() 7 | 8 | @property 9 | def is_authenticated(self): 10 | return True 11 | -------------------------------------------------------------------------------- /winter_openapi/inspection/__init__.py: -------------------------------------------------------------------------------- 1 | from .data_formats import DataFormat 2 | from .data_types import DataTypes 3 | from .inspection import inspect_type 4 | from .inspection import register_type_inspector 5 | from .type_info import TypeInfo 6 | -------------------------------------------------------------------------------- /winter/web/exceptions/problem_annotation.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | from .problem_handling_info import ProblemHandlingInfo 4 | 5 | 6 | @dataclass(frozen=True) 7 | class ProblemAnnotation: 8 | handling_info: ProblemHandlingInfo 9 | -------------------------------------------------------------------------------- /tests/api/package/subpackage/api_4.py: -------------------------------------------------------------------------------- 1 | from http import HTTPStatus 2 | 3 | import winter 4 | 5 | 6 | @winter.route('api_4/') 7 | class API4: 8 | @winter.response_status(HTTPStatus.OK) 9 | def no_route(self): # pragma: no cover 10 | pass 11 | -------------------------------------------------------------------------------- /tests/utils.py: -------------------------------------------------------------------------------- 1 | from django.http import HttpRequest 2 | from django.http import QueryDict 3 | 4 | 5 | def get_request(query_string=''): 6 | django_request = HttpRequest() 7 | django_request.GET = QueryDict(query_string, mutable=True) 8 | return django_request 9 | -------------------------------------------------------------------------------- /winter_ddd/__init__.py: -------------------------------------------------------------------------------- 1 | from .aggregate_root import AggregateRoot 2 | from .domain_event import DomainEvent 3 | from .domain_event_dispatcher import DomainEventDispatcher 4 | from .domain_event_handler import domain_event_handler 5 | from .domain_events import DomainEvents 6 | -------------------------------------------------------------------------------- /winter/messaging/__init__.py: -------------------------------------------------------------------------------- 1 | from .event import Event 2 | from .event_handler import event_handler 3 | from .event_subscription_registry import EventSubscriptionRegistry 4 | from .event_publisher import EventPublisher 5 | from .simple_event_publisher import SimpleEventPublisher 6 | -------------------------------------------------------------------------------- /winter/web/query_parameters/__init__.py: -------------------------------------------------------------------------------- 1 | from .map_query_parameter import map_query_parameter 2 | from .map_query_parameter_annotation import MapQueryParameterAnnotation 3 | from .query_parameter import QueryParameter 4 | from .query_parameters_annotation import query_parameters 5 | -------------------------------------------------------------------------------- /tests/web/test_autodiscovery.py: -------------------------------------------------------------------------------- 1 | import winter 2 | 3 | 4 | def test_find_package_routes(): 5 | # Act 6 | routes = winter.web.find_package_routes('tests.api.package') 7 | 8 | # Assert 9 | assert [route.url_path for route in routes] == ['api_1/', 'api_2/', 'api_2/', 'api_3/'] 10 | -------------------------------------------------------------------------------- /winter/core/json/undefined.py: -------------------------------------------------------------------------------- 1 | class Undefined: 2 | """A class to represent an absence of value.""" 3 | def __eq__(self, other): 4 | return isinstance(other, Undefined) 5 | 6 | def __hash__(self): 7 | return 0 8 | 9 | def __repr__(self): 10 | return 'Undefined' 11 | -------------------------------------------------------------------------------- /winter/web/routing/reverse.py: -------------------------------------------------------------------------------- 1 | from types import MappingProxyType 2 | 3 | from django import urls 4 | 5 | from winter.core import ComponentMethod 6 | 7 | 8 | def reverse(method: ComponentMethod, args=(), kwargs=MappingProxyType({})): 9 | return urls.reverse(method.full_name, args=args, kwargs=kwargs) 10 | -------------------------------------------------------------------------------- /tests/winter_django/test_api_with_output_template.py: -------------------------------------------------------------------------------- 1 | from http import HTTPStatus 2 | 3 | 4 | def test_api_with_output_template(api_client): 5 | response = api_client.get('/with-output-template/?name=John') 6 | 7 | assert response.status_code == HTTPStatus.OK 8 | assert response.content == b'Hello, John!' 9 | -------------------------------------------------------------------------------- /tests/test_version.py: -------------------------------------------------------------------------------- 1 | import importlib 2 | 3 | from semver import parse 4 | 5 | 6 | def test_version_is_a_valid_semver(): 7 | # Act 8 | version = parse(importlib.metadata.version('winter')) 9 | 10 | # Assert 11 | assert version['major'] > 0 # In fact we test that parse doesn't fail with ValueError 12 | -------------------------------------------------------------------------------- /winter/web/exceptions/problem_handling_info.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from dataclasses import dataclass 4 | 5 | 6 | @dataclass(frozen=True) 7 | class ProblemHandlingInfo: 8 | status: int 9 | title: Optional[str] = None 10 | detail: Optional[str] = None 11 | type: Optional[str] = None 12 | -------------------------------------------------------------------------------- /winter_openapi/inspection/data_types.py: -------------------------------------------------------------------------------- 1 | from strenum import StrEnum 2 | 3 | 4 | class DataTypes(StrEnum): 5 | OBJECT = "object" 6 | STRING = "string" 7 | NUMBER = "number" 8 | INTEGER = "integer" 9 | BOOLEAN = "boolean" 10 | ARRAY = "array" 11 | FILE = "file" 12 | ANY = 'AnyValue' 13 | -------------------------------------------------------------------------------- /winter/web/pagination/order_by_annotation.py: -------------------------------------------------------------------------------- 1 | from typing import FrozenSet 2 | from typing import Optional 3 | 4 | import dataclasses 5 | 6 | from winter.data.pagination import Sort 7 | 8 | 9 | @dataclasses.dataclass 10 | class OrderByAnnotation: 11 | allowed_fields: FrozenSet[str] 12 | default_sort: Optional[Sort] = None 13 | -------------------------------------------------------------------------------- /winter/web/query_parameters/map_query_parameter.py: -------------------------------------------------------------------------------- 1 | from winter.core import annotate_method 2 | from .map_query_parameter_annotation import MapQueryParameterAnnotation 3 | 4 | 5 | def map_query_parameter(name: str, *, to: str): 6 | annotation = MapQueryParameterAnnotation(name, to) 7 | return annotate_method(annotation, unique=True) 8 | -------------------------------------------------------------------------------- /tests/winter_ddd/test_domain_event_dispatcher_fixture/handler1.py: -------------------------------------------------------------------------------- 1 | from winter_ddd import domain_event_handler 2 | from .events import DomainEvent1 3 | 4 | 5 | class Handler1: 6 | received_events = [] 7 | 8 | @domain_event_handler 9 | def handle_event(self, event: DomainEvent1): 10 | self.received_events.append(event) 11 | -------------------------------------------------------------------------------- /winter_django/__init__.py: -------------------------------------------------------------------------------- 1 | from winter.web import arguments_resolver 2 | from .http_request_argument_resolver import HttpRequestArgumentResolver 3 | from .output_template import output_template 4 | from .view import create_django_urls_from_routes 5 | 6 | 7 | def setup(): 8 | arguments_resolver.add_argument_resolver(HttpRequestArgumentResolver()) 9 | -------------------------------------------------------------------------------- /.github/workflows/required-checks.yml: -------------------------------------------------------------------------------- 1 | name: Required checks 2 | 3 | on: 4 | pull_request: 5 | 6 | jobs: 7 | testing: 8 | uses: ./.github/workflows/testing.yml 9 | 10 | required-checks: 11 | name: Required checks 12 | runs-on: ubuntu-22.04 13 | needs: [testing] 14 | steps: 15 | - run: echo "All required checks passed" 16 | -------------------------------------------------------------------------------- /winter/core/injection.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from injector import Injector 4 | 5 | _injector: Optional[Injector] = None 6 | 7 | 8 | def set_injector(injector: Injector) -> None: 9 | global _injector 10 | _injector = injector 11 | 12 | 13 | def get_injector() -> Injector: 14 | global _injector 15 | return _injector 16 | -------------------------------------------------------------------------------- /tests/api/notes_api/api_2.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from uuid import UUID 3 | 4 | import winter 5 | 6 | 7 | @dataclass 8 | class Note: 9 | name: str 10 | 11 | 12 | class API2: 13 | @winter.route_patch('notes/{?note_id}') 14 | @winter.request_body('note') 15 | def update(self, note_id: UUID, note: Note): 16 | pass 17 | -------------------------------------------------------------------------------- /tests/api/package/api_2.py: -------------------------------------------------------------------------------- 1 | import winter 2 | from .api_1 import API1 3 | 4 | 5 | @winter.route('api_2/') 6 | class API2: 7 | @winter.route_get('') 8 | def method_1(self) -> int: # pragma: no cover 9 | return API1().method_1() 10 | 11 | @winter.route_get('') 12 | def method_2(self) -> int: # pragma: no cover 13 | return 2 14 | -------------------------------------------------------------------------------- /tests/core/json/test_undefined.py: -------------------------------------------------------------------------------- 1 | from winter.core.json import Undefined 2 | 3 | 4 | def test_undefined(): 5 | assert Undefined() == Undefined() 6 | assert Undefined() != 1 7 | assert Undefined() != '' 8 | assert Undefined() is not None 9 | assert repr(Undefined()) == 'Undefined' 10 | assert {Undefined(), Undefined()} == {Undefined()} 11 | -------------------------------------------------------------------------------- /winter/web/throttling/__init__.py: -------------------------------------------------------------------------------- 1 | from .exceptions import ThrottlingMisconfigurationException 2 | from .throttling import throttling 3 | from .throttling import reset 4 | from .throttling import create_throttle_class 5 | from .redis_throttling_configuration import set_redis_throttling_configuration 6 | from .redis_throttling_configuration import RedisThrottlingConfiguration 7 | -------------------------------------------------------------------------------- /tests/data/test_repository.py: -------------------------------------------------------------------------------- 1 | from winter.data import CRUDRepository 2 | 3 | 4 | def test_crud_repository_generic_parameters(): 5 | class MyEntity: 6 | pass 7 | 8 | class MyRepository(CRUDRepository[MyEntity, int]): 9 | pass 10 | 11 | assert MyRepository.__entity_cls__ is MyEntity 12 | assert MyRepository.__primary_key_type__ is int 13 | -------------------------------------------------------------------------------- /winter/messaging/event_publisher.py: -------------------------------------------------------------------------------- 1 | from abc import ABC 2 | from abc import abstractmethod 3 | from typing import List 4 | 5 | from .event import Event 6 | 7 | 8 | class EventPublisher(ABC): 9 | @abstractmethod 10 | def emit(self, event: Event): 11 | pass 12 | 13 | @abstractmethod 14 | def emit_many(self, events: List[Event]): 15 | pass 16 | -------------------------------------------------------------------------------- /winter/web/query_parameters/map_query_parameter_annotation.py: -------------------------------------------------------------------------------- 1 | import dataclasses 2 | 3 | 4 | @dataclasses.dataclass 5 | class MapQueryParameterAnnotation: 6 | name: str 7 | map_to: str = None 8 | 9 | def __eq__(self, other): 10 | return isinstance(other, MapQueryParameterAnnotation) and ( 11 | self.name == other.name or self.map_to == other.map_to) 12 | -------------------------------------------------------------------------------- /winter/web/routing/__init__.py: -------------------------------------------------------------------------------- 1 | from .reverse import reverse 2 | from .route import Route 3 | from .route_annotation import RouteAnnotation 4 | from .routing import get_route 5 | from .routing import route 6 | from .routing import route_delete 7 | from .routing import route_get 8 | from .routing import route_patch 9 | from .routing import route_post 10 | from .routing import route_put 11 | -------------------------------------------------------------------------------- /winter/data/exceptions.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | from typing import Type 3 | 4 | 5 | class NotFoundException(Exception): 6 | def __init__(self, entity_id: Any, entity_cls: Type): 7 | self.entity_id = entity_id 8 | self.entity_cls = entity_cls 9 | class_name = entity_cls.__name__ 10 | super().__init__(f'{class_name} with ID={entity_id} not found') 11 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v1.2.3 4 | hooks: 5 | - id: trailing-whitespace 6 | - id: flake8 7 | - id: end-of-file-fixer 8 | - id: double-quote-string-fixer 9 | 10 | - repo: https://github.com/asottile/add-trailing-comma 11 | rev: v1.3.0 12 | hooks: 13 | - id: add-trailing-comma 14 | -------------------------------------------------------------------------------- /tests/data/pagination/test_page.py: -------------------------------------------------------------------------------- 1 | from winter.data.pagination import Page 2 | from winter.data.pagination import PagePosition 3 | 4 | 5 | def test_iter_page(): 6 | page_position = PagePosition() 7 | items = [1, 2, 3, 4, 5, 6, 7, 8, 9] 8 | page = Page(10, items, page_position) 9 | 10 | # Act 11 | page_items = list(page) 12 | 13 | # Assert 14 | assert page_items == items 15 | -------------------------------------------------------------------------------- /tests/api/django/api_with_output_template.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | from typing import Dict 3 | 4 | import winter 5 | import winter_django 6 | 7 | 8 | @winter.route('with-output-template/') 9 | class APIWithOutputTemplate: 10 | 11 | @winter.route_get('{?name}') 12 | @winter_django.output_template('hello.txt') 13 | def greet(self, name: str) -> Dict[str, Any]: 14 | return {'name': name} 15 | -------------------------------------------------------------------------------- /winter/web/pagination/parse_sort.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from winter.data.pagination import Sort 4 | from .parse_order import parse_order 5 | 6 | 7 | def parse_sort(str_sort: Optional[str]) -> Optional[Sort]: 8 | if not str_sort: 9 | return None 10 | sort_parts = str_sort.split(',') 11 | orders = (parse_order(sort_part) for sort_part in sort_parts) 12 | return Sort(*orders) 13 | -------------------------------------------------------------------------------- /tests/messaging/events.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | from winter.messaging import Event 4 | 5 | 6 | @dataclass(frozen=True) 7 | class Event1(Event): 8 | x: int 9 | 10 | 11 | @dataclass(frozen=True) 12 | class Event2(Event): 13 | x: int 14 | 15 | 16 | @dataclass(frozen=True) 17 | class Event3(Event): 18 | x: int 19 | 20 | 21 | @dataclass(frozen=True) 22 | class Event4(Event): 23 | x: int 24 | -------------------------------------------------------------------------------- /winter/web/response_entity.py: -------------------------------------------------------------------------------- 1 | from http import HTTPStatus 2 | from typing import Any 3 | 4 | from winter.core.utils import TypeWrapper 5 | 6 | 7 | class ResponseEntity(TypeWrapper): 8 | def __init__(self, entity: Any = None, status_code: int = HTTPStatus.OK): 9 | super().__init__() 10 | self._check_nested_type(type(entity)) 11 | self.entity = entity 12 | self.status_code = status_code 13 | -------------------------------------------------------------------------------- /tests/winter_ddd/test_aggregate_root.py: -------------------------------------------------------------------------------- 1 | from winter_ddd import AggregateRoot, DomainEvents, DomainEvent 2 | 3 | 4 | def test_domain_events_returns_collection(): 5 | class MyEvent(DomainEvent): 6 | pass 7 | 8 | entity = AggregateRoot() 9 | assert isinstance(entity.domain_events, DomainEvents) 10 | event = MyEvent() 11 | entity.domain_events.register(event) 12 | assert event in entity.domain_events 13 | -------------------------------------------------------------------------------- /tests/api/api_with_media_types_routing.py: -------------------------------------------------------------------------------- 1 | from django.http import HttpResponse 2 | 3 | import winter 4 | from winter.web import MediaType 5 | 6 | 7 | @winter.route('with-media-types-routing/') 8 | class APIWithMediaTypesRouting: 9 | @winter.route_get('xml/', produces=(MediaType.APPLICATION_XML, )) 10 | def get_xml(self) -> HttpResponse: 11 | return HttpResponse(b'Hello, sir!', content_type=str(MediaType.APPLICATION_XML)) 12 | -------------------------------------------------------------------------------- /tests/api/notes_api/api_1.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from uuid import UUID 3 | 4 | import winter 5 | 6 | 7 | @dataclass 8 | class Note: 9 | name: str 10 | 11 | 12 | class API1: 13 | @winter.route_get('notes/{?note_id}') 14 | def get(self, note_id: UUID): 15 | pass 16 | 17 | @winter.route_post('notes/') 18 | @winter.request_body('note') 19 | def create(self, note: Note): 20 | pass 21 | -------------------------------------------------------------------------------- /winter/data/pagination/page_position.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | import dataclasses 4 | 5 | from .sort import Sort 6 | 7 | 8 | @dataclasses.dataclass(frozen=True) 9 | class PagePosition: 10 | limit: Optional[int] = None 11 | offset: Optional[int] = None 12 | sort: Optional[Sort] = None 13 | 14 | def __post_init__(self): 15 | if self.limit == 0: 16 | object.__setattr__(self, 'limit', None) 17 | -------------------------------------------------------------------------------- /winter/web/exceptions/exception_mapper.py: -------------------------------------------------------------------------------- 1 | from abc import ABC 2 | from abc import abstractmethod 3 | from typing import Any 4 | 5 | from django.http import HttpRequest 6 | 7 | from .problem_handling_info import ProblemHandlingInfo 8 | 9 | 10 | class ExceptionMapper(ABC): 11 | @abstractmethod 12 | def to_response_body(self, request: HttpRequest, exception: Exception, handling_info: ProblemHandlingInfo) -> Any: 13 | pass 14 | -------------------------------------------------------------------------------- /winter/web/exceptions/exception_handler_generator.py: -------------------------------------------------------------------------------- 1 | from abc import ABC 2 | from abc import abstractmethod 3 | from typing import Type 4 | 5 | from .handlers import ExceptionHandler 6 | from .problem_handling_info import ProblemHandlingInfo 7 | 8 | 9 | class ExceptionHandlerGenerator(ABC): 10 | @abstractmethod 11 | def generate(self, exception_class: Type[Exception], handling_info: ProblemHandlingInfo) -> Type[ExceptionHandler]: 12 | pass 13 | -------------------------------------------------------------------------------- /tests/winter_openapi/inspectors/test_route_parameter_inspector.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from winter_openapi import PathParametersInspector 4 | from winter_openapi import register_route_parameters_inspector 5 | 6 | 7 | def test_add_same_inspector_write_warning_log(caplog): 8 | with caplog.at_level(logging.WARNING): 9 | register_route_parameters_inspector(PathParametersInspector()) 10 | assert 'PathParametersInspector already registered' in caplog.text 11 | -------------------------------------------------------------------------------- /winter/web/response_status_annotation.py: -------------------------------------------------------------------------------- 1 | from http import HTTPStatus 2 | from typing import Union 3 | 4 | import dataclasses 5 | 6 | from ..core import annotate 7 | 8 | 9 | @dataclasses.dataclass 10 | class ResponseStatusAnnotation: 11 | status_code: HTTPStatus 12 | 13 | 14 | def response_status(status: Union[HTTPStatus, int]): 15 | status = HTTPStatus(status) 16 | annotation = ResponseStatusAnnotation(status) 17 | return annotate(annotation, single=True) 18 | -------------------------------------------------------------------------------- /tests/test_api_with_media_types_routing.py: -------------------------------------------------------------------------------- 1 | from http import HTTPStatus 2 | 3 | 4 | def test_api_with_media_types_routing_returns_200(api_client): 5 | response = api_client.get( 6 | '/with-media-types-routing/xml/', 7 | headers={ 8 | 'Accept': 'application/xml', 9 | }, 10 | ) 11 | 12 | assert response.status_code == HTTPStatus.OK 13 | assert response.content == b'Hello, sir!' 14 | assert response.headers['content-type'] == 'application/xml' 15 | -------------------------------------------------------------------------------- /winter/data/pagination/page.py: -------------------------------------------------------------------------------- 1 | from typing import Generic 2 | from typing import Iterable 3 | from typing import Iterator 4 | from typing import TypeVar 5 | 6 | import dataclasses 7 | 8 | from .page_position import PagePosition 9 | 10 | T = TypeVar('T') 11 | 12 | 13 | @dataclasses.dataclass(frozen=True) 14 | class Page(Generic[T]): 15 | total_count: int 16 | items: Iterable[T] 17 | position: PagePosition 18 | 19 | def __iter__(self) -> Iterator[T]: 20 | return iter(self.items) 21 | -------------------------------------------------------------------------------- /winter_openapi/inspection/data_formats.py: -------------------------------------------------------------------------------- 1 | from strenum import StrEnum 2 | 3 | 4 | class DataFormat(StrEnum): 5 | DATE = "date" 6 | DATETIME = "date-time" 7 | PASSWORD = "password" 8 | BINARY = "binary" 9 | BASE64 = "bytes" 10 | FLOAT = "float" 11 | DOUBLE = "double" 12 | INT32 = "int32" 13 | INT64 = "int64" 14 | 15 | # defined in JSON-schema 16 | EMAIL = "email" 17 | IPV4 = "ipv4" 18 | IPV6 = "ipv6" 19 | URI = "uri" 20 | UUID = "uuid" 21 | SLUG = "slug" 22 | DECIMAL = "decimal" 23 | -------------------------------------------------------------------------------- /tests/web/test_interceptors.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | 4 | @pytest.mark.parametrize( 5 | 'hello_world_query, hello_world_header', 6 | [ 7 | ('', None), 8 | ('?hello_world', 'Hello, World!'), 9 | ], 10 | ) 11 | def test_interceptor_headers(api_client, hello_world_query, hello_world_header): 12 | url = f'/winter-simple/get/{hello_world_query}' 13 | response = api_client.get(url) 14 | assert response.headers.get('x-method') == 'SimpleAPI.get' 15 | assert response.headers.get('x-hello-world') == hello_world_header 16 | -------------------------------------------------------------------------------- /winter_ddd/aggregate_root.py: -------------------------------------------------------------------------------- 1 | from .domain_events import DomainEvents 2 | 3 | 4 | class AggregateRoot: 5 | def __init__(self, *args, **kwargs): 6 | super().__init__(*args, **kwargs) 7 | self._domain_events: DomainEvents = None 8 | 9 | @property 10 | def domain_events(self) -> DomainEvents: 11 | if getattr(self, '_domain_events', None) is None: 12 | self._domain_events = DomainEvents() 13 | return self._domain_events 14 | 15 | def clear_domain_events(self): 16 | self._domain_events.clear() 17 | -------------------------------------------------------------------------------- /winter/web/interceptor.py: -------------------------------------------------------------------------------- 1 | from abc import ABC 2 | from abc import abstractmethod 3 | 4 | 5 | class Interceptor(ABC): 6 | @abstractmethod 7 | def pre_handle(self, **kwargs): 8 | pass 9 | 10 | 11 | class InterceptorRegistry: 12 | def __init__(self): 13 | self._interceptors = [] 14 | 15 | def add_interceptor(self, interceptor: Interceptor): 16 | self._interceptors.append(interceptor) 17 | 18 | def __iter__(self): 19 | return iter(self._interceptors) 20 | 21 | 22 | interceptor_registry = InterceptorRegistry() 23 | -------------------------------------------------------------------------------- /winter/web/routing/route_annotation.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING 2 | from typing import Tuple 3 | 4 | import dataclasses 5 | 6 | if TYPE_CHECKING: 7 | from winter.web import MediaType 8 | 9 | 10 | @dataclasses.dataclass(frozen=True) 11 | class RouteAnnotation: 12 | url_path: str 13 | http_method: str = None 14 | produces: Tuple['MediaType'] = None # It's used for swagger only at the moment, but will be used in routing later 15 | consumes: Tuple['MediaType'] = None # It's used for swagger only at the moment, but will be used in routing later 16 | -------------------------------------------------------------------------------- /winter_openapi/inspectors/__init__.py: -------------------------------------------------------------------------------- 1 | from .page_inspector import inspect_page 2 | from .page_position_argument_inspector import PagePositionArgumentsInspector 3 | from .path_parameters_inspector import PathParametersInspector 4 | from .query_parameters_inspector import QueryParametersInspector 5 | from .route_parameters_inspector import RouteParametersInspector 6 | from .route_parameters_inspector import get_route_parameters_inspectors 7 | from .route_parameters_inspector import get_route_parameters_inspectors 8 | from .route_parameters_inspector import register_route_parameters_inspector 9 | -------------------------------------------------------------------------------- /winter/web/exceptions/__init__.py: -------------------------------------------------------------------------------- 1 | from .exception_handler_generator import ExceptionHandlerGenerator 2 | from .exception_mapper import ExceptionMapper 3 | from .exceptions import RedirectException 4 | from .exceptions import ThrottleException 5 | from .exceptions import UnsupportedMediaTypeException 6 | from .exceptions import RequestDataDecodeException 7 | from .handlers import ExceptionHandler 8 | from .handlers import MethodExceptionsManager 9 | from .handlers import exception_handlers_registry 10 | from .problem import problem 11 | from .problem_handling_info import ProblemHandlingInfo 12 | -------------------------------------------------------------------------------- /tests/winter_django/test_view.py: -------------------------------------------------------------------------------- 1 | from http import HTTPStatus 2 | from uuid import uuid4 3 | 4 | 5 | def test_create_django_urls_from_routes(api_client): 6 | url = f"/notes/?note_id={uuid4()}" 7 | 8 | get_http_response = api_client.get(url) 9 | post_http_response = api_client.post("/notes/", json=dict(name="Name")) 10 | patch_http_response = api_client.patch(url, json=dict(name="New Name")) 11 | 12 | assert get_http_response.status_code == HTTPStatus.OK 13 | assert post_http_response.status_code == HTTPStatus.OK 14 | assert patch_http_response.status_code == HTTPStatus.OK 15 | -------------------------------------------------------------------------------- /winter/web/pagination/page_processor_resolver.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from winter.data.pagination import Page 4 | from winter.web.output_processor import IOutputProcessorResolver 5 | from .page_processor import PageProcessor 6 | 7 | 8 | class PageOutputProcessorResolver(IOutputProcessorResolver): 9 | 10 | def __init__(self): 11 | self._page_processor = PageProcessor() 12 | 13 | def is_supported(self, body: Any) -> bool: 14 | return isinstance(body, Page) 15 | 16 | def get_processor(self, body: Any) -> PageProcessor: 17 | return self._page_processor 18 | -------------------------------------------------------------------------------- /.github/workflows/upload-package.yml: -------------------------------------------------------------------------------- 1 | name: Upload Package 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | build-and-publish-python-package: 9 | name: Build Python package with sources and publish 10 | runs-on: ubuntu-22.04 11 | steps: 12 | - name: Checkout the code 13 | uses: actions/checkout@v4 14 | - uses: actions/setup-python@v5 15 | with: 16 | python-version: '3.12' 17 | - name: Build and publish to PyPi 18 | uses: JRubics/poetry-publish@v2.1 19 | with: 20 | pypi_token: ${{ secrets.PYPI_TOKEN }} 21 | -------------------------------------------------------------------------------- /tests/api/api_with_requested_headers.py: -------------------------------------------------------------------------------- 1 | from typing import Tuple 2 | 3 | import winter 4 | 5 | 6 | class APIWithRequestHeaders: 7 | @winter.request_header('X-Header', to='header') 8 | @winter.route_post('with-request-header/') 9 | def method(self, header: int) -> int: 10 | return header 11 | 12 | @winter.request_header('X-Header', to='header') 13 | @winter.request_header('Y-Header', to='another_header') 14 | @winter.route_post('with-request-several-headers/') 15 | def method_with_several_headers(self, header: int, another_header: str) -> Tuple[int, str]: 16 | return header, another_header 17 | -------------------------------------------------------------------------------- /winter/messaging/simple_event_publisher.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | from injector import inject 4 | 5 | from .event import Event 6 | from .event_dispacher import EventDispatcher 7 | from .event_publisher import EventPublisher 8 | 9 | 10 | class SimpleEventPublisher(EventPublisher): 11 | @inject 12 | def __init__(self, event_dispatcher: EventDispatcher): 13 | self._event_dispatcher = event_dispatcher 14 | 15 | def emit(self, event: Event): 16 | self._event_dispatcher.dispatch(event) 17 | 18 | def emit_many(self, events: List[Event]): 19 | self._event_dispatcher.dispatch_many(events) 20 | -------------------------------------------------------------------------------- /winter/web/pagination/parse_order.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from winter.data.pagination import Order 4 | from winter.data.pagination import SortDirection 5 | from winter.web.exceptions import RequestDataDecodeException 6 | 7 | _field_pattern = re.compile(r'(-?)(\w+)') 8 | 9 | 10 | def parse_order(field: str): 11 | match = _field_pattern.match(field) 12 | 13 | if match is None: 14 | raise RequestDataDecodeException(f'Invalid field for order: "{field}"') 15 | 16 | direction, field = match.groups() 17 | direction = SortDirection.DESC if direction == '-' else SortDirection.ASC 18 | return Order(field, direction) 19 | -------------------------------------------------------------------------------- /tests/winter_openapi/test_swagger_ui.py: -------------------------------------------------------------------------------- 1 | from lxml import etree 2 | 3 | 4 | def test_swagger_ui(api_client): 5 | response = api_client.get('/swagger-ui/') 6 | 7 | assert response.status_code == 200 8 | html_parser = etree.HTMLParser() 9 | etree.HTML(response.content, html_parser) 10 | assert not html_parser.error_log 11 | 12 | 13 | def test_swagger_ui_with_params(api_client): 14 | response = api_client.get('/swagger-ui-with-params/') 15 | 16 | assert response.status_code == 200 17 | html_parser = etree.HTMLParser() 18 | etree.HTML(response.content, html_parser) 19 | assert not html_parser.error_log 20 | -------------------------------------------------------------------------------- /winter/web/exception_handlers.py: -------------------------------------------------------------------------------- 1 | from http import HTTPStatus 2 | 3 | from .exceptions import ExceptionHandler 4 | from .exceptions import RedirectException 5 | from .response_header_annotation import ResponseHeader 6 | from .response_header_annotation import response_header 7 | from .response_status_annotation import response_status 8 | 9 | 10 | class RedirectExceptionHandler(ExceptionHandler): 11 | @response_status(HTTPStatus.FOUND) 12 | @response_header('Location', 'location_header') 13 | def handle(self, exception: RedirectException, location_header: ResponseHeader[str]): 14 | location_header.set(exception.redirect_to) 15 | -------------------------------------------------------------------------------- /tests/core/test_application.py: -------------------------------------------------------------------------------- 1 | import winter.core 2 | 3 | 4 | def test_autodiscover(): 5 | 6 | @winter.core.component 7 | class SimpleComponent: 8 | pass 9 | 10 | winter_app = winter.core.WinterApplication() 11 | 12 | winter_app.autodiscover() 13 | 14 | assert SimpleComponent in winter_app.components 15 | 16 | 17 | def test_autodiscover_with_pre_add(): 18 | 19 | class SimpleComponent: 20 | pass 21 | 22 | winter_app = winter.core.WinterApplication() 23 | winter_app.add_component(SimpleComponent) 24 | winter_app.autodiscover() 25 | 26 | assert SimpleComponent in winter_app.components 27 | -------------------------------------------------------------------------------- /winter/core/__init__.py: -------------------------------------------------------------------------------- 1 | from .annotation_decorator import annotate 2 | from .annotation_decorator import annotate_class 3 | from .annotation_decorator import annotate_method 4 | from .application import WinterApplication 5 | from .component import Component 6 | from .component import component 7 | from .component import is_component 8 | from .component_method import ComponentMethod 9 | from .component_method import component_method 10 | from .component_method_argument import ArgumentDoesNotHaveDefault 11 | from .component_method_argument import ComponentMethodArgument 12 | from .injection import get_injector 13 | from .injection import set_injector 14 | -------------------------------------------------------------------------------- /winter/core/utils/positive_integer.py: -------------------------------------------------------------------------------- 1 | from ..json import JSONDecodeException 2 | from ..json import json_decoder 3 | 4 | 5 | class PositiveInteger(int): 6 | 7 | # noinspection PyUnusedLocal 8 | def __init__(self, *args, **kwargs): 9 | if self < 0: 10 | raise ValueError(f'PositiveInteger can not be negative: {self}') 11 | super().__init__() 12 | 13 | 14 | @json_decoder(PositiveInteger) 15 | def decode_positive_integer(value, type_): 16 | try: 17 | return type_(value) 18 | except (TypeError, ValueError): 19 | raise JSONDecodeException.cannot_decode(value=value, type_name='PositiveInteger') 20 | -------------------------------------------------------------------------------- /tests/api/__init__.py: -------------------------------------------------------------------------------- 1 | from .api_with_exceptions import APIWithExceptions 2 | from .api_with_pagination import APIWithPagination 3 | from .api_with_media_types_routing import APIWithMediaTypesRouting 4 | from .api_with_path_parameters import APIWithPathParameters 5 | from .api_with_problem_exceptions import APIWithProblemExceptions 6 | from .api_with_query_parameters import APIWithQueryParameters 7 | from .api_with_request_data import APIWithRequestData 8 | from .api_with_requested_headers import APIWithRequestHeaders 9 | from .api_with_response_headers import APIWithResponseHeaders 10 | from .api_with_throttling import APIWithThrottling 11 | from .simple_api import SimpleAPI 12 | -------------------------------------------------------------------------------- /winter/web/pagination/check_sort.py: -------------------------------------------------------------------------------- 1 | from typing import FrozenSet 2 | from typing import TYPE_CHECKING 3 | 4 | from winter.web.exceptions import RequestDataDecodeException 5 | 6 | if TYPE_CHECKING: 7 | from winter.data.pagination import Sort 8 | 9 | 10 | def check_sort(sort: 'Sort', allowed_fields: FrozenSet[str]): 11 | not_allowed_fields = [ 12 | order.field 13 | for order in sort.orders 14 | if order.field not in allowed_fields 15 | ] 16 | if not_allowed_fields: 17 | not_allowed_fields = ','.join(not_allowed_fields) 18 | raise RequestDataDecodeException(f'Fields do not allowed as order by fields: "{not_allowed_fields}"') 19 | -------------------------------------------------------------------------------- /winter/web/pagination/order_by.py: -------------------------------------------------------------------------------- 1 | from typing import Iterable 2 | from typing import Tuple 3 | 4 | from winter.core.annotation_decorator import annotate_method 5 | from .check_sort import check_sort 6 | from .order_by_annotation import OrderByAnnotation 7 | from .parse_sort import parse_sort 8 | 9 | 10 | def order_by(allowed_fields: Iterable[str], default_sort: Tuple[str] = None): 11 | allowed_fields = frozenset(allowed_fields) 12 | if default_sort is not None: 13 | default_sort = parse_sort(','.join(default_sort)) 14 | check_sort(default_sort, allowed_fields) 15 | annotation = OrderByAnnotation(allowed_fields, default_sort) 16 | return annotate_method(annotation, single=True) 17 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for Winter framework 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /winter/web/request_header_annotation.py: -------------------------------------------------------------------------------- 1 | import dataclasses 2 | 3 | from winter.core import annotate_method 4 | 5 | 6 | @dataclasses.dataclass 7 | class RequestHeaderAnnotation: 8 | name: str 9 | map_to: str 10 | 11 | 12 | def request_header(name: str, *, to: str): 13 | 14 | def wrapper(func_or_method): 15 | annotation = RequestHeaderAnnotation(name, to) 16 | annotation_decorator = annotate_method(annotation) 17 | method = annotation_decorator(func_or_method) 18 | argument = method.get_argument(to) 19 | method_name = method.func.__name__ 20 | assert argument is not None, f'Not found argument "{to}" in "{method_name}"' 21 | return method 22 | 23 | return wrapper 24 | -------------------------------------------------------------------------------- /winter_ddd/domain_events.py: -------------------------------------------------------------------------------- 1 | import collections 2 | from typing import Iterator 3 | from typing import List 4 | 5 | from .domain_event import DomainEvent 6 | 7 | 8 | class DomainEvents(collections.abc.Collection): 9 | 10 | def __init__(self): 11 | self._events: List[DomainEvent] = [] 12 | 13 | def register(self, event: DomainEvent): 14 | self._events.append(event) 15 | 16 | def __contains__(self, event: DomainEvent) -> bool: 17 | return event in self._events 18 | 19 | def __iter__(self) -> Iterator[DomainEvent]: 20 | yield from self._events 21 | 22 | def __len__(self) -> int: 23 | return len(self._events) 24 | 25 | def clear(self): 26 | self._events.clear() 27 | -------------------------------------------------------------------------------- /winter/web/autodiscovery.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | from winter.core import Component 4 | from winter.core.module_discovery import get_all_classes 5 | from winter.core.module_discovery import import_recursively 6 | from .routing import Route 7 | from .routing import get_route 8 | 9 | 10 | def find_package_routes(package_name: str) -> List[Route]: 11 | import_recursively(package_name) 12 | routes = [] 13 | 14 | for class_name, cls in get_all_classes(package_name): 15 | component = Component.get_by_cls(cls) 16 | 17 | for method in component.methods: 18 | route = get_route(method) 19 | 20 | if route is not None: 21 | routes.append(route) 22 | 23 | return routes 24 | -------------------------------------------------------------------------------- /winter_django/http_request_argument_resolver.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | from typing import MutableMapping 3 | 4 | import django.http 5 | 6 | from winter.core import ComponentMethodArgument 7 | from winter.web.argument_resolver import ArgumentResolver 8 | 9 | 10 | class HttpRequestArgumentResolver(ArgumentResolver): 11 | 12 | def is_supported(self, argument: ComponentMethodArgument) -> bool: 13 | return inspect.isclass(argument.type_) and issubclass(argument.type_, django.http.HttpRequest) 14 | 15 | def resolve_argument( 16 | self, 17 | argument: ComponentMethodArgument, 18 | request: django.http.HttpRequest, 19 | response_headers: MutableMapping[str, str], 20 | ): 21 | return request 22 | -------------------------------------------------------------------------------- /tests/web/interceptors.py: -------------------------------------------------------------------------------- 1 | from django.http import HttpRequest 2 | 3 | import winter 4 | from winter.core import ComponentMethod 5 | from winter.web import Interceptor 6 | from winter.web import ResponseHeader 7 | 8 | 9 | class HelloWorldInterceptor(Interceptor): 10 | @winter.response_header('x-method', 'method_header') 11 | @winter.response_header('x-hello-world', 'hello_world_header') 12 | def pre_handle( 13 | self, 14 | method: ComponentMethod, 15 | request: HttpRequest, 16 | method_header: ResponseHeader[str], 17 | hello_world_header: ResponseHeader[str], 18 | ): 19 | method_header.set(method.full_name) 20 | if 'hello_world' in request.GET: 21 | hello_world_header.set('Hello, World!') 22 | -------------------------------------------------------------------------------- /tests/api/swagger_ui.py: -------------------------------------------------------------------------------- 1 | from django.http import HttpResponse 2 | 3 | import winter 4 | import winter_openapi 5 | 6 | 7 | class SwaggerUI: 8 | @winter.route_get('swagger-ui/') 9 | def get_swagger_ui(self): 10 | html = winter_openapi.get_swagger_ui_html( 11 | openapi_url='http://testserver/openapi.yml', 12 | ) 13 | return HttpResponse(html, content_type='text/html') 14 | 15 | @winter.route_get('swagger-ui-with-params/') 16 | def get_swagger_ui_with_custom_parameters(self): 17 | html = winter_openapi.get_swagger_ui_html( 18 | openapi_url='http://testserver/openapi.yml', 19 | swagger_ui_parameters={'deepLinking': False}, 20 | ) 21 | return HttpResponse(html, content_type='text/html') 22 | -------------------------------------------------------------------------------- /winter/core/application.py: -------------------------------------------------------------------------------- 1 | import types 2 | from typing import Mapping 3 | from typing import Type 4 | 5 | from .component import Component 6 | 7 | 8 | class WinterApplication: 9 | 10 | def __init__(self): 11 | self._components = {} 12 | 13 | @property 14 | def components(self) -> Mapping[Type, Component]: 15 | return types.MappingProxyType(self._components) 16 | 17 | def add_component(self, cls: Type) -> Type: 18 | Component.register(cls) 19 | self._components[cls] = Component.get_by_cls(cls) 20 | return cls 21 | 22 | def autodiscover(self) -> None: 23 | for component_cls in Component.get_all(): 24 | if component_cls not in self._components: 25 | self.add_component(component_cls) 26 | -------------------------------------------------------------------------------- /winter_django/output_template.py: -------------------------------------------------------------------------------- 1 | import django.http 2 | from django.shortcuts import render 3 | 4 | from winter.web.output_processor import IOutputProcessor 5 | from winter.web.output_processor import register_output_processor 6 | 7 | 8 | class TemplateRenderer(IOutputProcessor): 9 | 10 | def __init__(self, template_name: str): 11 | self._template_name = template_name 12 | 13 | def process_output(self, output, request: django.http.HttpRequest): 14 | return render(request, self._template_name, context=output) 15 | 16 | 17 | def output_template(template_name: str): 18 | def wrapper(func): 19 | output_processor = TemplateRenderer(template_name) 20 | return register_output_processor(func, output_processor) 21 | 22 | return wrapper 23 | -------------------------------------------------------------------------------- /tests/middleware.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.middleware import AuthenticationMiddleware as DjangoAuthenticationMiddleware 2 | from django.core.handlers.wsgi import WSGIRequest 3 | 4 | from .user import User 5 | 6 | 7 | class AuthenticationMiddleware(DjangoAuthenticationMiddleware): 8 | def __init__(self, get_response): 9 | super().__init__(get_response) 10 | self._get_response = get_response 11 | self._user = User() 12 | 13 | def __call__(self, request: WSGIRequest): 14 | authorize_as = request.META.get('HTTP_TEST_AUTHORIZE', '').lower() 15 | if authorize_as == 'user': 16 | request.user = self._user 17 | elif authorize_as == 'user_none': 18 | request.user = None 19 | 20 | return self._get_response(request) 21 | -------------------------------------------------------------------------------- /winter/web/default_response_status.py: -------------------------------------------------------------------------------- 1 | from http import HTTPStatus 2 | 3 | from ..core import ComponentMethod 4 | from .response_status_annotation import ResponseStatusAnnotation 5 | 6 | _default_http_method_statuses = { 7 | 'get': HTTPStatus.OK, 8 | 'post': HTTPStatus.OK, 9 | 'put': HTTPStatus.OK, 10 | 'patch': HTTPStatus.OK, 11 | 'delete': HTTPStatus.NO_CONTENT, 12 | } 13 | 14 | 15 | def get_response_status(http_method: str, method: ComponentMethod) -> int: 16 | response_status_annotation = method.annotations.get_one_or_none(ResponseStatusAnnotation) 17 | if response_status_annotation is not None: 18 | return response_status_annotation.status_code.value 19 | status_code = _default_http_method_statuses[http_method.lower()] 20 | return status_code.value 21 | -------------------------------------------------------------------------------- /winter/web/request_body_annotation.py: -------------------------------------------------------------------------------- 1 | import dataclasses 2 | from typing import List 3 | 4 | from winter.core import annotate_method 5 | 6 | ListType = type(List) 7 | 8 | 9 | @dataclasses.dataclass 10 | class RequestBodyAnnotation: 11 | argument_name: str 12 | 13 | 14 | def request_body(argument_name: str): 15 | 16 | def wrapper(func_or_method): 17 | annotation = RequestBodyAnnotation(argument_name) 18 | annotation_decorator = annotate_method(annotation, single=True) 19 | method = annotation_decorator(func_or_method) 20 | argument = method.get_argument(argument_name) 21 | method_name = method.func.__name__ 22 | assert argument is not None, f'Not found argument "{argument_name}" in "{method_name}"' 23 | return method 24 | return wrapper 25 | -------------------------------------------------------------------------------- /winter_openapi/inspection/type_info.py: -------------------------------------------------------------------------------- 1 | from collections import OrderedDict 2 | from dataclasses import dataclass 3 | from dataclasses import field 4 | from typing import Dict 5 | from typing import Optional 6 | from typing import Type 7 | 8 | from .data_formats import DataFormat 9 | from .data_types import DataTypes 10 | 11 | 12 | @dataclass 13 | class TypeInfo: 14 | type_: DataTypes 15 | hint_class: Type 16 | format_: Optional[DataFormat] = None 17 | child: Optional['TypeInfo'] = None 18 | nullable: bool = False 19 | can_be_undefined: bool = False 20 | properties: Dict[str, 'TypeInfo'] = field(default_factory=OrderedDict) 21 | properties_defaults: Dict[str, object] = field(default_factory=dict) 22 | enum: Optional[list] = None 23 | title: str = '' 24 | description: str = '' 25 | -------------------------------------------------------------------------------- /winter_openapi/annotations/global_exception.py: -------------------------------------------------------------------------------- 1 | from typing import Type 2 | 3 | from dataclasses import dataclass 4 | 5 | from winter.core import annotate 6 | 7 | 8 | @dataclass 9 | class GlobalExceptionAnnotation: 10 | exception_cls: Type[Exception] 11 | 12 | 13 | def global_exception(exception_class: Type[Exception]) -> Type[Exception]: 14 | return register_global_exception(exception_class) 15 | 16 | 17 | def register_global_exception(exception_class: Type[Exception]) -> Type[Exception]: 18 | assert issubclass(exception_class, Exception), f'Class "{exception_class}" must be a subclass of Exception' 19 | annotation = GlobalExceptionAnnotation(exception_cls=exception_class) 20 | annotation_decorator = annotate(annotation, unique=True) 21 | annotation_decorator(exception_class) 22 | return exception_class 23 | -------------------------------------------------------------------------------- /winter_sqlalchemy/query.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import asc 2 | from sqlalchemy import desc 3 | from sqlalchemy.sql import Select 4 | 5 | from winter.data.pagination import PagePosition 6 | from winter.data.pagination import Sort 7 | from winter.data.pagination import SortDirection 8 | 9 | _sort_direction_map = { 10 | SortDirection.ASC: asc, 11 | SortDirection.DESC: desc, 12 | } 13 | 14 | 15 | def paginate(select: Select, page_position: PagePosition) -> Select: 16 | if page_position.sort: 17 | select = sort(select, page_position.sort) 18 | return select.limit(page_position.limit).offset(page_position.offset) 19 | 20 | 21 | def sort(select: Select, sort: Sort) -> Select: 22 | order_by_clauses = [_sort_direction_map[order.direction](order.field) for order in sort.orders] 23 | return select.order_by(*order_by_clauses) 24 | -------------------------------------------------------------------------------- /tests/winter_ddd/test_domain_event_dispatcher_fixture/subpackage/handler2.py: -------------------------------------------------------------------------------- 1 | from winter.core import annotate 2 | from winter_ddd import domain_event_handler 3 | from ..events import DomainEvent2 4 | 5 | 6 | class AnotherAnnotation: 7 | pass 8 | 9 | 10 | class Handler2: 11 | received_events = [] 12 | 13 | @domain_event_handler 14 | def handle_event(self, event: DomainEvent2): 15 | self.received_events.append(event) 16 | 17 | def intentionally_without_annotation(self, event: DomainEvent2): # pragma: no cover 18 | # This shouldn't happen 19 | self.received_events.append(event) 20 | 21 | @annotate(AnotherAnnotation()) 22 | def intentionally_with_another_annotation(self, event: DomainEvent2): # pragma: no cover 23 | # This shouldn't happen 24 | self.received_events.append(event) 25 | -------------------------------------------------------------------------------- /winter/__init__.py: -------------------------------------------------------------------------------- 1 | from . import web 2 | from .web import ResponseEntity 3 | from .web import arguments_resolver 4 | from .web import request_body 5 | from .web import request_header 6 | from .web import response_header 7 | from .web import response_status 8 | from .web.argument_resolver import ArgumentResolver 9 | from .web.argument_resolver import ArgumentsResolver 10 | from .web.exceptions.raises import raises 11 | from .web.output_processor import register_output_processor_resolver 12 | from .web.query_parameters import map_query_parameter 13 | from .web.response_header_serializer import response_headers_serializer 14 | from .web.routing import route 15 | from .web.routing import route_delete 16 | from .web.routing import route_get 17 | from .web.routing import route_patch 18 | from .web.routing import route_post 19 | from .web.routing import route_put 20 | -------------------------------------------------------------------------------- /tests/api/api_with_path_parameters.py: -------------------------------------------------------------------------------- 1 | import enum 2 | import uuid 3 | 4 | import winter 5 | 6 | 7 | class OneTwoEnum(enum.Enum): 8 | ONE = 'one' 9 | TWO = 'two' 10 | 11 | 12 | class OneTwoEnumWithInt(enum.Enum): 13 | ONE = 1 14 | TWO = 2 15 | 16 | @classmethod 17 | def _missing_(cls, value): # This is need because of needing of instancing from string 18 | return cls(int(value)) 19 | 20 | 21 | @winter.route('with-path-parameters/{param1}/') 22 | class APIWithPathParameters: 23 | @winter.route_get('{param2}/{param3}/{param4}/{param5}/{?param6}') 24 | def test( 25 | self, 26 | param1: str, 27 | param2: int, 28 | param3: OneTwoEnum, 29 | param4: uuid.UUID, 30 | param5: OneTwoEnumWithInt, 31 | param6: str, 32 | ) -> str: # pragma: no cover 33 | pass 34 | -------------------------------------------------------------------------------- /tests/web/test_response_entity.py: -------------------------------------------------------------------------------- 1 | import dataclasses 2 | import pytest 3 | 4 | from winter.web import ResponseEntity 5 | 6 | 7 | @dataclasses.dataclass 8 | class Dataclass: 9 | pass 10 | 11 | 12 | def test_response_entity_fails_without_nested_type(): 13 | with pytest.raises(TypeError, match=r"Using TypeWrapper without nested type is forbidden, use TypeWrapper\[T\]"): 14 | ResponseEntity("foo") 15 | 16 | 17 | def test_response_entity_fails_with_wrong_nested_type(): 18 | with pytest.raises(TypeError, match="Types mismatch: and "): 19 | ResponseEntity[Dataclass]("foo") 20 | 21 | 22 | def test_response_entity_has_nested_type(): 23 | assert ResponseEntity[Dataclass]._nested_type is Dataclass 24 | assert ResponseEntity[Dataclass](Dataclass())._nested_type is Dataclass 25 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior 15 | 16 | **Expected behavior** 17 | A clear and concise description of what you expected to happen. 18 | 19 | **Code snippets** 20 | If applicable, add minimal code snippets to help explain your problem. 21 | 22 | **Screenshots** 23 | If applicable, add screenshots to help explain your problem. 24 | 25 | **Environment(please complete the following information):** 26 | - OS: [e.g. macOS] 27 | - Python [e.g. CPython 3.6, PyPy 3.4] 28 | - Winter version [e.g. 1.0.3] 29 | 30 | **Additional context** 31 | Add any other context about the problem here. 32 | -------------------------------------------------------------------------------- /winter/web/throttling/redis_throttling_configuration.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | from .exceptions import ThrottlingMisconfigurationException 4 | 5 | 6 | @dataclass 7 | class RedisThrottlingConfiguration: 8 | host: str 9 | port: int 10 | db: int 11 | password: str | None = None 12 | 13 | 14 | _redis_throttling_configuration: RedisThrottlingConfiguration | None = None 15 | 16 | 17 | def set_redis_throttling_configuration(configuration: RedisThrottlingConfiguration): 18 | global _redis_throttling_configuration 19 | if _redis_throttling_configuration is not None: 20 | raise ThrottlingMisconfigurationException(f'{RedisThrottlingConfiguration.__name__} is already initialized') 21 | _redis_throttling_configuration = configuration 22 | 23 | 24 | def get_redis_throttling_configuration() -> RedisThrottlingConfiguration | None: 25 | return _redis_throttling_configuration 26 | -------------------------------------------------------------------------------- /tests/core/test_component_method_argument.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from winter.core import ArgumentDoesNotHaveDefault 4 | from winter.core import ComponentMethod 5 | 6 | 7 | def test_parameter(): 8 | def test(number: int) -> int: # pragma: no cover 9 | pass 10 | 11 | method = ComponentMethod(test) 12 | argument = method.get_argument('number') 13 | parameter = argument.parameter 14 | assert parameter.name == 'number' 15 | assert parameter.annotation == int 16 | assert parameter.kind == parameter.POSITIONAL_OR_KEYWORD 17 | 18 | 19 | def test_default(): 20 | def test(number: int) -> int: # pragma: no cover 21 | pass 22 | 23 | method = ComponentMethod(test) 24 | argument = method.get_argument('number') 25 | 26 | with pytest.raises(ArgumentDoesNotHaveDefault) as exception: 27 | argument.get_default() 28 | 29 | assert str(exception.value) == f'{argument} does not have get_default' 30 | -------------------------------------------------------------------------------- /tests/api/api_with_pagination.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | from typing import Dict 3 | 4 | import winter 5 | from winter.data.pagination import PagePosition 6 | 7 | 8 | @winter.route('paginated/') 9 | class APIWithPagination: 10 | 11 | @winter.route_get('with-limits/') 12 | @winter.web.pagination.limits(default=20, maximum=100, redirect_to_default=True) 13 | def method1(self, page_position: PagePosition) -> Dict[str, Any]: 14 | return { 15 | 'limit': page_position.limit, 16 | 'offset': page_position.offset, 17 | } 18 | 19 | @winter.route_get('') 20 | @winter.web.pagination.order_by(['id', 'name'], default_sort=('name',)) 21 | def method2(self, page_position: PagePosition) -> Dict[str, Any]: 22 | return { 23 | 'limit': page_position.limit, 24 | 'offset': page_position.offset, 25 | 'sort': str(page_position.sort) if page_position.sort else None, 26 | } 27 | -------------------------------------------------------------------------------- /tests/web/test_response_status.py: -------------------------------------------------------------------------------- 1 | from http import HTTPStatus 2 | 3 | import pytest 4 | 5 | import winter 6 | from winter.web.default_response_status import get_response_status 7 | 8 | 9 | @pytest.mark.parametrize('as_int', [True, False]) 10 | @pytest.mark.parametrize('response_status', HTTPStatus) 11 | def test_response_status(response_status, as_int): 12 | if as_int: 13 | response_status = response_status.value 14 | 15 | class TestClass: 16 | @winter.response_status(response_status) 17 | def handler(self): # pragma: no cover 18 | pass 19 | 20 | status = get_response_status('post', TestClass.handler) 21 | 22 | assert status == response_status 23 | 24 | 25 | def test_response_status_with_invalid_status(): 26 | response_status = 1 27 | message = f'{response_status} is not a valid HTTPStatus' 28 | 29 | with pytest.raises(ValueError, match=message): 30 | winter.response_status(response_status) 31 | -------------------------------------------------------------------------------- /winter/web/query_parameters/query_parameters_annotation.py: -------------------------------------------------------------------------------- 1 | import dataclasses 2 | 3 | from winter.core import ComponentMethod 4 | from winter.core import ComponentMethodArgument 5 | from winter.core import annotate_method 6 | 7 | 8 | @dataclasses.dataclass 9 | class QueryParametersAnnotation: 10 | argument: ComponentMethodArgument 11 | 12 | 13 | def query_parameters(argument_name: str): 14 | 15 | def wrapper(func_or_method): 16 | method = ComponentMethod.get_or_create(func_or_method) 17 | argument = method.get_argument(argument_name) 18 | method_name = method.func.__name__ 19 | assert argument is not None, f'Not found argument "{argument_name}" in "{method_name}"' 20 | annotation = QueryParametersAnnotation(argument) 21 | annotation_decorator = annotate_method(annotation, single=True) 22 | method = annotation_decorator(func_or_method) 23 | return method 24 | return wrapper 25 | -------------------------------------------------------------------------------- /winter/core/utils/nested_types.py: -------------------------------------------------------------------------------- 1 | from typing import Type 2 | 3 | 4 | class NestedTypeMeta(type): 5 | def __getitem__(cls, nested_type: Type) -> Type: 6 | assert isinstance(nested_type, type), 'nested_type should be a type' 7 | return type(f'{cls.__name__}[{nested_type.__name__}]', (cls,), { 8 | '_nested_type': nested_type, 9 | }) 10 | 11 | 12 | class TypeWrapper(metaclass=NestedTypeMeta): 13 | def __init__(self, *args, **kwargs): 14 | super().__init__(*args, **kwargs) 15 | if not has_nested_type(self.__class__): 16 | raise TypeError('Using TypeWrapper without nested type is forbidden, use TypeWrapper[T]') 17 | 18 | def _check_nested_type(self, nested_type: Type): 19 | if not issubclass(nested_type, self._nested_type): 20 | raise TypeError(f'Types mismatch: {nested_type} and {self._nested_type}') 21 | 22 | 23 | def has_nested_type(cls): 24 | return hasattr(cls, '_nested_type') 25 | -------------------------------------------------------------------------------- /tests/routing/test_reverse.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | from tests.api import APIWithPathParameters 4 | from tests.api import SimpleAPI 5 | from winter.web.routing import reverse 6 | 7 | 8 | def test_reverse_without_args(): 9 | url = reverse(SimpleAPI.hello) 10 | 11 | # Assert 12 | assert url == '/winter-simple/' 13 | 14 | 15 | def test_reverse_with_args(): 16 | uid = uuid.uuid4() 17 | url = reverse( 18 | APIWithPathParameters.test, 19 | args=('param1', 1, 'one', uid, '1'), 20 | ) 21 | # Assert 22 | assert url == f'/with-path-parameters/param1/1/one/{uid}/1/' 23 | 24 | 25 | def test_reverse_with_kwargs(): 26 | uid = uuid.uuid4() 27 | url = reverse( 28 | APIWithPathParameters.test, 29 | kwargs={ 30 | 'param1': 'param1', 31 | 'param2': '1', 32 | 'param3': 'one', 33 | 'param4': uid, 34 | 'param5': '1', 35 | }, 36 | ) 37 | # Assert 38 | assert url == f'/with-path-parameters/param1/1/one/{uid}/1/' 39 | -------------------------------------------------------------------------------- /winter/web/exceptions/problem.py: -------------------------------------------------------------------------------- 1 | from http import HTTPStatus 2 | from typing import Optional 3 | from typing import Union 4 | 5 | from winter.core import annotate 6 | from .problem_annotation import ProblemAnnotation 7 | from .problem_handling_info import ProblemHandlingInfo 8 | 9 | 10 | def problem( 11 | status: Union[HTTPStatus, int], 12 | title: Optional[str] = None, 13 | detail: Optional[str] = None, 14 | type: Optional[str] = None, 15 | ): 16 | def wrapper(exception_class): 17 | assert issubclass(exception_class, Exception), f'Class "{exception_class}" must be a subclass of Exception' 18 | annotation = ProblemAnnotation( 19 | handling_info=ProblemHandlingInfo( 20 | status=int(status), 21 | type=type, 22 | title=title, 23 | detail=detail, 24 | ), 25 | ) 26 | annotation_decorator = annotate(annotation, unique=True) 27 | class_ = annotation_decorator(exception_class) 28 | return class_ 29 | return wrapper 30 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | When contributing to this repository, please first discuss the change you wish to make via issue, 4 | email, or any other method with the owners of this repository before making a change. 5 | 6 | Please note we have a code of conduct, please follow it in all your interactions with the project. 7 | 8 | ## Pull Request Process 9 | 10 | 1. Ensure any install or build dependencies are removed before the end of the layer when doing a 11 | build. 12 | 2. Update the README.md with details of changes to the interface, this includes new environment 13 | variables, exposed ports, useful file locations and container parameters. 14 | 3. Increase the version numbers in any examples files and the README.md to the new version that this 15 | Pull Request would represent. The versioning scheme we use is [SemVer](http://semver.org/). 16 | 4. You may merge the Pull Request in once you have the sign-off of two other developers, or if you 17 | do not have permission to do that, you may request the second reviewer to merge it for you. 18 | -------------------------------------------------------------------------------- /tests/winter_ddd/test_domain_event_dispatcher.py: -------------------------------------------------------------------------------- 1 | from winter_ddd import DomainEventDispatcher 2 | from tests.winter_ddd.test_domain_event_dispatcher_fixture.events import DomainEvent1 3 | from tests.winter_ddd.test_domain_event_dispatcher_fixture.events import DomainEvent2 4 | 5 | 6 | def test_add_handlers_from_package(): 7 | dispatcher = DomainEventDispatcher() 8 | dispatcher.add_handlers_from_package('tests.winter_ddd.test_domain_event_dispatcher_fixture') 9 | event1 = DomainEvent1() 10 | event2 = DomainEvent2() 11 | 12 | # Act 13 | dispatcher.dispatch([event1, event2]) 14 | 15 | # Assert 16 | # Intentionally importing here to make sure that handlers are registered without explicit imports 17 | from tests.winter_ddd.test_domain_event_dispatcher_fixture.handler1 import Handler1 18 | from tests.winter_ddd.test_domain_event_dispatcher_fixture.subpackage.handler2 import Handler2 19 | assert len(Handler1.received_events) == 1 20 | assert len(Handler2.received_events) == 1 21 | assert Handler1.received_events[0] is event1 22 | assert Handler2.received_events[0] is event2 23 | -------------------------------------------------------------------------------- /winter/messaging/event_subscription.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | from dataclasses import dataclass 3 | from typing import Callable 4 | from typing import Tuple 5 | from typing import Type 6 | from typing import get_args 7 | 8 | from winter.core import ComponentMethod 9 | from winter.core.utils.typing import is_iterable_type 10 | from winter.core.utils.typing import is_union 11 | from winter.messaging import Event 12 | 13 | 14 | @dataclass(frozen=True) 15 | class EventSubscription: 16 | event_filter: Tuple[Type[Event]] 17 | collection: bool 18 | handler_method: ComponentMethod 19 | 20 | @staticmethod 21 | def create(handler_method: ComponentMethod): 22 | arg_type = handler_method.arguments[0].type_ 23 | collection = is_iterable_type(arg_type) 24 | 25 | if collection: 26 | arg_type = get_args(arg_type)[0] 27 | 28 | if is_union(arg_type): 29 | domain_event_classes = tuple(get_args(arg_type)) 30 | else: 31 | domain_event_classes = (arg_type, ) 32 | 33 | return EventSubscription(domain_event_classes, collection, handler_method) 34 | -------------------------------------------------------------------------------- /tests/winter_ddd/test_domain_events.py: -------------------------------------------------------------------------------- 1 | from winter_ddd import DomainEvent 2 | from winter_ddd import DomainEvents 3 | 4 | 5 | def test_new_domain_events_is_empty(): 6 | domain_events = DomainEvents() 7 | assert len(domain_events) == 0 8 | assert list(domain_events) == [] 9 | 10 | 11 | def test_domain_events_registers_events(): 12 | domain_events = DomainEvents() 13 | event1 = DomainEvent() 14 | event2 = DomainEvent() 15 | domain_events.register(event1) 16 | domain_events.register(event2) 17 | assert len(domain_events) == 2 18 | assert list(domain_events) == [event1, event2] 19 | 20 | 21 | def test_domain_events_doesnt_clears_events(): 22 | domain_events = DomainEvents() 23 | domain_events.register(DomainEvent()) 24 | domain_events.register(DomainEvent()) 25 | list(domain_events) 26 | assert len(domain_events) == 2 27 | 28 | 29 | def test_domain_events_clear(): 30 | domain_events = DomainEvents() 31 | domain_events.register(DomainEvent()) 32 | domain_events.register(DomainEvent()) 33 | domain_events.clear() 34 | assert len(domain_events) == 0 35 | -------------------------------------------------------------------------------- /winter/web/exceptions/exceptions.py: -------------------------------------------------------------------------------- 1 | import dataclasses 2 | from http import HTTPStatus 3 | from typing import Dict 4 | from typing import Optional 5 | from typing import Union 6 | 7 | from .problem import problem 8 | 9 | 10 | @problem(status=HTTPStatus.TOO_MANY_REQUESTS, detail='Request was throttled') 11 | class ThrottleException(Exception): 12 | pass 13 | 14 | 15 | class RedirectException(Exception): 16 | def __init__(self, redirect_to: str): 17 | super().__init__() 18 | self.redirect_to = redirect_to 19 | 20 | 21 | @problem(status=HTTPStatus.BAD_REQUEST) 22 | @dataclasses.dataclass 23 | class RequestDataDecodeException(Exception): 24 | errors: Dict = dataclasses.field(default_factory=dict) 25 | 26 | def __init__(self, errors: Optional[Union[str, Dict]]): 27 | super().__init__('Failed to decode request data') 28 | if type(errors) == dict: 29 | self.errors = errors 30 | else: 31 | self.errors = {'error': errors} 32 | 33 | 34 | @problem(status=HTTPStatus.UNSUPPORTED_MEDIA_TYPE) 35 | class UnsupportedMediaTypeException(Exception): 36 | pass 37 | -------------------------------------------------------------------------------- /winter/web/exceptions/raises.py: -------------------------------------------------------------------------------- 1 | from typing import Dict 2 | from typing import Optional 3 | from typing import TYPE_CHECKING 4 | from typing import Type 5 | 6 | import dataclasses 7 | 8 | from winter.core import ComponentMethod 9 | from winter.core import annotate 10 | 11 | if TYPE_CHECKING: 12 | from .handlers import ExceptionHandler # noqa: F401 13 | 14 | 15 | @dataclasses.dataclass 16 | class ExceptionAnnotation: 17 | exception_cls: Type[Exception] 18 | handler: Optional['ExceptionHandler'] = None 19 | 20 | 21 | def raises(exception_cls: Type[Exception], handler_cls: Optional[Type['ExceptionHandler']] = None): 22 | """Decorator to use on methods.""" 23 | if handler_cls is not None: 24 | handler = handler_cls() 25 | else: 26 | handler = None 27 | 28 | return annotate(ExceptionAnnotation(exception_cls, handler), unique=True) 29 | 30 | 31 | def get_raises(method: ComponentMethod) -> Dict[Type[Exception], 'ExceptionHandler']: 32 | annotations = method.annotations.get(ExceptionAnnotation) 33 | return {annotation.exception_cls: annotation.handler for annotation in annotations} 34 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | source = . 3 | branch = True 4 | disable_warnings = module-not-measured 5 | omit = samples/* 6 | 7 | [report] 8 | # Regexes for lines to exclude from consideration 9 | exclude_lines = 10 | # Have to re-enable the standard pragma 11 | pragma: no cover 12 | 13 | # Don't complain about missing debug-only code: 14 | if self/.debug 15 | if typing.TYPE_CHECKING: 16 | def __repr__ 17 | 18 | # Don't complain if tests don't hit defensive assertion code: 19 | raise AssertionError 20 | raise ImproperlyConfigured 21 | raise TypeError 22 | raise NotImplementedError 23 | warnings.warn 24 | logger.debug 25 | logger.info 26 | logger.warning 27 | logger.error 28 | return NotHandled 29 | 30 | # Don't complain if non-runnable code isn't run: 31 | if 0: 32 | if __name__ == .__main__.: 33 | if typing.TYPE_CHECKING: 34 | if TYPE_CHECKING: 35 | # Don't complain if abstract methods ain't being called 36 | @abstract 37 | 38 | ignore_errors = True 39 | precision = 2 40 | show_missing = True 41 | skip_covered = True 42 | 43 | [paths] 44 | source = . 45 | -------------------------------------------------------------------------------- /winter/web/pagination/page_processor.py: -------------------------------------------------------------------------------- 1 | import dataclasses 2 | from typing import Dict 3 | 4 | import django.http 5 | 6 | from winter.data.pagination import Page 7 | from winter.web.output_processor import IOutputProcessor 8 | from .utils import get_next_page_url 9 | from .utils import get_previous_page_url 10 | 11 | 12 | class PageProcessor(IOutputProcessor): 13 | 14 | def process_output(self, output: Page, request: django.http.HttpRequest) -> Dict: 15 | extra_fields = set(dataclasses.fields(output)) - set(dataclasses.fields(Page)) 16 | return { 17 | 'meta': { 18 | 'total_count': output.total_count, 19 | 'limit': output.position.limit, 20 | 'offset': output.position.offset, 21 | 'previous': get_previous_page_url(output, request), 22 | 'next': get_next_page_url(output, request), 23 | **{ 24 | extra_field.name: getattr(output, extra_field.name) 25 | for extra_field in extra_fields 26 | }, 27 | }, 28 | 'objects': output.items, 29 | } 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Alexander Egorov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /winter/core/docstring.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | import docstring_parser 4 | 5 | 6 | class Docstring: 7 | 8 | def __init__(self, doc: str): 9 | self._docstring = docstring_parser.parse(doc) 10 | self.params_docs = {param_doc.arg_name: param_doc for param_doc in self._docstring.params} 11 | 12 | @property 13 | def short_description(self) -> Optional[str]: 14 | return self._docstring.short_description 15 | 16 | @property 17 | def long_description(self) -> Optional[str]: 18 | return self._docstring.long_description 19 | 20 | def get_description(self) -> Optional[str]: 21 | blank_line = self._docstring.blank_after_short_description and '\n' or '' 22 | long_description = self.long_description and f'\n{blank_line}{self.long_description}' or '' 23 | return self.short_description and self.short_description + long_description 24 | 25 | def get_argument_description(self, argument_name: str) -> str: 26 | param_doc = self.params_docs.get(argument_name) 27 | description = param_doc.description if param_doc is not None else '' 28 | 29 | return description 30 | -------------------------------------------------------------------------------- /winter/web/response_header_serializers.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from typing import Optional 3 | from typing import Type 4 | 5 | import pytz 6 | 7 | from .response_header_serializer import ResponseHeaderSerializer 8 | 9 | 10 | class DateTimeResponseHeaderSerializer(ResponseHeaderSerializer): 11 | def is_supported(self, header_name: str, value_type: Optional[Type] = None) -> bool: 12 | return value_type == datetime.datetime 13 | 14 | def serialize(self, value, header_name: str) -> str: 15 | assert isinstance(value, datetime.datetime) 16 | return value.isoformat() 17 | 18 | 19 | class LastModifiedResponseHeaderSerializer(ResponseHeaderSerializer): 20 | HEADER_NAME = 'last-modified' 21 | 22 | def is_supported(self, header_name: str, value_type: Optional[Type] = None) -> bool: 23 | return header_name == self.HEADER_NAME and (value_type is None or value_type == datetime.datetime) 24 | 25 | def serialize(self, value, header_name: str) -> str: 26 | assert header_name == self.HEADER_NAME 27 | assert isinstance(value, datetime.datetime) 28 | return value.astimezone(pytz.utc).strftime('%a, %d %b %Y %X GMT') 29 | -------------------------------------------------------------------------------- /winter_openapi/inspectors/route_parameters_inspector.py: -------------------------------------------------------------------------------- 1 | import abc 2 | import logging 3 | from typing import List 4 | from typing import TYPE_CHECKING 5 | 6 | from openapi_pydantic import Parameter 7 | 8 | from winter.web.routing import Route 9 | 10 | if TYPE_CHECKING: 11 | from winter_openapi.generator import SchemaRegistry 12 | 13 | 14 | class RouteParametersInspector(abc.ABC): # pragma: no cover 15 | 16 | @abc.abstractmethod 17 | def inspect_parameters(self, route: 'Route', schema_registry: 'SchemaRegistry') -> List[Parameter]: 18 | return [] 19 | 20 | 21 | _route_parameters_inspectors: List[RouteParametersInspector] = [] 22 | 23 | 24 | def register_route_parameters_inspector(inspector: RouteParametersInspector): 25 | inspector_classes = [inspector.__class__ for inspector in get_route_parameters_inspectors()] 26 | 27 | if inspector.__class__ in inspector_classes: 28 | logging.warning(f'{inspector.__class__.__name__} already registered') 29 | 30 | _route_parameters_inspectors.append(inspector) 31 | 32 | 33 | def get_route_parameters_inspectors() -> List[RouteParametersInspector]: 34 | return _route_parameters_inspectors 35 | -------------------------------------------------------------------------------- /winter_ddd/domain_event_subscription.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | from dataclasses import dataclass 3 | from typing import Callable 4 | from typing import Tuple 5 | from typing import Type 6 | from typing import get_args 7 | 8 | from winter.core.utils.typing import is_iterable_type 9 | from winter.core.utils.typing import is_union 10 | from .domain_event import DomainEvent 11 | 12 | 13 | @dataclass(frozen=True) 14 | class DomainEventSubscription: 15 | event_filter: Tuple[Type[DomainEvent]] 16 | collection: bool 17 | handler_class: Type 18 | handler_method: Callable 19 | 20 | @staticmethod 21 | def create(handler_class, handler_method): 22 | func_spec = inspect.getfullargspec(handler_method) 23 | arg_type = func_spec.annotations[func_spec.args[1]] 24 | collection = is_iterable_type(arg_type) 25 | if collection: 26 | arg_type = get_args(arg_type)[0] 27 | if is_union(arg_type): 28 | domain_event_classes = tuple(get_args(arg_type)) 29 | else: 30 | domain_event_classes = (arg_type, ) 31 | return DomainEventSubscription(domain_event_classes, collection, handler_class, handler_method) 32 | -------------------------------------------------------------------------------- /tests/winter_sqlalchemy/test_sqla_sort.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from sqlalchemy import Column 3 | from sqlalchemy import Integer 4 | from sqlalchemy import MetaData 5 | from sqlalchemy import Table 6 | from sqlalchemy import create_engine 7 | from sqlalchemy import select 8 | 9 | from winter.data.pagination import Sort 10 | from winter_sqlalchemy import sort 11 | 12 | 13 | @pytest.mark.parametrize( 14 | 'sort_, expected_ids', [ 15 | (Sort.by('id'), [1, 2, 3]), 16 | (Sort.by('id').desc(), [3, 2, 1]), 17 | ], 18 | ) 19 | def test_sort(id_database, sort_, expected_ids): 20 | engine, table = id_database 21 | statement = sort(select([table.c.id]), sort_) 22 | result = engine.execute(statement) 23 | ids = [row[0] for row in result] 24 | 25 | assert ids == expected_ids 26 | 27 | 28 | @pytest.fixture(scope='module') 29 | def id_database(): 30 | engine = create_engine('sqlite://') 31 | metadata = MetaData(engine) 32 | table = Table('table', metadata, Column('id', Integer, primary_key=True)) 33 | metadata.create_all() 34 | rows = [{'id': value} for value in range(1, 4)] 35 | engine.execute(table.insert(), *rows) 36 | return engine, table 37 | -------------------------------------------------------------------------------- /winter/web/pagination/utils.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | import django.http 4 | from furl import furl 5 | 6 | from winter.data.pagination import Page 7 | 8 | 9 | def get_previous_page_url(page: Page, request: django.http.HttpRequest) -> Optional[str]: 10 | offset = page.position.offset 11 | limit = page.position.limit 12 | 13 | if not offset or limit is None: 14 | return None 15 | 16 | url = furl(request.get_full_path()) 17 | url.query.set([('limit', limit)]) 18 | 19 | previous_offset = offset - limit 20 | 21 | if previous_offset <= 0: 22 | url.query.remove('offset') 23 | else: 24 | url.query.set([('offset', previous_offset)]) 25 | 26 | return url.tostr() 27 | 28 | 29 | def get_next_page_url(page: Page, request: django.http.HttpRequest) -> Optional[str]: 30 | offset = page.position.offset or 0 31 | limit = page.position.limit 32 | total = page.total_count 33 | 34 | if limit is None: 35 | return None 36 | 37 | next_offset = offset + limit 38 | 39 | if next_offset >= total: 40 | return None 41 | 42 | url = furl(request.get_full_path()) 43 | url.query.set([ 44 | ('limit', limit), 45 | ('offset', next_offset), 46 | ]) 47 | return url.tostr() 48 | -------------------------------------------------------------------------------- /tests/core/test_module_discovery.py: -------------------------------------------------------------------------------- 1 | import mock 2 | 3 | from winter.core import Component 4 | from winter.core.module_discovery import get_all_classes 5 | from winter.core.module_discovery import get_all_subclasses 6 | 7 | 8 | def test_module_import(): 9 | found_classes = list(get_all_classes('winter.web')) 10 | # Verifying that some class (it can be any class) could be found 11 | assert ('Component', Component) not in found_classes 12 | 13 | 14 | @mock.patch('inspect.getmembers') 15 | def test_module_import_with_exception(getmembers): 16 | # Verifying that import error during search of class will not stop the search 17 | getmembers.side_effect = ImportError 18 | found_classes = list(get_all_classes('winter.core')) 19 | # It can't find any class, because we have ImportError on any class 20 | assert not found_classes 21 | 22 | 23 | def test_all_subclasses(): 24 | exception_subclasses = list(get_all_subclasses(Exception)) 25 | assert ('OverflowError', OverflowError) in exception_subclasses 26 | 27 | 28 | @mock.patch('_weakref.ReferenceType') 29 | def test_all_subclasses_with_exception(reference_type): 30 | reference_type.side_effect = TypeError 31 | exception_subclasses = list(get_all_subclasses(Exception)) 32 | assert not exception_subclasses 33 | -------------------------------------------------------------------------------- /tests/winter_openapi/test_add_url_segment_as_tag.py: -------------------------------------------------------------------------------- 1 | import winter 2 | from winter.web.routing import get_route 3 | from winter_openapi import generate_openapi 4 | 5 | 6 | def test_add_url_segment_as_tag_false(): 7 | class _TestAPI: 8 | @winter.route_get('resource') 9 | def get_resource(self): # pragma: no cover 10 | pass 11 | 12 | route = get_route(_TestAPI.get_resource) 13 | # Act 14 | result = generate_openapi( 15 | title='title', 16 | version='1.0.0', 17 | description='description', 18 | routes=[route], 19 | add_url_segment_as_tag=False, 20 | ) 21 | # Assert 22 | assert result == { 23 | 'components': {'parameters': {}, 'responses': {}, 'schemas': {}}, 24 | 'info': {'description': 'description', 'title': 'title', 'version': '1.0.0'}, 25 | 'openapi': '3.0.3', 26 | 'paths': { 27 | '/resource': { 28 | 'get': { 29 | 'deprecated': False, 30 | 'operationId': '_TestAPI.get_resource', 31 | 'parameters': [], 32 | 'responses': {'200': {'description': ''}}, 33 | 'tags': [], 34 | }, 35 | }, 36 | }, 37 | 'servers': [{'url': '/'}], 38 | 'tags': [] 39 | } 40 | -------------------------------------------------------------------------------- /tests/winter_openapi/test_validators.py: -------------------------------------------------------------------------------- 1 | from http import HTTPStatus 2 | 3 | import pytest 4 | 5 | import winter 6 | from winter.web import problem 7 | from winter.web.routing import get_route 8 | from winter_openapi import generate_openapi 9 | from winter_openapi import validate_missing_raises_annotations 10 | 11 | 12 | def test_validate_missing_raises_annotations_with_missed_raises_and_not_global_expect_assert_exception(): 13 | @problem(HTTPStatus.BAD_REQUEST) 14 | class MissingException(Exception): 15 | pass 16 | expected_error_message = f'You are missing declaration for next exceptions: {MissingException.__name__}' 17 | 18 | with pytest.raises(AssertionError, match=expected_error_message): 19 | validate_missing_raises_annotations() 20 | 21 | 22 | @pytest.mark.parametrize('validate', [True, False]) 23 | def test_validate_spec(validate): 24 | """ 25 | There is no way to check the case when the spec is invalid. 26 | So it's mostly to execute both branches of the code and check that it doesn't fail. 27 | """ 28 | class _TestAPI: 29 | @winter.route_get('/test-api/') 30 | def endpoint(self): # pragma: no cover 31 | pass 32 | 33 | route = get_route(_TestAPI.endpoint) 34 | 35 | # Act 36 | generate_openapi(title='title', version='1.0.0', routes=[route], validate=validate) 37 | -------------------------------------------------------------------------------- /winter/web/pagination/limits.py: -------------------------------------------------------------------------------- 1 | import warnings 2 | from http import HTTPStatus 3 | from typing import Optional 4 | 5 | import dataclasses 6 | 7 | from winter.core import annotate 8 | from ..exceptions import problem 9 | 10 | 11 | @problem(HTTPStatus.BAD_REQUEST) 12 | class MaximumLimitValueExceeded(Exception): 13 | def __init__(self, maximum_limit: int): 14 | super().__init__(f'Maximum limit value is exceeded: {maximum_limit}') 15 | 16 | 17 | @dataclasses.dataclass(frozen=True) 18 | class Limits: 19 | default: Optional[int] 20 | maximum: Optional[int] 21 | redirect_to_default: bool 22 | 23 | def __post_init__(self): 24 | if self.redirect_to_default and self.default is None: 25 | warnings.warn( 26 | 'PagePositionArgumentResolver: redirect_to_default_limit is set to True, ' 27 | 'however it has no effect as default_limit is not specified', 28 | UserWarning, 29 | ) 30 | 31 | 32 | @dataclasses.dataclass(frozen=True) 33 | class LimitsAnnotation: 34 | limits: Limits 35 | 36 | 37 | def limits(*, default: Optional[int], maximum: Optional[int], redirect_to_default: bool = False): 38 | limits_ = Limits(default=default, maximum=maximum, redirect_to_default=redirect_to_default) 39 | annotation = LimitsAnnotation(limits_) 40 | return annotate(annotation, single=True) 41 | -------------------------------------------------------------------------------- /winter/web/response_header_resolver.py: -------------------------------------------------------------------------------- 1 | from typing import MutableMapping 2 | 3 | import django.http 4 | 5 | from winter.core import ComponentMethodArgument 6 | from winter.core.utils.typing import is_origin_type_subclasses 7 | from .argument_resolver import ArgumentResolver 8 | from .response_header_annotation import ResponseHeader 9 | from .response_header_annotation import ResponseHeaderAnnotation 10 | 11 | 12 | class ResponseHeaderArgumentResolver(ArgumentResolver): 13 | 14 | def is_supported(self, argument: ComponentMethodArgument) -> bool: 15 | if not is_origin_type_subclasses(argument.type_, ResponseHeader): 16 | return False 17 | annotations = argument.method.annotations.get(ResponseHeaderAnnotation) 18 | return any(annotation.argument_name == argument.name for annotation in annotations) 19 | 20 | def resolve_argument( 21 | self, 22 | argument: ComponentMethodArgument, 23 | request: django.http.HttpRequest, 24 | response_headers: MutableMapping[str, str], 25 | ): 26 | annotations = argument.method.annotations.get(ResponseHeaderAnnotation) 27 | annotation = [annotation for annotation in annotations if annotation.argument_name == argument.name][0] 28 | header_name = annotation.header_name 29 | header = argument.type_(response_headers, header_name) 30 | return header 31 | -------------------------------------------------------------------------------- /tests/winter_openapi/test_metadata_spec.py: -------------------------------------------------------------------------------- 1 | import winter 2 | from winter.web.routing import get_route 3 | from winter_openapi import generate_openapi 4 | 5 | 6 | def test_generate_openapi_with_all_args_spec(): 7 | class _TestAPI: 8 | @winter.route_get('resource') 9 | def get_resource(self): # pragma: no cover 10 | pass 11 | 12 | route = get_route(_TestAPI.get_resource) 13 | tags = [{'name': 'tag_value'}] 14 | # Act 15 | result = generate_openapi( 16 | title='title', 17 | version='1.0.0', 18 | description='description', 19 | routes=[route], 20 | tags=tags, 21 | ) 22 | # Assert 23 | assert result == { 24 | 'components': {'parameters': {}, 'responses': {}, 'schemas': {}}, 25 | 'info': {'description': 'description', 'title': 'title', 'version': '1.0.0'}, 26 | 'openapi': '3.0.3', 27 | 'paths': { 28 | '/resource': { 29 | 'get': { 30 | 'deprecated': False, 31 | 'operationId': '_TestAPI.get_resource', 32 | 'parameters': [], 33 | 'responses': {'200': {'description': ''}}, 34 | 'tags': ['tag_value', 'resource'], 35 | }, 36 | }, 37 | }, 38 | 'servers': [{'url': '/'}], 39 | 'tags': [{'name': 'tag_value'}] 40 | } 41 | -------------------------------------------------------------------------------- /tests/api/api_with_request_data.py: -------------------------------------------------------------------------------- 1 | import enum 2 | from typing import List 3 | from typing import NotRequired 4 | from typing import Optional 5 | 6 | import dataclasses 7 | from typing import Required 8 | from typing import TypeAlias 9 | from typing import TypedDict 10 | 11 | import winter 12 | 13 | 14 | class Status(enum.Enum): 15 | ACTIVE = 'active' 16 | SUPER_ACTIVE = 'super_active' 17 | 18 | 19 | ItemsTypeAlias: TypeAlias = list[int] 20 | 21 | 22 | class TypedDictExample(TypedDict): 23 | field: str 24 | required_field: Required[int] 25 | optional_field: NotRequired[int] 26 | 27 | 28 | @dataclasses.dataclass 29 | class Data: 30 | id: int 31 | name: str 32 | is_god: bool 33 | optional_status: Optional[Status] 34 | optional_status_new_typing_style: Status | None 35 | status: Status 36 | items: List[int] 37 | items_alias: ItemsTypeAlias 38 | optional_items: Optional[List[int]] 39 | optional_items_new_typing_style: list[int] | None 40 | typed_dict: TypedDictExample 41 | with_default: int = 5 42 | 43 | 44 | class APIWithRequestData: 45 | 46 | @winter.request_body('data') 47 | @winter.route_post('with-request-data/{?query}') 48 | def method(self, query: Optional[str], data: Data) -> Data: 49 | return data 50 | 51 | @winter.request_body('data') 52 | @winter.route_post('with-request-data/many/') 53 | def many_method(self, data: List[Data]) -> List[Data]: 54 | return data 55 | -------------------------------------------------------------------------------- /winter/core/component_method_argument.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | from functools import cached_property 3 | from typing import Any 4 | from typing import TYPE_CHECKING 5 | from typing import Type 6 | 7 | import dataclasses 8 | 9 | from .utils.typing import is_optional 10 | 11 | if TYPE_CHECKING: # pragma: no cover 12 | from .component_method import ComponentMethod 13 | 14 | 15 | class ArgumentDoesNotHaveDefault(Exception): 16 | 17 | def __init__(self, argument: 'ComponentMethodArgument'): 18 | self.argument = argument 19 | 20 | def __str__(self): 21 | return f'{self.argument} does not have get_default' 22 | 23 | 24 | @dataclasses.dataclass(frozen=True) 25 | class ComponentMethodArgument: 26 | method: 'ComponentMethod' 27 | name: str 28 | type_: Type 29 | 30 | @cached_property 31 | def parameter(self) -> inspect.Parameter: 32 | return self.method.signature.parameters[self.name] 33 | 34 | def get_default(self) -> Any: 35 | if self.parameter.default is not inspect.Parameter.empty: 36 | return self.parameter.default 37 | if is_optional(self.type_): 38 | return None 39 | raise ArgumentDoesNotHaveDefault(self) 40 | 41 | @property 42 | def description(self): 43 | return self.method.docstring.get_argument_description(self.name) 44 | 45 | @property 46 | def required(self) -> bool: 47 | return self.parameter.default is inspect.Parameter.empty and not is_optional(self.type_) 48 | -------------------------------------------------------------------------------- /tests/core/test_component_method.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | 3 | from winter.core import ComponentMethod 4 | from winter.core import ComponentMethodArgument 5 | from winter.core import WinterApplication 6 | from winter.core import component_method 7 | 8 | 9 | def test_all_attributes(): 10 | winter_app = WinterApplication() 11 | argument_type = int 12 | argument_name = 'number' 13 | method_name = 'method' 14 | 15 | class SimpleComponent: 16 | 17 | @component_method 18 | def method(self, number: argument_type): 19 | return self, number 20 | 21 | winter_app.add_component(SimpleComponent) 22 | simple_component = SimpleComponent() 23 | 24 | cls_method = SimpleComponent.method 25 | 26 | assert cls_method.component == winter_app.components[SimpleComponent] 27 | assert cls_method.name == method_name 28 | assert inspect.ismethod(simple_component.method) 29 | assert cls_method.func == simple_component.method.__func__ 30 | assert cls_method(simple_component, 123) == (simple_component, 123) 31 | assert simple_component.method(123) == (simple_component, 123) 32 | number_argument = ComponentMethodArgument(cls_method, argument_name, argument_type) 33 | assert cls_method.arguments == (number_argument,) 34 | assert number_argument == cls_method.get_argument(argument_name) 35 | 36 | 37 | def test_component_method(): 38 | def test(): # pragma: no cover 39 | pass 40 | 41 | method = ComponentMethod(test) 42 | assert method is component_method(method) 43 | -------------------------------------------------------------------------------- /winter/web/path_parameters_argument_resolver.py: -------------------------------------------------------------------------------- 1 | from typing import MutableMapping 2 | 3 | import django.http 4 | from django.urls import get_resolver 5 | 6 | from winter.core import ComponentMethodArgument 7 | from .argument_resolver import ArgumentNotSupported 8 | from .argument_resolver import ArgumentResolver 9 | from .routing import get_route 10 | 11 | 12 | class PathParametersArgumentResolver(ArgumentResolver): 13 | 14 | def __init__(self): 15 | super().__init__() 16 | self._url_resolver = get_resolver() 17 | self._cache = {} 18 | 19 | def is_supported(self, argument: ComponentMethodArgument) -> bool: 20 | if argument in self._cache: 21 | return self._cache[argument] 22 | 23 | route = get_route(argument.method) 24 | if route is None: 25 | return False 26 | 27 | path_variables = route.get_path_variables() 28 | is_supported = self._cache[argument] = argument.name in path_variables 29 | return is_supported 30 | 31 | def resolve_argument( 32 | self, 33 | argument: ComponentMethodArgument, 34 | request: django.http.HttpRequest, 35 | response_headers: MutableMapping[str, str], 36 | ): 37 | resolver_match = self._url_resolver.resolve(request.path_info) 38 | callback, callback_args, callback_kwargs = resolver_match 39 | 40 | if argument.name not in callback_kwargs: 41 | raise ArgumentNotSupported(argument) 42 | 43 | return argument.type_(callback_kwargs[argument.name]) 44 | -------------------------------------------------------------------------------- /winter/web/request_body_resolver.py: -------------------------------------------------------------------------------- 1 | import json 2 | from json import JSONDecodeError 3 | from typing import MutableMapping 4 | 5 | import django.http 6 | 7 | from .argument_resolver import ArgumentResolver 8 | from .exceptions import UnsupportedMediaTypeException 9 | from .exceptions import RequestDataDecodeException 10 | from .request_body_annotation import RequestBodyAnnotation 11 | from ..core import ComponentMethodArgument 12 | from ..core.json import JSONDecodeException 13 | from ..core.json import json_decode 14 | 15 | 16 | class RequestBodyArgumentResolver(ArgumentResolver): 17 | 18 | def is_supported(self, argument: ComponentMethodArgument) -> bool: 19 | annotation = argument.method.annotations.get_one_or_none(RequestBodyAnnotation) 20 | if annotation is None: 21 | return False 22 | return annotation.argument_name == argument.name 23 | 24 | def resolve_argument( 25 | self, 26 | argument: ComponentMethodArgument, 27 | request: django.http.HttpRequest, 28 | response_headers: MutableMapping[str, str], 29 | ): 30 | if 'CONTENT_TYPE' in request.META and request.META['CONTENT_TYPE'] != 'application/json': 31 | raise UnsupportedMediaTypeException() 32 | try: 33 | return json_decode(json.loads(request.body), argument.type_) 34 | except JSONDecodeException as e: 35 | raise RequestDataDecodeException(e.errors) 36 | except JSONDecodeError as e: 37 | raise RequestDataDecodeException(f'Invalid JSON: {e}') 38 | -------------------------------------------------------------------------------- /samples/sample_winter_api.py: -------------------------------------------------------------------------------- 1 | from http import HTTPStatus 2 | from typing import List 3 | 4 | from dataclasses import dataclass 5 | 6 | import winter 7 | 8 | 9 | @dataclass 10 | class GreetingRequest: 11 | name: str 12 | 13 | 14 | class TestRepository: 15 | def get_by_id(self, id_: int): 16 | return 123 17 | 18 | 19 | @dataclass 20 | class Greeting: 21 | message: str 22 | name: str 23 | 24 | 25 | @winter.route('winter_sample/') 26 | class SampleWinterAPI: 27 | 28 | def __init__(self, test_repository: TestRepository): 29 | self._test_repository = test_repository 30 | 31 | @winter.route_get('hello{?name}') 32 | @winter.map_query_parameter('name', to='names') 33 | def hello(self, names: List[str] = None) -> str: 34 | names = ', '.join(names or ['stranger']) 35 | return f'Hello, {names}!' 36 | 37 | @winter.route_get('foo/{number}{?name}') 38 | def hello_with_response_code(self, name: str, number: int) -> winter.ResponseEntity[str]: 39 | """ 40 | :param name: Just a name 41 | :param number: Just a number 42 | """ 43 | return winter.ResponseEntity[str](f'Hello, {name}! {number}', HTTPStatus.CREATED) 44 | 45 | @winter.route_get('example{?name}') 46 | def second_hello(self, name: str) -> Greeting: 47 | return Greeting('Welcome', name) 48 | 49 | @winter.route_post('example2') 50 | @winter.request_body('greeting') 51 | def third_hello(self, greeting: GreetingRequest) -> Greeting: 52 | return Greeting(message='got it', name=greeting.name) 53 | -------------------------------------------------------------------------------- /winter/messaging/event_subscription_registry.py: -------------------------------------------------------------------------------- 1 | from typing import Dict 2 | from typing import Iterable 3 | from typing import List 4 | from typing import Type 5 | 6 | from winter.core import Component 7 | from winter.core.module_discovery import get_all_classes 8 | from winter.core.module_discovery import import_recursively 9 | from .event import Event 10 | from .event_handler import EventHandlerAnnotation 11 | from .event_subscription import EventSubscription 12 | from ..core import ComponentMethod 13 | 14 | 15 | class EventSubscriptionRegistry: 16 | def __init__(self): 17 | self._event_type_to_subscription_map: Dict[Type[Event], List[EventSubscription]] = {} 18 | 19 | def register_subscription(self, handler_method: ComponentMethod): 20 | subscription = EventSubscription.create(handler_method) 21 | 22 | for event_type in subscription.event_filter: 23 | self._event_type_to_subscription_map.setdefault(event_type, []).append(subscription) 24 | 25 | def get_subscriptions(self, event_type: Type[Event]) -> Iterable[EventSubscription]: 26 | return self._event_type_to_subscription_map.get(event_type, []) 27 | 28 | def autodiscover(self, package_name: str): 29 | import_recursively(package_name) 30 | for class_name, class_ in get_all_classes(package_name): 31 | component = Component.get_by_cls(class_) 32 | for component_method in component.methods: 33 | if component_method.annotations.get_one_or_none(EventHandlerAnnotation): 34 | self.register_subscription(component_method) 35 | -------------------------------------------------------------------------------- /winter/data/pagination/sort.py: -------------------------------------------------------------------------------- 1 | import enum 2 | import itertools 3 | from typing import Tuple 4 | 5 | import dataclasses 6 | 7 | 8 | class SortDirection(enum.Enum): 9 | ASC = 'ASC' 10 | DESC = 'DESC' 11 | 12 | 13 | @dataclasses.dataclass(frozen=True) 14 | class Order: 15 | field: str 16 | direction: SortDirection = SortDirection.ASC 17 | 18 | def __str__(self): 19 | return ('-' if self.direction == SortDirection.DESC else '') + self.field 20 | 21 | 22 | @dataclasses.dataclass(frozen=True, init=False, repr=False) 23 | class Sort: 24 | orders: Tuple[Order] 25 | 26 | def __init__(self, *orders: Order): 27 | object.__setattr__(self, 'orders', orders) 28 | 29 | @staticmethod 30 | def by(*fields: str) -> 'Sort': 31 | if len(fields) == 0: 32 | raise ValueError('Specify at least one field.') 33 | 34 | orders = (Order(field=field) for field in fields) 35 | return Sort(*orders) 36 | 37 | def and_(self, sort: 'Sort') -> 'Sort': 38 | orders = itertools.chain(self.orders, sort.orders) 39 | return Sort(*orders) 40 | 41 | def asc(self) -> 'Sort': 42 | orders = (Order(field=order.field, direction=SortDirection.ASC) for order in self.orders) 43 | return Sort(*orders) 44 | 45 | def desc(self) -> 'Sort': 46 | orders = (Order(field=order.field, direction=SortDirection.DESC) for order in self.orders) 47 | return Sort(*orders) 48 | 49 | def __str__(self): 50 | return ','.join(map(str, self.orders)) 51 | 52 | def __repr__(self): 53 | return f"Sort('{self}')" 54 | -------------------------------------------------------------------------------- /tests/api/api_with_throttling.py: -------------------------------------------------------------------------------- 1 | from http import HTTPStatus 2 | 3 | from django.http import HttpRequest 4 | 5 | import winter.web 6 | from winter.web import ExceptionHandler 7 | from winter.web.exceptions import ThrottleException 8 | from winter.web.throttling import reset 9 | 10 | 11 | class CustomThrottleExceptionHandler(ExceptionHandler): 12 | @winter.response_status(HTTPStatus.TOO_MANY_REQUESTS) 13 | def handle(self, exception: ThrottleException) -> str: 14 | return 'custom throttle exception' 15 | 16 | 17 | @winter.route_get('with-throttling/') 18 | class APIWithThrottling: 19 | 20 | @winter.route_get() 21 | @winter.web.throttling('5/s') 22 | def simple_method(self) -> int: 23 | return 1 24 | 25 | @winter.route_post() 26 | def simple_post_method(self) -> int: 27 | return 1 28 | 29 | @winter.route_get('same/') 30 | @winter.web.throttling('5/s') 31 | def same_simple_method(self) -> int: 32 | return 1 33 | 34 | @winter.route_get('without-throttling/') 35 | def method_without_throttling(self): 36 | pass 37 | 38 | @winter.route_get('custom-handler/') 39 | @winter.web.throttling('5/s') 40 | @winter.raises(ThrottleException, CustomThrottleExceptionHandler) 41 | def simple_method_with_custom_handler(self) -> int: 42 | return 1 43 | 44 | @winter.route_get('with-reset/{?is_reset}') 45 | @winter.web.throttling('5/s', 'reset_scope') 46 | def simple_method_with_reset(self, request: HttpRequest, is_reset: bool) -> int: 47 | if is_reset: 48 | reset(request, 'reset_scope') 49 | return 1 50 | -------------------------------------------------------------------------------- /tests/api/api_with_query_parameters.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from typing import Any 3 | from typing import List 4 | from typing import Optional 5 | from typing import TypeAlias 6 | from uuid import UUID 7 | 8 | import winter 9 | 10 | ArrayAlias: TypeAlias = list[int] 11 | 12 | 13 | @winter.route('with-query-parameter') 14 | class APIWithQueryParameters: 15 | 16 | @winter.map_query_parameter('string', to='mapped_string') 17 | @winter.route_get('/{?date,boolean,optional_boolean,optional_boolean_new_typing_style,date_time,array,array_new_typing_style,array_alias,expanded_array*,string,uid}') 18 | def root( 19 | self, 20 | date: datetime.date, 21 | date_time: datetime.datetime, 22 | boolean: bool, 23 | array: List[int], 24 | array_new_typing_style: list[int], 25 | array_alias: ArrayAlias, 26 | expanded_array: List[str], 27 | mapped_string: str, 28 | uid: UUID, 29 | optional_boolean: Optional[bool] = None, 30 | optional_boolean_new_typing_style: bool | None = None, 31 | ) -> dict[str, Any]: 32 | return { 33 | 'date': date, 34 | 'date_time': date_time, 35 | 'boolean': boolean, 36 | 'optional_boolean': optional_boolean, 37 | 'optional_boolean_new_typing_style': optional_boolean_new_typing_style, 38 | 'array': array, 39 | 'array_new_typing_style': array_new_typing_style, 40 | 'array_alias': array_alias, 41 | 'expanded_array': expanded_array, 42 | 'string': mapped_string, 43 | 'uid': str(uid), 44 | } 45 | -------------------------------------------------------------------------------- /winter_openapi/validators.py: -------------------------------------------------------------------------------- 1 | from typing import Iterable 2 | from typing import Set 3 | from typing import Type 4 | 5 | 6 | from winter_openapi.annotations import GlobalExceptionAnnotation 7 | 8 | 9 | def validate_missing_raises_annotations(): 10 | from winter.web.exceptions.problem_annotation import ProblemAnnotation 11 | from winter.web.exceptions.raises import ExceptionAnnotation 12 | from winter.core import Component 13 | 14 | all_problem_exception: Set[Type[Exception]] = set() 15 | global_exceptions: Set[Type[Exception]] = set() 16 | declared_raises_exceptions: Set[Type[Exception]] = set() 17 | for cls, component in Component.get_all().items(): 18 | if component.annotations.get_one_or_none(ProblemAnnotation): 19 | all_problem_exception.add(cls) 20 | if component.annotations.get_one_or_none(GlobalExceptionAnnotation): 21 | global_exceptions.add(cls) 22 | for method_component in component.methods: 23 | exception_annotations: Iterable[ExceptionAnnotation] = method_component.annotations.get(ExceptionAnnotation) 24 | for exception_annotation in exception_annotations: 25 | declared_raises_exceptions.add(exception_annotation.exception_cls) 26 | 27 | not_global_exceptions = all_problem_exception - global_exceptions 28 | missing_exceptions = not_global_exceptions - declared_raises_exceptions 29 | if missing_exceptions: 30 | message = ', '.join(exc.__name__ for exc in sorted(missing_exceptions, key=lambda ex: ex.__name__)) 31 | raise AssertionError('You are missing declaration for next exceptions: ' + message) 32 | -------------------------------------------------------------------------------- /tests/winter_sqlalchemy/test_paginate.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from sqlalchemy import Column 3 | from sqlalchemy import Integer 4 | from sqlalchemy import MetaData 5 | from sqlalchemy import Table 6 | from sqlalchemy import create_engine 7 | from sqlalchemy import select 8 | 9 | from winter.data.pagination import PagePosition 10 | from winter.data.pagination import Sort 11 | from winter_sqlalchemy import paginate 12 | 13 | 14 | @pytest.mark.parametrize( 15 | 'limit, offset, sort, expected_ids', [ 16 | (None, None, None, None), 17 | (None, None, Sort.by('id'), [1, 2, 3, 4, 5]), 18 | (None, None, Sort.by('id').desc(), [5, 4, 3, 2, 1]), 19 | (3, None, Sort.by('id'), [1, 2, 3]), 20 | (6, None, Sort.by('id'), [1, 2, 3, 4, 5]), 21 | (2, 2, Sort.by('id'), [3, 4]), 22 | (None, 2, Sort.by('id'), [3, 4, 5]), 23 | ], 24 | ) 25 | def test_paginate(id_database, limit, offset, sort, expected_ids): 26 | engine, table = id_database 27 | statement = paginate(select([table.c.id]), PagePosition(limit, offset, sort)) 28 | result = engine.execute(statement) 29 | ids = [row[0] for row in result] 30 | if expected_ids is None: 31 | assert len(ids) == 5 32 | else: 33 | assert ids == expected_ids 34 | 35 | 36 | @pytest.fixture(scope='module') 37 | def id_database(): 38 | engine = create_engine('sqlite://') 39 | metadata = MetaData(engine) 40 | table = Table('table', metadata, Column('id', Integer, primary_key=True)) 41 | metadata.create_all() 42 | rows = [{'id': value} for value in range(1, 6)] 43 | engine.execute(table.insert(), *rows) 44 | return engine, table 45 | -------------------------------------------------------------------------------- /winter/web/response_header_serializer.py: -------------------------------------------------------------------------------- 1 | import abc 2 | from abc import abstractmethod 3 | from typing import List 4 | from typing import Optional 5 | from typing import Type 6 | 7 | 8 | class ResponseHeaderSerializer(abc.ABC): 9 | """ResponseHeaderSerializer interface is used to convert different response header values to string.""" 10 | 11 | @abstractmethod 12 | def is_supported(self, header_name: str, value_type: Optional[Type] = None) -> bool: # pragma: no cover 13 | pass 14 | 15 | @abstractmethod 16 | def serialize(self, value, header_name: str) -> str: # pragma: no cover 17 | pass 18 | 19 | 20 | class ResponseHeadersSerializer: 21 | def __init__(self): 22 | self._serializers: List[ResponseHeaderSerializer] = [] 23 | 24 | def add_serializer(self, serializer: ResponseHeaderSerializer): 25 | self._serializers.append(serializer) 26 | 27 | def serialize(self, value, header_name: str) -> str: 28 | serializer = self._get_serializer(header_name) 29 | if serializer is not None: 30 | return serializer.serialize(value, header_name) 31 | 32 | serializer = self._get_serializer(header_name, type(value)) 33 | if serializer is not None: 34 | return serializer.serialize(value, header_name) 35 | 36 | return str(value) 37 | 38 | def _get_serializer( 39 | self, 40 | header_name: str, 41 | value_type: Optional[Type] = None, 42 | ) -> Optional[ResponseHeaderSerializer]: 43 | for serializer in self._serializers: 44 | if serializer.is_supported(header_name, value_type): 45 | return serializer 46 | return None 47 | 48 | 49 | response_headers_serializer = ResponseHeadersSerializer() 50 | -------------------------------------------------------------------------------- /winter/web/query_parameters/query_parameters_argument_resolver.py: -------------------------------------------------------------------------------- 1 | import dataclasses 2 | from typing import MutableMapping 3 | 4 | import django.http 5 | 6 | from .query_parameter_argument_resolver import QueryParameterArgumentResolver 7 | from .query_parameters_annotation import QueryParametersAnnotation 8 | from ..argument_resolver import ArgumentResolver 9 | from ..routing import get_route 10 | from ...core import ComponentMethodArgument 11 | from ...core.json import json_decode 12 | from ...core.utils.typing import is_iterable_type 13 | 14 | 15 | class QueryParametersArgumentResolver(ArgumentResolver): 16 | 17 | def is_supported(self, argument: ComponentMethodArgument) -> bool: 18 | annotation = argument.method.annotations.get_one_or_none(QueryParametersAnnotation) 19 | if annotation is None: 20 | return False 21 | return annotation.argument.name == argument.name 22 | 23 | def resolve_argument( 24 | self, 25 | argument: ComponentMethodArgument, 26 | request: django.http.HttpRequest, 27 | response_headers: MutableMapping[str, str], 28 | ): 29 | kwargs = {} 30 | query_parameters = get_route(argument.method).get_query_parameters() 31 | query_parameters_map = {query_parameter.name: query_parameter for query_parameter in query_parameters} 32 | for field in dataclasses.fields(argument.type_): 33 | value = QueryParameterArgumentResolver.get_value( 34 | request.GET, 35 | field.name, 36 | is_iterable_type(field.type), 37 | query_parameters_map[field.name].explode, 38 | ) 39 | kwargs[field.name] = json_decode(value, field.type) 40 | return argument.type_(**kwargs) 41 | -------------------------------------------------------------------------------- /winter_openapi/inspection/inspection.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | from typing import Callable 3 | from typing import Dict 4 | from typing import List 5 | from typing import Optional 6 | from typing import Tuple 7 | from typing import Type 8 | 9 | from winter.core.utils.typing import get_origin_type 10 | from .type_info import TypeInfo 11 | 12 | _inspectors_by_type: Dict[ 13 | Type, 14 | List[Tuple[Callable, Optional[Callable]]], 15 | ] = {} 16 | 17 | 18 | def register_type_inspector(*types_: Type, checker: Callable = None, func: Callable = None): 19 | if func is None: 20 | return lambda func: register_type_inspector(*types_, checker=checker, func=func) 21 | 22 | for type_ in types_: 23 | callables = _inspectors_by_type.setdefault(type_, []) 24 | callables.append((func, checker)) 25 | return func 26 | 27 | 28 | class InspectorNotFound(Exception): 29 | 30 | def __init__(self, hint_cls): 31 | self.hint_cls = hint_cls 32 | 33 | def __str__(self): 34 | return f'Unknown type: {self.hint_cls}' 35 | 36 | 37 | def inspect_type(hint_class) -> TypeInfo: 38 | origin_type = get_origin_type(hint_class) 39 | 40 | types_ = origin_type.mro() if inspect.isclass(origin_type) else type(origin_type).mro() 41 | 42 | for type_ in types_: 43 | inspectors = _inspectors_by_type.get(type_, []) 44 | type_info = _inspect_type(hint_class, inspectors) 45 | 46 | if type_info is not None: 47 | return type_info 48 | 49 | raise InspectorNotFound(hint_class) 50 | 51 | 52 | def _inspect_type( 53 | hint_class, 54 | inspectors: List[Tuple[Callable, Optional[Callable]]], 55 | ) -> Optional[TypeInfo]: 56 | for inspector, checker in inspectors: 57 | if checker is None or checker(hint_class): 58 | return inspector(hint_class) 59 | -------------------------------------------------------------------------------- /winter/core/module_discovery.py: -------------------------------------------------------------------------------- 1 | import importlib 2 | import inspect 3 | import pkgutil 4 | import sys 5 | from types import ModuleType 6 | from typing import List, Generator, Dict, Union 7 | from typing import Tuple 8 | from typing import Type 9 | 10 | 11 | def get_all_classes(package: str) -> Generator[Tuple[str, Type], None, None]: 12 | classes_set = set() 13 | for module in import_recursively(package): 14 | try: 15 | for class_name, class_ in inspect.getmembers(module, inspect.isclass): 16 | if class_ in classes_set: 17 | continue 18 | if not class_.__module__.startswith(package): 19 | continue 20 | yield class_name, class_ 21 | classes_set.add(class_) 22 | except ImportError: 23 | pass 24 | 25 | 26 | def get_all_subclasses(supertype: Type) -> Generator[Tuple[str, Type], None, None]: 27 | for module in dict(sys.modules).values(): 28 | try: 29 | for class_name, class_ in inspect.getmembers(module, inspect.isclass): 30 | try: 31 | # Workaround to not fail in the issubclass check 32 | from _weakref import ReferenceType 33 | 34 | ReferenceType(class_) 35 | except TypeError: 36 | continue 37 | if class_ is not supertype and issubclass(class_, supertype): 38 | yield class_name, class_ 39 | except ImportError: # pragma: no cover 40 | pass 41 | 42 | 43 | def import_recursively(package: str) -> Generator[ModuleType, None, None]: 44 | package = importlib.import_module(package) 45 | for loader, name, is_pkg in pkgutil.walk_packages(package.__path__, package.__name__ + '.'): 46 | yield importlib.import_module(name) 47 | -------------------------------------------------------------------------------- /winter_openapi/__init__.py: -------------------------------------------------------------------------------- 1 | from winter.data.pagination import Page 2 | from winter.web.exceptions import RequestDataDecodeException 3 | from winter.web.exceptions import ThrottleException 4 | from winter.web.exceptions import UnsupportedMediaTypeException 5 | from winter.web.pagination import PagePositionArgumentResolver 6 | from winter.web.pagination.limits import MaximumLimitValueExceeded 7 | from .annotations import global_exception 8 | from .annotations import register_global_exception 9 | from .generator import generate_openapi 10 | from .inspection.inspection import inspect_type 11 | from .inspection.inspection import register_type_inspector 12 | from .inspectors import PagePositionArgumentsInspector 13 | from .inspectors import PathParametersInspector 14 | from .inspectors import QueryParametersInspector 15 | from .inspectors import RouteParametersInspector 16 | from .inspectors import get_route_parameters_inspectors 17 | from .inspectors import inspect_page 18 | from .inspectors import register_route_parameters_inspector 19 | from .swagger_ui import get_swagger_ui_html 20 | from .validators import validate_missing_raises_annotations 21 | 22 | 23 | def setup(allow_missing_raises_annotation: bool = False): 24 | register_global_exception(MaximumLimitValueExceeded) 25 | register_global_exception(ThrottleException) 26 | register_global_exception(RequestDataDecodeException) 27 | register_global_exception(UnsupportedMediaTypeException) 28 | register_type_inspector(Page, func=inspect_page) 29 | register_route_parameters_inspector(PathParametersInspector()) 30 | register_route_parameters_inspector(QueryParametersInspector()) 31 | register_route_parameters_inspector(PagePositionArgumentsInspector(PagePositionArgumentResolver())) 32 | if not allow_missing_raises_annotation: # pragma: no cover 33 | validate_missing_raises_annotations() 34 | -------------------------------------------------------------------------------- /winter_openapi/inspectors/path_parameters_inspector.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | from typing import TYPE_CHECKING 3 | 4 | from openapi_pydantic.v3.v3_0 import Parameter 5 | 6 | from winter.core import ComponentMethodArgument 7 | from winter.web.routing import Route 8 | from .route_parameters_inspector import RouteParametersInspector 9 | 10 | if TYPE_CHECKING: 11 | from winter_openapi.generator import SchemaRegistry 12 | 13 | 14 | class PathParametersInspector(RouteParametersInspector): 15 | 16 | def inspect_parameters(self, route: 'Route', schema_registry: 'SchemaRegistry') -> List[Parameter]: 17 | parameters = [] 18 | 19 | for argument in self._path_arguments(route): 20 | openapi_parameter = self._convert_argument_to_openapi_parameter(argument, schema_registry) 21 | parameters.append(openapi_parameter) 22 | 23 | return parameters 24 | 25 | def _convert_argument_to_openapi_parameter( 26 | self, 27 | argument: ComponentMethodArgument, 28 | schema_registry: 'SchemaRegistry', 29 | ) -> Parameter: 30 | schema = schema_registry.get_schema_or_reference(argument.type_, output=False) 31 | return Parameter( 32 | name=argument.name, 33 | description=argument.description, 34 | required=argument.required, 35 | param_in='path', 36 | param_schema=schema, 37 | ) 38 | 39 | def _path_arguments(self, route: 'Route') -> List[ComponentMethodArgument]: 40 | path_arguments = [] 41 | for path_variable in route.get_path_variables(): 42 | argument = route.method.get_argument(path_variable) 43 | if argument is None: 44 | raise ValueError(f'Path variable "{path_variable}" not found in method {route.method.full_name}') 45 | path_arguments.append(argument) 46 | return path_arguments 47 | -------------------------------------------------------------------------------- /winter_openapi/swagger_ui.py: -------------------------------------------------------------------------------- 1 | import json 2 | from typing import Any 3 | from typing import Dict 4 | from typing import Optional 5 | 6 | 7 | def get_swagger_ui_html( 8 | *, 9 | openapi_url: str, 10 | title: str = 'Swagger UI', 11 | swagger_js_url: str = "https://cdn.jsdelivr.net/npm/swagger-ui-dist@4/swagger-ui-bundle.js", 12 | swagger_css_url: str = "https://cdn.jsdelivr.net/npm/swagger-ui-dist@4/swagger-ui.css", 13 | swagger_favicon_url: str = "https://static1.smartbear.co/swagger/media/assets/swagger_fav.png", 14 | swagger_ui_parameters: Optional[Dict[str, Any]] = None, 15 | ) -> str: 16 | """Stolen from FastAPI""" 17 | current_swagger_ui_parameters = { 18 | "dom_id": "#swagger-ui", 19 | "layout": "BaseLayout", 20 | "deepLinking": True, 21 | "showExtensions": True, 22 | "showCommonExtensions": True, 23 | } 24 | if swagger_ui_parameters: 25 | current_swagger_ui_parameters.update(swagger_ui_parameters) 26 | 27 | html = f""" 28 | 29 | 30 | 31 | 32 | 33 | {title} 34 | 35 | 36 |
37 |
38 | 39 | 40 | 57 | 58 | 59 | """ 60 | return html 61 | -------------------------------------------------------------------------------- /winter/messaging/event_dispacher.py: -------------------------------------------------------------------------------- 1 | from typing import Callable 2 | from typing import Dict 3 | from typing import List 4 | 5 | from injector import inject 6 | 7 | from winter.core import get_injector 8 | from .event import Event 9 | from .event_subscription import EventSubscription 10 | from .event_subscription_registry import EventSubscriptionRegistry 11 | 12 | 13 | class EventDispatcher: 14 | @inject 15 | def __init__(self, subscription_registry: EventSubscriptionRegistry) -> None: 16 | self._subscription_registry = subscription_registry 17 | 18 | def dispatch(self, event: Event): 19 | self.dispatch_many([event]) 20 | 21 | def dispatch_many(self, events: List[Event]): 22 | events_grouped_by_subscription: Dict[EventSubscription, List[Event]] = {} 23 | injector = get_injector() 24 | 25 | for event in events: 26 | event_type = type(event) 27 | event_subscriptions = self._subscription_registry.get_subscriptions(event_type) 28 | 29 | for event_subscription in event_subscriptions: 30 | events_grouped_by_subscription.setdefault(event_subscription, []).append(event) 31 | 32 | for event_subscription, events in events_grouped_by_subscription.items(): 33 | handler_instance = injector.get(event_subscription.handler_method.component.component_cls) 34 | 35 | if event_subscription.collection: 36 | self._execute_handler(event_subscription.handler_method.func, handler_instance, events) 37 | else: 38 | for event in events: 39 | self._execute_handler(event_subscription.handler_method.func, handler_instance, event) 40 | 41 | def _execute_handler(self, func: Callable, *args, **kwargs): 42 | """ 43 | The method is intentionally extracted to make it possible to override it externally for logging purposes. 44 | """ 45 | func(*args, **kwargs) 46 | -------------------------------------------------------------------------------- /winter/web/response_header_annotation.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | from typing import MutableMapping 3 | 4 | import dataclasses 5 | 6 | from ..core import annotate_method 7 | 8 | 9 | class _ResponseHeaderMeta(type): 10 | def __getitem__(self, value_type): 11 | assert isinstance(value_type, type), 'value_type must be a type' 12 | return _ResponseHeaderMeta.__new__( 13 | type(self), 14 | f'{self.__name__}[{value_type.__name__}]', 15 | (self, ), 16 | { 17 | '_value_type': value_type, 18 | }, 19 | 20 | ) 21 | 22 | 23 | @dataclasses.dataclass 24 | class ResponseHeaderAnnotation: 25 | # TODO: add inspector for this annotation (https://github.com/mofr/winter/issues/135) 26 | header_name: str 27 | argument_name: str 28 | 29 | 30 | def response_header(header_name: str, argument_name: str): 31 | def wrapper(func_or_method): 32 | annotation = ResponseHeaderAnnotation(header_name, argument_name) 33 | annotation_decorator = annotate_method(annotation) 34 | method = annotation_decorator(func_or_method) 35 | argument = method.get_argument(argument_name) 36 | method_name = method.func.__name__ 37 | assert argument is not None, f'Not found argument "{argument_name}" in "{method_name}"' 38 | return method 39 | return wrapper 40 | 41 | 42 | class ResponseHeader(metaclass=_ResponseHeaderMeta): 43 | _value_type = str 44 | 45 | def __init__(self, headers: MutableMapping[str, Any], header_name: str): 46 | self._headers = headers 47 | self._header_name = header_name.lower() 48 | 49 | def __repr__(self): 50 | return f'{self.__class__.__name__}({self._header_name!r})' 51 | 52 | def set(self, value): 53 | # TODO: add value validation against self._value_type (https://github.com/mofr/winter/issues/136) 54 | self._headers[self._header_name] = value 55 | -------------------------------------------------------------------------------- /winter/web/request_header_resolver.py: -------------------------------------------------------------------------------- 1 | from typing import MutableMapping 2 | from typing import Optional 3 | 4 | import django.http 5 | 6 | from winter import ArgumentResolver 7 | from winter.core import ArgumentDoesNotHaveDefault 8 | from winter.core import ComponentMethodArgument 9 | from winter.core.json import JSONDecodeException 10 | from winter.core.json import json_decode 11 | from winter.web.exceptions import RequestDataDecodeException 12 | from winter.web.request_header_annotation import RequestHeaderAnnotation 13 | 14 | 15 | class RequestHeaderArgumentResolver(ArgumentResolver): 16 | def is_supported(self, argument: ComponentMethodArgument) -> bool: 17 | return self._get_annotation(argument) is not None 18 | 19 | def resolve_argument( 20 | self, 21 | argument: ComponentMethodArgument, 22 | request: django.http.HttpRequest, 23 | response_headers: MutableMapping[str, str], 24 | ): 25 | request_headers = request.headers 26 | annotation = self._get_annotation(argument) 27 | header_name = annotation.name 28 | 29 | if header_name not in request_headers: 30 | try: 31 | return argument.get_default() 32 | except ArgumentDoesNotHaveDefault: 33 | raise RequestDataDecodeException(f'Missing required header "{header_name}"') 34 | 35 | try: 36 | return json_decode(request_headers.get(header_name), argument.type_) 37 | except JSONDecodeException as e: 38 | raise RequestDataDecodeException(e.errors) 39 | 40 | def _get_annotation(self, argument: ComponentMethodArgument) -> Optional[RequestHeaderAnnotation]: 41 | return next( 42 | ( 43 | annotation 44 | for annotation in argument.method.annotations.get(RequestHeaderAnnotation) 45 | if annotation.map_to == argument.name 46 | ), 47 | None, 48 | ) 49 | -------------------------------------------------------------------------------- /winter/core/utils/typing.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | import types 3 | from typing import Iterable 4 | from typing import TypeVar 5 | from typing import Union 6 | from typing import get_origin 7 | 8 | NoneType = type(None) 9 | 10 | 11 | def is_optional(type_: object) -> bool: 12 | return is_union(type_) and NoneType in get_union_args(type_) 13 | 14 | 15 | def is_any(type_: object) -> bool: 16 | return str(type_) == 'typing.Any' 17 | 18 | 19 | def is_type_var(type_: object) -> bool: 20 | return type(type_) == TypeVar 21 | 22 | 23 | def is_iterable_type(type_: object) -> bool: 24 | """Note that str is not iterable here""" 25 | if is_union(type_): 26 | return all(is_iterable_type(arg) for arg in type_.__args__ if arg is not NoneType) 27 | 28 | return is_origin_type_subclasses(type_, Iterable) and not is_origin_type_subclasses(type_, str) 29 | 30 | 31 | def is_union(type_: object) -> bool: 32 | return get_origin_type(type_) in (Union, types.UnionType) 33 | 34 | 35 | def get_union_args(type_: object) -> list: 36 | return getattr(type_, '__args__', []) or [] 37 | 38 | 39 | def get_origin_type(hint_class): 40 | return get_origin(hint_class) or hint_class 41 | 42 | 43 | def is_origin_type_subclasses(hint_class, check_class): 44 | origin_type = get_origin_type(hint_class) 45 | return inspect.isclass(origin_type) and issubclass(origin_type, check_class) 46 | 47 | 48 | def get_type_name(type_): 49 | if inspect.isclass(type_): 50 | return type_.__name__ 51 | 52 | if is_optional(type_): 53 | base_type = type_.__args__[0] 54 | base_type_name = get_type_name(base_type) 55 | return f'Optional[{base_type_name}]' 56 | 57 | type_name = repr(type_) 58 | if type_name.startswith('typing.'): 59 | type_name = type_name[7:] 60 | return type_name 61 | 62 | if type(type_) in (types.GenericAlias, types.UnionType): 63 | return type_name 64 | 65 | return type(type_).__name__ 66 | -------------------------------------------------------------------------------- /tests/apps.py: -------------------------------------------------------------------------------- 1 | import atexit 2 | 3 | from django.apps import AppConfig 4 | from testcontainers.redis import RedisContainer 5 | 6 | from tests.web.interceptors import HelloWorldInterceptor 7 | from winter.web import RedisThrottlingConfiguration 8 | from winter.web import exception_handlers_registry 9 | from winter.web import interceptor_registry 10 | from winter.web.exceptions.handlers import DefaultExceptionHandler 11 | 12 | 13 | class TestAppConfig(AppConfig): 14 | name = 'tests' 15 | 16 | def __init__(self, *args, **kwargs): 17 | super().__init__(*args, **kwargs) 18 | self._redis_container: RedisContainer | None = None 19 | 20 | def ready(self): 21 | # define this import for force initialization all modules and to register Exceptions 22 | from .urls import urlpatterns # noqa: F401 23 | import winter 24 | import winter_django 25 | import winter_openapi 26 | 27 | interceptor_registry.add_interceptor(HelloWorldInterceptor()) 28 | 29 | winter_openapi.setup() 30 | 31 | winter.web.setup() 32 | 33 | self._redis_container = RedisContainer() 34 | self._redis_container.start() 35 | self._redis_container.get_client().flushdb() 36 | atexit.register(self.cleanup_redis) 37 | 38 | redis_throttling_configuration = RedisThrottlingConfiguration( 39 | host=self._redis_container.get_container_host_ip(), 40 | port=self._redis_container.get_exposed_port(self._redis_container.port), 41 | db=0, 42 | password=self._redis_container.password 43 | ) 44 | winter.web.set_redis_throttling_configuration(redis_throttling_configuration) 45 | 46 | winter_django.setup() 47 | 48 | exception_handlers_registry.set_default_handler(DefaultExceptionHandler) # for 100% test coverage 49 | 50 | def cleanup_redis(self): # pragma: no cover 51 | if self._redis_container: 52 | self._redis_container.stop() 53 | -------------------------------------------------------------------------------- /tests/web/test_urls.py: -------------------------------------------------------------------------------- 1 | import enum 2 | import re 3 | import uuid 4 | 5 | import pytest 6 | 7 | from winter.core import ComponentMethod 8 | from winter.web.urls import rewrite_uritemplate_with_regexps 9 | 10 | 11 | class _OneTwoEnum(enum.Enum): 12 | ONE = 'one' 13 | TWO = 'two' 14 | 15 | 16 | @pytest.mark.parametrize(('url_path', 'param_type', 'expected_url_path', 'example_url'), ( 17 | (r'/{param}/', int, r'/(?P\d+)/', r'/1/'), 18 | (r'/{param}/', str, r'/(?P[^/]+)/', r'/test/'), 19 | (r'/{param}/', _OneTwoEnum, r'/(?P((one)|(two)))/', r'/one/'), 20 | ( 21 | r'/{param}/', 22 | uuid.UUID, 23 | r'/(?P[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/', 24 | fr'/{uuid.uuid4()}/', 25 | ), 26 | )) 27 | def test_rewrite_uritemplate_with_regexps(url_path, param_type, expected_url_path, example_url): 28 | def method(param: param_type): # pragma: no cover 29 | return param 30 | 31 | method = ComponentMethod(method) 32 | 33 | # Act 34 | rewritten_url_path = rewrite_uritemplate_with_regexps(url_path, [method]) 35 | 36 | # Assert 37 | assert rewritten_url_path == expected_url_path 38 | assert re.match(rewritten_url_path, example_url) 39 | 40 | 41 | def test_rewrite_uritemplate_with_regexps_with_different_types_in_methods(): 42 | def method1(param: int): # pragma: no cover 43 | return param 44 | 45 | def method2(param: str): # pragma: no cover 46 | return param 47 | 48 | method1 = ComponentMethod(method1) 49 | method2 = ComponentMethod(method2) 50 | argument_types = {int, str} 51 | 52 | # Act 53 | with pytest.raises(Exception) as exception: 54 | rewrite_uritemplate_with_regexps('/{param}/', [method1, method2]) 55 | 56 | # Assert 57 | assert str(exception.value) == ( 58 | f'Different methods are bound to the same path variable, ' 59 | f'but have different types annotated: {argument_types}' 60 | ) 61 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import django 4 | import pytest 5 | from injector import CallableProvider 6 | from injector import Injector 7 | from injector import Module 8 | from injector import singleton 9 | from sqlalchemy import create_engine 10 | from sqlalchemy.engine import Engine 11 | 12 | from winter.core import set_injector 13 | 14 | 15 | def pytest_configure(): 16 | from django.conf import settings 17 | app_dir = Path(__file__) 18 | settings.configure( 19 | ROOT_URLCONF='tests.urls', 20 | TEMPLATES=[ 21 | { 22 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 23 | 'DIRS': [app_dir.parent.name + '/templates'], 24 | 'APP_DIRS': True, 25 | 'OPTIONS': { 26 | 'debug': True, # We want template errors to raise 27 | }, 28 | }, 29 | ], 30 | INSTALLED_APPS=( 31 | # Hack for making module discovery working 32 | 'django.contrib.admin', 33 | 'django.contrib.auth', 34 | 'django.contrib.contenttypes', 35 | # End hack 36 | 'tests', 37 | ), 38 | MIDDLEWARE=[ 39 | 'tests.middleware.AuthenticationMiddleware', 40 | ], 41 | SECRET_KEY='Test secret key', 42 | ) 43 | injector = Injector([Configuration]) 44 | set_injector(injector) 45 | django.setup() 46 | 47 | 48 | class Configuration(Module): 49 | def configure(self, binder): 50 | binder.bind(Engine, to=CallableProvider(make_engine), scope=singleton) 51 | 52 | 53 | def make_engine(): 54 | return create_engine('sqlite://') 55 | 56 | 57 | @pytest.fixture(scope='session') 58 | def wsgi(): 59 | from django.core.wsgi import get_wsgi_application 60 | return get_wsgi_application() 61 | 62 | 63 | @pytest.fixture() 64 | def api_client(wsgi): 65 | import httpx 66 | return httpx.Client(app=wsgi, base_url='http://testserver') 67 | -------------------------------------------------------------------------------- /winter/web/urls.py: -------------------------------------------------------------------------------- 1 | import enum 2 | import types 3 | import uuid 4 | from typing import Iterable 5 | 6 | import uritemplate 7 | 8 | from winter.core import ComponentMethod 9 | from winter.core.utils.typing import get_origin_type 10 | 11 | _regexp = {} 12 | 13 | 14 | def register_url_regexp(func: types.FunctionType): 15 | annotations = func.__annotations__.copy() 16 | annotations.pop('return', None) 17 | assert len(annotations) == 1 18 | _item, type_ = annotations.popitem() 19 | _regexp[type_] = func 20 | return func 21 | 22 | 23 | def rewrite_uritemplate_with_regexps(url_path: str, methods: Iterable[ComponentMethod]) -> str: 24 | for variable_name in uritemplate.variables(url_path): 25 | arguments = (method.get_argument(variable_name) for method in methods) 26 | 27 | argument_types = {argument.type_ for argument in arguments if argument is not None} 28 | 29 | if len(argument_types) > 1: 30 | raise Exception( 31 | f'Different methods are bound to the same path variable, ' 32 | f'but have different types annotated: {argument_types}', 33 | ) 34 | regexp = get_regexp(*argument_types) 35 | url_path = url_path.replace(f'{{{variable_name}}}', f'(?P<{variable_name}>{regexp})') 36 | return url_path 37 | 38 | 39 | def get_regexp(type_=None) -> str: 40 | origin_type = get_origin_type(type_) 41 | 42 | for cls in origin_type.mro(): 43 | func = _regexp.get(cls) 44 | 45 | if func is not None: 46 | return func(type_) 47 | return r'[^/]+' 48 | 49 | 50 | # noinspection PyUnusedLocal 51 | @register_url_regexp 52 | def int_regexp(cls: int): 53 | return r'\d+' 54 | 55 | 56 | # noinspection PyUnusedLocal 57 | @register_url_regexp 58 | def uuid_regexp(cls: uuid.UUID): 59 | return r'[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}' 60 | 61 | 62 | @register_url_regexp 63 | def enum_regexp(cls: enum.Enum): 64 | values = (f'({e.value})' for e in cls) 65 | return '(' + '|'.join(values) + ')' 66 | -------------------------------------------------------------------------------- /tests/api/api_with_response_headers.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from uuid import UUID 3 | 4 | import pytz 5 | 6 | import winter 7 | from winter.web import ResponseHeader 8 | 9 | 10 | @winter.route('with-response-headers/') 11 | class APIWithResponseHeaders: 12 | 13 | @winter.response_header('x-header', 'header') 14 | @winter.route_get('str-header/') 15 | def str_header(self, header: ResponseHeader[str]) -> str: 16 | header.set('test header') 17 | return 'OK' 18 | 19 | @winter.response_header('x-header', 'header') 20 | @winter.route_get('int-header/') 21 | def int_header(self, header: ResponseHeader[int]) -> str: 22 | header.set(123) 23 | return 'OK' 24 | 25 | @winter.response_header('x-header', 'header') 26 | @winter.route_get('datetime-isoformat-header/{?now}') 27 | def datetime_isoformat_header(self, now: float, header: ResponseHeader[datetime.datetime]) -> str: 28 | header.set(datetime.datetime.fromtimestamp(now)) 29 | return 'OK' 30 | 31 | @winter.response_header('Last-Modified', 'header') 32 | @winter.route_get('last-modified-header/{?now}') 33 | def last_modified_header(self, now: float, header: ResponseHeader[datetime.datetime]) -> str: 34 | header.set(datetime.datetime.fromtimestamp(now).astimezone(pytz.timezone('Asia/Novosibirsk'))) 35 | return 'OK' 36 | 37 | @winter.response_header('x-header', 'header') 38 | @winter.route_get('uuid-header/{?uid}') 39 | def uuid_header(self, uid: UUID, header: ResponseHeader[UUID]) -> str: 40 | header.set(uid) 41 | return 'OK' 42 | 43 | @winter.response_header('x-header1', 'header1') 44 | @winter.response_header('x-header2', 'header2') 45 | @winter.route_get('two-headers/') 46 | def two_headers(self, header1: ResponseHeader[str], header2: ResponseHeader[str]) -> str: 47 | header1.set('header1') 48 | header2.set('header2') 49 | return 'OK' 50 | 51 | @winter.route_get('header-without-annotation/') 52 | def header_without_annotation(self, header: ResponseHeader[str]) -> str: # pragma: no cover 53 | pass 54 | -------------------------------------------------------------------------------- /winter/data/repository.py: -------------------------------------------------------------------------------- 1 | from abc import abstractmethod 2 | from typing import Generic 3 | from typing import Iterable 4 | from typing import Optional 5 | from typing import TypeVar 6 | from typing import get_args 7 | 8 | T = TypeVar('T') 9 | K = TypeVar('K') 10 | 11 | 12 | class RepositoryGenericMeta(type): 13 | def __init__(cls, name, bases, attr, **kwargs): 14 | if name not in ('Repository', 'CRUDRepository'): 15 | args = get_args(cls.__orig_bases__[0]) 16 | if not args: # pragma: no cover 17 | return 18 | if len(args) != 2: 19 | raise TypeError(f'Repository class takes exactly 2 generic parameters, {len(args)} were given: {args}') 20 | cls.__entity_cls__ = args[0] 21 | cls.__primary_key_type__ = args[1] 22 | super().__init__(name, bases, attr, **kwargs) 23 | 24 | 25 | class Repository(Generic[T, K], metaclass=RepositoryGenericMeta): 26 | pass 27 | 28 | 29 | class CRUDRepository(Repository[T, K]): 30 | @abstractmethod 31 | def count(self) -> int: 32 | pass 33 | 34 | @abstractmethod 35 | def delete(self, entity: T): 36 | pass 37 | 38 | @abstractmethod 39 | def delete_many(self, entities: Iterable[T]): 40 | pass 41 | 42 | @abstractmethod 43 | def delete_all(self): 44 | pass 45 | 46 | @abstractmethod 47 | def delete_by_id(self, id_: K): 48 | pass 49 | 50 | @abstractmethod 51 | def exists_by_id(self, id_: K) -> bool: 52 | pass 53 | 54 | @abstractmethod 55 | def find_all(self) -> Iterable[T]: 56 | pass 57 | 58 | @abstractmethod 59 | def find_all_by_id(self, ids: Iterable[K]) -> Iterable[T]: 60 | pass 61 | 62 | @abstractmethod 63 | def find_by_id(self, id_: K) -> Optional[T]: 64 | pass 65 | 66 | @abstractmethod 67 | def get_by_id(self, id_: K) -> T: 68 | pass 69 | 70 | @abstractmethod 71 | def save(self, entity: T) -> T: 72 | pass 73 | 74 | @abstractmethod 75 | def save_many(self, entities: Iterable[T]) -> Iterable[T]: 76 | pass 77 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # winter is tested in different python versions with different dependencies, so it doesn't make sense to lock the dependencies 2 | poetry.lock 3 | 4 | # Byte-compiled / optimized / DLL files 5 | __pycache__/ 6 | *.py[cod] 7 | *$py.class 8 | 9 | # C extensions 10 | *.so 11 | 12 | # Distribution / packaging 13 | .Python 14 | build/ 15 | develop-eggs/ 16 | dist/ 17 | downloads/ 18 | eggs/ 19 | .eggs/ 20 | lib/ 21 | lib64/ 22 | parts/ 23 | sdist/ 24 | var/ 25 | wheels/ 26 | share/python-wheels/ 27 | *.egg-info/ 28 | .installed.cfg 29 | *.egg 30 | MANIFEST 31 | 32 | # PyInstaller 33 | # Usually these files are written by a python script from a template 34 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 35 | *.manifest 36 | *.spec 37 | 38 | # Installer logs 39 | pip-log.txt 40 | pip-delete-this-directory.txt 41 | 42 | # Unit test / coverage reports 43 | htmlcov/ 44 | .nox/ 45 | .coverage 46 | .coverage.* 47 | .cache 48 | nosetests.xml 49 | coverage.xml 50 | *.cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | 63 | # Flask stuff: 64 | instance/ 65 | .webassets-cache 66 | 67 | # Scrapy stuff: 68 | .scrapy 69 | 70 | # Sphinx documentation 71 | docs/_build/ 72 | 73 | # PyBuilder 74 | target/ 75 | 76 | # Jupyter Notebook 77 | .ipynb_checkpoints 78 | 79 | # IPython 80 | profile_default/ 81 | ipython_config.py 82 | 83 | # pyenv 84 | .python-version 85 | 86 | # celery beat schedule file 87 | celerybeat-schedule 88 | 89 | # SageMath parsed files 90 | *.sage.py 91 | 92 | # Environments 93 | .env 94 | .venv 95 | env/ 96 | venv/ 97 | ENV/ 98 | env.bak/ 99 | venv.bak/ 100 | 101 | # Spyder project settings 102 | .spyderproject 103 | .spyproject 104 | 105 | # Rope project settings 106 | .ropeproject 107 | 108 | # mkdocs documentation 109 | /site 110 | 111 | # mypy 112 | .mypy_cache/ 113 | .dmypy.json 114 | dmypy.json 115 | 116 | # Pyre type checker 117 | .pyre/ 118 | 119 | # IDE 120 | .idea/ 121 | .vscode/ 122 | -------------------------------------------------------------------------------- /winter/web/output_processor.py: -------------------------------------------------------------------------------- 1 | import abc 2 | import dataclasses 3 | from typing import Any 4 | from typing import Callable 5 | from typing import List 6 | from typing import Optional 7 | 8 | import django.http 9 | 10 | from winter.core import ComponentMethod 11 | from winter.core import annotate 12 | 13 | 14 | class IOutputProcessor(abc.ABC): 15 | """Process API method returned value so that it can be put to HttpResponse body. 16 | Common usage is to serializer some DTO to dict.""" 17 | 18 | @abc.abstractmethod 19 | def process_output(self, output, request: django.http.HttpRequest): # pragma: no cover 20 | return output 21 | 22 | 23 | @dataclasses.dataclass 24 | class OutputProcessorAnnotation: 25 | output_processor: IOutputProcessor 26 | 27 | 28 | class IOutputProcessorResolver(abc.ABC): 29 | """ 30 | Resolves IOutputProcessor for a given body type. 31 | Due to python dynamic typing it's called after every API request. 32 | """ 33 | 34 | @abc.abstractmethod 35 | def is_supported(self, body: Any) -> bool: # pragma: no cover 36 | return False 37 | 38 | @abc.abstractmethod 39 | def get_processor(self, body: Any) -> IOutputProcessor: # pragma: no cover 40 | pass 41 | 42 | 43 | _registered_resolvers: List[IOutputProcessorResolver] = [] 44 | 45 | 46 | def register_output_processor(method: Callable, output_processor: IOutputProcessor): 47 | return annotate(OutputProcessorAnnotation(output_processor), single=True)(method) 48 | 49 | 50 | def register_output_processor_resolver(output_processor_resolver: IOutputProcessorResolver): 51 | _registered_resolvers.append(output_processor_resolver) 52 | 53 | 54 | def get_output_processor(method: ComponentMethod, body: Any) -> Optional[IOutputProcessor]: 55 | output_processor_annotation = method.annotations.get_one_or_none(OutputProcessorAnnotation) 56 | if output_processor_annotation is not None: 57 | return output_processor_annotation.output_processor 58 | for resolver in _registered_resolvers: 59 | if resolver.is_supported(body): 60 | return resolver.get_processor(body) 61 | return None 62 | -------------------------------------------------------------------------------- /winter/core/annotation_decorator.py: -------------------------------------------------------------------------------- 1 | import abc 2 | import types 3 | from typing import Any 4 | from typing import Callable 5 | from typing import Type 6 | from typing import Union 7 | 8 | from .component import Component 9 | from .component_method import ComponentMethod 10 | 11 | 12 | class annotate_base(abc.ABC): 13 | _supported_classes = () 14 | 15 | def __init__(self, annotation: Any, *, unique=False, single=False): 16 | self.annotation = annotation 17 | self.unique = unique 18 | self.single = single 19 | 20 | @classmethod 21 | def is_supported(cls, item: Any) -> bool: 22 | return isinstance(item, cls._supported_classes) 23 | 24 | @abc.abstractmethod 25 | def __call__(self, item) -> Callable: # pragma: no cover 26 | pass 27 | 28 | 29 | class annotate(annotate_base): 30 | _supported_classes = (Type, types.FunctionType, ComponentMethod) 31 | 32 | def __call__(self, func_or_cls: Union[Type, types.FunctionType, ComponentMethod]) -> Callable: 33 | if annotate_method.is_supported(func_or_cls): 34 | decorator = annotate_method(self.annotation, unique=self.unique, single=self.single) 35 | elif annotate_class.is_supported(func_or_cls): 36 | decorator = annotate_class(self.annotation, unique=self.unique, single=self.single) 37 | else: 38 | raise ValueError(f'Need function or class. Got: {func_or_cls}') 39 | return decorator(func_or_cls) 40 | 41 | 42 | class annotate_class(annotate_base): 43 | _supported_classes = (type,) 44 | 45 | def __call__(self, cls: Type): 46 | component = Component.get_by_cls(cls) 47 | component.annotations.add(self.annotation, unique=self.unique, single=self.single) 48 | return cls 49 | 50 | 51 | class annotate_method(annotate_base): 52 | _supported_classes = (ComponentMethod, types.FunctionType) 53 | 54 | def __call__(self, func_or_method: Union[types.FunctionType, ComponentMethod]) -> ComponentMethod: 55 | method = ComponentMethod.get_or_create(func_or_method) 56 | method.annotations.add(self.annotation, unique=self.unique, single=self.single) 57 | return method 58 | -------------------------------------------------------------------------------- /winter/web/routing/route.py: -------------------------------------------------------------------------------- 1 | import re 2 | from typing import List 3 | from typing import Set 4 | 5 | import uritemplate 6 | from uritemplate import URITemplate 7 | from uritemplate.variable import Operator 8 | 9 | from winter.core import ComponentMethod 10 | from winter.web.query_parameters import MapQueryParameterAnnotation 11 | from winter.web.query_parameters.query_parameter import QueryParameter 12 | 13 | _remove_query_params_regexp = re.compile(r'{\?[^}]*}') 14 | 15 | 16 | class Route: 17 | 18 | def __init__( 19 | self, 20 | http_method: str, 21 | url_path_with_query_parameters: str, 22 | method: ComponentMethod, 23 | ): 24 | self._url_path_with_query_parameters = url_path_with_query_parameters 25 | self.method = method 26 | self.http_method = http_method 27 | 28 | @property 29 | def url_path(self): 30 | return _remove_query_params_regexp.sub('', self._url_path_with_query_parameters) 31 | 32 | def get_path_variables(self) -> Set[str]: 33 | return uritemplate.variables(self.url_path) 34 | 35 | def get_query_parameters(self) -> List[QueryParameter]: 36 | query_parameters = [] 37 | map_query_parameters_annotations = self.method.annotations.get(MapQueryParameterAnnotation) 38 | map_to_by_names = { 39 | map_query_parameter.name: map_query_parameter.map_to 40 | for map_query_parameter in map_query_parameters_annotations 41 | } 42 | 43 | query_variables = ( 44 | variable 45 | for variable in URITemplate(self._url_path_with_query_parameters).variables 46 | if variable.operator == Operator.form_style_query 47 | ) 48 | for variable in query_variables: 49 | for variable_name, variable_params in variable.variables: 50 | map_to = map_to_by_names.get(variable_name, variable_name) 51 | query_parameter = QueryParameter( 52 | name=variable_name, 53 | map_to=map_to, 54 | explode=variable_params['explode'], 55 | ) 56 | query_parameters.append(query_parameter) 57 | return query_parameters 58 | -------------------------------------------------------------------------------- /winter_openapi/inspectors/page_inspector.py: -------------------------------------------------------------------------------- 1 | import dataclasses 2 | from typing import Dict 3 | from typing import List 4 | from typing import Optional 5 | from typing import Type 6 | 7 | from winter.data.pagination import Page 8 | from winter_openapi.inspection.type_info import TypeInfo 9 | from winter_openapi.inspectors.standard_types_inspectors import inspect_type 10 | from winter_openapi.inspectors.standard_types_inspectors import register_type_inspector 11 | 12 | 13 | def create_dataclass(page_type: Type) -> Type: 14 | args = getattr(page_type, '__args__', None) 15 | child_class = args[0] if args else str 16 | extra_fields = set(dataclasses.fields(page_type.__origin__)) - set(dataclasses.fields(Page)) 17 | child_type_info = inspect_type(child_class) 18 | title = child_type_info.title or child_type_info.type_.capitalize() 19 | 20 | PageMetaDataclass = dataclasses.dataclass( 21 | type( 22 | f'PageMetaOf{title}', 23 | (), 24 | { 25 | '__annotations__': { 26 | 'total_count': int, 27 | 'limit': Optional[int], 28 | 'offset': Optional[int], 29 | 'previous': Optional[str], 30 | 'next': Optional[str], 31 | **{extra_field.name: extra_field.type for extra_field in extra_fields}, 32 | }, 33 | }, 34 | ), 35 | ) 36 | PageMetaDataclass.__doc__ = '' 37 | PageDataclass = dataclasses.dataclass( 38 | type( 39 | f'PageOf{title}', 40 | (), 41 | { 42 | '__annotations__': { 43 | 'meta': PageMetaDataclass, 44 | 'objects': List[child_class], 45 | }, 46 | }, 47 | ), 48 | ) 49 | PageDataclass.__doc__ = '' 50 | return PageDataclass 51 | 52 | 53 | page_to_dataclass_map: Dict[Type, Type] = {} 54 | 55 | 56 | # noinspection PyUnusedLocal 57 | @register_type_inspector(Page) 58 | def inspect_page(hint_class) -> TypeInfo: 59 | if hint_class not in page_to_dataclass_map: 60 | page_to_dataclass_map[hint_class] = create_dataclass(hint_class) 61 | page_dataclass = page_to_dataclass_map[hint_class] 62 | return inspect_type(page_dataclass) 63 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "winter" 3 | version = "31.0.2" 4 | homepage = "https://github.com/WinterFramework/winter" 5 | description = "Web Framework with focus on python typing, dataclasses and modular design" 6 | authors = ["Alexander Egorov "] 7 | classifiers = [ 8 | 'Intended Audience :: Developers', 9 | 'License :: OSI Approved :: MIT License', 10 | 'Development Status :: 3 - Alpha', 11 | 'Operating System :: OS Independent', 12 | 'Environment :: Web Environment', 13 | 'Programming Language :: Python', 14 | 'Programming Language :: Python :: 3.11', 15 | 'Programming Language :: Python :: 3.12', 16 | 'Programming Language :: Python :: 3.13', 17 | 'Framework :: Django', 18 | 'Framework :: Django :: 4', 19 | 'Topic :: Software Development :: Libraries :: Application Frameworks', 20 | ] 21 | packages = [ 22 | { include = "winter" }, 23 | { include = "winter_ddd" }, 24 | { include = "winter_django" }, 25 | { include = "winter_openapi" }, 26 | { include = "winter_sqlalchemy" }, 27 | ] 28 | 29 | [tool.poetry.dependencies] 30 | python = "^3.11" 31 | Django = ">=4.2,<5" 32 | docstring-parser = ">=0.1" 33 | furl = ">=2.0.0, <3" 34 | python-dateutil = "^2.8.2" 35 | injector = ">=0.15.0, <1" 36 | SQLAlchemy = ">=1.4, <2" 37 | typing-extensions = "^4.8" 38 | StrEnum = "^0.4.8" 39 | openapi-pydantic = ">=0.5.0, <0.6" 40 | pydantic = ">=1.10, <2" 41 | openapi-spec-validator = ">=0.5.7, <1" 42 | uritemplate = "==4.2.0" # Lib doesn't follow semantic versioning 43 | httpx = ">=0.24.1, <0.28" 44 | redis = "^6.2.0" 45 | 46 | [tool.poetry.dev-dependencies] 47 | flake8 = ">=3.7.7, <4" 48 | flake8-commas = ">=2.0.0, <4" 49 | flake8-formatter-abspath = ">=1.0.1, <2" 50 | pre-commit-hooks = ">=2.2.3, <3" 51 | freezegun = ">=1.5.1, <2" 52 | mock = ">=2.0.0, <3" 53 | pytest = ">=6.2.5, <7" 54 | pytest-pythonpath = ">=0.7.1" 55 | pytest-cov = "^6.0.0" 56 | pytest-django = ">=3.2.0, <4" 57 | semver = ">=2.8.1, <3" 58 | add-trailing-comma = ">=1.3.0, <2" 59 | pre-commit = ">=1.17.0, <2" 60 | lxml = ">=4.9.1, <6.0.0" 61 | pytz = ">=2020.5" 62 | 63 | [tool.poetry.group.dev.dependencies] 64 | setuptools = "^71.1.0" 65 | testcontainers = "^4.10.0" 66 | 67 | [build-system] 68 | requires = ["poetry-core>=1.3.1"] 69 | build-backend = "poetry.core.masonry.api" 70 | -------------------------------------------------------------------------------- /tests/core/test_component.py: -------------------------------------------------------------------------------- 1 | import dataclasses 2 | import pytest 3 | 4 | import winter.core 5 | from winter.core import Component 6 | from winter.core import WinterApplication 7 | 8 | 9 | @dataclasses.dataclass(frozen=True) 10 | class SimpleAnnotation: 11 | value: str 12 | 13 | 14 | def simple_annotation(param: str): 15 | return winter.core.annotate(SimpleAnnotation(param)) 16 | 17 | 18 | def test_is_component(): 19 | winter_app = WinterApplication() 20 | 21 | class SimpleComponent: 22 | pass 23 | 24 | winter_app.add_component(SimpleComponent) 25 | assert winter.core.is_component(SimpleComponent) 26 | 27 | 28 | def test_component_raises(): 29 | with pytest.raises(ValueError): 30 | winter.core.component(object()) 31 | 32 | 33 | def test_methods(): 34 | class SimpleComponent: 35 | 36 | @winter.core.component_method 37 | def simple_method(self): 38 | return self 39 | 40 | component = Component.get_by_cls(SimpleComponent) 41 | 42 | assert len(component.methods) == 1, SimpleComponent.simple_method 43 | method = component.get_method('simple_method') 44 | 45 | assert method is SimpleComponent.simple_method 46 | component = SimpleComponent() 47 | assert SimpleComponent.simple_method(component) is component 48 | 49 | 50 | def test_method_state(): 51 | class SimpleComponent: 52 | @simple_annotation('/url/') 53 | def simple_method(self): # pragma: no cover 54 | pass 55 | 56 | assert SimpleComponent.simple_method.annotations.get(SimpleAnnotation) == [SimpleAnnotation('/url/')] 57 | 58 | 59 | def test_method_state_many(): 60 | class SimpleComponent: 61 | @simple_annotation('first') 62 | @simple_annotation('second') 63 | def simple_method(self): # pragma: no cover 64 | pass 65 | 66 | expected_annotations = [SimpleAnnotation('second'), SimpleAnnotation('first')] 67 | assert SimpleComponent.simple_method.annotations.get(SimpleAnnotation) == expected_annotations 68 | 69 | 70 | def test_register_with_instance(): 71 | instance = object() 72 | 73 | with pytest.raises(ValueError) as exception: 74 | Component.register(instance) 75 | 76 | assert str(exception.value) == f'Need class. Got: {instance}' 77 | assert exception.value.args == (f'Need class. Got: {instance}',) 78 | -------------------------------------------------------------------------------- /winter/web/throttling/redis_throttling_client.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | from redis import Redis 4 | 5 | from .exceptions import ThrottlingMisconfigurationException 6 | from .redis_throttling_configuration import get_redis_throttling_configuration 7 | from .redis_throttling_configuration import RedisThrottlingConfiguration 8 | 9 | 10 | class RedisThrottlingClient: 11 | # Redis Lua scripts are atomic 12 | # Sliding window throttling. 13 | # Rejected requests aren't counted. 14 | THROTTLING_LUA = ''' 15 | local key = KEYS[1] 16 | local now = tonumber(ARGV[1]) 17 | local duration = tonumber(ARGV[2]) 18 | local max_requests = tonumber(ARGV[3]) 19 | 20 | redis.call("ZREMRANGEBYSCORE", key, 0, now - duration) 21 | local count = redis.call("ZCARD", key) 22 | 23 | if count >= max_requests then 24 | return 0 25 | end 26 | 27 | redis.call("ZADD", key, now, now) 28 | redis.call("EXPIRE", key, duration) 29 | return 1 30 | ''' 31 | 32 | def __init__(self, configuration: RedisThrottlingConfiguration): 33 | self._redis_client = Redis( 34 | host=configuration.host, 35 | port=configuration.port, 36 | db=configuration.db, 37 | password=configuration.password, 38 | decode_responses=True, 39 | ) 40 | self._throttling_script = self._redis_client.register_script(self.THROTTLING_LUA) 41 | 42 | def is_request_allowed(self, key: str, duration: int, num_requests: int) -> bool: 43 | now = time.time() 44 | is_allowed = self._throttling_script( 45 | keys=[key], 46 | args=[now, duration, num_requests] 47 | ) 48 | return is_allowed == 1 49 | 50 | def delete(self, key: str): 51 | self._redis_client.delete(key) 52 | 53 | 54 | _redis_throttling_client: RedisThrottlingClient | None = None 55 | 56 | def get_redis_throttling_client() -> RedisThrottlingClient: 57 | global _redis_throttling_client 58 | 59 | if _redis_throttling_client is None: 60 | configuration = get_redis_throttling_configuration() 61 | 62 | if configuration is None: 63 | raise ThrottlingMisconfigurationException('Configuration for Redis must be set before using the throttling') 64 | 65 | _redis_throttling_client = RedisThrottlingClient(configuration) 66 | 67 | return _redis_throttling_client 68 | -------------------------------------------------------------------------------- /tests/web/test_request_header.py: -------------------------------------------------------------------------------- 1 | from http import HTTPStatus 2 | 3 | import pytest 4 | 5 | from winter.web.request_header_annotation import request_header 6 | 7 | 8 | def test_without_arguments(): 9 | def method(header: str): # pragma: no cover 10 | return header 11 | 12 | annotation_decorator = request_header('X_Header', to='invalid_header') 13 | 14 | with pytest.raises(AssertionError) as exception: 15 | annotation_decorator(method) 16 | assert exception.value.args == ('Not found argument "invalid_header" in "method"',) 17 | 18 | 19 | def test_request_header(api_client): 20 | # Act 21 | response = api_client.post('/with-request-header/', headers={ 22 | 'x-header': '314', 23 | }) 24 | 25 | assert response.status_code == HTTPStatus.OK 26 | assert response.json() == 314 27 | 28 | 29 | def test_request_several_headers(api_client): 30 | # Act 31 | response = api_client.post( 32 | '/with-request-several-headers/', 33 | headers={ 34 | 'x-header': '314', 35 | 'y-header': '315', 36 | }, 37 | ) 38 | 39 | assert response.status_code == HTTPStatus.OK 40 | assert response.json() == [314, '315'] 41 | 42 | 43 | def test_request_header_invalid_type(api_client): 44 | # Act 45 | response = api_client.post( 46 | '/with-request-header/', 47 | headers={ 48 | 'x-header': 'abracadabra', 49 | }, 50 | ) 51 | 52 | assert response.status_code == HTTPStatus.BAD_REQUEST 53 | assert response.json() == { 54 | 'status': 400, 55 | 'type': 'urn:problem-type:request-data-decode', 56 | 'title': 'Request data decode', 57 | 'detail': 'Failed to decode request data', 58 | 'errors': { 59 | 'error': 'Cannot decode "abracadabra" to integer', 60 | } 61 | } 62 | 63 | 64 | def test_request_header_without_header(api_client): 65 | # Act 66 | response = api_client.post('/with-request-header/') 67 | 68 | assert response.status_code == HTTPStatus.BAD_REQUEST 69 | assert response.json() == { 70 | 'status': 400, 71 | 'type': 'urn:problem-type:request-data-decode', 72 | 'title': 'Request data decode', 73 | 'detail': 'Failed to decode request data', 74 | 'errors': { 75 | 'error': 'Missing required header "X-Header"', 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /winter/core/component.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | from typing import Dict 3 | from typing import Mapping 4 | from typing import Optional 5 | from typing import TYPE_CHECKING 6 | from typing import Type 7 | 8 | from .annotations import Annotations 9 | 10 | if TYPE_CHECKING: # pragma: no cover 11 | from .component_method import ComponentMethod 12 | 13 | 14 | class Component: 15 | _components = {} 16 | 17 | def __init__(self, component_cls: Type): 18 | self.component_cls = component_cls 19 | self.annotations = Annotations() 20 | self._methods: Dict[str, 'ComponentMethod'] = {} 21 | 22 | def __repr__(self): 23 | return f'Component(component_cls={self.component_cls.__name__})' 24 | 25 | @property 26 | def methods(self): 27 | return self._methods.values() 28 | 29 | def get_method(self, name: str) -> Optional['ComponentMethod']: 30 | return self._methods.get(name) 31 | 32 | def add_method(self, method: 'ComponentMethod'): 33 | method_name = method.name 34 | assert method_name not in self._methods, 'Component cannot have two methods with same name' 35 | self._methods[method_name] = method 36 | 37 | @classmethod 38 | def register(cls, cls_: Type) -> 'Component': 39 | if not inspect.isclass(cls_): 40 | cls._raise_invalid_class(cls_) 41 | instance = cls._components.get(cls_) 42 | if instance is None: 43 | instance = cls._components[cls_] = cls(cls_) 44 | return instance 45 | 46 | @classmethod 47 | def get_all(cls) -> Mapping: 48 | return cls._components 49 | 50 | @classmethod 51 | def get_by_cls(cls, component_cls) -> 'Component': 52 | if not inspect.isclass(component_cls): 53 | cls._raise_invalid_class(component_cls) 54 | component_ = cls._components.get(component_cls) 55 | if component_ is None: 56 | component_ = cls.register(component_cls) 57 | return component_ 58 | 59 | @classmethod 60 | def _raise_invalid_class(cls, cls_): 61 | raise ValueError(f'Need class. Got: {cls_}') 62 | 63 | 64 | def is_component(cls: Type) -> bool: 65 | return cls in Component.get_all() 66 | 67 | 68 | def component(cls: Type) -> Type: 69 | if not inspect.isclass(cls): 70 | raise ValueError(f'Need class. Given: {cls}') 71 | Component.register(cls) 72 | return cls 73 | -------------------------------------------------------------------------------- /.github/workflows/testing.yml: -------------------------------------------------------------------------------- 1 | name: Testing 2 | 3 | on: workflow_call 4 | 5 | jobs: 6 | test: 7 | runs-on: ubuntu-22.04 8 | strategy: 9 | matrix: 10 | python-version: [3.11, 3.12, 3.13] 11 | sqlalchemy-version: [ 1.4 ] 12 | django-version: [ 4.2 ] 13 | name: Test Py ${{ matrix.python-version }}, SQLA ${{ matrix.sqlalchemy-version }}, Django ${{ matrix.django-version }} 14 | steps: 15 | - uses: actions/checkout@v3 16 | - name: Set up Python ${{ matrix.python-version }} 17 | uses: actions/setup-python@v4 18 | with: 19 | python-version: ${{ matrix.python-version }} 20 | - name: Install and configure Poetry 21 | uses: snok/install-poetry@v1 22 | with: 23 | version: 1.3.2 24 | - name: Install dependencies 25 | run: | 26 | sed -i.bak 's/^SQLAlchemy = .*/SQLAlchemy = "^${{ matrix.sqlalchemy-version }}"/' pyproject.toml 27 | sed -i.bak 's/^Django = .*/Django = "^${{ matrix.django-version }}"/' pyproject.toml 28 | poetry install 29 | - name: Test with pytest 30 | run: | 31 | poetry run pytest -rfs --cov --cov-config=.coveragerc --cov-report="" --disable-warnings 32 | cp .coverage ".coverage.${{ matrix.python-version }}-${{ matrix.sqlalchemy-version }}-${{ matrix.django-version }}" 33 | - name: Upload coverage report 34 | uses: actions/upload-artifact@v4 35 | with: 36 | name: coverage-reports-${{ matrix.python-version }}-${{ matrix.sqlalchemy-version }}-${{ matrix.django-version }} 37 | include-hidden-files: true 38 | path: ".coverage.${{ matrix.python-version }}-${{ matrix.sqlalchemy-version }}-${{ matrix.django-version }}" 39 | 40 | coverage-check: 41 | name: Coverage check 42 | runs-on: ubuntu-22.04 43 | needs: [test] 44 | steps: 45 | - uses: actions/checkout@v3 46 | - name: Set up Python 3.12 47 | uses: actions/setup-python@v4 48 | with: 49 | python-version: 3.12 50 | - name: Install dependencies 51 | run: | 52 | pip3 install coverage==7.6.12 53 | - name: Download coverage reports 54 | uses: actions/download-artifact@v4 55 | with: 56 | pattern: coverage-reports-* 57 | merge-multiple: true 58 | path: coverage-reports 59 | - name: Combine reports 60 | run: | 61 | coverage combine coverage-reports 62 | coverage report --fail-under=100 63 | -------------------------------------------------------------------------------- /tests/web/test_media_type.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from winter.web import InvalidMediaTypeException 4 | from winter.web import MediaType 5 | 6 | 7 | @pytest.mark.parametrize('media_type, expected_result', [ 8 | ('*', ('*', '*', {})), 9 | ('*/*', ('*', '*', {})), 10 | ('abc/*', ('abc', '*', {})), 11 | ('application/json', ('application', 'json', {})), 12 | ('application/json; charset=utf-8', ('application', 'json', {'charset': 'utf-8'})), 13 | ('application/xml; charset=utf-8', ('application', 'xml', {'charset': 'utf-8'})), 14 | ('application/problem+xml; charset=utf-8; boundary=secondary', ('application', 'problem+xml', { 15 | 'charset': 'utf-8', 16 | 'boundary': 'secondary', 17 | })), 18 | ]) 19 | def test_valid_media_types(media_type, expected_result): 20 | result = MediaType.parse(media_type) 21 | assert result == expected_result 22 | 23 | 24 | @pytest.mark.parametrize('media_type, expected_message', [ 25 | ('*/abc', 'Wildcard is allowed only in */* (all media types)'), 26 | ('*/', 'Empty subtype is specified'), 27 | ('/', 'Empty type is specified'), 28 | ('/test', 'Empty type is specified'), 29 | ('', 'Media type must not be empty'), 30 | ('test', 'Media type must contain "/"'), 31 | ('test/test/test', 'Invalid media type format'), 32 | ('test/test; hz', 'Invalid media type parameter list'), 33 | ]) 34 | def test_invalid_media_types(media_type, expected_message): 35 | with pytest.raises(InvalidMediaTypeException) as exception_info: 36 | MediaType.parse(media_type) 37 | 38 | assert exception_info.value.message == expected_message 39 | 40 | 41 | @pytest.mark.parametrize(('first', 'second'), ( 42 | ('type/subtype', ' type / subtype '), 43 | ('type/subtype;charset=utf-8', ' type / subtype; charset = utf-8 '), 44 | )) 45 | def test_comparing_media_types(first, second): 46 | first_media_type = MediaType(first) 47 | second_media_type = MediaType(second) 48 | 49 | assert first_media_type == second_media_type 50 | assert hash(first_media_type) == hash(second_media_type) 51 | 52 | 53 | def test_comparing_with_other_types(): 54 | assert MediaType.ALL != '*' 55 | 56 | 57 | @pytest.mark.parametrize('media_type, expected_str', [ 58 | (MediaType(' * '), '*/*'), 59 | (MediaType(' application/problem+xml; charset=utf-8; boundary=secondary '), 60 | 'application/problem+xml; charset=utf-8; boundary=secondary'), 61 | ]) 62 | def test_str_representation(media_type, expected_str): 63 | assert str(media_type) == expected_str 64 | -------------------------------------------------------------------------------- /tests/api/simple_api.py: -------------------------------------------------------------------------------- 1 | import json 2 | from dataclasses import dataclass 3 | from http import HTTPStatus 4 | from typing import List 5 | 6 | from django.http import HttpRequest 7 | from django.http import HttpResponse 8 | 9 | import winter 10 | from winter import ResponseEntity 11 | from winter.core.json import JSONEncoder 12 | from winter.data.pagination import Page 13 | from winter.data.pagination import PagePosition 14 | 15 | 16 | @dataclass 17 | class Dataclass: 18 | number: int 19 | 20 | 21 | @dataclass(frozen=True) 22 | class CustomPage(Page[int]): 23 | extra: int 24 | 25 | 26 | @dataclass 27 | class CustomQueryParameters: 28 | x: List[int] 29 | y: List[int] 30 | 31 | 32 | @winter.route('winter-simple/') 33 | class SimpleAPI: 34 | 35 | @winter.route_get('{?name}') 36 | def hello(self, name: str = 'stranger') -> str: 37 | return f'Hello, {name}!' 38 | 39 | @winter.route_get('page-response/') 40 | def page_response(self, page_position: PagePosition) -> Page[Dataclass]: 41 | items = [Dataclass(1)] 42 | return Page(10, items, page_position) 43 | 44 | @winter.route_get('custom-page-response/') 45 | def custom_page_response(self, page_position: PagePosition) -> CustomPage: 46 | return CustomPage(total_count=10, items=[1, 2], position=page_position, extra=456) 47 | 48 | @winter.route_get('get-response-entity/') 49 | @winter.response_status(HTTPStatus.ACCEPTED) 50 | def return_response_entity(self) -> ResponseEntity[Dataclass]: 51 | return ResponseEntity[Dataclass](Dataclass(123), status_code=HTTPStatus.OK) 52 | 53 | @winter.route_get('return-response/') 54 | def return_response(self) -> HttpResponse: 55 | return HttpResponse(b'hi') 56 | 57 | @winter.route_get('get/') 58 | def get(self): 59 | pass 60 | 61 | @winter.route_post('post/') 62 | def post(self): 63 | pass 64 | 65 | @winter.route_delete('delete/') 66 | def delete(self): 67 | pass 68 | 69 | @winter.route_patch('patch/') 70 | def patch(self): 71 | pass 72 | 73 | @winter.route_put('put/') 74 | def put(self): 75 | pass 76 | 77 | @winter.response_status(HTTPStatus.OK) 78 | def no_route(self): # pragma: no cover 79 | pass 80 | 81 | @winter.route_get('custom-query-parameters/{?x*,y}') 82 | @winter.web.query_parameters('query_parameters') 83 | def custom_query_parameters(self, query_parameters: CustomQueryParameters) -> List[int]: 84 | return [*query_parameters.x, *query_parameters.y] 85 | -------------------------------------------------------------------------------- /winter/core/annotations.py: -------------------------------------------------------------------------------- 1 | from typing import Dict 2 | from typing import List 3 | from typing import Optional 4 | from typing import TypeVar 5 | 6 | AnnotationType = TypeVar('AnnotationType') 7 | 8 | 9 | class AnnotationException(Exception): 10 | pass 11 | 12 | 13 | class MultipleAnnotationFound(AnnotationException): 14 | 15 | def __init__(self, annotation_type: AnnotationType, count: int): 16 | self.annotation_type = annotation_type 17 | self.count = count 18 | 19 | def __str__(self): 20 | return f'Found more than one annotation for {self.annotation_type}: {self.count}' 21 | 22 | 23 | class NotFoundAnnotation(AnnotationException): 24 | 25 | def __init__(self, annotation_type: AnnotationType): 26 | self.annotation_type = annotation_type 27 | super().__init__(annotation_type) 28 | 29 | def __str__(self): 30 | return f'Not found annotation for {self.annotation_type}' 31 | 32 | 33 | class AlreadyAnnotated(AnnotationException): 34 | 35 | def __init__(self, annotation: AnnotationType): 36 | self.annotation = annotation 37 | 38 | def __str__(self): 39 | return f'Cannot annotate twice: {type(self.annotation)}' 40 | 41 | 42 | class Annotations: 43 | 44 | def __init__(self): 45 | self._data: Dict = {} 46 | 47 | def get(self, annotation_type: AnnotationType) -> List[AnnotationType]: 48 | return self._data.get(annotation_type, []) 49 | 50 | def get_one_or_none(self, annotation_type: AnnotationType) -> Optional[AnnotationType]: 51 | annotations = self.get(annotation_type) 52 | 53 | count_annotations = len(annotations) 54 | 55 | if count_annotations > 1: 56 | raise MultipleAnnotationFound(annotation_type, count_annotations) 57 | 58 | return annotations[0] if annotations else None 59 | 60 | def get_one(self, annotation_type: AnnotationType) -> AnnotationType: 61 | annotation = self.get_one_or_none(annotation_type) 62 | if annotation is None: 63 | raise NotFoundAnnotation(annotation_type) 64 | return annotation 65 | 66 | def add(self, annotation: AnnotationType, unique=False, single=False): 67 | annotation_type = annotation.__class__ 68 | 69 | if single and annotation_type in self._data: 70 | raise AlreadyAnnotated(annotation) 71 | 72 | annotations = self._data.setdefault(annotation_type, []) 73 | 74 | if unique and annotation in annotations: 75 | raise AlreadyAnnotated(annotation) 76 | 77 | annotations.append(annotation) 78 | -------------------------------------------------------------------------------- /winter/web/routing/routing.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | from typing import Tuple 3 | 4 | from winter.core import ComponentMethod 5 | from winter.core import annotate 6 | from .route_annotation import RouteAnnotation 7 | from ..media_type import MediaType 8 | from ..routing.route import Route 9 | 10 | 11 | def route( 12 | url_path: str, 13 | http_method: Optional[str] = None, 14 | produces: Optional[Tuple[MediaType]] = None, 15 | consumes: Optional[Tuple[MediaType]] = None, 16 | ): 17 | route_annotation = RouteAnnotation(url_path, http_method, produces, consumes) 18 | return annotate(route_annotation, single=True) 19 | 20 | 21 | def route_get(url_path='', produces: Optional[Tuple[MediaType]] = None, consumes: Optional[Tuple[MediaType]] = None): 22 | return route(url_path, 'GET', produces=produces, consumes=consumes) 23 | 24 | 25 | def route_post(url_path='', produces: Optional[Tuple[MediaType]] = None, consumes: Optional[Tuple[MediaType]] = None): 26 | return route(url_path, 'POST', produces=produces, consumes=consumes) 27 | 28 | 29 | def route_delete(url_path='', produces: Optional[Tuple[MediaType]] = None, consumes: Optional[Tuple[MediaType]] = None): 30 | return route(url_path, 'DELETE', produces=produces, consumes=consumes) 31 | 32 | 33 | def route_patch(url_path='', produces: Optional[Tuple[MediaType]] = None, consumes: Optional[Tuple[MediaType]] = None): 34 | return route(url_path, 'PATCH', produces=produces, consumes=consumes) 35 | 36 | 37 | def route_put(url_path='', produces: Optional[Tuple[MediaType]] = None, consumes: Optional[Tuple[MediaType]] = None): 38 | return route(url_path, 'PUT', produces=produces, consumes=consumes) 39 | 40 | 41 | def get_route(method: ComponentMethod) -> Optional[Route]: 42 | route_annotation = method.annotations.get_one_or_none(RouteAnnotation) 43 | if route_annotation is None: 44 | return None 45 | 46 | url_path = get_url_path(method) 47 | route = Route( 48 | route_annotation.http_method, 49 | url_path, 50 | method, 51 | ) 52 | return route 53 | 54 | 55 | def get_url_path(method: ComponentMethod) -> str: 56 | if method.component is None: 57 | component_route_annotation = None 58 | else: 59 | route_annotations = method.component.annotations 60 | component_route_annotation = route_annotations.get_one_or_none(RouteAnnotation) 61 | component_url = component_route_annotation.url_path if component_route_annotation is not None else '' 62 | route_annotation = method.annotations.get_one_or_none(RouteAnnotation) 63 | url_path = component_url + ('' if route_annotation is None else route_annotation.url_path) 64 | return url_path 65 | -------------------------------------------------------------------------------- /tests/web/test_response_header.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import uuid 3 | from http import HTTPStatus 4 | 5 | import pytz 6 | 7 | from winter.web import ResponseHeader 8 | 9 | 10 | def test_response_header_sets_header(): 11 | headers = {} 12 | header = ResponseHeader[uuid.UUID](headers, 'My-Header') 13 | uid = uuid.uuid4() 14 | 15 | # Act 16 | header.set(uid) 17 | 18 | assert headers['my-header'] == uid 19 | 20 | 21 | def test_str_response_header(api_client): 22 | # Act 23 | response = api_client.get('/with-response-headers/str-header/') 24 | 25 | assert response.status_code == HTTPStatus.OK 26 | assert response.json() == 'OK' 27 | assert response.headers['x-header'] == 'test header' 28 | 29 | 30 | def test_int_response_header(api_client): 31 | # Act 32 | response = api_client.get('/with-response-headers/int-header/') 33 | 34 | assert response.status_code == HTTPStatus.OK 35 | assert response.json() == 'OK' 36 | assert response.headers['x-header'] == '123' 37 | 38 | 39 | def test_datetime_isoformat_response_header(api_client): 40 | now = datetime.datetime.now() 41 | 42 | # Act 43 | response = api_client.get(f'/with-response-headers/datetime-isoformat-header/?now={now.timestamp()}') 44 | 45 | assert response.status_code == HTTPStatus.OK 46 | assert response.json() == 'OK' 47 | assert response.headers['x-header'] == now.isoformat() 48 | 49 | 50 | def test_last_modified_response_header(api_client): 51 | now = datetime.datetime.now() 52 | 53 | # Act 54 | response = api_client.get(f'/with-response-headers/last-modified-header/?now={now.timestamp()}') 55 | 56 | assert response.status_code == HTTPStatus.OK 57 | assert response.json() == 'OK' 58 | assert response.headers['last-modified'] == now.astimezone(pytz.utc).strftime('%a, %d %b %Y %X GMT') 59 | 60 | 61 | def test_uuid_response_header(api_client): 62 | uid = uuid.uuid4() 63 | 64 | # Act 65 | response = api_client.get(f'/with-response-headers/uuid-header/?uid={uid}') 66 | 67 | assert response.status_code == HTTPStatus.OK 68 | assert response.json() == 'OK' 69 | assert response.headers['x-header'] == str(uid) 70 | 71 | 72 | def test_two_response_headers(api_client): 73 | # Act 74 | response = api_client.get('/with-response-headers/two-headers/') 75 | 76 | assert response.status_code == HTTPStatus.OK 77 | assert response.json() == 'OK' 78 | assert response.headers['x-header1'] == 'header1' 79 | assert response.headers['x-header2'] == 'header2' 80 | 81 | 82 | def test_header_without_annotation(api_client): 83 | # Act 84 | response = api_client.get('/with-response-headers/header-without-annotation/') 85 | 86 | # Assert 87 | assert response.status_code == 500 # It's better to be replaced with something more human readable 88 | -------------------------------------------------------------------------------- /tests/test_argument_resolver.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from mock import Mock 3 | 4 | from winter import ArgumentsResolver 5 | from winter.core import ComponentMethod 6 | from winter.web.argument_resolver import ArgumentNotSupported 7 | 8 | 9 | def test_resolve_arguments_returns_empty_dict_for_empty_arguments(): 10 | def func(): # pragma: no cover 11 | pass 12 | 13 | expected_resolved_arguments = {} 14 | method = ComponentMethod(func) 15 | arguments_resolver = ArgumentsResolver() 16 | 17 | # Act 18 | resolved_arguments = arguments_resolver.resolve_arguments(method, request=Mock(), response_headers={}) 19 | 20 | # Assert 21 | assert resolved_arguments == expected_resolved_arguments 22 | 23 | 24 | def test_resolve_arguments_resolves_argument_with_the_first_resolver(): 25 | def func(a: int): # pragma: no cover 26 | pass 27 | 28 | expected_resolved_value = 1 29 | expected_resolved_arguments = { 30 | 'a': expected_resolved_value, 31 | } 32 | method = ComponentMethod(func) 33 | arguments_resolver = ArgumentsResolver() 34 | resolver = Mock() 35 | resolver.is_supported.return_value = True 36 | resolver.resolve_argument.return_value = expected_resolved_value 37 | arguments_resolver.add_argument_resolver(resolver) 38 | 39 | # Act 40 | resolved_arguments = arguments_resolver.resolve_arguments(method, request=Mock(), response_headers={}) 41 | 42 | # Assert 43 | assert resolved_arguments == expected_resolved_arguments 44 | 45 | 46 | def test_resolve_arguments_resolves_argument_with_the_second_resolver(): 47 | def func(a: int): # pragma: no cover 48 | pass 49 | 50 | expected_resolved_value = 1 51 | expected_resolved_arguments = { 52 | 'a': expected_resolved_value, 53 | } 54 | method = ComponentMethod(func) 55 | arguments_resolver = ArgumentsResolver() 56 | resolver1 = Mock() 57 | resolver1.is_supported.return_value = False 58 | arguments_resolver.add_argument_resolver(resolver1) 59 | resolver2 = Mock() 60 | resolver2.is_supported.return_value = True 61 | resolver2.resolve_argument.return_value = expected_resolved_value 62 | arguments_resolver.add_argument_resolver(resolver2) 63 | 64 | # Act 65 | resolved_arguments = arguments_resolver.resolve_arguments(method, request=Mock(), response_headers={}) 66 | 67 | # Assert 68 | assert resolved_arguments == expected_resolved_arguments 69 | 70 | 71 | def test_resolve_arguments_fails(): 72 | def func(a: int): # pragma: no cover 73 | pass 74 | 75 | arg_name = 'a' 76 | method = ComponentMethod(func) 77 | arguments_resolver = ArgumentsResolver() 78 | 79 | # Assert 80 | with pytest.raises(ArgumentNotSupported, match=f'Unable to resolve argument {arg_name}: int'): 81 | # Act 82 | arguments_resolver.resolve_arguments(method, request=Mock(), response_headers={}) 83 | -------------------------------------------------------------------------------- /tests/routing/test_path_parameters.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | import pytest 4 | from django.http import HttpRequest 5 | from mock import Mock 6 | 7 | from tests.api.api_with_path_parameters import APIWithPathParameters 8 | from tests.api.api_with_path_parameters import OneTwoEnum 9 | from tests.api.api_with_path_parameters import OneTwoEnumWithInt 10 | from winter.core import Component 11 | from winter.web.argument_resolver import ArgumentNotSupported 12 | from winter.web.path_parameters_argument_resolver import PathParametersArgumentResolver 13 | 14 | uuid_ = uuid.uuid4() 15 | 16 | 17 | @pytest.mark.parametrize( 18 | 'path, arg_name, expected_value', [ 19 | (f'/with-path-parameters/123/456/one/{uuid_}/2/', 'param1', '123'), 20 | (f'/with-path-parameters/123/456/one/{uuid_}/2/', 'param2', 456), 21 | (f'/with-path-parameters/123/456/one/{uuid_}/2/', 'param3', OneTwoEnum.ONE), 22 | (f'/with-path-parameters/123/456/one/{uuid_}/2/', 'param4', uuid_), 23 | (f'/with-path-parameters/123/456/one/{uuid_}/2/', 'param5', OneTwoEnumWithInt.TWO), 24 | ], 25 | ) 26 | def test_resolve_path_parameter(path, arg_name, expected_value): 27 | component = Component.get_by_cls(APIWithPathParameters) 28 | argument = component.get_method('test').get_argument(arg_name) 29 | resolver = PathParametersArgumentResolver() 30 | request = Mock(spec=HttpRequest) 31 | request.path_info = path 32 | 33 | # Act 34 | result = resolver.resolve_argument(argument, request, {}) 35 | 36 | # Assert 37 | assert result == expected_value 38 | 39 | 40 | @pytest.mark.parametrize( 41 | 'api_class, method_name, arg_name, expected_value', [ 42 | (APIWithPathParameters, 'test', 'param1', True), 43 | (APIWithPathParameters, 'test', 'param2', True), 44 | (APIWithPathParameters, 'test', 'param6', False), 45 | ], 46 | ) 47 | def test_is_supported_path_parameter(api_class, method_name, arg_name, expected_value): 48 | component = Component.get_by_cls(api_class) 49 | argument = component.get_method(method_name).get_argument(arg_name) 50 | resolver = PathParametersArgumentResolver() 51 | 52 | # Act 53 | is_supported = resolver.is_supported(argument) 54 | second_is_supported = resolver.is_supported(argument) 55 | 56 | # Assert 57 | assert is_supported == expected_value 58 | assert second_is_supported == expected_value 59 | 60 | 61 | def test_with_raises_argument_not_supported(): 62 | component = Component.get_by_cls(APIWithPathParameters) 63 | argument = component.get_method('test').get_argument('param6') 64 | resolver = PathParametersArgumentResolver() 65 | request = Mock(spec=HttpRequest) 66 | request.path_info = f'/with-path-parameters/123/456/one/{uuid_}/2/' 67 | 68 | with pytest.raises(ArgumentNotSupported) as exception: 69 | resolver.resolve_argument(argument, request, {}) 70 | 71 | assert str(exception.value) == 'Unable to resolve argument param6: str' 72 | -------------------------------------------------------------------------------- /tests/winter_openapi/test_generator.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from winter_openapi.generator import determine_path_prefix 4 | from winter_openapi.generator import get_url_path_tag 5 | from winter_openapi.generator import get_url_path_without_prefix 6 | 7 | 8 | @pytest.mark.parametrize( 9 | ('url_paths', 'expected_result'), 10 | [ 11 | ( 12 | [ 13 | 'prefix-1/prefix-2/get-resource', 14 | 'prefix-1/prefix-3/post-resource' 15 | ], 16 | '/prefix-1', 17 | ), 18 | ( 19 | [ 20 | '/prefix-1/get-resource', 21 | '/prefix-1/post-resource' 22 | ], 23 | '/prefix-1', 24 | ), 25 | ( 26 | [ 27 | 'get-resource', 28 | 'post-resource' 29 | ], 30 | '/', 31 | ), 32 | ( 33 | [ 34 | '', 35 | '' 36 | ], 37 | '/', 38 | ), 39 | ( 40 | [ 41 | '{id}/get-resource', 42 | '{id}/post-resource' 43 | ], 44 | '/', 45 | ), 46 | ], 47 | ) 48 | def test_determine_path_prefix_when_prefix_exist(url_paths, expected_result): 49 | # Act 50 | path_prefix = determine_path_prefix(url_paths) 51 | 52 | # Assert 53 | assert path_prefix == expected_result 54 | 55 | 56 | @pytest.mark.parametrize( 57 | 'url_path, path_prefix, expected_result', 58 | [ 59 | ('prefix/get-resource', '/prefix', 'get-resource'), 60 | ('prefix/{id}/get-resource', '/prefix', None), 61 | ('prefix', '/prefix', None), 62 | ] 63 | ) 64 | def test_get_url_path_tag(url_path, path_prefix, expected_result): 65 | # Act 66 | url_path_tag = get_url_path_tag(url_path, path_prefix) 67 | 68 | # Assert 69 | assert url_path_tag == expected_result 70 | 71 | 72 | def test_get_url_path_tag_when_url_path_is_shorter_when_prefix(): 73 | # Act & Assert 74 | with pytest.raises(ValueError, match='Invalid path prefix /prefix-1/prefix-2 for url_path get-resource'): 75 | get_url_path_tag('get-resource', '/prefix-1/prefix-2') 76 | 77 | 78 | @pytest.mark.parametrize( 79 | 'url_path,path_prefix,expected_result', 80 | [ 81 | ('/get-resource', '/', '/get-resource'), 82 | ('/get-resource', '/prefix-1/prefix-2', '/get-resource'), 83 | ('/prefix-1/prefix-2/get-resource', '/prefix-1/prefix-2', '/get-resource'), 84 | ('/prefix-1/prefix-2/get-resource', '/prefix-1', '/prefix-2/get-resource'), 85 | ('prefix-1/prefix-2/get-resource', '/prefix-1/prefix-2', '/get-resource'), 86 | ] 87 | ) 88 | def test_get_url_path_without_prefix(url_path, path_prefix, expected_result): 89 | # Act 90 | url_path_without_prefix = get_url_path_without_prefix(url_path, path_prefix) 91 | 92 | # Assert 93 | assert url_path_without_prefix == expected_result 94 | -------------------------------------------------------------------------------- /winter_ddd/domain_event_dispatcher.py: -------------------------------------------------------------------------------- 1 | from typing import Callable 2 | from typing import Dict 3 | from typing import Iterable 4 | from typing import List 5 | from typing import Type 6 | 7 | from winter.core import Component 8 | from winter.core import ComponentMethod 9 | from winter.core import get_injector 10 | from winter.core.module_discovery import get_all_classes 11 | from winter.core.module_discovery import import_recursively 12 | from .domain_event import DomainEvent 13 | from .domain_event_handler import DomainEventHandlerAnnotation 14 | from .domain_event_subscription import DomainEventSubscription 15 | 16 | 17 | class DomainEventDispatcher: 18 | def __init__(self): 19 | self.event_type_to_subscription_map: Dict[Type[DomainEvent], List[DomainEventSubscription]] = {} 20 | 21 | def add_handler(self, handler: ComponentMethod): 22 | subscription = DomainEventSubscription.create(handler.component.component_cls, handler.func) 23 | for event_type in subscription.event_filter: 24 | self.event_type_to_subscription_map.setdefault(event_type, []).append(subscription) 25 | 26 | def add_handlers_from_package(self, package_name: str): 27 | import_recursively(package_name) 28 | for class_name, class_ in get_all_classes(package_name): 29 | self.add_handlers_from_class(class_) 30 | 31 | def add_handlers_from_class(self, handler_class: Type): 32 | component = Component.get_by_cls(handler_class) 33 | for component_method in component.methods: 34 | if component_method.annotations.get_one_or_none(DomainEventHandlerAnnotation): 35 | self.add_handler(component_method) 36 | 37 | def dispatch(self, events: Iterable[DomainEvent]): 38 | events_grouped_by_subscription: Dict[DomainEventSubscription, List[DomainEvent]] = {} 39 | 40 | for event in events: 41 | event_type = type(event) 42 | domain_event_subscriptions = self.event_type_to_subscription_map.get(event_type, []) 43 | for domain_event_subscription in domain_event_subscriptions: 44 | events_grouped_by_subscription.setdefault(domain_event_subscription, []).append(event) 45 | 46 | injector = get_injector() 47 | 48 | for domain_event_subscription, events in events_grouped_by_subscription.items(): 49 | handler_instance = injector.get(domain_event_subscription.handler_class) 50 | if domain_event_subscription.collection: 51 | self._execute_handler(domain_event_subscription.handler_method, handler_instance, events) 52 | else: 53 | for event in events: 54 | self._execute_handler(domain_event_subscription.handler_method, handler_instance, event) 55 | 56 | def _execute_handler(self, func: Callable, *args, **kwargs): 57 | """ 58 | The method is intentionally extracted to make it possible to override it externally for logging purposes. 59 | """ 60 | func(*args, **kwargs) 61 | -------------------------------------------------------------------------------- /tests/winter_openapi/test_api_with_media_types_spec.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | import winter 4 | from winter.web import MediaType 5 | from winter.web.routing import get_route 6 | from winter_openapi import generate_openapi 7 | 8 | 9 | def test_generate_spec_for_media_type_produces(): 10 | class _TestAPI: 11 | @winter.route_get('xml/', produces=(MediaType.APPLICATION_XML,)) 12 | def get_xml(self) -> str: # pragma: no cover 13 | return 'Hello, sir!' 14 | 15 | route = get_route(_TestAPI.get_xml) 16 | 17 | # Act 18 | result = generate_openapi(title='title', version='1.0.0', routes=[route]) 19 | 20 | # Assert 21 | paths = result['paths'] 22 | assert paths == { 23 | '/xml/': { 24 | 'get': { 25 | 'deprecated': False, 26 | 'operationId': '_TestAPI.get_xml', 27 | 'parameters': [], 28 | 'responses': { 29 | '200': { 30 | 'content': {'application/xml': {'schema': {'type': 'string'}}}, 31 | 'description': '', 32 | }, 33 | }, 34 | 'tags': ['xml'], 35 | }, 36 | }, 37 | } 38 | 39 | 40 | def test_generate_spec_for_media_type_consumes(): 41 | @dataclass 42 | class Data: 43 | field1: str 44 | 45 | class _TestAPI: 46 | @winter.route_post('xml/', consumes=(MediaType.APPLICATION_XML,)) 47 | @winter.request_body('body') 48 | def get_xml(self, body: Data): # pragma: no cover 49 | pass 50 | 51 | route = get_route(_TestAPI.get_xml) 52 | 53 | # Act 54 | result = generate_openapi(title='title', version='1.0.0', routes=[route]) 55 | 56 | # Assert 57 | paths = result['paths'] 58 | assert paths == { 59 | '/xml/': { 60 | 'post': { 61 | 'deprecated': False, 62 | 'operationId': '_TestAPI.get_xml', 63 | 'parameters': [], 64 | 'requestBody': { 65 | 'content': { 66 | 'application/xml': { 67 | 'schema': { 68 | '$ref': '#/components/schemas/DataInput', 69 | }, 70 | }, 71 | }, 72 | 'required': False, 73 | }, 74 | 'responses': {'200': {'description': ''}}, 75 | 'tags': ['xml'], 76 | }, 77 | }, 78 | } 79 | assert result['components'] == { 80 | 'schemas': { 81 | 'DataInput': { 82 | 'description': 'Data(field1: str)', 83 | 'properties': {'field1': {'type': 'string'}}, 84 | 'required': ['field1'], 85 | 'title': 'DataInput', 86 | 'type': 'object', 87 | }, 88 | }, 89 | 'parameters': {}, 90 | 'responses': {}, 91 | } 92 | -------------------------------------------------------------------------------- /tests/api/api_with_exceptions.py: -------------------------------------------------------------------------------- 1 | import dataclasses 2 | 3 | import winter.web 4 | 5 | 6 | class CustomException(Exception): 7 | def __init__(self, message: str): 8 | super().__init__(message) 9 | self.message = message 10 | 11 | 12 | class WithUnknownArgumentException(Exception): 13 | pass 14 | 15 | 16 | class ExceptionWithoutHandler(Exception): 17 | pass 18 | 19 | 20 | @dataclasses.dataclass 21 | class CustomExceptionDTO: 22 | message: str 23 | 24 | 25 | class CustomExceptionHandler(winter.web.ExceptionHandler): 26 | @winter.response_status(400) 27 | def handle(self, exception: CustomException) -> CustomExceptionDTO: 28 | return CustomExceptionDTO(exception.message) 29 | 30 | 31 | class AnotherExceptionHandler(winter.web.ExceptionHandler): 32 | @winter.response_status(401) 33 | def handle(self, exception: CustomException) -> int: 34 | return 21 35 | 36 | 37 | class WithUnknownArgumentExceptionHandler(winter.web.ExceptionHandler): 38 | @winter.response_status(400) 39 | def handle(self, exception: WithUnknownArgumentException, unknown_argument: int) -> str: # pragma: no cover 40 | pass 41 | 42 | 43 | winter.web.exception_handlers_registry.add_handler(CustomException, CustomExceptionHandler) 44 | winter.web.exception_handlers_registry.add_handler(WithUnknownArgumentException, WithUnknownArgumentExceptionHandler) 45 | 46 | 47 | class ChildCustomException(CustomException): 48 | pass 49 | 50 | 51 | @winter.route('with_exceptions/') 52 | class APIWithExceptions: 53 | 54 | @winter.route_get('declared_but_not_thrown/') 55 | @winter.raises(CustomException) 56 | def declared_but_not_thrown(self) -> str: 57 | return 'Hello, sir!' 58 | 59 | @winter.route_get('declared_and_thrown/') 60 | @winter.raises(CustomException) 61 | def declared_and_thrown(self) -> str: 62 | raise CustomException('declared_and_thrown') 63 | 64 | @winter.route_get('declared_and_thrown_child/') 65 | @winter.raises(CustomException) 66 | def declared_and_thrown_child(self) -> str: 67 | raise ChildCustomException('declared_and_thrown_child') 68 | 69 | @winter.route_get('not_declared_but_thrown/') 70 | def not_declared_but_thrown(self) -> str: 71 | raise CustomException('not_declared_but_thrown') 72 | 73 | @winter.route_get('declared_but_no_handler/') 74 | @winter.raises(ExceptionWithoutHandler) 75 | def declared_but_no_handler(self) -> str: 76 | raise ExceptionWithoutHandler() 77 | 78 | @winter.raises(CustomException, AnotherExceptionHandler) 79 | @winter.route_get('exception_with_custom_handler/') 80 | def with_custom_handler(self) -> str: 81 | raise CustomException('message') 82 | 83 | @winter.raises(WithUnknownArgumentException) 84 | @winter.raises(CustomException) # second "raises" just for 100% test coverage 85 | @winter.route_get('with_unknown_argument_exception/') 86 | def with_unknown_argument_handler(self) -> str: 87 | raise WithUnknownArgumentException() 88 | -------------------------------------------------------------------------------- /tests/data/pagination/test_sort.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from winter.data.pagination import Order 4 | from winter.data.pagination import Sort 5 | from winter.data.pagination import SortDirection 6 | 7 | 8 | def test_empty_sort_orders(): 9 | sort = Sort() 10 | assert sort.orders == () 11 | 12 | 13 | def test_sort_orders(): 14 | orders = ( 15 | Order('a', SortDirection.DESC), 16 | Order('b'), 17 | ) 18 | sort = Sort(*orders) 19 | assert sort.orders == orders 20 | 21 | 22 | def test_sort_by(): 23 | fields = ('a', 'b') 24 | orders = tuple(Order(field) for field in fields) 25 | sort = Sort.by(*fields) 26 | assert sort.orders == orders 27 | 28 | 29 | def test_sort_and(): 30 | orders_1 = ( 31 | Order('a', SortDirection.DESC), 32 | Order('b'), 33 | ) 34 | orders_2 = ( 35 | Order('c'), 36 | Order('d', SortDirection.ASC), 37 | ) 38 | sort_1 = Sort(*orders_1) 39 | sort_2 = Sort(*orders_2) 40 | new_sort = sort_1.and_(sort_2) 41 | assert new_sort.orders == orders_1 + orders_2 42 | 43 | 44 | def test_sort_asc(): 45 | orders = ( 46 | Order('a', SortDirection.DESC), 47 | Order('b', SortDirection.ASC), 48 | Order('c', SortDirection.DESC), 49 | ) 50 | sort = Sort(*orders) 51 | new_sort = sort.asc() 52 | assert all(order.direction == SortDirection.ASC for order in new_sort.orders) 53 | 54 | 55 | def test_sort_desc(): 56 | orders = ( 57 | Order('a', SortDirection.ASC), 58 | Order('b', SortDirection.DESC), 59 | Order('c', SortDirection.ASC), 60 | ) 61 | sort = Sort(*orders) 62 | new_sort = sort.desc() 63 | assert all(order.direction == SortDirection.DESC for order in new_sort.orders) 64 | 65 | 66 | def test_sort_equal(): 67 | orders = ( 68 | Order('a', SortDirection.DESC), 69 | Order('b', SortDirection.ASC), 70 | Order('c', SortDirection.DESC), 71 | ) 72 | sort_1 = Sort(*orders) 73 | sort_2 = Sort(*orders) 74 | assert sort_1 == sort_2 75 | 76 | 77 | def test_sort_not_equal_to_int(): 78 | sort = Sort() 79 | assert sort != 0 80 | 81 | 82 | def test_sort_hash(): 83 | orders = ( 84 | Order('a', SortDirection.DESC), 85 | Order('b', SortDirection.ASC), 86 | Order('c', SortDirection.DESC), 87 | ) 88 | sort_1 = Sort(*orders) 89 | sort_2 = Sort(*orders) 90 | sort_3 = Sort() 91 | sort_set = {sort_1, sort_2, sort_3} 92 | assert len(sort_set) == 2 93 | 94 | 95 | def test_sort_by_fails_for_zero_fields(): 96 | with pytest.raises(ValueError, match='Specify at least one field.'): 97 | Sort.by() 98 | 99 | 100 | def test_order_to_string(): 101 | assert str(Order('id', SortDirection.ASC)) == 'id' 102 | assert str(Order('id', SortDirection.DESC)) == '-id' 103 | 104 | 105 | def test_repr_sort(): 106 | sort = Sort( 107 | Order('foo', SortDirection.ASC), 108 | Order('bar', SortDirection.DESC), 109 | ) 110 | 111 | assert repr(sort) == "Sort('foo,-bar')" 112 | -------------------------------------------------------------------------------- /winter/web/query_parameters/query_parameter_argument_resolver.py: -------------------------------------------------------------------------------- 1 | from typing import MutableMapping 2 | from typing import Optional 3 | 4 | import django.http 5 | 6 | from winter.core import ArgumentDoesNotHaveDefault 7 | from winter.core import ComponentMethodArgument 8 | from winter.core.json import JSONDecodeException 9 | from winter.core.json import json_decode 10 | from winter.core.utils.typing import is_iterable_type 11 | from .query_parameter import QueryParameter 12 | from ..argument_resolver import ArgumentNotSupported 13 | from ..argument_resolver import ArgumentResolver 14 | from ..exceptions import RequestDataDecodeException 15 | from ..routing import get_route 16 | 17 | 18 | class QueryParameterArgumentResolver(ArgumentResolver): 19 | 20 | def __init__(self): 21 | super().__init__() 22 | self._query_parameters = {} 23 | 24 | def is_supported(self, argument: ComponentMethodArgument) -> bool: 25 | return self._get_query_parameter(argument) is not None 26 | 27 | def resolve_argument( 28 | self, 29 | argument: ComponentMethodArgument, 30 | request: django.http.HttpRequest, 31 | response_headers: MutableMapping[str, str], 32 | ): 33 | query_parameters = request.GET 34 | 35 | query_parameter = self._get_query_parameter(argument) 36 | 37 | if query_parameter is None: 38 | raise ArgumentNotSupported(argument) 39 | 40 | parameter_name = query_parameter.name 41 | explode = query_parameter.explode 42 | is_iterable = is_iterable_type(argument.type_) 43 | 44 | if parameter_name not in query_parameters: 45 | try: 46 | return argument.get_default() 47 | except ArgumentDoesNotHaveDefault: 48 | raise RequestDataDecodeException(f'Missing required query parameter "{parameter_name}"') 49 | 50 | value = self.get_value(query_parameters, parameter_name, is_iterable, explode) 51 | 52 | try: 53 | return json_decode(value, argument.type_) 54 | except JSONDecodeException as e: 55 | raise RequestDataDecodeException(e.errors) 56 | 57 | def _get_query_parameter(self, argument: ComponentMethodArgument) -> Optional[QueryParameter]: 58 | if argument in self._query_parameters: 59 | return self._query_parameters[argument] 60 | 61 | route = get_route(argument.method) 62 | if route is None: 63 | return None 64 | 65 | query_parameter = next( 66 | ( 67 | query_parameter 68 | for query_parameter in route.get_query_parameters() 69 | if query_parameter.map_to == argument.name 70 | ), 71 | None, 72 | ) 73 | self._query_parameters[argument] = query_parameter 74 | return query_parameter 75 | 76 | @staticmethod 77 | def get_value(parameters, parameter_name, is_iterable, explode): 78 | if is_iterable: 79 | if explode: 80 | return parameters.getlist(parameter_name) 81 | return parameters[parameter_name].split(',') 82 | else: 83 | return parameters[parameter_name] 84 | -------------------------------------------------------------------------------- /winter/web/throttling/throttling.py: -------------------------------------------------------------------------------- 1 | import dataclasses 2 | import time 3 | from typing import Optional 4 | from typing import TYPE_CHECKING 5 | from typing import Tuple 6 | 7 | import django.http 8 | 9 | from winter.core import annotate_method 10 | from .redis_throttling_client import get_redis_throttling_client 11 | 12 | if TYPE_CHECKING: 13 | from winter.web.routing import Route # noqa: F401 14 | 15 | 16 | @dataclasses.dataclass 17 | class ThrottlingAnnotation: 18 | rate: Optional[str] 19 | scope: Optional[str] 20 | 21 | 22 | @dataclasses.dataclass 23 | class Throttling: 24 | num_requests: int 25 | duration: int 26 | scope: str 27 | 28 | 29 | def throttling(rate: Optional[str], scope: Optional[str] = None): 30 | return annotate_method(ThrottlingAnnotation(rate, scope), single=True) 31 | 32 | 33 | class BaseRateThrottle: 34 | def __init__(self, throttling_: Throttling): 35 | self._throttling = throttling_ 36 | self._redis_client = get_redis_throttling_client() 37 | 38 | def allow_request(self, request: django.http.HttpRequest) -> bool: 39 | ident = _get_ident(request) 40 | key = _get_cache_key(self._throttling.scope, ident) 41 | 42 | return self._redis_client.is_request_allowed(key, self._throttling.duration, self._throttling.num_requests) 43 | 44 | 45 | def reset(request: django.http.HttpRequest, scope: str): 46 | """ 47 | This function allows to reset the accumulated throttling state 48 | for a specific user and scope 49 | """ 50 | ident = _get_ident(request) 51 | key = _get_cache_key(scope, ident) 52 | redis_client = get_redis_throttling_client() 53 | redis_client.delete(key) 54 | 55 | 56 | CACHE_KEY_FORMAT = 'throttle_{scope}_{ident}' 57 | 58 | 59 | def _get_cache_key(scope: str, ident: str) -> str: 60 | return CACHE_KEY_FORMAT.format(scope=scope, ident=ident) 61 | 62 | 63 | def _get_ident(request: django.http.HttpRequest) -> str: 64 | if hasattr(request, 'user') and request.user and request.user.is_authenticated: 65 | return str(request.user.pk) 66 | 67 | xff = request.META.get('HTTP_X_FORWARDED_FOR') 68 | remote_addr = request.META.get('REMOTE_ADDR') 69 | return ''.join(xff.split()) if xff else remote_addr 70 | 71 | 72 | def _parse_rate(rate: str) -> Tuple[int, int]: 73 | """ 74 | Given the request rate string, return a two tuple of: 75 | , 76 | """ 77 | num, period = rate.split('/') 78 | num_requests = int(num) 79 | duration = {'s': 1, 'm': 60, 'h': 3600, 'd': 86400}[period[0]] 80 | return num_requests, duration 81 | 82 | 83 | def create_throttle_class(route: 'Route') -> Optional[BaseRateThrottle]: 84 | throttling_annotation = route.method.annotations.get_one_or_none(ThrottlingAnnotation) 85 | 86 | if getattr(throttling_annotation, 'rate', None) is None: 87 | return None 88 | 89 | num_requests, duration = _parse_rate(throttling_annotation.rate) 90 | throttling_scope = throttling_annotation.scope or route.method.full_name 91 | throttling_ = Throttling(num_requests, duration, throttling_scope) 92 | 93 | return BaseRateThrottle(throttling_) 94 | -------------------------------------------------------------------------------- /winter/web/argument_resolver.py: -------------------------------------------------------------------------------- 1 | import abc 2 | from abc import abstractmethod 3 | from typing import Any 4 | from typing import Dict 5 | from typing import List 6 | from typing import Mapping 7 | from typing import MutableMapping 8 | from typing import Optional 9 | 10 | import django.http 11 | 12 | from winter.core import ComponentMethod 13 | from winter.core import ComponentMethodArgument 14 | from winter.core.utils.typing import get_type_name 15 | 16 | 17 | class ArgumentNotSupported(Exception): 18 | 19 | def __init__(self, argument: ComponentMethodArgument): 20 | type_name = get_type_name(argument.type_) 21 | super().__init__(f'Unable to resolve argument {argument.name}: {type_name}') 22 | 23 | 24 | class ArgumentResolver(abc.ABC): 25 | """ArgumentResolver interface is used to map http request contents to API method arguments.""" 26 | 27 | @abstractmethod 28 | def is_supported(self, argument: ComponentMethodArgument) -> bool: # pragma: no cover 29 | pass 30 | 31 | @abstractmethod 32 | def resolve_argument( 33 | self, 34 | argument: ComponentMethodArgument, 35 | request: django.http.HttpRequest, 36 | response_headers: MutableMapping[str, str], 37 | ): # pragma: no cover 38 | pass 39 | 40 | 41 | class ArgumentsResolver: 42 | 43 | def __init__(self): 44 | super().__init__() 45 | self._argument_resolvers: List[ArgumentResolver] = [] 46 | self._cache = {} 47 | 48 | def add_argument_resolver(self, argument_resolver: ArgumentResolver): 49 | self._argument_resolvers.append(argument_resolver) 50 | 51 | def resolve_arguments( 52 | self, 53 | method: ComponentMethod, 54 | request: django.http.HttpRequest, 55 | response_headers: MutableMapping[str, str], 56 | context: Optional[Mapping[str, Any]] = None, 57 | ) -> Dict[str, Any]: 58 | resolved_arguments = {} 59 | if context is None: 60 | context = {} 61 | 62 | for argument in method.arguments: 63 | if argument.name in context: 64 | resolved_arguments[argument.name] = context[argument.name] 65 | else: 66 | resolved_arguments[argument.name] = self._resolve_argument(argument, request, response_headers) 67 | 68 | return resolved_arguments 69 | 70 | def _resolve_argument( 71 | self, 72 | argument: ComponentMethodArgument, 73 | request: django.http.HttpRequest, 74 | response_headers: MutableMapping[str, str], 75 | ) -> Any: 76 | argument_resolver = self._get_argument_resolver(argument) 77 | return argument_resolver.resolve_argument(argument, request, response_headers) 78 | 79 | def _get_argument_resolver(self, argument: ComponentMethodArgument) -> 'ArgumentResolver': 80 | if argument in self._cache: 81 | return self._cache[argument] 82 | for argument_resolver in self._argument_resolvers: 83 | if argument_resolver.is_supported(argument): 84 | self._cache[argument] = argument_resolver 85 | return argument_resolver 86 | raise ArgumentNotSupported(argument) 87 | 88 | 89 | arguments_resolver = ArgumentsResolver() 90 | -------------------------------------------------------------------------------- /winter/core/component_method.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | import types 3 | from functools import cached_property 4 | from types import FunctionType 5 | from typing import Mapping 6 | from typing import Optional 7 | from typing import Tuple 8 | from typing import Type 9 | from typing import Union 10 | from typing import get_type_hints 11 | 12 | from .annotations import Annotations 13 | from .component import Component 14 | from .component_method_argument import ComponentMethodArgument 15 | from .docstring import Docstring 16 | 17 | 18 | class ComponentMethod: 19 | 20 | def __init__(self, func: Union[FunctionType, 'ComponentMethod']): 21 | self.func = func 22 | self.name: str = None 23 | self._component: 'Component' = None 24 | self.annotations = Annotations() 25 | 26 | self._component_cls: Type = None 27 | 28 | type_hints = get_type_hints(func) 29 | self.return_value_type = type_hints.pop('return', None) 30 | self._arguments = self._build_arguments(type_hints) 31 | 32 | def __get__(self, instance, owner): 33 | if instance is None: 34 | return self 35 | return self.func.__get__(instance, owner) 36 | 37 | def __call__(self, *args, **kwargs): 38 | return self.func(*args, **kwargs) 39 | 40 | def __set_name__(self, owner: Type, name: str): 41 | self._component_cls = owner 42 | self.name = name 43 | 44 | self._component = Component.get_by_cls(owner) 45 | self._component.add_method(self) 46 | 47 | def __repr__(self): 48 | return str(self) 49 | 50 | def __str__(self): 51 | return f'ComponentMethod(component={self._component}, name={self.name}, func={self.func})' 52 | 53 | @classmethod 54 | def get_or_create(cls, func_or_method): 55 | if isinstance(func_or_method, cls): 56 | return func_or_method 57 | elif isinstance(func_or_method, types.FunctionType): 58 | return ComponentMethod(func_or_method) 59 | else: 60 | raise ValueError(f'Need function. Got: {func_or_method}') 61 | 62 | @property 63 | def component(self): 64 | return self._component 65 | 66 | @property 67 | def full_name(self) -> str: 68 | return f'{self.component.component_cls.__name__}.{self.name}' 69 | 70 | @property 71 | def arguments(self) -> Tuple[ComponentMethodArgument, ...]: 72 | return tuple(self._arguments.values()) 73 | 74 | def get_argument(self, name: str) -> Optional[ComponentMethodArgument]: 75 | return self._arguments.get(name) 76 | 77 | @cached_property 78 | def docstring(self): 79 | return Docstring(self.func.__doc__) 80 | 81 | @property 82 | def signature(self) -> inspect.Signature: 83 | return inspect.signature(self.func) 84 | 85 | def _build_arguments(self, argument_type_hints: dict) -> Mapping: 86 | arguments = { 87 | arg_name: ComponentMethodArgument(self, arg_name, arg_type) 88 | for arg_name, arg_type in argument_type_hints.items() 89 | } 90 | return arguments 91 | 92 | 93 | def component_method(func: Union[types.FunctionType, ComponentMethod]) -> ComponentMethod: 94 | if isinstance(func, ComponentMethod): 95 | return func 96 | return ComponentMethod(func) 97 | --------------------------------------------------------------------------------