diff --git a/backend/app/adapter/repository/__init__.py b/backend/app/adapter/repository/__init__.py index 8bb6d0f..10b5137 100644 --- a/backend/app/adapter/repository/__init__.py +++ b/backend/app/adapter/repository/__init__.py @@ -1,12 +1,23 @@ +from typing import Optional + from app.domain import Game +from app.dto import RtcRoomMsgData class AbstractGameRepository: async def save(self, game: Game): - raise NotImplementedError("AbstractRepository.save") + raise NotImplementedError("AbstractGameRepository.save") async def get_game(self, game_id: str) -> Game: - raise NotImplementedError("AbstractRepository.get_game") + raise NotImplementedError("AbstractGameRepository.get_game") + + +class AbstractRtcRoomRepository: + async def save(self, data: RtcRoomMsgData): + raise NotImplementedError("AbstractRtcRoomRepository.save") + + async def get(self, game_id: str) -> Optional[RtcRoomMsgData]: + raise NotImplementedError("AbstractRtcRoomRepository.get") def get_game_repository(): @@ -16,3 +27,9 @@ def get_game_repository(): from .in_mem import InMemoryGameRepository return InMemoryGameRepository() + + +def get_rtc_room_repository(): + from .in_mem import InMemoryRtcRoomRepository + + return InMemoryRtcRoomRepository() diff --git a/backend/app/adapter/repository/in_mem.py b/backend/app/adapter/repository/in_mem.py index c8c15cf..2bf7667 100644 --- a/backend/app/adapter/repository/in_mem.py +++ b/backend/app/adapter/repository/in_mem.py @@ -2,7 +2,8 @@ from typing import Optional from app.domain import Game -from app.adapter.repository import AbstractGameRepository +from app.adapter.repository import AbstractGameRepository, AbstractRtcRoomRepository +from app.dto import RtcRoomMsgData class InMemoryGameRepository(AbstractGameRepository): @@ -19,3 +20,17 @@ async def save(self, game: Game): async def get_game(self, game_id: str) -> Optional[Game]: async with self._lock: return self._games.get(game_id) + + +class InMemoryRtcRoomRepository(AbstractRtcRoomRepository): + def __init__(self): + self._lock = asyncio.Lock() + self._rooms = {} + + async def save(self, data: RtcRoomMsgData): + async with self._lock: + self._rooms[data.gameID] = data + + async def get(self, game_id: str) -> Optional[RtcRoomMsgData]: + async with self._lock: + return self._rooms.get(game_id) diff --git a/backend/app/adapter/sio_srv.py b/backend/app/adapter/sio_srv.py index 6d79d2e..8886379 100644 --- a/backend/app/adapter/sio_srv.py +++ b/backend/app/adapter/sio_srv.py @@ -1,44 +1,30 @@ import logging import asyncio import os -from typing import Dict, List +from typing import Dict import socketio from hypercorn.config import Config from hypercorn.asyncio import serve -from pydantic import BaseModel, ConfigDict, ValidationError +from pydantic import ValidationError from app.constant import RealTimeCommConst as RtcConst from app.config import RTC_HOST, RTC_PORT, LOG_FILE_PATH +from app.adapter.repository import get_rtc_room_repository +from app.dto import RtcRoomMsgData, ChatMsgData, RtcInitMsgData +from app.domain import GameError, GameErrorCodes, GameFuncCodes srv = socketio.AsyncServer(async_mode="asgi") -# currently the logger is configured in simple way, if someone needs to run it -# in production environment, maybe they can switch to more advanced architecture -# e.g. centralized logging architecture, ELK stack, EFK stack ...etc +# currently the logger is configured in simple way, anyone who needs to run it +# in production environment can switch to more advanced architecture e.g. +# centralized logging architecture, ELK stack, EFK stack ...etc _logger = logging.getLogger(__name__) _logger.setLevel(logging.WARNING) _logger.addHandler(logging.FileHandler(LOG_FILE_PATH["RTC"], mode="a")) - -class NewRoomMsgData(BaseModel): - model_config = ConfigDict(extra="forbid") - gameID: str - players: List[str] - - -class ChatMsgData(BaseModel): - model_config = ConfigDict(extra="forbid") - msg: str - nickname: str - gameID: str - client: str ## client session ID - - -class RtcInitMsgData(BaseModel): - model_config = ConfigDict(extra="forbid") - nickname: str - gameID: str - client: str +# there is no use-case layer in this socketio server, all the game logic should +# be implemented in the http sserver +_repo = get_rtc_room_repository() @srv.on(RtcConst.EVENTS.CHAT.value, namespace=RtcConst.NAMESPACE) @@ -59,22 +45,46 @@ async def _forward_chat_msg(sid, data: Dict): ) +async def check_room_exist(repo, data: RtcInitMsgData): + fetched_room = await repo.get(data.gameID) + if fetched_room is None or fetched_room.gameID != data.gameID: + ecode = GameErrorCodes.GAME_NOT_FOUND + elif data.player.id not in fetched_room.players: + ecode = GameErrorCodes.INVALID_PLAYER + else: + ecode = None + if ecode: + raise GameError(ecode, fn_code=GameFuncCodes.RTC_ENDPOINT) + + +## TODO, error handling decorator @srv.on(RtcConst.EVENTS.INIT.value, namespace=RtcConst.NAMESPACE) async def init_communication(sid, data: Dict): + _logger.debug("init-rtc, raw-req-data: %s", data) + error = None try: - RtcInitMsgData(**data) - await srv.enter_room(sid, room=data["gameID"], namespace=RtcConst.NAMESPACE) + validated = RtcInitMsgData(**data) + await check_room_exist(_repo, data=validated) + await srv.enter_room(sid, room=validated.gameID, namespace=RtcConst.NAMESPACE) data["succeed"] = True await srv.emit( RtcConst.EVENTS.INIT.value, data, namespace=RtcConst.NAMESPACE, - room=data["gameID"], + room=validated.gameID, ) except ValidationError as e: _logger.error("%s", e) error = e.errors(include_url=False, include_input=False) error["succeed"] = False + except GameError as e: + _logger.error("req-room-ID:%s , error:%s", validated.gameID, e) + error = { + "succeed": False, + "code": e.error_code.value[0], + "func": e.func_code.value, + } + if error: await srv.emit( RtcConst.EVENTS.INIT.value, namespace=RtcConst.NAMESPACE, @@ -85,20 +95,30 @@ async def init_communication(sid, data: Dict): @srv.on(RtcConst.EVENTS.DEINIT.value, namespace=RtcConst.NAMESPACE) async def deinit_communication(sid, data: Dict): + error = None try: - RtcInitMsgData(**data) - await srv.leave_room(sid, room=data["gameID"], namespace=RtcConst.NAMESPACE) + validated = RtcInitMsgData(**data) + await check_room_exist(_repo, data=validated) + await srv.leave_room(sid, room=validated.gameID, namespace=RtcConst.NAMESPACE) data["succeed"] = True await srv.emit( RtcConst.EVENTS.DEINIT.value, data, namespace=RtcConst.NAMESPACE, - room=data["gameID"], + room=validated.gameID, ) except ValidationError as e: _logger.error("%s", e) error = e.errors(include_url=False, include_input=False) - data["succeed"] = False + error["succeed"] = False + except GameError as e: + _logger.error("req-room-ID:%s , error:%s", validated.gameID, e) + error = { + "succeed": False, + "code": e.error_code.value[0], + "func": e.func_code.value, + } + if error: await srv.emit( RtcConst.EVENTS.DEINIT.value, namespace=RtcConst.NAMESPACE, @@ -110,7 +130,8 @@ async def deinit_communication(sid, data: Dict): @srv.on(RtcConst.EVENTS.NEW_ROOM.value, namespace=RtcConst.NAMESPACE) async def _new_game_room(sid, data: Dict): try: # TODO, ensure this event is sent by authorized http server - data = NewRoomMsgData(**data) + validated = RtcRoomMsgData(**data) + await _repo.save(validated) except ValidationError as e: _logger.error("%s", e) diff --git a/backend/app/domain/game.py b/backend/app/domain/game.py index 0f42f62..382ba07 100644 --- a/backend/app/domain/game.py +++ b/backend/app/domain/game.py @@ -26,14 +26,16 @@ class GameFuncCodes(Enum): SWITCH_CHARACTER = 1003 CLEAR_CHARACTER_SELECTION = 1004 UPDATE_DIFFICULTY = 1005 - USE_CASE_EXECUTE = 1099 ## TODO, rename this + ## TODO, rename the following members + USE_CASE_EXECUTE = 1099 + RTC_ENDPOINT = 1098 ## for real-time communication like socket.io server endpoint class GameError(Exception): def __init__( - self, e_code: GameFuncCodes, fn_code: GameFuncCodes, msg: Optional[str] = None + self, e_code: GameErrorCodes, fn_code: GameFuncCodes, msg: Optional[str] = None ): - self.error_code: GameFuncCodes = e_code + self.error_code: GameErrorCodes = e_code self.func_code: GameFuncCodes = fn_code self.message = msg diff --git a/backend/app/dto.py b/backend/app/dto.py index cbfb5bf..171dddb 100644 --- a/backend/app/dto.py +++ b/backend/app/dto.py @@ -1,6 +1,6 @@ from enum import Enum from typing import List -from pydantic import BaseModel, RootModel +from pydantic import BaseModel, RootModel, ConfigDict # data transfer objects (DTO) in the application # TODO, determine module path @@ -63,3 +63,24 @@ class UpdateDifficultyDto(BaseModel): class UpdateCommonRespDto(BaseModel): message: str + + +class RtcRoomMsgData(BaseModel): + model_config = ConfigDict(extra="forbid") + gameID: str + players: List[str] + + +class ChatMsgData(BaseModel): + model_config = ConfigDict(extra="forbid") + msg: str + nickname: str # TODO, replace with PlayerDto + gameID: str + client: str ## client session ID + + +class RtcInitMsgData(BaseModel): + model_config = ConfigDict(extra="forbid") + player: PlayerDto + gameID: str + client: str # client session ID diff --git a/backend/tests/e2e/test_socketio.py b/backend/tests/e2e/test_socketio.py index 8feacdd..9bd7eb8 100644 --- a/backend/tests/e2e/test_socketio.py +++ b/backend/tests/e2e/test_socketio.py @@ -9,17 +9,33 @@ SERVER_URL = "http://%s:%s" % (RTC_HOST, RTC_PORT) -class MockClient: - def __init__(self, nickname: str, toplvl_namespace=RtcConst.NAMESPACE): +class MockiAbstractClient: + def __init__(self, toplvl_namespace: str): self._sio_client = socketio.AsyncSimpleClient(logger=False) self._toplvl_namespace = toplvl_namespace + + async def connect(self, url: str): + await self._sio_client.connect(url, namespace=self._toplvl_namespace) + + async def disconnect(self): + await self._sio_client.disconnect() + + +class MockClient(MockiAbstractClient): + def __init__(self, nickname: str, player_id: str): + super().__init__(toplvl_namespace=RtcConst.NAMESPACE) self._nickname = nickname + self._player_id = player_id self._msg_log: List[Dict] = [] @property def sio(self): return self._sio_client + @property + def player_id(self): + return self._player_id + @property def nickname(self): return self._nickname @@ -28,17 +44,14 @@ def nickname(self): def messages_log(self): return self._msg_log - async def connect(self, url: str): - await self._sio_client.connect(url, namespace=self._toplvl_namespace) - - async def disconnect(self): - await self._sio_client.disconnect() - async def join(self, room_id: str): await self._sio_client.emit( RtcConst.EVENTS.INIT.value, data={ - "nickname": self._nickname, + "player": { + "id": self._player_id, + "nickname": self._nickname, + }, "client": self._sio_client.sid, "gameID": room_id, }, @@ -48,7 +61,10 @@ async def leave(self, room_id: str): await self._sio_client.emit( RtcConst.EVENTS.DEINIT.value, data={ - "nickname": self._nickname, + "player": { + "id": self._player_id, + "nickname": self._nickname, + }, "client": self._sio_client.sid, "gameID": room_id, }, @@ -61,16 +77,18 @@ async def chat(self, room_id: str, msg: str): await self._sio_client.emit(RtcConst.EVENTS.CHAT.value, data=data) self._msg_log.append(item) - async def verify_join(self, expect_clients: List): + async def verify_join(self, expect_clients: List, expect_join_success: bool): expect_client_sid = [v.sio.sid for v in expect_clients] actual_client_sid = [] for expect_sid in expect_client_sid: evts = await self._sio_client.receive(timeout=1) assert len(evts) == 2 assert evts[0] == RtcConst.EVENTS.INIT.value - assert evts[1]["succeed"] - actual_client_sid.append(evts[1]["client"]) - assert set(actual_client_sid) == set(expect_client_sid) + assert evts[1]["succeed"] == expect_join_success + if expect_join_success: + actual_client_sid.append(evts[1]["client"]) + if expect_join_success: + assert set(actual_client_sid) == set(expect_client_sid) async def verify_chat(self, expect_sender, expect_error: Dict = None): evts: List = await self._sio_client.receive(timeout=3) @@ -87,34 +105,48 @@ async def verify_chat(self, expect_sender, expect_error: Dict = None): assert self.messages_log == expect_sender.messages_log +class MockiHttpServer(MockiAbstractClient): + async def new_room(self, room_id: str, members: List[str]): + await self._sio_client.emit( + RtcConst.EVENTS.NEW_ROOM.value, + data={ + "players": members, + "gameID": room_id, + }, + ) + + class TestRealTimeComm: @pytest.mark.asyncio - async def test_chat(self): + async def test_chat_ok(self): + http_server = MockiHttpServer(RtcConst.NAMESPACE) clients = [ - MockClient(nickname="Veronika"), - MockClient(nickname="Satoshi"), - MockClient(nickname="Mehlin"), - MockClient(nickname="Jose"), - MockClient(nickname="Raj"), + MockClient(nickname="Veronika", player_id="u0011"), + MockClient(nickname="Satoshi", player_id="u0012"), + MockClient(nickname="Mehlin", player_id="u0013"), + MockClient(nickname="Jose", player_id="u0014"), + MockClient(nickname="Raj", player_id="u0015"), ] game_rooms = {"a001": clients[:2], "b073": clients[2:]} + await http_server.connect(SERVER_URL) for clients in game_rooms.values(): for client in clients: await client.connect(SERVER_URL) for g_id, clients in game_rooms.items(): + await http_server.new_room(g_id, members=[c.player_id for c in clients]) for c in clients: await c.join(room_id=g_id) # receive the result right after emitting `init` event # Note `socket.io` does not gurantee the order of completion clients = game_rooms["b073"] - await clients[0].verify_join([clients[2], clients[1], clients[0]]) - await clients[1].verify_join(clients[1:]) - await clients[2].verify_join([clients[2]]) + await clients[0].verify_join([clients[2], clients[1], clients[0]], True) + await clients[1].verify_join(clients[1:], True) + await clients[2].verify_join([clients[2]], True) clients = game_rooms["a001"] - await clients[0].verify_join([clients[1], clients[0]]) - await clients[1].verify_join([clients[1]]) + await clients[0].verify_join([clients[1], clients[0]], True) + await clients[1].verify_join([clients[1]], True) # one client sends chat event to others in the same room clients = game_rooms["a001"] @@ -155,4 +187,21 @@ async def test_chat(self): for c in clients: await c.leave(room_id=g_id) await c.disconnect() + await http_server.disconnect() # end of test_event_subscription + + @pytest.mark.asyncio + async def test_enter_room(self): + http_server = MockiHttpServer(RtcConst.NAMESPACE) + client = MockClient(nickname="Asuka", player_id="u0016") + await http_server.connect(SERVER_URL) + await client.connect(SERVER_URL) + await http_server.new_room("de056", members=["another-player"]) + await http_server.new_room("c0034", members=[client.player_id]) + await client.join(room_id="de056") + await client.verify_join([client], False) + await client.join(room_id="c0034") + await client.verify_join([client], True) + await client.leave(room_id="c0034") + await client.disconnect() + await http_server.disconnect()