diff --git a/setup.py b/setup.py index 56d7967..998e71f 100644 --- a/setup.py +++ b/setup.py @@ -28,6 +28,7 @@ setup( 'aiohttp', 'aiohttp_jinja2', 'aiohttp_security', + 'apispec', 'Jinja2', 'passlib', 'requests', diff --git a/src/service/api/auth.py b/src/service/api/auth.py index 8b7dbed..defb068 100644 --- a/src/service/api/auth.py +++ b/src/service/api/auth.py @@ -35,6 +35,7 @@ class AuthorizationPolicy(AbstractAuthorizationPolicy): def authorize_factory() -> Callable: allowed_paths = {'/', '/favicon.ico', '/api/v1/login', '/api/v1/logout'} + allowed_paths_groups = {'/api-docs', '/static'} @middleware async def authorize(request: Request, handler: Callable) -> Response: @@ -42,7 +43,8 @@ def authorize_factory() -> Callable: permission = 'admin' else: permission = 'get' if request.method in ('GET', 'HEAD') else 'post' - if request.path not in allowed_paths: + if request.path not in allowed_paths \ + and not any(request.path.startswith(path) for path in allowed_paths_groups): await check_permission(request, permission) return await handler(request) diff --git a/src/service/api/routes.py b/src/service/api/routes.py index e3d1a6c..a288912 100644 --- a/src/service/api/routes.py +++ b/src/service/api/routes.py @@ -13,6 +13,7 @@ from .views.api.login import LoginView from .views.api.logout import LogoutView from .views.api.loot import LootView from .views.api.player import PlayerView +from .views.html.api import ApiDocVIew, ApiHtmlView from .views.html.bis import BiSHtmlView from .views.html.index import IndexHtmlView from .views.html.loot import LootHtmlView @@ -24,10 +25,10 @@ from .views.html.users import UsersHtmlView def setup_routes(app: Application) -> None: # api routes - app.router.add_delete('/api/v1/login/{username}', LoginView) + app.router.add_delete('/admin/api/v1/login/{username}', LoginView) app.router.add_post('/api/v1/login', LoginView) app.router.add_post('/api/v1/logout', LogoutView) - app.router.add_put('/api/v1/login', LoginView) + app.router.add_put('/admin/api/v1/login', LoginView) app.router.add_get('/api/v1/party', PlayerView) app.router.add_post('/api/v1/party', PlayerView) @@ -44,6 +45,9 @@ def setup_routes(app: Application) -> None: app.router.add_get('/', IndexHtmlView) app.router.add_get('/static/{resource_id}', StaticHtmlView) + app.router.add_get('/api-docs', ApiHtmlView) + app.router.add_get('/api-docs/swagger.json', ApiDocVIew) + app.router.add_get('/party', PlayerHtmlView) app.router.add_post('/party', PlayerHtmlView) diff --git a/src/service/api/spec.py b/src/service/api/spec.py new file mode 100644 index 0000000..bb73dfd --- /dev/null +++ b/src/service/api/spec.py @@ -0,0 +1,72 @@ +# +# Copyright (c) 2019 Evgeniy Alekseev. +# +# This file is part of ffxivbis +# (see https://github.com/arcan1s/ffxivbis). +# +# License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause +# +from aiohttp.web import Application +from apispec import APISpec + +from service.core.version import __version__ +from service.models.action import Action +from service.models.bis import BiS +from service.models.error import Error +from service.models.job import Job +from service.models.piece import Piece +from service.models.player import Player, PlayerId +from service.models.player_edit import PlayerEdit +from service.models.upgrade import Upgrade + + +def get_spec(app: Application) -> APISpec: + spec = APISpec( + title='FFXIV loot helper', + version=__version__, + openapi_version='3.0.2', + info=dict(description='Loot manager for FFXIV statics'), + ) + + # routes + for route in app.router.routes(): + path = route.get_info().get('path') or route.get_info().get('formatter') + method = route.method.lower() + + spec_method = f'endpoint_{method}_spec' + if not hasattr(route.handler, spec_method): + continue + operations = getattr(route.handler, spec_method)() + if not operations: + continue + + spec.path(path, operations={method: operations}) + + # components + spec.components.schema(Action.model_name(), Action.model_spec()) + spec.components.schema(BiS.model_name(), BiS.model_spec()) + spec.components.schema(Error.model_name(), Error.model_spec()) + spec.components.schema(Job.model_name(), Job.model_spec()) + spec.components.schema(Piece.model_name(), Piece.model_spec()) + spec.components.schema(Player.model_name(), Player.model_spec()) + spec.components.schema(PlayerEdit.model_name(), PlayerEdit.model_spec()) + spec.components.schema(PlayerId.model_name(), PlayerId.model_spec()) + spec.components.schema(Upgrade.model_name(), Upgrade.model_spec()) + + # default responses + spec.components.response('BadRequest', dict( + description='Bad parameters applied or bad request was formed', + content={'application/json': {'schema': {'$ref': Error.model_ref('Error')}}} + )) + spec.components.response('Forbidden', dict( + description='User permissions do not allow this action' + )) + spec.components.response('ServerError', dict( + description='Server was unable to process request', + content={'application/json': {'schema': {'$ref': Error.model_ref('Error')}}} + )) + spec.components.response('Unauthorized', dict( + description='User was not authorized' + )) + + return spec \ No newline at end of file diff --git a/src/service/api/utils.py b/src/service/api/utils.py index 26ed564..97607ff 100644 --- a/src/service/api/utils.py +++ b/src/service/api/utils.py @@ -14,27 +14,29 @@ from typing import Any, Mapping, List from .json import HttpEncoder -def make_json(response: Any, args: Mapping[str, Any], code: int = 200) -> str: - return json.dumps({ - 'arguments': dict(args), - 'response': response, - 'status': code - }, cls=HttpEncoder, sort_keys=True) +def make_json(response: Any) -> str: + return json.dumps(response, cls=HttpEncoder, sort_keys=True) def wrap_exception(exception: Exception, args: Mapping[str, Any], code: int = 500) -> Response: if isinstance(exception, HTTPException): raise exception # reraise return - return wrap_json({'message': repr(exception)}, args, code) + return wrap_json({ + 'message': repr(exception), + 'arguments': dict(args) + }, code) def wrap_invalid_param(params: List[str], args: Mapping[str, Any], code: int = 400) -> Response: - return wrap_json({'message': 'invalid or missing parameters: `{}`'.format(params)}, args, code) + return wrap_json({ + 'message': f'invalid or missing parameters: `{params}`', + 'arguments': dict(args) + }, code) -def wrap_json(response: Any, args: Mapping[str, Any], code: int = 200) -> Response: +def wrap_json(response: Any, code: int = 200) -> Response: return Response( - text=make_json(response, args, code), + text=make_json(response), status=code, content_type='application/json' ) diff --git a/src/service/api/views/api/bis.py b/src/service/api/views/api/bis.py index a3981e2..b7b6894 100644 --- a/src/service/api/views/api/bis.py +++ b/src/service/api/views/api/bis.py @@ -26,7 +26,7 @@ class BiSView(BiSBaseView): self.request.app.logger.exception('could not get bis') return wrap_exception(e, self.request.query) - return wrap_json(loot, self.request.query) + return wrap_json(loot) async def post(self) -> Response: try: @@ -51,7 +51,7 @@ class BiSView(BiSBaseView): self.request.app.logger.exception('could not add bis') return wrap_exception(e, data) - return wrap_json({'piece': piece, 'player_id': player_id}, data) + return wrap_json({'piece': piece, 'player_id': player_id}) async def put(self) -> Response: try: @@ -71,4 +71,4 @@ class BiSView(BiSBaseView): self.request.app.logger.exception('could not parse bis') return wrap_exception(e, data) - return wrap_json({'link': link}, data) \ No newline at end of file + return wrap_json({'link': link}) \ No newline at end of file diff --git a/src/service/api/views/api/login.py b/src/service/api/views/api/login.py index 2e7a44d..503e19b 100644 --- a/src/service/api/views/api/login.py +++ b/src/service/api/views/api/login.py @@ -23,7 +23,7 @@ class LoginView(LoginBaseView): self.request.app.logger.exception('cannot remove user') return wrap_exception(e, {'username': username}) - return wrap_json({}, {'username': username}) + return wrap_json({}) async def post(self) -> Response: try: @@ -41,7 +41,7 @@ class LoginView(LoginBaseView): self.request.app.logger.exception('cannot login user') return wrap_exception(e, data) - return wrap_json({}, data) + return wrap_json({}) async def put(self) -> Response: try: @@ -56,7 +56,7 @@ class LoginView(LoginBaseView): try: await self.create_user(data['username'], data['password'], data.get('permission', 'get')) except Exception as e: - self.request.app.logger.exception('cannot login user') + self.request.app.logger.exception('cannot create user') return wrap_exception(e, data) - return wrap_json({}, data) \ No newline at end of file + return wrap_json({}) \ No newline at end of file diff --git a/src/service/api/views/api/logout.py b/src/service/api/views/api/logout.py index 1bc5cea..fcac24f 100644 --- a/src/service/api/views/api/logout.py +++ b/src/service/api/views/api/logout.py @@ -18,4 +18,4 @@ class LogoutView(LoginBaseView): self.request.app.logger.exception('cannot logout user') return wrap_exception(e, {}) - return wrap_json({}, {}) \ No newline at end of file + return wrap_json({}) \ No newline at end of file diff --git a/src/service/api/views/api/loot.py b/src/service/api/views/api/loot.py index 34300a0..7e0d755 100644 --- a/src/service/api/views/api/loot.py +++ b/src/service/api/views/api/loot.py @@ -26,7 +26,7 @@ class LootView(LootBaseView): self.request.app.logger.exception('could not get loot') return wrap_exception(e, self.request.query) - return wrap_json(loot, self.request.query) + return wrap_json(loot) async def post(self) -> Response: try: @@ -51,7 +51,7 @@ class LootView(LootBaseView): self.request.app.logger.exception('could not add loot') return wrap_exception(e, data) - return wrap_json({'piece': piece, 'player_id': player_id}, data) + return wrap_json({'piece': piece, 'player_id': player_id}) async def put(self) -> Response: try: @@ -71,4 +71,4 @@ class LootView(LootBaseView): self.request.app.logger.exception('could not suggest loot') return wrap_exception(e, data) - return wrap_json(players, data) \ No newline at end of file + return wrap_json(players) \ No newline at end of file diff --git a/src/service/api/views/api/openapi.py b/src/service/api/views/api/openapi.py new file mode 100644 index 0000000..6215660 --- /dev/null +++ b/src/service/api/views/api/openapi.py @@ -0,0 +1,115 @@ +# +# Copyright (c) 2019 Evgeniy Alekseev. +# +# This file is part of ffxivbis +# (see https://github.com/arcan1s/ffxivbis). +# +# License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause +# +from __future__ import annotations + +from typing import Any, Dict, List, Optional, Type + +from service.models.serializable import Serializable + + +class OpenApi(Serializable): + + @classmethod + def endpoint_delete_spec(cls: Type[OpenApi]) -> Dict[str, Any]: + return {} + + @classmethod + def endpoint_get_description(cls: Type[OpenApi]) -> Optional[str]: + return None + + @classmethod + def endpoint_get_parameters(cls: Type[OpenApi]) -> List[Dict[str, Any]]: + return [] + + @classmethod + def endpoint_get_responses(cls: Type[OpenApi]) -> Dict[str, Any]: + return {} + + @classmethod + def endpoint_get_summary(cls: Type[OpenApi]) -> Optional[str]: + return None + + @classmethod + def endpoint_get_tags(cls: Type[OpenApi]) -> List[str]: + return [] + + @classmethod + def endpoint_get_spec(cls: Type[OpenApi]) -> Dict[str, Any]: + description = cls.endpoint_get_description() + if description is None: + return {} + return { + 'description': description, + 'parameters': cls.endpoint_get_parameters(), + 'responses': cls.endpoint_with_default_responses(cls.endpoint_get_responses()), + 'summary': cls.endpoint_get_summary(), + 'tags': cls.endpoint_get_tags() + } + + @classmethod + def endpoint_post_consumes(cls: Type[OpenApi]) -> List[str]: + return ['application/json'] + + @classmethod + def endpoint_post_description(cls: Type[OpenApi]) -> Optional[str]: + return None + + @classmethod + def endpoint_post_request_body(cls: Type[OpenApi], content_type: str) -> str: + return '' + + @classmethod + def endpoint_post_responses(cls: Type[OpenApi]) -> Dict[str, Any]: + return {} + + @classmethod + def endpoint_post_summary(cls: Type[OpenApi]) -> Optional[str]: + return None + + @classmethod + def endpoint_post_tags(cls: Type[OpenApi]) -> List[str]: + return [] + + @classmethod + def endpoint_post_spec(cls: Type[OpenApi]) -> Dict[str, Any]: + description = cls.endpoint_post_description() + if description is None: + return {} + return { + 'consumes': cls.endpoint_post_consumes(), + 'description': description, + 'requestBody': { + 'content': { + content_type: { + 'schema': {'$ref': cls.model_ref(cls.endpoint_post_request_body(content_type))} + } + for content_type in cls.endpoint_post_consumes() + } + }, + 'responses': cls.endpoint_with_default_responses(cls.endpoint_post_responses()), + 'summary': cls.endpoint_post_summary(), + 'tags': cls.endpoint_post_tags() + } + + @classmethod + def endpoint_spec(cls: Type[OpenApi], operations: List[str]) -> Dict[str, Any]: + return { + operation.lower(): getattr(cls, f'endpoint_{operation.lower()}_spec') + for operation in operations + } + + @classmethod + def endpoint_with_default_responses(cls: Type[OpenApi], responses: Dict[str, Any]) -> Dict[str, Any]: + responses.update({ + '400': {'$ref': cls.model_ref('BadRequest', 'responses')}, + '401': {'$ref': cls.model_ref('Unauthorized', 'responses')}, + '403': {'$ref': cls.model_ref('Forbidden', 'responses')}, + '500': {'$ref': cls.model_ref('ServerError', 'responses')} + }) + return responses diff --git a/src/service/api/views/api/player.py b/src/service/api/views/api/player.py index e326635..ac3fe0a 100644 --- a/src/service/api/views/api/player.py +++ b/src/service/api/views/api/player.py @@ -7,14 +7,69 @@ # License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause # from aiohttp.web import Response +from typing import Any, Dict, List, Optional, Type from service.models.job import Job from service.api.utils import wrap_exception, wrap_invalid_param, wrap_json from service.api.views.common.player_base import PlayerBaseView +from .openapi import OpenApi -class PlayerView(PlayerBaseView): + +class PlayerView(PlayerBaseView, OpenApi): + + @classmethod + def endpoint_get_description(cls: Type[OpenApi]) -> Optional[str]: + return 'Get party players with optional nick filter' + + @classmethod + def endpoint_get_parameters(cls: Type[OpenApi]) -> List[Dict[str, Any]]: + return [ + { + 'name': 'nick', + 'in': 'query', + 'description': 'player nick name to filter', + 'required': False, + 'type': 'string' + } + ] + + @classmethod + def endpoint_get_responses(cls: Type[OpenApi]) -> Dict[str, Any]: + return { + '200': {'content': {'application/json': {'schema': {'$ref': cls.model_ref('Player')}}}} + } + + @classmethod + def endpoint_get_summary(cls: Type[OpenApi]) -> Optional[str]: + return 'get party players' + + @classmethod + def endpoint_get_tags(cls: Type[OpenApi]) -> List[str]: + return ['party'] + + @classmethod + def endpoint_post_description(cls: Type[OpenApi]) -> Optional[str]: + return 'Create new party player or remove existing' + + @classmethod + def endpoint_post_request_body(cls: Type[OpenApi], content_type: str) -> str: + return 'PlayerEdit' + + @classmethod + def endpoint_post_responses(cls: Type[OpenApi]) -> Dict[str, Any]: + return { + '200': {'content': {'application/json': {'schema': {'$ref': cls.model_ref('PlayerId')}}}} + } + + @classmethod + def endpoint_post_summary(cls: Type[OpenApi]) -> Optional[str]: + return 'add or remove player' + + @classmethod + def endpoint_post_tags(cls: Type[OpenApi]) -> List[str]: + return ['party'] async def get(self) -> Response: try: @@ -24,7 +79,7 @@ class PlayerView(PlayerBaseView): self.request.app.logger.exception('could not get party') return wrap_exception(e, self.request.query) - return wrap_json(party, self.request.query) + return wrap_json(party) async def post(self) -> Response: try: @@ -49,4 +104,4 @@ class PlayerView(PlayerBaseView): self.request.app.logger.exception('could not add loot') return wrap_exception(e, data) - return wrap_json(player_id, data) \ No newline at end of file + return wrap_json(player_id) \ No newline at end of file diff --git a/src/service/api/views/html/api.py b/src/service/api/views/html/api.py new file mode 100644 index 0000000..0be13df --- /dev/null +++ b/src/service/api/views/html/api.py @@ -0,0 +1,29 @@ +# Copyright (c) 2019 Evgeniy Alekseev. +# +# This file is part of ffxivbis +# (see https://github.com/arcan1s/ffxivbis). +# +# License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause +# +import json + +from aiohttp.web import Response, View +from aiohttp_jinja2 import template +from typing import Any, Dict + + +class ApiDocVIew(View): + + async def get(self) -> Response: + return Response( + text=json.dumps(self.request.app['spec'].to_dict()), + status=200, + content_type='application/json' + ) + + +class ApiHtmlView(View): + + @template('api.jinja2') + async def get(self) -> Dict[str, Any]: + return {} \ No newline at end of file diff --git a/src/service/api/web.py b/src/service/api/web.py index 7795131..503e9d9 100644 --- a/src/service/api/web.py +++ b/src/service/api/web.py @@ -21,6 +21,7 @@ from service.core.party import Party from .auth import AuthorizationPolicy, authorize_factory from .routes import setup_routes +from .spec import get_spec async def on_shutdown(app: web.Application) -> None: @@ -53,6 +54,7 @@ def setup_service(config: Configuration, database: Database, loot: LootSelector, templates_root = app['templates_root'] = config.get('web', 'templates') app['static_root_url'] = '/static' aiohttp_jinja2.setup(app, loader=jinja2.FileSystemLoader(templates_root)) + app['spec'] = get_spec(app) app.logger.info('setup configuration') app['config'] = config diff --git a/src/service/core/ariyala_parser.py b/src/service/core/ariyala_parser.py index 7a80142..5aee1b4 100644 --- a/src/service/core/ariyala_parser.py +++ b/src/service/core/ariyala_parser.py @@ -47,7 +47,7 @@ class AriyalaParser: def get_ids(self, url: str, job: str) -> Dict[str, int]: norm_path = os.path.normpath(url) set_id = os.path.basename(norm_path) - response = requests.get('{}/store.app'.format(self.ariyala_url), params={'identifier': set_id}) + response = requests.get(f'{self.ariyala_url}/store.app', params={'identifier': set_id}) response.raise_for_status() data = response.json() @@ -72,8 +72,7 @@ class AriyalaParser: if self.xivapi_key is not None: params['private_key'] = self.xivapi_key - response = requests.get('{}/item/{}'.format(self.xivapi_url, item_id), - params=params, timeout=self.request_timeout) + response = requests.get(f'{self.xivapi_url}/item/{item_id}', params=params, timeout=self.request_timeout) response.raise_for_status() data = response.json() diff --git a/src/service/core/database.py b/src/service/core/database.py index dcdddb2..9758bdd 100644 --- a/src/service/core/database.py +++ b/src/service/core/database.py @@ -96,7 +96,7 @@ class Database: raise NotImplementedError def migration(self) -> None: - self.logger.info('perform migrations at {}'.format(self.connection)) + self.logger.info('perform migrations') backend = get_backend(self.connection) migrations = read_migrations(self.migrations_path) with backend.lock(): diff --git a/src/service/core/exceptions.py b/src/service/core/exceptions.py index 83e4657..a2a0cb7 100644 --- a/src/service/core/exceptions.py +++ b/src/service/core/exceptions.py @@ -12,16 +12,16 @@ from typing import Any, Mapping class InvalidDatabase(Exception): def __init__(self, database_type: str) -> None: - Exception.__init__(self, 'Unsupported database {}'.format(database_type)) + Exception.__init__(self, f'Unsupported database {database_type}') class InvalidDataRow(Exception): def __init__(self, data: Mapping[str, Any]) -> None: - Exception.__init__(self, 'Invalid data row `{}`'.format(data)) + Exception.__init__(self, f'Invalid data row `{data}`') class MissingConfiguration(Exception): def __init__(self, section: str) -> None: - Exception.__init__(self, 'Missing configuration section {}'.format(section)) \ No newline at end of file + Exception.__init__(self, f'Missing configuration section {section}') \ No newline at end of file diff --git a/src/service/core/postgres.py b/src/service/core/postgres.py index c3bbce9..d7f7292 100644 --- a/src/service/core/postgres.py +++ b/src/service/core/postgres.py @@ -36,9 +36,7 @@ class PostgresDatabase(Database): @property def connection(self) -> str: - return 'postgresql://{username}:{password}@{host}:{port}/{database}'.format( - username=self.username, password=self.password, host=self.host, port=self.port, database=self.database - ) + return f'postgresql://{self.username}:{self.password}@{self.host}:{self.port}/{self.database}' async def init(self) -> None: self.pool = await asyncpg.create_pool(host=self.host, port=self.port, username=self.username, diff --git a/src/service/core/sqlite.py b/src/service/core/sqlite.py index d79e385..f28dd3e 100644 --- a/src/service/core/sqlite.py +++ b/src/service/core/sqlite.py @@ -29,7 +29,7 @@ class SQLiteDatabase(Database): @property def connection(self) -> str: - return 'sqlite:///{path}'.format(path=self.database_path) + return f'sqlite:///{self.database_path}' async def delete_piece(self, player_id: PlayerId, piece: Union[Piece, Upgrade]) -> None: player = await self.get_player(player_id) diff --git a/src/service/models/action.py b/src/service/models/action.py new file mode 100644 index 0000000..7110c35 --- /dev/null +++ b/src/service/models/action.py @@ -0,0 +1,16 @@ +# +# Copyright (c) 2019 Evgeniy Alekseev. +# +# This file is part of ffxivbis +# (see https://github.com/arcan1s/ffxivbis). +# +# License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause +# +from enum import auto + +from .serializable import SerializableEnum + + +class Action(SerializableEnum): + add = auto() + remove = auto() \ No newline at end of file diff --git a/src/service/models/bis.py b/src/service/models/bis.py index 419a955..0d3746b 100644 --- a/src/service/models/bis.py +++ b/src/service/models/bis.py @@ -9,14 +9,15 @@ import itertools from dataclasses import dataclass -from typing import Dict, List, Optional, Union +from typing import Any, Dict, List, Optional, Type, Union from .piece import Piece +from .serializable import Serializable from .upgrade import Upgrade @dataclass -class BiS: +class BiS(Serializable): weapon: Optional[Piece] = None head: Optional[Piece] = None body: Optional[Piece] = None @@ -30,13 +31,6 @@ class BiS: left_ring: Optional[Piece] = None right_ring: Optional[Piece] = None - def has_piece(self, piece: Union[Piece, Upgrade]) -> bool: - if isinstance(piece, Piece): - return piece in self.pieces - elif isinstance(piece, Upgrade): - return self.upgrades_required.get(piece) is not None - return False - @property def pieces(self) -> List[Piece]: return [piece for piece in self.__dict__.values() if isinstance(piece, Piece)] @@ -48,6 +42,66 @@ class BiS: for upgrade, pieces in itertools.groupby(self.pieces, lambda piece: piece.upgrade) } + @classmethod + def model_properties(cls: Type[Serializable]) -> Dict[str, Any]: + return { + 'weapon': { + 'description': 'weapon part of BiS', + '$ref': cls.model_ref('Piece') + }, + 'head': { + 'description': 'head part of BiS', + '$ref': cls.model_ref('Piece') + }, + 'body': { + 'description': 'body part of BiS', + '$ref': cls.model_ref('Piece') + }, + 'hands': { + 'description': 'hands part of BiS', + '$ref': cls.model_ref('Piece') + }, + 'waist': { + 'description': 'waist part of BiS', + '$ref': cls.model_ref('Piece') + }, + 'legs': { + 'description': 'legs part of BiS', + '$ref': cls.model_ref('Piece') + }, + 'feet': { + 'description': 'feet part of BiS', + '$ref': cls.model_ref('Piece') + }, + 'ears': { + 'description': 'ears part of BiS', + '$ref': cls.model_ref('Piece') + }, + 'neck': { + 'description': 'neck part of BiS', + '$ref': cls.model_ref('Piece') + }, + 'wrist': { + 'description': 'wrist part of BiS', + '$ref': cls.model_ref('Piece') + }, + 'left_ring': { + 'description': 'left_ring part of BiS', + '$ref': cls.model_ref('Piece') + }, + 'right_ring': { + 'description': 'right_ring part of BiS', + '$ref': cls.model_ref('Piece') + } + } + + def has_piece(self, piece: Union[Piece, Upgrade]) -> bool: + if isinstance(piece, Piece): + return piece in self.pieces + elif isinstance(piece, Upgrade): + return self.upgrades_required.get(piece) is not None + return False + def set_item(self, piece: Union[Piece, Upgrade]) -> None: setattr(self, piece.name, piece) diff --git a/src/service/models/error.py b/src/service/models/error.py new file mode 100644 index 0000000..5701053 --- /dev/null +++ b/src/service/models/error.py @@ -0,0 +1,36 @@ +# +# Copyright (c) 2019 Evgeniy Alekseev. +# +# This file is part of ffxivbis +# (see https://github.com/arcan1s/ffxivbis). +# +# License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause +# +from dataclasses import dataclass +from typing import Any, Dict, List, Type + +from .serializable import Serializable + + +@dataclass +class Error(Serializable): + message: str + arguments: Dict[str, Any] + + @classmethod + def model_properties(cls: Type[Serializable]) -> Dict[str, Any]: + return { + 'arguments': { + 'description': 'arguments passed to request', + 'type': 'object', + 'additionalProperties': True + }, + 'message': { + 'description': 'error message', + 'type': 'string' + } + } + + @classmethod + def model_required(cls: Type[Serializable]) -> List[str]: + return ['arguments', 'message'] \ No newline at end of file diff --git a/src/service/models/job.py b/src/service/models/job.py index 05a1af2..420cc13 100644 --- a/src/service/models/job.py +++ b/src/service/models/job.py @@ -8,13 +8,14 @@ # from __future__ import annotations -from enum import Enum, auto +from enum import auto from typing import Tuple from .piece import Piece, PieceAccessory, Weapon +from .serializable import SerializableEnum -class Job(Enum): +class Job(SerializableEnum): PLD = auto() WAR = auto() DRK = auto() diff --git a/src/service/models/piece.py b/src/service/models/piece.py index f5a6f63..2bcded5 100644 --- a/src/service/models/piece.py +++ b/src/service/models/piece.py @@ -9,15 +9,17 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Any, List, Mapping, Type, Union - -from .upgrade import Upgrade +from typing import Any, Dict, List, Mapping, Type, Union from service.core.exceptions import InvalidDataRow +from .serializable import Serializable +from .upgrade import Upgrade + + @dataclass -class Piece: +class Piece(Serializable): is_tome: bool name: str @@ -75,6 +77,24 @@ class Piece: else: raise InvalidDataRow(data) + @classmethod + def model_properties(cls: Type[Serializable]) -> Dict[str, Any]: + return { + 'is_tome': { + 'description': 'is this piece tome gear or not', + 'type': 'boolean' + }, + 'name': { + 'description': 'piece name', + 'required': True, + 'type': 'string' + } + } + + @classmethod + def model_required(cls: Type[Serializable]) -> List[str]: + return ['is_tome', 'name'] + @dataclass class PieceAccessory(Piece): diff --git a/src/service/models/player.py b/src/service/models/player.py index b6c4d06..9075964 100644 --- a/src/service/models/player.py +++ b/src/service/models/player.py @@ -11,22 +11,23 @@ from __future__ import annotations import re from dataclasses import dataclass -from typing import List, Optional, Type, Union +from typing import Any, Dict, List, Optional, Type, Union from .bis import BiS from .job import Job from .piece import Piece +from .serializable import Serializable from .upgrade import Upgrade @dataclass -class PlayerId: +class PlayerId(Serializable): job: Job nick: str @property def pretty_name(self) -> str: - return '{} ({})'.format(self.nick, self.job.name) + return f'{self.nick} ({self.job.name})' @classmethod def from_pretty_name(cls: Type[PlayerId], value: str) -> Optional[PlayerId]: @@ -35,6 +36,23 @@ class PlayerId: return None return PlayerId(Job[matches.group('job')], matches.group('nick')) + @classmethod + def model_properties(cls: Type[Serializable]) -> Dict[str, Any]: + return { + 'job': { + 'description': 'player job name', + '$ref': cls.model_ref('Job') + }, + 'nick': { + 'description': 'player nick name', + 'type': 'string' + } + } + + @classmethod + def model_required(cls: Type[Serializable]) -> List[str]: + return ['job', 'nick'] + def __hash__(self) -> int: return hash(str(self)) @@ -50,13 +68,7 @@ class PlayerIdWithCounters(PlayerId): @dataclass -class PlayerIdFull: - jobs: List[Job] - nick: str - - -@dataclass -class Player: +class Player(Serializable): job: Job nick: str bis: BiS @@ -68,6 +80,45 @@ class Player: def player_id(self) -> PlayerId: return PlayerId(self.job, self.nick) + @classmethod + def model_properties(cls: Type[Serializable]) -> Dict[str, Any]: + return { + 'bis': { + 'description': 'player BiS', + '$ref': cls.model_ref('BiS') + }, + 'job': { + 'description': 'player job name', + '$ref': cls.model_ref('Job') + }, + 'link': { + 'description': 'link to player BiS', + 'type': 'string' + }, + 'loot': { + 'description': 'player looted items', + 'type': 'array', + 'items': { + 'anyOf': [ + {'$ref': cls.model_ref('Piece')}, + {'$ref': cls.model_ref('Upgrade')} + ] + } + }, + 'nick': { + 'description': 'player nick name', + 'type': 'string' + }, + 'priority': { + 'description': 'player loot priority', + 'type': 'integer' + } + } + + @classmethod + def model_required(cls: Type[Serializable]) -> List[str]: + return ['bis', 'job', 'loot', 'nick', 'priority'] + def player_id_with_counters(self, piece: Union[Piece, Upgrade, None]) -> PlayerIdWithCounters: return PlayerIdWithCounters(self.job, self.nick, self.is_required(piece), self.priority, abs(self.loot_count(piece)), abs(self.loot_count_bis(piece)), diff --git a/src/service/models/player_edit.py b/src/service/models/player_edit.py new file mode 100644 index 0000000..e3270f2 --- /dev/null +++ b/src/service/models/player_edit.py @@ -0,0 +1,35 @@ +# +# Copyright (c) 2019 Evgeniy Alekseev. +# +# This file is part of ffxivbis +# (see https://github.com/arcan1s/ffxivbis). +# +# License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause +# +from typing import Any, Dict, List, Type + +from .serializable import Serializable + + +class PlayerEdit(Serializable): + + @classmethod + def model_properties(cls: Type[Serializable]) -> Dict[str, Any]: + return { + 'action': { + 'description': 'action to perform', + '$ref': cls.model_ref('Action') + }, + 'job': { + 'description': 'player job name to edit', + '$ref': cls.model_ref('Job') + }, + 'nick': { + 'description': 'player nick name to edit', + 'type': 'string' + } + } + + @classmethod + def model_required(cls: Type[Serializable]) -> List[str]: + return ['action', 'nick', 'job'] \ No newline at end of file diff --git a/src/service/models/serializable.py b/src/service/models/serializable.py new file mode 100644 index 0000000..247b5ec --- /dev/null +++ b/src/service/models/serializable.py @@ -0,0 +1,57 @@ +# +# Copyright (c) 2019 Evgeniy Alekseev. +# +# This file is part of ffxivbis +# (see https://github.com/arcan1s/ffxivbis). +# +# License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause +# +from __future__ import annotations + +from enum import Enum +from typing import Any, Dict, List, Type + + +class Serializable: + + @classmethod + def model_name(cls: Type[Serializable]) -> str: + return cls.__name__ + + @classmethod + def model_properties(cls: Type[Serializable]) -> Dict[str, Any]: + raise NotImplementedError + + @staticmethod + def model_ref(model_name: str, model_group: str = 'schemas') -> str: + return f'#/components/{model_group}/{model_name}' + + @classmethod + def model_required(cls: Type[Serializable]) -> List[str]: + return [] + + @classmethod + def model_spec(cls: Type[Serializable]) -> Dict[str, Any]: + return { + 'type': cls.model_type(), + 'properties': cls.model_properties(), + 'required': cls.model_required() + } + + @classmethod + def model_type(cls: Type[Serializable]) -> str: + return 'object' + + +class SerializableEnum(Serializable, Enum): + + @classmethod + def model_spec(cls: Type[SerializableEnum]) -> Dict[str, Any]: + return { + 'type': cls.model_type(), + 'enum': [item.name for item in cls] + } + + @classmethod + def model_type(cls: Type[Serializable]) -> str: + return 'string' \ No newline at end of file diff --git a/src/service/models/upgrade.py b/src/service/models/upgrade.py index 7baf37e..b5fb86d 100644 --- a/src/service/models/upgrade.py +++ b/src/service/models/upgrade.py @@ -6,11 +6,13 @@ # # License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause # -from enum import Enum, auto +from enum import auto from typing import List +from .serializable import SerializableEnum -class Upgrade(Enum): + +class Upgrade(SerializableEnum): NoUpgrade = auto() AccessoryUpgrade = auto() GearUpgrade = auto() diff --git a/templates/api.jinja2 b/templates/api.jinja2 new file mode 100644 index 0000000..0a9fda0 --- /dev/null +++ b/templates/api.jinja2 @@ -0,0 +1,24 @@ + + + + ReDoc + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/test_view_bis.py b/test/test_view_bis.py index af27115..99cd3e2 100644 --- a/test/test_view_bis.py +++ b/test/test_view_bis.py @@ -14,7 +14,7 @@ async def test_bis_get(server: Any, party: Party, player: Player, player2: Playe response = await server.get('/api/v1/party/bis') assert response.status == 200 - assert await response.text() == make_json([weapon, weapon, head_with_upgrade], {}, 200) + assert await response.text() == make_json([weapon, weapon, head_with_upgrade]) async def test_bis_get_with_filter(server: Any, party: Party, player: Player, player2: Player, @@ -25,11 +25,11 @@ async def test_bis_get_with_filter(server: Any, party: Party, player: Player, pl response = await server.get('/api/v1/party/bis', params={'nick': player.nick}) assert response.status == 200 - assert await response.text() == make_json([weapon], {'nick': player.nick}, 200) + assert await response.text() == make_json([weapon]) response = await server.get('/api/v1/party/bis', params={'nick': player2.nick}) assert response.status == 200 - assert await response.text() == make_json([weapon, head_with_upgrade], {'nick': player2.nick}, 200) + assert await response.text() == make_json([weapon, head_with_upgrade]) async def test_bis_post_add(server: Any, player: Player, head_with_upgrade: Piece) -> None: diff --git a/test/test_view_loot.py b/test/test_view_loot.py index c0db9c5..5dd1f4a 100644 --- a/test/test_view_loot.py +++ b/test/test_view_loot.py @@ -12,7 +12,7 @@ async def test_loot_get(server: Any, party: Party, player: Player, player2: Play response = await server.get('/api/v1/party/loot') assert response.status == 200 - assert await response.text() == make_json([weapon, weapon], {}, 200) + assert await response.text() == make_json([weapon, weapon]) async def test_loot_get_with_filter(server: Any, party: Party, player: Player, player2: Player, weapon: Piece) -> None: @@ -21,17 +21,17 @@ async def test_loot_get_with_filter(server: Any, party: Party, player: Player, p response = await server.get('/api/v1/party/loot', params={'nick': player.nick}) assert response.status == 200 - assert await response.text() == make_json([weapon], {'nick': player.nick}, 200) + assert await response.text() == make_json([weapon]) response = await server.get('/api/v1/party/loot', params={'nick': player2.nick}) assert response.status == 200 - assert await response.text() == make_json([weapon], {'nick': player2.nick}, 200) + assert await response.text() == make_json([weapon]) async def test_loot_post_add(server: Any, player: Player, weapon: Piece) -> None: response = await server.get('/api/v1/party/loot') assert response.status == 200 - assert await response.text() == make_json([], {}, 200) + assert await response.text() == make_json([]) assert weapon not in player.loot response = await server.post('/api/v1/party/loot', json={ @@ -82,7 +82,5 @@ async def test_loot_put(server: Any, player: Player, player2: Player, head_with_ }) assert response.status == 200 assert await response.text() == make_json( - [player2.player_id_with_counters(head_with_upgrade), player.player_id_with_counters(head_with_upgrade)], - {'is_tome': head_with_upgrade.is_tome, 'piece': head_with_upgrade.name}, - 200 + [player2.player_id_with_counters(head_with_upgrade), player.player_id_with_counters(head_with_upgrade)] ) diff --git a/test/test_view_player.py b/test/test_view_player.py index 81ff88f..f56503d 100644 --- a/test/test_view_player.py +++ b/test/test_view_player.py @@ -11,7 +11,7 @@ async def test_players_get(server: Any, party: Party, player: Player) -> None: response = await server.get('/api/v1/party') assert response.status == 200 - assert await response.text() == make_json(party.party, {}, 200) + assert await response.text() == make_json(party.party) async def test_players_get_with_filter(server: Any, party: Party, player: Player, player2: Player) -> None: @@ -19,11 +19,11 @@ async def test_players_get_with_filter(server: Any, party: Party, player: Player response = await server.get('/api/v1/party', params={'nick': player.nick}) assert response.status == 200 - assert await response.text() == make_json([player], {'nick': player.nick}, 200) + assert await response.text() == make_json([player]) response = await server.get('/api/v1/party', params={'nick': player2.nick}) assert response.status == 200 - assert await response.text() == make_json([player2], {'nick': player2.nick}, 200) + assert await response.text() == make_json([player2]) async def test_players_post_add(server: Any, party: Party, player: Player) -> None: @@ -31,7 +31,7 @@ async def test_players_post_add(server: Any, party: Party, player: Player) -> No response = await server.get('/api/v1/party', params={'nick': player.nick}) assert response.status == 200 - assert await response.text() == make_json([], {'nick': player.nick}, 200) + assert await response.text() == make_json([]) response = await server.post('/api/v1/party', json={ 'action': 'add', @@ -46,7 +46,7 @@ async def test_players_post_add(server: Any, party: Party, player: Player) -> No async def test_players_post_remove(server: Any, party: Party, player: Player) -> None: response = await server.get('/api/v1/party', params={'nick': player.nick}) assert response.status == 200 - assert await response.text() == make_json([player], {'nick': player.nick}, 200) + assert await response.text() == make_json([player]) response = await server.post('/api/v1/party', json={ 'action': 'remove', @@ -57,7 +57,7 @@ async def test_players_post_remove(server: Any, party: Party, player: Player) -> response = await server.get('/api/v1/party', params={'nick': player.nick}) assert response.status == 200 - assert await response.text() == make_json([], {'nick': player.nick}, 200) + assert await response.text() == make_json([]) assert player.player_id not in party.players @@ -68,7 +68,7 @@ async def test_players_post_add_with_link(server: Any, party: Party, player: Pla response = await server.get('/api/v1/party', params={'nick': player.nick}) assert response.status == 200 - assert await response.text() == make_json([], {'nick': player.nick}, 200) + assert await response.text() == make_json([]) response = await server.post('/api/v1/party', json={ 'action': 'add',