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('/', 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('///', 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, }, 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