Merged in feature/advanced_rest_api (pull request #334)

Feature/advanced rest api

Approved-by: Milan Kolar <milan@orbi.tools>
This commit is contained in:
Jakub Trllo 2019-11-19 14:52:30 +00:00 committed by Milan Kolar
commit d15c57b57f
14 changed files with 1315 additions and 441 deletions

View file

@ -14,6 +14,11 @@ class AvalonApps:
self.parent = parent
self.app_launcher = None
def process_modules(self, modules):
if "RestApiServer" in modules:
from .rest_api import AvalonRestApi
self.rest_api_obj = AvalonRestApi()
# Definition of Tray menu
def tray_menu(self, parent_menu=None):
# Actions

View file

@ -0,0 +1,86 @@
import os
import re
import json
import bson
import bson.json_util
from pype.services.rest_api import RestApi, abort, CallbackResult
from pype.ftrack.lib.custom_db_connector import DbConnector
class AvalonRestApi(RestApi):
dbcon = DbConnector(
os.environ["AVALON_MONGO"],
os.environ["AVALON_DB"]
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.dbcon.install()
@RestApi.route("/projects/<project_name>", url_prefix="/avalon", methods="GET")
def get_project(self, request):
project_name = request.url_data["project_name"]
if not project_name:
output = {}
for project_name in self.dbcon.tables():
project = self.dbcon[project_name].find_one({"type": "project"})
output[project_name] = project
return CallbackResult(data=self.result_to_json(output))
project = self.dbcon[project_name].find_one({"type": "project"})
if project:
return CallbackResult(data=self.result_to_json(project))
abort(404, "Project \"{}\" was not found in database".format(
project_name
))
@RestApi.route("/projects/<project_name>/assets/<asset>", url_prefix="/avalon", methods="GET")
def get_assets(self, request):
_project_name = request.url_data["project_name"]
_asset = request.url_data["asset"]
if not self.dbcon.exist_table(_project_name):
abort(404, "Project \"{}\" was not found in database".format(
project_name
))
if not _asset:
assets = self.dbcon[_project_name].find({"type": "asset"})
output = self.result_to_json(assets)
return CallbackResult(data=output)
# identificator can be specified with url query (default is `name`)
identificator = request.query.get("identificator", "name")
asset = self.dbcon[_project_name].find_one({
"type": "asset",
identificator: _asset
})
if asset:
id = asset["_id"]
asset["_id"] = str(id)
return asset
abort(404, "Asset \"{}\" with {} was not found in project {}".format(
_asset, identificator, project_name
))
def result_to_json(self, result):
""" Converts result of MongoDB query to dict without $oid (ObjectId)
keys with help of regex matching.
..note:
This will convert object type entries similar to ObjectId.
"""
bson_json = bson.json_util.dumps(result)
# Replace "{$oid: "{entity id}"}" with "{entity id}"
regex1 = '(?P<id>{\"\$oid\": \"[^\"]+\"})'
regex2 = '{\"\$oid\": (?P<id>\"[^\"]+\")}'
for value in re.findall(regex1, bson_json):
for substr in re.findall(regex2, value):
bson_json = bson_json.replace(value, substr)
return json.loads(bson_json)

View file

@ -22,7 +22,12 @@ import pymongo
from pymongo.client_session import ClientSession
class NotActiveTable(Exception):
pass
def __init__(self, *args, **kwargs):
msg = "Active table is not set. (This is bug)"
if not (args or kwargs):
args = (default_message,)
super().__init__(*args, **kwargs)
def auto_reconnect(func):
"""Handling auto reconnect in 3 retry times"""
@ -37,7 +42,16 @@ def auto_reconnect(func):
time.sleep(0.1)
else:
raise
return decorated
def check_active_table(func):
"""Check if DbConnector has active table before db method is called"""
@functools.wraps(func)
def decorated(obj, *args, **kwargs):
if not obj.active_table:
raise NotActiveTable()
return func(obj, *args, **kwargs)
return decorated
@ -53,7 +67,6 @@ def check_active_table(func):
class DbConnector:
log = logging.getLogger(__name__)
timeout = 1000
@ -68,10 +81,18 @@ class DbConnector:
self.active_table = table_name
def __getitem__(self, key):
# gives direct access to collection withou setting `active_table`
return self._database[key]
def __getattribute__(self, attr):
# not all methods of PyMongo database are implemented with this it is
# possible to use them too
try:
return super().__getattribute__(attr)
return super(DbConnector, self).__getattribute__(attr)
except AttributeError:
if self.active_table is None:
raise NotActiveTable()
return self._database[self.active_table].__getattribute__(attr)
def install(self):
@ -131,6 +152,15 @@ class DbConnector:
def exist_table(self, table_name):
return table_name in self.tables()
def create_table(self, name, **options):
if self.exist_table(name):
return
return self._database.create_collection(name, **options)
def exist_table(self, table_name):
return table_name in self.tables()
def tables(self):
"""List available tables
Returns:
@ -166,18 +196,21 @@ class DbConnector:
@check_active_table
@auto_reconnect
def find(self, filter, projection=None, sort=None, **options):
options["projection"] = projection
options["sort"] = sort
return self._database[self.active_table].find(filter, **options)
return self._database[self.active_table].find(
filter, projection, **options
)
@check_active_table
@auto_reconnect
def find_one(self, filter, projection=None, sort=None, **options):
assert isinstance(filter, dict), "filter must be <dict>"
options["projection"] = projection
options["sort"] = sort
return self._database[self.active_table].find_one(filter, **options)
return self._database[self.active_table].find_one(
filter,
projection,
**options
)
@check_active_table
@auto_reconnect
@ -202,8 +235,8 @@ class DbConnector:
@check_active_table
@auto_reconnect
def distinct(self, *args, **kwargs):
return self._database[self.active_table].distinct(*args, **kwargs)
def distinct(self, **options):
return self._database[self.active_table].distinct(**options)
@check_active_table
@auto_reconnect
@ -216,10 +249,14 @@ class DbConnector:
@auto_reconnect
def delete_one(self, filter, collation=None, **options):
options["collation"] = collation
return self._database[self.active_table].delete_one(filter, **options)
return self._database[self.active_table].delete_one(
filter, **options
)
@check_active_table
@auto_reconnect
def delete_many(self, filter, collation=None, **options):
options["collation"] = collation
return self._database[self.active_table].delete_many(filter, **options)
return self._database[self.active_table].delete_many(
filter, **options
)

View file

@ -43,8 +43,10 @@ class MusterModule:
self.aShowLogin.trigger()
if "RestApiServer" in modules:
def api_show_login():
self.aShowLogin.trigger()
modules["RestApiServer"].register_callback(
"muster/show_login", api_callback, "post"
"/show_login", api_show_login, "muster", "post"
)
# Definition of Tray menu

View file

@ -1,4 +1,6 @@
from .rest_api import RestApiServer
from .base_class import RestApi, abort, route, register_statics
from .lib import RestMethods, CallbackResult
def tray_init(tray_widget, main_widget):

View file

@ -0,0 +1,114 @@
from functools import wraps
from http import HTTPStatus
from .lib import (
RestApiFactory, Splitter,
ObjAlreadyExist, AbortException,
)
def route(path, url_prefix="", methods=[], strict_match=False):
"""Decorator that register callback and all its attributes.
Callback is registered to Singleton RestApiFactory.
:param path: Specify url path when callback should be triggered.
:type path: str
:param url_prefix: Specify prefix of path, defaults to "/".
:type url_prefix: str, list, optional
:param methods: Specify request method (GET, POST, PUT, etc.) when callback will be triggered, defaults to ["GET"]
:type methods: list, str, optional
:param strict_match: Decides if callback can handle both single and multiple entities (~/projects/<project_name> && ~/projects/), defaults to False.
:type strict_match: bool
`path` may include dynamic keys that will be stored to object which can
be obtained in callback.
Example:
- registered path: "/projects/<project_name>"
- url request path: "/projects/S001_test_project"
In this case will be callback triggered and in accessible data will be
stored {"project_name": "S001_test_project"}.
`url_prefix` is optional but it is better to specify for easier filtering
of requests.
Example:
- url_prefix: `"/avalon"` or `["avalon"]`
- path: `"/projects"`
In this case request path must be "/avalon/projects" to trigger registered
callback.
"""
def decorator(callback):
RestApiFactory.register_route(
path, callback, url_prefix, methods, strict_match
)
callback.restapi = True
return callback
return decorator
def register_statics(url_prefix, dir_path):
"""Decorator that register callback and all its attributes.
Callback is registered to Singleton RestApiFactory.
:param url_prefix: Specify prefix of path, defaults to "/". (Example: "/resources")
:type url_prefix: str
:param dir_path: Path to file folder where statics are located.
:type dir_path: str
"""
RestApiFactory.register_statics((url_prefix, dir_path))
def abort(status_code=HTTPStatus.NOT_FOUND, message=None):
"""Should be used to stop registered callback
`abort` raise AbortException that is handled with request Handler which
returns entered status and may send optional message in body.
:param status_code: Status that will be send in reply of request, defaults to 404
:type status_code: int
:param message: Message to send in body, default messages are based on statuc_code in Handler, defaults to None
:type message: str, optional
...
:raises AbortException: This exception is handled in Handler to know about launched `abort`
"""
items = []
items.append(str(status_code))
if not message:
message = ""
items.append(message)
raise AbortException(Splitter.join(items))
class RestApi:
"""Base class for RestApi classes.
Use this class is required when it is necessary to have class for handling
requests and want to use decorators for registering callbacks.
It is possible to use decorators in another class only when object, of class
where decorators are, is registered to RestApiFactory.
"""
def route(path, url_prefix="", methods=[], strict_match=False):
return route(path, url_prefix, methods, strict_match)
@classmethod
def register_route(
cls, callback, path, url_prefix="", methods=[], strict_match=False
):
return route(path, methods, url_prefix, strict_match)(callback)
@classmethod
def register_statics(cls, url_prefix, dir_path):
return register_statics(url_prefix, dir_path)
@classmethod
def abort(cls, status_code=HTTPStatus.NOT_FOUND, message=None):
abort(status_code, message)
def __new__(cls, *args, **kwargs):
for obj in RestApiFactory.registered_objs:
if type(obj) == cls:
raise ObjAlreadyExist(cls)
instance = super(RestApi, cls).__new__(cls)
RestApiFactory.register_obj(instance)
return instance

View file

@ -0,0 +1,9 @@
Splitter = "__splitter__"
from .exceptions import ObjAlreadyExist, AbortException
from .lib import RestMethods, CustomNone, CallbackResult, RequestInfo
from .factory import _RestApiFactory
RestApiFactory = _RestApiFactory()
from .handler import Handler

View file

@ -0,0 +1,11 @@
class ObjAlreadyExist(Exception):
"""Is used when is created multiple objects of same RestApi class."""
def __init__(self, cls=None, message=None):
if not (cls and message):
message = "RestApi object was created twice."
elif not message:
message = "{} object was created twice.".format(cls.__name__)
super().__init__(message)
class AbortException(Exception): pass

View file

@ -0,0 +1,353 @@
import os
import re
import inspect
import collections
from .lib import RestMethods
from pypeapp import Logger
log = Logger().get_logger("RestApiFactory")
def prepare_fullpath(path, prefix):
"""Concatenate registered path and prefix with right form.
:param path: Registered url path for registered callback.
:type path: str, list
:param prefix: Registered and prepared url prefix.
:type prefix: str, None
:return: concatenated prefix and path in right form
:rtype: str
"""
if isinstance(path, (list, tuple)):
path_items = path
else:
path_items = [part for part in path.split("/") if part]
fullpath = "/"
if path and prefix:
items = [part for part in prefix.split("/") if part]
items.extend(path_items)
fullpath = "/".join(items)
if path.endswith("/"):
fullpath += "/"
elif path:
fullpath = "/".join(path_items)
if path.endswith("/"):
fullpath += "/"
elif prefix:
fullpath = prefix
if not fullpath.startswith("/"):
fullpath = "/{}".format(fullpath)
return fullpath
def prepare_regex_from_path(full_path, strict_match):
"""Prepare regex based on set path.
When registered path do not contain dynamic keys regex is not set.
Dynamic keys are specified with "<" and ">" ("<{dynamic key}>").
:param full_path: Full url path (prefix + path) for registered callback.
:type full_path: str, list, None
:return: regex and keys of all groups in regex
:rtype: tuple(SRE_Pattern, list), tuple(None, None)
"""
get_indexes_regex = "<[^< >]+>"
all_founded_keys = re.findall(get_indexes_regex, full_path)
if not all_founded_keys:
return None, None
regex_path = full_path
keys = []
for key in all_founded_keys:
replacement = "(?P{}\w+)".format(key)
keys.append(key.replace("<", "").replace(">", ""))
if not strict_match:
if full_path.endswith(key):
replacement = "?{}?".format(replacement)
regex_path = regex_path.replace(key, replacement)
regex_path = "^{}$".format(regex_path)
return re.compile(regex_path), keys
def prepare_prefix(url_prefix):
"""Check if the url_prefix is set and is in correct form.
Output is None when prefix is empty or "/".
:param url_prefix: Registered prefix of registered callback.
:type url_prefix: str, list, None
:return: Url prefix of registered callback
:rtype: str, None
"""
if url_prefix is None or url_prefix.strip() == "/":
return None
elif isinstance(url_prefix, (list, tuple)):
url_prefix = "/".join(url_prefix)
else:
items = [part for part in url_prefix.split("/") if part]
url_prefix = "/".join(items)
if not url_prefix:
return None
while url_prefix.endswith("/"):
url_prefix = url_prefix[:-1]
if not url_prefix.startswith("/"):
url_prefix = "/{}".format(url_prefix)
return url_prefix
def prepare_methods(methods, callback=None):
"""Check and convert entered methods.
String `methods` is converted to list. All values are converted to
`RestMethods` enum object. Invalid methods are ignored and printed out.
:param methods: Contain rest api methods, when callback is called.
:type methods: str, list
:param callback: Registered callback, helps to identify where is invalid method.
:type callback: function, method, optional
:return: Valid methods
:rtype: list
"""
invalid_methods = collections.defaultdict(list)
if not methods:
_methods = [RestMethods.GET]
elif isinstance(methods, str) or isinstance(methods, RestMethods):
_method = RestMethods.get(methods)
_methods = []
if _method is None:
invalid_methods[methods].append(callback)
else:
_methods.append(_method)
else:
_methods = []
for method in methods:
found = False
_method = RestMethods.get(method)
if _method == None:
invalid_methods[methods].append(callback)
continue
_methods.append(_method)
for method, callbacks in invalid_methods.items():
callback_info = ""
callbacks = [cbk for cbk in callbacks if cbk]
if len(callbacks) > 0:
multiple_ind = ""
if len(callbacks) > 1:
multiple_ind = "s"
callback_items = []
for callback in callbacks:
callback_items.append("\"{}<{}>\"".format(
callback.__qualname__, callback.__globals__["__file__"]
))
callback_info = " with callback{} {}".format(
multiple_ind, "| ".join(callback_items)
)
log.warning(
("Invalid RestApi method \"{}\"{}").format(method, callback_info)
)
return _methods
def prepare_callback_info(callback):
"""Prepare data for callback handling when should be triggered."""
callback_info = inspect.getfullargspec(callback)
callback_args = callback_info.args
callback_args_len = 0
if callback_args:
callback_args_len = len(callback_args)
if type(callback).__name__ == "method":
callback_args_len -= 1
defaults = callback_info.defaults
defaults_len = 0
if defaults:
defaults_len = len(defaults)
annotations = callback_info.annotations
return {
"args": callback_args,
"args_len": callback_args_len,
"defaults": defaults,
"defaults_len": defaults_len,
"hasargs": callback_info.varargs is not None,
"haskwargs": callback_info.varkw is not None,
"annotations": annotations
}
class _RestApiFactory:
"""Factory is used to store and prepare callbacks for requests.
Should be created only one object used for all registered callbacks when
it is expected to run only one http server.
"""
registered_objs = []
unprocessed_routes = []
unprocessed_statics = []
prepared_routes = {
method: collections.defaultdict(list) for method in RestMethods
}
prepared_statics = {}
has_routes = False
def has_handlers(self):
return (self.has_routes or self.prepared_statics)
def _process_route(self, route):
return self.unprocessed_routes.pop(
self.unprocessed_routes.index(route)
)
def _process_statics(self, item):
return self.unprocessed_statics.pop(
self.unprocessed_statics.index(item)
)
def register_route(self, path, callback, url_prefix, methods, strict_match):
log.debug("Registering callback for item \"{}\"".format(
callback.__qualname__
))
route = {
"path": path,
"callback": callback,
"url_prefix": url_prefix,
"methods": methods,
"strict_match": strict_match
}
self.unprocessed_routes.append(route)
def register_obj(self, obj):
"""Register object for decorated methods in class definition"""
self.registered_objs.append(obj)
def register_statics(self, item):
log.debug("Registering statics path \"{}\"".format(item))
self.unprocessed_statics.append(item)
def _prepare_route(self, route):
"""Prepare data of registered callbacks for routes.
Registration info are prepared to easy filter during handling
of requests.
:param route: Contain all necessary info for filtering and handling callback for registered route.
:type route: dict
"""
callback = route["callback"]
methods = prepare_methods(route["methods"], callback)
url_prefix = prepare_prefix(route["url_prefix"])
fullpath = prepare_fullpath(route["path"], url_prefix)
regex, regex_keys = prepare_regex_from_path(
fullpath, route["strict_match"]
)
callback_info = prepare_callback_info(callback)
for method in methods:
self.has_routes = True
self.prepared_routes[method][url_prefix].append({
"regex": regex,
"regex_keys": regex_keys,
"fullpath": fullpath,
"callback": callback,
"callback_info": callback_info
})
def prepare_registered(self):
"""Iterate through all registered callbacks and statics and prepare them
First are processed callbacks registered with decorators in classes by
registered objects. Remaining callbacks are filtered, it is checked if
methods has `__self__` or are defined in <locals> (it is expeted they
do not requise access to object)
"""
for url_prefix, dir_path in self.unprocessed_statics:
self._process_statics((url_prefix, dir_path))
dir_path = os.path.normpath(dir_path)
if not os.path.exists(dir_path):
log.warning(
"Directory path \"{}\" was not found".format(dir_path)
)
continue
url_prefix = prepare_prefix(url_prefix)
self.prepared_statics[url_prefix] = dir_path
for obj in self.registered_objs:
method_names = [
attr for attr in dir(obj)
if inspect.ismethod(getattr(obj, attr))
]
for method_name in method_names:
method = obj.__getattribute__(method_name)
if not hasattr(method, "restapi"):
continue
if not method.restapi:
continue
for route in self.unprocessed_routes:
callback = route["callback"]
if not (
callback.__qualname__ == method.__qualname__ and
callback.__module__ == method.__module__ and
callback.__globals__["__file__"] == method.__globals__["__file__"]
):
continue
route["callback"] = method
self._process_route(route)
self._prepare_route(route)
break
for route in self.unprocessed_routes:
callback = route["callback"]
is_class_method = len(callback.__qualname__.split(".")) != 1
if is_class_method:
missing_self = True
if hasattr(callback, "__self__"):
if callback.__self__ is not None:
missing_self = False
if "<locals>" in callback.__qualname__:
pass
elif missing_self:
log.warning((
"Object of callback \"{}\" from \"{}\" is not"
" accessible for api. Register object or"
" register callback with already created object"
"(not with decorator in class).".format(
callback.__qualname__,
callback.__globals__["__file__"]
)
))
continue
self._prepare_route(route)
continue
self._prepare_route(route)

View file

@ -0,0 +1,338 @@
import os
import re
import json
import traceback
import http.server
from http import HTTPStatus
from urllib.parse import urlparse
from .lib import RestMethods, CallbackResult, RequestInfo
from .exceptions import AbortException
from . import RestApiFactory, Splitter
from pypeapp import Logger
log = Logger().get_logger("RestApiHandler")
class Handler(http.server.SimpleHTTPRequestHandler):
# TODO fill will necessary statuses
default_messages = {
HTTPStatus.BAD_REQUEST: "Bad request",
HTTPStatus.NOT_FOUND: "Not found"
}
statuses = {
"POST": {
"OK": 200,
"CREATED": 201
},
"PUT": {
"OK": 200,
"NO_CONTENT": 204
}
}
def do_GET(self):
return self._handle_request(RestMethods.GET)
def do_POST(self):
return self._handle_request(RestMethods.POST)
def do_PUT(self):
return self._handle_request(RestMethods.PUT)
def do_DELETE(self):
return self._handle_request(RestMethods.DELETE)
def do_PATCH(self):
return self._handle_request(RestMethods.PATCH)
def _handle_request(self, rest_method):
"""Handle request by registered callbacks and statics.
Statics are only for GET method request. `_handle_statics` is called
when path is matching registered statics prefix. Callbacks are filtered
by method and their prefixes. When path is matching `_handle_callback`
is called.
If any registered callback or statics match requested path 400 status
is responsed. And 500 when unexpected error happens.
"""
parsed_url = urlparse(self.path)
path = parsed_url.path
if rest_method is RestMethods.GET:
for prefix, dirpath in RestApiFactory.prepared_statics.items():
if not path.startswith(prefix):
continue
_path = path[len(prefix):]
return self._handle_statics(dirpath, _path)
matching_item = None
found_prefix = None
url_prefixes = RestApiFactory.prepared_routes[rest_method]
for url_prefix, items in url_prefixes.items():
if matching_item is not None:
break
if url_prefix is not None:
if not path.startswith(url_prefix):
continue
found_prefix = url_prefix
for item in items:
regex = item["regex"]
item_full_path = item["fullpath"]
if regex is None:
if path == item_full_path:
item["url_data"] = None
matching_item = item
break
else:
found = re.match(regex, path)
if found:
item["url_data"] = found.groupdict()
matching_item = item
break
if not matching_item:
if found_prefix is not None:
_path = path.replace(found_prefix, "")
if _path:
request_str = " \"{}\"".format(_path)
else:
request_str = ""
message = "Invalid path request{} for prefix \"{}\"".format(
request_str, found_prefix
)
else:
message = "Invalid path request \"{}\"".format(self.path)
log.debug(message)
self.send_error(HTTPStatus.BAD_REQUEST, message)
return
try:
log.debug("Triggering callback for path \"{}\"".format(path))
result = self._handle_callback(
matching_item, parsed_url, rest_method
)
return self._handle_callback_result(result, rest_method)
except AbortException as exc:
status_code, message = str(exc).split(Splitter)
status_code = int(status_code)
if not message:
message = self.default_messages.get(
status_code, "UnexpectedError"
)
self.send_response(status_code)
self.send_header("Content-type", "text/html")
self.send_header("Content-Length", len(message))
self.end_headers()
self.wfile.write(message.encode())
return message
except Exception as exc:
log_message = "Unexpected Exception was raised (this is bug!)"
log.error(log_message, exc_info=True)
replace_helper = 0
items = [log_message]
items += traceback.extract_tb(exc.__traceback__).format()
message = "\n".join(items)
self.send_response(HTTPStatus.INTERNAL_SERVER_ERROR)
self.send_header("Content-type", "text/html")
self.send_header("Content-Length", len(message))
self.end_headers()
self.wfile.write(message.encode())
return message
def _handle_callback_result(self, result, rest_method):
"""Send response to request based on result of callback.
:param result: Result returned by callback.
:type result: None, bool, dict, list, CallbackResult
:param rest_method: Rest api method (GET, POST, etc.).
:type rest_method: RestMethods
Response is based on result type:
- None, True - It is expected everything was OK, status 200.
- False - It is expected callback was not successful, status 400
- dict, list - Result is send under "data" key of body, status 200
- CallbackResult - object specify status and data
"""
content_type = "application/json"
status = HTTPStatus.OK
success = True
message = None
data = None
body = None
# TODO better handling of results
if isinstance(result, CallbackResult):
status = result.status_code
body_dict = {}
for key, value in result.items():
if value is not None:
body_dict[key] = value
body = json.dumps(body_dict)
elif result in [None, True]:
status = HTTPStatus.OK
success = True
message = "{} request for \"{}\" passed".format(
rest_method, self.path
)
elif result is False:
status = HTTPStatus.BAD_REQUEST
success = False
elif isinstance(result, (dict, list)):
status = HTTPStatus.OK
data = result
if status == HTTPStatus.NO_CONTENT:
self.send_response(status)
self.end_headers()
return
if not body:
body_dict = {"success": success}
if message:
body_dict["message"] = message
if not data:
data = {}
body_dict["data"] = data
body = json.dumps(body_dict)
self.send_response(status)
self.send_header("Content-type", content_type)
self.send_header("Content-Length", len(body))
self.end_headers()
self.wfile.write(body.encode())
return body
def _handle_callback(self, item, parsed_url, rest_method):
"""Prepare data from request and trigger callback.
Data are loaded from body of request if there are any.
:param item: Item stored during callback registration with all info.
:type item: dict
:param parsed_url: Url parsed with urllib (separated path, query, etc.).
:type parsed_url: ParseResult
:param rest_method: Rest api method (GET, POST, etc.).
:type rest_method: RestMethods
"""
regex_keys = item["regex_keys"]
url_data = None
if regex_keys:
url_data = {key: None for key in regex_keys}
if item["url_data"]:
for key, value in item["url_data"].items():
url_data[key] = value
in_data = None
cont_len = self.headers.get("Content-Length")
if cont_len:
content_length = int(cont_len)
in_data_str = self.rfile.read(content_length)
if in_data_str:
in_data = json.loads(in_data_str)
request_info = RequestInfo(
url_data=url_data,
request_data=in_data,
query=parsed_url.query,
fragment=parsed_url.fragment,
params=parsed_url.params,
method=rest_method,
handler=self
)
callback = item["callback"]
callback_info = item["callback_info"]
_args_len = callback_info["args_len"]
_has_args = callback_info["hasargs"]
_has_kwargs = callback_info["haskwargs"]
args = []
kwargs = {}
if _args_len == 0:
if _has_args:
args.append(request_info)
elif _has_kwargs:
kwargs["request_info"] = request_info
else:
args.append(request_info)
return callback(*args, **kwargs)
def _handle_statics(self, dirpath, path):
"""Return static file in response when file exist in registered destination."""
path = os.path.normpath(dirpath + path)
ctype = self.guess_type(path)
try:
file_obj = open(path, "rb")
except OSError:
self.send_error(HTTPStatus.NOT_FOUND, "File not found")
return None
try:
file_stat = os.fstat(file_obj.fileno())
# Use browser cache if possible
if ("If-Modified-Since" in self.headers
and "If-None-Match" not in self.headers):
# compare If-Modified-Since and time of last file modification
try:
ims = http.server.email.utils.parsedate_to_datetime(
self.headers["If-Modified-Since"])
except (TypeError, IndexError, OverflowError, ValueError):
# ignore ill-formed values
pass
else:
if ims.tzinfo is None:
# obsolete format with no timezone, cf.
# https://tools.ietf.org/html/rfc7231#section-7.1.1.1
ims = ims.replace(tzinfo=datetime.timezone.utc)
if ims.tzinfo is datetime.timezone.utc:
# compare to UTC datetime of last modification
last_modif = datetime.datetime.fromtimestamp(
file_stat.st_mtime, datetime.timezone.utc)
# remove microseconds, like in If-Modified-Since
last_modif = last_modif.replace(microsecond=0)
if last_modif <= ims:
self.send_response(HTTPStatus.NOT_MODIFIED)
self.end_headers()
file_obj.close()
return None
self.send_response(HTTPStatus.OK)
self.send_header("Content-type", ctype)
self.send_header("Content-Length", str(file_stat[6]))
self.send_header("Last-Modified",
self.date_time_string(file_stat.st_mtime))
self.end_headers()
self.wfile.write(file_obj.read())
return file_obj
except:
self.log.error("Failed to read data from file \"{}\"".format(path))
finally:
file_obj.close()

View file

@ -0,0 +1,207 @@
import os
import re
import enum
from http import HTTPStatus
from urllib.parse import urlencode, parse_qs
from pypeapp import Logger
log = Logger().get_logger("RestApiServer")
class RestMethods(enum.Enum):
GET = "GET"
POST = "POST"
PUT = "PUT"
PATCH = "PATCH"
DELETE = "DELETE"
def __eq__(self, other):
if isinstance(other, self.__class__):
return self.value == other.value
elif isinstance(other, str):
return self.value.lower() == other.lower()
return self.value == other
def __hash__(self):
return enum.Enum.__hash__(self)
@classmethod
def get(cls, name, default=None):
for meth in cls:
if meth == name:
return meth
return default
class CustomNone:
"""Created object can be used as custom None (not equal to None)"""
def __init__(self, name):
self._name = name
def __bool__(self):
return False
def __eq__(self, other):
if type(other) == type(self):
if other._name == self._name:
return True
return False
def __str__(self):
return self._name
def __repr__(self):
return self._name
class HandlerDict(dict):
def __init__(self, data=None, *args, **kwargs):
if not data:
data = {}
super().__init__(data, *args, **kwargs)
def __repr__(self):
return "<{}> {}".format(self.__class__.__name__, str(dict(self)))
class Params(HandlerDict): pass
class UrlData(HandlerDict): pass
class RequestData(HandlerDict): pass
class Query(HandlerDict):
"""Class for url query convert to dict and string"""
def __init__(self, query):
if isinstance(query, dict):
pass
else:
query = parse_qs(query)
super().__init__(query)
def get_string(self):
return urlencode(dict(self), doseq=True)
class Fragment(HandlerDict):
"""Class for url fragment convert to dict and string"""
def __init__(self, fragment):
if isinstance(fragment, dict):
_fragment = fragment
else:
_fragment = {}
for frag in fragment.split("&"):
if not frag:
continue
items = frag.split("=")
value = None
key = items[0]
if len(items) == 2:
value = items[1]
elif len(items) > 2:
value = "=".join(items[1:])
_fragment[key] = value
super().__init__(_fragment)
def get_string(self):
items = []
for parts in dict(self).items():
items.append(
"=".join([p for p in parts if p])
)
return "&".join(items)
class RequestInfo:
"""Object that can be passed to callback as argument.
Contain necessary data for handling request.
Object is created to single use and can be used similar to dict.
:param url_data: Data collected from path when path with dynamic keys is matching.
:type url_data: dict, None
:param request_data: Data of body from request.
:type request_data: dict, None
:param query: Query from url path of reques.
:type query: str, None
:param fragment: Fragment from url path of reques.
:type fragment: str, None
:param params: Parems from url path of reques.
:type params: str, None
:param method: Method of request (GET, POST, etc.)
:type method: RestMethods
:param handler: Handler handling request from http server.
:type handler: Handler
"""
def __init__(
self, url_data, request_data, query, fragment, params, method, handler
):
self.url_data = UrlData(url_data)
self.request_data = RequestData(request_data)
self.query = Query(query)
self.fragment = Fragment(fragment)
self.params = Params(params)
self.method = method
self.handler = handler
def __getitem__(self, key):
return self.__getattribute__(key)
def __hash__(self):
return {
"url_data": self.url_data,
"request_data": self. request_data,
"query": self.query,
"fragment": self.fragment,
"params": self.params,
"method": self.method,
"handler": self.handler
}
def items(self):
return dict(self).items()
class CallbackResult:
"""Can be used as return value of callback.
It is possible to specify status code, success boolean, message and data
for specify head and body of request response. `abort` should be rather used
when result is error.
:param status_code: Status code of result.
:type status_code: int
:param success: Success is key in body, may be used for handling response.
:type success: bool
:param message: Similar to success, message is key in body and may be used for handling response.
:type message: str, None
:param data: Data is also key for body in response.
:type data: dict, None
"""
_data = {}
def __init__(
self, status_code=HTTPStatus.OK, success=True, message=None, data=None,
**kwargs
):
self.status_code = status_code
self._data = {
"success": success,
"message": message,
"data": data
}
for k, v in kwargs.items():
self._data[k] = v
def __getitem__(self, key):
return self._data[key]
def __iter__(self):
for key in self._data:
yield key
def get(self, key, default=None):
return self._data.get(key, default)
def items(self):
return self._data.items()

View file

@ -1,260 +1,127 @@
import os
import json
import enum
import re
import collections
import threading
from inspect import signature
import socket
import http.server
from http import HTTPStatus
import socketserver
from Qt import QtCore
from .lib import RestApiFactory, Handler
from .base_class import route, register_statics
from pypeapp import config, Logger
log = Logger().get_logger("RestApiServer")
class RestMethods(enum.Enum):
GET = "GET"
POST = "POST"
PUT = "PUT"
PATCH = "PATCH"
DELETE = "DELETE"
class RestApiServer:
"""Rest Api allows to access statics or callbacks with http requests.
def __repr__(self):
return str(self.value)
To register statics use `register_statics`.
def __eq__(self, other):
if isinstance(other, str):
return self.value == other
return self == other
To register callback use `register_callback` method or use `route` decorator.
`route` decorator should be used with not-class functions, it is possible
to use within class when inherits `RestApi` (defined in `base_class.py`)
or created object, with used decorator, is registered with `register_obj`.
def __hash__(self):
return enum.Enum.__hash__(self)
.. code-block:: python
@route("/username", url_prefix="/api", methods=["get"], strict_match=False)
def get_username():
return {"username": getpass.getuser()}
def __str__(self):
return str(self.value)
In that case response to `localhost:{port}/api/username` will be status
`200` with body including `{"data": {"username": getpass.getuser()}}`
Callback may expect one argument which will be filled with request
info. Data object has attributes: `request_data`, `query`,
`fragment`, `params`, `method`, `handler`, `url_data`.
request_data - Data from request body if there are any.
query - query from url path (?identificator=name)
fragment - fragment from url path (#reset_credentials)
params - params from url path
method - request method (GET, POST, PUT, etc.)
handler - Handler object of HttpServer Handler
url_data - dynamic url keys from registered path with their values
class Handler(http.server.SimpleHTTPRequestHandler):
Dynamic url keys may be set with path argument.
.. code-block:: python
from rest_api import route
def do_GET(self):
self.process_request(RestMethods.GET)
def do_POST(self):
"""Common code for POST.
This trigger callbacks on specific paths.
If request contain data and callback func has arg data are sent to
callback too.
Send back return values of callbacks.
"""
self.process_request(RestMethods.POST)
def process_request(self, rest_method):
"""Because processing is technically the same for now so it is used
the same way
"""
in_data = None
cont_len = self.headers.get("Content-Length")
if cont_len:
content_length = int(cont_len)
in_data_str = self.rfile.read(content_length)
if in_data_str:
in_data = json.loads(in_data_str)
registered_callbacks = self.server.registered_callbacks[rest_method]
path_items = [part.lower() for part in self.path.split("/") if part]
request_path = "/".join(path_items)
callback = registered_callbacks.get(request_path)
result = None
if callback:
log.debug(
"Triggering callbacks for path \"{}\"".format(request_path)
)
try:
params = signature(callback).parameters
if len(params) > 0 and in_data:
result = callback(in_data)
else:
result = callback()
self.send_response(HTTPStatus.OK)
if result in [None, True] or isinstance(result, (str, int)):
message = str(result)
if result in [None, True]:
message = "{} request for \"{}\" passed".format(
rest_method, self.path
)
self.handle_result(rest_method, message=message)
return
if isinstance(result, (dict, list)):
message = json.dumps(result).encode()
self.handle_result(rest_method, final_output=message)
return
except Exception:
message = "{} request for \"{}\" failed".format(
rest_method, self.path
)
log.error(message, exc_info=True)
self.send_response(HTTPStatus.BAD_REQUEST)
self.handle_result(rest_method, message=message, success=False)
return
self.handle_result(rest_method)
else:
message = (
"{} request for \"{}\" don't have registered callback"
).format(rest_method, self.path)
log.debug(message)
self.send_response(HTTPStatus.NOT_FOUND)
self.handle_result(rest_method, message=message, success=False)
def handle_result(
self, rest_method, final_output=None, message=None, success=True,
content_type="application/json"
):
self.send_header("Content-type", content_type)
if final_output:
output = final_output
else:
if not message:
output = json.dumps({
"success": False,
"message": (
"{} request for \"{}\" has unexpected result"
).format(rest_method, self.path)
}).encode()
else:
output = json.dumps({
"success": success, "message": message
}).encode()
if isinstance(output, str):
self.send_header("Content-Length", len(output))
self.end_headers()
if output:
self.wfile.write(output)
class AdditionalArgsTCPServer(socketserver.TCPServer):
def __init__(self, registered_callbacks, *args, **kwargs):
self.registered_callbacks = registered_callbacks
super(AdditionalArgsTCPServer, self).__init__(*args, **kwargs)
class RestApiServer(QtCore.QThread):
""" Listener for REST requests.
It is possible to register callbacks for url paths.
Be careful about crossreferencing to different QThreads it is not allowed.
"""
def __init__(self):
super(RestApiServer, self).__init__()
self.registered_callbacks = {
RestMethods.GET: collections.defaultdict(list),
RestMethods.POST: collections.defaultdict(list),
RestMethods.PUT: collections.defaultdict(list),
RestMethods.PATCH: collections.defaultdict(list),
RestMethods.DELETE: collections.defaultdict(list)
all_projects = {
"Proj1": {"proj_data": []},
"Proj2": {"proj_data": []},
}
@route("/projects/<project_name>", url_prefix="/api", methods=["get"], strict_match=False)
def get_projects(request_info):
project_name = request_info.url_data["project_name"]
if not project_name:
return all_projects
return all_projects.get(project_name)
This example should end with status 404 if project is not found. In that
case is best to use `abort` method.
.. code-block:: python
from rest_api import abort
@route("/projects/<project_name>", url_prefix="/api", methods=["get"], strict_match=False)
def get_projects(request_info):
project_name = request_info.url_data["project_name"]
if not project_name:
return all_projects
project = all_projects.get(project_name)
if not project:
abort(404, "Project \"{}\".format(project_name) was not found")
return project
`strict_match` allows to handle not only specific entity but all entity types.
E.g. "/projects/<project_name>" with set `strict_match` to False will handle also
"/projects" or "/projects/" path. It is necessary to set `strict_match` to
True when should handle only single entity.
Callback may return many types. For more information read docstring of
`_handle_callback_result` defined in handler.
"""
def __init__(self):
self.qaction = None
self.failed_icon = None
self._is_running = False
try:
self.presets = config.get_presets().get(
"services", {}).get(
"rest_api", {}
)
self.presets = config.get_presets()["services"]["rest_api"]
except Exception:
self.presets = {"default_port": 8011, "exclude_ports": []}
log.debug((
"There are not set presets for RestApiModule."
" Using defaults \"{}\""
).format(str(self.presets)))
self.port = self.find_port()
port = self.find_port()
self.rest_api_thread = RestApiThread(self, port)
statics_dir = os.path.sep.join([os.environ["PYPE_MODULE_ROOT"], "res"])
self.register_statics("/res", statics_dir)
os.environ["PYPE_STATICS_SERVER"] = "{}/res".format(
os.environ["PYPE_REST_API_URL"]
)
def set_qaction(self, qaction, failed_icon):
self.qaction = qaction
self.failed_icon = failed_icon
def register_callback(self, path, callback, rest_method=RestMethods.POST):
if isinstance(path, (list, set)):
path = "/".join([part.lower() for part in path])
elif isinstance(path, str):
path = "/".join(
[part.lower() for part in str(path).split("/") if part]
)
if isinstance(rest_method, str):
rest_method = str(rest_method).upper()
if path in self.registered_callbacks[rest_method]:
log.error(
"Path \"{}\" has already registered callback.".format(path)
)
return False
log.debug(
"Registering callback for path \"{}\"".format(path)
def register_callback(
self, path, callback, url_prefix="", methods=[], strict_match=False
):
RestApiFactory.register_route(
path, callback, url_prefix, methods, strict_match
)
self.registered_callbacks[rest_method][path] = callback
return True
def register_statics(self, url_prefix, dir_path):
register_statics(url_prefix, dir_path)
def tray_start(self):
self.start()
@property
def is_running(self):
return self._is_running
def stop(self):
self._is_running = False
def run(self):
self._is_running = True
if not self.registered_callbacks:
log.info("Any registered callbacks for Rest Api server.")
return
try:
log.debug(
"Running Rest Api server on URL:"
" \"http://localhost:{}\"".format(self.port)
)
with AdditionalArgsTCPServer(
self.registered_callbacks,
("", self.port),
Handler
) as httpd:
while self._is_running:
httpd.handle_request()
except Exception:
log.warning(
"Rest Api Server service has failed", exc_info=True
)
self._is_running = False
if self.qaction and self.failed_icon:
self.qaction.setIcon(self.failed_icon)
def register_obj(self, obj):
RestApiFactory.register_obj(obj)
def find_port(self):
start_port = self.presets["default_port"]
@ -276,3 +143,53 @@ class RestApiServer(QtCore.QThread):
found_port
)
return found_port
def tray_start(self):
RestApiFactory.prepare_registered()
if not RestApiFactory.has_handlers():
log.debug("There are not registered any handlers for RestApi")
return
self.rest_api_thread.start()
@property
def is_running(self):
return self.rest_api_thread.is_running
def stop(self):
self.rest_api_thread.is_running = False
def thread_stopped(self):
self._is_running = False
class RestApiThread(QtCore.QThread):
""" Listener for REST requests.
It is possible to register callbacks for url paths.
Be careful about crossreferencing to different Threads it is not allowed.
"""
def __init__(self, module, port):
super(RestApiThread, self).__init__()
self.is_running = False
self.module = module
self.port = port
def run(self):
self.is_running = True
try:
log.debug(
"Running Rest Api server on URL:"
" \"http://localhost:{}\"".format(self.port)
)
with socketserver.TCPServer(("", self.port), Handler) as httpd:
while self.is_running:
httpd.handle_request()
except Exception:
log.warning(
"Rest Api Server service has failed", exc_info=True
)
self.is_running = False
self.module.thread_stopped()

View file

@ -1,5 +0,0 @@
from .statics_server import StaticsServer
def tray_init(tray_widget, main_widget):
return StaticsServer()

View file

@ -1,202 +0,0 @@
import os
import sys
import datetime
import socket
import http.server
from http import HTTPStatus
import urllib
import posixpath
import socketserver
from Qt import QtCore
from pypeapp import config, Logger
DIRECTORY = os.path.sep.join([os.environ['PYPE_MODULE_ROOT'], 'res'])
class Handler(http.server.SimpleHTTPRequestHandler):
def __init__(self, *args, **kwargs):
py_version = sys.version.split('.')
# If python version is 3.7 or higher
if int(py_version[0]) >= 3 and int(py_version[1]) >= 7:
super().__init__(*args, directory=DIRECTORY, **kwargs)
else:
self.directory = DIRECTORY
super().__init__(*args, **kwargs)
def send_head(self):
"""Common code for GET and HEAD commands.
This sends the response code and MIME headers.
Return value is either a file object (which has to be copied
to the outputfile by the caller unless the command was HEAD,
and must be closed by the caller under all circumstances), or
None, in which case the caller has nothing further to do.
"""
path = self.translate_path(self.path)
f = None
if os.path.isdir(path):
parts = urllib.parse.urlsplit(self.path)
if not parts.path.endswith('/'):
# redirect browser - doing basically what apache does
self.send_response(HTTPStatus.MOVED_PERMANENTLY)
new_parts = (parts[0], parts[1], parts[2] + '/',
parts[3], parts[4])
new_url = urllib.parse.urlunsplit(new_parts)
self.send_header("Location", new_url)
self.end_headers()
return None
for index in "index.html", "index.htm":
index = os.path.join(path, index)
if os.path.exists(index):
path = index
break
else:
return self.list_directory(path)
ctype = self.guess_type(path)
try:
f = open(path, 'rb')
except OSError:
self.send_error(HTTPStatus.NOT_FOUND, "File not found")
return None
try:
fs = os.fstat(f.fileno())
# Use browser cache if possible
if ("If-Modified-Since" in self.headers
and "If-None-Match" not in self.headers):
# compare If-Modified-Since and time of last file modification
try:
ims = http.server.email.utils.parsedate_to_datetime(
self.headers["If-Modified-Since"])
except (TypeError, IndexError, OverflowError, ValueError):
# ignore ill-formed values
pass
else:
if ims.tzinfo is None:
# obsolete format with no timezone, cf.
# https://tools.ietf.org/html/rfc7231#section-7.1.1.1
ims = ims.replace(tzinfo=datetime.timezone.utc)
if ims.tzinfo is datetime.timezone.utc:
# compare to UTC datetime of last modification
last_modif = datetime.datetime.fromtimestamp(
fs.st_mtime, datetime.timezone.utc)
# remove microseconds, like in If-Modified-Since
last_modif = last_modif.replace(microsecond=0)
if last_modif <= ims:
self.send_response(HTTPStatus.NOT_MODIFIED)
self.end_headers()
f.close()
return None
self.send_response(HTTPStatus.OK)
self.send_header("Content-type", ctype)
self.send_header("Content-Length", str(fs[6]))
self.send_header("Last-Modified",
self.date_time_string(fs.st_mtime))
self.end_headers()
return f
except:
f.close()
raise
def translate_path(self, path):
"""Translate a /-separated PATH to the local filename syntax.
Components that mean special things to the local file system
(e.g. drive or directory names) are ignored. (XXX They should
probably be diagnosed.)
"""
# abandon query parameters
path = path.split('?',1)[0]
path = path.split('#',1)[0]
# Don't forget explicit trailing slash when normalizing. Issue17324
trailing_slash = path.rstrip().endswith('/')
try:
path = urllib.parse.unquote(path, errors='surrogatepass')
except UnicodeDecodeError:
path = urllib.parse.unquote(path)
path = posixpath.normpath(path)
words = path.split('/')
words = filter(None, words)
path = self.directory
for word in words:
if os.path.dirname(word) or word in (os.curdir, os.pardir):
# Ignore components that are not a simple file/directory name
continue
path = os.path.join(path, word)
if trailing_slash:
path += '/'
return path
class StaticsServer(QtCore.QThread):
""" Measure user's idle time in seconds.
Idle time resets on keyboard/mouse input.
Is able to emit signals at specific time idle.
"""
def __init__(self):
super(StaticsServer, self).__init__()
self.qaction = None
self.failed_icon = None
self._is_running = False
self.log = Logger().get_logger(self.__class__.__name__)
try:
self.presets = config.get_presets().get(
'services', {}).get('statics_server')
except Exception:
self.presets = {'default_port': 8010, 'exclude_ports': []}
self.port = self.find_port()
def set_qaction(self, qaction, failed_icon):
self.qaction = qaction
self.failed_icon = failed_icon
def tray_start(self):
self.start()
@property
def is_running(self):
return self._is_running
def stop(self):
self._is_running = False
def run(self):
self._is_running = True
try:
with socketserver.TCPServer(("", self.port), Handler) as httpd:
while self._is_running:
httpd.handle_request()
except Exception:
self.log.warning(
'Statics Server service has failed', exc_info=True
)
self._is_running = False
if self.qaction and self.failed_icon:
self.qaction.setIcon(self.failed_icon)
def find_port(self):
start_port = self.presets['default_port']
exclude_ports = self.presets['exclude_ports']
found_port = None
# port check takes time so it's lowered to 100 ports
for port in range(start_port, start_port+100):
if port in exclude_ports:
continue
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
result = sock.connect_ex(('localhost', port))
if result != 0:
found_port = port
if found_port is not None:
break
if found_port is None:
return None
os.environ['PYPE_STATICS_SERVER'] = 'http://localhost:{}'.format(found_port)
return found_port