Commit 3fe83b1a authored by echel0n's avatar echel0n

Restructured API v2 folders

Refactored APIv2BaseHandler to APIBaseHandler
Added API base method to generate swagger.json
Added API v2 schema validation method for requests
Added application API v2 documentation and validation for post-processing endpoint
Added application API v2 documentation and validation for episode manual search endpoint
Added application API v2 documentation and validation for episode rename endpoints
Added application API v2 documentation and validation for series endpoints
parent a7be1cf4
......@@ -21,6 +21,7 @@
######################
*.db*
*.ini
swagger.json
privatekey.pem
autoProcessTV.cfg
/.imdbpie_cache/
......
......@@ -36,8 +36,10 @@ import sickrage
from sickrage.core.helpers import create_https_certificates
from sickrage.core.webserver.handlers.account import AccountLinkHandler, AccountUnlinkHandler, AccountIsLinkedHandler
from sickrage.core.webserver.handlers.announcements import AnnouncementsHandler, MarkAnnouncementSeenHandler, AnnouncementCountHandler
from sickrage.core.webserver.handlers.api import SwaggerDotJsonHandler, PingHandler
from sickrage.core.webserver.handlers.api.v1 import ApiHandler
from sickrage.core.webserver.handlers.api.v2 import PingHandler, RetrieveSeriesMetadataHandler, PostProcessHandler
from sickrage.core.webserver.handlers.api.v2 import RetrieveSeriesMetadataHandler
from sickrage.core.webserver.handlers.api.v2.postprocess import PostProcessHandler
from sickrage.core.webserver.handlers.api.v2.config import ConfigHandler
from sickrage.core.webserver.handlers.api.v2.episode import EpisodesRenameHandler, EpisodesManualSearchHandler
from sickrage.core.webserver.handlers.api.v2.file_browser import FileBrowserHandler
......@@ -123,6 +125,7 @@ class WebServer(threading.Thread):
self.name = "TORNADO"
self.daemon = True
self.started = False
self.handlers = {}
self.video_root = None
self.api_v1_root = None
self.api_v2_root = None
......@@ -193,43 +196,24 @@ class WebServer(threading.Thread):
filename = '{}/{}'.format('/'.join(path), file).lstrip('/')
templates[filename] = mako_lookup.get_template(filename)
# Load the app
self.app = Application(
debug=True,
autoreload=False,
gzip=sickrage.app.config.general.web_use_gzip,
cookie_secret=sickrage.app.config.general.web_cookie_secret,
login_url='%s/login/' % sickrage.app.config.general.web_root,
templates=templates,
default_handler_class=NotFoundHandler
)
# Websocket handler
self.app.add_handlers('.*$', [
self.handlers['websocket_handlers'] = [
(fr'{sickrage.app.config.general.web_root}/ws/ui', WebSocketUIHandler)
])
# GUI App Static File Handlers
self.app.add_handlers('.*$', [
# media
(fr'{sickrage.app.config.general.web_root}/app/static/media/(.*)', StaticImageHandler,
{"path": os.path.join(sickrage.app.gui_app_dir, 'static', 'media')}),
# css
(fr'{sickrage.app.config.general.web_root}/app/static/css/(.*)', StaticNoCacheFileHandler,
{"path": os.path.join(sickrage.app.gui_app_dir, 'static', 'css')}),
]
# js
(fr'{sickrage.app.config.general.web_root}/app/static/js/(.*)', StaticNoCacheFileHandler,
{"path": os.path.join(sickrage.app.gui_app_dir, 'static', 'js')}),
# API v1 Handlers
self.handlers['api_v1_handlers'] = [
# api
(fr'{self.api_v1_root}(/?.*)', ApiHandler),
# base
(fr"{sickrage.app.config.general.web_root}/app/(.*)", tornado.web.StaticFileHandler,
{"path": sickrage.app.gui_app_dir, "default_filename": "index.html"})
])
# api builder
(fr'{sickrage.app.config.general.web_root}/api/builder', RedirectHandler,
{"url": sickrage.app.config.general.web_root + '/apibuilder/'}),
]
# API v2 Handlers
self.app.add_handlers('.*$', [
self.handlers['api_v2_handlers'] = [
(fr'{self.api_v2_root}/swagger.json', SwaggerDotJsonHandler, {'api_handlers': 'api_v2_handlers', 'api_version': '2.0.0'}),
(fr'{self.api_v2_root}/config', ConfigHandler),
(fr'{self.api_v2_root}/ping', PingHandler),
(fr'{self.api_v2_root}/file-browser', FileBrowserHandler),
......@@ -249,21 +233,33 @@ class WebServer(threading.Thread):
(fr'{self.api_v2_root}/series/(\d+[-][a-z]+)/update', SeriesUpdateHandler),
(fr'{self.api_v2_root}/episodes/rename', EpisodesRenameHandler),
(fr'{self.api_v2_root}/episodes/(\d+[-][a-z]+)/search', EpisodesManualSearchHandler),
])
]
# Static File Handlers
self.app.add_handlers('.*$', [
# api
(fr'{self.api_v1_root}(/?.*)', ApiHandler),
# New UI Static File Handlers
self.handlers['new_ui_static_file_handlers'] = [
# media
(fr'{sickrage.app.config.general.web_root}/app/static/media/(.*)', StaticImageHandler,
{"path": os.path.join(sickrage.app.gui_app_dir, 'static', 'media')}),
# css
(fr'{sickrage.app.config.general.web_root}/app/static/css/(.*)', StaticNoCacheFileHandler,
{"path": os.path.join(sickrage.app.gui_app_dir, 'static', 'css')}),
# js
(fr'{sickrage.app.config.general.web_root}/app/static/js/(.*)', StaticNoCacheFileHandler,
{"path": os.path.join(sickrage.app.gui_app_dir, 'static', 'js')}),
# base
(fr"{sickrage.app.config.general.web_root}/app/(.*)", tornado.web.StaticFileHandler,
{"path": sickrage.app.gui_app_dir, "default_filename": "index.html"})
]
# Static File Handlers
self.handlers['static_file_handlers'] = [
# redirect to home
(fr"({sickrage.app.config.general.web_root})(/?)", RedirectHandler,
{"url": f"{sickrage.app.config.general.web_root}/home"}),
# api builder
(fr'{sickrage.app.config.general.web_root}/api/builder', RedirectHandler,
{"url": sickrage.app.config.general.web_root + '/apibuilder/'}),
# login
(fr'{sickrage.app.config.general.web_root}/login(/?)', LoginHandler),
......@@ -297,10 +293,10 @@ class WebServer(threading.Thread):
# videos
(fr'{sickrage.app.config.general.web_root}/videos/(.*)', StaticNoCacheFileHandler,
{"path": self.video_root}),
])
]
# Handlers
self.app.add_handlers('.*$', [
self.handlers['web_handlers'] = [
(fr'{sickrage.app.config.general.web_root}/robots.txt', RobotsDotTxtHandler),
(fr'{sickrage.app.config.general.web_root}/messages.po', MessagesDotPoHandler),
(fr'{sickrage.app.config.general.web_root}/quicksearch.json', QuicksearchDotJsonHandler),
......@@ -469,7 +465,19 @@ class WebServer(threading.Thread):
(fr'{sickrage.app.config.general.web_root}/config/subtitles/get_code(/?)', ConfigSubtitleGetCodeHandler),
(fr'{sickrage.app.config.general.web_root}/config/subtitles/wanted_languages(/?)', ConfigSubtitlesWantedLanguagesHandler),
(fr'{sickrage.app.config.general.web_root}/config/subtitles/saveSubtitles(/?)', SaveSubtitlesHandler),
])
]
# Initialize Tornado application
self.app = Application(
handlers=sum(self.handlers.values(), []),
debug=True,
autoreload=False,
gzip=sickrage.app.config.general.web_use_gzip,
cookie_secret=sickrage.app.config.general.web_cookie_secret,
login_url='%s/login/' % sickrage.app.config.general.web_root,
templates=templates,
default_handler_class=NotFoundHandler
)
# HTTPS Cert/Key object
ssl_ctx = None
......
......@@ -18,3 +18,155 @@
# You should have received a copy of the GNU General Public License
# along with SiCKRAGE. If not, see <http://www.gnu.org/licenses/>.
# ##############################################################################
import json
import traceback
from abc import ABC
from apispec import APISpec
from apispec.exceptions import APISpecError
from apispec.ext.marshmallow import MarshmallowPlugin
from apispec_webframeworks.tornado import TornadoPlugin
from tornado.escape import to_basestring
from tornado.web import HTTPError
import sickrage
from sickrage.core.helpers import get_external_ip, get_internal_ip
from sickrage.core.webserver.handlers.base import BaseHandler
class APIBaseHandler(BaseHandler, ABC):
def prepare(self):
super(APIBaseHandler, self).prepare()
method_name = self.request.method.lower()
if method_name == 'options':
return
certs = sickrage.app.auth_server.certs()
auth_header = self.request.headers.get('Authorization')
if auth_header:
if 'bearer' in auth_header.lower():
try:
token = auth_header.strip('Bearer').strip()
decoded_auth_token = sickrage.app.auth_server.decode_token(token, certs)
if not sickrage.app.config.user.sub_id:
sickrage.app.config.user.sub_id = decoded_auth_token.get('sub')
sickrage.app.config.save()
if sickrage.app.config.user.sub_id != decoded_auth_token.get('sub'):
return self.send_error(401, error='user is not authorized')
if sickrage.app.config.general.enable_sickrage_api and not sickrage.app.api.token:
exchanged_token = sickrage.app.auth_server.token_exchange(token)
if exchanged_token:
sickrage.app.api.token = exchanged_token
internal_connections = "{}://{}:{}{}".format(self.request.protocol,
get_internal_ip(),
sickrage.app.config.general.web_port,
sickrage.app.config.general.web_root)
external_connections = "{}://{}:{}{}".format(self.request.protocol,
get_external_ip(),
sickrage.app.config.general.web_port,
sickrage.app.config.general.web_root)
connections = ','.join([internal_connections, external_connections])
if sickrage.app.config.general.server_id and not sickrage.app.api.account.update_server(sickrage.app.config.general.server_id, connections):
sickrage.app.config.general.server_id = ''
if not sickrage.app.config.general.server_id:
server_id = sickrage.app.api.account.register_server(connections)
if server_id:
sickrage.app.config.general.server_id = server_id
sickrage.app.config.save()
self.current_user = decoded_auth_token
except Exception:
return self.send_error(401, error='failed to decode token')
else:
return self.send_error(401, error='invalid authorization request')
else:
return self.send_error(401, error='authorization header missing')
def get_current_user(self):
return self.current_user
def write_error(self, status_code, **kwargs):
self.set_header('Content-Type', 'application/json')
self.set_status(status_code)
if status_code == 500:
excp = kwargs['exc_info'][1]
tb = kwargs['exc_info'][2]
stack = traceback.extract_tb(tb)
clean_stack = [i for i in stack if i[0][-6:] != 'gen.py' and i[0][-13:] != 'concurrent.py']
error_msg = '{}\n Exception: {}'.format(''.join(traceback.format_list(clean_stack)), excp)
sickrage.app.log.debug(error_msg)
else:
error_msg = kwargs.get('reason', '') or kwargs.get('error', '')
self.write_json({'error': error_msg})
def set_default_headers(self):
super(APIBaseHandler, self).set_default_headers()
self.set_header('Content-Type', 'application/json')
def write_json(self, response):
self.write(json.dumps(response))
def _validate_schema(self, schema, arguments):
return schema().validate({k: to_basestring(v[0]) if len(v) <= 1 else to_basestring(v) for k, v in arguments.items()})
def _parse_value(self, value, func):
if value is not None:
try:
return func(value)
except ValueError:
raise HTTPError(400, f'Invalid value {value!r}')
def _parse_boolean(self, value):
if isinstance(value, str):
return value.lower() == 'true'
return self._parse_value(value, bool)
def generate_swagger_json(self, handlers, api_version):
"""Automatically generates Swagger spec file based on RequestHandler
docstrings and returns it.
"""
spec = APISpec(
title="SiCKRAGE App API",
version=api_version,
openapi_version="3.0.2",
info={'description': "Documentation for SiCKRAGE App API"},
plugins=[TornadoPlugin(), MarshmallowPlugin()],
)
for handler in handlers:
try:
spec.path(urlspec=handler)
except APISpecError:
pass
return spec.to_dict()
class PingHandler(APIBaseHandler, ABC):
def get(self):
return self.write_json({'message': 'pong'})
class SwaggerDotJsonHandler(APIBaseHandler, ABC):
def initialize(self, api_handlers, api_version):
super(SwaggerDotJsonHandler, self).initialize()
self.api_handlers = sickrage.app.wserver.handlers[api_handlers]
self.api_version = api_version
def get(self):
""" Get swagger.json """
return self.write_json(self.generate_swagger_json(self.api_handlers, self.api_version))
# ##############################################################################
# Author: echel0n <[email protected]>
# URL: https://sickrage.ca/
# Git: https://git.sickrage.ca/SiCKRAGE/sickrage.git
# -
# This file is part of SiCKRAGE.
# -
# SiCKRAGE is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
# -
# SiCKRAGE is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
# -
# You should have received a copy of the GNU General Public License
# along with SiCKRAGE. If not, see <http://www.gnu.org/licenses/>.
# ##############################################################################
from marshmallow import Schema, fields
class BaseSchema(Schema):
class Meta:
ordered = True
class NotAuthorizedSchema(BaseSchema):
error = fields.String(
required=False,
description="Authorization error",
default="Not Authorized",
)
class NotFoundSchema(BaseSchema):
error = fields.String(
required=False,
description="Not Found error",
default="Not Found",
)
class BaseSuccessSchema(BaseSchema):
success = fields.Boolean(
required=True,
description='This is always "True" when a request succeeds',
example=True,
)
class BaseErrorSchema(BaseSchema):
success = fields.Boolean(
required=True,
description='This is always "False" when a request fails',
example=False,
)
class BadRequestSchema(BaseErrorSchema):
errors = fields.Dict(
required=False,
description="Attached request validation errors",
example={"name": ["Missing data for required field."]},
)
......@@ -1165,8 +1165,7 @@ class CMD_PostProcess(ApiCall):
"delete": {"desc": "Delete processed files and folders"},
"failed": {"desc": "Mark download as failed"},
"type": {"desc": "The type of post-process being requested"},
"force_next": {"desc": "Waits for the current processing queue item to finish and returns result of this "
"request"},
"force_next": {"desc": "Waits for the current processing queue item to finish and returns result of this request"},
}
}
......
......@@ -18,124 +18,14 @@
# You should have received a copy of the GNU General Public License
# along with SiCKRAGE. If not, see <http://www.gnu.org/licenses/>.
# ##############################################################################
import json
import os
import traceback
from abc import ABC
from tornado.escape import json_decode
from tornado.web import HTTPError
import sickrage
from sickrage.core.enums import ProcessMethod
from sickrage.core.helpers import get_internal_ip, get_external_ip
from sickrage.core.webserver.handlers.base import BaseHandler
class APIv2BaseHandler(BaseHandler, ABC):
def prepare(self):
super(APIv2BaseHandler, self).prepare()
method_name = self.request.method.lower()
if method_name == 'options':
return
certs = sickrage.app.auth_server.certs()
auth_header = self.request.headers.get('Authorization')
if auth_header:
if 'bearer' in auth_header.lower():
try:
token = auth_header.strip('Bearer').strip()
decoded_auth_token = sickrage.app.auth_server.decode_token(token, certs)
if not sickrage.app.config.user.sub_id:
sickrage.app.config.user.sub_id = decoded_auth_token.get('sub')
sickrage.app.config.save()
if sickrage.app.config.user.sub_id != decoded_auth_token.get('sub'):
return self.send_error(401, error='user is not authorized')
if sickrage.app.config.general.enable_sickrage_api and not sickrage.app.api.token:
exchanged_token = sickrage.app.auth_server.token_exchange(token)
if exchanged_token:
sickrage.app.api.token = exchanged_token
internal_connections = "{}://{}:{}{}".format(self.request.protocol,
get_internal_ip(),
sickrage.app.config.general.web_port,
sickrage.app.config.general.web_root)
external_connections = "{}://{}:{}{}".format(self.request.protocol,
get_external_ip(),
sickrage.app.config.general.web_port,
sickrage.app.config.general.web_root)
connections = ','.join([internal_connections, external_connections])
if sickrage.app.config.general.server_id and not sickrage.app.api.account.update_server(sickrage.app.config.general.server_id, connections):
sickrage.app.config.general.server_id = ''
if not sickrage.app.config.general.server_id:
server_id = sickrage.app.api.account.register_server(connections)
if server_id:
sickrage.app.config.general.server_id = server_id
sickrage.app.config.save()
self.current_user = decoded_auth_token
except Exception:
return self.send_error(401, error='failed to decode token')
else:
return self.send_error(401, error='invalid authorization request')
else:
return self.send_error(401, error='authorization header missing')
def get_current_user(self):
return self.current_user
def write_error(self, status_code, **kwargs):
self.set_header('Content-Type', 'application/json')
self.set_status(status_code)
from sickrage.core.webserver.handlers.api import APIBaseHandler
if status_code == 500:
excp = kwargs['exc_info'][1]
tb = kwargs['exc_info'][2]
stack = traceback.extract_tb(tb)
clean_stack = [i for i in stack if i[0][-6:] != 'gen.py' and i[0][-13:] != 'concurrent.py']
error_msg = '{}\n Exception: {}'.format(''.join(traceback.format_list(clean_stack)), excp)
sickrage.app.log.debug(error_msg)
else:
error_msg = kwargs.get('reason', '') or kwargs.get('error', '')
self.write_json({'error': error_msg})
def set_default_headers(self):
super(APIv2BaseHandler, self).set_default_headers()
self.set_header('Content-Type', 'application/json')
def write_json(self, response):
self.write(json.dumps(response))
def _parse_value(self, value, func):
if value is not None:
try:
return func(value)
except ValueError:
raise HTTPError(400, f'Invalid value {value!r}')
def _parse_boolean(self, value):
if isinstance(value, str):
return value.lower() == 'true'
return self._parse_value(value, bool)
class PingHandler(APIv2BaseHandler, ABC):
def get(self, *args, **kwargs):
return self.write_json({'message': 'pong'})
class RetrieveSeriesMetadataHandler(APIv2BaseHandler, ABC):
class RetrieveSeriesMetadataHandler(APIBaseHandler, ABC):
def get(self):
series_directory = self.get_argument('seriesDirectory', None)
if not series_directory:
......@@ -166,27 +56,3 @@ class RetrieveSeriesMetadataHandler(APIv2BaseHandler, ABC):
json_data['seriesSlug'] = f'{series_id}-{series_provider_id.slug}'
self.write_json(json_data)
class PostProcessHandler(APIv2BaseHandler, ABC):
def get(self):
path = self.get_argument("path", sickrage.app.config.general.tv_download_dir)
force_replace = self._parse_boolean(self.get_argument("forceReplace", None) or False)
return_data = self._parse_boolean(self.get_argument("returnData", None) or False)
process_method = self.get_argument("processMethod", ProcessMethod.COPY.name)
is_priority = self._parse_boolean(self.get_argument("isPriority", None) or False)
delete = self._parse_boolean(self.get_argument("delete", None) or False)
failed = self._parse_boolean(self.get_argument("failed", None) or False)
proc_type = self.get_argument("type", 'manual')
force_next = self._parse_boolean(self.get_argument("forceNext", None) or False)
if not path and not sickrage.app.config.general.tv_download_dir:
return self.send_error(400, error="You need to provide a path or set TV Download Dir")
json_data = sickrage.app.postprocessor_queue.put(path, process_method=ProcessMethod[process_method.upper()], force=force_replace,
is_priority=is_priority, delete_on=delete, failed=failed, proc_type=proc_type, force_next=force_next)
if 'Processing succeeded' not in json_data:
return self.send_error(400, error=json_data)
self.write_json({'data': json_data if return_data else ''})
......@@ -24,10 +24,10 @@ import sickrage
from sickrage.core.common import Overview
from sickrage.core.common import Qualities, EpisodeStatus
from sickrage.core.enums import SearchFormat
from sickrage.core.webserver.handlers.api.v2 import APIv2BaseHandler
from sickrage.core.webserver.handlers.api import APIBaseHandler
class ConfigHandler(APIv2BaseHandler, ABC):
class ConfigHandler(APIBaseHandler, ABC):
def get(self, *args, **kwargs):
config_data = sickrage.app.config.to_json()
......
......@@ -27,13 +27,60 @@ import sickrage
from sickrage.core.queues.search import ManualSearchTask
from sickrage.core.tv.episode.helpers import find_episode_by_slug, find_episode
from sickrage.core.tv.show.helpers import find_show_by_slug
from sickrage.core.webserver.handlers.api.v2 import APIv2BaseHandler
from sickrage.core.webserver.handlers.api import APIBaseHandler
from sickrage.core.webserver.handlers.api.v2.episode.schemas import EpisodesManualSearchSchema, EpisodesRenameSchema, EpisodesManualSearchPath
from sickrage.core.websocket import WebSocketMessage
class EpisodesManualSearchHandler(APIv2BaseHandler, ABC):
class EpisodesManualSearchHandler(APIBaseHandler, ABC):
def get(self, episode_slug):
use_existing_quality = self.get_argument('useExistingQuality')
"""Episode Manual Search"
---
tags: [Episodes]
summary: Manually search for episode on search providers
description: Manually search for episode on search providers
parameters:
- in: path
schema:
EpisodesManualSearchPath
- in: query
schema:
EpisodesManualSearchSchema
responses:
200:
description: Success payload
content:
application/json: