Skip to content

Commit

Permalink
[#37] refactor: modify socket.io server
Browse files Browse the repository at this point in the history
- validate event arguments with pydantic base model
- rename the field `room` to game ID in `init` , `deinit` events
- add the field `nickname` to the event data `init` and `deinit`
- move constants , configurable parameters in appropriate modules

Signed-off-by: T.H. <[email protected]>
  • Loading branch information
metalalive committed Dec 10, 2023
1 parent f0fcb9c commit a7dea19
Show file tree
Hide file tree
Showing 6 changed files with 134 additions and 40 deletions.
9 changes: 8 additions & 1 deletion backend/app/adapter/api.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import asyncio
import logging
from contextlib import asynccontextmanager

from fastapi import FastAPI, APIRouter, status as FastApiHTTPstatus
from fastapi.responses import JSONResponse
from hypercorn.config import Config
from hypercorn.asyncio import serve
from app.config import LOG_FILE_PATH

from app.dto import (
CreateGameReqDto,
Expand All @@ -20,10 +22,15 @@
SwitchInvestigatorUseCase,
UpdateGameDifficultyUseCase,
)
from app.config import REST_HOST, REST_PORT
from app.domain import GameError
from app.adapter.repository import get_repository
from app.adapter.presenter import read_investigator_presenter, create_game_presenter

_logger = logging.getLogger(__name__)
_logger.setLevel(logging.WARNING)
_logger.addHandler(logging.FileHandler(LOG_FILE_PATH["REST"], mode="a"))

_router = APIRouter(
prefix="", # could be API versioning e.g. /v0.0.1/* , /v2.0.1/*
dependencies=[],
Expand Down Expand Up @@ -106,6 +113,6 @@ def init_app_server() -> FastAPI:
def start_web_app() -> None:
# TODO, parameterize with separate python module or `toml` file
cfg = Config()
cfg.bind = ["localhost:8081"]
cfg.bind = ["%s:%s" % (REST_HOST, REST_PORT)]
app = init_app_server()
asyncio.run(serve(app, cfg))
103 changes: 73 additions & 30 deletions backend/app/adapter/sio_srv.py
Original file line number Diff line number Diff line change
@@ -1,62 +1,104 @@
import logging
import asyncio
import os

from typing import Dict

import socketio
from hypercorn.config import Config
from hypercorn.asyncio import serve
from pydantic import BaseModel, ConfigDict, ValidationError

from app.constant import RealTimeCommConst as RtcConst
from app.config import RTC_HOST, RTC_PORT, LOG_FILE_PATH

toplvl_namespace = "/game"
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
_logger = logging.getLogger(__name__)
_logger.setLevel(logging.WARNING)
_logger.addHandler(logging.FileHandler(LOG_FILE_PATH["RTC"], mode="a"))


@srv.on("chat", namespace=toplvl_namespace)
async def _forward_chat_msg(sid, data: Dict):
required = ["msg", "nickname", "gameID"]
class ChatMsgData(BaseModel):
model_config = ConfigDict(extra="forbid")
msg: str
nickname: str
gameID: str
client: str ## client session ID


def field_check(name) -> tuple:
return (name, data.get(name, None))
class RtcInitMsgData(BaseModel):
model_config = ConfigDict(extra="forbid")
nickname: str
gameID: str
client: str

def field_filter(item) -> bool:
return item[1] is None

not_exist = filter(field_filter, map(field_check, required))
not_exist = list(not_exist)
if len(not_exist) == 0:
@srv.on(RtcConst.EVENTS.CHAT.value, namespace=RtcConst.NAMESPACE)
async def _forward_chat_msg(sid, data: Dict):
try:
ChatMsgData(**data)
await srv.emit(
"chat", data, namespace=toplvl_namespace, room=data["gameID"], skip_sid=sid
RtcConst.EVENTS.CHAT.value,
data,
namespace=RtcConst.NAMESPACE,
room=data["gameID"],
skip_sid=sid,
)
except ValidationError as e:
error = e.errors(include_url=False, include_input=False)
await srv.emit(
RtcConst.EVENTS.CHAT.value, data=error, namespace=RtcConst.NAMESPACE, to=sid
)
else:

def extract_field(item) -> str:
return item[0]

error = {"missing_fields": list(map(extract_field, not_exist))}
await srv.emit("chat", data=error, namespace=toplvl_namespace, to=sid)


@srv.on("init", namespace=toplvl_namespace)
@srv.on(RtcConst.EVENTS.INIT.value, namespace=RtcConst.NAMESPACE)
async def init_communication(sid, data: Dict):
try:
await srv.enter_room(sid, room=data["room"], namespace=toplvl_namespace)
RtcInitMsgData(**data)
await srv.enter_room(sid, room=data["gameID"], namespace=RtcConst.NAMESPACE)
data["succeed"] = True
except (ValueError, KeyError) as e:
await srv.emit(
RtcConst.EVENTS.INIT.value,
data,
namespace=RtcConst.NAMESPACE,
room=data["gameID"],
)
except ValidationError as e:
_logger.error("%s", e)
data["succeed"] = False
await srv.emit("init", data, namespace=toplvl_namespace, room=data["room"])
error = e.errors(include_url=False, include_input=False)
error["succeed"] = False
await srv.emit(
RtcConst.EVENTS.INIT.value,
namespace=RtcConst.NAMESPACE,
data=error,
to=sid,
)


@srv.on("deinit", namespace=toplvl_namespace)
@srv.on(RtcConst.EVENTS.DEINIT.value, namespace=RtcConst.NAMESPACE)
async def deinit_communication(sid, data: Dict):
try:
await srv.leave_room(sid, room=data["room"], namespace=toplvl_namespace)
RtcInitMsgData(**data)
await srv.leave_room(sid, room=data["gameID"], namespace=RtcConst.NAMESPACE)
data["succeed"] = True
except KeyError as e:
await srv.emit(
RtcConst.EVENTS.DEINIT.value,
data,
namespace=RtcConst.NAMESPACE,
room=data["gameID"],
)
except ValidationError as e:
_logger.error("%s", e)
error = e.errors(include_url=False, include_input=False)
data["succeed"] = False
await srv.emit("deinit", data, namespace=toplvl_namespace, room=data["room"])
await srv.emit(
RtcConst.EVENTS.DEINIT.value,
namespace=RtcConst.NAMESPACE,
data=error,
to=sid,
)


def gen_srv_task(host: str):
Expand All @@ -70,4 +112,5 @@ def entry() -> None:
with open("pid.log", "w") as f:
pid = os.getpid()
f.write(str(pid))
asyncio.run(gen_srv_task("localhost:8082"))
url = "%s:%s" % (RTC_HOST, RTC_PORT)
asyncio.run(gen_srv_task(url))
7 changes: 7 additions & 0 deletions backend/app/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
REST_HOST = "localhost"
REST_PORT = 8081

RTC_HOST = "localhost"
RTC_PORT = 8082

LOG_FILE_PATH = {"REST": "./log/dev/rest-server.log", "RTC": "./log/dev/rtc-server.log"}
16 changes: 16 additions & 0 deletions backend/app/constant.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
from enum import Enum


class GameRtcEvent(Enum):
"""
events which synchronize game state in real-time communication
"""

INIT = "init"
DEINIT = "deinit"
CHAT = "chat"


class RealTimeCommConst:
EVENTS = GameRtcEvent
NAMESPACE = "/game"
3 changes: 2 additions & 1 deletion backend/tests/e2e/test_game.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from fastapi.testclient import TestClient
import pytest

from app.config import REST_HOST, REST_PORT
from app.adapter.api import init_app_server
from app.domain import GameErrorCodes

Expand All @@ -9,7 +10,7 @@
def test_client():
with TestClient(
app=init_app_server(),
base_url="http://localhost:8081",
base_url="http://%s:%s" % (REST_HOST, REST_PORT),
raise_server_exceptions=True,
) as _client:
yield _client
Expand Down
36 changes: 28 additions & 8 deletions backend/tests/e2e/test_socketio.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,14 @@
import pytest
import socketio

SERVER_URL = "http://localhost:8082"
from app.config import RTC_HOST, RTC_PORT
from app.constant import RealTimeCommConst as RtcConst

SERVER_URL = "http://%s:%s" % (RTC_HOST, RTC_PORT)


class MockClient:
def __init__(self, nickname: str, toplvl_namespace="/game"):
def __init__(self, nickname: str, toplvl_namespace=RtcConst.NAMESPACE):
self._sio_client = socketio.AsyncSimpleClient(logger=False)
self._toplvl_namespace = toplvl_namespace
self._nickname = nickname
Expand All @@ -33,19 +36,29 @@ async def disconnect(self):

async def join(self, room_id: str):
await self._sio_client.emit(
"init", data={"client": self._sio_client.sid, "room": room_id}
RtcConst.EVENTS.INIT.value,
data={
"nickname": self._nickname,
"client": self._sio_client.sid,
"gameID": room_id,
},
)

async def leave(self, room_id: str):
await self._sio_client.emit(
"deinit", data={"client": self._sio_client.sid, "room": room_id}
RtcConst.EVENTS.DEINIT.value,
data={
"nickname": self._nickname,
"client": self._sio_client.sid,
"gameID": room_id,
},
)

async def chat(self, room_id: str, msg: str):
item = {"nickname": self._nickname, "msg": msg}
data = {"client": self._sio_client.sid, "gameID": room_id}
data.update(item)
await self._sio_client.emit("chat", data=data)
await self._sio_client.emit(RtcConst.EVENTS.CHAT.value, data=data)
self._msg_log.append(item)

async def verify_join(self, expect_clients: List):
Expand All @@ -54,15 +67,15 @@ async def verify_join(self, expect_clients: List):
for expect_sid in expect_client_sid:
evts = await self._sio_client.receive(timeout=1)
assert len(evts) == 2
assert evts[0] == "init"
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)

async def verify_chat(self, expect_sender, expect_error: Dict = None):
evts: List = await self._sio_client.receive(timeout=3)
assert len(evts) == 2
assert evts[0] == "chat"
assert evts[0] == RtcConst.EVENTS.CHAT.value
if expect_error:
assert evts[1] == expect_error
self.messages_log.pop()
Expand Down Expand Up @@ -115,7 +128,14 @@ async def test_chat(self):

await clients[0].chat(room_id="a001", msg=None)
await clients[0].verify_chat(
clients[0], expect_error={"missing_fields": ["msg"]}
clients[0],
expect_error=[
{
"type": "string_type",
"loc": ["msg"],
"msg": "Input should be a valid string",
}
],
)

await clients[1].verify_chat(clients[0])
Expand Down

0 comments on commit a7dea19

Please sign in to comment.