From ddc3b0401936a13467f13c859ea1efaf85ad9cdc Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Tue, 21 Nov 2023 11:36:16 +0100 Subject: [PATCH 01/23] adds sio to requirements --- services/payments/requirements/_base.in | 1 + services/payments/requirements/_base.txt | 11 +++++ services/payments/requirements/_test.in | 1 + services/payments/requirements/_test.txt | 54 ++++++++++++++++++++++++ 4 files changed, 67 insertions(+) diff --git a/services/payments/requirements/_base.in b/services/payments/requirements/_base.in index 602ea897f9e..5ef19fb42d5 100644 --- a/services/payments/requirements/_base.in +++ b/services/payments/requirements/_base.in @@ -20,5 +20,6 @@ httpx packaging python-jose python-multipart +python-socketio typer[all] uvicorn[standard] diff --git a/services/payments/requirements/_base.txt b/services/payments/requirements/_base.txt index c1f4db27fa6..9e1ab68c35a 100644 --- a/services/payments/requirements/_base.txt +++ b/services/payments/requirements/_base.txt @@ -62,6 +62,8 @@ attrs==23.1.0 # aiohttp # jsonschema # referencing +bidict==0.22.1 + # via python-socketio certifi==2023.7.22 # via # -c requirements/../../../packages/models-library/requirements/../../../requirements/constraints.txt @@ -131,6 +133,7 @@ h11==0.14.0 # via # httpcore # uvicorn + # wsproto httpcore==0.18.0 # via httpx httptools==0.6.1 @@ -233,10 +236,14 @@ python-dateutil==2.8.2 # via arrow python-dotenv==1.0.0 # via uvicorn +python-engineio==4.8.0 + # via python-socketio python-jose==3.3.0 # via -r requirements/_base.in python-multipart==0.0.6 # via -r requirements/_base.in +python-socketio==5.10.0 + # via -r requirements/_base.in pyyaml==6.0.1 # via # -c requirements/../../../packages/models-library/requirements/../../../requirements/constraints.txt @@ -297,6 +304,8 @@ rsa==4.9 # python-jose shellingham==1.5.4 # via typer +simple-websocket==1.0.0 + # via python-engineio six==1.16.0 # via # ecdsa @@ -372,6 +381,8 @@ watchfiles==0.21.0 # via uvicorn websockets==12.0 # via uvicorn +wsproto==1.2.0 + # via simple-websocket yarl==1.9.2 # via # -r requirements/../../../packages/postgres-database/requirements/_base.in diff --git a/services/payments/requirements/_test.in b/services/payments/requirements/_test.in index b4f4311171e..32d6384e0d0 100644 --- a/services/payments/requirements/_test.in +++ b/services/payments/requirements/_test.in @@ -24,4 +24,5 @@ pytest-mock pytest-runner pytest-sugar python-dotenv +python-socketio[asyncio_client] respx diff --git a/services/payments/requirements/_test.txt b/services/payments/requirements/_test.txt index b0774954946..a1847d847dd 100644 --- a/services/payments/requirements/_test.txt +++ b/services/payments/requirements/_test.txt @@ -4,12 +4,33 @@ # # pip-compile --output-file=requirements/_test.txt --strip-extras requirements/_test.in # +aiohttp==3.8.6 + # via + # -c requirements/../../../requirements/constraints.txt + # -c requirements/_base.txt + # python-socketio +aiosignal==1.3.1 + # via + # -c requirements/_base.txt + # aiohttp anyio==4.0.0 # via # -c requirements/_base.txt # httpcore asgi-lifespan==2.1.0 # via -r requirements/_test.in +async-timeout==4.0.3 + # via + # -c requirements/_base.txt + # aiohttp +attrs==23.1.0 + # via + # -c requirements/_base.txt + # aiohttp +bidict==0.22.1 + # via + # -c requirements/_base.txt + # python-socketio certifi==2023.7.22 # via # -c requirements/../../../requirements/constraints.txt @@ -20,6 +41,7 @@ certifi==2023.7.22 charset-normalizer==3.3.2 # via # -c requirements/_base.txt + # aiohttp # requests coverage==7.3.2 # via @@ -34,10 +56,16 @@ exceptiongroup==1.1.3 # pytest faker==19.13.0 # via -r requirements/_test.in +frozenlist==1.4.0 + # via + # -c requirements/_base.txt + # aiohttp + # aiosignal h11==0.14.0 # via # -c requirements/_base.txt # httpcore + # wsproto httpcore==0.18.0 # via # -c requirements/_base.txt @@ -55,10 +83,16 @@ idna==3.4 # anyio # httpx # requests + # yarl iniconfig==2.0.0 # via pytest jsonref==1.1.0 # via -r requirements/_test.in +multidict==6.0.4 + # via + # -c requirements/_base.txt + # aiohttp + # yarl packaging==23.2 # via # -c requirements/_base.txt @@ -97,10 +131,22 @@ python-dotenv==1.0.0 # via # -c requirements/_base.txt # -r requirements/_test.in +python-engineio==4.8.0 + # via + # -c requirements/_base.txt + # python-socketio +python-socketio==5.10.0 + # via + # -c requirements/_base.txt + # -r requirements/_test.in requests==2.31.0 # via docker respx==0.20.2 # via -r requirements/_test.in +simple-websocket==1.0.0 + # via + # -c requirements/_base.txt + # python-engineio six==1.16.0 # via # -c requirements/_base.txt @@ -125,3 +171,11 @@ urllib3==2.0.7 # requests websocket-client==1.6.4 # via docker +wsproto==1.2.0 + # via + # -c requirements/_base.txt + # simple-websocket +yarl==1.9.2 + # via + # -c requirements/_base.txt + # aiohttp From 8a16f0358fd4436061ec44ae4c532e559a475337 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Tue, 21 Nov 2023 11:38:43 +0100 Subject: [PATCH 02/23] drafts setup and tests --- .../services/rabbitmq.py | 7 +++- .../services/socketio.py | 35 +++++++++++++++++++ .../tests/unit/test_services_socketio.py | 20 +++++++++++ 3 files changed, 61 insertions(+), 1 deletion(-) create mode 100644 services/payments/src/simcore_service_payments/services/socketio.py create mode 100644 services/payments/tests/unit/test_services_socketio.py diff --git a/services/payments/src/simcore_service_payments/services/rabbitmq.py b/services/payments/src/simcore_service_payments/services/rabbitmq.py index 8938b52cbc2..ecb0ba8f382 100644 --- a/services/payments/src/simcore_service_payments/services/rabbitmq.py +++ b/services/payments/src/simcore_service_payments/services/rabbitmq.py @@ -13,8 +13,13 @@ _logger = logging.getLogger(__name__) -def setup_rabbitmq(app: FastAPI) -> None: +def get_rabbitmq_settings(app: FastAPI) -> RabbitSettings: settings: RabbitSettings = app.state.settings.PAYMENTS_RABBITMQ + return settings + + +def setup_rabbitmq(app: FastAPI) -> None: + settings: RabbitSettings = get_rabbitmq_settings(app) app.state.rabbitmq_client = None app.state.rabbitmq_rpc_server = None diff --git a/services/payments/src/simcore_service_payments/services/socketio.py b/services/payments/src/simcore_service_payments/services/socketio.py new file mode 100644 index 00000000000..dc9dc0e7ab6 --- /dev/null +++ b/services/payments/src/simcore_service_payments/services/socketio.py @@ -0,0 +1,35 @@ +import logging + +import socketio +from fastapi import FastAPI +from settings_library.rabbit import RabbitSettings + +from .rabbitmq import get_rabbitmq_settings + +_logger = logging.getLogger(__name__) + + +def setup_socketio(app: FastAPI): + settings: RabbitSettings = get_rabbitmq_settings(app) + + async def _on_startup() -> None: + assert app.state.rabbitmq_client # nosec + + # + # https://python-socketio.readthedocs.io/en/stable/server.html#emitting-from-external-processes + # + # Connect to the as an external process in write-only mode + # + app.state.external_sio = socketio.AsyncAioPikaManager( + url=settings.dsn, logger=_logger, write_only=True + ) + + async def _on_shutdown() -> None: + ... + + app.add_event_handler("startup", _on_startup) + app.add_event_handler("shutdown", _on_shutdown) + + +async def emit_to_frontend(app: FastAPI, event_name: str, data: dict, to=None): + return await app.state.external_sio.emit(event_name, data=data, to=to) diff --git a/services/payments/tests/unit/test_services_socketio.py b/services/payments/tests/unit/test_services_socketio.py new file mode 100644 index 00000000000..c4397a44b80 --- /dev/null +++ b/services/payments/tests/unit/test_services_socketio.py @@ -0,0 +1,20 @@ +from faker import Faker +from fastapi import FastAPI +from simcore_service_payments.services.socketio import emit_to_frontend + + +async def test_socketio_setup(): + # is this closing properly? + ... + + +async def test_emit_socketio_event_to_front_end(app: FastAPI, faker: Faker): + # create a client + sid = faker.uuid4() + + # create a server + + # emit from external + await emit_to_frontend(app, event_name="event", data={"foo": "bar"}, to=sid) + + # client receives it From c5220c17766abc574a7ed7db1700b3cf5ee554d9 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Tue, 21 Nov 2023 14:35:03 +0100 Subject: [PATCH 03/23] WIP: sio --- .../tests/unit/test_services_socketio.py | 43 ++++++++++++++++--- 1 file changed, 37 insertions(+), 6 deletions(-) diff --git a/services/payments/tests/unit/test_services_socketio.py b/services/payments/tests/unit/test_services_socketio.py index c4397a44b80..8edc1dc48ee 100644 --- a/services/payments/tests/unit/test_services_socketio.py +++ b/services/payments/tests/unit/test_services_socketio.py @@ -1,6 +1,12 @@ +from collections.abc import Callable + +import pytest +import socketio +from aiohttp import web from faker import Faker from fastapi import FastAPI from simcore_service_payments.services.socketio import emit_to_frontend +from socketio import AsyncAioPikaManager, AsyncServer async def test_socketio_setup(): @@ -8,13 +14,38 @@ async def test_socketio_setup(): ... -async def test_emit_socketio_event_to_front_end(app: FastAPI, faker: Faker): - # create a client - sid = faker.uuid4() +@pytest.fixture +async def socketio_aiohttp_server(aiohttp_server: Callable): + aiohttp_app = web.Application() + server = await aiohttp_server(aiohttp_app) + + # Emulates simcore_service_webserver/socketio/server.py + server_manager = AsyncAioPikaManager(url=get_rabbitmq_settings(app).dsn) + sio_server = AsyncServer( + async_mode="aiohttp", + engineio_logger=True, + client_manager=server_manager, + ) + sio_server.attach(aiohttp_app) + +async def test_emit_socketio_event_to_front_end(app: FastAPI, faker: Faker, sio_server): # create a server - # emit from external - await emit_to_frontend(app, event_name="event", data={"foo": "bar"}, to=sid) + # create a client + async with socketio.AsyncSimpleClient(logger=True, engineio_logger=True) as sio: + + # connect to a server + await sio.connect(server_url, transports=["websocket"]) + session_client_id = sio.sid + + # emit from external + await emit_to_frontend( + app, event_name="event", data={"foo": "bar"}, to=session_client_id + ) + + # client receives it + event: list = await sio.receive(timeout=5) + event_name, *event_kwargs = event - # client receives it + assert event_name == "event" From 5868399662b84deda0a87e11dd5f2ebfc9892b38 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Tue, 21 Nov 2023 19:22:56 +0100 Subject: [PATCH 04/23] minor --- services/payments/requirements/_test.in | 1 + services/payments/requirements/_test.txt | 8 ++- .../tests/unit/test_services_socketio.py | 57 ++++++++++++++++--- 3 files changed, 58 insertions(+), 8 deletions(-) diff --git a/services/payments/requirements/_test.in b/services/payments/requirements/_test.in index 32d6384e0d0..e67da6f24e9 100644 --- a/services/payments/requirements/_test.in +++ b/services/payments/requirements/_test.in @@ -17,6 +17,7 @@ docker faker jsonref pytest +pytest-aiohttp pytest-asyncio pytest-cov pytest-icdiff diff --git a/services/payments/requirements/_test.txt b/services/payments/requirements/_test.txt index a1847d847dd..9b76509ff98 100644 --- a/services/payments/requirements/_test.txt +++ b/services/payments/requirements/_test.txt @@ -8,6 +8,7 @@ aiohttp==3.8.6 # via # -c requirements/../../../requirements/constraints.txt # -c requirements/_base.txt + # pytest-aiohttp # python-socketio aiosignal==1.3.1 # via @@ -106,13 +107,18 @@ pprintpp==0.4.0 pytest==7.4.3 # via # -r requirements/_test.in + # pytest-aiohttp # pytest-asyncio # pytest-cov # pytest-icdiff # pytest-mock # pytest-sugar -pytest-asyncio==0.21.1 +pytest-aiohttp==1.0.5 # via -r requirements/_test.in +pytest-asyncio==0.21.1 + # via + # -r requirements/_test.in + # pytest-aiohttp pytest-cov==4.1.0 # via -r requirements/_test.in pytest-icdiff==0.8 diff --git a/services/payments/tests/unit/test_services_socketio.py b/services/payments/tests/unit/test_services_socketio.py index 8edc1dc48ee..ee7dd35519c 100644 --- a/services/payments/tests/unit/test_services_socketio.py +++ b/services/payments/tests/unit/test_services_socketio.py @@ -3,11 +3,40 @@ import pytest import socketio from aiohttp import web -from faker import Faker +from aiohttp.test_utils import TestServer from fastapi import FastAPI -from simcore_service_payments.services.socketio import emit_to_frontend +from pytest_simcore.helpers.typing_env import EnvVarsDict +from pytest_simcore.helpers.utils_envs import setenvs_from_dict +from settings_library.rabbit import RabbitSettings +from simcore_service_payments.services.socketio import ( + emit_to_frontend, + get_rabbitmq_settings, +) from socketio import AsyncAioPikaManager, AsyncServer +pytest_simcore_core_services_selection = [ + "rabbit", +] +pytest_simcore_ops_services_selection = [] + + +@pytest.fixture +def app_environment( + monkeypatch: pytest.MonkeyPatch, + app_environment: EnvVarsDict, + rabbit_env_vars_dict: EnvVarsDict, # rabbitMQ settings from 'rabbit' service +): + # set environs + monkeypatch.delenv("PAYMENTS_RABBITMQ", raising=False) + + return setenvs_from_dict( + monkeypatch, + { + **app_environment, + **rabbit_env_vars_dict, + }, + ) + async def test_socketio_setup(): # is this closing properly? @@ -15,26 +44,40 @@ async def test_socketio_setup(): @pytest.fixture -async def socketio_aiohttp_server(aiohttp_server: Callable): +async def socketio_aiohttp_server(app: FastAPI, aiohttp_server: Callable) -> TestServer: + """ + this emulates the webserver setup: socketio server with + an aiopika manager that attaches an aiohttp web app + """ aiohttp_app = web.Application() - server = await aiohttp_server(aiohttp_app) # Emulates simcore_service_webserver/socketio/server.py - server_manager = AsyncAioPikaManager(url=get_rabbitmq_settings(app).dsn) + settings: RabbitSettings = get_rabbitmq_settings(app) + server_manager = AsyncAioPikaManager(url=settings.dsn) sio_server = AsyncServer( async_mode="aiohttp", engineio_logger=True, client_manager=server_manager, ) + + @sio_server.event() + async def connect(sid, environ): + ... + sio_server.attach(aiohttp_app) + return await aiohttp_server(aiohttp_app) + -async def test_emit_socketio_event_to_front_end(app: FastAPI, faker: Faker, sio_server): - # create a server +async def test_emit_socketio_event_to_front_end( + app: FastAPI, socketio_aiohttp_server: TestServer +): + server_url = socketio_aiohttp_server.make_url("/") # create a client async with socketio.AsyncSimpleClient(logger=True, engineio_logger=True) as sio: + # https://python-socketio.readthedocs.io/en/stable/client.html#connecting-to-a-server # connect to a server await sio.connect(server_url, transports=["websocket"]) session_client_id = sio.sid From 14a4c91610b6b9072d1a7ebb950ad42940590cd1 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Fri, 24 Nov 2023 11:37:54 +0100 Subject: [PATCH 05/23] WIP --- .../core/application.py | 2 + .../services/socketio.py | 61 +++++++++++- .../tests/unit/test_services_socketio.py | 99 +++++++++++++++---- .../socketio/messages.py | 4 + 4 files changed, 144 insertions(+), 22 deletions(-) diff --git a/services/payments/src/simcore_service_payments/core/application.py b/services/payments/src/simcore_service_payments/core/application.py index 815355b1cf9..f4a8397193c 100644 --- a/services/payments/src/simcore_service_payments/core/application.py +++ b/services/payments/src/simcore_service_payments/core/application.py @@ -1,5 +1,6 @@ from fastapi import FastAPI from servicelib.fastapi.openapi import override_fastapi_openapi_method +from simcore_service_payments.services.socketio import setup_socketio from .._meta import ( API_VERSION, @@ -54,6 +55,7 @@ def create_app(settings: ApplicationSettings | None = None) -> FastAPI: # Listening to Rabbitmq setup_auto_recharge_listener(app) + setup_socketio(app) # ERROR HANDLERS # ... add here ... diff --git a/services/payments/src/simcore_service_payments/services/socketio.py b/services/payments/src/simcore_service_payments/services/socketio.py index dc9dc0e7ab6..bcf412b272e 100644 --- a/services/payments/src/simcore_service_payments/services/socketio.py +++ b/services/payments/src/simcore_service_payments/services/socketio.py @@ -1,7 +1,14 @@ import logging +from collections.abc import Sequence +from typing import Any, Final, TypedDict import socketio from fastapi import FastAPI +from fastapi.encoders import jsonable_encoder +from models_library.api_schemas_webserver.wallets import PaymentTransaction +from models_library.users import UserID +from servicelib.json_serialization import json_dumps +from servicelib.utils import logged_gather from settings_library.rabbit import RabbitSettings from .rabbitmq import get_rabbitmq_settings @@ -9,6 +16,10 @@ _logger = logging.getLogger(__name__) +SOCKET_IO_PAYMENT_COMPLETED_EVENT: Final[str] = "paymentCompleted" +SOCKET_IO_PAYMENT_METHOD_ACKED_EVENT: Final[str] = "paymentMethodAcknoledged" + + def setup_socketio(app: FastAPI): settings: RabbitSettings = get_rabbitmq_settings(app) @@ -31,5 +42,53 @@ async def _on_shutdown() -> None: app.add_event_handler("shutdown", _on_shutdown) -async def emit_to_frontend(app: FastAPI, event_name: str, data: dict, to=None): +async def emit_to_frontend( + app: FastAPI, event_name: str, data: dict, to: str | None = None +): + + # Send messages to clients from external processes, such as Celery workers or auxiliary scripts. return await app.state.external_sio.emit(event_name, data=data, to=to) + + +async def notify_payment_completed( + app: FastAPI, + *, + user_id: UserID, + payment: PaymentTransaction, +): + assert payment.completed_at is not None # nosec + + messages: list[SocketMessageDict] = [ + { + "event_type": SOCKET_IO_PAYMENT_COMPLETED_EVENT, + "data": jsonable_encoder(payment, by_alias=True), + } + ] + await send_messages(app, user_id, messages) + + +class SocketMessageDict(TypedDict): + event_type: str + data: dict[str, Any] + + +async def send_messages( + app: FastAPI, user_id: UserID, messages: Sequence[SocketMessageDict] +) -> None: + + sio = app.state.external_sio + + socket_ids: list[str] = [] + # with managed_resource(user_id, None, app) as user_session: + # socket_ids = await user_session.find_socket_ids() + + await logged_gather( + *( + sio.emit(message["event_type"], data=json_dumps(message["data"]), room=sid) + for message in messages + for sid in socket_ids + ), + reraise=False, + log=_logger, + max_concurrency=100, + ) diff --git a/services/payments/tests/unit/test_services_socketio.py b/services/payments/tests/unit/test_services_socketio.py index ee7dd35519c..8d105cf1636 100644 --- a/services/payments/tests/unit/test_services_socketio.py +++ b/services/payments/tests/unit/test_services_socketio.py @@ -1,4 +1,13 @@ +# pylint: disable=redefined-outer-name +# pylint: disable=unused-argument +# pylint: disable=unused-variable +# pylint: disable=too-many-arguments + + +import asyncio from collections.abc import Callable +from typing import Any +from unittest.mock import AsyncMock import pytest import socketio @@ -9,6 +18,7 @@ from pytest_simcore.helpers.utils_envs import setenvs_from_dict from settings_library.rabbit import RabbitSettings from simcore_service_payments.services.socketio import ( + SOCKET_IO_PAYMENT_COMPLETED_EVENT, emit_to_frontend, get_rabbitmq_settings, ) @@ -24,6 +34,7 @@ def app_environment( monkeypatch: pytest.MonkeyPatch, app_environment: EnvVarsDict, + with_disabled_postgres: None, rabbit_env_vars_dict: EnvVarsDict, # rabbitMQ settings from 'rabbit' service ): # set environs @@ -44,7 +55,7 @@ async def test_socketio_setup(): @pytest.fixture -async def socketio_aiohttp_server(app: FastAPI, aiohttp_server: Callable) -> TestServer: +async def socketio_server(app: FastAPI, aiohttp_server: Callable) -> TestServer: """ this emulates the webserver setup: socketio server with an aiopika manager that attaches an aiohttp web app @@ -60,35 +71,81 @@ async def socketio_aiohttp_server(app: FastAPI, aiohttp_server: Callable) -> Tes client_manager=server_manager, ) - @sio_server.event() - async def connect(sid, environ): - ... + @sio_server.event + async def connect(sid: str, environ): + print("connecting", sid) + + @sio_server.on(SOCKET_IO_PAYMENT_COMPLETED_EVENT) + async def on_payment(sid, data): + print(sid, Any) + + @sio_server.event + async def disconnect(sid: str): + print("disconnecting", sid) sio_server.attach(aiohttp_app) + # starts server return await aiohttp_server(aiohttp_app) -async def test_emit_socketio_event_to_front_end( - app: FastAPI, socketio_aiohttp_server: TestServer +@pytest.fixture +async def create_sio_client(socketio_server: TestServer): + server_url = socketio_server.make_url("/") + _clients = [] + + async def _(): + cli = socketio.AsyncClient( + logger=True, + engineio_logger=True, + ) + + # https://python-socketio.readthedocs.io/en/stable/client.html#connecting-to-a-server + # Allows WebSocket transport and disconnect HTTP long-polling + await cli.connect(f"{server_url}", transports=["websocket"]) + + _clients.append(cli) + + return cli + + yield _ + + for client in _clients: + await client.disconnect() + + +async def test_emit_message_as_external_process_to_frontend_client( + app: FastAPI, create_sio_client: Callable ): - server_url = socketio_aiohttp_server.make_url("/") + """ + front-end -> socketio client (many different clients) + webserver -> socketio server (one/more replicas) + payments -> Sends messages to clients from external processes (one/more replicas) + """ - # create a client - async with socketio.AsyncSimpleClient(logger=True, engineio_logger=True) as sio: + # emulates front-end receiving message + client_1: socketio.AsyncClient = await create_sio_client() - # https://python-socketio.readthedocs.io/en/stable/client.html#connecting-to-a-server - # connect to a server - await sio.connect(server_url, transports=["websocket"]) - session_client_id = sio.sid + @client_1.on(SOCKET_IO_PAYMENT_COMPLETED_EVENT) + async def on_event(data): + print("client1", data) - # emit from external - await emit_to_frontend( - app, event_name="event", data={"foo": "bar"}, to=session_client_id - ) + on_event_spy = AsyncMock(wraps=on_event) + + await client_1.emit(SOCKET_IO_PAYMENT_COMPLETED_EVENT, data="hoi1") + + # TODO: better to do this from a different process?? + # emit from external process + await emit_to_frontend( + app, + event_name=SOCKET_IO_PAYMENT_COMPLETED_EVENT, + data={"foo": "bar"}, + # to=client_1.sid, + ) + + await client_1.emit(SOCKET_IO_PAYMENT_COMPLETED_EVENT, data="hoi2") - # client receives it - event: list = await sio.receive(timeout=5) - event_name, *event_kwargs = event + await client_1.sleep(1) + await asyncio.sleep(1) - assert event_name == "event" + on_event_spy.assert_called() diff --git a/services/web/server/src/simcore_service_webserver/socketio/messages.py b/services/web/server/src/simcore_service_webserver/socketio/messages.py index 14130602100..23cb8898238 100644 --- a/services/web/server/src/simcore_service_webserver/socketio/messages.py +++ b/services/web/server/src/simcore_service_webserver/socketio/messages.py @@ -18,6 +18,10 @@ _logger = logging.getLogger(__name__) + +# +# List of socket-io event names +# SOCKET_IO_EVENT: Final[str] = "event" SOCKET_IO_HEARTBEAT_EVENT: Final[str] = "set_heartbeat_emit_interval" SOCKET_IO_LOG_EVENT: Final[str] = "logger" From a538509f741f4b04e0b38263bbd03ee41241bc3c Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero Date: Fri, 24 Nov 2023 14:25:44 +0100 Subject: [PATCH 06/23] delay response and common socketio model --- .../api_schemas_payments/socketio.py | 4 ++++ .../models-library/src/models_library/socketio.py | 6 ++++++ packages/service-library/src/servicelib/utils.py | 4 ++-- .../simcore_service_payments/services/socketio.py | 14 ++++---------- .../notifications/_rabbitmq_consumers.py | 2 +- .../payments/_socketio.py | 12 ++++++------ .../projects/projects_api.py | 2 +- .../socketio/_handlers.py | 3 ++- .../simcore_service_webserver/socketio/messages.py | 10 ++-------- .../wallets/_payments_handlers.py | 10 +++++++++- 10 files changed, 37 insertions(+), 30 deletions(-) create mode 100644 packages/models-library/src/models_library/api_schemas_payments/socketio.py create mode 100644 packages/models-library/src/models_library/socketio.py diff --git a/packages/models-library/src/models_library/api_schemas_payments/socketio.py b/packages/models-library/src/models_library/api_schemas_payments/socketio.py new file mode 100644 index 00000000000..b828080f9ba --- /dev/null +++ b/packages/models-library/src/models_library/api_schemas_payments/socketio.py @@ -0,0 +1,4 @@ +from typing import Final + +SOCKET_IO_PAYMENT_COMPLETED_EVENT: Final[str] = "paymentCompleted" +SOCKET_IO_PAYMENT_METHOD_ACKED_EVENT: Final[str] = "paymentMethodAcknoledged" diff --git a/packages/models-library/src/models_library/socketio.py b/packages/models-library/src/models_library/socketio.py new file mode 100644 index 00000000000..88b0e9a0beb --- /dev/null +++ b/packages/models-library/src/models_library/socketio.py @@ -0,0 +1,6 @@ +from typing import Any, TypedDict + + +class SocketMessageDict(TypedDict): + event_type: str + data: dict[str, Any] diff --git a/packages/service-library/src/servicelib/utils.py b/packages/service-library/src/servicelib/utils.py index 3cbad7930db..001e183914e 100644 --- a/packages/service-library/src/servicelib/utils.py +++ b/packages/service-library/src/servicelib/utils.py @@ -75,7 +75,7 @@ def fire_and_forget_task( task = asyncio.create_task(obj, name=f"fire_and_forget_task_{task_suffix_name}") fire_and_forget_tasks_collection.add(task) - def log_exception_callback(fut: asyncio.Future): + def _log_exception_callback(fut: asyncio.Future): try: fut.result() except asyncio.CancelledError: @@ -83,7 +83,7 @@ def log_exception_callback(fut: asyncio.Future): except Exception: # pylint: disable=broad-except _logger.exception("Error occurred while running task %s!", task.get_name()) - task.add_done_callback(log_exception_callback) + task.add_done_callback(_log_exception_callback) task.add_done_callback(fire_and_forget_tasks_collection.discard) return task diff --git a/services/payments/src/simcore_service_payments/services/socketio.py b/services/payments/src/simcore_service_payments/services/socketio.py index bcf412b272e..e5fd26239dc 100644 --- a/services/payments/src/simcore_service_payments/services/socketio.py +++ b/services/payments/src/simcore_service_payments/services/socketio.py @@ -1,11 +1,14 @@ import logging from collections.abc import Sequence -from typing import Any, Final, TypedDict import socketio from fastapi import FastAPI from fastapi.encoders import jsonable_encoder +from models_library.api_schemas_payments.socketio import ( + SOCKET_IO_PAYMENT_COMPLETED_EVENT, +) from models_library.api_schemas_webserver.wallets import PaymentTransaction +from models_library.socketio import SocketMessageDict from models_library.users import UserID from servicelib.json_serialization import json_dumps from servicelib.utils import logged_gather @@ -16,10 +19,6 @@ _logger = logging.getLogger(__name__) -SOCKET_IO_PAYMENT_COMPLETED_EVENT: Final[str] = "paymentCompleted" -SOCKET_IO_PAYMENT_METHOD_ACKED_EVENT: Final[str] = "paymentMethodAcknoledged" - - def setup_socketio(app: FastAPI): settings: RabbitSettings = get_rabbitmq_settings(app) @@ -67,11 +66,6 @@ async def notify_payment_completed( await send_messages(app, user_id, messages) -class SocketMessageDict(TypedDict): - event_type: str - data: dict[str, Any] - - async def send_messages( app: FastAPI, user_id: UserID, messages: Sequence[SocketMessageDict] ) -> None: diff --git a/services/web/server/src/simcore_service_webserver/notifications/_rabbitmq_consumers.py b/services/web/server/src/simcore_service_webserver/notifications/_rabbitmq_consumers.py index b79ee8ceeee..5decd463917 100644 --- a/services/web/server/src/simcore_service_webserver/notifications/_rabbitmq_consumers.py +++ b/services/web/server/src/simcore_service_webserver/notifications/_rabbitmq_consumers.py @@ -13,6 +13,7 @@ ProgressType, WalletCreditsMessage, ) +from models_library.socketio import SocketMessageDict from pydantic import parse_raw_as from servicelib.aiohttp.monitor_services import ( MONITOR_SERVICE_STARTED_LABELS, @@ -34,7 +35,6 @@ SOCKET_IO_NODE_UPDATED_EVENT, SOCKET_IO_PROJECT_PROGRESS_EVENT, SOCKET_IO_WALLET_OSPARC_CREDITS_UPDATED_EVENT, - SocketMessageDict, send_group_messages, send_messages, ) diff --git a/services/web/server/src/simcore_service_webserver/payments/_socketio.py b/services/web/server/src/simcore_service_webserver/payments/_socketio.py index faa4f9a2dda..c2a4a68ebaa 100644 --- a/services/web/server/src/simcore_service_webserver/payments/_socketio.py +++ b/services/web/server/src/simcore_service_webserver/payments/_socketio.py @@ -1,17 +1,17 @@ from aiohttp import web +from models_library.api_schemas_payments.socketio import ( + SOCKET_IO_PAYMENT_COMPLETED_EVENT, + SOCKET_IO_PAYMENT_METHOD_ACKED_EVENT, +) from models_library.api_schemas_webserver.wallets import ( PaymentMethodTransaction, PaymentTransaction, ) +from models_library.socketio import SocketMessageDict from models_library.users import UserID from models_library.utils.fastapi_encoders import jsonable_encoder -from ..socketio.messages import ( - SOCKET_IO_PAYMENT_COMPLETED_EVENT, - SOCKET_IO_PAYMENT_METHOD_ACKED_EVENT, - SocketMessageDict, - send_messages, -) +from ..socketio.messages import send_messages async def notify_payment_completed( diff --git a/services/web/server/src/simcore_service_webserver/projects/projects_api.py b/services/web/server/src/simcore_service_webserver/projects/projects_api.py index e3884eb26ed..98ff556a0ab 100644 --- a/services/web/server/src/simcore_service_webserver/projects/projects_api.py +++ b/services/web/server/src/simcore_service_webserver/projects/projects_api.py @@ -42,6 +42,7 @@ ) from models_library.services import ServiceKey, ServiceVersion from models_library.services_resources import ServiceResourcesDict +from models_library.socketio import SocketMessageDict from models_library.users import UserID from models_library.utils.fastapi_encoders import jsonable_encoder from models_library.wallets import ZERO_CREDITS, WalletID, WalletInfo @@ -77,7 +78,6 @@ from ..socketio.messages import ( SOCKET_IO_NODE_UPDATED_EVENT, SOCKET_IO_PROJECT_UPDATED_EVENT, - SocketMessageDict, send_group_messages, send_messages, ) diff --git a/services/web/server/src/simcore_service_webserver/socketio/_handlers.py b/services/web/server/src/simcore_service_webserver/socketio/_handlers.py index ef8d6b563f2..ad1eacca7ec 100644 --- a/services/web/server/src/simcore_service_webserver/socketio/_handlers.py +++ b/services/web/server/src/simcore_service_webserver/socketio/_handlers.py @@ -8,6 +8,7 @@ from typing import Any from aiohttp import web +from models_library.socketio import SocketMessageDict from models_library.users import UserID from servicelib.aiohttp.observer import emit from servicelib.logging_utils import get_log_record_extra, log_context @@ -18,7 +19,7 @@ from ..login.decorators import login_required from ..resource_manager.user_sessions import managed_resource from ._utils import EnvironDict, SocketID, get_socket_server, register_socketio_handler -from .messages import SOCKET_IO_HEARTBEAT_EVENT, SocketMessageDict, send_messages +from .messages import SOCKET_IO_HEARTBEAT_EVENT, send_messages _logger = logging.getLogger(__name__) diff --git a/services/web/server/src/simcore_service_webserver/socketio/messages.py b/services/web/server/src/simcore_service_webserver/socketio/messages.py index 23cb8898238..f76d4545b69 100644 --- a/services/web/server/src/simcore_service_webserver/socketio/messages.py +++ b/services/web/server/src/simcore_service_webserver/socketio/messages.py @@ -4,9 +4,10 @@ import logging from collections.abc import Sequence -from typing import Any, Final, TypedDict +from typing import Final from aiohttp.web import Application +from models_library.socketio import SocketMessageDict from models_library.users import UserID from servicelib.aiohttp.application_keys import APP_FIRE_AND_FORGET_TASKS_KEY from servicelib.json_serialization import json_dumps @@ -27,18 +28,11 @@ SOCKET_IO_LOG_EVENT: Final[str] = "logger" SOCKET_IO_NODE_PROGRESS_EVENT: Final[str] = "nodeProgress" SOCKET_IO_NODE_UPDATED_EVENT: Final[str] = "nodeUpdated" -SOCKET_IO_PAYMENT_COMPLETED_EVENT: Final[str] = "paymentCompleted" -SOCKET_IO_PAYMENT_METHOD_ACKED_EVENT: Final[str] = "paymentMethodAcknoledged" SOCKET_IO_PROJECT_PROGRESS_EVENT: Final[str] = "projectProgress" SOCKET_IO_PROJECT_UPDATED_EVENT: Final[str] = "projectStateUpdated" SOCKET_IO_WALLET_OSPARC_CREDITS_UPDATED_EVENT: Final[str] = "walletOsparcCreditsUpdated" -class SocketMessageDict(TypedDict): - event_type: str - data: dict[str, Any] - - async def send_messages( app: Application, user_id: UserID, messages: Sequence[SocketMessageDict] ) -> None: diff --git a/services/web/server/src/simcore_service_webserver/wallets/_payments_handlers.py b/services/web/server/src/simcore_service_webserver/wallets/_payments_handlers.py index 0efde369a95..267574bb281 100644 --- a/services/web/server/src/simcore_service_webserver/wallets/_payments_handlers.py +++ b/services/web/server/src/simcore_service_webserver/wallets/_payments_handlers.py @@ -1,3 +1,4 @@ +import asyncio import logging from aiohttp import web @@ -342,8 +343,15 @@ async def _pay_with_payment_method(request: web.Request): # we decided not to change the return value to avoid changing the front-end logic # instead we emulate a init-prompt-ack workflow by firing a background task that acks payment + async def _notify_payment_completed_after_response(app, user_id, payment): + # A small delay notify after response + await asyncio.sleep(1) + return ( + await notify_payment_completed(app, user_id=user_id, payment=payment), + ) + fire_and_forget_task( - notify_payment_completed( + _notify_payment_completed_after_response( request.app, user_id=req_ctx.user_id, payment=payment ), task_suffix_name=f"{__name__}._pay_with_payment_method", From a0995e241a71ce066e5d1b7b719e6c37916dea29 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero Date: Sun, 26 Nov 2023 12:06:17 +0100 Subject: [PATCH 07/23] WIP --- .../services/socketio.py | 48 ++------------ .../tests/unit/test_services_socketio.py | 64 ++++++++++++++----- .../wallets/_payments_handlers.py | 4 +- 3 files changed, 56 insertions(+), 60 deletions(-) diff --git a/services/payments/src/simcore_service_payments/services/socketio.py b/services/payments/src/simcore_service_payments/services/socketio.py index e5fd26239dc..905865f291f 100644 --- a/services/payments/src/simcore_service_payments/services/socketio.py +++ b/services/payments/src/simcore_service_payments/services/socketio.py @@ -1,5 +1,4 @@ import logging -from collections.abc import Sequence import socketio from fastapi import FastAPI @@ -8,10 +7,7 @@ SOCKET_IO_PAYMENT_COMPLETED_EVENT, ) from models_library.api_schemas_webserver.wallets import PaymentTransaction -from models_library.socketio import SocketMessageDict -from models_library.users import UserID -from servicelib.json_serialization import json_dumps -from servicelib.utils import logged_gather +from models_library.users import GroupID from settings_library.rabbit import RabbitSettings from .rabbitmq import get_rabbitmq_settings @@ -41,48 +37,18 @@ async def _on_shutdown() -> None: app.add_event_handler("shutdown", _on_shutdown) -async def emit_to_frontend( - app: FastAPI, event_name: str, data: dict, to: str | None = None -): - - # Send messages to clients from external processes, such as Celery workers or auxiliary scripts. - return await app.state.external_sio.emit(event_name, data=data, to=to) - - async def notify_payment_completed( app: FastAPI, *, - user_id: UserID, + user_primary_group_id: GroupID, payment: PaymentTransaction, ): assert payment.completed_at is not None # nosec - messages: list[SocketMessageDict] = [ - { - "event_type": SOCKET_IO_PAYMENT_COMPLETED_EVENT, - "data": jsonable_encoder(payment, by_alias=True), - } - ] - await send_messages(app, user_id, messages) - - -async def send_messages( - app: FastAPI, user_id: UserID, messages: Sequence[SocketMessageDict] -) -> None: - - sio = app.state.external_sio - - socket_ids: list[str] = [] - # with managed_resource(user_id, None, app) as user_session: - # socket_ids = await user_session.find_socket_ids() + external_sio: socketio.AsyncAioPikaManager = app.state.external_sio - await logged_gather( - *( - sio.emit(message["event_type"], data=json_dumps(message["data"]), room=sid) - for message in messages - for sid in socket_ids - ), - reraise=False, - log=_logger, - max_concurrency=100, + return await external_sio.emit( + SOCKET_IO_PAYMENT_COMPLETED_EVENT, + data=jsonable_encoder(payment, by_alias=True), + room=user_primary_group_id, ) diff --git a/services/payments/tests/unit/test_services_socketio.py b/services/payments/tests/unit/test_services_socketio.py index 8d105cf1636..4aabda838fe 100644 --- a/services/payments/tests/unit/test_services_socketio.py +++ b/services/payments/tests/unit/test_services_socketio.py @@ -6,7 +6,7 @@ import asyncio from collections.abc import Callable -from typing import Any +from typing import Any, Awaitable from unittest.mock import AsyncMock import pytest @@ -14,14 +14,15 @@ from aiohttp import web from aiohttp.test_utils import TestServer from fastapi import FastAPI +from models_library.api_schemas_payments.socketio import ( + SOCKET_IO_PAYMENT_COMPLETED_EVENT, +) +from pytest_mock import MockerFixture from pytest_simcore.helpers.typing_env import EnvVarsDict from pytest_simcore.helpers.utils_envs import setenvs_from_dict from settings_library.rabbit import RabbitSettings -from simcore_service_payments.services.socketio import ( - SOCKET_IO_PAYMENT_COMPLETED_EVENT, - emit_to_frontend, - get_rabbitmq_settings, -) +from simcore_service_payments.services.rabbitmq import get_rabbitmq_settings +from simcore_service_payments.services.socketio import notify_payment_completed from socketio import AsyncAioPikaManager, AsyncServer pytest_simcore_core_services_selection = [ @@ -55,43 +56,69 @@ async def test_socketio_setup(): @pytest.fixture -async def socketio_server(app: FastAPI, aiohttp_server: Callable) -> TestServer: +async def socketio_server(app: FastAPI) -> AsyncServer: """ this emulates the webserver setup: socketio server with an aiopika manager that attaches an aiohttp web app """ - aiohttp_app = web.Application() - - # Emulates simcore_service_webserver/socketio/server.py + # Same configuration as simcore_service_webserver/socketio/server.py settings: RabbitSettings = get_rabbitmq_settings(app) server_manager = AsyncAioPikaManager(url=settings.dsn) + sio_server = AsyncServer( async_mode="aiohttp", engineio_logger=True, client_manager=server_manager, ) + return sio_server + - @sio_server.event +def socketio_events( + sio_server: AsyncServer, mocker: MockerFixture +) -> dict[str, AsyncMock]: + + # handlers & spies async def connect(sid: str, environ): print("connecting", sid) - @sio_server.on(SOCKET_IO_PAYMENT_COMPLETED_EVENT) + spy_connect = mocker.AsyncMock(wraps=connect) + sio_server.event()(spy_connect) + async def on_payment(sid, data): print(sid, Any) - @sio_server.event + spy_on_payment = mocker.AsyncMock(wraps=on_payment) + sio_server.on(SOCKET_IO_PAYMENT_COMPLETED_EVENT, spy_on_payment) + async def disconnect(sid: str): print("disconnecting", sid) - sio_server.attach(aiohttp_app) + spy_disconnect = mocker.AsyncMock(wraps=disconnect) + sio_server.event()(spy_disconnect) + + return { + connect.__name__: spy_on_payment, + on_payment.__name__: spy_on_payment, + disconnect.__name__: spy_disconnect, + } + + +@pytest.fixture() +async def web_server(socketio_server: AsyncServer, aiohttp_server: Callable): + aiohttp_app = web.Application() + socketio_server.attach(aiohttp_app) # starts server return await aiohttp_server(aiohttp_app) @pytest.fixture -async def create_sio_client(socketio_server: TestServer): - server_url = socketio_server.make_url("/") +async def server_url(web_server: TestServer) -> str: + return f'{web_server.make_url("/")}' + + +@pytest.fixture +async def create_sio_client(server_url: str): _clients = [] async def _(): @@ -115,7 +142,7 @@ async def _(): async def test_emit_message_as_external_process_to_frontend_client( - app: FastAPI, create_sio_client: Callable + app: FastAPI, create_sio_client: Callable[..., Awaitable[socketio.AsyncClient]] ): """ front-end -> socketio client (many different clients) @@ -136,6 +163,9 @@ async def on_event(data): # TODO: better to do this from a different process?? # emit from external process + + await notify_payment_completed() + await emit_to_frontend( app, event_name=SOCKET_IO_PAYMENT_COMPLETED_EVENT, diff --git a/services/web/server/src/simcore_service_webserver/wallets/_payments_handlers.py b/services/web/server/src/simcore_service_webserver/wallets/_payments_handlers.py index 267574bb281..554b7f2f509 100644 --- a/services/web/server/src/simcore_service_webserver/wallets/_payments_handlers.py +++ b/services/web/server/src/simcore_service_webserver/wallets/_payments_handlers.py @@ -344,8 +344,8 @@ async def _pay_with_payment_method(request: web.Request): # instead we emulate a init-prompt-ack workflow by firing a background task that acks payment async def _notify_payment_completed_after_response(app, user_id, payment): - # A small delay notify after response - await asyncio.sleep(1) + # A small delay to send notification just after the response + await asyncio.sleep(0.5) return ( await notify_payment_completed(app, user_id=user_id, payment=payment), ) From fc2e249706683b5a5f5aad8676261fceebc86cb7 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Sun, 26 Nov 2023 13:19:24 +0100 Subject: [PATCH 08/23] socketio utils --- .../src/servicelib/socketio_utils.py | 43 +++++++++++++++++++ .../services/socketio.py | 9 +++- .../socketio/server.py | 33 ++------------ 3 files changed, 54 insertions(+), 31 deletions(-) create mode 100644 packages/service-library/src/servicelib/socketio_utils.py diff --git a/packages/service-library/src/servicelib/socketio_utils.py b/packages/service-library/src/servicelib/socketio_utils.py new file mode 100644 index 00000000000..efc63436715 --- /dev/null +++ b/packages/service-library/src/servicelib/socketio_utils.py @@ -0,0 +1,43 @@ +""" Common utilities for python-socketio library + + +NOTE: we intentionally avoided importing socketio here to avoid adding an extra dependency at +this level which would include python-socketio in all libraries +""" + +import asyncio + + +async def cleanup_socketio_async_pubsub_manager(server_manager): + + # NOTE: this is ugly. It seems though that python-socketio does not + # cleanup its background tasks properly. + # https://github.com/miguelgrinberg/python-socketio/discussions/1092 + cancelled_tasks = [] + + if hasattr(server_manager, "thread"): + server_thread = server_manager.thread + assert isinstance(server_thread, asyncio.Task) # nosec + server_thread.cancel() + cancelled_tasks.append(server_thread) + + if server_manager.publisher_channel: + await server_manager.publisher_channel.close() + + if server_manager.publisher_connection: + await server_manager.publisher_connection.close() + + current_tasks = asyncio.tasks.all_tasks() + for task in current_tasks: + coro = task.get_coro() + if any( + coro_name in coro.__qualname__ # type: ignore + for coro_name in [ + "AsyncServer._service_task", + "AsyncSocket.schedule_ping", + "AsyncPubSubManager._thread", + ] + ): + task.cancel() + cancelled_tasks.append(task) + await asyncio.gather(*cancelled_tasks, return_exceptions=True) diff --git a/services/payments/src/simcore_service_payments/services/socketio.py b/services/payments/src/simcore_service_payments/services/socketio.py index 905865f291f..409a938193e 100644 --- a/services/payments/src/simcore_service_payments/services/socketio.py +++ b/services/payments/src/simcore_service_payments/services/socketio.py @@ -8,6 +8,7 @@ ) from models_library.api_schemas_webserver.wallets import PaymentTransaction from models_library.users import GroupID +from servicelib.socketio_utils import cleanup_socketio_async_pubsub_manager from settings_library.rabbit import RabbitSettings from .rabbitmq import get_rabbitmq_settings @@ -31,7 +32,10 @@ async def _on_startup() -> None: ) async def _on_shutdown() -> None: - ... + if app.state.external_sio: + await cleanup_socketio_async_pubsub_manager( + server_manager=app.state.external_sio + ) app.add_event_handler("startup", _on_startup) app.add_event_handler("shutdown", _on_shutdown) @@ -43,6 +47,9 @@ async def notify_payment_completed( user_primary_group_id: GroupID, payment: PaymentTransaction, ): + # We assume that the user has been added to all + # rooms associated to his groups + # assert payment.completed_at is not None # nosec external_sio: socketio.AsyncAioPikaManager = app.state.external_sio diff --git a/services/web/server/src/simcore_service_webserver/socketio/server.py b/services/web/server/src/simcore_service_webserver/socketio/server.py index 693f95d404b..7d9c9b42031 100644 --- a/services/web/server/src/simcore_service_webserver/socketio/server.py +++ b/services/web/server/src/simcore_service_webserver/socketio/server.py @@ -1,8 +1,8 @@ -import asyncio import logging from typing import AsyncIterator from aiohttp import web +from servicelib.socketio_utils import cleanup_socketio_async_pubsub_manager from socketio import AsyncAioPikaManager, AsyncServer from ..rabbitmq_settings import get_plugin_settings as get_rabbitmq_settings @@ -32,37 +32,10 @@ async def _socketio_server_cleanup_ctx(app: web.Application) -> AsyncIterator[No app[APP_CLIENT_SOCKET_SERVER_KEY] = sio_server register_socketio_handlers(app, _handlers) - yield - - # NOTE: this is ugly. It seems though that python-socketio does not - # cleanup its background tasks properly. - # https://github.com/miguelgrinberg/python-socketio/discussions/1092 - cancelled_tasks = [] - if hasattr(server_manager, "thread"): - server_thread = server_manager.thread - assert isinstance(server_thread, asyncio.Task) # nosec - server_thread.cancel() - cancelled_tasks.append(server_thread) - if server_manager.publisher_channel: - await server_manager.publisher_channel.close() - if server_manager.publisher_connection: - await server_manager.publisher_connection.close() - current_tasks = asyncio.tasks.all_tasks() + yield - for task in current_tasks: - coro = task.get_coro() - if any( - coro_name in coro.__qualname__ # type: ignore - for coro_name in [ - "AsyncServer._service_task", - "AsyncSocket.schedule_ping", - "AsyncPubSubManager._thread", - ] - ): - task.cancel() - cancelled_tasks.append(task) - await asyncio.gather(*cancelled_tasks, return_exceptions=True) + await cleanup_socketio_async_pubsub_manager(server_manager) def setup_socketio_server(app: web.Application) -> None: From d563721fb77a603a2c13d508e91465665e581c53 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Mon, 27 Nov 2023 15:06:07 +0100 Subject: [PATCH 09/23] cleanup test --- .../tests/unit/test_services_socketio.py | 162 +++++++++++------- 1 file changed, 99 insertions(+), 63 deletions(-) diff --git a/services/payments/tests/unit/test_services_socketio.py b/services/payments/tests/unit/test_services_socketio.py index 4aabda838fe..125762ef9a1 100644 --- a/services/payments/tests/unit/test_services_socketio.py +++ b/services/payments/tests/unit/test_services_socketio.py @@ -6,24 +6,33 @@ import asyncio from collections.abc import Callable -from typing import Any, Awaitable +from typing import Any, AsyncIterable from unittest.mock import AsyncMock import pytest import socketio from aiohttp import web from aiohttp.test_utils import TestServer +from faker import Faker from fastapi import FastAPI from models_library.api_schemas_payments.socketio import ( SOCKET_IO_PAYMENT_COMPLETED_EVENT, ) +from models_library.users import GroupID +from pydantic import parse_obj_as from pytest_mock import MockerFixture +from pytest_simcore.helpers.rawdata_fakers import random_payment_transaction from pytest_simcore.helpers.typing_env import EnvVarsDict from pytest_simcore.helpers.utils_envs import setenvs_from_dict +from servicelib.socketio_utils import cleanup_socketio_async_pubsub_manager from settings_library.rabbit import RabbitSettings +from simcore_service_payments.models.db import PaymentsTransactionsDB from simcore_service_payments.services.rabbitmq import get_rabbitmq_settings from simcore_service_payments.services.socketio import notify_payment_completed from socketio import AsyncAioPikaManager, AsyncServer +from tenacity import AsyncRetrying +from tenacity.stop import stop_after_attempt +from tenacity.wait import wait_fixed pytest_simcore_core_services_selection = [ "rabbit", @@ -50,61 +59,80 @@ def app_environment( ) -async def test_socketio_setup(): - # is this closing properly? - ... +@pytest.fixture +def user_primary_group_id(faker: Faker) -> GroupID: + return parse_obj_as(GroupID, faker.pyint()) @pytest.fixture -async def socketio_server(app: FastAPI) -> AsyncServer: - """ - this emulates the webserver setup: socketio server with - an aiopika manager that attaches an aiohttp web app - """ +async def socketio_server(app: FastAPI) -> AsyncIterable[AsyncServer]: # Same configuration as simcore_service_webserver/socketio/server.py settings: RabbitSettings = get_rabbitmq_settings(app) server_manager = AsyncAioPikaManager(url=settings.dsn) - sio_server = AsyncServer( + server = AsyncServer( async_mode="aiohttp", engineio_logger=True, client_manager=server_manager, ) - return sio_server + yield server + + await cleanup_socketio_async_pubsub_manager(server_manager) + await server.shutdown() -def socketio_events( - sio_server: AsyncServer, mocker: MockerFixture + +@pytest.fixture +def socketio_server_events( + socketio_server: AsyncServer, + mocker: MockerFixture, + user_primary_group_id: GroupID, ) -> dict[str, AsyncMock]: - # handlers & spies + user_room_name = f"{user_primary_group_id}" + + # handlers async def connect(sid: str, environ): print("connecting", sid) + await socketio_server.enter_room(sid, user_room_name) - spy_connect = mocker.AsyncMock(wraps=connect) - sio_server.event()(spy_connect) + async def on_check(sid, data): + print("check", sid, Any) async def on_payment(sid, data): - print(sid, Any) - - spy_on_payment = mocker.AsyncMock(wraps=on_payment) - sio_server.on(SOCKET_IO_PAYMENT_COMPLETED_EVENT, spy_on_payment) + print("payment", sid, Any) async def disconnect(sid: str): print("disconnecting", sid) + await socketio_server.leave_room(sid, user_room_name) + + # spies + spy_connect = mocker.AsyncMock(wraps=connect) + socketio_server.on("connect", spy_connect) + + spy_on_payment = mocker.AsyncMock(wraps=on_payment) + socketio_server.on(SOCKET_IO_PAYMENT_COMPLETED_EVENT, spy_on_payment) + + spy_on_check = mocker.AsyncMock(wraps=on_check) + socketio_server.on("check", spy_on_check) spy_disconnect = mocker.AsyncMock(wraps=disconnect) - sio_server.event()(spy_disconnect) + socketio_server.on("disconnect", spy_disconnect) return { connect.__name__: spy_on_payment, - on_payment.__name__: spy_on_payment, disconnect.__name__: spy_disconnect, + on_check.__name__: spy_on_check, + on_payment.__name__: spy_on_payment, } -@pytest.fixture() +@pytest.fixture async def web_server(socketio_server: AsyncServer, aiohttp_server: Callable): + """ + this emulates the webserver setup: socketio server with + an aiopika manager that attaches an aiohttp web app + """ aiohttp_app = web.Application() socketio_server.attach(aiohttp_app) @@ -118,31 +146,47 @@ async def server_url(web_server: TestServer) -> str: @pytest.fixture -async def create_sio_client(server_url: str): - _clients = [] +async def socketio_client(server_url: str) -> AsyncIterable[socketio.AsyncClient]: + client = socketio.AsyncClient(logger=True, engineio_logger=True) + await client.connect(f"{server_url}", transports=["websocket"]) - async def _(): - cli = socketio.AsyncClient( - logger=True, - engineio_logger=True, - ) + yield client + + await client.disconnect() - # https://python-socketio.readthedocs.io/en/stable/client.html#connecting-to-a-server - # Allows WebSocket transport and disconnect HTTP long-polling - await cli.connect(f"{server_url}", transports=["websocket"]) - _clients.append(cli) +@pytest.fixture +async def socketio_client_events( + socketio_client: socketio.AsyncClient, +) -> dict[str, AsyncMock]: + # emulates front-end receiving message + async def on_event(data): + print("client1", data) - return cli + on_event_spy = AsyncMock(wraps=on_event) + socketio_client.on(SOCKET_IO_PAYMENT_COMPLETED_EVENT, on_event_spy) - yield _ + return {on_event.__name__: on_event_spy} - for client in _clients: - await client.disconnect() + +@pytest.fixture +async def notify_payment(app: FastAPI, user_primary_group_id: GroupID) -> Callable: + async def _(): + payment = PaymentsTransactionsDB(**random_payment_transaction()).to_api_model() + await notify_payment_completed( + app, user_primary_group_id=user_primary_group_id, payment=payment + ) + + return _ async def test_emit_message_as_external_process_to_frontend_client( - app: FastAPI, create_sio_client: Callable[..., Awaitable[socketio.AsyncClient]] + app: FastAPI, + web_server: socketio.AsyncServer, + socketio_server_events: dict[str, AsyncMock], + socketio_client: socketio.AsyncClient, + socketio_client_events: dict[str, AsyncMock], + notify_payment: Callable, ): """ front-end -> socketio client (many different clients) @@ -150,32 +194,24 @@ async def test_emit_message_as_external_process_to_frontend_client( payments -> Sends messages to clients from external processes (one/more replicas) """ - # emulates front-end receiving message - client_1: socketio.AsyncClient = await create_sio_client() + # web server events + connect_spy = socketio_server_events["connect"] + on_check_spy = socketio_server_events["on_check"] - @client_1.on(SOCKET_IO_PAYMENT_COMPLETED_EVENT) - async def on_event(data): - print("client1", data) + assert connect_spy.called + assert not on_check_spy.called - on_event_spy = AsyncMock(wraps=on_event) - - await client_1.emit(SOCKET_IO_PAYMENT_COMPLETED_EVENT, data="hoi1") - - # TODO: better to do this from a different process?? - # emit from external process - - await notify_payment_completed() - - await emit_to_frontend( - app, - event_name=SOCKET_IO_PAYMENT_COMPLETED_EVENT, - data={"foo": "bar"}, - # to=client_1.sid, - ) + await socketio_client.emit("check", data="hoi") + assert on_check_spy.called - await client_1.emit(SOCKET_IO_PAYMENT_COMPLETED_EVENT, data="hoi2") + # client events + await notify_payment() - await client_1.sleep(1) - await asyncio.sleep(1) + on_event_spy = socketio_client_events["on_event"] - on_event_spy.assert_called() + async for attempt in AsyncRetrying( + wait=wait_fixed(2), stop=stop_after_attempt(5), reraise=True + ): + with attempt: + await asyncio.sleep(0.1) + on_event_spy.assert_called() From 2a1ef783d38f3b75a92616eb46064dea5c71e12a Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Mon, 27 Nov 2023 20:41:12 +0100 Subject: [PATCH 10/23] updates tests --- .../tests/unit/test_services_socketio.py | 74 ++++++++++--------- 1 file changed, 38 insertions(+), 36 deletions(-) diff --git a/services/payments/tests/unit/test_services_socketio.py b/services/payments/tests/unit/test_services_socketio.py index 125762ef9a1..aafd862fb2e 100644 --- a/services/payments/tests/unit/test_services_socketio.py +++ b/services/payments/tests/unit/test_services_socketio.py @@ -5,10 +5,10 @@ import asyncio -from collections.abc import Callable -from typing import Any, AsyncIterable +from collections.abc import AsyncIterable, Callable from unittest.mock import AsyncMock +import arrow import pytest import socketio from aiohttp import web @@ -18,6 +18,7 @@ from models_library.api_schemas_payments.socketio import ( SOCKET_IO_PAYMENT_COMPLETED_EVENT, ) +from models_library.api_schemas_webserver.wallets import PaymentTransaction from models_library.users import GroupID from pydantic import parse_obj_as from pytest_mock import MockerFixture @@ -30,7 +31,6 @@ from simcore_service_payments.services.rabbitmq import get_rabbitmq_settings from simcore_service_payments.services.socketio import notify_payment_completed from socketio import AsyncAioPikaManager, AsyncServer -from tenacity import AsyncRetrying from tenacity.stop import stop_after_attempt from tenacity.wait import wait_fixed @@ -79,7 +79,6 @@ async def socketio_server(app: FastAPI) -> AsyncIterable[AsyncServer]: yield server await cleanup_socketio_async_pubsub_manager(server_manager) - await server.shutdown() @pytest.fixture @@ -97,30 +96,30 @@ async def connect(sid: str, environ): await socketio_server.enter_room(sid, user_room_name) async def on_check(sid, data): - print("check", sid, Any) + print("check", sid, data) async def on_payment(sid, data): - print("payment", sid, Any) + print("payment", sid, parse_obj_as(PaymentTransaction, data)) async def disconnect(sid: str): print("disconnecting", sid) await socketio_server.leave_room(sid, user_room_name) # spies + socketio_server.on("connect", connect) spy_connect = mocker.AsyncMock(wraps=connect) - socketio_server.on("connect", spy_connect) + socketio_server.on(SOCKET_IO_PAYMENT_COMPLETED_EVENT, on_payment) spy_on_payment = mocker.AsyncMock(wraps=on_payment) - socketio_server.on(SOCKET_IO_PAYMENT_COMPLETED_EVENT, spy_on_payment) + socketio_server.on("check", on_check) spy_on_check = mocker.AsyncMock(wraps=on_check) - socketio_server.on("check", spy_on_check) + socketio_server.on("disconnect", disconnect) spy_disconnect = mocker.AsyncMock(wraps=disconnect) - socketio_server.on("disconnect", spy_disconnect) return { - connect.__name__: spy_on_payment, + connect.__name__: spy_connect, disconnect.__name__: spy_disconnect, on_check.__name__: spy_on_check, on_payment.__name__: spy_on_payment, @@ -149,6 +148,7 @@ async def server_url(web_server: TestServer) -> str: async def socketio_client(server_url: str) -> AsyncIterable[socketio.AsyncClient]: client = socketio.AsyncClient(logger=True, engineio_logger=True) await client.connect(f"{server_url}", transports=["websocket"]) + await client.emit("check", data="hoi") yield client @@ -160,19 +160,22 @@ async def socketio_client_events( socketio_client: socketio.AsyncClient, ) -> dict[str, AsyncMock]: # emulates front-end receiving message - async def on_event(data): - print("client1", data) - on_event_spy = AsyncMock(wraps=on_event) - socketio_client.on(SOCKET_IO_PAYMENT_COMPLETED_EVENT, on_event_spy) + async def on_payment(data): + assert parse_obj_as(PaymentTransaction, data) is None - return {on_event.__name__: on_event_spy} + socketio_client.on(SOCKET_IO_PAYMENT_COMPLETED_EVENT, on_payment) + on_event_spy = AsyncMock(wraps=on_payment) + + return {on_payment.__name__: on_event_spy} @pytest.fixture async def notify_payment(app: FastAPI, user_primary_group_id: GroupID) -> Callable: async def _(): - payment = PaymentsTransactionsDB(**random_payment_transaction()).to_api_model() + payment = PaymentsTransactionsDB( + **random_payment_transaction(completed_at=arrow.utcnow().datetime) + ).to_api_model() await notify_payment_completed( app, user_primary_group_id=user_primary_group_id, payment=payment ) @@ -182,7 +185,7 @@ async def _(): async def test_emit_message_as_external_process_to_frontend_client( app: FastAPI, - web_server: socketio.AsyncServer, + socketio_server: socketio.AsyncServer, socketio_server_events: dict[str, AsyncMock], socketio_client: socketio.AsyncClient, socketio_client_events: dict[str, AsyncMock], @@ -193,25 +196,24 @@ async def test_emit_message_as_external_process_to_frontend_client( webserver -> socketio server (one/more replicas) payments -> Sends messages to clients from external processes (one/more replicas) """ + retry_kwargs = { + "wait": wait_fixed(2), + "stop": stop_after_attempt(5), + "reraise": True, + } - # web server events - connect_spy = socketio_server_events["connect"] - on_check_spy = socketio_server_events["on_check"] - - assert connect_spy.called - assert not on_check_spy.called - - await socketio_client.emit("check", data="hoi") - assert on_check_spy.called + # web server spy events + server_connect = socketio_server_events["connect"] + server_disconnect = socketio_server_events["disconnect"] + server_on_check = socketio_server_events["on_check"] + server_on_payment = socketio_server_events["on_payment"] - # client events - await notify_payment() + # client spy events + client_on_payment = socketio_client_events["on_payment"] - on_event_spy = socketio_client_events["on_event"] + await asyncio.gather( + socketio_client.emit("check", data="hoi"), + notify_payment(), + ) - async for attempt in AsyncRetrying( - wait=wait_fixed(2), stop=stop_after_attempt(5), reraise=True - ): - with attempt: - await asyncio.sleep(0.1) - on_event_spy.assert_called() + await asyncio.sleep(3) From ac4f59fe02d171ac567080daf08bec89b2d540ec Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Tue, 28 Nov 2023 18:15:36 +0100 Subject: [PATCH 11/23] fixes test --- .../services/socketio.py | 2 +- .../tests/unit/test_services_socketio.py | 85 ++++++++++++++----- 2 files changed, 65 insertions(+), 22 deletions(-) diff --git a/services/payments/src/simcore_service_payments/services/socketio.py b/services/payments/src/simcore_service_payments/services/socketio.py index 409a938193e..aad06ea0d07 100644 --- a/services/payments/src/simcore_service_payments/services/socketio.py +++ b/services/payments/src/simcore_service_payments/services/socketio.py @@ -57,5 +57,5 @@ async def notify_payment_completed( return await external_sio.emit( SOCKET_IO_PAYMENT_COMPLETED_EVENT, data=jsonable_encoder(payment, by_alias=True), - room=user_primary_group_id, + room=f"{user_primary_group_id}", ) diff --git a/services/payments/tests/unit/test_services_socketio.py b/services/payments/tests/unit/test_services_socketio.py index aafd862fb2e..3f32e1aa957 100644 --- a/services/payments/tests/unit/test_services_socketio.py +++ b/services/payments/tests/unit/test_services_socketio.py @@ -5,7 +5,9 @@ import asyncio +import threading from collections.abc import AsyncIterable, Callable +from typing import AsyncIterator from unittest.mock import AsyncMock import arrow @@ -31,8 +33,10 @@ from simcore_service_payments.services.rabbitmq import get_rabbitmq_settings from simcore_service_payments.services.socketio import notify_payment_completed from socketio import AsyncAioPikaManager, AsyncServer +from tenacity import AsyncRetrying from tenacity.stop import stop_after_attempt from tenacity.wait import wait_fixed +from yarl import URL pytest_simcore_core_services_selection = [ "rabbit", @@ -106,17 +110,17 @@ async def disconnect(sid: str): await socketio_server.leave_room(sid, user_room_name) # spies - socketio_server.on("connect", connect) spy_connect = mocker.AsyncMock(wraps=connect) + socketio_server.on("connect", spy_connect) - socketio_server.on(SOCKET_IO_PAYMENT_COMPLETED_EVENT, on_payment) spy_on_payment = mocker.AsyncMock(wraps=on_payment) + socketio_server.on(SOCKET_IO_PAYMENT_COMPLETED_EVENT, spy_on_payment) - socketio_server.on("check", on_check) spy_on_check = mocker.AsyncMock(wraps=on_check) + socketio_server.on("check", spy_on_check) - socketio_server.on("disconnect", disconnect) spy_disconnect = mocker.AsyncMock(wraps=disconnect) + socketio_server.on("disconnect", spy_disconnect) return { connect.__name__: spy_connect, @@ -127,7 +131,9 @@ async def disconnect(sid: str): @pytest.fixture -async def web_server(socketio_server: AsyncServer, aiohttp_server: Callable): +async def web_server( + socketio_server: AsyncServer, aiohttp_unused_port: Callable +) -> AsyncIterator[URL]: """ this emulates the webserver setup: socketio server with an aiopika manager that attaches an aiohttp web app @@ -135,20 +141,39 @@ async def web_server(socketio_server: AsyncServer, aiohttp_server: Callable): aiohttp_app = web.Application() socketio_server.attach(aiohttp_app) - # starts server - return await aiohttp_server(aiohttp_app) + async def _lifespan( + server: TestServer, started: asyncio.Event, teardown: asyncio.Event + ): + # NOTE: this is necessary to avoid blocking comms between client and this server + await server.start_server() + started.set() # notifies started + await teardown.wait() # keeps test0server until needs to close + await server.close() + + setup = asyncio.Event() + teardown = asyncio.Event() + + server = TestServer(aiohttp_app, port=aiohttp_unused_port()) + t = asyncio.create_task(_lifespan(server, setup, teardown), name="server-lifespan") + + await setup.wait() + + yield URL(server.make_url("/")) + + assert t + teardown.set() @pytest.fixture -async def server_url(web_server: TestServer) -> str: - return f'{web_server.make_url("/")}' +async def server_url(web_server: URL) -> str: + return f'{web_server.with_path("/")}' @pytest.fixture async def socketio_client(server_url: str) -> AsyncIterable[socketio.AsyncClient]: + """This emulates a socketio client in the front-end""" client = socketio.AsyncClient(logger=True, engineio_logger=True) await client.connect(f"{server_url}", transports=["websocket"]) - await client.emit("check", data="hoi") yield client @@ -162,10 +187,10 @@ async def socketio_client_events( # emulates front-end receiving message async def on_payment(data): - assert parse_obj_as(PaymentTransaction, data) is None + assert parse_obj_as(PaymentTransaction, data) is not None - socketio_client.on(SOCKET_IO_PAYMENT_COMPLETED_EVENT, on_payment) on_event_spy = AsyncMock(wraps=on_payment) + socketio_client.on(SOCKET_IO_PAYMENT_COMPLETED_EVENT, on_event_spy) return {on_payment.__name__: on_event_spy} @@ -184,8 +209,6 @@ async def _(): async def test_emit_message_as_external_process_to_frontend_client( - app: FastAPI, - socketio_server: socketio.AsyncServer, socketio_server_events: dict[str, AsyncMock], socketio_client: socketio.AsyncClient, socketio_client_events: dict[str, AsyncMock], @@ -196,8 +219,9 @@ async def test_emit_message_as_external_process_to_frontend_client( webserver -> socketio server (one/more replicas) payments -> Sends messages to clients from external processes (one/more replicas) """ - retry_kwargs = { - "wait": wait_fixed(2), + # Used iusntead of a fix asyncio.sleep + context_switch_retry_kwargs = { + "wait": wait_fixed(0.1), "stop": stop_after_attempt(5), "reraise": True, } @@ -211,9 +235,28 @@ async def test_emit_message_as_external_process_to_frontend_client( # client spy events client_on_payment = socketio_client_events["on_payment"] - await asyncio.gather( - socketio_client.emit("check", data="hoi"), - notify_payment(), - ) + # checks + assert server_connect.called + assert not server_disconnect.called + + # client emits + await socketio_client.emit("check", data="hoi") + + async for attempt in AsyncRetrying(**context_switch_retry_kwargs): + with attempt: + assert server_on_check.called + + # payment server emits + def _(lp): + asyncio.run_coroutine_threadsafe(notify_payment(), lp) + + threading.Thread( + target=_, + args=(asyncio.get_event_loop(),), + daemon=False, + ).start() - await asyncio.sleep(3) + async for attempt in AsyncRetrying(**context_switch_retry_kwargs): + with attempt: + assert client_on_payment.called + assert not server_on_payment.called From 9e11756a5579a21fa789554b5591c1fbace32786 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Tue, 28 Nov 2023 21:51:48 +0100 Subject: [PATCH 12/23] updates fake gateway --- .../gateway/example_payment_gateway.py | 199 +++++++++++------- 1 file changed, 128 insertions(+), 71 deletions(-) diff --git a/services/payments/gateway/example_payment_gateway.py b/services/payments/gateway/example_payment_gateway.py index e43df1d6596..b4dbef8df88 100644 --- a/services/payments/gateway/example_payment_gateway.py +++ b/services/payments/gateway/example_payment_gateway.py @@ -1,10 +1,18 @@ +# pylint: disable=protected-access +# pylint: disable=redefined-builtin +# pylint: disable=redefined-outer-name +# pylint: disable=too-many-arguments +# pylint: disable=unused-argument +# pylint: disable=unused-variable """ This is a simple example of a payments-gateway service - Mainly used to create the openapi specs (SEE `openapi.json`) that the payments service expects - Also used as a fake payment-gateway for manual exploratory testing """ + import argparse +import datetime import json import logging import types @@ -12,20 +20,11 @@ from dataclasses import dataclass from pathlib import Path from typing import Annotated, Any, cast -from uuid import UUID, uuid4 +from uuid import uuid4 import httpx import uvicorn -from fastapi import ( - APIRouter, - Depends, - FastAPI, - Form, - Header, - HTTPException, - Request, - status, -) +from fastapi import APIRouter, Depends, FastAPI, Form, Header, Request, status from fastapi.encoders import jsonable_encoder from fastapi.responses import HTMLResponse from fastapi.routing import APIRoute @@ -47,6 +46,7 @@ ) from simcore_service_payments.models.schemas.acknowledgements import ( AckPayment, + AckPaymentMethod, AckPaymentWithPaymentMethod, ) from simcore_service_payments.models.schemas.auth import Token @@ -64,7 +64,7 @@ class Settings(BaseCustomSettings): PAYMENTS_PASSWORD: SecretStr = "replace-with-password" -def set_operation_id_as_handler_function_name(router: APIRouter): +def _set_operation_id_as_handler_function_name(router: APIRouter): for route in router.routes: if isinstance(route, APIRoute): assert isinstance(route.endpoint, types.FunctionType) # nosec @@ -76,7 +76,7 @@ def set_operation_id_as_handler_function_name(router: APIRouter): "4XX": {"content": {"text/html": {"schema": {"type": "string"}}}} } -PAYMENT_HTML = """ +FORM_HTML = """ @@ -84,7 +84,7 @@ def set_operation_id_as_handler_function_name(router: APIRouter):

