From f260fc56620b0e214b24e6bcee37e13c5b14f602 Mon Sep 17 00:00:00 2001 From: Friskes Date: Tue, 25 Jun 2024 23:01:55 +0300 Subject: [PATCH] support for nested routers and refactoring --- .github/workflows/publish-to-pypi.yml | 44 +- .gitignore | 1 + .pre-commit-config.yaml | 7 + pyproject.toml | 23 +- src/drf_spectacular_websocket/decorators.py | 7 +- .../schemas/__init__.py | 2 +- .../schemas/consumer_schema.py | 78 +-- .../schemas/schema.py | 89 ++- src/drf_spectacular_websocket/types.py | 4 +- tests/asgi.py | 43 +- tests/conftest.py | 7 + tests/schemas/expected_data.py | 604 ++++++++++++++++++ tests/schemas/expected_schema.py | 125 ---- tests/schemas/test_router.py | 11 + tests/schemas/test_schema.py | 5 +- 15 files changed, 788 insertions(+), 262 deletions(-) create mode 100644 tests/schemas/expected_data.py delete mode 100644 tests/schemas/expected_schema.py create mode 100644 tests/schemas/test_router.py diff --git a/.github/workflows/publish-to-pypi.yml b/.github/workflows/publish-to-pypi.yml index e4f99fd..cd95b95 100644 --- a/.github/workflows/publish-to-pypi.yml +++ b/.github/workflows/publish-to-pypi.yml @@ -19,7 +19,7 @@ jobs: # max-parallel: 5 matrix: python-version: ['3.8', '3.9', '3.10', '3.11', '3.12'] - django-version: ['Django>=3.2,<3.3', 'Django>=4.0,<4.1', 'Django>=4.1,<4.2', 'Django>=4.2,<4.3'] + django-version: ['Django>=4.0,<4.1', 'Django>=4.1,<4.2', 'Django>=4.2,<4.3'] platform: [ ubuntu-latest, # macos-latest, @@ -124,30 +124,30 @@ jobs: - name: Publish distribution 📦 to PyPI uses: pypa/gh-action-pypi-publish@release/v1 - # publish-to-testpypi: - # name: Publish Python 🐍 distribution 📦 to TestPyPI + publish-to-testpypi: + name: Publish Python 🐍 distribution 📦 to TestPyPI - # if: github.repository_owner == 'Friskes' && github.event_name == 'push' + if: github.repository_owner == 'Friskes' && github.event_name == 'push' - # needs: - # - build - # runs-on: ubuntu-latest + needs: + - build + runs-on: ubuntu-latest - # environment: - # name: testpypi - # url: https://test.pypi.org/p/drf-spectacular-websocket + environment: + name: testpypi + url: https://test.pypi.org/p/drf-spectacular-websocket - # permissions: - # id-token: write + permissions: + id-token: write - # steps: - # - name: Download all the dists - # uses: actions/download-artifact@v4 - # with: - # name: python-package-distributions - # path: dist/ + steps: + - name: Download all the dists + uses: actions/download-artifact@v4 + with: + name: python-package-distributions + path: dist/ - # - name: Publish distribution 📦 to TestPyPI - # uses: pypa/gh-action-pypi-publish@release/v1 - # with: - # repository-url: https://test.pypi.org/legacy/ + - name: Publish distribution 📦 to TestPyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + repository-url: https://test.pypi.org/legacy/ diff --git a/.gitignore b/.gitignore index 044aa03..8ea7c3f 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,4 @@ trash .ruff_cache .mypy_cache .coverage +htmlcov diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 68a1cf4..b7afc39 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -2,13 +2,20 @@ # https://verdantfox.com/blog/how-to-use-git-pre-commit-hooks-the-hard-way-and-the-easy-way # https://docs.astral.sh/ruff/integrations/ # https://pre-commit.com/ + # Commands: # pre-commit run # Запустить pre-commit проверку для теста # pre-commit install # Создать файл в директории .git/hooks/pre-commit + # После создания файла, при любом git commit будет запускаться pre-commit проверка. + # Если возникли проблемы с pre-commit и нужно срочно сделать commit то можно # временно удалить проверку до выяснения проблемы с помощью: # pre-commit uninstall + +# Либо можно воспользоваться флагом при коммите (--no-verify или -n): +# git commit --no-verify -m "msg" + repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v4.6.0 diff --git a/pyproject.toml b/pyproject.toml index 536eab4..57943a9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,7 +21,6 @@ classifiers = [ "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", - "Framework :: Django :: 3.2", "Framework :: Django :: 4.0", "Framework :: Django :: 4.1", "Framework :: Django :: 4.2", @@ -48,15 +47,25 @@ dev = [ "pytest>=7.0.1", "mypy>=1.10.0", "pytest-cov>=5.0.0", + "djangorestframework-simplejwt>=5.3.0", + "channels-auth-token-middlewares>=1.1.0", ] ci = [ "ruff==0.4.1", "pytest>=7.0.1", "mypy>=1.10.0", "pytest-cov>=5.0.0", + "djangorestframework-simplejwt>=5.3.0", + "channels-auth-token-middlewares>=1.1.0", ] +# https://packaging.python.org/en/latest/guides/single-sourcing-package-version/#single-sourcing-the-version +# Получать номер версии из переменной __version__ из __init__.py пакета +# [tool.setuptools.dynamic] +# version = {attr="drf_spectacular_websocket.__version__"} + + [project.urls] Homepage = "https://github.com/Friskes/drf-spectacular-websocket" Changelog = "https://github.com/Friskes/drf-spectacular-websocket/releases/" @@ -183,6 +192,7 @@ extend-select = [ "SIM", # https://docs.astral.sh/ruff/rules/#flake8-simplify-sim "TID", # https://docs.astral.sh/ruff/rules/#flake8-tidy-imports-tid "TCH", # https://docs.astral.sh/ruff/rules/#flake8-type-checking-tch + "TD", # https://docs.astral.sh/ruff/rules/#flake8-todos-td "INT", # https://docs.astral.sh/ruff/rules/#flake8-gettext-int "FLY", # https://docs.astral.sh/ruff/rules/#flynt-fly "PERF", # https://docs.astral.sh/ruff/rules/#perflint-perf @@ -193,8 +203,8 @@ extend-select = [ ] ignore = [ # https://docs.astral.sh/ruff/rules/#pyflakes-f - "F401", # (не ругаться на неиспользуемые импорты) - "F841", # (не ругаться на неиспользуемые переменные) + # "F401", # (не ругаться на неиспользуемые импорты) + # "F841", # (не ругаться на неиспользуемые переменные) "F403", # (не ругаться на использование from ... import *) # https://docs.astral.sh/ruff/rules/#pyupgrade-up "UP031", # (не ругаться на форматирование с помощью %s) @@ -221,11 +231,16 @@ ignore = [ "RET503", # (не ругаться на отсутствие return None в конце функций) # https://docs.astral.sh/ruff/rules/#flake8-blind-except-ble "BLE001", # (не ругаться на обработку обычного Exception) - # https://docs.astral.sh/ruff/rules/#flake8-django-dj #! (ВРЕМЕННО) + # https://docs.astral.sh/ruff/rules/#flake8-django-dj + # https://docs.djangoproject.com/en/4.2/ref/models/fields/#null + # https://sentry.io/answers/django-difference-between-null-and-blank/ + # https://www.django-rest-framework.org/api-guide/fields/#charfield "DJ001", # (не ругаться на использование null в моделях для текстовых полей) # https://docs.astral.sh/ruff/rules/#flake8-comprehensions-c4 # {} VS dict() # https://switowski.com/blog/dict-function-vs-literal-syntax/ "C408", # (не ругаться на использование классов коллекций вместо их литералов) + "TD001", # (не ругаться на использование FIXME и XXX) + "TD003", # (не ругаться на отсутствие ссылки на issues) ] # Не давать исправлять эти ошибки в тултипе, и в том числе автоматически при линте через команду unfixable = [ diff --git a/src/drf_spectacular_websocket/decorators.py b/src/drf_spectacular_websocket/decorators.py index 658c244..0f87555 100644 --- a/src/drf_spectacular_websocket/decorators.py +++ b/src/drf_spectacular_websocket/decorators.py @@ -7,13 +7,15 @@ from .schemas.consumer_schema import ConsumerAutoSchema if TYPE_CHECKING: - from src.drf_spectacular_websocket.types import DecoratedCallable, _Type + from src.drf_spectacular_websocket.types import DecoratedCallable + + from drf_spectacular_websocket.types import WsMethod __all__ = ('extend_ws_schema',) def extend_ws_schema( - type: _Type = 'send', # noqa: A002 + type: WsMethod = 'send', # noqa: A002 **kwargs: Any, ) -> Callable[[DecoratedCallable], DecoratedCallable]: """ @@ -35,7 +37,6 @@ def decorator(func: DecoratedCallable) -> DecoratedCallable: func.schema = ConsumerAutoSchema # type: ignore[attr-defined] func.type = type # type: ignore[attr-defined] func.event = func.__name__ # type: ignore[attr-defined] - func.include_event = False # type: ignore[attr-defined] return extend_schema(type, **kwargs)(func) return decorator diff --git a/src/drf_spectacular_websocket/schemas/__init__.py b/src/drf_spectacular_websocket/schemas/__init__.py index d1179cf..313ab83 100644 --- a/src/drf_spectacular_websocket/schemas/__init__.py +++ b/src/drf_spectacular_websocket/schemas/__init__.py @@ -1 +1 @@ -from .schema import WsSchemaGenerator +from .schema import WsSchemaGenerator # noqa: F401 diff --git a/src/drf_spectacular_websocket/schemas/consumer_schema.py b/src/drf_spectacular_websocket/schemas/consumer_schema.py index be72e62..a983211 100644 --- a/src/drf_spectacular_websocket/schemas/consumer_schema.py +++ b/src/drf_spectacular_websocket/schemas/consumer_schema.py @@ -7,28 +7,22 @@ build_media_type_object, force_instance, is_list_serializer, - is_serializer, ) -from inflection import camelize from rest_framework import status from rest_framework.parsers import JSONParser from rest_framework.renderers import JSONRenderer -from rest_framework.serializers import CharField, Serializer +from rest_framework.serializers import Serializer if TYPE_CHECKING: from drf_spectacular.utils import Direction, _SchemaType - from drf_spectacular_websocket.types import _Type + from drf_spectacular_websocket.types import WsMethod class EmptySerializer(Serializer): pass -class NotReadyError(Exception): - pass - - class EventHandler: request = None kwargs: dict[str, Any] = {} @@ -50,21 +44,20 @@ def determine_version(self, *args: Any, **kwargs: Any) -> tuple[None, None]: class ConsumerAutoSchema(AutoSchema): """""" - method: _Type + method: WsMethod method_name: str event: str - include_event: bool def __init__(self) -> None: super().__init__() - self.prepared: dict[str, dict[str, Serializer]] = {'request': {}, 'response': {}} + self.prepared: dict[Direction, dict[str, Serializer]] = {'request': {}, 'response': {}} @property def view(self) -> EventHandler: return EventHandler(name=self.event) def get_operation_id(self) -> str: - return '%s_%s' % (self.method, self.method_name) + return f'{self.method}_{self.method_name}' def get_request_body(self, serializer: Serializer) -> dict[str, dict[str, _SchemaType]] | None: """""" @@ -103,7 +96,7 @@ def _get_request_for_media_type( def get_response_bodies( self, response_serializers: Serializer | dict[int, Serializer] - ) -> dict[str, Any] | None: + ) -> _SchemaType | None: """""" if not response_serializers: return None @@ -133,74 +126,25 @@ def _get_serializer_name( def get_tags(self) -> list[str]: return ['web_socket'] - def get_summary(self) -> str: - return '' - def _force_ws_serializer( - self, serializer: Serializer, serializer_type: Direction, direction: _Type | None = None + self, serializer: Serializer, serializer_type: Direction, direction: WsMethod | None = None ) -> dict[str, Serializer]: + """""" if serializer is None: return {self.event: force_instance(EmptySerializer)} serializer = force_instance(serializer) - if isinstance(serializer, EmptySerializer): - return {self.event: serializer} - if direction is None: direction = self.method - if isinstance(serializer, list): - return self._force_serializers_list(events=serializer, serializer_type=serializer_type) - if self.event in self.prepared[serializer_type]: return {self.event: self.prepared[serializer_type][self.event]} if is_list_serializer(serializer): - serializer_name = serializer.child.__class__.__name__ - elif is_serializer(serializer): - serializer_name = serializer.__class__.__name__ - else: - raise AssertionError('Invalid type of serializer') - - name: str = self._get_forced_serializer_name( - direction=direction, serializer_name=serializer_name - ) + inner_name: str = f'Ws{self.event.capitalize()}DataSerializer' - if is_list_serializer(serializer): - inner_name: str = 'Ws%sDataSerializer' % self.event.capitalize() serializer = type(inner_name, (Serializer,), {self.event: serializer})() - attrs = { - 'event': CharField(default=self.event), - 'data': serializer, - } - else: - attrs = { - 'event': CharField(default=self.event), - 'data': serializer, - } - - prepared = type(name, (Serializer,), attrs)() if self.include_event else serializer - - self.prepared[serializer_type][self.event] = prepared - return {self.event: prepared} - - def _force_serializers_list(self, events: list[str], serializer_type: str) -> dict[str, Serializer]: - result: dict[str, Serializer] = {} - - for event in events: - forced = self.prepared[serializer_type].get(event) - if forced is None: - raise NotReadyError - result[event] = forced - - return result - - def _get_forced_serializer_name(self, direction: str, serializer_name: str) -> str: - name: str = 'Ws%s' % direction.capitalize() - event: str = camelize(self.event) - if serializer_name.startswith(event): - return '%s%s' % (name, serializer_name) - - return '%s%s%s' % (name, event, serializer_name) + self.prepared[serializer_type][self.event] = serializer + return {self.event: serializer} diff --git a/src/drf_spectacular_websocket/schemas/schema.py b/src/drf_spectacular_websocket/schemas/schema.py index d65e577..d06932e 100644 --- a/src/drf_spectacular_websocket/schemas/schema.py +++ b/src/drf_spectacular_websocket/schemas/schema.py @@ -2,6 +2,7 @@ from typing import TYPE_CHECKING, Any +from channels.consumer import AsyncConsumer from channels.routing import ProtocolTypeRouter, URLRouter from django.conf import settings from django.contrib.admindocs.views import simplify_regex @@ -9,21 +10,27 @@ from drf_spectacular.generators import SchemaGenerator from typing_extensions import Never # noqa: UP035 -from .consumer_schema import ConsumerAutoSchema, NotReadyError - if TYPE_CHECKING: from collections.abc import Callable from channels.consumer import AsyncConsumer + from django.urls.resolvers import URLPattern + from drf_spectacular.utils import Direction from rest_framework.serializers import Serializer + from drf_spectacular_websocket.types import HttpMethod + + from .consumer_schema import ConsumerAutoSchema + class WsSchemaGenerator(SchemaGenerator): """""" + ws_to_http_method = {'send': 'post', 'receive': 'get'} + def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(*args, **kwargs) - self.prepared: dict[str, dict[str, Serializer]] = {'request': {}, 'response': {}} + self.prepared: dict[Direction, dict[str, Serializer]] = {'request': {}, 'response': {}} def parse(self, input_request: None, public: bool) -> dict[str, Any]: result = super().parse(input_request, public) @@ -36,28 +43,48 @@ def get_asgi_application() -> ProtocolTypeRouter: return import_string(settings.ASGI_APPLICATION) @property - def get_ws_endpoints(self) -> list[Never] | dict[str, Any]: + def get_ws_endpoints(self) -> list[Never] | dict[str, dict[HttpMethod, Any]]: application = self.get_asgi_application() - socket_routes = application.application_mapping.get('websocket') + middleware = application.application_mapping.get('websocket') - if socket_routes is None: + if middleware is None: return [] - router = socket_routes + router = self._search_router(middleware) - while not isinstance(router, URLRouter): - router = self._get_router(router) + return self._collect_nested_endpoints(router) - result = {} + def _collect_nested_endpoints(self, router: URLRouter) -> dict[str, dict[HttpMethod, Any]]: + endpoints = {} + routes: dict[str, list[URLPattern]] = {'': router.routes} - for route in router.routes: - consumer = route.callback.consumer_class() - result.update(self._find_methods(consumer=consumer, path=simplify_regex(str(route.pattern)))) + while routes: + cur_lvl_routes: dict[str, list[URLPattern]] = {} - return result + for parent_pattern, nested_routes in routes.items(): + for route in nested_routes: + pattern = f'{parent_pattern}{route.pattern}' + + # support: AuthMiddlewareStack + if isinstance(route.callback, URLRouter) or hasattr(route.callback, 'inner'): + router = self._search_router(route.callback) + cur_lvl_routes.update({pattern: router.routes}) + else: + consumer = route.callback.consumer_class() + endpoints.update( + self._find_methods(consumer=consumer, path=simplify_regex(pattern)) + ) + routes = cur_lvl_routes - def _get_router(self, middleware: Any) -> Any: + return endpoints + + def _search_router(self, middleware: Any) -> URLRouter: + while not isinstance(middleware, URLRouter): + middleware = self._get_child_middleware(middleware) + return middleware + + def _get_child_middleware(self, middleware: Any) -> Any: try: return middleware.inner except AttributeError: @@ -73,26 +100,23 @@ def _find_methods(self, consumer: AsyncConsumer, path: str) -> dict[str, Any]: while methods_list: method = methods_list.pop(0) event: str = method.event # type: ignore[attr-defined] - name: str = '%s::%s' % (path, event) action_schema = self.get_action_schema(method=method) - try: - consumer_endpoints[name] = { - action_schema.method == 'receive' and 'get' or 'post': { - 'operationId': f'{event}_{action_schema.get_operation_id()}', - 'requestBody': action_schema.get_request_body( - serializer=action_schema.get_request_serializer(), - ), - 'summary': action_schema.get_summary(), - 'description': action_schema.get_description(), - 'tags': action_schema.get_tags(), - 'responses': action_schema.get_response_bodies( - action_schema.get_response_serializers(), - ), - } + + consumer_endpoints[f'{path}::{event}'] = { + self.ws_to_http_method[action_schema.method]: { + 'operationId': f'{event}_{action_schema.get_operation_id()}', + 'requestBody': action_schema.get_request_body( + serializer=action_schema.get_request_serializer(), + ), + 'summary': action_schema.get_summary(), + 'description': action_schema.get_description(), + 'tags': action_schema.get_tags(), + 'responses': action_schema.get_response_bodies( + action_schema.get_response_serializers(), + ), } - except NotReadyError: - methods_list.append(method) + } return consumer_endpoints @@ -111,7 +135,6 @@ def get_action_schema(self, method: Callable[..., Any]) -> ConsumerAutoSchema: schema.method_name = method.__name__ schema.method = method.type # type: ignore[attr-defined] schema.event = method.event # type: ignore[attr-defined] - schema.include_event = method.include_event # type: ignore[attr-defined] schema.registry = self.registry schema.prepared = self.prepared return schema diff --git a/src/drf_spectacular_websocket/types.py b/src/drf_spectacular_websocket/types.py index 57904f9..62bd917 100644 --- a/src/drf_spectacular_websocket/types.py +++ b/src/drf_spectacular_websocket/types.py @@ -5,4 +5,6 @@ # https://mypy.readthedocs.io/en/stable/generics.html#decorator-factories DecoratedCallable = TypeVar('DecoratedCallable', bound=Callable[..., Any]) -_Type = Literal['send', 'receive'] +HttpMethod = Literal['get', 'post'] + +WsMethod = Literal['send', 'receive'] diff --git a/tests/asgi.py b/tests/asgi.py index 780319c..2b33234 100644 --- a/tests/asgi.py +++ b/tests/asgi.py @@ -1,14 +1,51 @@ +from channels.auth import AuthMiddlewareStack from channels.routing import ProtocolTypeRouter, URLRouter -from django.urls import re_path +from channels.security.websocket import AllowedHostsOriginValidator +from channels_auth_token_middlewares.middleware.drf import ( + SimpleJWTAuthTokenMiddlewareStack, +) +from django.urls import path from tests.consumers import Consumer websocket_urlpatterns = [ - re_path(r'ws/path/$', Consumer.as_asgi()), + path('consumer-path/', Consumer.as_asgi()), ] application = ProtocolTypeRouter( { - 'websocket': URLRouter(websocket_urlpatterns), + 'websocket': AllowedHostsOriginValidator( + URLRouter( + [ + path( + 'A/', + SimpleJWTAuthTokenMiddlewareStack( + URLRouter( + [ + path( + 'AA/', + URLRouter(websocket_urlpatterns), + ), + path( + 'AB/', + URLRouter( + [ + path('AAA/', URLRouter(websocket_urlpatterns)), + ] + ), + ), + ] + ), + ), + ), + path( + 'B/', + AuthMiddlewareStack( + URLRouter(websocket_urlpatterns), + ), + ), + ] + ) + ) } ) diff --git a/tests/conftest.py b/tests/conftest.py index 772d030..7755cd8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,3 +1,4 @@ +import django from django.conf import settings @@ -5,4 +6,10 @@ def pytest_configure() -> None: settings.configure( ROOT_URLCONF='tests.urls', ASGI_APPLICATION='tests.asgi.application', + SECRET_KEY='super-puper-secret-key', + INSTALLED_APPS=( + 'django.contrib.auth', + 'django.contrib.contenttypes', + ), ) + django.setup() diff --git a/tests/schemas/expected_data.py b/tests/schemas/expected_data.py new file mode 100644 index 0000000..966c439 --- /dev/null +++ b/tests/schemas/expected_data.py @@ -0,0 +1,604 @@ +expected_router_schema1 = { + '/B/consumer-path/::method_1': { + 'post': { + 'operationId': 'method_1_send', + 'requestBody': { + 'content': { + 'application/json': {'schema': {'$ref': '#/components/schemas/InputSerializer'}} + } + }, + 'summary': 'method_1 summary', + 'description': 'method_1 description', + 'tags': ['web_socket'], + 'responses': { + 'method_1\xa0\xa0\xa0200': { + 'content': { + 'application/json': {'schema': {'$ref': '#/components/schemas/OutputSerializer'}} + }, + 'description': '', + } + }, + } + }, + '/B/consumer-path/::method_2': { + 'post': { + 'operationId': 'method_2_send', + 'requestBody': { + 'content': { + 'application/json': {'schema': {'$ref': '#/components/schemas/InputSerializer'}} + } + }, + 'summary': 'method_2 summary', + 'description': 'method_2 description', + 'tags': ['web_socket'], + 'responses': { + 'method_2\xa0\xa0\xa0201': { + 'content': { + 'application/json': {'schema': {'$ref': '#/components/schemas/OutputSerializer'}} + }, + 'description': '', + }, + 'method_2\xa0\xa0\xa0400': { + 'content': { + 'application/json': { + 'schema': {'$ref': '#/components/schemas/BadOutputSerializer'} + } + }, + 'description': '', + }, + }, + } + }, + '/B/consumer-path/::method_3': { + 'get': { + 'operationId': 'method_3_receive', + 'requestBody': None, + 'summary': 'method_3 summary', + 'description': 'method_3 description', + 'tags': ['web_socket'], + 'responses': { + 'method_3\xa0\xa0\xa0200': { + 'content': { + 'application/json': {'schema': {'$ref': '#/components/schemas/OutputSerializer'}} + }, + 'description': '', + } + }, + } + }, + '/B/consumer-path/::method_4': { + 'get': { + 'operationId': 'method_4_receive', + 'requestBody': None, + 'summary': 'method_4 summary', + 'description': 'method_4 description', + 'tags': ['web_socket'], + 'responses': { + 'method_4\xa0\xa0\xa0201': { + 'content': { + 'application/json': {'schema': {'$ref': '#/components/schemas/OutputSerializer'}} + }, + 'description': '', + }, + 'method_4\xa0\xa0\xa0400': { + 'content': { + 'application/json': { + 'schema': {'$ref': '#/components/schemas/BadOutputSerializer'} + } + }, + 'description': '', + }, + }, + } + }, + '/A/AA/consumer-path/::method_1': { + 'post': { + 'operationId': 'method_1_send', + 'requestBody': { + 'content': { + 'application/json': {'schema': {'$ref': '#/components/schemas/InputSerializer'}} + } + }, + 'summary': 'method_1 summary', + 'description': 'method_1 description', + 'tags': ['web_socket'], + 'responses': { + 'method_1\xa0\xa0\xa0200': { + 'content': { + 'application/json': {'schema': {'$ref': '#/components/schemas/OutputSerializer'}} + }, + 'description': '', + } + }, + } + }, + '/A/AA/consumer-path/::method_2': { + 'post': { + 'operationId': 'method_2_send', + 'requestBody': { + 'content': { + 'application/json': {'schema': {'$ref': '#/components/schemas/InputSerializer'}} + } + }, + 'summary': 'method_2 summary', + 'description': 'method_2 description', + 'tags': ['web_socket'], + 'responses': { + 'method_2\xa0\xa0\xa0201': { + 'content': { + 'application/json': {'schema': {'$ref': '#/components/schemas/OutputSerializer'}} + }, + 'description': '', + }, + 'method_2\xa0\xa0\xa0400': { + 'content': { + 'application/json': { + 'schema': {'$ref': '#/components/schemas/BadOutputSerializer'} + } + }, + 'description': '', + }, + }, + } + }, + '/A/AA/consumer-path/::method_3': { + 'get': { + 'operationId': 'method_3_receive', + 'requestBody': None, + 'summary': 'method_3 summary', + 'description': 'method_3 description', + 'tags': ['web_socket'], + 'responses': { + 'method_3\xa0\xa0\xa0200': { + 'content': { + 'application/json': {'schema': {'$ref': '#/components/schemas/OutputSerializer'}} + }, + 'description': '', + } + }, + } + }, + '/A/AA/consumer-path/::method_4': { + 'get': { + 'operationId': 'method_4_receive', + 'requestBody': None, + 'summary': 'method_4 summary', + 'description': 'method_4 description', + 'tags': ['web_socket'], + 'responses': { + 'method_4\xa0\xa0\xa0201': { + 'content': { + 'application/json': {'schema': {'$ref': '#/components/schemas/OutputSerializer'}} + }, + 'description': '', + }, + 'method_4\xa0\xa0\xa0400': { + 'content': { + 'application/json': { + 'schema': {'$ref': '#/components/schemas/BadOutputSerializer'} + } + }, + 'description': '', + }, + }, + } + }, + '/A/AB/AAA/consumer-path/::method_1': { + 'post': { + 'operationId': 'method_1_send', + 'requestBody': { + 'content': { + 'application/json': {'schema': {'$ref': '#/components/schemas/InputSerializer'}} + } + }, + 'summary': 'method_1 summary', + 'description': 'method_1 description', + 'tags': ['web_socket'], + 'responses': { + 'method_1\xa0\xa0\xa0200': { + 'content': { + 'application/json': {'schema': {'$ref': '#/components/schemas/OutputSerializer'}} + }, + 'description': '', + } + }, + } + }, + '/A/AB/AAA/consumer-path/::method_2': { + 'post': { + 'operationId': 'method_2_send', + 'requestBody': { + 'content': { + 'application/json': {'schema': {'$ref': '#/components/schemas/InputSerializer'}} + } + }, + 'summary': 'method_2 summary', + 'description': 'method_2 description', + 'tags': ['web_socket'], + 'responses': { + 'method_2\xa0\xa0\xa0201': { + 'content': { + 'application/json': {'schema': {'$ref': '#/components/schemas/OutputSerializer'}} + }, + 'description': '', + }, + 'method_2\xa0\xa0\xa0400': { + 'content': { + 'application/json': { + 'schema': {'$ref': '#/components/schemas/BadOutputSerializer'} + } + }, + 'description': '', + }, + }, + } + }, + '/A/AB/AAA/consumer-path/::method_3': { + 'get': { + 'operationId': 'method_3_receive', + 'requestBody': None, + 'summary': 'method_3 summary', + 'description': 'method_3 description', + 'tags': ['web_socket'], + 'responses': { + 'method_3\xa0\xa0\xa0200': { + 'content': { + 'application/json': {'schema': {'$ref': '#/components/schemas/OutputSerializer'}} + }, + 'description': '', + } + }, + } + }, + '/A/AB/AAA/consumer-path/::method_4': { + 'get': { + 'operationId': 'method_4_receive', + 'requestBody': None, + 'summary': 'method_4 summary', + 'description': 'method_4 description', + 'tags': ['web_socket'], + 'responses': { + 'method_4\xa0\xa0\xa0201': { + 'content': { + 'application/json': {'schema': {'$ref': '#/components/schemas/OutputSerializer'}} + }, + 'description': '', + }, + 'method_4\xa0\xa0\xa0400': { + 'content': { + 'application/json': { + 'schema': {'$ref': '#/components/schemas/BadOutputSerializer'} + } + }, + 'description': '', + }, + }, + } + }, +} + +expected_schema_1 = { + 'openapi': '3.0.3', + 'info': {'title': '', 'version': '0.0.0'}, + 'paths': { + '/B/consumer-path/::method_1': { + 'post': { + 'operationId': 'method_1_send_3', + 'requestBody': { + 'content': { + 'application/json': {'schema': {'$ref': '#/components/schemas/InputSerializer'}} + } + }, + 'summary': 'method_1 summary', + 'description': 'method_1 description', + 'tags': ['web_socket'], + 'responses': { + 'method_1\xa0\xa0\xa0200': { + 'content': { + 'application/json': { + 'schema': {'$ref': '#/components/schemas/OutputSerializer'} + } + }, + 'description': '', + } + }, + } + }, + '/B/consumer-path/::method_2': { + 'post': { + 'operationId': 'method_2_send_3', + 'requestBody': { + 'content': { + 'application/json': {'schema': {'$ref': '#/components/schemas/InputSerializer'}} + } + }, + 'summary': 'method_2 summary', + 'description': 'method_2 description', + 'tags': ['web_socket'], + 'responses': { + 'method_2\xa0\xa0\xa0201': { + 'content': { + 'application/json': { + 'schema': {'$ref': '#/components/schemas/OutputSerializer'} + } + }, + 'description': '', + }, + 'method_2\xa0\xa0\xa0400': { + 'content': { + 'application/json': { + 'schema': {'$ref': '#/components/schemas/BadOutputSerializer'} + } + }, + 'description': '', + }, + }, + } + }, + '/B/consumer-path/::method_3': { + 'get': { + 'operationId': 'method_3_receive_3', + 'requestBody': None, + 'summary': 'method_3 summary', + 'description': 'method_3 description', + 'tags': ['web_socket'], + 'responses': { + 'method_3\xa0\xa0\xa0200': { + 'content': { + 'application/json': { + 'schema': {'$ref': '#/components/schemas/OutputSerializer'} + } + }, + 'description': '', + } + }, + } + }, + '/B/consumer-path/::method_4': { + 'get': { + 'operationId': 'method_4_receive_3', + 'requestBody': None, + 'summary': 'method_4 summary', + 'description': 'method_4 description', + 'tags': ['web_socket'], + 'responses': { + 'method_4\xa0\xa0\xa0201': { + 'content': { + 'application/json': { + 'schema': {'$ref': '#/components/schemas/OutputSerializer'} + } + }, + 'description': '', + }, + 'method_4\xa0\xa0\xa0400': { + 'content': { + 'application/json': { + 'schema': {'$ref': '#/components/schemas/BadOutputSerializer'} + } + }, + 'description': '', + }, + }, + } + }, + '/A/AA/consumer-path/::method_1': { + 'post': { + 'operationId': 'method_1_send', + 'requestBody': { + 'content': { + 'application/json': {'schema': {'$ref': '#/components/schemas/InputSerializer'}} + } + }, + 'summary': 'method_1 summary', + 'description': 'method_1 description', + 'tags': ['web_socket'], + 'responses': { + 'method_1\xa0\xa0\xa0200': { + 'content': { + 'application/json': { + 'schema': {'$ref': '#/components/schemas/OutputSerializer'} + } + }, + 'description': '', + } + }, + } + }, + '/A/AA/consumer-path/::method_2': { + 'post': { + 'operationId': 'method_2_send', + 'requestBody': { + 'content': { + 'application/json': {'schema': {'$ref': '#/components/schemas/InputSerializer'}} + } + }, + 'summary': 'method_2 summary', + 'description': 'method_2 description', + 'tags': ['web_socket'], + 'responses': { + 'method_2\xa0\xa0\xa0201': { + 'content': { + 'application/json': { + 'schema': {'$ref': '#/components/schemas/OutputSerializer'} + } + }, + 'description': '', + }, + 'method_2\xa0\xa0\xa0400': { + 'content': { + 'application/json': { + 'schema': {'$ref': '#/components/schemas/BadOutputSerializer'} + } + }, + 'description': '', + }, + }, + } + }, + '/A/AA/consumer-path/::method_3': { + 'get': { + 'operationId': 'method_3_receive', + 'requestBody': None, + 'summary': 'method_3 summary', + 'description': 'method_3 description', + 'tags': ['web_socket'], + 'responses': { + 'method_3\xa0\xa0\xa0200': { + 'content': { + 'application/json': { + 'schema': {'$ref': '#/components/schemas/OutputSerializer'} + } + }, + 'description': '', + } + }, + } + }, + '/A/AA/consumer-path/::method_4': { + 'get': { + 'operationId': 'method_4_receive', + 'requestBody': None, + 'summary': 'method_4 summary', + 'description': 'method_4 description', + 'tags': ['web_socket'], + 'responses': { + 'method_4\xa0\xa0\xa0201': { + 'content': { + 'application/json': { + 'schema': {'$ref': '#/components/schemas/OutputSerializer'} + } + }, + 'description': '', + }, + 'method_4\xa0\xa0\xa0400': { + 'content': { + 'application/json': { + 'schema': {'$ref': '#/components/schemas/BadOutputSerializer'} + } + }, + 'description': '', + }, + }, + } + }, + '/A/AB/AAA/consumer-path/::method_1': { + 'post': { + 'operationId': 'method_1_send_2', + 'requestBody': { + 'content': { + 'application/json': {'schema': {'$ref': '#/components/schemas/InputSerializer'}} + } + }, + 'summary': 'method_1 summary', + 'description': 'method_1 description', + 'tags': ['web_socket'], + 'responses': { + 'method_1\xa0\xa0\xa0200': { + 'content': { + 'application/json': { + 'schema': {'$ref': '#/components/schemas/OutputSerializer'} + } + }, + 'description': '', + } + }, + } + }, + '/A/AB/AAA/consumer-path/::method_2': { + 'post': { + 'operationId': 'method_2_send_2', + 'requestBody': { + 'content': { + 'application/json': {'schema': {'$ref': '#/components/schemas/InputSerializer'}} + } + }, + 'summary': 'method_2 summary', + 'description': 'method_2 description', + 'tags': ['web_socket'], + 'responses': { + 'method_2\xa0\xa0\xa0201': { + 'content': { + 'application/json': { + 'schema': {'$ref': '#/components/schemas/OutputSerializer'} + } + }, + 'description': '', + }, + 'method_2\xa0\xa0\xa0400': { + 'content': { + 'application/json': { + 'schema': {'$ref': '#/components/schemas/BadOutputSerializer'} + } + }, + 'description': '', + }, + }, + } + }, + '/A/AB/AAA/consumer-path/::method_3': { + 'get': { + 'operationId': 'method_3_receive_2', + 'requestBody': None, + 'summary': 'method_3 summary', + 'description': 'method_3 description', + 'tags': ['web_socket'], + 'responses': { + 'method_3\xa0\xa0\xa0200': { + 'content': { + 'application/json': { + 'schema': {'$ref': '#/components/schemas/OutputSerializer'} + } + }, + 'description': '', + } + }, + } + }, + '/A/AB/AAA/consumer-path/::method_4': { + 'get': { + 'operationId': 'method_4_receive_2', + 'requestBody': None, + 'summary': 'method_4 summary', + 'description': 'method_4 description', + 'tags': ['web_socket'], + 'responses': { + 'method_4\xa0\xa0\xa0201': { + 'content': { + 'application/json': { + 'schema': {'$ref': '#/components/schemas/OutputSerializer'} + } + }, + 'description': '', + }, + 'method_4\xa0\xa0\xa0400': { + 'content': { + 'application/json': { + 'schema': {'$ref': '#/components/schemas/BadOutputSerializer'} + } + }, + 'description': '', + }, + }, + } + }, + }, + 'components': { + 'schemas': { + 'BadOutputSerializer': { + 'type': 'object', + 'properties': {'field5': {'type': 'string'}, 'field6': {'type': 'integer'}}, + 'required': ['field5', 'field6'], + }, + 'InputSerializer': { + 'type': 'object', + 'properties': {'field1': {'type': 'string'}, 'field2': {'type': 'integer'}}, + 'required': ['field1', 'field2'], + }, + 'OutputSerializer': { + 'type': 'object', + 'properties': {'field3': {'type': 'string'}, 'field4': {'type': 'integer'}}, + 'required': ['field3', 'field4'], + }, + } + }, +} diff --git a/tests/schemas/expected_schema.py b/tests/schemas/expected_schema.py deleted file mode 100644 index f58ce79..0000000 --- a/tests/schemas/expected_schema.py +++ /dev/null @@ -1,125 +0,0 @@ -expected_schema_1 = { - 'openapi': '3.0.3', - 'info': {'title': '', 'version': '0.0.0'}, - 'paths': { - '/ws/path/::method_1': { - 'post': { - 'operationId': 'method_1_send', - 'requestBody': { - 'content': { - 'application/json': {'schema': {'$ref': '#/components/schemas/InputSerializer'}} - } - }, - 'summary': 'method_1 summary', - 'description': 'method_1 description', - 'tags': ['web_socket'], - 'responses': { - 'method_1\xa0\xa0\xa0200': { - 'content': { - 'application/json': { - 'schema': {'$ref': '#/components/schemas/OutputSerializer'} - } - }, - 'description': '', - } - }, - } - }, - '/ws/path/::method_2': { - 'post': { - 'operationId': 'method_2_send', - 'requestBody': { - 'content': { - 'application/json': {'schema': {'$ref': '#/components/schemas/InputSerializer'}} - } - }, - 'summary': 'method_2 summary', - 'description': 'method_2 description', - 'tags': ['web_socket'], - 'responses': { - 'method_2\xa0\xa0\xa0201': { - 'content': { - 'application/json': { - 'schema': {'$ref': '#/components/schemas/OutputSerializer'} - } - }, - 'description': '', - }, - 'method_2\xa0\xa0\xa0400': { - 'content': { - 'application/json': { - 'schema': {'$ref': '#/components/schemas/BadOutputSerializer'} - } - }, - 'description': '', - }, - }, - } - }, - '/ws/path/::method_3': { - 'get': { - 'operationId': 'method_3_receive', - 'requestBody': None, - 'summary': 'method_3 summary', - 'description': 'method_3 description', - 'tags': ['web_socket'], - 'responses': { - 'method_3\xa0\xa0\xa0200': { - 'content': { - 'application/json': { - 'schema': {'$ref': '#/components/schemas/OutputSerializer'} - } - }, - 'description': '', - } - }, - } - }, - '/ws/path/::method_4': { - 'get': { - 'operationId': 'method_4_receive', - 'requestBody': None, - 'summary': 'method_4 summary', - 'description': 'method_4 description', - 'tags': ['web_socket'], - 'responses': { - 'method_4\xa0\xa0\xa0201': { - 'content': { - 'application/json': { - 'schema': {'$ref': '#/components/schemas/OutputSerializer'} - } - }, - 'description': '', - }, - 'method_4\xa0\xa0\xa0400': { - 'content': { - 'application/json': { - 'schema': {'$ref': '#/components/schemas/BadOutputSerializer'} - } - }, - 'description': '', - }, - }, - } - }, - }, - 'components': { - 'schemas': { - 'BadOutputSerializer': { - 'type': 'object', - 'properties': {'field5': {'type': 'string'}, 'field6': {'type': 'integer'}}, - 'required': ['field5', 'field6'], - }, - 'InputSerializer': { - 'type': 'object', - 'properties': {'field1': {'type': 'string'}, 'field2': {'type': 'integer'}}, - 'required': ['field1', 'field2'], - }, - 'OutputSerializer': { - 'type': 'object', - 'properties': {'field3': {'type': 'string'}, 'field4': {'type': 'integer'}}, - 'required': ['field3', 'field4'], - }, - } - }, -} diff --git a/tests/schemas/test_router.py b/tests/schemas/test_router.py new file mode 100644 index 0000000..ae074e0 --- /dev/null +++ b/tests/schemas/test_router.py @@ -0,0 +1,11 @@ +from src.drf_spectacular_websocket.schemas.schema import WsSchemaGenerator + +from tests.schemas.expected_data import expected_router_schema1 + + +# pytest -s ./tests/schemas/test_router.py::test_router +def test_router() -> None: + """""" + generator = WsSchemaGenerator() + router_schema1 = generator.get_ws_endpoints + assert router_schema1 == expected_router_schema1 diff --git a/tests/schemas/test_schema.py b/tests/schemas/test_schema.py index 27d7895..e7b389e 100644 --- a/tests/schemas/test_schema.py +++ b/tests/schemas/test_schema.py @@ -1,10 +1,9 @@ from src.drf_spectacular_websocket.schemas.schema import WsSchemaGenerator -from tests.schemas.expected_schema import expected_schema_1 +from tests.schemas.expected_data import expected_schema_1 -# python -m pip install . -# pytest -s ./tests +# pytest -s ./tests/schemas/test_schema.py::test_schema def test_schema() -> None: """""" generator = WsSchemaGenerator()