import json import requests from typing import Tuple, Dict, List, Any, Optional from typing_extensions import Final from bemani.common import APIConstants, GameConstants, VersionConstants, DBConstants, ValidatedDict class APIException(Exception): pass class NotAuthorizedAPIException(APIException): pass class UnsupportedRequestAPIException(APIException): pass class UnrecognizedRequestAPIException(APIException): pass class UnsupportedVersionAPIException(APIException): pass class RemoteServerErrorAPIException(APIException): pass class APIClient: """ A client that fully speaks BEMAPI and can pull information from a remote server. """ API_VERSION: Final[str] = 'v1' def __init__(self, base_uri: str, token: str, allow_stats: bool, allow_scores: bool) -> None: self.base_uri = base_uri self.token = token self.allow_stats = allow_stats self.allow_scores = allow_scores def _content_type_valid(self, content_type: str) -> bool: if ';' in content_type: left, right = content_type.split(';', 1) left = left.strip().lower() right = right.strip().lower() if left == 'application/json' and ('=' in right): identifier, charset = right.split('=', 1) identifier = identifier.strip() charset = charset.strip() if identifier == 'charset' and charset == 'utf-8': # This is valid. return True return False def __exchange_data(self, request_uri: str, request_args: Dict[str, Any]) -> Dict[str, Any]: if self.base_uri[-1:] != '/': uri = f'{self.base_uri}/{request_uri}' else: uri = f'{self.base_uri}{request_uri}' headers = { 'Authorization': f'Token {self.token}', 'Content-Type': 'application/json; charset=utf-8', } data = json.dumps(request_args).encode('utf8') try: r = requests.request( 'GET', uri, headers=headers, data=data, allow_redirects=False, timeout=10, ) except Exception: raise APIException('Failed to query remote server!') # Verify that content type is in the form of "application/json; charset=utf-8". if not self._content_type_valid(r.headers['content-type']): raise APIException(f'API returned invalid content type \'{r.headers["content-type"]}\'!') jsondata = r.json() if r.status_code == 200: return jsondata if 'error' not in jsondata: raise APIException(f'API returned error code {r.status_code} but did not include \'error\' attribute in response JSON!') error = jsondata['error'] if r.status_code == 401: raise NotAuthorizedAPIException('The API token used is not authorized against this server!') if r.status_code == 404: raise UnsupportedRequestAPIException('The server does not support this game/version or request object!') if r.status_code == 405: raise UnrecognizedRequestAPIException('The server did not recognize the request!') if r.status_code == 500: raise RemoteServerErrorAPIException(f'The server had an error processing the request and returned \'{error}\'') if r.status_code == 501: raise UnsupportedVersionAPIException('The server does not support this version of the API!') raise APIException('The server returned an invalid status code {}!', format(r.status_code)) def __translate(self, game: GameConstants, version: int) -> Tuple[str, str]: servergame = { GameConstants.DDR: 'ddr', GameConstants.IIDX: 'iidx', GameConstants.JUBEAT: 'jubeat', GameConstants.MUSECA: 'museca', GameConstants.POPN_MUSIC: 'popnmusic', GameConstants.REFLEC_BEAT: 'reflecbeat', GameConstants.SDVX: 'soundvoltex', }.get(game) if servergame is None: raise UnsupportedRequestAPIException('The client does not support this game/version!') if version >= DBConstants.OMNIMIX_VERSION_BUMP: version = version - DBConstants.OMNIMIX_VERSION_BUMP omnimix = True else: omnimix = False serverversion = { GameConstants.DDR: { VersionConstants.DDR_X2: '12', VersionConstants.DDR_X3_VS_2NDMIX: '13', VersionConstants.DDR_2013: '14', VersionConstants.DDR_2014: '15', VersionConstants.DDR_ACE: '16', }, GameConstants.IIDX: { VersionConstants.IIDX_TRICORO: '20', VersionConstants.IIDX_SPADA: '21', VersionConstants.IIDX_PENDUAL: '22', VersionConstants.IIDX_COPULA: '23', VersionConstants.IIDX_SINOBUZ: '24', VersionConstants.IIDX_CANNON_BALLERS: '25', VersionConstants.IIDX_ROOTAGE: '26', }, GameConstants.JUBEAT: { VersionConstants.JUBEAT_SAUCER: '5', VersionConstants.JUBEAT_SAUCER_FULFILL: '5a', VersionConstants.JUBEAT_PROP: '6', VersionConstants.JUBEAT_QUBELL: '7', VersionConstants.JUBEAT_CLAN: '8', }, GameConstants.MUSECA: { VersionConstants.MUSECA: '1', VersionConstants.MUSECA_1_PLUS: '1p', }, GameConstants.POPN_MUSIC: { VersionConstants.POPN_MUSIC_TUNE_STREET: '19', VersionConstants.POPN_MUSIC_FANTASIA: '20', VersionConstants.POPN_MUSIC_SUNNY_PARK: '21', VersionConstants.POPN_MUSIC_LAPISTORIA: '22', VersionConstants.POPN_MUSIC_ECLALE: '23', VersionConstants.POPN_MUSIC_USANEKO: '24', }, GameConstants.REFLEC_BEAT: { VersionConstants.REFLEC_BEAT: '1', VersionConstants.REFLEC_BEAT_LIMELIGHT: '2', VersionConstants.REFLEC_BEAT_COLETTE: '3as', VersionConstants.REFLEC_BEAT_GROOVIN: '4u', VersionConstants.REFLEC_BEAT_VOLZZA: '5', VersionConstants.REFLEC_BEAT_VOLZZA_2: '5a', VersionConstants.REFLEC_BEAT_REFLESIA: '6', }, GameConstants.SDVX: { VersionConstants.SDVX_BOOTH: '1', VersionConstants.SDVX_INFINITE_INFECTION: '2', VersionConstants.SDVX_GRAVITY_WARS: '3', VersionConstants.SDVX_HEAVENLY_HAVEN: '4', }, }.get(game, {}).get(version) if serverversion is None: raise UnsupportedRequestAPIException('The client does not support this game/version!') if omnimix: serverversion = 'o' + serverversion return (servergame, serverversion) def get_server_info(self) -> ValidatedDict: resp = self.__exchange_data('', {}) return ValidatedDict({ 'name': resp['name'], 'email': resp['email'], 'versions': resp['versions'], }) def get_profiles(self, game: GameConstants, version: int, idtype: APIConstants, ids: List[str]) -> List[Dict[str, Any]]: # Allow remote servers to be disabled if not self.allow_scores: return [] try: servergame, serverversion = self.__translate(game, version) resp = self.__exchange_data( f'{self.API_VERSION}/{servergame}/{serverversion}', { 'ids': ids, 'type': idtype.value, 'objects': ['profile'], }, ) return resp['profile'] except APIException: # Couldn't talk to server, assume empty profiles return [] def get_records( self, game: GameConstants, version: int, idtype: APIConstants, ids: List[str], since: Optional[int]=None, until: Optional[int]=None, ) -> List[Dict[str, Any]]: # Allow remote servers to be disabled if not self.allow_scores: return [] try: servergame, serverversion = self.__translate(game, version) data: Dict[str, Any] = { 'ids': ids, 'type': idtype.value, 'objects': ['records'], } if since is not None: data['since'] = since if until is not None: data['until'] = until resp = self.__exchange_data( f'{self.API_VERSION}/{servergame}/{serverversion}', data, ) return resp['records'] except APIException: # Couldn't talk to server, assume empty records return [] def get_statistics(self, game: GameConstants, version: int, idtype: APIConstants, ids: List[str]) -> List[Dict[str, Any]]: # Allow remote servers to be disabled if not self.allow_stats: return [] try: servergame, serverversion = self.__translate(game, version) resp = self.__exchange_data( f'{self.API_VERSION}/{servergame}/{serverversion}', { 'ids': ids, 'type': idtype.value, 'objects': ['statistics'], }, ) return resp['statistics'] except APIException: # Couldn't talk to server, assume empty statistics return [] def get_catalog(self, game: GameConstants, version: int) -> Dict[str, List[Dict[str, Any]]]: # No point disallowing this, since its only ever used for bootstrapping. try: servergame, serverversion = self.__translate(game, version) resp = self.__exchange_data( f'{self.API_VERSION}/{servergame}/{serverversion}', { 'ids': [], 'type': 'server', 'objects': ['catalog'], }, ) return resp['catalog'] except APIException: # Couldn't talk to server, assume empty catalog return {}