From 8078afae97950f99204d33f58d4513caaeeb46c0 Mon Sep 17 00:00:00 2001 From: sharkykh Date: Sat, 25 Aug 2018 21:12:38 +0300 Subject: [PATCH 01/15] Multi-threaded API v2 handler POC --- medusa/server/api/v2/alias.py | 8 +- medusa/server/api/v2/alias_source.py | 4 +- medusa/server/api/v2/auth.py | 8 +- medusa/server/api/v2/base.py | 99 +++++++++++++++++++----- medusa/server/api/v2/config.py | 6 +- medusa/server/api/v2/episodes.py | 6 +- medusa/server/api/v2/log.py | 6 +- medusa/server/api/v2/series.py | 10 +-- medusa/server/api/v2/series_asset.py | 4 +- medusa/server/api/v2/series_legacy.py | 2 +- medusa/server/api/v2/series_operation.py | 2 +- medusa/server/api/v2/stats.py | 2 +- 12 files changed, 108 insertions(+), 49 deletions(-) diff --git a/medusa/server/api/v2/alias.py b/medusa/server/api/v2/alias.py index d0a75971d0..e55b4d073e 100644 --- a/medusa/server/api/v2/alias.py +++ b/medusa/server/api/v2/alias.py @@ -21,7 +21,7 @@ class AliasHandler(BaseRequestHandler): #: allowed HTTP methods allowed_methods = ('GET', 'POST', 'PUT', 'DELETE') - def get(self, identifier, path_param): + def _get(self, identifier, path_param): """Query scene_exception information.""" cache_db_con = db.DBConnection('cache.db') sql_base = (b'SELECT ' @@ -92,7 +92,7 @@ def get(self, identifier, path_param): return self._ok(data=data) - def put(self, identifier, **kwargs): + def _put(self, identifier, **kwargs): """Update alias information.""" identifier = self._parse(identifier) if not identifier: @@ -128,7 +128,7 @@ def put(self, identifier, **kwargs): return self._no_content() - def post(self, identifier, **kwargs): + def _post(self, identifier, **kwargs): """Add an alias.""" if identifier is not None: return self._bad_request('Alias id should not be specified') @@ -159,7 +159,7 @@ def post(self, identifier, **kwargs): data['id'] = cursor.lastrowid return self._created(data=data, identifier=data['id']) - def delete(self, identifier, **kwargs): + def _delete(self, identifier, **kwargs): """Delete an alias.""" identifier = self._parse(identifier) if not identifier: diff --git a/medusa/server/api/v2/alias_source.py b/medusa/server/api/v2/alias_source.py index 323d3ea8fb..5316dcddaf 100644 --- a/medusa/server/api/v2/alias_source.py +++ b/medusa/server/api/v2/alias_source.py @@ -34,7 +34,7 @@ class AliasSourceHandler(BaseRequestHandler): #: allowed HTTP methods allowed_methods = ('GET', ) - def get(self, identifier, path_param=None): + def _get(self, identifier, path_param=None): """Query alias source information. :param identifier: source name @@ -71,7 +71,7 @@ class AliasSourceOperationHandler(BaseRequestHandler): #: allowed HTTP methods allowed_methods = ('POST', ) - def post(self, identifier): + def _post(self, identifier): """Refresh all scene exception types.""" types = { 'local': 'custom_exceptions', diff --git a/medusa/server/api/v2/auth.py b/medusa/server/api/v2/auth.py index 260fa9ef3a..abaa9fe916 100644 --- a/medusa/server/api/v2/auth.py +++ b/medusa/server/api/v2/auth.py @@ -34,7 +34,7 @@ def prepare(self): """Prepare.""" pass - def post(self, *args, **kwargs): + def _post(self, *args, **kwargs): """Request JWT.""" username = app.WEB_USERNAME password = app.WEB_PASSWORD @@ -56,7 +56,7 @@ def post(self, *args, **kwargs): if username != submitted_username or password != submitted_password: return self._failed_login(error='Invalid credentials') - self._login(submitted_exp) + return self._login(submitted_exp) def _login(self, exp=86400): self.set_header('Content-Type', 'application/json') @@ -65,7 +65,7 @@ def _login(self, exp=86400): log.info('{user} logged into the API v2', {'user': app.WEB_USERNAME}) time_now = int(time.time()) - self._ok(data={ + return self._ok(data={ 'token': jwt.encode({ 'iss': 'Medusa ' + text_type(app.APP_VERSION), 'iat': time_now, @@ -78,8 +78,8 @@ def _login(self, exp=86400): }) def _failed_login(self, error=None): - self._unauthorized(error=error) log.warning('{user} attempted a failed login to the API v2 from IP: {ip}', { 'user': app.WEB_USERNAME, 'ip': self.request.remote_ip }) + return self._unauthorized(error=error) diff --git a/medusa/server/api/v2/base.py b/medusa/server/api/v2/base.py index 9a3dc05ede..68e7e9473a 100644 --- a/medusa/server/api/v2/base.py +++ b/medusa/server/api/v2/base.py @@ -10,6 +10,7 @@ import operator import traceback from builtins import object +from concurrent.futures import ThreadPoolExecutor from datetime import date, datetime from babelfish.language import Language @@ -21,6 +22,8 @@ from six import itervalues, string_types, text_type, viewitems +from tornado.concurrent import run_on_executor +from tornado.gen import coroutine from tornado.httpclient import HTTPError from tornado.httputil import url_concat from tornado.web import RequestHandler @@ -31,6 +34,7 @@ class BaseRequestHandler(RequestHandler): """A base class used for shared RequestHandler methods.""" + executor = ThreadPoolExecutor(thread_name_prefix='APIv2-Thread') DEFAULT_ALLOWED_METHODS = ('OPTIONS', ) @@ -74,6 +78,56 @@ def prepare(self): else: return self._unauthorized('Invalid token.') + @run_on_executor + def async_call(self, name, *args, **kwargs): + """@TODO:Document | Call.""" + try: + method = getattr(self, '_' + name) + # log.debug('{method} {uri}', {'method': name.upper(), 'uri': self.request.uri}) + return method(*args, **kwargs) + except AttributeError: + raise HTTPError(405, name.upper() + ' method is probably not allowed') + except Exception: + log.debug('Failed doing API {method} request: {error}', + {'method': name.upper(), 'error': traceback.format_exc()}) + raise HTTPError(404) + + @coroutine + def head(self, *args, **kwargs): + content = yield self.async_call('head', *args, **kwargs) + # log.debug('[APIv2-DONE] {uri}', {'uri': self.request.uri}) + self.finish(content) + + @coroutine + def get(self, *args, **kwargs): + content = yield self.async_call('get', *args, **kwargs) + # log.debug('[APIv2-DONE] {uri}', {'uri': self.request.uri}) + self.finish(content) + + @coroutine + def post(self, *args, **kwargs): + content = yield self.async_call('post', *args, **kwargs) + # log.debug('[APIv2-DONE] {uri}', {'uri': self.request.uri}) + self.finish(content) + + @coroutine + def delete(self, *args, **kwargs): + content = yield self.async_call('delete', *args, **kwargs) + # log.debug('[APIv2-DONE] {uri}', {'uri': self.request.uri}) + self.finish(content) + + @coroutine + def patch(self, *args, **kwargs): + content = yield self.async_call('patch', *args, **kwargs) + # log.debug('[APIv2-DONE] {uri}', {'uri': self.request.uri}) + self.finish(content) + + @coroutine + def put(self, *args, **kwargs): + content = yield self.async_call('put', *args, **kwargs) + # log.debug('[APIv2-DONE] {uri}', {'uri': self.request.uri}) + self.finish(content) + def write_error(self, *args, **kwargs): """Only send traceback if app.DEVELOPER is true.""" if app.DEVELOPER and 'exc_info' in kwargs: @@ -105,7 +159,7 @@ def set_default_headers(self): allowed_methods += self.allowed_methods self.set_header('Access-Control-Allow-Methods', ', '.join(allowed_methods)) - def api_finish(self, status=None, error=None, data=None, headers=None, stream=None, content_type=None, **kwargs): + def api_response(self, status=None, error=None, data=None, headers=None, stream=None, content_type=None, **kwargs): """End the api request writing error or data to http response.""" content_type = content_type or 'application/json; charset=UTF-8' if headers is not None: @@ -114,21 +168,26 @@ def api_finish(self, status=None, error=None, data=None, headers=None, stream=No if error is not None and status is not None: self.set_status(status) self.set_header('content-type', content_type) - self.finish({ + return { 'error': error - }) + } else: self.set_status(status or 200) if data is not None: self.set_header('content-type', content_type) - self.finish(json.JSONEncoder(default=json_default_encoder).encode(data)) + return json.JSONEncoder(default=json_default_encoder).encode(data) elif stream: # This is mainly for assets self.set_header('content-type', content_type) - self.finish(stream) + return stream elif kwargs and 'chunk' in kwargs: self.set_header('content-type', content_type) - self.finish(kwargs) + return kwargs + + # Is this possible? + return { + 'error': 'Unknown API error!' + } @classmethod def _create_base_url(cls, prefix_url, resource_name, *args): @@ -167,12 +226,12 @@ def create_app_handler(cls, base): def _handle_request_exception(self, e): if isinstance(e, HTTPError): - self.api_finish(e.code, e.message) + return self.api_response(e.code, e.message) else: super(BaseRequestHandler, self)._handle_request_exception(e) def _ok(self, data=None, headers=None, stream=None, content_type=None): - self.api_finish(200, data=data, headers=headers, stream=stream, content_type=content_type) + return self.api_response(200, data=data, headers=headers, stream=stream, content_type=content_type) def _created(self, data=None, identifier=None): if identifier is not None: @@ -181,37 +240,37 @@ def _created(self, data=None, identifier=None): location += '/' self.set_header('Location', '{0}{1}'.format(location, identifier)) - self.api_finish(201, data=data) + return self.api_response(201, data=data) def _accepted(self): - self.api_finish(202) + return self.api_response(202) def _no_content(self): - self.api_finish(204) + return self.api_response(204) def _multi_status(self, data=None, headers=None): - self.api_finish(207, data=data, headers=headers) + return self.api_response(207, data=data, headers=headers) def _bad_request(self, error): - self.api_finish(400, error=error) + return self.api_response(400, error=error) def _unauthorized(self, error): - self.api_finish(401, error=error) + return self.api_response(401, error=error) def _not_found(self, error='Resource not found'): - self.api_finish(404, error=error) + return self.api_response(404, error=error) def _method_not_allowed(self, error): - self.api_finish(405, error=error) + return self.api_response(405, error=error) def _conflict(self, error): - self.api_finish(409, error=error) + return self.api_response(409, error=error) def _internal_server_error(self, error='Internal Server Error'): - self.api_finish(500, error=error) + return self.api_response(500, error=error) def _not_implemented(self): - self.api_finish(501) + return self.api_response(501) @classmethod def _raise_bad_request_error(cls, error): @@ -353,7 +412,7 @@ class NotFoundHandler(BaseRequestHandler): def get(self, *args, **kwargs): """Get.""" - self.api_finish(status=404) + return self.api_response(status=404) @classmethod def create_app_handler(cls, base): diff --git a/medusa/server/api/v2/config.py b/medusa/server/api/v2/config.py index 1e864b296d..842feed1b8 100644 --- a/medusa/server/api/v2/config.py +++ b/medusa/server/api/v2/config.py @@ -150,7 +150,7 @@ class ConfigHandler(BaseRequestHandler): 'postProcessing.naming.stripYear': BooleanField(app, 'NAMING_STRIP_YEAR') } - def get(self, identifier, path_param=None): + def _get(self, identifier, path_param=None): """Query general configuration. :param identifier: @@ -180,7 +180,7 @@ def get(self, identifier, path_param=None): return self._ok(data=config_data) - def patch(self, identifier, *args, **kwargs): + def _patch(self, identifier, *args, **kwargs): """Patch general configuration.""" if not identifier: return self._bad_request('Config identifier not specified') @@ -224,7 +224,7 @@ def patch(self, identifier, *args, **kwargs): }) msg.push() - self._ok(data=accepted) + return self._ok(data=accepted) class DataGenerator(object): diff --git a/medusa/server/api/v2/episodes.py b/medusa/server/api/v2/episodes.py index 26a5c23a40..c19eb39fd6 100644 --- a/medusa/server/api/v2/episodes.py +++ b/medusa/server/api/v2/episodes.py @@ -38,7 +38,7 @@ class EpisodeHandler(BaseRequestHandler): #: allowed HTTP methods allowed_methods = ('GET', 'PATCH', ) - def get(self, series_slug, episode_slug, path_param): + def _get(self, series_slug, episode_slug, path_param): """Query episode information. :param series_slug: series slug. E.g.: tvdb1234 @@ -105,7 +105,7 @@ def patch(self, series_slug, episode_slug=None, path_param=None): accepted = self._patch_episode(episode, data) - self._ok(data=accepted) + return self._ok(data=accepted) def _patch_multi(self, series, request_data): """Patch multiple episodes.""" @@ -126,7 +126,7 @@ def _patch_multi(self, series, request_data): statuses[slug] = {'status': 200} - self._multi_status(data=statuses) + return self._multi_status(data=statuses) @staticmethod def _patch_episode(episode, data): diff --git a/medusa/server/api/v2/log.py b/medusa/server/api/v2/log.py index fd0fbc0e14..7598142988 100644 --- a/medusa/server/api/v2/log.py +++ b/medusa/server/api/v2/log.py @@ -23,7 +23,7 @@ class LogHandler(BaseRequestHandler): #: allowed HTTP methods allowed_methods = ('GET', 'POST', ) - def get(self): + def _get(self): """Query logs.""" log_level = self.get_argument('level', 'INFO').upper() if log_level not in LOGGING_LEVELS: @@ -42,7 +42,7 @@ def data_generator(): return self._paginate(data_generator=data_generator) - def post(self): + def _post(self): """Create a log line. By definition this method is NOT idempotent. @@ -60,4 +60,4 @@ def post(self): kwargs = data.get('kwargs', {}) level = LOGGING_LEVELS[data['level']] log.log(level, message, exc_info=False, *args, **kwargs) - self._created() + return self._created() diff --git a/medusa/server/api/v2/series.py b/medusa/server/api/v2/series.py index 9dacd3f755..e94d1b0cee 100644 --- a/medusa/server/api/v2/series.py +++ b/medusa/server/api/v2/series.py @@ -36,7 +36,7 @@ class SeriesHandler(BaseRequestHandler): #: allowed HTTP methods allowed_methods = ('GET', 'PATCH', 'DELETE', ) - def get(self, series_slug, path_param=None): + def _get(self, series_slug, path_param=None): """Query series information. :param series_slug: series slug. E.g.: tvdb1234 @@ -69,7 +69,7 @@ def filter_series(current): return self._ok(data) - def post(self, series_slug=None, path_param=None): + def _post(self, series_slug=None, path_param=None): """Add a new series.""" if series_slug is not None: return self._bad_request('Series slug should not be specified') @@ -96,7 +96,7 @@ def post(self, series_slug=None, path_param=None): return self._created(series.to_json(), identifier=identifier.slug) - def patch(self, series_slug, path_param=None): + def _patch(self, series_slug, path_param=None): """Patch series.""" if not series_slug: return self._method_not_allowed('Patching multiple series is not allowed') @@ -151,9 +151,9 @@ def patch(self, series_slug, path_param=None): if ignored: log.warning('Series patch ignored {items!r}', {'items': ignored}) - self._ok(data=accepted) + return self._ok(data=accepted) - def delete(self, series_slug, path_param=None): + def _delete(self, series_slug, path_param=None): """Delete the series.""" if not series_slug: return self._method_not_allowed('Deleting multiple series are not allowed') diff --git a/medusa/server/api/v2/series_asset.py b/medusa/server/api/v2/series_asset.py index acda0286d8..7e394d8668 100644 --- a/medusa/server/api/v2/series_asset.py +++ b/medusa/server/api/v2/series_asset.py @@ -19,7 +19,7 @@ class SeriesAssetHandler(BaseRequestHandler): #: allowed HTTP methods allowed_methods = ('GET', ) - def get(self, series_slug, identifier, *args, **kwargs): + def _get(self, series_slug, identifier, *args, **kwargs): """Get an asset.""" series_identifier = SeriesIdentifier.from_slug(series_slug) if not series_identifier: @@ -38,4 +38,4 @@ def get(self, series_slug, identifier, *args, **kwargs): if not media: return self._not_found('{kind} not found'.format(kind=asset_type.capitalize())) - self._ok(stream=media, content_type=asset.media_type) + return self._ok(stream=media, content_type=asset.media_type) diff --git a/medusa/server/api/v2/series_legacy.py b/medusa/server/api/v2/series_legacy.py index f95b56cc49..a530fb8a4b 100644 --- a/medusa/server/api/v2/series_legacy.py +++ b/medusa/server/api/v2/series_legacy.py @@ -23,7 +23,7 @@ class SeriesLegacyHandler(BaseRequestHandler): #: allowed HTTP methods allowed_methods = ('GET', ) - def get(self, series_slug, identifier): + def _get(self, series_slug, identifier): """Query series information. :param series_slug: series slug. E.g.: tvdb1234 diff --git a/medusa/server/api/v2/series_operation.py b/medusa/server/api/v2/series_operation.py index 3ee4a99a10..62ac0278fe 100644 --- a/medusa/server/api/v2/series_operation.py +++ b/medusa/server/api/v2/series_operation.py @@ -23,7 +23,7 @@ class SeriesOperationHandler(BaseRequestHandler): #: allowed HTTP methods allowed_methods = ('POST', ) - def post(self, series_slug): + def _post(self, series_slug): """Query series information. :param series_slug: series slug. E.g.: tvdb1234 diff --git a/medusa/server/api/v2/stats.py b/medusa/server/api/v2/stats.py index 4d766cafcf..cea08f9639 100644 --- a/medusa/server/api/v2/stats.py +++ b/medusa/server/api/v2/stats.py @@ -31,7 +31,7 @@ class StatsHandler(BaseRequestHandler): #: allowed HTTP methods allowed_methods = ('GET', ) - def get(self, identifier, path_param=None): + def _get(self, identifier, path_param=None): """Query statistics. :param identifier: From ea2f1e13ee05b2249165feab4babb98052ab212c Mon Sep 17 00:00:00 2001 From: sharkykh Date: Sun, 26 Aug 2018 13:49:10 +0300 Subject: [PATCH 02/15] Fix NotFound handler --- medusa/server/api/v2/base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/medusa/server/api/v2/base.py b/medusa/server/api/v2/base.py index 68e7e9473a..250dca835e 100644 --- a/medusa/server/api/v2/base.py +++ b/medusa/server/api/v2/base.py @@ -410,7 +410,7 @@ def _parse_date(cls, value, fmt='%Y-%m-%d'): class NotFoundHandler(BaseRequestHandler): """A class used for the API v2 404 page.""" - def get(self, *args, **kwargs): + def _get(self, *args, **kwargs): """Get.""" return self.api_response(status=404) From 40a8a6415455299936c4421baf0e36b5aa0bad55 Mon Sep 17 00:00:00 2001 From: sharkykh Date: Sun, 26 Aug 2018 13:49:38 +0300 Subject: [PATCH 03/15] Add docstrings --- medusa/server/api/v2/base.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/medusa/server/api/v2/base.py b/medusa/server/api/v2/base.py index 250dca835e..146a8c2f8f 100644 --- a/medusa/server/api/v2/base.py +++ b/medusa/server/api/v2/base.py @@ -94,38 +94,38 @@ def async_call(self, name, *args, **kwargs): @coroutine def head(self, *args, **kwargs): + """HEAD HTTP method.""" content = yield self.async_call('head', *args, **kwargs) - # log.debug('[APIv2-DONE] {uri}', {'uri': self.request.uri}) self.finish(content) @coroutine def get(self, *args, **kwargs): + """GET HTTP method.""" content = yield self.async_call('get', *args, **kwargs) - # log.debug('[APIv2-DONE] {uri}', {'uri': self.request.uri}) self.finish(content) @coroutine def post(self, *args, **kwargs): + """POST HTTP method.""" content = yield self.async_call('post', *args, **kwargs) - # log.debug('[APIv2-DONE] {uri}', {'uri': self.request.uri}) self.finish(content) @coroutine def delete(self, *args, **kwargs): + """DELETE HTTP method.""" content = yield self.async_call('delete', *args, **kwargs) - # log.debug('[APIv2-DONE] {uri}', {'uri': self.request.uri}) self.finish(content) @coroutine def patch(self, *args, **kwargs): + """PATCH HTTP method.""" content = yield self.async_call('patch', *args, **kwargs) - # log.debug('[APIv2-DONE] {uri}', {'uri': self.request.uri}) self.finish(content) @coroutine def put(self, *args, **kwargs): + """PUT HTTP method.""" content = yield self.async_call('put', *args, **kwargs) - # log.debug('[APIv2-DONE] {uri}', {'uri': self.request.uri}) self.finish(content) def write_error(self, *args, **kwargs): @@ -140,7 +140,7 @@ def write_error(self, *args, **kwargs): self._internal_server_error() def options(self, *args, **kwargs): - """Options.""" + """OPTIONS HTTP method.""" self._no_content() def set_default_headers(self): From b089cbcc79fa601d0cc18a18ef437a51454215c3 Mon Sep 17 00:00:00 2001 From: sharkykh Date: Sun, 26 Aug 2018 13:50:53 +0300 Subject: [PATCH 04/15] Update `async_call` function --- medusa/server/api/v2/base.py | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/medusa/server/api/v2/base.py b/medusa/server/api/v2/base.py index 146a8c2f8f..796bafe45b 100644 --- a/medusa/server/api/v2/base.py +++ b/medusa/server/api/v2/base.py @@ -80,17 +80,13 @@ def prepare(self): @run_on_executor def async_call(self, name, *args, **kwargs): - """@TODO:Document | Call.""" + """Call the actual HTTP method, if available.""" try: method = getattr(self, '_' + name) - # log.debug('{method} {uri}', {'method': name.upper(), 'uri': self.request.uri}) - return method(*args, **kwargs) except AttributeError: - raise HTTPError(405, name.upper() + ' method is probably not allowed') - except Exception: - log.debug('Failed doing API {method} request: {error}', - {'method': name.upper(), 'error': traceback.format_exc()}) - raise HTTPError(404) + raise HTTPError(405, '{name} method is not allowed'.format(name=name.upper())) + + return method(*args, **kwargs) @coroutine def head(self, *args, **kwargs): From b6ab815e4a47cd235b43a44fbfeed6780e78643c Mon Sep 17 00:00:00 2001 From: sharkykh Date: Sun, 26 Aug 2018 13:51:48 +0300 Subject: [PATCH 05/15] Rename `_{method}` to `http_{method}` --- medusa/server/api/v2/alias.py | 8 ++++---- medusa/server/api/v2/alias_source.py | 4 ++-- medusa/server/api/v2/auth.py | 2 +- medusa/server/api/v2/base.py | 4 ++-- medusa/server/api/v2/config.py | 4 ++-- medusa/server/api/v2/episodes.py | 2 +- medusa/server/api/v2/log.py | 4 ++-- medusa/server/api/v2/series.py | 8 ++++---- medusa/server/api/v2/series_asset.py | 2 +- medusa/server/api/v2/series_legacy.py | 2 +- medusa/server/api/v2/series_operation.py | 2 +- medusa/server/api/v2/stats.py | 2 +- 12 files changed, 22 insertions(+), 22 deletions(-) diff --git a/medusa/server/api/v2/alias.py b/medusa/server/api/v2/alias.py index e55b4d073e..87dcc8689f 100644 --- a/medusa/server/api/v2/alias.py +++ b/medusa/server/api/v2/alias.py @@ -21,7 +21,7 @@ class AliasHandler(BaseRequestHandler): #: allowed HTTP methods allowed_methods = ('GET', 'POST', 'PUT', 'DELETE') - def _get(self, identifier, path_param): + def http_get(self, identifier, path_param): """Query scene_exception information.""" cache_db_con = db.DBConnection('cache.db') sql_base = (b'SELECT ' @@ -92,7 +92,7 @@ def _get(self, identifier, path_param): return self._ok(data=data) - def _put(self, identifier, **kwargs): + def http_put(self, identifier, **kwargs): """Update alias information.""" identifier = self._parse(identifier) if not identifier: @@ -128,7 +128,7 @@ def _put(self, identifier, **kwargs): return self._no_content() - def _post(self, identifier, **kwargs): + def http_post(self, identifier, **kwargs): """Add an alias.""" if identifier is not None: return self._bad_request('Alias id should not be specified') @@ -159,7 +159,7 @@ def _post(self, identifier, **kwargs): data['id'] = cursor.lastrowid return self._created(data=data, identifier=data['id']) - def _delete(self, identifier, **kwargs): + def http_delete(self, identifier, **kwargs): """Delete an alias.""" identifier = self._parse(identifier) if not identifier: diff --git a/medusa/server/api/v2/alias_source.py b/medusa/server/api/v2/alias_source.py index 5316dcddaf..70f7178878 100644 --- a/medusa/server/api/v2/alias_source.py +++ b/medusa/server/api/v2/alias_source.py @@ -34,7 +34,7 @@ class AliasSourceHandler(BaseRequestHandler): #: allowed HTTP methods allowed_methods = ('GET', ) - def _get(self, identifier, path_param=None): + def http_get(self, identifier, path_param=None): """Query alias source information. :param identifier: source name @@ -71,7 +71,7 @@ class AliasSourceOperationHandler(BaseRequestHandler): #: allowed HTTP methods allowed_methods = ('POST', ) - def _post(self, identifier): + def http_post(self, identifier): """Refresh all scene exception types.""" types = { 'local': 'custom_exceptions', diff --git a/medusa/server/api/v2/auth.py b/medusa/server/api/v2/auth.py index abaa9fe916..fa9b8626b3 100644 --- a/medusa/server/api/v2/auth.py +++ b/medusa/server/api/v2/auth.py @@ -34,7 +34,7 @@ def prepare(self): """Prepare.""" pass - def _post(self, *args, **kwargs): + def http_post(self, *args, **kwargs): """Request JWT.""" username = app.WEB_USERNAME password = app.WEB_PASSWORD diff --git a/medusa/server/api/v2/base.py b/medusa/server/api/v2/base.py index 796bafe45b..1013d55d88 100644 --- a/medusa/server/api/v2/base.py +++ b/medusa/server/api/v2/base.py @@ -82,7 +82,7 @@ def prepare(self): def async_call(self, name, *args, **kwargs): """Call the actual HTTP method, if available.""" try: - method = getattr(self, '_' + name) + method = getattr(self, 'http_' + name) except AttributeError: raise HTTPError(405, '{name} method is not allowed'.format(name=name.upper())) @@ -406,7 +406,7 @@ def _parse_date(cls, value, fmt='%Y-%m-%d'): class NotFoundHandler(BaseRequestHandler): """A class used for the API v2 404 page.""" - def _get(self, *args, **kwargs): + def http_get(self, *args, **kwargs): """Get.""" return self.api_response(status=404) diff --git a/medusa/server/api/v2/config.py b/medusa/server/api/v2/config.py index 842feed1b8..61e1ca09e6 100644 --- a/medusa/server/api/v2/config.py +++ b/medusa/server/api/v2/config.py @@ -150,7 +150,7 @@ class ConfigHandler(BaseRequestHandler): 'postProcessing.naming.stripYear': BooleanField(app, 'NAMING_STRIP_YEAR') } - def _get(self, identifier, path_param=None): + def http_get(self, identifier, path_param=None): """Query general configuration. :param identifier: @@ -180,7 +180,7 @@ def _get(self, identifier, path_param=None): return self._ok(data=config_data) - def _patch(self, identifier, *args, **kwargs): + def http_patch(self, identifier, *args, **kwargs): """Patch general configuration.""" if not identifier: return self._bad_request('Config identifier not specified') diff --git a/medusa/server/api/v2/episodes.py b/medusa/server/api/v2/episodes.py index c19eb39fd6..ac666a074c 100644 --- a/medusa/server/api/v2/episodes.py +++ b/medusa/server/api/v2/episodes.py @@ -38,7 +38,7 @@ class EpisodeHandler(BaseRequestHandler): #: allowed HTTP methods allowed_methods = ('GET', 'PATCH', ) - def _get(self, series_slug, episode_slug, path_param): + def http_get(self, series_slug, episode_slug, path_param): """Query episode information. :param series_slug: series slug. E.g.: tvdb1234 diff --git a/medusa/server/api/v2/log.py b/medusa/server/api/v2/log.py index 7598142988..f685be7eca 100644 --- a/medusa/server/api/v2/log.py +++ b/medusa/server/api/v2/log.py @@ -23,7 +23,7 @@ class LogHandler(BaseRequestHandler): #: allowed HTTP methods allowed_methods = ('GET', 'POST', ) - def _get(self): + def http_get(self): """Query logs.""" log_level = self.get_argument('level', 'INFO').upper() if log_level not in LOGGING_LEVELS: @@ -42,7 +42,7 @@ def data_generator(): return self._paginate(data_generator=data_generator) - def _post(self): + def http_post(self): """Create a log line. By definition this method is NOT idempotent. diff --git a/medusa/server/api/v2/series.py b/medusa/server/api/v2/series.py index e94d1b0cee..6eb45fb7e4 100644 --- a/medusa/server/api/v2/series.py +++ b/medusa/server/api/v2/series.py @@ -36,7 +36,7 @@ class SeriesHandler(BaseRequestHandler): #: allowed HTTP methods allowed_methods = ('GET', 'PATCH', 'DELETE', ) - def _get(self, series_slug, path_param=None): + def http_get(self, series_slug, path_param=None): """Query series information. :param series_slug: series slug. E.g.: tvdb1234 @@ -69,7 +69,7 @@ def filter_series(current): return self._ok(data) - def _post(self, series_slug=None, path_param=None): + def http_post(self, series_slug=None, path_param=None): """Add a new series.""" if series_slug is not None: return self._bad_request('Series slug should not be specified') @@ -96,7 +96,7 @@ def _post(self, series_slug=None, path_param=None): return self._created(series.to_json(), identifier=identifier.slug) - def _patch(self, series_slug, path_param=None): + def http_patch(self, series_slug, path_param=None): """Patch series.""" if not series_slug: return self._method_not_allowed('Patching multiple series is not allowed') @@ -153,7 +153,7 @@ def _patch(self, series_slug, path_param=None): return self._ok(data=accepted) - def _delete(self, series_slug, path_param=None): + def http_delete(self, series_slug, path_param=None): """Delete the series.""" if not series_slug: return self._method_not_allowed('Deleting multiple series are not allowed') diff --git a/medusa/server/api/v2/series_asset.py b/medusa/server/api/v2/series_asset.py index 7e394d8668..2a2bfdab1e 100644 --- a/medusa/server/api/v2/series_asset.py +++ b/medusa/server/api/v2/series_asset.py @@ -19,7 +19,7 @@ class SeriesAssetHandler(BaseRequestHandler): #: allowed HTTP methods allowed_methods = ('GET', ) - def _get(self, series_slug, identifier, *args, **kwargs): + def http_get(self, series_slug, identifier, *args, **kwargs): """Get an asset.""" series_identifier = SeriesIdentifier.from_slug(series_slug) if not series_identifier: diff --git a/medusa/server/api/v2/series_legacy.py b/medusa/server/api/v2/series_legacy.py index a530fb8a4b..b32b4a9ae9 100644 --- a/medusa/server/api/v2/series_legacy.py +++ b/medusa/server/api/v2/series_legacy.py @@ -23,7 +23,7 @@ class SeriesLegacyHandler(BaseRequestHandler): #: allowed HTTP methods allowed_methods = ('GET', ) - def _get(self, series_slug, identifier): + def http_get(self, series_slug, identifier): """Query series information. :param series_slug: series slug. E.g.: tvdb1234 diff --git a/medusa/server/api/v2/series_operation.py b/medusa/server/api/v2/series_operation.py index 62ac0278fe..512c4d6f7b 100644 --- a/medusa/server/api/v2/series_operation.py +++ b/medusa/server/api/v2/series_operation.py @@ -23,7 +23,7 @@ class SeriesOperationHandler(BaseRequestHandler): #: allowed HTTP methods allowed_methods = ('POST', ) - def _post(self, series_slug): + def http_post(self, series_slug): """Query series information. :param series_slug: series slug. E.g.: tvdb1234 diff --git a/medusa/server/api/v2/stats.py b/medusa/server/api/v2/stats.py index cea08f9639..ac27034c22 100644 --- a/medusa/server/api/v2/stats.py +++ b/medusa/server/api/v2/stats.py @@ -31,7 +31,7 @@ class StatsHandler(BaseRequestHandler): #: allowed HTTP methods allowed_methods = ('GET', ) - def _get(self, identifier, path_param=None): + def http_get(self, identifier, path_param=None): """Query statistics. :param identifier: From 5217a855bebe74ded9aa0c43b2c21e487d44535a Mon Sep 17 00:00:00 2001 From: sharkykh Date: Sun, 26 Aug 2018 14:02:47 +0300 Subject: [PATCH 06/15] Only yield content if not None --- medusa/server/api/v2/base.py | 29 +++++++++++++++++++---------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/medusa/server/api/v2/base.py b/medusa/server/api/v2/base.py index 1013d55d88..98ff20d7bb 100644 --- a/medusa/server/api/v2/base.py +++ b/medusa/server/api/v2/base.py @@ -91,37 +91,49 @@ def async_call(self, name, *args, **kwargs): @coroutine def head(self, *args, **kwargs): """HEAD HTTP method.""" - content = yield self.async_call('head', *args, **kwargs) + content = self.async_call('head', *args, **kwargs) + if content is not None: + yield content self.finish(content) @coroutine def get(self, *args, **kwargs): """GET HTTP method.""" - content = yield self.async_call('get', *args, **kwargs) + content = self.async_call('get', *args, **kwargs) + if content is not None: + yield content self.finish(content) @coroutine def post(self, *args, **kwargs): """POST HTTP method.""" - content = yield self.async_call('post', *args, **kwargs) + content = self.async_call('post', *args, **kwargs) + if content is not None: + yield content self.finish(content) @coroutine def delete(self, *args, **kwargs): """DELETE HTTP method.""" - content = yield self.async_call('delete', *args, **kwargs) + content = self.async_call('delete', *args, **kwargs) + if content is not None: + yield content self.finish(content) @coroutine def patch(self, *args, **kwargs): """PATCH HTTP method.""" - content = yield self.async_call('patch', *args, **kwargs) + content = self.async_call('patch', *args, **kwargs) + if content is not None: + yield content self.finish(content) @coroutine def put(self, *args, **kwargs): """PUT HTTP method.""" - content = yield self.async_call('put', *args, **kwargs) + content = self.async_call('put', *args, **kwargs) + if content is not None: + yield content self.finish(content) def write_error(self, *args, **kwargs): @@ -180,10 +192,7 @@ def api_response(self, status=None, error=None, data=None, headers=None, stream= self.set_header('content-type', content_type) return kwargs - # Is this possible? - return { - 'error': 'Unknown API error!' - } + return None @classmethod def _create_base_url(cls, prefix_url, resource_name, *args): From 4b5d733df456afab45dedf0697f6233bab0ca2aa Mon Sep 17 00:00:00 2001 From: sharkykh Date: Sun, 26 Aug 2018 14:22:27 +0300 Subject: [PATCH 07/15] flake8 --- medusa/server/api/v2/base.py | 1 + 1 file changed, 1 insertion(+) diff --git a/medusa/server/api/v2/base.py b/medusa/server/api/v2/base.py index 98ff20d7bb..6dfb30be44 100644 --- a/medusa/server/api/v2/base.py +++ b/medusa/server/api/v2/base.py @@ -34,6 +34,7 @@ class BaseRequestHandler(RequestHandler): """A base class used for shared RequestHandler methods.""" + executor = ThreadPoolExecutor(thread_name_prefix='APIv2-Thread') DEFAULT_ALLOWED_METHODS = ('OPTIONS', ) From a1b6231f947244b9ae530f3a494b5dd326751ccf Mon Sep 17 00:00:00 2001 From: sharkykh Date: Sun, 26 Aug 2018 14:23:51 +0300 Subject: [PATCH 08/15] Fix `yield content` --- medusa/server/api/v2/base.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/medusa/server/api/v2/base.py b/medusa/server/api/v2/base.py index 6dfb30be44..b1c904cd43 100644 --- a/medusa/server/api/v2/base.py +++ b/medusa/server/api/v2/base.py @@ -94,7 +94,7 @@ def head(self, *args, **kwargs): """HEAD HTTP method.""" content = self.async_call('head', *args, **kwargs) if content is not None: - yield content + content = yield content self.finish(content) @coroutine @@ -102,7 +102,7 @@ def get(self, *args, **kwargs): """GET HTTP method.""" content = self.async_call('get', *args, **kwargs) if content is not None: - yield content + content = yield content self.finish(content) @coroutine @@ -110,7 +110,7 @@ def post(self, *args, **kwargs): """POST HTTP method.""" content = self.async_call('post', *args, **kwargs) if content is not None: - yield content + content = yield content self.finish(content) @coroutine @@ -118,7 +118,7 @@ def delete(self, *args, **kwargs): """DELETE HTTP method.""" content = self.async_call('delete', *args, **kwargs) if content is not None: - yield content + content = yield content self.finish(content) @coroutine @@ -126,7 +126,7 @@ def patch(self, *args, **kwargs): """PATCH HTTP method.""" content = self.async_call('patch', *args, **kwargs) if content is not None: - yield content + content = yield content self.finish(content) @coroutine @@ -134,7 +134,7 @@ def put(self, *args, **kwargs): """PUT HTTP method.""" content = self.async_call('put', *args, **kwargs) if content is not None: - yield content + content = yield content self.finish(content) def write_error(self, *args, **kwargs): From 9b7732876e0182c2bc5621d0245db2733e81d8e9 Mon Sep 17 00:00:00 2001 From: sharkykh Date: Sun, 26 Aug 2018 14:40:53 +0300 Subject: [PATCH 09/15] Fix bad endpoints --- medusa/server/api/v2/episodes.py | 2 +- medusa/server/api/v2/internal.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/medusa/server/api/v2/episodes.py b/medusa/server/api/v2/episodes.py index ac666a074c..b682decda1 100644 --- a/medusa/server/api/v2/episodes.py +++ b/medusa/server/api/v2/episodes.py @@ -79,7 +79,7 @@ def http_get(self, series_slug, episode_slug, path_param): return self._ok(data=data) - def patch(self, series_slug, episode_slug=None, path_param=None): + def http_patch(self, series_slug, episode_slug=None, path_param=None): """Patch episode.""" series_identifier = SeriesIdentifier.from_slug(series_slug) if not series_identifier: diff --git a/medusa/server/api/v2/internal.py b/medusa/server/api/v2/internal.py index 70159e3c94..ba73ae89a5 100644 --- a/medusa/server/api/v2/internal.py +++ b/medusa/server/api/v2/internal.py @@ -33,7 +33,7 @@ class InternalHandler(BaseRequestHandler): #: allowed HTTP methods allowed_methods = ('GET', ) - def get(self, resource, path_param=None): + def http_get(self, resource, path_param=None): """Query internal data. :param resource: a resource name From 1b1173d3831a6a05ce9da828186f35847f26fb8e Mon Sep 17 00:00:00 2001 From: sharkykh Date: Sun, 26 Aug 2018 17:52:54 +0300 Subject: [PATCH 10/15] Replace run_on_executor --- medusa/server/api/v2/base.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/medusa/server/api/v2/base.py b/medusa/server/api/v2/base.py index b1c904cd43..97f3f5ab78 100644 --- a/medusa/server/api/v2/base.py +++ b/medusa/server/api/v2/base.py @@ -5,6 +5,7 @@ import base64 import collections +import functools import json import logging import operator @@ -22,21 +23,21 @@ from six import itervalues, string_types, text_type, viewitems -from tornado.concurrent import run_on_executor from tornado.gen import coroutine from tornado.httpclient import HTTPError from tornado.httputil import url_concat +from tornado.ioloop import IOLoop from tornado.web import RequestHandler log = BraceAdapter(logging.getLogger(__name__)) log.logger.addHandler(logging.NullHandler()) +executor = ThreadPoolExecutor(thread_name_prefix='APIv2-Thread') + class BaseRequestHandler(RequestHandler): """A base class used for shared RequestHandler methods.""" - executor = ThreadPoolExecutor(thread_name_prefix='APIv2-Thread') - DEFAULT_ALLOWED_METHODS = ('OPTIONS', ) #: resource name @@ -79,7 +80,6 @@ def prepare(self): else: return self._unauthorized('Invalid token.') - @run_on_executor def async_call(self, name, *args, **kwargs): """Call the actual HTTP method, if available.""" try: @@ -87,7 +87,8 @@ def async_call(self, name, *args, **kwargs): except AttributeError: raise HTTPError(405, '{name} method is not allowed'.format(name=name.upper())) - return method(*args, **kwargs) + method = functools.partial(method, *args, **kwargs) + return IOLoop.current().run_in_executor(executor, method) @coroutine def head(self, *args, **kwargs): From 7cc049d06ec44d48c34724c2691b1ed0b0c2a6dd Mon Sep 17 00:00:00 2001 From: sharkykh Date: Sun, 26 Aug 2018 18:04:10 +0300 Subject: [PATCH 11/15] Fix HTTPError exception handling --- medusa/server/api/v2/base.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/medusa/server/api/v2/base.py b/medusa/server/api/v2/base.py index 97f3f5ab78..d2792fc27a 100644 --- a/medusa/server/api/v2/base.py +++ b/medusa/server/api/v2/base.py @@ -233,7 +233,8 @@ def create_app_handler(cls, base): def _handle_request_exception(self, e): if isinstance(e, HTTPError): - return self.api_response(e.code, e.message) + response = self.api_response(e.code, e.message) + self.finish(response) else: super(BaseRequestHandler, self)._handle_request_exception(e) From b9ed1b489f85061041192c80f9ad72be38c86dcf Mon Sep 17 00:00:00 2001 From: sharkykh Date: Sun, 26 Aug 2018 18:28:17 +0300 Subject: [PATCH 12/15] Fix "internal server error" --- medusa/server/api/v2/base.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/medusa/server/api/v2/base.py b/medusa/server/api/v2/base.py index d2792fc27a..a2ac2e1f51 100644 --- a/medusa/server/api/v2/base.py +++ b/medusa/server/api/v2/base.py @@ -147,7 +147,8 @@ def write_error(self, *args, **kwargs): self.write(line) self.finish() else: - self._internal_server_error() + response = self._internal_server_error() + self.finish(response) def options(self, *args, **kwargs): """OPTIONS HTTP method.""" From 8efc6bf59f08c6c8f6c4ba857aee6280e202508b Mon Sep 17 00:00:00 2001 From: sharkykh Date: Tue, 28 Aug 2018 19:49:12 +0300 Subject: [PATCH 13/15] e => error --- medusa/server/api/v2/base.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/medusa/server/api/v2/base.py b/medusa/server/api/v2/base.py index a2ac2e1f51..9ab46ab80a 100644 --- a/medusa/server/api/v2/base.py +++ b/medusa/server/api/v2/base.py @@ -232,12 +232,12 @@ def create_app_handler(cls, base): return cls.create_url(base, cls.name, *(cls.identifier, cls.path_param)), cls - def _handle_request_exception(self, e): - if isinstance(e, HTTPError): - response = self.api_response(e.code, e.message) + def _handle_request_exception(self, error): + if isinstance(error, HTTPError): + response = self.api_response(error.code, error.message) self.finish(response) else: - super(BaseRequestHandler, self)._handle_request_exception(e) + super(BaseRequestHandler, self)._handle_request_exception(error) def _ok(self, data=None, headers=None, stream=None, content_type=None): return self.api_response(200, data=data, headers=headers, stream=stream, content_type=content_type) From d181bd4d88746fcf4d858c0bcbbce9996df84c7a Mon Sep 17 00:00:00 2001 From: sharkykh Date: Sun, 26 Aug 2018 21:39:08 +0300 Subject: [PATCH 14/15] Initial fix for handling exceptions --- medusa/server/api/v2/base.py | 28 +++++++++++++++++++--------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/medusa/server/api/v2/base.py b/medusa/server/api/v2/base.py index 9ab46ab80a..92bc20d272 100644 --- a/medusa/server/api/v2/base.py +++ b/medusa/server/api/v2/base.py @@ -5,7 +5,6 @@ import base64 import collections -import functools import json import logging import operator @@ -87,8 +86,13 @@ def async_call(self, name, *args, **kwargs): except AttributeError: raise HTTPError(405, '{name} method is not allowed'.format(name=name.upper())) - method = functools.partial(method, *args, **kwargs) - return IOLoop.current().run_in_executor(executor, method) + def blocking_call(): + try: + return method(*args, **kwargs) + except Exception as error: + self._handle_request_exception(error) + + return IOLoop.current().run_in_executor(executor, blocking_call) @coroutine def head(self, *args, **kwargs): @@ -96,7 +100,8 @@ def head(self, *args, **kwargs): content = self.async_call('head', *args, **kwargs) if content is not None: content = yield content - self.finish(content) + if not self._finished: + self.finish(content) @coroutine def get(self, *args, **kwargs): @@ -104,7 +109,8 @@ def get(self, *args, **kwargs): content = self.async_call('get', *args, **kwargs) if content is not None: content = yield content - self.finish(content) + if not self._finished: + self.finish(content) @coroutine def post(self, *args, **kwargs): @@ -112,7 +118,8 @@ def post(self, *args, **kwargs): content = self.async_call('post', *args, **kwargs) if content is not None: content = yield content - self.finish(content) + if not self._finished: + self.finish(content) @coroutine def delete(self, *args, **kwargs): @@ -120,7 +127,8 @@ def delete(self, *args, **kwargs): content = self.async_call('delete', *args, **kwargs) if content is not None: content = yield content - self.finish(content) + if not self._finished: + self.finish(content) @coroutine def patch(self, *args, **kwargs): @@ -128,7 +136,8 @@ def patch(self, *args, **kwargs): content = self.async_call('patch', *args, **kwargs) if content is not None: content = yield content - self.finish(content) + if not self._finished: + self.finish(content) @coroutine def put(self, *args, **kwargs): @@ -136,7 +145,8 @@ def put(self, *args, **kwargs): content = self.async_call('put', *args, **kwargs) if content is not None: content = yield content - self.finish(content) + if not self._finished: + self.finish(content) def write_error(self, *args, **kwargs): """Only send traceback if app.DEVELOPER is true.""" From 898c22f25df45e2ff5ddc58b21f902d23a25598a Mon Sep 17 00:00:00 2001 From: sharkykh Date: Fri, 7 Sep 2018 12:41:03 +0300 Subject: [PATCH 15/15] Update changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index fdcf999b03..8574d3c59a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ #### Improvements - Updated `guessit` to version 3.0.0 ([#4244](https://github.com/pymedusa/Medusa/pull/4244)) +- Updated the API v2 endpoint to handle concurrent requests ([#4970](https://github.com/pymedusa/Medusa/pull/4970)) #### Fixes - Fixed many release name parsing issues as a result of updating `guessit` ([#4244](https://github.com/pymedusa/Medusa/pull/4244))