initial swagger impl, small refactoring and move create/delete user under admin perms

This commit is contained in:
Evgenii Alekseev 2019-09-14 17:27:03 +03:00
parent f63b64e77c
commit c83f36f40b
31 changed files with 655 additions and 82 deletions

View File

@ -28,6 +28,7 @@ setup(
'aiohttp', 'aiohttp',
'aiohttp_jinja2', 'aiohttp_jinja2',
'aiohttp_security', 'aiohttp_security',
'apispec',
'Jinja2', 'Jinja2',
'passlib', 'passlib',
'requests', 'requests',

View File

@ -35,6 +35,7 @@ class AuthorizationPolicy(AbstractAuthorizationPolicy):
def authorize_factory() -> Callable: def authorize_factory() -> Callable:
allowed_paths = {'/', '/favicon.ico', '/api/v1/login', '/api/v1/logout'} allowed_paths = {'/', '/favicon.ico', '/api/v1/login', '/api/v1/logout'}
allowed_paths_groups = {'/api-docs', '/static'}
@middleware @middleware
async def authorize(request: Request, handler: Callable) -> Response: async def authorize(request: Request, handler: Callable) -> Response:
@ -42,7 +43,8 @@ def authorize_factory() -> Callable:
permission = 'admin' permission = 'admin'
else: else:
permission = 'get' if request.method in ('GET', 'HEAD') else 'post' 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) await check_permission(request, permission)
return await handler(request) return await handler(request)

View File

@ -13,6 +13,7 @@ from .views.api.login import LoginView
from .views.api.logout import LogoutView from .views.api.logout import LogoutView
from .views.api.loot import LootView from .views.api.loot import LootView
from .views.api.player import PlayerView from .views.api.player import PlayerView
from .views.html.api import ApiDocVIew, ApiHtmlView
from .views.html.bis import BiSHtmlView from .views.html.bis import BiSHtmlView
from .views.html.index import IndexHtmlView from .views.html.index import IndexHtmlView
from .views.html.loot import LootHtmlView from .views.html.loot import LootHtmlView
@ -24,10 +25,10 @@ from .views.html.users import UsersHtmlView
def setup_routes(app: Application) -> None: def setup_routes(app: Application) -> None:
# api routes # 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/login', LoginView)
app.router.add_post('/api/v1/logout', LogoutView) 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_get('/api/v1/party', PlayerView)
app.router.add_post('/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('/', IndexHtmlView)
app.router.add_get('/static/{resource_id}', StaticHtmlView) 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_get('/party', PlayerHtmlView)
app.router.add_post('/party', PlayerHtmlView) app.router.add_post('/party', PlayerHtmlView)

72
src/service/api/spec.py Normal file
View File

@ -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

View File

@ -14,27 +14,29 @@ from typing import Any, Mapping, List
from .json import HttpEncoder from .json import HttpEncoder
def make_json(response: Any, args: Mapping[str, Any], code: int = 200) -> str: def make_json(response: Any) -> str:
return json.dumps({ return json.dumps(response, cls=HttpEncoder, sort_keys=True)
'arguments': dict(args),
'response': response,
'status': code
}, cls=HttpEncoder, sort_keys=True)
def wrap_exception(exception: Exception, args: Mapping[str, Any], code: int = 500) -> Response: def wrap_exception(exception: Exception, args: Mapping[str, Any], code: int = 500) -> Response:
if isinstance(exception, HTTPException): if isinstance(exception, HTTPException):
raise exception # reraise return 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: 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( return Response(
text=make_json(response, args, code), text=make_json(response),
status=code, status=code,
content_type='application/json' content_type='application/json'
) )

View File

