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_jinja2',
'aiohttp_security',
'apispec',
'Jinja2',
'passlib',
'requests',

View File

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

View File

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

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

View File

@ -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)
return wrap_json({'link': link})

View File

@ -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)
return wrap_json({})

View File

@ -18,4 +18,4 @@ class LogoutView(LoginBaseView):
self.request.app.logger.exception('cannot logout user')
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')
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)
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
#
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)
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 .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

View File

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

View File

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

View File

@ -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))
Exception.__init__(self, f'Missing configuration section {section}')

View File

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

View File

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

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

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

View File

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

View File

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

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

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')
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:

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

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')
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',