Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement initial multi api file changes. #126

Draft
wants to merge 3 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 80 additions & 5 deletions asynction/mock_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ class MockAsynctionSocketIO(AsynctionSocketIO):

def __init__(
self,
spec: AsyncApiSpec,
specs: Sequence[AsyncApiSpec],
validation: bool,
docs: bool,
app: Optional[Flask],
Expand All @@ -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] = []
Expand All @@ -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.

Expand Down Expand Up @@ -208,12 +208,86 @@ 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,
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)
Expand Down Expand Up @@ -247,10 +321,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)
Expand Down
6 changes: 4 additions & 2 deletions asynction/playground_docs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@dedoussis I'm not sure what to do about the name here. I am passing in the spec.info.title but that only works if your title only contains the correct characters. I think for the multi API it may be a situation where the use of a new Jinja template that can handle multiple specs would be better?

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)
Expand Down
120 changes: 109 additions & 11 deletions asynction/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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],
Expand All @@ -95,20 +108,24 @@ 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)

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,
name=spec.info.title,
)
app.register_blueprint(docs_bp)

@classmethod
def from_spec(
Expand Down Expand Up @@ -175,8 +192,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_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
)

"""
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(
Expand All @@ -197,9 +293,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)
Expand All @@ -220,10 +317,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
Expand Down Expand Up @@ -253,7 +351,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(
Expand Down
4 changes: 4 additions & 0 deletions tests/fixtures/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ class FixturePaths(NamedTuple):
security: Path
security_oauth2: Path
namespace_security: Path
multi1: Path
multi2: Path


paths = FixturePaths(
Expand All @@ -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"),
)
4 changes: 4 additions & 0 deletions tests/fixtures/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ def connect() -> None:
pass


def connect_true() -> bool:
return True


def disconnect() -> None:
# Dummy handler
pass
Expand Down
19 changes: 19 additions & 0 deletions tests/fixtures/multi1.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
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:
subscripe:
message:
schema:
type: string
x-handlers:
connect: tests.fixtures.handlers.connect_true
19 changes: 19 additions & 0 deletions tests/fixtures/multi2.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
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:
subscripe:
message:
schema:
type: string
x-handlers:
connect: tests.fixtures.handlers.connect_true
Loading