@ -26,7 +26,7 @@ class BiSView(BiSBaseView):
self.request.app.logger.exception('could not get bis') self.request.app.logger.exception('could not get bis')
return wrap_exception(e, self.request.query) return wrap_exception(e, self.request.query)
return wrap_json(loot, self.request.query) return wrap_json(loot)
async def post(self) -> Response: async def post(self) -> Response:
try: try:
@ -51,7 +51,7 @@ class BiSView(BiSBaseView):
self.request.app.logger.exception('could not add bis') self.request.app.logger.exception('could not add bis')
return wrap_exception(e, data) 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: async def put(self) -> Response:
try: try:
@ -71,4 +71,4 @@ class BiSView(BiSBaseView):
self.request.app.logger.exception('could not parse bis') self.request.app.logger.exception('could not parse bis')
return wrap_exception(e, data) return wrap_exception(e, data)
return wrap_json({'link': link}, data) return wrap_json({'link': link})

View File

@ -23,7 +23,7 @@ class LoginView(LoginBaseView):
self.request.app.logger.exception('cannot remove user') self.request.app.logger.exception('cannot remove user')
return wrap_exception(e, {'username': username}) return wrap_exception(e, {'username': username})
return wrap_json({}, {'username': username}) return wrap_json({})
async def post(self) -> Response: async def post(self) -> Response:
try: try:
@ -41,7 +41,7 @@ class LoginView(LoginBaseView):
self.request.app.logger.exception('cannot login user') self.request.app.logger.exception('cannot login user')
return wrap_exception(e, data) return wrap_exception(e, data)
return wrap_json({}, data) return wrap_json({})
async def put(self) -> Response: async def put(self) -> Response:
try: try:
@ -56,7 +56,7 @@ class LoginView(LoginBaseView):
try: try:
await self.create_user(data['username'], data['password'], data.get('permission', 'get')) await self.create_user(data['username'], data['password'], data.get('permission', 'get'))
except Exception as e: 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_exception(e, data)
return wrap_json({}, data) return wrap_json({})

View File

@ -18,4 +18,4 @@ class LogoutView(LoginBaseView):
self.request.app.logger.exception('cannot logout user') self.request.app.logger.exception('cannot logout user')
return wrap_exception(e, {}) return wrap_exception(e, {})
return wrap_json({}, {}) return wrap_json({})

View File

@ -26,7 +26,7 @@ class LootView(LootBaseView):
self.request.app.logger.exception('could not get loot') self.request.app.logger.exception('could not get loot')
return wrap_exception(e, self.request.query) return wrap_exception(e, self.request.query)
return wrap_json(loot, self.request.query) return wrap_json(loot)
async def post(self) -> Response: async def post(self) -> Response:
try: try:
@ -51,7 +51,7 @@ class LootView(LootBaseView):
self.request.app.logger.exception('could not add loot') self.request.app.logger.exception('could not add loot')
return wrap_exception(e, data) 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: async def put(self) -> Response:
try: try:
@ -71,4 +71,4 @@ class LootView(LootBaseView):
self.request.app.logger.exception('could not suggest loot') self.request.app.logger.exception('could not suggest loot')
return wrap_exception(e, data) return wrap_exception(e, data)
return wrap_json(players, data) return wrap_json(players)

View File

@ -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

View File

@ -7,14 +7,69 @@
# License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause # License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause
# #
from aiohttp.web import Response from aiohttp.web import Response
from typing import Any, Dict, List, Optional, Type
from service.models.job import Job from service.models.job import Job
from service.api.utils import wrap_exception, wrap_invalid_param, wrap_json from service.api.utils import wrap_exception, wrap_invalid_param, wrap_json
from service.api.views.common.player_base import PlayerBaseView 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: async def get(self) -> Response:
try: try:
@ -24,7 +79,7 @@ class PlayerView(PlayerBaseView):
self.request.app.logger.exception('could not get party') self.request.app.logger.exception('could not get party')
return wrap_exception(e, self.request.query) return wrap_exception(e, self.request.query)
return wrap_json(party, self.request.query) return wrap_json(party)
async def post(self) -> Response: async def post(self) -> Response:
try: try:
@ -49,4 +104,4 @@ class PlayerView(PlayerBaseView):
self.request.app.logger.exception('could not add loot') self.request.app.logger.exception('could not add loot')
return wrap_exception(e, data) return wrap_exception(e, data)
return wrap_json(player_id, data) return wrap_json(player_id)

