Skip to content

Commit

Permalink
[#37] feature: restrict players to the game room they belong to
Browse files Browse the repository at this point in the history
- implement (currently in-memory) room repository , so the socketio
  server knows all the ongoing games
- check from the room repository when player attempts to initialize (or
  de-initialize) real-time communication (RTC) through the socketio
  event `INIT` (or `DEINIT`)
- add new member to `GameFuncCodes`, for RTC endpoints (e.g. currently
  `socketio`)

Signed-off-by: T.H. <[email protected]>
  • Loading branch information
metalalive committed Jan 24, 2024
1 parent 5b17c85 commit b211fdb
Show file tree
Hide file tree
Showing 6 changed files with 190 additions and 65 deletions.
21 changes: 19 additions & 2 deletions backend/app/adapter/repository/__init__.py
Original file line number Diff line number Diff line change
@@ -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():
Expand All @@ -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()
17 changes: 16 additions & 1 deletion backend/app/adapter/repository/in_mem.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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)
87 changes: 54 additions & 33 deletions backend/app/adapter/sio_srv.py
Original file line number Diff line number Diff line change
@@ -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)
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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)

Expand Down
8 changes: 5 additions & 3 deletions backend/app/domain/game.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
23 changes: 22 additions & 1 deletion backend/app/dto.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Loading

0 comments on commit b211fdb

Please sign in to comment.