mirror of
https://gitea.tendokyu.moe/eamuse/eaapi.git
synced 2024-11-27 23:40:50 +01:00
Server stuff
This commit is contained in:
parent
84a87f5a7f
commit
b8f8ac759b
@ -1 +0,0 @@
|
||||
Subproject commit dec7ca3536cf459be21b7284358bf2bea4eb3d14
|
43
eaapi/server/README.md
Normal file
43
eaapi/server/README.md
Normal file
@ -0,0 +1,43 @@
|
||||
# eaapi.server
|
||||
|
||||
## Quickstart
|
||||
|
||||
```py
|
||||
server = EAMServer("http://127.0.0.1:5000")
|
||||
|
||||
@server.handler("message", "get")
|
||||
def message(ctx):
|
||||
ctx.resp.append("message", expire="300", status="0")
|
||||
|
||||
server.run("0.0.0.0", 5000)
|
||||
```
|
||||
|
||||
```py
|
||||
EAMServer(
|
||||
# The URL this server can be access at. Used for services
|
||||
public_url,
|
||||
# Add `/<service>/` as a prefix when generating service urls (useful when debugging games)
|
||||
prefix_services: bool = False,
|
||||
# If both the URL and the query params match, which one gets the final say?
|
||||
prioritise_params: bool = False,
|
||||
# Include e-Amusement specific details of why requests failed in the responses
|
||||
verbose_errors: bool = False,
|
||||
# The operation mode in services.get's response
|
||||
services_mode: eaapi.const.ServicesMode = eaapi.const.ServicesMode.Operation,
|
||||
# The NTP server to use in services.get
|
||||
ntp_server: str = "ntp://pool.ntp.org/",
|
||||
# Keepalive server to use in serices.get. We'll use our own if one is not specified
|
||||
keepalive_server: str = None
|
||||
)
|
||||
|
||||
@handler(
|
||||
# Module name to handle. Will curry if method is not provided
|
||||
module,
|
||||
# Method name to handle
|
||||
method=None,
|
||||
# The datecode prefix to match during routing
|
||||
dc_prefix=None,
|
||||
# The service to use. Likely `local` or `local2` when handling game functions
|
||||
service=None
|
||||
)
|
||||
```
|
13
eaapi/server/__init__.py
Normal file
13
eaapi/server/__init__.py
Normal file
@ -0,0 +1,13 @@
|
||||
from .server import EAMServer
|
||||
from .context import CallContext
|
||||
from .model import Model, ModelMatcher, DatecodeMatcher
|
||||
from .exceptions import EAMHTTPException
|
||||
from .controller import Controller
|
||||
|
||||
__all__ = (
|
||||
"EAMServer",
|
||||
"CallContext",
|
||||
"Model", "ModelMatcher", "DatecodeMatcher",
|
||||
"EAMHTTPException",
|
||||
"Controller",
|
||||
)
|
4
eaapi/server/__main__.py
Normal file
4
eaapi/server/__main__.py
Normal file
@ -0,0 +1,4 @@
|
||||
from .server import EAMServer
|
||||
|
||||
app = EAMServer("http://127.0.0.1:5000", verbose_errors=True)
|
||||
app.run("0.0.0.0", 5000, debug=True)
|
45
eaapi/server/const.py
Normal file
45
eaapi/server/const.py
Normal file
@ -0,0 +1,45 @@
|
||||
# Services where the module and service name match
|
||||
TRIVIAL_SERVICES = [
|
||||
"pcbtracker",
|
||||
"message",
|
||||
"facility",
|
||||
"pcbevent",
|
||||
"cardmng",
|
||||
"package",
|
||||
"userdata",
|
||||
"userid",
|
||||
"dlstatus",
|
||||
"eacoin",
|
||||
# "traceroute",
|
||||
"apsmanager",
|
||||
"sidmgr",
|
||||
]
|
||||
|
||||
# Just chilling here until I figure out where these route
|
||||
UNMAPPED_SERVICES = {
|
||||
"???0": "numbering",
|
||||
"???1": "pkglist",
|
||||
"???2": "posevent",
|
||||
"???3": "lobby",
|
||||
"???4": "lobby2",
|
||||
"???5": "netlog", # ins.netlog (?)
|
||||
"???6": "globby",
|
||||
"???7": "matching",
|
||||
"???8": "netsci",
|
||||
}
|
||||
|
||||
MODULE_SERVICES = "services"
|
||||
RESERVED_MODULES = [
|
||||
MODULE_SERVICES,
|
||||
]
|
||||
|
||||
METHOD_SERVICES_GET = "get"
|
||||
|
||||
SERVICE_SERVICES = "services"
|
||||
SERVICE_KEEPALIVE = "keepalive"
|
||||
SERVICE_NTP = "ntp"
|
||||
RESERVED_SERVICES = [
|
||||
SERVICE_SERVICES,
|
||||
SERVICE_KEEPALIVE,
|
||||
SERVICE_NTP,
|
||||
]
|
105
eaapi/server/context.py
Normal file
105
eaapi/server/context.py
Normal file
@ -0,0 +1,105 @@
|
||||
import eaapi
|
||||
|
||||
from . import exceptions as exc
|
||||
from .model import Model
|
||||
|
||||
|
||||
NODE_CALL = "call"
|
||||
NODE_RESP = "response"
|
||||
|
||||
|
||||
class CallContext:
|
||||
def __init__(self, request, decoder, call, eainfo, compressed):
|
||||
if call.name != NODE_CALL:
|
||||
raise exc.CallNodeMissing
|
||||
self._request = request
|
||||
self._decoder = decoder
|
||||
self._call: eaapi.XMLNode = call
|
||||
self._eainfo: str | None = eainfo
|
||||
self._compressed: bool = compressed
|
||||
self._resp: eaapi.XMLNode = eaapi.XMLNode.void(NODE_RESP)
|
||||
|
||||
self._module: str | None = None
|
||||
self._method: str | None = None
|
||||
self._url_slash: bool | None = None
|
||||
|
||||
self._model: Model = Model.from_model_str(call.get("model"))
|
||||
|
||||
@property
|
||||
def module(self):
|
||||
return self._module
|
||||
|
||||
@property
|
||||
def method(self):
|
||||
return self._method
|
||||
|
||||
@property
|
||||
def url_slash(self):
|
||||
return self._url_slash
|
||||
|
||||
@property
|
||||
def request(self):
|
||||
return self._request
|
||||
|
||||
@property
|
||||
def was_xml_string(self):
|
||||
return self._decoder.is_xml_string
|
||||
|
||||
@property
|
||||
def was_compressed(self):
|
||||
return self._compressed
|
||||
|
||||
@property
|
||||
def call(self):
|
||||
return self._call
|
||||
|
||||
@property
|
||||
def resp(self):
|
||||
return self._resp
|
||||
|
||||
@property
|
||||
def model(self):
|
||||
return self._model
|
||||
|
||||
@property
|
||||
def srcid(self):
|
||||
return self._call.get("srcid")
|
||||
|
||||
@property
|
||||
def tag(self):
|
||||
return self._call.get("tag")
|
||||
|
||||
def get_root(self):
|
||||
return self.call.xpath(self.module)
|
||||
|
||||
def abort(self, status="1"):
|
||||
return self.resp.append(self.module, status=status)
|
||||
|
||||
def ok(self):
|
||||
return self.abort("0")
|
||||
|
||||
|
||||
class ResponseContext:
|
||||
def __init__(self, resp, decoder, response, compressed):
|
||||
if response.name != NODE_RESP:
|
||||
raise exc.CallNodeMissing
|
||||
self._resp = resp
|
||||
self._decoder = decoder
|
||||
self._response = response
|
||||
self._compressed = compressed
|
||||
|
||||
@property
|
||||
def resp(self):
|
||||
return self._resp
|
||||
|
||||
@property
|
||||
def decoder(self):
|
||||
return self._decoder
|
||||
|
||||
@property
|
||||
def response(self):
|
||||
return self._response
|
||||
|
||||
@property
|
||||
def compressed(self):
|
||||
return self._compressed
|
210
eaapi/server/controller.py
Normal file
210
eaapi/server/controller.py
Normal file
@ -0,0 +1,210 @@
|
||||
from typing import Callable
|
||||
from collections import defaultdict
|
||||
from abc import ABC
|
||||
|
||||
from .context import CallContext
|
||||
from .model import ModelMatcher
|
||||
from .const import RESERVED_MODULES, RESERVED_SERVICES, TRIVIAL_SERVICES, MODULE_SERVICES, METHOD_SERVICES_GET
|
||||
|
||||
import eaapi
|
||||
|
||||
|
||||
Handler = Callable[[CallContext], None]
|
||||
|
||||
|
||||
class IController(ABC):
|
||||
_name: str
|
||||
|
||||
def get_handler(self, ctx: CallContext) -> Handler | None:
|
||||
raise NotImplementedError
|
||||
|
||||
def get_service_routes(self, ctx: CallContext | None) -> dict[str, str]:
|
||||
raise NotImplementedError
|
||||
|
||||
def serviced_prefixes(self) -> list[str]:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class Controller(IController):
|
||||
def __init__(self, server, endpoint="", matcher: None | ModelMatcher = None):
|
||||
from .server import EAMServer
|
||||
self._server: EAMServer = server
|
||||
server.controllers.append(self)
|
||||
|
||||
import inspect
|
||||
caller = inspect.getmodule(inspect.stack()[1][0])
|
||||
assert caller is not None
|
||||
self._name = caller.__name__
|
||||
|
||||
self._handlers: dict[
|
||||
tuple[str | None, str | None],
|
||||
list[tuple[ModelMatcher, Handler]]
|
||||
] = defaultdict(lambda: [])
|
||||
self._endpoint = endpoint
|
||||
self._matcher = matcher
|
||||
|
||||
self._services: set[tuple[ModelMatcher, str]] = set()
|
||||
|
||||
self._pre_handler: list[Callable[[CallContext, Handler], Handler]] = []
|
||||
|
||||
@property
|
||||
def server(self):
|
||||
return self._server
|
||||
|
||||
def on_pre(self, callback):
|
||||
if callback not in self._pre_handler:
|
||||
self._pre_handler.append(callback)
|
||||
|
||||
def add_dummy_service(
|
||||
self,
|
||||
service: str,
|
||||
matcher: ModelMatcher | None = None,
|
||||
unsafe_force_bypass_reserved: bool = False
|
||||
):
|
||||
if not unsafe_force_bypass_reserved:
|
||||
if service in RESERVED_SERVICES:
|
||||
raise KeyError(
|
||||
f"{service} is a reserved service provided by default.\n"
|
||||
"Pass unsafe_force_bypass_reserved=True to override this implementation"
|
||||
)
|
||||
if matcher is None:
|
||||
matcher = ModelMatcher()
|
||||
self._services.add((matcher, service))
|
||||
|
||||
def register_handler(
|
||||
self,
|
||||
handler: Handler,
|
||||
module: str,
|
||||
method: str,
|
||||
matcher: ModelMatcher | None = None,
|
||||
service: str | None = None,
|
||||
unsafe_force_bypass_reserved: bool = False
|
||||
):
|
||||
if not unsafe_force_bypass_reserved:
|
||||
if module in RESERVED_MODULES:
|
||||
raise KeyError(
|
||||
f"{module} is a reserved module provided by default.\n"
|
||||
"Pass unsafe_force_bypass_reserved=True to override this implementation"
|
||||
)
|
||||
|
||||
if service is not None and service in RESERVED_SERVICES:
|
||||
raise KeyError(
|
||||
f"{service} is a reserved service provided by default.\n"
|
||||
"Pass unsafe_force_bypass_reserved=True to override this implementation"
|
||||
)
|
||||
|
||||
if service is None:
|
||||
if module not in TRIVIAL_SERVICES:
|
||||
raise ValueError(f"Unable to identify service for {module}")
|
||||
service = module
|
||||
|
||||
handlers = self._handlers[(module, method)]
|
||||
for i in handlers:
|
||||
if matcher is None and i[0] is None:
|
||||
raise ValueError(f"Duplicate default handler for {module}.{method}")
|
||||
if matcher == i[0]:
|
||||
raise ValueError(f"Duplicate handler for {module}.{method} ({matcher})")
|
||||
matcher_ = matcher or ModelMatcher()
|
||||
handlers.append((matcher_, handler))
|
||||
handlers.sort(key=lambda x: x[0])
|
||||
|
||||
self._services.add((matcher_, service))
|
||||
|
||||
def handler(
|
||||
self,
|
||||
module: str,
|
||||
method: str | None = None,
|
||||
matcher: ModelMatcher | None = None,
|
||||
service: str | None = None,
|
||||
unsafe_force_bypass_reserved: bool = False
|
||||
):
|
||||
if method is None:
|
||||
def h2(method):
|
||||
return self.handler(module, method, matcher, service)
|
||||
return h2
|
||||
|
||||
# Commented out for MachineCallContext bodge
|
||||
# def decorator(handler: Handler):
|
||||
def decorator(handler):
|
||||
self.register_handler(handler, module, method, matcher, service, unsafe_force_bypass_reserved)
|
||||
return handler
|
||||
return decorator
|
||||
|
||||
def get_handler(self, ctx):
|
||||
if self._matcher is not None:
|
||||
if not self._matcher.matches(ctx.model):
|
||||
return None
|
||||
|
||||
handlers = self._handlers[(ctx.module, ctx.method)]
|
||||
if not handlers:
|
||||
return None
|
||||
|
||||
for matcher, handler in handlers:
|
||||
if matcher.matches(ctx.model):
|
||||
for i in self._pre_handler:
|
||||
handler = i(ctx, handler)
|
||||
return handler
|
||||
|
||||
return None
|
||||
|
||||
def get_service_routes(self, ctx: CallContext | None):
|
||||
endpoint = self._server.expand_url(self._endpoint)
|
||||
|
||||
if ctx is None:
|
||||
return {
|
||||
service: endpoint
|
||||
for _, service in self._services
|
||||
}
|
||||
|
||||
if self._matcher is not None and not self._matcher.matches(ctx.model):
|
||||
return {}
|
||||
|
||||
return {
|
||||
service: endpoint
|
||||
for matcher, service in self._services
|
||||
if matcher.matches(ctx.model)
|
||||
}
|
||||
|
||||
def serviced_prefixes(self) -> list[str]:
|
||||
return [self._endpoint]
|
||||
|
||||
|
||||
class ServicesController(IController):
|
||||
def __init__(self, server, services_mode: eaapi.const.ServicesMode):
|
||||
from .server import EAMServer
|
||||
self._server: EAMServer = server
|
||||
|
||||
self.services_mode = services_mode
|
||||
|
||||
self._name = __name__ + "." + self.__class__.__name__
|
||||
|
||||
def service_routes_for(self, ctx: CallContext):
|
||||
services = defaultdict(lambda: self._server.public_url)
|
||||
for service, route in self._server.get_service_routes(ctx):
|
||||
services[service] = self._server.expand_url(route)
|
||||
|
||||
return services
|
||||
|
||||
def get_handler(self, ctx: CallContext) -> Handler | None:
|
||||
if ctx.module == MODULE_SERVICES and ctx.method == METHOD_SERVICES_GET:
|
||||
return self.services_get
|
||||
return None
|
||||
|
||||
def service_route(self, for_service: str, ctx: CallContext):
|
||||
routes = self.service_routes_for(ctx)
|
||||
return routes[for_service]
|
||||
|
||||
def services_get(self, ctx: CallContext):
|
||||
services = ctx.resp.append(
|
||||
MODULE_SERVICES, expire="600", mode=self.services_mode.value, status="0"
|
||||
)
|
||||
|
||||
routes = self._server.get_service_routes(ctx)
|
||||
for service in routes:
|
||||
services.append("item", name=service, url=routes[service])
|
||||
|
||||
def get_service_routes(self, ctx: CallContext | None) -> dict[str, str]:
|
||||
return {}
|
||||
|
||||
def serviced_prefixes(self) -> list[str]:
|
||||
return [""]
|
51
eaapi/server/exceptions.py
Normal file
51
eaapi/server/exceptions.py
Normal file
@ -0,0 +1,51 @@
|
||||
from werkzeug.exceptions import HTTPException
|
||||
|
||||
|
||||
class EAMHTTPException(HTTPException):
|
||||
code = None
|
||||
eam_description = None
|
||||
|
||||
|
||||
class InvalidUpstream(EAMHTTPException):
|
||||
code = 400
|
||||
eam_description = "Upstream URL invalid"
|
||||
|
||||
|
||||
class UpstreamFailed(EAMHTTPException):
|
||||
code = 400
|
||||
eam_description = "Upstream request failed"
|
||||
|
||||
|
||||
class UnknownCompression(EAMHTTPException):
|
||||
code = 400
|
||||
eam_description = "Unknown compression type"
|
||||
|
||||
|
||||
class InvalidPacket(EAMHTTPException):
|
||||
code = 400
|
||||
eam_description = "Invalid XML packet"
|
||||
|
||||
|
||||
class InvalidModel(EAMHTTPException):
|
||||
code = 400
|
||||
eam_description = "Invalid model"
|
||||
|
||||
|
||||
class ModelMissmatch(EAMHTTPException):
|
||||
code = 400
|
||||
eam_description = "Model missmatched"
|
||||
|
||||
|
||||
class ModuleMethodMissing(EAMHTTPException):
|
||||
code = 400
|
||||
eam_description = "Module or method missing"
|
||||
|
||||
|
||||
class CallNodeMissing(EAMHTTPException):
|
||||
code = 400
|
||||
eam_description = "<call> node missing"
|
||||
|
||||
|
||||
class NoMethodHandler(EAMHTTPException):
|
||||
code = 404
|
||||
eam_description = "No handler found for module/method"
|
201
eaapi/server/model.py
Normal file
201
eaapi/server/model.py
Normal file
@ -0,0 +1,201 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
import eaapi
|
||||
|
||||
|
||||
class Model:
|
||||
def __init__(self, gamecode: str, dest: str, spec: str, rev: str, datecode: str):
|
||||
self._gamecode = gamecode
|
||||
self._dest = dest
|
||||
self._spec = spec
|
||||
self._rev = rev
|
||||
self._datecode = datecode
|
||||
|
||||
@classmethod
|
||||
def from_model_str(cls, model: str) -> "Model":
|
||||
return cls(*eaapi.parse_model(model))
|
||||
|
||||
@property
|
||||
def gamecode(self):
|
||||
return self._gamecode
|
||||
|
||||
@property
|
||||
def dest(self):
|
||||
return self._dest
|
||||
|
||||
@property
|
||||
def spec(self):
|
||||
return self._spec
|
||||
|
||||
@property
|
||||
def rev(self):
|
||||
return self._rev
|
||||
|
||||
@property
|
||||
def datecode(self):
|
||||
return int(self._datecode)
|
||||
|
||||
@property
|
||||
def year(self):
|
||||
return int(self._datecode[:4])
|
||||
|
||||
@property
|
||||
def month(self):
|
||||
return int(self._datecode[4:6])
|
||||
|
||||
@property
|
||||
def day(self):
|
||||
return int(self._datecode[6:8])
|
||||
|
||||
@property
|
||||
def minor(self):
|
||||
return int(self._datecode[8:10])
|
||||
|
||||
def __hash__(self):
|
||||
return hash(str(self))
|
||||
|
||||
def __eq__(self, other):
|
||||
if not isinstance(other, Model):
|
||||
return False
|
||||
return str(other) == str(self)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.gamecode}:{self.dest}:{self.spec}:{self.rev}:{self.datecode}"
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Model {self} at 0x{id(self):016X}>"
|
||||
|
||||
|
||||
@dataclass
|
||||
class DatecodeMatcher:
|
||||
year: int | None = None
|
||||
month: int | None = None
|
||||
day: int | None = None
|
||||
minor: int | None = None
|
||||
|
||||
@classmethod
|
||||
def from_str(cls, datecode: str):
|
||||
if len(datecode) != 10 or not datecode.isdigit():
|
||||
raise ValueError("Not a valid datecode")
|
||||
return cls(
|
||||
int(datecode[0:4]),
|
||||
int(datecode[4:6]),
|
||||
int(datecode[6:8]),
|
||||
int(datecode[8:10])
|
||||
)
|
||||
|
||||
def _num_filters(self):
|
||||
num = 0
|
||||
if self.year is not None:
|
||||
num += 1
|
||||
if self.month is not None:
|
||||
num += 1
|
||||
if self.day is not None:
|
||||
num += 1
|
||||
if self.minor is not None:
|
||||
num += 1
|
||||
return num
|
||||
|
||||
def __lt__(self, other):
|
||||
if self._num_filters() < other._num_filters():
|
||||
return False
|
||||
if self.minor is None and other.minor is not None:
|
||||
return False
|
||||
if self.day is None and other.day is not None:
|
||||
return False
|
||||
if self.month is None and other.month is not None:
|
||||
return False
|
||||
if self.year is None and other.year is not None:
|
||||
return False
|
||||
return True
|
||||
|
||||
def __hash__(self):
|
||||
return hash(str(self))
|
||||
|
||||
def __str__(self):
|
||||
year = self.year if self.year is not None else "----"
|
||||
month = self.month if self.month is not None else "--"
|
||||
day = self.day if self.day is not None else "--"
|
||||
minor = self.minor if self.minor is not None else "--"
|
||||
return f"{year:04}{month:02}{day:02}{minor:02}"
|
||||
|
||||
def matches(self, model):
|
||||
if self.year is not None and model.year != self.year:
|
||||
return False
|
||||
if self.month is not None and model.month != self.month:
|
||||
return False
|
||||
if self.day is not None and model.day != self.day:
|
||||
return False
|
||||
if self.minor is not None and model.minor != self.minor:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
@dataclass
|
||||
class ModelMatcher:
|
||||
gamecode: str | None = None
|
||||
dest: str | None = None
|
||||
spec: str | None = None
|
||||
rev: str | None = None
|
||||
datecode: list[DatecodeMatcher] | DatecodeMatcher | None = None
|
||||
|
||||
def _num_filters(self):
|
||||
num = 0
|
||||
if self.gamecode is not None:
|
||||
num += 1
|
||||
if self.dest is not None:
|
||||
num += 1
|
||||
if self.spec is not None:
|
||||
num += 1
|
||||
if self.rev is not None:
|
||||
num += 1
|
||||
if isinstance(self.datecode, list):
|
||||
num += sum(i._num_filters() for i in self.datecode)
|
||||
elif self.datecode is not None:
|
||||
num += self.datecode._num_filters()
|
||||
return num
|
||||
|
||||
def __lt__(self, other):
|
||||
if self._num_filters() < other._num_filters():
|
||||
return False
|
||||
if self.datecode is None and other.datecode is not None:
|
||||
return False
|
||||
if self.rev is None and other.rev is not None:
|
||||
return False
|
||||
if self.spec is None and other.spec is not None:
|
||||
return False
|
||||
if self.dest is None and other.dest is not None:
|
||||
return False
|
||||
if self.gamecode is None and other.gamecode is not None:
|
||||
return False
|
||||
return True
|
||||
|
||||
def __hash__(self):
|
||||
return hash(str(self))
|
||||
|
||||
def __str__(self):
|
||||
gamecode = self.gamecode if self.gamecode is not None else "---"
|
||||
dest = self.dest if self.dest is not None else "-"
|
||||
spec = self.spec if self.spec is not None else "-"
|
||||
rev = self.rev if self.rev is not None else "-"
|
||||
datecode = self.datecode if self.datecode is not None else "-" * 10
|
||||
if isinstance(self.datecode, list):
|
||||
datecode = "/".join(str(i) for i in self.datecode)
|
||||
if not datecode:
|
||||
datecode = "-" * 10
|
||||
return f"{gamecode:3}:{dest}:{spec}:{rev}:{datecode}"
|
||||
|
||||
def matches(self, model):
|
||||
if self.gamecode is not None and model.gamecode != self.gamecode:
|
||||
return False
|
||||
if self.dest is not None and model.dest != self.dest:
|
||||
return False
|
||||
if self.spec is not None and model.spec != self.spec:
|
||||
return False
|
||||
if self.rev is not None and model.rev != self.rev:
|
||||
return False
|
||||
if isinstance(self.datecode, list):
|
||||
return any(i.matches(model) for i in self.datecode)
|
||||
if self.datecode is not None:
|
||||
return self.datecode.matches(model)
|
||||
return True
|
397
eaapi/server/server.py
Normal file
397
eaapi/server/server.py
Normal file
@ -0,0 +1,397 @@
|
||||
import traceback
|
||||
import urllib.parse
|
||||
import urllib
|
||||
import sys
|
||||
import os
|
||||
|
||||
from typing import Callable
|
||||
from collections import defaultdict
|
||||
|
||||
from werkzeug.exceptions import HTTPException, MethodNotAllowed
|
||||
from werkzeug.wrappers import Request, Response
|
||||
from werkzeug.routing import Map, Rule
|
||||
|
||||
import eaapi
|
||||
|
||||
from . import exceptions as exc
|
||||
from .context import CallContext
|
||||
from .model import Model
|
||||
from .controller import IController, ServicesController
|
||||
from .const import SERVICE_NTP, SERVICE_KEEPALIVE
|
||||
|
||||
|
||||
Handler = Callable[[CallContext], None]
|
||||
|
||||
|
||||
HEADER_ENCRYPTION = "X-Eamuse-Info"
|
||||
HEADER_COMPRESSION = "X-Compress"
|
||||
|
||||
PINGABLE_IP = "127.0.0.1"
|
||||
|
||||
|
||||
class NetworkState:
|
||||
def __init__(self):
|
||||
self._pa = PINGABLE_IP # TODO: what does this one mean?
|
||||
self.router_ip = PINGABLE_IP
|
||||
self.gateway_ip = PINGABLE_IP
|
||||
self.center_ip = PINGABLE_IP
|
||||
|
||||
def format_ka(self, base):
|
||||
return base + "?" + urllib.parse.urlencode({
|
||||
"pa": self.pa,
|
||||
"ia": self.ia,
|
||||
"ga": self.ga,
|
||||
"ma": self.ma,
|
||||
"t1": self.t1,
|
||||
"t2": self.t2,
|
||||
})
|
||||
|
||||
@property
|
||||
def pa(self) -> str:
|
||||
return self._pa
|
||||
|
||||
@property
|
||||
def ia(self) -> str:
|
||||
return self.router_ip
|
||||
|
||||
@property
|
||||
def ga(self) -> str:
|
||||
return self.gateway_ip
|
||||
|
||||
@property
|
||||
def ma(self) -> str:
|
||||
return self.center_ip
|
||||
|
||||
# TODO: Identify what these values are. Ping intervals?
|
||||
@property
|
||||
def t1(self):
|
||||
return 2
|
||||
|
||||
@property
|
||||
def t2(self):
|
||||
return 10
|
||||
|
||||
|
||||
class EAMServer:
|
||||
def __init__(
|
||||
self,
|
||||
public_url: str,
|
||||
prioritise_params: bool = False,
|
||||
verbose_errors: bool = False,
|
||||
services_mode: eaapi.const.ServicesMode = eaapi.const.ServicesMode.Operation,
|
||||
ntp_server: str = "ntp://pool.ntp.org/",
|
||||
keepalive_server: str | None = None,
|
||||
no_keepalive_route: bool = False,
|
||||
disable_routes: bool = False,
|
||||
no_services_handler: bool = False,
|
||||
):
|
||||
self.network = NetworkState()
|
||||
|
||||
self.verbose_errors = verbose_errors
|
||||
|
||||
self._prioritise_params = prioritise_params
|
||||
self._public_url = public_url
|
||||
|
||||
self.disable_routes = disable_routes
|
||||
self._no_keepalive_route = no_keepalive_route
|
||||
|
||||
self.ntp = ntp_server
|
||||
self.keepalive = keepalive_server or f"{public_url}/keepalive"
|
||||
|
||||
self._prng = eaapi.crypt.new_prng()
|
||||
|
||||
self._setup = []
|
||||
self._pre_handlers_check = []
|
||||
self._teardown = []
|
||||
|
||||
self._einfo_ctx: CallContext | None = None
|
||||
self._einfo_controller: str | None = None
|
||||
|
||||
self.controllers: list[IController] = []
|
||||
if not no_services_handler:
|
||||
self.controllers.append(ServicesController(self, services_mode))
|
||||
|
||||
def on_setup(self, callback):
|
||||
if callback not in self._setup:
|
||||
self._setup.append(callback)
|
||||
|
||||
def on_pre_handlers_check(self, callback):
|
||||
if callback not in self._pre_handlers_check:
|
||||
self._pre_handlers_check.append(callback)
|
||||
|
||||
def on_teardown(self, callback):
|
||||
if callback not in self._teardown:
|
||||
self._teardown.append(callback)
|
||||
|
||||
def build_rules_map(self) -> Map:
|
||||
if self.disable_routes:
|
||||
return Map([])
|
||||
|
||||
rules = Map([], strict_slashes=False, merge_slashes=False)
|
||||
|
||||
prefixes = {"/"}
|
||||
for i in self.controllers:
|
||||
for prefix in i.serviced_prefixes():
|
||||
prefix = self.expand_url(prefix)
|
||||
if not prefix.startswith(self._public_url):
|
||||
continue
|
||||
prefix = prefix[len(self._public_url):]
|
||||
if prefix == "":
|
||||
prefix = "/"
|
||||
|
||||
prefixes.add(prefix)
|
||||
|
||||
for i in prefixes:
|
||||
rules.add(Rule(f"{i}/<model>/<module>/<method>", endpoint="xrpc_request"))
|
||||
# WSGI flattens the // at the start
|
||||
if i == "/":
|
||||
rules.add(Rule("/<model>/<module>/<method>", endpoint="xrpc_request"))
|
||||
rules.add(Rule(f"{i}", endpoint="xrpc_request"))
|
||||
|
||||
if not self._no_keepalive_route:
|
||||
rules.add(Rule("/keepalive", endpoint="keepalive_request"))
|
||||
|
||||
return rules
|
||||
|
||||
def expand_url(self, url: str) -> str:
|
||||
return urllib.parse.urljoin(self._public_url, url)
|
||||
|
||||
@property
|
||||
def public_url(self) -> str:
|
||||
return self._public_url
|
||||
|
||||
def get_service_routes(self, ctx: CallContext | None) -> dict[str, str]:
|
||||
services: dict[str, str] = defaultdict(lambda: self.public_url)
|
||||
services[SERVICE_NTP] = self.ntp
|
||||
services[SERVICE_KEEPALIVE] = self.network.format_ka(self.keepalive)
|
||||
|
||||
for i in self.controllers:
|
||||
services.update(i.get_service_routes(ctx))
|
||||
return services
|
||||
|
||||
def _decode_request(self, request: Request) -> CallContext:
|
||||
ea_info = request.headers.get(HEADER_ENCRYPTION)
|
||||
compression = request.headers.get(HEADER_COMPRESSION)
|
||||
|
||||
compressed = False
|
||||
if compression == eaapi.Compression.Lz77.value:
|
||||
compressed = True
|
||||
elif compression != eaapi.Compression.None_.value:
|
||||
raise exc.UnknownCompression
|
||||
|
||||
payload = eaapi.unwrap(request.data, ea_info, compressed)
|
||||
decoder = eaapi.Decoder(payload)
|
||||
try:
|
||||
call = decoder.unpack()
|
||||
except eaapi.EAAPIException:
|
||||
raise exc.InvalidPacket
|
||||
|
||||
return CallContext(request, decoder, call, ea_info, compressed)
|
||||
|
||||
def _encode_response(self, ctx: CallContext) -> Response:
|
||||
if ctx._eainfo is None:
|
||||
ea_info = None
|
||||
else:
|
||||
ea_info = eaapi.crypt.get_key(self._prng)
|
||||
|
||||
encoded = eaapi.Encoder.encode(ctx.resp, ctx.was_xml_string)
|
||||
wrapped = eaapi.wrap(encoded, ea_info, ctx.was_compressed)
|
||||
response = Response(wrapped, 200)
|
||||
if ea_info:
|
||||
response.headers[HEADER_ENCRYPTION] = ea_info
|
||||
response.headers[HEADER_COMPRESSION] = (
|
||||
eaapi.Compression.Lz77 if ctx.was_compressed
|
||||
else eaapi.Compression.None_
|
||||
).value
|
||||
|
||||
return response
|
||||
|
||||
def _create_ctx(
|
||||
self,
|
||||
url_slash: bool,
|
||||
request: Request,
|
||||
model: Model | None,
|
||||
module: str,
|
||||
method: str
|
||||
) -> CallContext:
|
||||
ctx = self._decode_request(request)
|
||||
ctx._module = module
|
||||
ctx._method = method
|
||||
ctx._url_slash = url_slash
|
||||
self._einfo_ctx = ctx
|
||||
|
||||
if ctx.model != model:
|
||||
raise exc.ModelMissmatch
|
||||
return ctx
|
||||
|
||||
def _handle_request(self, ctx: CallContext) -> Response:
|
||||
for controller in self.controllers:
|
||||
if (handler := controller.get_handler(ctx)) is not None:
|
||||
self._einfo_controller = (
|
||||
f"{controller._name}"
|
||||
)
|
||||
|
||||
handler(ctx)
|
||||
break
|
||||
else:
|
||||
raise exc.NoMethodHandler
|
||||
|
||||
return self._encode_response(ctx)
|
||||
|
||||
def on_xrpc_other(
|
||||
self,
|
||||
request: Request,
|
||||
service: str | None = None,
|
||||
model: str | None = None,
|
||||
module: str | None = None,
|
||||
method: str | None = None
|
||||
):
|
||||
if request.method != "GET" or not self.verbose_errors:
|
||||
raise MethodNotAllowed
|
||||
|
||||
return Response(
|
||||
f"XRPC running. model {model}, call {module}.{method} ({service})"
|
||||
)
|
||||
|
||||
def keepalive_request(self) -> Response:
|
||||
return Response(None)
|
||||
|
||||
def parse_request(
|
||||
self,
|
||||
request: Request,
|
||||
service: str | None = None,
|
||||
model: str | None = None,
|
||||
module: str | None = None,
|
||||
method: str | None = None
|
||||
):
|
||||
url_slash = bool(module and module and method)
|
||||
model_param = request.args.get("model", None)
|
||||
module_param = request.args.get("module", None)
|
||||
method_param = request.args.get("method", None)
|
||||
if "f" in request.args:
|
||||
module_param, _, method_param = request.args.get("f", "").partition(".")
|
||||
|
||||
if self._prioritise_params:
|
||||
model = model_param or model
|
||||
module = module_param or module
|
||||
method = method_param or method
|
||||
else:
|
||||
model = model or model_param
|
||||
module = module or module_param
|
||||
method = method or method_param
|
||||
|
||||
if module is None or method is None:
|
||||
raise exc.ModuleMethodMissing
|
||||
|
||||
if model is None:
|
||||
model_obj = None
|
||||
else:
|
||||
try:
|
||||
model_obj = Model.from_model_str(model)
|
||||
except eaapi.exception.InvalidModel:
|
||||
raise exc.InvalidModel
|
||||
|
||||
return url_slash, service, model_obj, module, method
|
||||
|
||||
def on_xrpc_request(
|
||||
self,
|
||||
request: Request,
|
||||
service: str | None = None,
|
||||
model: str | None = None,
|
||||
module: str | None = None,
|
||||
method: str | None = None
|
||||
):
|
||||
url_slash, service, model_obj, module, method = self.parse_request(request, service, model, module, method)
|
||||
|
||||
if request.method != "POST":
|
||||
return self.on_xrpc_other(request, service, model, module, method)
|
||||
|
||||
ctx = self._create_ctx(url_slash, request, model_obj, module, method)
|
||||
for i in self._pre_handlers_check:
|
||||
i(ctx)
|
||||
return self._handle_request(ctx)
|
||||
|
||||
def _make_error(self, status: int | None = None, message: str | None = None) -> Response:
|
||||
response = eaapi.XMLNode.void("response")
|
||||
if status is not None:
|
||||
response["status"] = str(status)
|
||||
|
||||
if self.verbose_errors:
|
||||
if message:
|
||||
response.append("details", eaapi.Type.Str, message)
|
||||
|
||||
context = response.append("context")
|
||||
if self._einfo_ctx is not None:
|
||||
context.append("module", eaapi.Type.Str, self._einfo_ctx.module)
|
||||
context.append("method", eaapi.Type.Str, self._einfo_ctx.method)
|
||||
context.append("game", eaapi.Type.Str, str(self._einfo_ctx.model))
|
||||
if self._einfo_controller is not None:
|
||||
context.append("controller", eaapi.Type.Str, self._einfo_controller)
|
||||
|
||||
encoded = eaapi.Encoder.encode(response, False)
|
||||
wrapped = eaapi.wrap(encoded, None, False)
|
||||
response = Response(wrapped, status or 500)
|
||||
response.headers[HEADER_COMPRESSION] = eaapi.Compression.None_.value
|
||||
|
||||
return response
|
||||
|
||||
def _eamhttp_error(self, exc: exc.EAMHTTPException) -> Response:
|
||||
return self._make_error(exc.code, exc.eam_description)
|
||||
|
||||
def _structure_error(self, e: eaapi.exception.XMLStrutureError) -> Response:
|
||||
summary = traceback.extract_tb(e.__traceback__)
|
||||
for frame_summary in summary:
|
||||
filename = frame_summary.filename
|
||||
frame_summary.filename = os.path.relpath(filename)
|
||||
|
||||
# The first three entries are within the controller, and the last one is us
|
||||
summary = summary[3:-1]
|
||||
tb = "".join(traceback.format_list(traceback.StackSummary.from_list(summary)))
|
||||
tb += f"{e.__module__}.{e.__class__.__name__}"
|
||||
|
||||
return self._make_error(400, tb)
|
||||
|
||||
def _generic_error(self, exc: Exception) -> Response:
|
||||
return self._make_error(500, str(exc))
|
||||
|
||||
def dispatch_request(self, request):
|
||||
self._einfo_ctx = None
|
||||
self._einfo_controller = None
|
||||
|
||||
adapter = self.build_rules_map().bind_to_environ(request.environ)
|
||||
try:
|
||||
endpoint, values = adapter.match()
|
||||
return getattr(self, f"on_{endpoint}")(request, **values)
|
||||
except exc.EAMHTTPException as e:
|
||||
return self._eamhttp_error(e)
|
||||
except HTTPException as e:
|
||||
return e
|
||||
except eaapi.exception.XMLStrutureError as e:
|
||||
traceback.print_exc(file=sys.stderr)
|
||||
return self._structure_error(e)
|
||||
except Exception as e:
|
||||
traceback.print_exc(file=sys.stderr)
|
||||
return self._generic_error(e)
|
||||
|
||||
def wsgi_app(self, environ, start_response):
|
||||
request = Request(environ)
|
||||
response = self.dispatch_request(request)
|
||||
return response(environ, start_response)
|
||||
|
||||
def __call__(self, environ, start_response):
|
||||
for i in self._setup:
|
||||
i()
|
||||
|
||||
try:
|
||||
response = self.wsgi_app(environ, start_response)
|
||||
for i in self._teardown:
|
||||
i(None)
|
||||
return response
|
||||
except Exception as e:
|
||||
for i in self._teardown:
|
||||
i(e)
|
||||
raise e
|
||||
|
||||
def run(self, host="127.0.0.1", port=5000, debug=False):
|
||||
from werkzeug.serving import run_simple
|
||||
run_simple(host, port, self, use_debugger=debug, use_reloader=debug)
|
Loading…
Reference in New Issue
Block a user