From c6100f21e6b78d88c1b8177b58ecaf513f1657d5 Mon Sep 17 00:00:00 2001 From: Alex Zywicki Date: Thu, 18 Nov 2021 15:18:28 -0600 Subject: [PATCH 1/3] Implement initial multi api file changes. --- asynction/mock_server.py | 10 +-- asynction/server.py | 118 ++++++++++++++++++++++++++++++--- tests/unit/test_mock_server.py | 31 ++++----- tests/unit/test_server.py | 55 +++++++-------- 4 files changed, 157 insertions(+), 57 deletions(-) diff --git a/asynction/mock_server.py b/asynction/mock_server.py index 65ad245..2991a8d 100644 --- a/asynction/mock_server.py +++ b/asynction/mock_server.py @@ -120,7 +120,7 @@ class MockAsynctionSocketIO(AsynctionSocketIO): def __init__( self, - spec: AsyncApiSpec, + specs: Sequence[AsyncApiSpec], validation: bool, docs: bool, app: Optional[Flask], @@ -130,7 +130,7 @@ def __init__( """This is a private constructor. Use the :meth:`MockAsynctionSocketIO.from_spec` factory instead. """ - super().__init__(spec, validation=validation, docs=docs, app=app, **kwargs) + super().__init__(specs, validation=validation, docs=docs, app=app, **kwargs) self.faker = Faker() self.custom_formats = make_faker_formats(self.faker, custom_formats_sample_size) self._subscription_tasks: Sequence[SubscriptionTask] = [] @@ -210,10 +210,11 @@ def from_spec( def _register_handlers( self, + spec: AsyncApiSpec, server_security: Sequence[SecurityRequirement] = (), default_error_handler: Optional[ErrorHandler] = None, ) -> None: - for namespace, channel in self.spec.channels.items(): + for namespace, channel in spec.channels.items(): if channel.publish is not None: for message in channel.publish.message.oneOf: handler = self.make_publish_handler(message) @@ -247,10 +248,11 @@ def _register_handlers( else server_security ) if security: + _, spec = self.namespace_map[namespace] # create a security handler wrapper with_security = security_handler_factory( security, - self.spec.components.security_schemes, + spec.components.security_schemes, ) # apply security connect_handler = with_security(connect_handler) diff --git a/asynction/server.py b/asynction/server.py index e9d4c96..1bf5f93 100644 --- a/asynction/server.py +++ b/asynction/server.py @@ -2,9 +2,11 @@ The :class:`AsynctionSocketIO` server is essentially a ``flask_socketio.SocketIO`` server with an additional factory classmethod. """ +from collections import defaultdict from functools import singledispatch from pathlib import Path from typing import Any +from typing import Mapping from typing import Optional from typing import Sequence from urllib.parse import urlparse @@ -81,12 +83,23 @@ def _noop_handler(*args, **kwargs) -> None: return None +def build_namespace_channel_mapping(specs: Sequence[AsyncApiSpec]) -> Mapping: + mapping = {} + for spec in specs: + for namespace, channel in spec.channels.items(): + if namespace in mapping: + raise ValueError(f"Duplicate namespace {namespace} in specs") + mapping[namespace] = (channel, spec) + + return mapping + + class AsynctionSocketIO(SocketIO): """Inherits the :class:`flask_socketio.SocketIO` class.""" def __init__( self, - spec: AsyncApiSpec, + specs: Sequence[AsyncApiSpec], validation: bool, docs: bool, app: Optional[Flask], @@ -95,9 +108,10 @@ def __init__( """This is a private constructor. Use the :meth:`AsynctionSocketIO.from_spec` factory instead. """ - self.spec = spec + self.specs = specs self.validation = validation self.docs = docs + self.namespace_map = build_namespace_channel_mapping(specs) super().__init__(app=app, **kwargs) @@ -105,10 +119,11 @@ def init_app(self, app: Optional[Flask], **kwargs) -> None: super().init_app(app, **kwargs) if self.docs and app is not None: - docs_bp = make_docs_blueprint( - spec=self.spec, url_prefix=Path(self.sockio_mw.engineio_path).parent - ) - app.register_blueprint(docs_bp) + for spec in self.specs: + docs_bp = make_docs_blueprint( + spec=spec, url_prefix=Path(self.sockio_mw.engineio_path).parent + ) + app.register_blueprint(docs_bp) @classmethod def from_spec( @@ -175,8 +190,87 @@ def from_spec( server_security = server.security - asio = cls(spec, validation, docs, app, **kwargs) - asio._register_handlers(server_security, default_error_handler) + asio = cls([spec], validation, docs, app, **kwargs) + asio._register_handlers(spec, server_security, default_error_handler) + return asio + + @classmethod + def from_specs( + cls, + spec_paths: Sequence[Path], + validation: bool = True, + server_name: Optional[str] = None, + docs: bool = True, + default_error_handler: Optional[ErrorHandler] = None, + app: Optional[Flask] = None, + **kwargs, + ) -> SocketIO: + """Create a Flask-SocketIO server from multiple AsyncAPI spec. + + :param spec_paths: The paths where the AsyncAPI YAML specifications are located. + :param validation: When set to ``False``, message payloads, channel + bindings and ack callbacks are NOT validated. + Defaults to ``True``. + :param server_name: The server to pick from the AsyncAPI ``servers`` object. + The server object is then used to configure + the path ``kwarg`` of the SocketIO server. + :param docs: When set to ``True``, HTML rendered documentation is generated + and served through the ``GET {base_path}/docs`` route of the app. + The ``GET {base_path}/docs/asyncapi.json`` route is also exposed, + returning the raw specification data for programmatic retrieval. + Defaults to ``True``. + :param default_error_handler: The error handler that handles any namespace + without an explicit error handler. + Equivelant of ``@socketio.on_error_default`` + :param app: The flask application instance. Defaults to ``None``. + :param kwargs: Flask-SocketIO, Socket.IO and Engine.IO server options. + + :returns: A Flask-SocketIO server. + The server has all the event and error handlers registered. + + Example:: + + asio = AsynctionSocketIO.from_spec( + spec_path="./docs/asyncapi.yaml", + app=flask_app, + message_queue="redis://localhost:6379", + # any other kwarg that the flask_socketio.SocketIO constructor accepts + ) + + """ + specs = list(map(load_spec, spec_paths)) + securities = [] + if ( + server_name is not None + and kwargs.get("path") is None + and kwargs.get("resource") is None + ): + + paths = defaultdict(set) + for spec, spec_path in zip(specs, spec_paths): + server = spec.servers.get(server_name) + if server is None: + raise ValueError( + f"Server {server_name} is not defined in spec {spec_path}." + ) + + url_parse_result = urlparse( + url=f"//{server.url}", scheme=server.protocol.value + ) + paths[url_parse_result.path].add(spec_path) + securities.append(server.security or []) + + if len(paths) == 1: + kwargs["path"] = next(iter(paths.keys())) + else: + raise ValueError( + f"Multiple conflicting server paths provided in specs: {paths}" + ) + + asio = cls(specs, validation, docs, app, **kwargs) + for spec, security in zip(specs, securities): + asio._register_handlers(spec, security, default_error_handler) + return asio def _register_namespace_handlers( @@ -197,9 +291,10 @@ def _register_namespace_handlers( on_connect = with_bindings_validation(on_connect) if security: + _, spec = self.namespace_map[namespace] # create a security handler wrapper with_security = security_handler_factory( - security, self.spec.components.security_schemes + security, spec.components.security_schemes ) # apply security on_connect = with_security(on_connect) @@ -220,10 +315,11 @@ def _register_namespace_handlers( def _register_handlers( self, + spec: AsyncApiSpec, server_security: Sequence[SecurityRequirement] = (), default_error_handler: Optional[ErrorHandler] = None, ) -> None: - for namespace, channel in self.spec.channels.items(): + for namespace, channel in spec.channels.items(): if channel.publish is not None: for message in channel.publish.message.oneOf: assert message.x_handler is not None @@ -253,7 +349,7 @@ def _register_handlers( def emit(self, event: str, *args, **kwargs) -> None: if self.validation: namespace = kwargs.get("namespace", GLOBAL_NAMESPACE) - channel = self.spec.channels.get(namespace) + channel, _ = self.namespace_map.get(namespace, (None, None)) if channel is None: raise ValidationException( diff --git a/tests/unit/test_mock_server.py b/tests/unit/test_mock_server.py index bfafcb9..47b81c4 100644 --- a/tests/unit/test_mock_server.py +++ b/tests/unit/test_mock_server.py @@ -146,7 +146,7 @@ def new_mock_asynction_socket_io( async_mode: str = "threading", ) -> MockAsynctionSocketIO: return MockAsynctionSocketIO( - spec=spec, + specs=[spec], validation=True, docs=True, app=app, @@ -182,7 +182,7 @@ def test_register_handlers_registers_noop_handler_for_message_with_no_ack( ) server = new_mock_asynction_socket_io(spec) - server._register_handlers() + server._register_handlers(spec) assert len(server.handlers) == 2 # connect handler included as well registered_event, registered_handler, registered_namespace = server.handlers[0] assert registered_event == event_name @@ -233,7 +233,7 @@ def test_register_handlers_registers_valid_handler_for_message_with_ack( ) server = new_mock_asynction_socket_io(spec) - server._register_handlers() + server._register_handlers(spec) assert len(server.handlers) == 2 # connect handler included as well registered_event, registered_handler, registered_namespace = server.handlers[0] assert registered_event == event_name @@ -271,7 +271,7 @@ def test_register_handlers_adds_payload_validator_if_validation_is_enabled( ) server = new_mock_asynction_socket_io(spec) - server._register_handlers() + server._register_handlers(spec) _, registered_handler, _ = server.handlers[0] handler_with_validation = deep_unwrap(registered_handler, depth=1) actual_handler = deep_unwrap(handler_with_validation) @@ -291,7 +291,7 @@ def test_register_handlers_registers_connection_handler( ) server = new_mock_asynction_socket_io(spec) - server._register_handlers() + server._register_handlers(spec) assert len(server.handlers) == 1 registered_event, registered_handler, registered_namespace = server.handlers[0] @@ -321,7 +321,7 @@ def test_register_handlers_registers_connection_handler_with_bindings_validation server = new_mock_asynction_socket_io(spec) flask_app = Flask(__name__) - server._register_handlers() + server._register_handlers(spec) _, registered_handler, _ = server.handlers[0] handler_with_validation = deep_unwrap(registered_handler, depth=1) @@ -356,7 +356,9 @@ def test_register_namespace_handlers_includes_security_validator_if_security_nee ) server = new_mock_asynction_socket_io(spec) - server._register_handlers(server_security=server.spec.servers.get("test").security) + server._register_handlers( + spec=spec, server_security=spec.servers.get("test").security + ) event_name, registered_handler, _ = server.handlers[0] assert event_name == "connect" handler_with_security = deep_unwrap(registered_handler, depth=1) @@ -393,7 +395,7 @@ def test_register_namespace_handlers_includes_namespace_specific_security_valida ) server = new_mock_asynction_socket_io(spec) - server._register_handlers(server_security=server.spec.servers.get("test").security) + server._register_handlers(spec, server_security=spec.servers.get("test").security) event_name, registered_handler, _ = server.handlers[0] assert event_name == "connect" handler_with_security = deep_unwrap(registered_handler, depth=1) @@ -415,11 +417,10 @@ def test_register_namespace_handlers_includes_namespace_specific_security_valida def test_register_handlers_registers_default_error_handler( optional_error_handler: Optional[ErrorHandler], server_info: Info, faker: Faker ): - server = new_mock_asynction_socket_io( - AsyncApiSpec(asyncapi=faker.pystr(), info=server_info, channels={}) - ) + spec = AsyncApiSpec(asyncapi=faker.pystr(), info=server_info, channels={}) + server = new_mock_asynction_socket_io(spec) - server._register_handlers(default_error_handler=optional_error_handler) + server._register_handlers(spec=spec, default_error_handler=optional_error_handler) assert server.default_exception_handler == optional_error_handler @@ -454,7 +455,7 @@ def test_run_spawns_background_tasks_and_calls_super_run( ) flask_app = Flask(__name__) server = new_mock_asynction_socket_io(spec, flask_app) - server._register_handlers() + server._register_handlers(spec) background_tasks: MutableSequence[MockThread] = [] @@ -507,7 +508,7 @@ def start_background_task_mock(target, *args, **kwargs): flask_app = Flask(__name__) server = new_mock_asynction_socket_io(spec, flask_app) - server._register_handlers() + server._register_handlers(spec) with patch.object(SocketIO, "run"): with patch.object(server, "start_background_task", start_background_task_mock): @@ -550,7 +551,7 @@ def start_background_task_mock(target, *args, **kwargs): flask_app = Flask(__name__) server = new_mock_asynction_socket_io(spec, flask_app) - server._register_handlers() + server._register_handlers(spec) with patch.object(SocketIO, "run"): with patch.object(server, "start_background_task", start_background_task_mock): diff --git a/tests/unit/test_server.py b/tests/unit/test_server.py index a419aa7..bfdb4b6 100644 --- a/tests/unit/test_server.py +++ b/tests/unit/test_server.py @@ -259,9 +259,9 @@ def test_register_handlers_registers_callables_with_correct_event_name_and_names ) }, ) - server = AsynctionSocketIO(spec, True, True, None) + server = AsynctionSocketIO([spec], True, True, None) - server._register_handlers() + server._register_handlers(spec) assert len(server.handlers) == 1 registered_event, registered_handler, registered_namespace = server.handlers[0] assert registered_event == event_name @@ -287,9 +287,9 @@ def test_register_handlers_registers_channel_handlers( ) }, ) - server = AsynctionSocketIO(spec, True, True, None) + server = AsynctionSocketIO([spec], True, True, None) - server._register_handlers() + server._register_handlers(spec) assert server.exception_handlers[namespace] == some_error for event_name, handler, handler_namespace in server.handlers: @@ -326,9 +326,9 @@ def test_register_handlers_adds_payload_validator_if_validation_is_enabled( ) }, ) - server = AsynctionSocketIO(spec, True, True, None) + server = AsynctionSocketIO([spec], True, True, None) - server._register_handlers() + server._register_handlers(spec) _, registered_handler, _ = server.handlers[0] handler_with_validation = deep_unwrap(registered_handler, depth=1) actual_handler = deep_unwrap(handler_with_validation) @@ -370,9 +370,9 @@ def test_register_handlers_adds_ack_validator_if_validation_is_enabled( ) }, ) - server = AsynctionSocketIO(spec, True, True, None) + server = AsynctionSocketIO([spec], True, True, None) - server._register_handlers() + server._register_handlers(spec) _, registered_handler, _ = server.handlers[0] handler_with_validation = deep_unwrap(registered_handler, depth=1) actual_handler = deep_unwrap(handler_with_validation) @@ -410,9 +410,9 @@ def test_register_handlers_skips_payload_validator_if_validation_is_disabled( ) }, ) - server = AsynctionSocketIO(spec, False, True, None) + server = AsynctionSocketIO([spec], False, True, None) - server._register_handlers() + server._register_handlers(spec) _, registered_handler, _ = server.handlers[0] handler_with_validation = deep_unwrap(registered_handler, depth=1) actual_handler = deep_unwrap(handler_with_validation) @@ -431,14 +431,15 @@ def test_register_handlers_skips_payload_validator_if_validation_is_disabled( def test_register_handlers_registers_default_error_handler( optional_error_handler: Optional[ErrorHandler], server_info: Info, faker: Faker ): + spec = AsyncApiSpec(asyncapi=faker.pystr(), info=server_info, channels={}) server = AsynctionSocketIO( - AsyncApiSpec(asyncapi=faker.pystr(), info=server_info, channels={}), + [spec], True, True, None, ) - server._register_handlers(default_error_handler=optional_error_handler) + server._register_handlers(spec=spec, default_error_handler=optional_error_handler) assert server.default_exception_handler == optional_error_handler @@ -449,7 +450,7 @@ def test_register_namespace_handlers_wraps_bindings_validator_if_validation_enab method="GET", ) ) - server = AsynctionSocketIO(mock.Mock(), True, True, None) + server = AsynctionSocketIO([mock.MagicMock()], True, True, None) server._register_namespace_handlers( GLOBAL_NAMESPACE, channel_handlers, channel_bindings, [] @@ -473,7 +474,7 @@ def test_register_namespace_handlers_omits_bindings_validator_if_validation_disa method="GET", ) ) - server = AsynctionSocketIO(mock.Mock(), False, True, None) + server = AsynctionSocketIO([mock.MagicMock()], False, True, None) server._register_namespace_handlers( GLOBAL_NAMESPACE, channel_handlers, channel_bindings, [] @@ -510,12 +511,12 @@ def test_register_namespace_handlers_includes_security_validator_if_security_nee ), ) - server = AsynctionSocketIO(spec, False, True, None) + server = AsynctionSocketIO([spec], False, True, None) server._register_namespace_handlers( GLOBAL_NAMESPACE, channel_handlers, None, - server.spec.servers.get("test").security, + server.specs[0].servers.get("test").security, ) event_name, registered_handler, _ = server.handlers[0] assert event_name == "connect" @@ -553,11 +554,11 @@ def test_register_namespace_handlers_emits_security_if_security_enabled_on_names ), ) - server = AsynctionSocketIO(spec, False, True, None) + server = AsynctionSocketIO([spec], False, True, None) security = ( channel_security if channel_security is not None - else server.spec.servers.get("test").security + else server.specs[0].servers.get("test").security ) server._register_namespace_handlers( @@ -599,7 +600,7 @@ def test_emit_event_with_non_existent_namespace_raises_validation_exc( ) }, ) - server = AsynctionSocketIO(spec, True, True, None) + server = AsynctionSocketIO([spec], True, True, None) with pytest.raises(ValidationException): # Correct event name but no namespace: @@ -630,7 +631,7 @@ def test_emit_event_that_has_no_subscribe_operation_raises_validation_exc( ) }, ) - server = AsynctionSocketIO(spec, True, True, None) + server = AsynctionSocketIO([spec], True, True, None) with pytest.raises(ValidationException): server.emit( @@ -661,7 +662,7 @@ def test_emit_event_not_defined_under_given_valid_namespace_raises_validation_ex ) }, ) - server = AsynctionSocketIO(spec, True, True, None) + server = AsynctionSocketIO([spec], True, True, None) with pytest.raises(ValidationException): # Correct namespace but undefined event: @@ -691,7 +692,7 @@ def test_emit_event_with_invalid_args_fails_validation(server_info: Info, faker: ) }, ) - server = AsynctionSocketIO(spec, True, True, None) + server = AsynctionSocketIO([spec], True, True, None) with pytest.raises(PayloadValidationException): # Event args do not adhere to the schema @@ -722,7 +723,7 @@ def test_emit_valid_event_invokes_super_method( ) }, ) - server = AsynctionSocketIO(spec, True, True, None) + server = AsynctionSocketIO([spec], True, True, None) event_args = [faker.pystr()] server.emit(event_name, *event_args, namespace=namespace) @@ -755,7 +756,7 @@ def test_emit_validiation_is_ignored_if_validation_flag_is_false( ) }, ) - server = AsynctionSocketIO(spec, False, True, None) + server = AsynctionSocketIO([spec], False, True, None) event_args = [faker.pystr()] # invalid args server.emit(event_name, *event_args, namespace=namespace) @@ -791,7 +792,7 @@ def test_emit_event_wraps_callback_with_validator( ) }, ) - server = AsynctionSocketIO(spec, True, True, None) + server = AsynctionSocketIO([spec], True, True, None) def actual_callback(*args): # dummy callback @@ -824,7 +825,7 @@ def test_init_app_registers_blueprint_if_docs_are_enabled( info=server_info, channels={}, ) - server = AsynctionSocketIO(spec, True, True, None) + server = AsynctionSocketIO([spec], True, True, None) app = Flask(__name__) server.init_app(app) assert "asynction_docs" in app.blueprints @@ -838,7 +839,7 @@ def test_init_app_does_not_register_blueprint_if_docs_are_disabled( info=server_info, channels={}, ) - server = AsynctionSocketIO(spec, True, False, None) + server = AsynctionSocketIO([spec], True, False, None) app = Flask(__name__) server.init_app(app) assert "asynction_docs" not in app.blueprints From da901f3171b5bae8ab69086819765f4932281046 Mon Sep 17 00:00:00 2001 From: Alex Zywicki Date: Thu, 9 Dec 2021 10:39:18 -0600 Subject: [PATCH 2/3] Add tests for multi api --- tests/fixtures/__init__.py | 4 ++++ tests/fixtures/multi1.yaml | 13 +++++++++++++ tests/fixtures/multi2.yaml | 13 +++++++++++++ tests/unit/test_server.py | 31 +++++++++++++++++++++++++++++++ 4 files changed, 61 insertions(+) create mode 100644 tests/fixtures/multi1.yaml create mode 100644 tests/fixtures/multi2.yaml diff --git a/tests/fixtures/__init__.py b/tests/fixtures/__init__.py index bc6a603..ad7b41f 100644 --- a/tests/fixtures/__init__.py +++ b/tests/fixtures/__init__.py @@ -9,6 +9,8 @@ class FixturePaths(NamedTuple): security: Path security_oauth2: Path namespace_security: Path + multi1: Path + multi2: Path paths = FixturePaths( @@ -18,4 +20,6 @@ class FixturePaths(NamedTuple): security=Path(__file__).parent.joinpath("security.yaml"), security_oauth2=Path(__file__).parent.joinpath("security_oauth2.yaml"), namespace_security=Path(__file__).parent.joinpath("namespace_security.yaml"), + multi1=Path(__file__).parent.joinpath("multi1.yaml"), + multi2=Path(__file__).parent.joinpath("multi2.yaml"), ) diff --git a/tests/fixtures/multi1.yaml b/tests/fixtures/multi1.yaml new file mode 100644 index 0000000..9d7c3bc --- /dev/null +++ b/tests/fixtures/multi1.yaml @@ -0,0 +1,13 @@ +asyncapi: 2.2.0 +info: + title: Multi1 + version: 1.0.0 +servers: + test: + url: https://locallhost/test + protocol: wss + test2: + url: https://locallhost/test + protocol: wss +channels: + channel1: {} diff --git a/tests/fixtures/multi2.yaml b/tests/fixtures/multi2.yaml new file mode 100644 index 0000000..57c24d5 --- /dev/null +++ b/tests/fixtures/multi2.yaml @@ -0,0 +1,13 @@ +asyncapi: 2.2.0 +info: + title: Multi2 + version: 1.0.0 +servers: + test: + url: https://locallhost/test + protocol: wss + test2: + url: https://locallhost/test2 + protocol: wss +channels: + channel2: {} diff --git a/tests/unit/test_server.py b/tests/unit/test_server.py index bfdb4b6..f3afdb3 100644 --- a/tests/unit/test_server.py +++ b/tests/unit/test_server.py @@ -115,6 +115,37 @@ def my_default_error_handler(_): assert asio.default_exception_handler == my_default_error_handler +def test_asynction_socketio_from_specs(fixture_paths: FixturePaths): + specs = [fixture_paths.multi1, fixture_paths.multi2] + asio = AsynctionSocketIO.from_specs(specs, server_name="test") + + assert len(asio.specs) == 2 + + +def test_asynction_socketio_from_specs_fails_missing_server_name( + fixture_paths: FixturePaths, +): + specs = [fixture_paths.multi1, fixture_paths.multi2] + with pytest.raises(ValueError): + _ = AsynctionSocketIO.from_specs(specs, server_name="foo") + + +def test_asynction_socketio_from_specs_fails_conflicting_server_paths( + fixture_paths: FixturePaths, +): + specs = [fixture_paths.multi1, fixture_paths.multi2] + with pytest.raises(ValueError): + _ = AsynctionSocketIO.from_specs(specs, server_name="test2") + + +def test_asynction_socketio_from_specs_fails_with_duplicate_spece( + fixture_paths: FixturePaths, +): + specs = [fixture_paths.multi1, fixture_paths.multi1] + with pytest.raises(ValueError): + _ = AsynctionSocketIO.from_specs(specs, server_name="test") + + def test_resolve_references_resolves_successfully(): raw_spec = { "asyncapi": "2.2.0", From e995df2b80412571659c6ad088d1d64f4af5550c Mon Sep 17 00:00:00 2001 From: Alex Zywicki Date: Thu, 9 Dec 2021 11:30:11 -0600 Subject: [PATCH 3/3] Add integration tests, fix issue with blueprint name conflicts --- asynction/mock_server.py | 75 +++++++++++++++++++++++++++++++- asynction/playground_docs.py | 6 ++- asynction/server.py | 6 ++- tests/fixtures/handlers.py | 4 ++ tests/fixtures/multi1.yaml | 8 +++- tests/fixtures/multi2.yaml | 8 +++- tests/integration/conftest.py | 36 +++++++++++++++ tests/integration/test_server.py | 32 ++++++++++++++ tests/unit/test_mock_server.py | 31 +++++++++++++ tests/unit/test_server.py | 2 +- 10 files changed, 200 insertions(+), 8 deletions(-) diff --git a/asynction/mock_server.py b/asynction/mock_server.py index 2991a8d..b0ff939 100644 --- a/asynction/mock_server.py +++ b/asynction/mock_server.py @@ -151,7 +151,7 @@ def from_spec( The server emits events containing payloads of fake data in regular intervals, through background subscription tasks. It also listens for events as per the spec definitions - and returns mock aknowledgements where applicable. + and returns mock acknowledgements where applicable. All event and acknowledgment payloads adhere to the schemata defined within the AsyncAPI spec. @@ -208,6 +208,79 @@ def from_spec( **kwargs, ) + @classmethod + def from_specs( + cls, + spec_paths: Sequence[Path], + validation: bool = True, + server_name: Optional[str] = None, + docs: bool = True, + default_error_handler: Optional[ErrorHandler] = None, + app: Optional[Flask] = None, + custom_formats_sample_size: int = 20, + **kwargs, + ) -> "MockAsynctionSocketIO": + """Create a Flask-SocketIO mock server given an AsyncAPI spec. + The server emits events containing payloads of fake data in regular intervals, + through background subscription tasks. + It also listens for events as per the spec definitions + and returns mock acknowledgements where applicable. + All event and acknowledgment payloads adhere to the schemata defined + within the AsyncAPI spec. + + In addition to the args and kwargs of :meth:`AsynctionSocketIO.from_spec`, + this factory method accepts some extra keyword arguments: + + * ``custom_formats_sample_size`` + + :param spec_paths: The paths where the AsyncAPI YAML specifications are located. + :param validation: When set to ``False``, message payloads, channel + bindings and ack callbacks are NOT validated. + Defaults to ``True``. + :param server_name: The server to pick from the AsyncAPI ``servers`` object. + The server object is then used to configure + the path ``kwarg`` of the SocketIO server. + :param docs: When set to ``True``, HTML rendered documentation is generated + and served through the ``GET {base_path}/docs`` route of the app. + The ``GET {base_path}/docs/asyncapi.json`` route is also exposed, + returning the raw specification data for programmatic retrieval. + Defaults to ``True``. + :param default_error_handler: The error handler that handles any namespace + without an explicit error handler. + Equivelant of ``@socketio.on_error_default`` + :param app: The flask application instance. Defaults to ``None``. + :param custom_formats_sample_size: The ammout of the Faker provider samples + to be used for each custom string format. + Hypotheses uses these samples to generate + fake data. Set to ``0`` if custom formats + are not needed. + Defaults to ``20``. + :param kwargs: Flask-SocketIO, Socket.IO and Engine.IO server options. + + :returns: A Flask-SocketIO mock server, emitting events of fake data in + regular intervals. + The server also has mock event and error handlers registered. + + Example:: + + mock_asio = MockAsynctionSocketIO.from_spec( + spec_paths="["./docs/asyncapi.yaml","./docs/asyncapi2.yaml"], + app=flask_app, + # any other kwarg that the flask_socketio.SocketIO constructor accepts + ) + + """ + return super().from_specs( + spec_paths, + validation=validation, + server_name=server_name, + docs=docs, + default_error_handler=default_error_handler, + app=app, + custom_formats_sample_size=custom_formats_sample_size, + **kwargs, + ) + def _register_handlers( self, spec: AsyncApiSpec, diff --git a/asynction/playground_docs.py b/asynction/playground_docs.py index e38368c..0a5eb07 100644 --- a/asynction/playground_docs.py +++ b/asynction/playground_docs.py @@ -29,8 +29,10 @@ def make_raw_spec_view(spec: AsyncApiSpec) -> View: return lambda: jsonify(spec.to_dict()) -def make_docs_blueprint(spec: AsyncApiSpec, url_prefix: Path) -> Blueprint: - bp = Blueprint("asynction_docs", __name__, url_prefix=str(url_prefix)) +def make_docs_blueprint( + spec: AsyncApiSpec, url_prefix: Path, name: str = "asynction_docs" +) -> Blueprint: + bp = Blueprint(name, __name__, url_prefix=str(url_prefix)) bp.add_url_rule("/docs", "html_rendered_docs", make_html_rendered_docs_view(spec)) bp.add_url_rule( "/docs/asyncapi.json", "raw_specification", make_raw_spec_view(spec) diff --git a/asynction/server.py b/asynction/server.py index 1bf5f93..fe8485e 100644 --- a/asynction/server.py +++ b/asynction/server.py @@ -121,7 +121,9 @@ def init_app(self, app: Optional[Flask], **kwargs) -> None: if self.docs and app is not None: for spec in self.specs: docs_bp = make_docs_blueprint( - spec=spec, url_prefix=Path(self.sockio_mw.engineio_path).parent + spec=spec, + url_prefix=Path(self.sockio_mw.engineio_path).parent, + name=spec.info.title, ) app.register_blueprint(docs_bp) @@ -231,7 +233,7 @@ def from_specs( Example:: asio = AsynctionSocketIO.from_spec( - spec_path="./docs/asyncapi.yaml", + spec_paths=["./docs/asyncapi.yaml","./docs/asyncapi2.yaml"], app=flask_app, message_queue="redis://localhost:6379", # any other kwarg that the flask_socketio.SocketIO constructor accepts diff --git a/tests/fixtures/handlers.py b/tests/fixtures/handlers.py index 9d128e5..464b4e0 100644 --- a/tests/fixtures/handlers.py +++ b/tests/fixtures/handlers.py @@ -30,6 +30,10 @@ def connect() -> None: pass +def connect_true() -> bool: + return True + + def disconnect() -> None: # Dummy handler pass diff --git a/tests/fixtures/multi1.yaml b/tests/fixtures/multi1.yaml index 9d7c3bc..a984ffd 100644 --- a/tests/fixtures/multi1.yaml +++ b/tests/fixtures/multi1.yaml @@ -10,4 +10,10 @@ servers: url: https://locallhost/test protocol: wss channels: - channel1: {} + /channel1: + subscripe: + message: + schema: + type: string + x-handlers: + connect: tests.fixtures.handlers.connect_true diff --git a/tests/fixtures/multi2.yaml b/tests/fixtures/multi2.yaml index 57c24d5..cdfdb7a 100644 --- a/tests/fixtures/multi2.yaml +++ b/tests/fixtures/multi2.yaml @@ -10,4 +10,10 @@ servers: url: https://locallhost/test2 protocol: wss channels: - channel2: {} + /channel2: + subscripe: + message: + schema: + type: string + x-handlers: + connect: tests.fixtures.handlers.connect_true diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 51fd3aa..0386ab6 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -1,6 +1,7 @@ from pathlib import Path from typing import Callable from typing import Optional +from typing import Sequence import pytest from flask import Flask @@ -48,3 +49,38 @@ def factory( ) return factory + + +@pytest.fixture +def asynction_socketio_multi_api_server_factory( + fixture_paths: FixturePaths, flask_app: Flask +) -> Callable[[Sequence[Path]], SocketIO]: + def factory(spec_paths=None, server_name: Optional[str] = "test") -> SocketIO: + if spec_paths is None: + spec_paths = [fixture_paths.multi1, fixture_paths.multi2] + + return AsynctionSocketIO.from_specs( + spec_paths, + server_name=server_name, + app=flask_app, + ) + + return factory + + +@pytest.fixture +def mock_asynction_socketio_multi_api_server_factory( + fixture_paths: FixturePaths, flask_app: Flask +) -> Callable[[Sequence[Path]], SocketIO]: + def factory(spec_paths=None, server_name: Optional[str] = "test") -> SocketIO: + if spec_paths is None: + spec_paths = [fixture_paths.multi1, fixture_paths.multi2] + + return MockAsynctionSocketIO.from_specs( + spec_paths, + server_name=server_name, + app=flask_app, + async_mode="threading", + ) + + return factory diff --git a/tests/integration/test_server.py b/tests/integration/test_server.py index d05c054..e126655 100644 --- a/tests/integration/test_server.py +++ b/tests/integration/test_server.py @@ -21,6 +21,10 @@ class FactoryFixture(Enum): ASYNCTION_SOCKET_IO = "asynction_socketio_server_factory" MOCK_ASYNCTION_SOCKET_IO = "mock_asynction_socketio_server_factory" + ASYNCTION_SOCKET_IO_MULTI_API = "asynction_socketio_multi_api_server_factory" + MOCK_ASYNCTION_SOCKET_IO_MULTI_API = ( + "mock_asynction_socketio_multi_api_server_factory" + ) @pytest.mark.parametrize( @@ -521,3 +525,31 @@ def test_client_connects_with_namespace_security( ) assert socketio_test_client.is_connected(secure_namespace) is True + + +@pytest.mark.parametrize( + argnames="factory_fixture", + argvalues=[ + FactoryFixture.ASYNCTION_SOCKET_IO_MULTI_API, + FactoryFixture.MOCK_ASYNCTION_SOCKET_IO_MULTI_API, + ], + ids=["server", "mock_server"], +) +def test_multi_api( + factory_fixture: FactoryFixture, + flask_app: Flask, + fixture_paths: FixturePaths, + request: pytest.FixtureRequest, +): + server_factory: AsynctionFactory = request.getfixturevalue(factory_fixture.value) + socketio_server = server_factory() + + flask_test_client = flask_app.test_client() + socketio_test_client = socketio_server.test_client( + flask_app, flask_test_client=flask_test_client, namespace="/channel1" + ) + + assert socketio_test_client.is_connected("/channel1") + + socketio_test_client.connect("/channel2") + assert socketio_test_client.is_connected("/channel2") diff --git a/tests/unit/test_mock_server.py b/tests/unit/test_mock_server.py index 47b81c4..6eea923 100644 --- a/tests/unit/test_mock_server.py +++ b/tests/unit/test_mock_server.py @@ -155,6 +155,37 @@ def new_mock_asynction_socket_io( ) +def test_asynction_socketio_from_specs(fixture_paths: FixturePaths): + specs = [fixture_paths.multi1, fixture_paths.multi2] + asio = MockAsynctionSocketIO.from_specs(specs, server_name="test") + + assert len(asio.specs) == 2 + + +def test_asynction_socketio_from_specs_fails_missing_server_name( + fixture_paths: FixturePaths, +): + specs = [fixture_paths.multi1, fixture_paths.multi2] + with pytest.raises(ValueError): + _ = MockAsynctionSocketIO.from_specs(specs, server_name="foo") + + +def test_asynction_socketio_from_specs_fails_conflicting_server_paths( + fixture_paths: FixturePaths, +): + specs = [fixture_paths.multi1, fixture_paths.multi2] + with pytest.raises(ValueError): + _ = MockAsynctionSocketIO.from_specs(specs, server_name="test2") + + +def test_asynction_socketio_from_specs_fails_with_duplicate_spece( + fixture_paths: FixturePaths, +): + specs = [fixture_paths.multi1, fixture_paths.multi1] + with pytest.raises(ValueError): + _ = MockAsynctionSocketIO.from_specs(specs, server_name="test") + + def test_register_handlers_registers_noop_handler_for_message_with_no_ack( server_info: Info, faker: Faker, diff --git a/tests/unit/test_server.py b/tests/unit/test_server.py index f3afdb3..7fdd866 100644 --- a/tests/unit/test_server.py +++ b/tests/unit/test_server.py @@ -859,7 +859,7 @@ def test_init_app_registers_blueprint_if_docs_are_enabled( server = AsynctionSocketIO([spec], True, True, None) app = Flask(__name__) server.init_app(app) - assert "asynction_docs" in app.blueprints + assert server_info.title in app.blueprints def test_init_app_does_not_register_blueprint_if_docs_are_disabled(