diff --git a/backend/app/adapter/api.py b/backend/app/adapter/api.py index 5d893bb..f77482a 100644 --- a/backend/app/adapter/api.py +++ b/backend/app/adapter/api.py @@ -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, @@ -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=[], @@ -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)) diff --git a/backend/app/adapter/sio_srv.py b/backend/app/adapter/sio_srv.py index f312100..1a0218f 100644 --- a/backend/app/adapter/sio_srv.py +++ b/backend/app/adapter/sio_srv.py @@ -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): @@ -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)) diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..8301d3f --- /dev/null +++ b/backend/app/config.py @@ -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"} diff --git a/backend/app/constant.py b/backend/app/constant.py new file mode 100644 index 0000000..df98b4e --- /dev/null +++ b/backend/app/constant.py @@ -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" diff --git a/backend/tests/e2e/test_game.py b/backend/tests/e2e/test_game.py index 4ebd696..375b922 100644 --- a/backend/tests/e2e/test_game.py +++ b/backend/tests/e2e/test_game.py @@ -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 @@ -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 diff --git a/backend/tests/e2e/test_socketio.py b/backend/tests/e2e/test_socketio.py index 75b7ef2..8feacdd 100644 --- a/backend/tests/e2e/test_socketio.py +++ b/backend/tests/e2e/test_socketio.py @@ -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 @@ -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): @@ -54,7 +67,7 @@ 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) @@ -62,7 +75,7 @@ async def verify_join(self, expect_clients: List): 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() @@ -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])