From c42847911024280cb38445c2ad15c13d2899569b Mon Sep 17 00:00:00 2001 From: SantaSpeen Date: Tue, 11 Jul 2023 18:26:26 +0300 Subject: [PATCH] Add WebAPI --- .gitignore | 1 + requirements.txt | 7 +- src/core/__init__.py | 20 ++- src/core/core.py | 41 +++++- src/core/core.pyi | 9 +- src/core/utils.py | 9 +- src/main.py | 21 ++- .../config_provider-builtins.pyi | 3 +- src/modules/ConfigProvider/config_provider.py | 4 +- src/modules/WebAPISystem/__init__.py | 2 + src/modules/WebAPISystem/app.py | 129 ++++++++++++++++++ src/modules/WebAPISystem/models.py | 0 src/modules/WebAPISystem/utils.py | 87 ++++++++++++ src/modules/__init__.py | 2 + src/modules/i18n/files/en.json | 3 + src/modules/i18n/files/ru.json | 3 + src/modules/i18n/i18n-builtins.pyi | 3 + src/modules/i18n/i18n.py | 6 + 18 files changed, 319 insertions(+), 31 deletions(-) create mode 100644 src/modules/WebAPISystem/__init__.py create mode 100644 src/modules/WebAPISystem/app.py create mode 100644 src/modules/WebAPISystem/models.py create mode 100644 src/modules/WebAPISystem/utils.py diff --git a/.gitignore b/.gitignore index 1f15908..274026a 100644 --- a/.gitignore +++ b/.gitignore @@ -136,3 +136,4 @@ dmypy.json /src/mods /src/plugins /test/ +*test.py diff --git a/requirements.txt b/requirements.txt index e2c128d..17cc2f7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,8 @@ PyYAML~=6.0 prompt-toolkit~=3.0.38 -aiohttp~=3.8.4 \ No newline at end of file +aiohttp~=3.8.4 +uvicorn~=0.22.0 +fastapi~=0.100.0 +starlette~=0.27.0 +pydantic~=2.0.2 +click~=8.1.4 \ No newline at end of file diff --git a/src/core/__init__.py b/src/core/__init__.py index 99a4920..cd6f424 100644 --- a/src/core/__init__.py +++ b/src/core/__init__.py @@ -16,13 +16,6 @@ __author_email__ = 'admin@kuitoi.su' __license__ = "FPA" __copyright__ = 'Copyright 2023 © SantaSpeen (Maxim Khomutov)' -from main import parser - -args = parser.parse_args() -if args.version: - print(f"{__title__}:\n\tVersion: {__version__}\n\tBuild: {__build__}") - exit(0) - import asyncio import builtins import os @@ -31,14 +24,20 @@ import webbrowser import prompt_toolkit.shortcuts as shortcuts from .utils import get_logger +from core.core import Core +from main import parser from modules import ConfigProvider, EventsSystem, PluginsLoader from modules import Console from modules import MultiLanguage -from core.core import Core + +args = parser.parse_args() +if args.version: + print(f"{__title__}:\n\tVersion: {__version__}\n\tBuild: {__build__}") + exit(0) loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) -log = get_logger("init") +log = get_logger("core.init") # Config file init config_path = "kuitoi.yml" @@ -49,7 +48,7 @@ config = config_provider.open_config() if config.Server['debug'] is True: utils.set_debug_status() log.info("Debug enabled!") - log = get_logger("init") + log = get_logger("core.init") log.debug("Debug mode enabled!") log.debug(f"Server config: {config}") @@ -111,7 +110,6 @@ console.add_command("exit", console.stop, i18n.man_message_exit, i18n.help_messa if not os.path.exists("mods"): os.mkdir("mods") - log.debug("Initializing PluginsLoader...") if not os.path.exists("plugins"): os.mkdir("plugins") diff --git a/src/core/core.py b/src/core/core.py index e022294..1685ae6 100644 --- a/src/core/core.py +++ b/src/core/core.py @@ -5,11 +5,16 @@ # Licence: FPA # (c) kuitoi.su 2023 import asyncio +import time import zlib +from threading import Thread + +import uvicorn from core import utils -from .tcp_server import TCPServer -from .udp_server import UDPServer +from modules.WebAPISystem import app as webapp +from core.tcp_server import TCPServer +from core.udp_server import UDPServer class Client: @@ -145,6 +150,9 @@ class Core: self.server_port = config.Server["server_port"] self.tcp = TCPServer self.udp = UDPServer + self.web_thread = None + self.web_pool = webapp.data_pool + self.web_stop = None def get_client(self, sock=None, cid=None): if cid: @@ -172,15 +180,41 @@ class Core: if d: self.log.debug(f"Client ID: {cl.id} died...") + @staticmethod + def start_web(): + global uvserver + uvconfig = uvicorn.Config("modules.WebAPISystem.app:web_app", + host=config.WebAPI["server_ip"], + port=config.WebAPI["server_port"], + loop="asyncio") + uvserver = uvicorn.Server(uvconfig) + webapp.uvserver = uvserver + uvserver.run() + + @staticmethod + async def stop_me(): + while webapp.data_run[0]: + await asyncio.sleep(1) + raise KeyboardInterrupt + async def main(self): self.tcp = self.tcp(self, self.server_ip, self.server_port) self.udp = self.udp(self, self.server_ip, self.server_port) - tasks = [self.tcp.start(), self.udp.start(), console.start()] # self.check_alive() + tasks = [self.tcp.start(), self.udp.start(), console.start(), self.stop_me()] # self.check_alive() t = asyncio.wait(tasks, return_when=asyncio.FIRST_EXCEPTION) + if config.WebAPI["enabled"]: + self.log.debug("Initializing WebAPI...") + web_thread = Thread(target=self.start_web) + web_thread.start() + self.web_thread = web_thread + self.web_stop = webapp._stop self.log.info(i18n.start) + # watch = Thread(target=self.stop_me) + # watch.start() # TODO: Server auth ev.call_event("on_started") await t + # watch.join() # while True: # try: # tasks = [console.start(), self.tcp.start(), self.udp.start()] # self.check_alive() @@ -198,4 +232,5 @@ class Core: def stop(self): self.log.info(i18n.stop) + asyncio.run(self.web_stop()) exit(0) diff --git a/src/core/core.pyi b/src/core/core.pyi index 559862f..26c1eb7 100644 --- a/src/core/core.pyi +++ b/src/core/core.pyi @@ -6,6 +6,8 @@ # (c) kuitoi.su 2023 import asyncio from asyncio import StreamWriter, StreamReader +from threading import Thread +from typing import Callable from core import utils from .tcp_server import TCPServer @@ -46,10 +48,15 @@ class Core: self.loop = asyncio.get_event_loop() self.tcp = TCPServer self.udp = UDPServer + self.web_thread: Thread = None + self.web_stop: Callable = lambda: None def insert_client(self, client: Client) -> None: ... def create_client(self, *args, **kwargs) -> Client: ... async def check_alive(self) -> None: ... + @staticmethod + def start_web() -> None: ... + @staticmethod + def stop_me(self) -> None: ... async def main(self) -> None: ... def start(self) -> None: ... def stop(self) -> None: ... - diff --git a/src/core/utils.py b/src/core/utils.py index cd2e1d1..4eb3395 100644 --- a/src/core/utils.py +++ b/src/core/utils.py @@ -8,15 +8,16 @@ import logging import os -log_format = "[%(asctime)s | %(name)s | %(levelname)-5s] %(message)s" +log_format = "[%(asctime)s | %(name)-14s | %(levelname)-5s] %(message)s" +log_format_access = '[%(asctime)s | %(name)-14s | %(levelname)-5s] %(client_addr)s - "%(request_line)s" %(status_code)s' log_file = "server.log" log_level = logging.INFO # Инициализируем логирование logging.basicConfig(level=log_level, format=log_format) # Настройка логирование в файл. -if os.path.exists(log_file): - os.remove(log_file) -fh = logging.FileHandler(log_file) +# if os.path.exists(log_file): +# os.remove(log_file) +fh = logging.FileHandler(log_file, encoding='utf-8') fh.setFormatter(logging.Formatter(log_format)) diff --git a/src/main.py b/src/main.py index fc41bb9..574a7cd 100644 --- a/src/main.py +++ b/src/main.py @@ -14,13 +14,20 @@ parser.add_argument('-v', '--version', action="store_true", help='Print version parser.add_argument('--config', help='Patch to config file.', nargs='?', default=None, type=str) parser.add_argument('--language', help='Setting localisation.', nargs='?', default=None, type=str) -if __name__ == '__main__': +run = True + +def main(): + global run from core import Core core = Core() - try: - core.start() - except KeyboardInterrupt: - pass - finally: - core.stop() + while run: + try: + core.start() + except KeyboardInterrupt: + run = False + core.stop() + + +if __name__ == '__main__': + main() diff --git a/src/modules/ConfigProvider/config_provider-builtins.pyi b/src/modules/ConfigProvider/config_provider-builtins.pyi index 37b9029..cd947d2 100644 --- a/src/modules/ConfigProvider/config_provider-builtins.pyi +++ b/src/modules/ConfigProvider/config_provider-builtins.pyi @@ -2,8 +2,7 @@ class Config: Auth: dict Game: dict Server: dict - + WebAPI: dict def __repr__(self): return "%s(Auth=%r, Game=%r, Server=%r)" % (self.__class__.__name__, self.Auth, self.Game, self.Server) - class config (Config): ... diff --git a/src/modules/ConfigProvider/config_provider.py b/src/modules/ConfigProvider/config_provider.py index 4e13e72..5770d22 100644 --- a/src/modules/ConfigProvider/config_provider.py +++ b/src/modules/ConfigProvider/config_provider.py @@ -18,8 +18,8 @@ class Config: self.Game = game or {"map": "gridmap_v2", "players": 8, "max_cars": 1} self.Server = server or {"name": "KuiToi-Server", "description": "Welcome to KuiToi Server!", "language": "en", "server_ip": "0.0.0.0", "server_port": 30814, "debug": False} - # self.WebAPI = web or {"enabled": False, "server_ip": "127.0.0.1", "server_port": 8433, - # "secret_key": secrets.token_hex(16)} + self.WebAPI = web or {"enabled": False, "server_ip": "127.0.0.1", "server_port": 8433, + "secret_key": secrets.token_hex(16)} def __repr__(self): return "%s(Auth=%r, Game=%r, Server=%r)" % (self.__class__.__name__, self.Auth, self.Game, self.Server) diff --git a/src/modules/WebAPISystem/__init__.py b/src/modules/WebAPISystem/__init__.py new file mode 100644 index 0000000..3130ee7 --- /dev/null +++ b/src/modules/WebAPISystem/__init__.py @@ -0,0 +1,2 @@ +from .app import web_app +from .app import _stop diff --git a/src/modules/WebAPISystem/app.py b/src/modules/WebAPISystem/app.py new file mode 100644 index 0000000..554b7ef --- /dev/null +++ b/src/modules/WebAPISystem/app.py @@ -0,0 +1,129 @@ +import asyncio +from asyncio import CancelledError + +import uvicorn +from fastapi import FastAPI, Request, HTTPException +from fastapi.exceptions import RequestValidationError +from starlette import status +from starlette.exceptions import HTTPException as StarletteHTTPException +from starlette.responses import JSONResponse +from uvicorn.config import LOGGING_CONFIG + +import core.utils +from . import utils + +# from .models import SecretKey + +web_app = FastAPI() +log = core.utils.get_logger("web") + +uvserver = None +data_pool = [] +data_run = [True] + +LOGGING_CONFIG["formatters"]["default"]['fmt'] = core.utils.log_format +LOGGING_CONFIG["formatters"]["access"]["fmt"] = core.utils.log_format_access +LOGGING_CONFIG["formatters"].update({ + "file_default": { + "fmt": core.utils.log_format + }, + "file_access": { + "fmt": core.utils.log_format_access + } +}) +LOGGING_CONFIG["handlers"]["default"]['stream'] = "ext://sys.stdout" +LOGGING_CONFIG["handlers"].update({ + "file_default": { + "class": "logging.handlers.RotatingFileHandler", + "filename": "webserver.log" + }, + "file_access": { + "class": "logging.handlers.RotatingFileHandler", + "filename": "webserver.log" + } +}) +LOGGING_CONFIG["loggers"]["uvicorn"]["handlers"].append("file_default") +LOGGING_CONFIG["loggers"]["uvicorn.access"]["handlers"].append("file_access") + + +def response(data=None, code=status.HTTP_200_OK, error_code=0, error_message=None): + if 200 >= code <= 300: + return JSONResponse(content={"result": data, "error": None}, status_code=code) + return JSONResponse( + content={"error": {"code": error_code if error_code else code, "message": f"{error_message}"}, "result": None}, + status_code=code) + + +@web_app.get("/") +async def index(): + log.debug("Request IndexPage;") + return response("Index page") + + +@web_app.get("/method/{method}") +async def _method(method, secret_key: str = None): + # log.debug(f"Request method; kwargs: {kwargs}") + is_auth = secret_key == config.WebAPI["secret_key"] + spl = method.split(".") + if len(spl) != 2: + raise StarletteHTTPException(405) + api_class, api_method = spl + match api_class: + case "events": + match api_method, is_auth: + case "get", False: + return response(data_pool) + raise StarletteHTTPException(404) + + +async def _stop(): + await asyncio.sleep(1) + uvserver.should_exit = True + data_run[0] = False + + +@web_app.get("/stop") +async def stop(secret_key: str): + log.debug(f"Request stop; secret key: {secret_key}") + if secret_key == config.WebAPI["secret_key"]: + log.info("Stopping Web server") + asyncio.create_task(_stop()) + return response("Web server stopped") + + +@web_app.exception_handler(HTTPException) +async def default_exception_handler(request: Request, exc: HTTPException): + return response( + code=status.HTTP_500_INTERNAL_SERVER_ERROR, + error_code=exc.status_code, error_message=f"Internal Server Error: {exc.status_code}" + ) + + +@web_app.exception_handler(StarletteHTTPException) +async def http_exception_handler(request: Request, exc: StarletteHTTPException): + code = exc.status_code + if code == status.HTTP_405_METHOD_NOT_ALLOWED: + return response(code=code, error_message="Method Not Allowed") + if code == status.HTTP_404_NOT_FOUND: + return response(code=code, error_message="Method not Found") + return response(code=code, error_message="Unhandled error..") + + +@web_app.exception_handler(RequestValidationError) +async def request_validation_exception_handler(request: Request, exc: RequestValidationError): + code = status.HTTP_422_UNPROCESSABLE_ENTITY + return response(code=code, error_message="Request Validation Error") + + +utils.hack_fastapi() + +if __name__ == '__main__': + try: + uvconfig = uvicorn.Config(web_app, + host=config.WebAPI["server_ip"], + port=config.WebAPI["server_port"], + loop="asyncio") + uvserver = uvicorn.Server(uvconfig) + uvserver.run() + except KeyboardInterrupt or CancelledError: + pass diff --git a/src/modules/WebAPISystem/models.py b/src/modules/WebAPISystem/models.py new file mode 100644 index 0000000..e69de29 diff --git a/src/modules/WebAPISystem/utils.py b/src/modules/WebAPISystem/utils.py new file mode 100644 index 0000000..ca6fda9 --- /dev/null +++ b/src/modules/WebAPISystem/utils.py @@ -0,0 +1,87 @@ +import asyncio +import sys + +import click +from uvicorn.server import Server, logger + +from uvicorn.lifespan import on + + +def ev_log_started_message(self, listeners) -> None: + cfg = self.config + if cfg.fd is not None: + sock = listeners[0] + logger.info(i18n.web_start.format(sock.getsockname())) + elif cfg.uds is not None: + logger.info(i18n.web_start.format(cfg.uds)) + else: + addr_format = "%s://%s:%d" + host = "0.0.0.0" if cfg.host is None else cfg.host + if ":" in host: + addr_format = "%s://[%s]:%d" + port = cfg.port + if port == 0: + port = listeners[0].getsockname()[1] + protocol_name = "https" if cfg.ssl else "http" + message = i18n.web_start.format(addr_format) + color_message = (i18n.web_start.format(click.style(addr_format, bold=True))) + logger.info(message, protocol_name, host, port, extra={"color_message": color_message}) + + +async def ev_shutdown(self, sockets=None) -> None: + logger.debug("Shutting down") + for server in self.servers: + server.close() + for sock in sockets or []: + sock.close() + for server in self.servers: + await server.wait_closed() + for connection in list(self.server_state.connections): + connection.shutdown() + await asyncio.sleep(0.1) + try: + await asyncio.wait_for(self._wait_tasks_to_complete(), timeout=self.config.timeout_graceful_shutdown) + except asyncio.TimeoutError: + logger.error("Cancel %s running task(s), timeout graceful shutdown exceeded",len(self.server_state.tasks)) + for t in self.server_state.tasks: + if sys.version_info < (3, 9): + t.cancel() + else: + t.cancel(msg="Task cancelled, timeout graceful shutdown exceeded") + if not self.force_exit: + await self.lifespan.shutdown() + + +async def on_startup(self) -> None: + self.logger.debug("Waiting for application startup.") + loop = asyncio.get_event_loop() + main_lifespan_task = loop.create_task(self.main()) # noqa: F841 + startup_event = {"type": "lifespan.startup"} + await self.receive_queue.put(startup_event) + await self.startup_event.wait() + if self.startup_failed or (self.error_occured and self.config.lifespan == "on"): + self.logger.error("Application startup failed. Exiting.") + self.should_exit = True + else: + self.logger.debug("Application startup complete.") + + +async def on_shutdown(self) -> None: + if self.error_occured: + return + self.logger.debug("Waiting for application shutdown.") + shutdown_event = {"type": "lifespan.shutdown"} + await self.receive_queue.put(shutdown_event) + await self.shutdown_event.wait() + if self.shutdown_failed or (self.error_occured and self.config.lifespan == "on"): + self.logger.error("Application shutdown failed. Exiting.") + self.should_exit = True + else: + self.logger.debug("Application shutdown complete.") + + +def hack_fastapi(): + Server.shutdown = ev_shutdown + Server._log_started_message = ev_log_started_message + on.LifespanOn.startup = on_startup + on.LifespanOn.shutdown = on_shutdown diff --git a/src/modules/__init__.py b/src/modules/__init__.py index ddaa4d0..6671fa1 100644 --- a/src/modules/__init__.py +++ b/src/modules/__init__.py @@ -11,3 +11,5 @@ from .ConfigProvider import ConfigProvider, Config from .i18n import MultiLanguage from .EventsSystem import EventsSystem from .PluginsLoader import PluginsLoader +from .WebAPISystem import web_app +from .WebAPISystem import _stop as stop_web diff --git a/src/modules/i18n/files/en.json b/src/modules/i18n/files/en.json index 7447b66..a260259 100644 --- a/src/modules/i18n/files/en.json +++ b/src/modules/i18n/files/en.json @@ -21,6 +21,9 @@ "GUI_enter_key_message": "Please type your key:", "GUI_cannot_open_browser": "Cannot open browser.\nUse this link: {}", + "": "Web phases", + "web_start": "WebAPI running on {} (Press CTRL+C to quit)", + "": "Command: man", "man_message_man": "man - display the manual page for COMMAND.\nUsage: man COMMAND", "help_message_man": "Display the manual page for COMMAND.", diff --git a/src/modules/i18n/files/ru.json b/src/modules/i18n/files/ru.json index ffdb522..48814b5 100644 --- a/src/modules/i18n/files/ru.json +++ b/src/modules/i18n/files/ru.json @@ -21,6 +21,9 @@ "GUI_enter_key_message": "Пожалуйста введите ключ:", "GUI_cannot_open_browser": "Не получилось открыть браузер.\nИспользуй эту ссылку: {}", + "": "Web phases", + "web_start": "WebAPI запустился на {} (CTRL+C для выключения)", + "": "Command: man", "man_message_man": "man - Показывает страничку помощи для COMMAND.\nИспользование: man COMMAND", "help_message_man": "Показывает страничку помощи для COMMAND.", diff --git a/src/modules/i18n/i18n-builtins.pyi b/src/modules/i18n/i18n-builtins.pyi index 86acae2..d9523f7 100644 --- a/src/modules/i18n/i18n-builtins.pyi +++ b/src/modules/i18n/i18n-builtins.pyi @@ -21,6 +21,9 @@ class i18n: GUI_enter_key_message: str = data["GUI_enter_key_message"] GUI_cannot_open_browser: str = data["GUI_cannot_open_browser"] + # Web phases + web_start: str = data["web_start"] + # Command: man man_message_man: str = data["man_message_man"] help_message_man: str = data["help_message_man"] diff --git a/src/modules/i18n/i18n.py b/src/modules/i18n/i18n.py index 4a4c6ca..b5d1fd2 100644 --- a/src/modules/i18n/i18n.py +++ b/src/modules/i18n/i18n.py @@ -38,6 +38,9 @@ class i18n: self.GUI_enter_key_message: str = data["GUI_enter_key_message"] self.GUI_cannot_open_browser: str = data["GUI_cannot_open_browser"] + # Web phases + self.web_start: str = data["web_start"] + # Command: man self.man_message_man: str = data["man_message_man"] self.help_message_man: str = data["help_message_man"] @@ -108,6 +111,9 @@ class MultiLanguage: "GUI_enter_key_message": "Please type your key:", "GUI_cannot_open_browser": "Cannot open browser.\nUse this link: {}", + "": "Web phases", + "web_start": "WebAPI running on {} (Press CTRL+C to quit)", + "": "Command: man", "man_message_man": "man - display the manual page for COMMAND.\nUsage: man COMMAND", "help_message_man": "Display the manual page for COMMAND.",