View File

@ -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 {}

View File

@ -21,6 +21,7 @@ from service.core.party import Party
from .auth import AuthorizationPolicy, authorize_factory from .auth import AuthorizationPolicy, authorize_factory
from .routes import setup_routes from .routes import setup_routes
from .spec import get_spec
async def on_shutdown(app: web.Application) -> None: 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') templates_root = app['templates_root'] = config.get('web', 'templates')
app['static_root_url'] = '/static' app['static_root_url'] = '/static'
aiohttp_jinja2.setup(app, loader=jinja2.FileSystemLoader(templates_root)) aiohttp_jinja2.setup(app, loader=jinja2.FileSystemLoader(templates_root))
app['spec'] = get_spec(app)
app.logger.info('setup configuration') app.logger.info('setup configuration')
app['config'] = config app['config'] = config

View File

@ -47,7 +47,7 @@ class AriyalaParser:
def get_ids(self, url: str, job: str) -> Dict[str, int]: def get_ids(self, url: str, job: str) -> Dict[str, int]:
norm_path = os.path.normpath(url) norm_path = os.path.normpath(url)
set_id = os.path.basename(norm_path) 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() response.raise_for_status()
data = response.json() data = response.json()
@ -72,8 +72,7 @@ class AriyalaParser:
if self.xivapi_key is not None: if self.xivapi_key is not None:
params['private_key'] = self.xivapi_key params['private_key'] = self.xivapi_key
response = requests.get('{}/item/{}'.format(self.xivapi_url, item_id), response = requests.get(f'{self.xivapi_url}/item/{item_id}', params=params, timeout=self.request_timeout)
params=params, timeout=self.request_timeout)
response.raise_for_status() response.raise_for_status()
data = response.json() data = response.json()

View File

@ -96,7 +96,7 @@ class Database:
raise NotImplementedError raise NotImplementedError
def migration(self) -> None: def migration(self) -> None:
self.logger.info('perform migrations at {}'.format(self.connection)) self.logger.info('perform migrations')
backend = get_backend(self.connection) backend = get_backend(self.connection)
migrations = read_migrations(self.migrations_path) migrations = read_migrations(self.migrations_path)
with backend.lock(): with backend.lock():

View File

@ -12,16 +12,16 @@ from typing import Any, Mapping
class InvalidDatabase(Exception): class InvalidDatabase(Exception):
def __init__(self, database_type: str) -> None: 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): class InvalidDataRow(Exception):
def __init__(self, data: Mapping[str, Any]) -> None: 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): class MissingConfiguration(Exception):
def __init__(self, section: str) -> None: def __init__(self, section: str) -> None:
Exception.__init__(self, 'Missing configuration section {}'.format(section)) Exception.__init__(self, f'Missing configuration section {section}')

View File

