332 lines
10 KiB
Python
332 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
|