Enter Credit Card Information

-
+

@@ -101,12 +101,24 @@ def set_operation_id_as_handler_function_name(router: APIRouter):

- +
""" +ERROR_HTTP = """ + + + + Error {0} + + +

{0}

+ + +""" + @dataclass class PaymentForm: @@ -145,19 +157,45 @@ def auth_flow(self, request): yield request +async def ack_payment(id_: PaymentID, acked: AckPayment, settings: Settings): + async with httpx.AsyncClient() as client: + await client.post( + f"{settings.PAYMENTS_SERVICE_API_BASE_URL}/v1/payments/{id_}:ack", + json=acked.dict(), + auth=PaymentsAuth( + username=settings.PAYMENTS_USERNAME, + password=settings.PAYMENTS_PASSWORD.get_secret_value(), + ), + ) + + +async def ack_payment_method( + id_: PaymentMethodID, acked: AckPaymentMethod, settings: Settings +): + async with httpx.AsyncClient() as client: + await client.post( + f"{settings.PAYMENTS_SERVICE_API_BASE_URL}/v1/payments-methods/{id_}:ack", + json=acked.dict(), + auth=PaymentsAuth( + username=settings.PAYMENTS_USERNAME, + password=settings.PAYMENTS_PASSWORD.get_secret_value(), + ), + ) + + # # Dependencies # -def get_payments(request: Request) -> dict[str, Any]: - return request.app.state.payments - - def get_settings(request: Request) -> Settings: return cast(Settings, request.app.state.settings) +def auth_session(x_init_api_secret: Annotated[str | None, Header()] = None) -> int: + return 1 if x_init_api_secret is not None else 0 + + # # Router factories # @@ -176,34 +214,27 @@ def create_payment_router(): response_model=PaymentInitiated, responses=ERROR_RESPONSES, ) - def init_payment( + async def _init_payment( payment: InitPayment, auth: Annotated[int, Depends(auth_session)], - all_payments: Annotated[dict[UUID, Any], Depends(get_payments)], ): assert payment # nosec assert auth # nosec - payment_id = uuid4() - all_payments[payment_id] = {"init": InitPayment} - - return PaymentInitiated(payment_id=payment_id) + id_ = f"{uuid4()}" + return PaymentInitiated(payment_id=id_) @router.get( "/pay", response_class=HTMLResponse, responses=ERROR_HTML_RESPONSES, ) - def get_payment_form( + async def _get_payment_form( id: PaymentID, - all_payments: Annotated[dict[UUID, Any], Depends(get_payments)], ): assert id # nosec - if id not in all_payments: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) - - return PAYMENT_HTML.format(f"{id}") + return FORM_HTML.format(f"/pay?id={id}", "Submit Payment") @router.post( "/pay", @@ -211,55 +242,37 @@ def get_payment_form( responses=ERROR_RESPONSES, include_in_schema=False, ) - def pay( + async def _pay( id: PaymentID, payment_form: Annotated[PaymentForm, Depends()], - all_payments: Annotated[dict[UUID, Any], Depends(get_payments)], settings: Annotated[Settings, Depends(get_settings)], ): - assert id # nosec - - if id not in all_payments: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) - - all_payments[id]["form"] = payment_form - - # request ACK - httpx.post( - f"{settings.PAYMENTS_SERVICE_API_BASE_URL}/v1/payments/{id}:ack", - json=AckPayment.Config.schema_extra["example"], # one-time success - auth=PaymentsAuth( - username=settings.PAYMENTS_USERNAME, - password=settings.PAYMENTS_PASSWORD.get_secret_value(), - ), + """WARNING: this is only for faking pay. DO NOT EXPOSE TO openapi.json""" + acked = AckPayment( + success=True, + message=f"Fake Payment {id}", + invoice_url="https://fakeimg.pl/300/", + saved=None, ) + await ack_payment(id_=id, acked=acked, settings=settings) @router.post( "/cancel", response_model=PaymentCancelled, responses=ERROR_RESPONSES, ) - def cancel_payment( + async def _cancel_payment( payment: PaymentInitiated, auth: Annotated[int, Depends(auth_session)], - all_payments: Annotated[dict[UUID, Any], Depends(get_payments)], ): assert payment # nosec assert auth # nosec - try: - all_payments[payment.payment_id] = "CANCELLED" - return PaymentCancelled(message="CANCELLED") - except KeyError as exc: - raise HTTPException(status.HTTP_404_NOT_FOUND) from exc + return PaymentCancelled(message=f"CANCELLED {payment.payment_id}") return router -def auth_session(x_init_api_secret: Annotated[str | None, Header()] = None) -> int: - return 1 if x_init_api_secret is not None else 0 - - def create_payment_method_router(): router = APIRouter( prefix="/payment-methods", @@ -274,20 +287,44 @@ def create_payment_method_router(): response_model=PaymentMethodInitiated, responses=ERROR_RESPONSES, ) - def init_payment_method( + async def _init_payment_method( payment_method: InitPaymentMethod, auth: Annotated[int, Depends(auth_session)], ): assert payment_method # nosec assert auth # nosec + id_ = f"{uuid4()}" + return PaymentMethodInitiated(payment_method_id=id_) + @router.get( "/form", response_class=HTMLResponse, responses=ERROR_HTML_RESPONSES, ) - def get_form_payment_method(id: PaymentMethodID): - assert id # nosec + async def _get_form_payment_method( + id: PaymentMethodID, + ): + return FORM_HTML.format(f"/save?id={id}", "Save Payment") + + @router.post( + "/save", + response_class=HTMLResponse, + responses=ERROR_RESPONSES, + include_in_schema=False, + ) + async def _save( + id: PaymentMethodID, + payment_form: Annotated[PaymentForm, Depends()], + settings: Annotated[Settings, Depends(get_settings)], + ): + """WARNING: this is only for faking save. DO NOT EXPOSE TO openapi.json""" + card_number_masked = f"**** **** **** {payment_form.card_number[-4:]}" + acked = AckPaymentMethod( + success=True, + message=f"Fake Payment-method saved {card_number_masked}", + ) + await ack_payment_method(id_=id, acked=acked, settings=settings) # CRUD payment-methods @router.post( @@ -295,12 +332,20 @@ def get_form_payment_method(id: PaymentMethodID): response_model=PaymentMethodsBatch, responses=ERROR_RESPONSES, ) - def batch_get_payment_methods( + async def _batch_get_payment_methods( batch: BatchGetPaymentMethods, auth: Annotated[int, Depends(auth_session)], ): assert auth # nosec assert batch # nosec + return PaymentMethodsBatch( + items=[ + GetPaymentMethod( + id=id_, created=datetime.datetime.now(tz=datetime.timezone.utc) + ) + for id_ in batch.payment_methods_ids + ] + ) @router.get( "/{id}", @@ -313,19 +358,23 @@ def batch_get_payment_methods( **ERROR_RESPONSES, }, ) - def get_payment_method( + async def _get_payment_method( id: PaymentMethodID, auth: Annotated[int, Depends(auth_session)], ): assert id # nosec assert auth # nosec + return GetPaymentMethod( + id=id, created=datetime.datetime.now(tz=datetime.timezone.utc) + ) + @router.delete( "/{id}", status_code=status.HTTP_204_NO_CONTENT, responses=ERROR_RESPONSES, ) - def delete_payment_method( + async def _delete_payment_method( id: PaymentMethodID, auth: Annotated[int, Depends(auth_session)], ): @@ -337,7 +386,7 @@ def delete_payment_method( response_model=AckPaymentWithPaymentMethod, responses=ERROR_RESPONSES, ) - def pay_with_payment_method( + async def _pay_with_payment_method( id: PaymentMethodID, payment: InitPayment, auth: Annotated[int, Depends(auth_session)], @@ -346,18 +395,26 @@ def pay_with_payment_method( assert payment # nosec assert auth # nosec + return AckPaymentWithPaymentMethod( + success=True, + invoice_url="https://fakeimg.pl/300/", + payment_id=f"{uuid4()}", + message=f"Payed with payment-method {id}", + ) + return router @asynccontextmanager async def _app_lifespan(app: FastAPI): - state_path = Path("app.state.payments.ignore.json") + state_path = Path("app.state.keep.ignore.json") + app.state.keep = {} if state_path.exists(): - app.state.payments = parse_file_as(dict[str, Any], state_path) + app.state.keep = parse_file_as(dict[str, Any], state_path) yield - state_path.write_text(json.dumps(jsonable_encoder(app.state.payments), indent=1)) + state_path.write_text(json.dumps(jsonable_encoder(app.state.keep), indent=1)) def create_app(): @@ -370,7 +427,7 @@ def create_app(): app.openapi_version = "3.0.0" # NOTE: small hack to allow current version of `42Crunch.vscode-openapi` to work with openapi override_fastapi_openapi_method(app) - app.state.payments = {} + app.state.keep = {} app.state.settings = Settings.create_from_envs() logging.info(app.state.settings.json(indent=2)) @@ -379,7 +436,7 @@ def create_app(): create_payment_method_router, ): router = factory() - set_operation_id_as_handler_function_name(router) + _set_operation_id_as_handler_function_name(router) app.include_router(router) return app From 721b6155255ed0a975c063b9a7995ef57fb169a8 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Tue, 28 Nov 2023 21:58:32 +0100 Subject: [PATCH 13/23] fixes patch --- services/payments/tests/unit/conftest.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/services/payments/tests/unit/conftest.py b/services/payments/tests/unit/conftest.py index 7f75753186c..729f9b93f7a 100644 --- a/services/payments/tests/unit/conftest.py +++ b/services/payments/tests/unit/conftest.py @@ -57,15 +57,16 @@ @pytest.fixture def disable_rabbitmq_and_rpc_setup(mocker: MockerFixture) -> Callable: - def _do(): + def _(): # The following services are affected if rabbitmq is not in place + mocker.patch("simcore_service_payments.core.application.setup_socketio") mocker.patch("simcore_service_payments.core.application.setup_rabbitmq") mocker.patch("simcore_service_payments.core.application.setup_rpc_api_routes") mocker.patch( "simcore_service_payments.core.application.setup_auto_recharge_listener" ) - return _do + return _ @pytest.fixture @@ -92,7 +93,7 @@ def _setup(app: FastAPI): Mock() ) # NOTE: avoids error in api._dependencies::get_db_engine - def _do(): + def _(): # The following services are affected if postgres is not in place mocker.patch( "simcore_service_payments.core.application.setup_postgres", @@ -100,7 +101,7 @@ def _do(): side_effect=_setup, ) - return _do + return _ @pytest.fixture From a30122a55a4c9a255051939bd1013ea7d0da61e8 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Tue, 28 Nov 2023 22:28:19 +0100 Subject: [PATCH 14/23] adds as ascript --- .../gateway/example_payment_gateway.py | 59 +++++++++---------- services/payments/setup.py | 1 + 2 files changed, 30 insertions(+), 30 deletions(-) mode change 100644 => 100755 services/payments/gateway/example_payment_gateway.py diff --git a/services/payments/gateway/example_payment_gateway.py b/services/payments/gateway/example_payment_gateway.py old mode 100644 new mode 100755 index b4dbef8df88..bfd49bdb579 --- a/services/payments/gateway/example_payment_gateway.py +++ b/services/payments/gateway/example_payment_gateway.py @@ -1,3 +1,5 @@ +#!/usr/bin/env python + # pylint: disable=protected-access # pylint: disable=redefined-builtin # pylint: disable=redefined-outer-name @@ -16,19 +18,26 @@ import json import logging import types -from contextlib import asynccontextmanager from dataclasses import dataclass -from pathlib import Path from typing import Annotated, Any, cast from uuid import uuid4 import httpx import uvicorn -from fastapi import APIRouter, Depends, FastAPI, Form, Header, Request, status +from fastapi import ( + APIRouter, + Depends, + FastAPI, + Form, + Header, + HTTPException, + Request, + status, +) from fastapi.encoders import jsonable_encoder from fastapi.responses import HTMLResponse from fastapi.routing import APIRoute -from pydantic import HttpUrl, SecretStr, parse_file_as +from pydantic import HttpUrl, SecretStr from servicelib.fastapi.openapi import override_fastapi_openapi_method from settings_library.base import BaseCustomSettings from simcore_service_payments.models.payments_gateway import ( @@ -193,7 +202,11 @@ def get_settings(request: Request) -> Settings: def auth_session(x_init_api_secret: Annotated[str | None, Header()] = None) -> int: - return 1 if x_init_api_secret is not None else 0 + if x_init_api_secret is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail="api secret missing" + ) + return 1 # @@ -214,7 +227,7 @@ def create_payment_router(): response_model=PaymentInitiated, responses=ERROR_RESPONSES, ) - async def _init_payment( + async def init_payment( payment: InitPayment, auth: Annotated[int, Depends(auth_session)], ): @@ -229,7 +242,7 @@ async def _init_payment( response_class=HTMLResponse, responses=ERROR_HTML_RESPONSES, ) - async def _get_payment_form( + async def get_payment_form( id: PaymentID, ): assert id # nosec @@ -242,7 +255,7 @@ async def _get_payment_form( responses=ERROR_RESPONSES, include_in_schema=False, ) - async def _pay( + async def pay( id: PaymentID, payment_form: Annotated[PaymentForm, Depends()], settings: Annotated[Settings, Depends(get_settings)], @@ -261,7 +274,7 @@ async def _pay( response_model=PaymentCancelled, responses=ERROR_RESPONSES, ) - async def _cancel_payment( + async def cancel_payment( payment: PaymentInitiated, auth: Annotated[int, Depends(auth_session)], ): @@ -287,7 +300,7 @@ def create_payment_method_router(): response_model=PaymentMethodInitiated, responses=ERROR_RESPONSES, ) - async def _init_payment_method( + async def init_payment_method( payment_method: InitPaymentMethod, auth: Annotated[int, Depends(auth_session)], ): @@ -302,7 +315,7 @@ async def _init_payment_method( response_class=HTMLResponse, responses=ERROR_HTML_RESPONSES, ) - async def _get_form_payment_method( + async def get_form_payment_method( id: PaymentMethodID, ): return FORM_HTML.format(f"/save?id={id}", "Save Payment") @@ -313,7 +326,7 @@ async def _get_form_payment_method( responses=ERROR_RESPONSES, include_in_schema=False, ) - async def _save( + async def save( id: PaymentMethodID, payment_form: Annotated[PaymentForm, Depends()], settings: Annotated[Settings, Depends(get_settings)], @@ -332,7 +345,7 @@ async def _save( response_model=PaymentMethodsBatch, responses=ERROR_RESPONSES, ) - async def _batch_get_payment_methods( + async def batch_get_payment_methods( batch: BatchGetPaymentMethods, auth: Annotated[int, Depends(auth_session)], ): @@ -358,7 +371,7 @@ async def _batch_get_payment_methods( **ERROR_RESPONSES, }, ) - async def _get_payment_method( + async def get_payment_method( id: PaymentMethodID, auth: Annotated[int, Depends(auth_session)], ): @@ -374,7 +387,7 @@ async def _get_payment_method( status_code=status.HTTP_204_NO_CONTENT, responses=ERROR_RESPONSES, ) - async def _delete_payment_method( + async def delete_payment_method( id: PaymentMethodID, auth: Annotated[int, Depends(auth_session)], ): @@ -386,7 +399,7 @@ async def _delete_payment_method( response_model=AckPaymentWithPaymentMethod, responses=ERROR_RESPONSES, ) - async def _pay_with_payment_method( + async def pay_with_payment_method( id: PaymentMethodID, payment: InitPayment, auth: Annotated[int, Depends(auth_session)], @@ -405,29 +418,15 @@ async def _pay_with_payment_method( return router -@asynccontextmanager -async def _app_lifespan(app: FastAPI): - state_path = Path("app.state.keep.ignore.json") - app.state.keep = {} - if state_path.exists(): - app.state.keep = parse_file_as(dict[str, Any], state_path) - - yield - - state_path.write_text(json.dumps(jsonable_encoder(app.state.keep), indent=1)) - - def create_app(): app = FastAPI( title="osparc-compliant payment-gateway", version=PAYMENTS_GATEWAY_SPECS_VERSION, - lifespan=_app_lifespan, debug=True, ) app.openapi_version = "3.0.0" # NOTE: small hack to allow current version of `42Crunch.vscode-openapi` to work with openapi override_fastapi_openapi_method(app) - app.state.keep = {} app.state.settings = Settings.create_from_envs() logging.info(app.state.settings.json(indent=2)) diff --git a/services/payments/setup.py b/services/payments/setup.py index 569209d1bf6..bdd459870e0 100755 --- a/services/payments/setup.py +++ b/services/payments/setup.py @@ -61,6 +61,7 @@ def read_reqs(reqs_path: Path) -> set[str]: "simcore-service = simcore_service_payments.cli:main", ], }, + "scripts": ["gateway/example_payment_gateway.py"], } if __name__ == "__main__": From 5a44eb082c63c8f789da795edd4d2873c939c533 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Tue, 28 Nov 2023 22:31:21 +0100 Subject: [PATCH 15/23] refactor --- services/payments/gateway/Makefile | 9 +- services/payments/gateway/openapi.json | 761 ------------------ .../example_payment_gateway.py | 0 services/payments/setup.py | 2 +- 4 files changed, 2 insertions(+), 770 deletions(-) rename services/payments/{gateway => scripts}/example_payment_gateway.py (100%) diff --git a/services/payments/gateway/Makefile b/services/payments/gateway/Makefile index 87709160533..98c1c728461 100644 --- a/services/payments/gateway/Makefile +++ b/services/payments/gateway/Makefile @@ -2,13 +2,6 @@ include ../../../scripts/common.Makefile -.PHONY: run-devel -run-devel: ## runs example_payment_gateway server - # SEE http://127.0.0.1:8000/docs - set -o allexport; source .env-secret; set +o allexport; \ - uvicorn example_payment_gateway:the_app --reload - .PHONY: openapi.json openapi.json: ## creates OAS - @set -o allexport; source .env-secret; set +o allexport; \ - python example_payment_gateway.py openapi > $@ + example_payment_gateway.py openapi > $@ diff --git a/services/payments/gateway/openapi.json b/services/payments/gateway/openapi.json index ecc63c61c0f..e69de29bb2d 100644 --- a/services/payments/gateway/openapi.json +++ b/services/payments/gateway/openapi.json @@ -1,761 +0,0 @@ -{ - "openapi": "3.0.0", - "info": { - "title": "osparc-compliant payment-gateway", - "version": "0.3.0" - }, - "paths": { - "/init": { - "post": { - "tags": [ - "payment" - ], - "summary": "Init Payment", - "operationId": "init_payment", - "parameters": [ - { - "required": false, - "schema": { - "type": "string", - "title": "X-Init-Api-Secret" - }, - "name": "x-init-api-secret", - "in": "header" - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/InitPayment" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PaymentInitiated" - } - } - } - }, - "4XX": { - "description": "Client Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - } - } - } - } - }, - "/pay": { - "get": { - "tags": [ - "payment" - ], - "summary": "Get Payment Form", - "operationId": "get_payment_form", - "parameters": [ - { - "required": true, - "schema": { - "type": "string", - "maxLength": 50, - "minLength": 1, - "title": "Id" - }, - "name": "id", - "in": "query" - } - ], - "responses": { - "200": { - "description": "Successful Response", - "content": { - "text/html": { - "schema": { - "type": "string" - } - } - } - }, - "4XX": { - "description": "Client Error", - "content": { - "text/html": { - "schema": { - "type": "string" - } - } - } - } - } - } - }, - "/cancel": { - "post": { - "tags": [ - "payment" - ], - "summary": "Cancel Payment", - "operationId": "cancel_payment", - "parameters": [ - { - "required": false, - "schema": { - "type": "string", - "title": "X-Init-Api-Secret" - }, - "name": "x-init-api-secret", - "in": "header" - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PaymentInitiated" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PaymentCancelled" - } - } - } - }, - "4XX": { - "description": "Client Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - } - } - } - } - }, - "/payment-methods:init": { - "post": { - "tags": [ - "payment-method" - ], - "summary": "Init Payment Method", - "operationId": "init_payment_method", - "parameters": [ - { - "required": false, - "schema": { - "type": "string", - "title": "X-Init-Api-Secret" - }, - "name": "x-init-api-secret", - "in": "header" - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/InitPaymentMethod" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PaymentMethodInitiated" - } - } - } - }, - "4XX": { - "description": "Client Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - } - } - } - } - }, - "/payment-methods/form": { - "get": { - "tags": [ - "payment-method" - ], - "summary": "Get Form Payment Method", - "operationId": "get_form_payment_method", - "parameters": [ - { - "required": true, - "schema": { - "type": "string", - "maxLength": 50, - "minLength": 1, - "title": "Id" - }, - "name": "id", - "in": "query" - } - ], - "responses": { - "200": { - "description": "Successful Response", - "content": { - "text/html": { - "schema": { - "type": "string" - } - } - } - }, - "4XX": { - "description": "Client Error", - "content": { - "text/html": { - "schema": { - "type": "string" - } - } - } - } - } - } - }, - "/payment-methods:batchGet": { - "post": { - "tags": [ - "payment-method" - ], - "summary": "Batch Get Payment Methods", - "operationId": "batch_get_payment_methods", - "parameters": [ - { - "required": false, - "schema": { - "type": "string", - "title": "X-Init-Api-Secret" - }, - "name": "x-init-api-secret", - "in": "header" - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BatchGetPaymentMethods" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PaymentMethodsBatch" - } - } - } - }, - "4XX": { - "description": "Client Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - } - } - } - } - }, - "/payment-methods/{id}": { - "get": { - "tags": [ - "payment-method" - ], - "summary": "Get Payment Method", - "operationId": "get_payment_method", - "parameters": [ - { - "required": true, - "schema": { - "type": "string", - "maxLength": 50, - "minLength": 1, - "title": "Id" - }, - "name": "id", - "in": "path" - }, - { - "required": false, - "schema": { - "type": "string", - "title": "X-Init-Api-Secret" - }, - "name": "x-init-api-secret", - "in": "header" - } - ], - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/GetPaymentMethod" - } - } - } - }, - "404": { - "description": "Payment method not found: It was not added or incomplete (i.e. create flow failed or canceled)", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - } - }, - "4XX": { - "description": "Client Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - } - } - } - }, - "delete": { - "tags": [ - "payment-method" - ], - "summary": "Delete Payment Method", - "operationId": "delete_payment_method", - "parameters": [ - { - "required": true, - "schema": { - "type": "string", - "maxLength": 50, - "minLength": 1, - "title": "Id" - }, - "name": "id", - "in": "path" - }, - { - "required": false, - "schema": { - "type": "string", - "title": "X-Init-Api-Secret" - }, - "name": "x-init-api-secret", - "in": "header" - } - ], - "responses": { - "204": { - "description": "Successful Response" - }, - "4XX": { - "description": "Client Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - } - } - } - } - }, - "/payment-methods/{id}:pay": { - "post": { - "tags": [ - "payment-method" - ], - "summary": "Pay With Payment Method", - "operationId": "pay_with_payment_method", - "parameters": [ - { - "required": true, - "schema": { - "type": "string", - "maxLength": 50, - "minLength": 1, - "title": "Id" - }, - "name": "id", - "in": "path" - }, - { - "required": false, - "schema": { - "type": "string", - "title": "X-Init-Api-Secret" - }, - "name": "x-init-api-secret", - "in": "header" - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/InitPayment" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AckPaymentWithPaymentMethod" - } - } - } - }, - "4XX": { - "description": "Client Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - } - } - } - } - } - }, - "components": { - "schemas": { - "AckPaymentWithPaymentMethod": { - "properties": { - "success": { - "type": "boolean", - "title": "Success" - }, - "message": { - "type": "string", - "title": "Message" - }, - "provider_payment_id": { - "type": "string", - "maxLength": 50, - "minLength": 1, - "title": "Provider Payment Id", - "description": "Payment ID from the provider (e.g. stripe payment ID)" - }, - "invoice_url": { - "type": "string", - "maxLength": 2083, - "minLength": 1, - "format": "uri", - "title": "Invoice Url", - "description": "Link to invoice is required when success=true" - }, - "payment_id": { - "type": "string", - "maxLength": 50, - "minLength": 1, - "title": "Payment Id", - "description": "Payment ID from the gateway" - } - }, - "type": "object", - "required": [ - "success" - ], - "title": "AckPaymentWithPaymentMethod", - "example": { - "success": true, - "provider_payment_id": "pi_123ABC", - "invoice_url": "https://invoices.com/id=12345", - "payment_id": "D19EE68B-B007-4B61-A8BC-32B7115FB244" - } - }, - "BatchGetPaymentMethods": { - "properties": { - "payment_methods_ids": { - "items": { - "type": "string", - "maxLength": 50, - "minLength": 1 - }, - "type": "array", - "title": "Payment Methods Ids" - } - }, - "type": "object", - "required": [ - "payment_methods_ids" - ], - "title": "BatchGetPaymentMethods" - }, - "ErrorModel": { - "properties": { - "message": { - "type": "string", - "title": "Message" - }, - "exception": { - "type": "string", - "title": "Exception" - }, - "file": { - "anyOf": [ - { - "type": "string", - "format": "path" - }, - { - "type": "string" - } - ], - "title": "File" - }, - "line": { - "type": "integer", - "title": "Line" - }, - "trace": { - "items": {}, - "type": "array", - "title": "Trace" - } - }, - "type": "object", - "required": [ - "message" - ], - "title": "ErrorModel" - }, - "GetPaymentMethod": { - "properties": { - "id": { - "type": "string", - "maxLength": 50, - "minLength": 1, - "title": "Id" - }, - "card_holder_name": { - "type": "string", - "title": "Card Holder Name" - }, - "card_number_masked": { - "type": "string", - "title": "Card Number Masked" - }, - "card_type": { - "type": "string", - "title": "Card Type" - }, - "expiration_month": { - "type": "integer", - "title": "Expiration Month" - }, - "expiration_year": { - "type": "integer", - "title": "Expiration Year" - }, - "created": { - "type": "string", - "format": "date-time", - "title": "Created" - } - }, - "type": "object", - "required": [ - "id", - "created" - ], - "title": "GetPaymentMethod" - }, - "InitPayment": { - "properties": { - "amount_dollars": { - "type": "number", - "exclusiveMaximum": true, - "exclusiveMinimum": true, - "title": "Amount Dollars", - "maximum": 1000000.0, - "minimum": 0.0 - }, - "credits": { - "type": "number", - "exclusiveMaximum": true, - "exclusiveMinimum": true, - "title": "Credits", - "maximum": 1000000.0, - "minimum": 0.0 - }, - "user_name": { - "type": "string", - "maxLength": 50, - "minLength": 1, - "title": "User Name" - }, - "user_email": { - "type": "string", - "format": "email", - "title": "User Email" - }, - "wallet_name": { - "type": "string", - "maxLength": 50, - "minLength": 1, - "title": "Wallet Name" - } - }, - "additionalProperties": false, - "type": "object", - "required": [ - "amount_dollars", - "credits", - "user_name", - "user_email", - "wallet_name" - ], - "title": "InitPayment" - }, - "InitPaymentMethod": { - "properties": { - "method": { - "type": "string", - "enum": [ - "CC" - ], - "title": "Method", - "default": "CC" - }, - "user_name": { - "type": "string", - "maxLength": 50, - "minLength": 1, - "title": "User Name" - }, - "user_email": { - "type": "string", - "format": "email", - "title": "User Email" - }, - "wallet_name": { - "type": "string", - "maxLength": 50, - "minLength": 1, - "title": "Wallet Name" - } - }, - "additionalProperties": false, - "type": "object", - "required": [ - "user_name", - "user_email", - "wallet_name" - ], - "title": "InitPaymentMethod" - }, - "PaymentCancelled": { - "properties": { - "message": { - "type": "string", - "title": "Message" - } - }, - "type": "object", - "title": "PaymentCancelled" - }, - "PaymentInitiated": { - "properties": { - "payment_id": { - "type": "string", - "maxLength": 50, - "minLength": 1, - "title": "Payment Id" - } - }, - "type": "object", - "required": [ - "payment_id" - ], - "title": "PaymentInitiated" - }, - "PaymentMethodInitiated": { - "properties": { - "payment_method_id": { - "type": "string", - "maxLength": 50, - "minLength": 1, - "title": "Payment Method Id" - } - }, - "type": "object", - "required": [ - "payment_method_id" - ], - "title": "PaymentMethodInitiated" - }, - "PaymentMethodsBatch": { - "properties": { - "items": { - "items": { - "$ref": "#/components/schemas/GetPaymentMethod" - }, - "type": "array", - "title": "Items" - } - }, - "type": "object", - "required": [ - "items" - ], - "title": "PaymentMethodsBatch" - } - } - } -} diff --git a/services/payments/gateway/example_payment_gateway.py b/services/payments/scripts/example_payment_gateway.py similarity index 100% rename from services/payments/gateway/example_payment_gateway.py rename to services/payments/scripts/example_payment_gateway.py diff --git a/services/payments/setup.py b/services/payments/setup.py index bdd459870e0..234334fa2ab 100755 --- a/services/payments/setup.py +++ b/services/payments/setup.py @@ -61,7 +61,7 @@ def read_reqs(reqs_path: Path) -> set[str]: "simcore-service = simcore_service_payments.cli:main", ], }, - "scripts": ["gateway/example_payment_gateway.py"], + "scripts": ["scripts/example_payment_gateway.py"], } if __name__ == "__main__": From 58130c20d76cbb47d6ff9bea7536062695c43efa Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Wed, 29 Nov 2023 12:40:00 +0100 Subject: [PATCH 16/23] fixes test --- .../simcore_service_webserver/wallets/_payments_handlers.py | 2 +- .../with_dbs/03/wallets/payments/test_payments_methods.py | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/services/web/server/src/simcore_service_webserver/wallets/_payments_handlers.py b/services/web/server/src/simcore_service_webserver/wallets/_payments_handlers.py index 554b7f2f509..f52e3ebdfb7 100644 --- a/services/web/server/src/simcore_service_webserver/wallets/_payments_handlers.py +++ b/services/web/server/src/simcore_service_webserver/wallets/_payments_handlers.py @@ -345,7 +345,7 @@ async def _pay_with_payment_method(request: web.Request): async def _notify_payment_completed_after_response(app, user_id, payment): # A small delay to send notification just after the response - await asyncio.sleep(0.5) + await asyncio.sleep(0.1) return ( await notify_payment_completed(app, user_id=user_id, payment=payment), ) diff --git a/services/web/server/tests/unit/with_dbs/03/wallets/payments/test_payments_methods.py b/services/web/server/tests/unit/with_dbs/03/wallets/payments/test_payments_methods.py index c18ce0973dc..67b673b6aa2 100644 --- a/services/web/server/tests/unit/with_dbs/03/wallets/payments/test_payments_methods.py +++ b/services/web/server/tests/unit/with_dbs/03/wallets/payments/test_payments_methods.py @@ -4,6 +4,7 @@ # pylint: disable=too-many-arguments +import asyncio from decimal import Decimal from unittest.mock import MagicMock @@ -376,7 +377,8 @@ async def test_one_time_payment_with_payment_method( assert mock_rut_add_credits_to_wallet.called mock_rut_add_credits_to_wallet.assert_called_once() - # check notification (fake) + # check notification after response + await asyncio.sleep(0.1) assert send_message.called send_message.assert_called_once() From 421483cc0ec95279e5426d814ab4b2cb357828a8 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Wed, 29 Nov 2023 13:01:19 +0100 Subject: [PATCH 17/23] cleanup --- .../simcore_service_payments/services/socketio.py | 13 ++++++------- .../simcore_service_webserver/socketio/server.py | 2 +- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/services/payments/src/simcore_service_payments/services/socketio.py b/services/payments/src/simcore_service_payments/services/socketio.py index aad06ea0d07..6b0c5def3e8 100644 --- a/services/payments/src/simcore_service_payments/services/socketio.py +++ b/services/payments/src/simcore_service_payments/services/socketio.py @@ -27,14 +27,14 @@ async def _on_startup() -> None: # # Connect to the as an external process in write-only mode # - app.state.external_sio = socketio.AsyncAioPikaManager( + app.state.external_socketio = socketio.AsyncAioPikaManager( url=settings.dsn, logger=_logger, write_only=True ) async def _on_shutdown() -> None: - if app.state.external_sio: + if app.state.external_socketio: await cleanup_socketio_async_pubsub_manager( - server_manager=app.state.external_sio + server_manager=app.state.external_socketio ) app.add_event_handler("startup", _on_startup) @@ -47,14 +47,13 @@ async def notify_payment_completed( user_primary_group_id: GroupID, payment: PaymentTransaction, ): - # We assume that the user has been added to all + # NOTE: We assume that the user has been added to all # rooms associated to his groups - # assert payment.completed_at is not None # nosec - external_sio: socketio.AsyncAioPikaManager = app.state.external_sio + external_socketio: socketio.AsyncAioPikaManager = app.state.external_socketio - return await external_sio.emit( + return await external_socketio.emit( SOCKET_IO_PAYMENT_COMPLETED_EVENT, data=jsonable_encoder(payment, by_alias=True), room=f"{user_primary_group_id}", diff --git a/services/web/server/src/simcore_service_webserver/socketio/server.py b/services/web/server/src/simcore_service_webserver/socketio/server.py index 7d9c9b42031..175604e30ad 100644 --- a/services/web/server/src/simcore_service_webserver/socketio/server.py +++ b/services/web/server/src/simcore_service_webserver/socketio/server.py @@ -1,5 +1,5 @@ import logging -from typing import AsyncIterator +from collections.abc import AsyncIterator from aiohttp import web from servicelib.socketio_utils import cleanup_socketio_async_pubsub_manager From 556d6200dcdb440e3060cb11e78d595acce76d63 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Wed, 29 Nov 2023 13:24:06 +0100 Subject: [PATCH 18/23] fixes on_payment_completed --- .../src/simcore_service_payments/services/payments.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/services/payments/src/simcore_service_payments/services/payments.py b/services/payments/src/simcore_service_payments/services/payments.py index db617bcd052..51e98fe5e5e 100644 --- a/services/payments/src/simcore_service_payments/services/payments.py +++ b/services/payments/src/simcore_service_payments/services/payments.py @@ -155,7 +155,6 @@ async def on_payment_completed( if notify_enabled: _logger.debug( "Notify front-end of payment -> sio SOCKET_IO_PAYMENT_COMPLETED_EVENT " - "socketio.notify_payment_completed(sio, user_primary_group_id=gid, payment=transaction)" ) if transaction.state == PaymentTransactionState.SUCCESS: @@ -254,7 +253,8 @@ async def pay_with_payment_method( # noqa: PLR0913 invoice_url=ack.invoice_url, ) - # NOTE: notifications here are done as background-task after responding `POST /wallets/{wallet_id}/payments-methods/{payment_method_id}:pay` + # NOTE: notifications here are done as background-task after responding + # POST /wallets/{wallet_id}/payments-methods/{payment_method_id}:pay await on_payment_completed(transaction, rut, notify_enabled=False) return transaction.to_api_model() From 43b8be2612b57498a567e366a5f80aec8a2d6ffe Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Wed, 29 Nov 2023 13:48:41 +0100 Subject: [PATCH 19/23] cleanup on_completed --- .../src/simcore_service_payments/services/payments.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/services/payments/src/simcore_service_payments/services/payments.py b/services/payments/src/simcore_service_payments/services/payments.py index 51e98fe5e5e..db617bcd052 100644 --- a/services/payments/src/simcore_service_payments/services/payments.py +++ b/services/payments/src/simcore_service_payments/services/payments.py @@ -155,6 +155,7 @@ async def on_payment_completed( if notify_enabled: _logger.debug( "Notify front-end of payment -> sio SOCKET_IO_PAYMENT_COMPLETED_EVENT " + "socketio.notify_payment_completed(sio, user_primary_group_id=gid, payment=transaction)" ) if transaction.state == PaymentTransactionState.SUCCESS: @@ -253,8 +254,7 @@ async def pay_with_payment_method( # noqa: PLR0913 invoice_url=ack.invoice_url, ) - # NOTE: notifications here are done as background-task after responding - # POST /wallets/{wallet_id}/payments-methods/{payment_method_id}:pay + # NOTE: notifications here are done as background-task after responding `POST /wallets/{wallet_id}/payments-methods/{payment_method_id}:pay` await on_payment_completed(transaction, rut, notify_enabled=False) return transaction.to_api_model() From e0f3c9a2808440dff446e26b1cc5b415d2250f26 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Wed, 29 Nov 2023 13:54:28 +0100 Subject: [PATCH 20/23] creates notifier --- .../services/socketio.py | 52 +++++++++++++++---- 1 file changed, 43 insertions(+), 9 deletions(-) diff --git a/services/payments/src/simcore_service_payments/services/socketio.py b/services/payments/src/simcore_service_payments/services/socketio.py index 6b0c5def3e8..ba841739803 100644 --- a/services/payments/src/simcore_service_payments/services/socketio.py +++ b/services/payments/src/simcore_service_payments/services/socketio.py @@ -1,12 +1,17 @@ import logging +from dataclasses import dataclass import socketio from fastapi import FastAPI from fastapi.encoders import jsonable_encoder from models_library.api_schemas_payments.socketio import ( SOCKET_IO_PAYMENT_COMPLETED_EVENT, + SOCKET_IO_PAYMENT_METHOD_ACKED_EVENT, +) +from models_library.api_schemas_webserver.wallets import ( + PaymentMethodTransaction, + PaymentTransaction, ) -from models_library.api_schemas_webserver.wallets import PaymentTransaction from models_library.users import GroupID from servicelib.socketio_utils import cleanup_socketio_async_pubsub_manager from settings_library.rabbit import RabbitSettings @@ -16,6 +21,37 @@ _logger = logging.getLogger(__name__) +@dataclass +class Notifier: + _sio_manager: socketio.AsyncAioPikaManager + + async def notify_payment_completed( + self, + user_primary_group_id: GroupID, + payment: PaymentTransaction, + ): + # NOTE: We assume that the user has been added to all + # rooms associated to his groups + assert payment.completed_at is not None # nosec + + return await self._sio_manager.emit( + SOCKET_IO_PAYMENT_COMPLETED_EVENT, + data=jsonable_encoder(payment, by_alias=True), + room=f"{user_primary_group_id}", + ) + + async def notify_payment_method_acked( + self, + user_primary_group_id: GroupID, + payment_method: PaymentMethodTransaction, + ): + return await self._sio_manager.emit( + SOCKET_IO_PAYMENT_METHOD_ACKED_EVENT, + data=jsonable_encoder(payment_method, by_alias=True), + room=f"{user_primary_group_id}", + ) + + def setup_socketio(app: FastAPI): settings: RabbitSettings = get_rabbitmq_settings(app) @@ -31,6 +67,9 @@ async def _on_startup() -> None: url=settings.dsn, logger=_logger, write_only=True ) + # NOTE: this might be moved somewhere else when notifier incorporates emails etc + app.state.notifier = Notifier(_sio_manager=app.state.external_socketio) + async def _on_shutdown() -> None: if app.state.external_socketio: await cleanup_socketio_async_pubsub_manager( @@ -47,14 +86,9 @@ async def notify_payment_completed( user_primary_group_id: GroupID, payment: PaymentTransaction, ): - # NOTE: We assume that the user has been added to all - # rooms associated to his groups - assert payment.completed_at is not None # nosec - external_socketio: socketio.AsyncAioPikaManager = app.state.external_socketio + notifier: Notifier = app.state.notifier - return await external_socketio.emit( - SOCKET_IO_PAYMENT_COMPLETED_EVENT, - data=jsonable_encoder(payment, by_alias=True), - room=f"{user_primary_group_id}", + return await notifier.notify_payment_completed( + user_primary_group_id=user_primary_group_id, payment=payment ) From dee7ebb6eb703479686cbf1c4b33c49d705c84c0 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Wed, 29 Nov 2023 15:45:37 +0100 Subject: [PATCH 21/23] cc --- services/payments/scripts/example_payment_gateway.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/services/payments/scripts/example_payment_gateway.py b/services/payments/scripts/example_payment_gateway.py index bfd49bdb579..8f8295e0d42 100755 --- a/services/payments/scripts/example_payment_gateway.py +++ b/services/payments/scripts/example_payment_gateway.py @@ -408,14 +408,14 @@ async def pay_with_payment_method( assert payment # nosec assert auth # nosec - return AckPaymentWithPaymentMethod( + return AckPaymentWithPaymentMethod( # nosec success=True, invoice_url="https://fakeimg.pl/300/", payment_id=f"{uuid4()}", message=f"Payed with payment-method {id}", ) - return router + return router # nosec def create_app(): From 7417cff57f6f25911c7062aeabc6de594283258e Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Wed, 29 Nov 2023 17:28:58 +0100 Subject: [PATCH 22/23] @GitHK review: doc and rm comments --- .../src/simcore_service_payments/services/payments.py | 1 - .../wallets/_payments_handlers.py | 7 +++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/services/payments/src/simcore_service_payments/services/payments.py b/services/payments/src/simcore_service_payments/services/payments.py index db617bcd052..eb91c5e93ba 100644 --- a/services/payments/src/simcore_service_payments/services/payments.py +++ b/services/payments/src/simcore_service_payments/services/payments.py @@ -155,7 +155,6 @@ async def on_payment_completed( if notify_enabled: _logger.debug( "Notify front-end of payment -> sio SOCKET_IO_PAYMENT_COMPLETED_EVENT " - "socketio.notify_payment_completed(sio, user_primary_group_id=gid, payment=transaction)" ) if transaction.state == PaymentTransactionState.SUCCESS: diff --git a/services/web/server/src/simcore_service_webserver/wallets/_payments_handlers.py b/services/web/server/src/simcore_service_webserver/wallets/_payments_handlers.py index f52e3ebdfb7..7b5cec05172 100644 --- a/services/web/server/src/simcore_service_webserver/wallets/_payments_handlers.py +++ b/services/web/server/src/simcore_service_webserver/wallets/_payments_handlers.py @@ -300,6 +300,9 @@ async def _delete_payment_method(request: web.Request): return web.HTTPNoContent(content_type=MIMETYPE_APPLICATION_JSON) +_TINY_WAIT_TO_TRIGGER_CONTEXT_SWITCH = 0.1 + + @routes.post( f"/{VTAG}/wallets/{{wallet_id}}/payments-methods/{{payment_method_id}}:pay", name="pay_with_payment_method", @@ -344,8 +347,8 @@ async def _pay_with_payment_method(request: web.Request): # instead we emulate a init-prompt-ack workflow by firing a background task that acks payment async def _notify_payment_completed_after_response(app, user_id, payment): - # A small delay to send notification just after the response - await asyncio.sleep(0.1) + # NOTE: A small delay to send notification just after the response + await asyncio.sleep(_TINY_WAIT_TO_TRIGGER_CONTEXT_SWITCH) return ( await notify_payment_completed(app, user_id=user_id, payment=payment), ) From 453d955788ae98033ef7801c1db7f44766c48b4a Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Wed, 29 Nov 2023 17:38:44 +0100 Subject: [PATCH 23/23] rm cc --- .codeclimate.yml | 33 ++++++++++--------- .../scripts/example_payment_gateway.py | 1 + 2 files changed, 18 insertions(+), 16 deletions(-) diff --git a/.codeclimate.yml b/.codeclimate.yml index a0936fee04a..e1c5a936f9d 100644 --- a/.codeclimate.yml +++ b/.codeclimate.yml @@ -59,29 +59,30 @@ plugins: - .js exclude_patterns: - - "config/" - - "db/" - - "dist/" - - "features/" - - "**/node_modules/" - - "script" - - "**/spec/" - - "**/test/" - - "**/tests/" - - "**/vendor/" - - "**/*.d.ts" - - "**/.venv/" - ".venv/" - - "**/healthcheck.py" + - "**/.venv/" + - "**/*.d.ts" + - "**/*.js" - "**/client-sdk/" - "**/generated_code/" + - "**/healthcheck.py" - "**/migrations/" - - "**/*.js" - - "**/pytest-simcore/" + - "**/node_modules/" - "**/pytest_plugin/" + - "**/pytest-simcore/" - "**/sandbox/" + - "**/spec/" + - "**/test/" + - "**/tests/" + - "**/vendor/" + - "config/" + - "db/" + - "dist/" + - "features/" + - "script" + - "scripts/" - packages/models-library/src/models_library/utils/_original_fastapi_encoders.py + - services/payments/scripts/example_payment_gateway.py - services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/modules/db/repositories/resource_tracker.py - services/web/server/src/simcore_service_webserver/exporter/formatters/sds/xlsx/templates/code_description.py - services/web/server/src/simcore_service_webserver/projects/db.py - - "scripts/" diff --git a/services/payments/scripts/example_payment_gateway.py b/services/payments/scripts/example_payment_gateway.py index 8f8295e0d42..19f28790e86 100755 --- a/services/payments/scripts/example_payment_gateway.py +++ b/services/payments/scripts/example_payment_gateway.py @@ -6,6 +6,7 @@ # pylint: disable=too-many-arguments # pylint: disable=unused-argument # pylint: disable=unused-variable + """ This is a simple example of a payments-gateway service - Mainly used to create the openapi specs (SEE `openapi.json`) that the payments service expects