@ -36,9 +36,7 @@ class PostgresDatabase(Database):
@property @property
def connection(self) -> str: def connection(self) -> str:
return 'postgresql://{username}:{password}@{host}:{port}/{database}'.format( return f'postgresql://{self.username}:{self.password}@{self.host}:{self.port}/{self.database}'
username=self.username, password=self.password, host=self.host, port=self.port, database=self.database
)
async def init(self) -> None: async def init(self) -> None:
self.pool = await asyncpg.create_pool(host=self.host, port=self.port, username=self.username, self.pool = await asyncpg.create_pool(host=self.host, port=self.port, username=self.username,

View File

@ -29,7 +29,7 @@ class SQLiteDatabase(Database):
@property @property
def connection(self) -> str: 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: async def delete_piece(self, player_id: PlayerId, piece: Union[Piece, Upgrade]) -> None:
player = await self.get_player(player_id) player = await self.get_player(player_id)

View File

@ -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()

View File

@ -9,14 +9,15 @@
import itertools import itertools
from dataclasses import dataclass 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 .piece import Piece
from .serializable import Serializable
from .upgrade import Upgrade from .upgrade import Upgrade
@dataclass @dataclass
class BiS: class BiS(Serializable):
weapon: Optional[Piece] = None weapon: Optional[Piece] = None
head: Optional[Piece] = None head: Optional[Piece] = None
body: Optional[Piece] = None body: Optional[Piece] = None
@ -30,13 +31,6 @@ class BiS:
left_ring: Optional[Piece] = None left_ring: Optional[Piece] = None
right_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 @property
def pieces(self) -> List[Piece]: def pieces(self) -> List[Piece]:
return [piece for piece in self.__dict__.values() if isinstance(piece, 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) 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: def set_item(self, piece: Union[Piece, Upgrade]) -> None:
setattr(self, piece.name, piece) setattr(self, piece.name, piece)

View File

@ -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']

View File

@ -8,13 +8,14 @@
# #
from __future__ import annotations from __future__ import annotations
from enum import Enum, auto from enum import auto
from typing import Tuple from typing import Tuple
from .piece import Piece, PieceAccessory, Weapon from .piece import Piece, PieceAccessory, Weapon
from .serializable import SerializableEnum
class Job(Enum): class Job(SerializableEnum):
PLD = auto() PLD = auto()
WAR = auto() WAR = auto()
DRK = auto() DRK = auto()

View File

@ -9,15 +9,17 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any, List, Mapping, Type, Union from typing import Any, Dict, List, Mapping, Type, Union
from .upgrade import Upgrade
from service.core.exceptions import InvalidDataRow from service.core.exceptions import InvalidDataRow
from .serializable import Serializable
from .upgrade import Upgrade
@dataclass @dataclass
class Piece: class Piece(Serializable):
is_tome: bool is_tome: bool
name: str name: str
@ -75,6 +77,24 @@ class Piece:
else: else:
raise InvalidDataRow(data) 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 @dataclass
class PieceAccessory(Piece): class PieceAccessory(Piece):

View File

@ -11,22 +11,23 @@ from __future__ import annotations
import re import re
from dataclasses import dataclass 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 .bis import BiS
from .job import Job from .job import Job
from .piece import Piece from .piece import Piece
from .serializable import Serializable
from .upgrade import Upgrade from .upgrade import Upgrade
@dataclass @dataclass
class PlayerId: class PlayerId(Serializable):
job: Job job: Job
nick: str nick: str
@property @property
def pretty_name(self) -> str: def pretty_name(self) -> str:
return '{} ({})'.format(self.nick, self.job.name) return f'{self.nick} ({self.job.name})'
@classmethod @classmethod
def from_pretty_name(cls: Type[PlayerId], value: str) -> Optional[PlayerId]: def from_pretty_name(cls: Type[PlayerId], value: str) -> Optional[PlayerId]:
@ -35,6 +36,23 @@ class PlayerId:
return None return None
return PlayerId(Job[matches.group('job')], matches.group('nick')) 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: def __hash__(self) -> int:
return hash(str(self)) return hash(str(self))
@ -50,13 +68,7 @@ class PlayerIdWithCounters(PlayerId):
@dataclass @dataclass
class PlayerIdFull: class Player(Serializable):
jobs: List[Job]
nick: str
@dataclass
class Player:
job: Job job: Job
nick: str nick: str
bis: BiS bis: BiS
@ -68,6 +80,45 @@ class Player:
def player_id(self) -> PlayerId: def player_id(self) -> PlayerId:
return PlayerId(self.job, self.nick) 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: def player_id_with_counters(self, piece: Union[Piece, Upgrade, None]) -> PlayerIdWithCounters:
return PlayerIdWithCounters(self.job, self.nick, self.is_required(piece), self.priority, return PlayerIdWithCounters(self.job, self.nick, self.is_required(piece), self.priority,
abs(self.loot_count(piece)), abs(self.loot_count_bis(piece)), abs(self.loot_count(piece)), abs(self.loot_count_bis(piece)),

View File

@ -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']

View File

@ -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'

View File

@ -6,11 +6,13 @@
# #
# License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause # 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 typing import List
from .serializable import SerializableEnum
class Upgrade(Enum):
class Upgrade(SerializableEnum):
NoUpgrade = auto() NoUpgrade = auto()
AccessoryUpgrade = auto() AccessoryUpgrade = auto()
GearUpgrade = auto() GearUpgrade = auto()

24
templates/api.jinja2 Normal file
View File

@ -0,0 +1,24 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>ReDoc</title>
<!-- needed for adaptive design -->
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link href="https://fonts.googleapis.com/css?family=Montserrat:300,400,700|Roboto:300,400,700" rel="stylesheet">
<!--
ReDoc doesn't change outer page styles
-->
<style>
body {
margin: 0;
padding: 0;
}
</style>
</head>
<body>
<redoc spec-url='/api-docs/swagger.json'></redoc>
<script src="https://cdn.jsdelivr.net/npm/redoc@next/bundles/redoc.standalone.js"> </script>
</body>
</html>

View File

@ -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') response = await server.get('/api/v1/party/bis')
assert response.status == 200 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, 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}) response = await server.get('/api/v1/party/bis', params={'nick': player.nick})
assert response.status == 200 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}) response = await server.get('/api/v1/party/bis', params={'nick': player2.nick})
assert response.status == 200 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: async def test_bis_post_add(server: Any, player: Player, head_with_upgrade: Piece) -> None:

View File

@ -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') response = await server.get('/api/v1/party/loot')
assert response.status == 200 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: 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}) response = await server.get('/api/v1/party/loot', params={'nick': player.nick})
assert response.status == 200 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}) response = await server.get('/api/v1/party/loot', params={'nick': player2.nick})
assert response.status == 200 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: async def test_loot_post_add(server: Any, player: Player, weapon: Piece) -> None:
response = await server.get('/api/v1/party/loot') response = await server.get('/api/v1/party/loot')
assert response.status == 200 assert response.status == 200
assert await response.text() == make_json([], {}, 200) assert await response.text() == make_json([])
assert weapon not in player.loot assert weapon not in player.loot
response = await server.post('/api/v1/party/loot', json={ 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 response.status == 200
assert await response.text() == make_json( assert await response.text() == make_json(
[player2.player_id_with_counters(head_with_upgrade), player.player_id_with_counters(head_with_upgrade)], [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
) )

View File

@ -11,7 +11,7 @@ async def test_players_get(server: Any, party: Party, player: Player) -> None:
response = await server.get('/api/v1/party') response = await server.get('/api/v1/party')
assert response.status == 200 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: 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}) response = await server.get('/api/v1/party', params={'nick': player.nick})
assert response.status == 200 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}) response = await server.get('/api/v1/party', params={'nick': player2.nick})
assert response.status == 200 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: 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}) response = await server.get('/api/v1/party', params={'nick': player.nick})
assert response.status == 200 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={ response = await server.post('/api/v1/party', json={
'action': 'add', '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: async def test_players_post_remove(server: Any, party: Party, player: Player) -> None:
response = await server.get('/api/v1/party', params={'nick': player.nick}) response = await server.get('/api/v1/party', params={'nick': player.nick})
assert response.status == 200 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={ response = await server.post('/api/v1/party', json={
'action': 'remove', '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}) response = await server.get('/api/v1/party', params={'nick': player.nick})
assert response.status == 200 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 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}) response = await server.get('/api/v1/party', params={'nick': player.nick})
assert response.status == 200 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={ response = await server.post('/api/v1/party', json={
'action': 'add', 'action': 'add',