343 lines
10 KiB
Python
343 lines
10 KiB
Python
import copy
|
|
import json
|
|
import traceback
|
|
from typing import Any, Callable, Dict, List
|
|
from flask import Flask, abort, request, Response
|
|
from functools import wraps
|
|
|
|
from bemani.api.exceptions import APIException
|
|
from bemani.api.objects import (
|
|
RecordsObject,
|
|
ProfileObject,
|
|
StatisticsObject,
|
|
CatalogObject,
|
|
)
|
|
from bemani.api.types import g
|
|
from bemani.common import GameConstants, APIConstants, VersionConstants
|
|
from bemani.data import Config, Data
|
|
|
|
app = Flask(__name__)
|
|
config = Config()
|
|
|
|
SUPPORTED_VERSIONS: List[str] = ["v1"]
|
|
|
|
|
|
def jsonify_response(data: Dict[str, Any], code: int = 200) -> Response:
|
|
return Response(
|
|
json.dumps(data).encode("utf8"),
|
|
content_type="application/json; charset=utf-8",
|
|
status=code,
|
|
)
|
|
|
|
|
|
@app.before_request
|
|
def before_request() -> None:
|
|
global config
|
|
|
|
g.config = config
|
|
g.data = Data(config)
|
|
g.authorized = False
|
|
|
|
authkey = request.headers.get("Authorization")
|
|
if authkey is not None:
|
|
try:
|
|
authtype, authtoken = authkey.split(" ", 1)
|
|
except ValueError:
|
|
authtype = None
|
|
authtoken = None
|
|
|
|
if authtype.lower() == "token":
|
|
g.authorized = g.data.local.api.validate_client(authtoken)
|
|
|
|
|
|
@app.after_request
|
|
def after_request(response: Response) -> Response:
|
|
# Make sure our REST responses don't get cached, so that remote
|
|
# servers which respect cache headers don't get confused.
|
|
response.cache_control.no_cache = True
|
|
response.cache_control.must_revalidate = True
|
|
response.cache_control.private = True
|
|
return response
|
|
|
|
|
|
@app.teardown_request
|
|
def teardown_request(exception: Any) -> None:
|
|
data = getattr(g, "data", None)
|
|
if data is not None:
|
|
data.close()
|
|
|
|
|
|
def authrequired(func: Callable) -> Callable:
|
|
@wraps(func)
|
|
def decoratedfunction(*args: Any, **kwargs: Any) -> Response:
|
|
if not g.authorized:
|
|
return jsonify_response(
|
|
{"error": "Unauthorized client!"},
|
|
401,
|
|
)
|
|
else:
|
|
return func(*args, **kwargs)
|
|
|
|
return decoratedfunction
|
|
|
|
|
|
def jsonify(func: Callable) -> Callable:
|
|
@wraps(func)
|
|
def decoratedfunction(*args: Any, **kwargs: Any) -> Response:
|
|
return jsonify_response(func(*args, **kwargs))
|
|
|
|
return decoratedfunction
|
|
|
|
|
|
@app.errorhandler(Exception)
|
|
def server_exception(exception: Any) -> Response:
|
|
stack = "".join(
|
|
traceback.format_exception(type(exception), exception, exception.__traceback__)
|
|
)
|
|
print(stack)
|
|
try:
|
|
g.data.local.network.put_event(
|
|
"exception",
|
|
{
|
|
"service": "api",
|
|
"request": request.url,
|
|
"traceback": stack,
|
|
},
|
|
)
|
|
except Exception:
|
|
pass
|
|
|
|
return jsonify_response(
|
|
{"error": "Exception occured while processing request."},
|
|
500,
|
|
)
|
|
|
|
|
|
@app.errorhandler(APIException)
|
|
def api_exception(exception: Any) -> Response:
|
|
return jsonify_response(
|
|
{"error": exception.message},
|
|
exception.code,
|
|
)
|
|
|
|
|
|
@app.errorhandler(500)
|
|
def server_error(error: Any) -> Response:
|
|
return jsonify_response(
|
|
{"error": "Exception occured while processing request."},
|
|
500,
|
|
)
|
|
|
|
|
|
@app.errorhandler(501)
|
|
def protocol_error(error: Any) -> Response:
|
|
return jsonify_response(
|
|
{"error": "Unsupported protocol version in request."},
|
|
501,
|
|
)
|
|
|
|
|
|
@app.errorhandler(400)
|
|
def bad_json(error: Any) -> Response:
|
|
return jsonify_response(
|
|
{"error": "Request JSON could not be decoded."},
|
|
500,
|
|
)
|
|
|
|
|
|
@app.errorhandler(404)
|
|
def unrecognized_object(error: Any) -> Response:
|
|
return jsonify_response(
|
|
{"error": "Unrecognized request game/version or object."},
|
|
404,
|
|
)
|
|
|
|
|
|
@app.errorhandler(405)
|
|
def invalid_request(error: Any) -> Response:
|
|
return jsonify_response(
|
|
{"error": "Invalid request URI or method."},
|
|
405,
|
|
)
|
|
|
|
|
|
@app.route("/<path:path>", methods=["GET", "POST"])
|
|
@authrequired
|
|
def catch_all(path: str) -> Response:
|
|
abort(405)
|
|
|
|
|
|
@app.route("/", methods=["GET", "POST"])
|
|
@authrequired
|
|
@jsonify
|
|
def info() -> Dict[str, Any]:
|
|
requestdata = request.get_json()
|
|
if requestdata is None:
|
|
raise APIException("Request JSON could not be decoded.")
|
|
if requestdata:
|
|
raise APIException("Unrecognized parameters for request.")
|
|
|
|
return {
|
|
"versions": SUPPORTED_VERSIONS,
|
|
"name": g.config.name,
|
|
"email": g.config.email,
|
|
}
|
|
|
|
|
|
@app.route("/<protoversion>/<requestgame>/<requestversion>", methods=["GET", "POST"])
|
|
@authrequired
|
|
@jsonify
|
|
def lookup(protoversion: str, requestgame: str, requestversion: str) -> Dict[str, Any]:
|
|
requestdata = request.get_json()
|
|
for expected in ["type", "ids", "objects"]:
|
|
if expected not in requestdata:
|
|
raise APIException("Missing parameters for request.")
|
|
for param in requestdata:
|
|
if param not in ["type", "ids", "objects", "since", "until"]:
|
|
raise APIException("Unrecognized parameters for request.")
|
|
|
|
args = copy.deepcopy(requestdata)
|
|
del args["type"]
|
|
del args["ids"]
|
|
del args["objects"]
|
|
|
|
if protoversion not in SUPPORTED_VERSIONS:
|
|
# Don't know about this protocol version
|
|
abort(501)
|
|
|
|
# Figure out what games we support based on config, and map those.
|
|
gamemapping = {}
|
|
for (gameid, constant) in [
|
|
("ddr", GameConstants.DDR),
|
|
("iidx", GameConstants.IIDX),
|
|
("jubeat", GameConstants.JUBEAT),
|
|
("museca", GameConstants.MUSECA),
|
|
("popnmusic", GameConstants.POPN_MUSIC),
|
|
("reflecbeat", GameConstants.REFLEC_BEAT),
|
|
("soundvoltex", GameConstants.SDVX),
|
|
]:
|
|
if constant in g.config.support:
|
|
gamemapping[gameid] = constant
|
|
game = gamemapping.get(requestgame)
|
|
if game is None:
|
|
# Don't support this game!
|
|
abort(404)
|
|
|
|
if requestversion[0] == "o":
|
|
omnimix = True
|
|
requestversion = requestversion[1:]
|
|
else:
|
|
omnimix = False
|
|
|
|
version = (
|
|
{
|
|
GameConstants.DDR: {
|
|
"12": VersionConstants.DDR_X2,
|
|
"13": VersionConstants.DDR_X3_VS_2NDMIX,
|
|
"14": VersionConstants.DDR_2013,
|
|
"15": VersionConstants.DDR_2014,
|
|
"16": VersionConstants.DDR_ACE,
|
|
},
|
|
GameConstants.IIDX: {
|
|
"20": VersionConstants.IIDX_TRICORO,
|
|
"21": VersionConstants.IIDX_SPADA,
|
|
"22": VersionConstants.IIDX_PENDUAL,
|
|
"23": VersionConstants.IIDX_COPULA,
|
|
"24": VersionConstants.IIDX_SINOBUZ,
|
|
"25": VersionConstants.IIDX_CANNON_BALLERS,
|
|
"26": VersionConstants.IIDX_ROOTAGE,
|
|
},
|
|
GameConstants.JUBEAT: {
|
|
"5": VersionConstants.JUBEAT_SAUCER,
|
|
"5a": VersionConstants.JUBEAT_SAUCER_FULFILL,
|
|
"6": VersionConstants.JUBEAT_PROP,
|
|
"7": VersionConstants.JUBEAT_QUBELL,
|
|
"8": VersionConstants.JUBEAT_CLAN,
|
|
"9": VersionConstants.JUBEAT_FESTO,
|
|
},
|
|
GameConstants.MUSECA: {
|
|
"1": VersionConstants.MUSECA,
|
|
"1p": VersionConstants.MUSECA_1_PLUS,
|
|
},
|
|
GameConstants.POPN_MUSIC: {
|
|
"19": VersionConstants.POPN_MUSIC_TUNE_STREET,
|
|
"20": VersionConstants.POPN_MUSIC_FANTASIA,
|
|
"21": VersionConstants.POPN_MUSIC_SUNNY_PARK,
|
|
"22": VersionConstants.POPN_MUSIC_LAPISTORIA,
|
|
"23": VersionConstants.POPN_MUSIC_ECLALE,
|
|
"24": VersionConstants.POPN_MUSIC_USANEKO,
|
|
},
|
|
GameConstants.REFLEC_BEAT: {
|
|
"1": VersionConstants.REFLEC_BEAT,
|
|
"2": VersionConstants.REFLEC_BEAT_LIMELIGHT,
|
|
# We don't support non-final COLETTE, so just return scores for
|
|
# final colette to any network that asks.
|
|
"3w": VersionConstants.REFLEC_BEAT_COLETTE,
|
|
"3sp": VersionConstants.REFLEC_BEAT_COLETTE,
|
|
"3su": VersionConstants.REFLEC_BEAT_COLETTE,
|
|
"3a": VersionConstants.REFLEC_BEAT_COLETTE,
|
|
"3as": VersionConstants.REFLEC_BEAT_COLETTE,
|
|
# We don't support groovin'!!, so just return upper scores.
|
|
"4": VersionConstants.REFLEC_BEAT_GROOVIN,
|
|
"4u": VersionConstants.REFLEC_BEAT_GROOVIN,
|
|
"5": VersionConstants.REFLEC_BEAT_VOLZZA,
|
|
"5a": VersionConstants.REFLEC_BEAT_VOLZZA_2,
|
|
"6": VersionConstants.REFLEC_BEAT_REFLESIA,
|
|
},
|
|
GameConstants.SDVX: {
|
|
"1": VersionConstants.SDVX_BOOTH,
|
|
"2": VersionConstants.SDVX_INFINITE_INFECTION,
|
|
"3": VersionConstants.SDVX_GRAVITY_WARS,
|
|
"4": VersionConstants.SDVX_HEAVENLY_HAVEN,
|
|
},
|
|
}
|
|
.get(game, {})
|
|
.get(requestversion)
|
|
)
|
|
if version is None:
|
|
# Don't support this version!
|
|
abort(404)
|
|
|
|
# Attempt to coerce ID type. If we fail, provide the correct failure message.
|
|
idtype = None
|
|
try:
|
|
idtype = APIConstants(requestdata["type"])
|
|
except ValueError:
|
|
pass
|
|
if idtype is None:
|
|
raise APIException("Invalid ID type provided!")
|
|
|
|
# Validate the provided IDs given the ID type above.
|
|
ids = requestdata["ids"]
|
|
if idtype == APIConstants.ID_TYPE_CARD and len(ids) == 0:
|
|
raise APIException("Invalid number of IDs given!")
|
|
if idtype == APIConstants.ID_TYPE_SONG and len(ids) not in [1, 2]:
|
|
raise APIException("Invalid number of IDs given!")
|
|
if idtype == APIConstants.ID_TYPE_INSTANCE and len(ids) != 3:
|
|
raise APIException("Invalid number of IDs given!")
|
|
if idtype == APIConstants.ID_TYPE_SERVER and len(ids) != 0:
|
|
raise APIException("Invalid number of IDs given!")
|
|
|
|
responsedata = {}
|
|
for obj in requestdata["objects"]:
|
|
handler = {
|
|
"records": RecordsObject,
|
|
"profile": ProfileObject,
|
|
"statistics": StatisticsObject,
|
|
"catalog": CatalogObject,
|
|
}.get(obj)
|
|
if handler is None:
|
|
# Don't support this object type
|
|
abort(404)
|
|
|
|
inst = handler(g.data, game, version, omnimix)
|
|
try:
|
|
fetchmethod = getattr(inst, f"fetch_{protoversion}")
|
|
except AttributeError:
|
|
# Don't know how to handle this object for this version
|
|
abort(501)
|
|
|
|
responsedata[obj] = fetchmethod(idtype, ids, args)
|
|
|
|
return responsedata
|