mirror of
https://github.com/ynput/ayon-core.git
synced 2025-12-24 21:04:40 +01:00
initial commit of rest api module with partially working rest api handling
This commit is contained in:
parent
262c186ba8
commit
1298bf73bf
8 changed files with 984 additions and 230 deletions
|
|
@ -1,4 +1,15 @@
|
|||
from .rest_api import RestApiServer
|
||||
from .base_class import RestApi, abort, route, register_statics
|
||||
from .lib import (
|
||||
RestMethods,
|
||||
UrlData,
|
||||
RequestData,
|
||||
Query,
|
||||
Fragment,
|
||||
Params,
|
||||
Handler,
|
||||
CallbackResult
|
||||
)
|
||||
|
||||
|
||||
def tray_init(tray_widget, main_widget):
|
||||
|
|
|
|||
68
pype/services/rest_api/base_class.py
Normal file
68
pype/services/rest_api/base_class.py
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
from functools import wraps
|
||||
from http import HTTPStatus
|
||||
|
||||
from .lib import (
|
||||
RestApiFactory, Splitter,
|
||||
ObjAlreadyExist, AbortException,
|
||||
Params, UrlData, RequestData, Query, Fragment, Handler
|
||||
)
|
||||
|
||||
|
||||
def route(path, url_prefix="", methods=[]):
|
||||
def decorator(callback):
|
||||
@wraps(callback)
|
||||
def wrapper(*args, **kwargs):
|
||||
return callback(*args, **kwargs)
|
||||
|
||||
func = wrapper
|
||||
func.restapi = True
|
||||
func.path = path
|
||||
func.methods = methods
|
||||
func.url_prefix = url_prefix
|
||||
if hasattr(callback, "__self__"):
|
||||
func.__self__ = callback.__self__
|
||||
func.callback = callback
|
||||
|
||||
RestApiFactory.register_route(func)
|
||||
return func
|
||||
return decorator
|
||||
|
||||
|
||||
def register_statics(url_prefix, dir_path):
|
||||
RestApiFactory.register_statics((url_prefix, dir_path))
|
||||
|
||||
|
||||
def abort(status_code=HTTPStatus.NOT_FOUND, message=None):
|
||||
items = []
|
||||
items.append(str(status_code))
|
||||
if not message:
|
||||
message = ""
|
||||
|
||||
items.append(message)
|
||||
|
||||
raise AbortException(Splitter.join(items))
|
||||
|
||||
|
||||
class RestApi:
|
||||
def route(path, url_prefix="", methods=[]):
|
||||
return route(path, url_prefix, methods)
|
||||
|
||||
@classmethod
|
||||
def register_route(cls, callback, path, url_prefix="", methods=[]):
|
||||
return route(path, methods, url_prefix)(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
|
||||
19
pype/services/rest_api/lib/__init__.py
Normal file
19
pype/services/rest_api/lib/__init__.py
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
Splitter = "__splitter__"
|
||||
|
||||
from .exceptions import ObjAlreadyExist, AbortException
|
||||
from .lib import (
|
||||
RestMethods,
|
||||
CustomNone,
|
||||
UrlData,
|
||||
RequestData,
|
||||
Query,
|
||||
Fragment,
|
||||
Params,
|
||||
CallbackResult
|
||||
)
|
||||
|
||||
from .factory import _RestApiFactory
|
||||
|
||||
RestApiFactory = _RestApiFactory()
|
||||
|
||||
from .handler import Handler
|
||||
10
pype/services/rest_api/lib/exceptions.py
Normal file
10
pype/services/rest_api/lib/exceptions.py
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
class ObjAlreadyExist(Exception):
|
||||
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
|
||||
266
pype/services/rest_api/lib/factory.py
Normal file
266
pype/services/rest_api/lib/factory.py
Normal file
|
|
@ -0,0 +1,266 @@
|
|||
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):
|
||||
if path and prefix:
|
||||
fullpath = "{}/{}".format(prefix, path).replace("//", "/")
|
||||
elif path:
|
||||
fullpath = path
|
||||
elif prefix:
|
||||
fullpath = prefix
|
||||
else:
|
||||
fullpath = "/"
|
||||
|
||||
if not fullpath.startswith("/"):
|
||||
fullpath = "/{}".format(fullpath)
|
||||
|
||||
return fullpath
|
||||
|
||||
|
||||
def prepare_regex_from_path(full_path):
|
||||
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 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):
|
||||
if url_prefix is None:
|
||||
url_prefix = ""
|
||||
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
|
||||
|
||||
if not url_prefix.startswith("/"):
|
||||
url_prefix = "/{}".format(url_prefix)
|
||||
|
||||
return url_prefix
|
||||
|
||||
|
||||
def prepare_methods(methods, callback=None):
|
||||
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):
|
||||
callback = _callback.callback
|
||||
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:
|
||||
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, callback):
|
||||
return self.unprocessed_routes.pop(
|
||||
self.unprocessed_routes.index(callback)
|
||||
)
|
||||
|
||||
def _process_statics(self, item):
|
||||
return self.unprocessed_statics.pop(
|
||||
self.unprocessed_statics.index(item)
|
||||
)
|
||||
|
||||
def register_route(self, item):
|
||||
log.debug("Registering callback for item \"{}\"".format(
|
||||
item.__qualname__
|
||||
))
|
||||
self.unprocessed_routes.append(item)
|
||||
|
||||
def register_obj(self, obj):
|
||||
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, callback):
|
||||
methods = prepare_methods(callback.methods, callback)
|
||||
url_prefix = prepare_prefix(callback.url_prefix)
|
||||
fullpath = prepare_fullpath(callback.path, url_prefix)
|
||||
regex, regex_keys = prepare_regex_from_path(fullpath)
|
||||
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):
|
||||
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)
|
||||
|
||||
for callback in self.unprocessed_routes:
|
||||
if not (
|
||||
callback.__qualname__ == method.__qualname__ and
|
||||
callback.__module__ == method.__module__ and
|
||||
callback.__globals__["__file__"] == method.__globals__["__file__"]
|
||||
):
|
||||
continue
|
||||
|
||||
self._process_route(callback)
|
||||
|
||||
if not hasattr(method, "restapi"):
|
||||
continue
|
||||
|
||||
if not method.restapi:
|
||||
continue
|
||||
|
||||
self._prepare_route(method)
|
||||
break
|
||||
|
||||
for callback in self.unprocessed_routes:
|
||||
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(callback)
|
||||
continue
|
||||
|
||||
self._prepare_route(callback)
|
||||
399
pype/services/rest_api/lib/handler.py
Normal file
399
pype/services/rest_api/lib/handler.py
Normal file
|
|
@ -0,0 +1,399 @@
|
|||
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,
|
||||
UrlData, RequestData, Query, Fragment, Params
|
||||
)
|
||||
from .exceptions import AbortException
|
||||
from . import RestApiFactory, CustomNone, Splitter
|
||||
|
||||
from pypeapp import Logger
|
||||
|
||||
log = Logger().get_logger("RestApiHandler")
|
||||
NotSet = CustomNone("NotSet")
|
||||
|
||||
|
||||
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):
|
||||
"""Because processing is technically the same for now so it is used
|
||||
the same way
|
||||
"""
|
||||
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)
|
||||
|
||||
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):
|
||||
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):
|
||||
|
||||
regex = item["regex"]
|
||||
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)
|
||||
|
||||
url_data = UrlData(url_data)
|
||||
request_data = RequestData(in_data)
|
||||
query = Query(parsed_url.query)
|
||||
params = Params(parsed_url.params)
|
||||
fragment = Fragment(parsed_url.fragment)
|
||||
|
||||
callback = item["callback"]
|
||||
callback_info = item["callback_info"]
|
||||
|
||||
_args = callback_info["args"]
|
||||
_args_len = callback_info["args_len"]
|
||||
_defaults = callback_info["defaults"]
|
||||
_defaults_len = callback_info["defaults_len"]
|
||||
_has_args = callback_info["hasargs"]
|
||||
_has_kwargs = callback_info["haskwargs"]
|
||||
|
||||
_annotations = callback_info["annotations"]
|
||||
|
||||
arg_index = 0
|
||||
if type(callback).__name__ == "method":
|
||||
arg_index = 1
|
||||
|
||||
_kwargs = {arg: NotSet for arg in _args[arg_index:]}
|
||||
_available_kwargs = {
|
||||
"url_data": url_data,
|
||||
"request_data": request_data,
|
||||
"query": query,
|
||||
"params": params,
|
||||
"fragment": fragment,
|
||||
"handler": self
|
||||
}
|
||||
if not regex:
|
||||
_available_kwargs.pop("url_data")
|
||||
|
||||
available_len = len(_available_kwargs)
|
||||
if available_len < (_args_len - _defaults_len):
|
||||
raise Exception((
|
||||
"Callback expects {} required positional arguments but {} are"
|
||||
" available {}<{}>"
|
||||
).format(
|
||||
_args_len, available_len, callback.__qualname__,
|
||||
callback.__globals__.get("__file__", "unknown file")
|
||||
))
|
||||
elif available_len < _args_len:
|
||||
log.warning((
|
||||
"Handler \"{}\" will never fill all args of callback {}<{}>"
|
||||
).format(
|
||||
self.__class__.__name__,
|
||||
callback.__qualname__,
|
||||
callback.__globals__.get("__file__", "unknown file")
|
||||
))
|
||||
|
||||
if _args_len == 0:
|
||||
if _has_args:
|
||||
return callback(*_available_kwargs.values())
|
||||
if _has_kwargs:
|
||||
return callback(**_available_kwargs)
|
||||
else:
|
||||
return callback()
|
||||
|
||||
if _annotations:
|
||||
for arg, argtype in _annotations.items():
|
||||
if argtype == Query:
|
||||
key = "query"
|
||||
elif argtype == Params:
|
||||
key = "params"
|
||||
elif argtype == Fragment:
|
||||
key = "fragment"
|
||||
elif argtype == Handler:
|
||||
key = "handler"
|
||||
elif argtype == UrlData:
|
||||
key = "url_data"
|
||||
elif argtype == RequestData:
|
||||
key = "request_data"
|
||||
else:
|
||||
continue
|
||||
|
||||
_kwargs[arg] = _available_kwargs[key]
|
||||
_available_kwargs[key] = NotSet
|
||||
|
||||
for key1, value1 in _kwargs.items():
|
||||
if value1 is not NotSet:
|
||||
continue
|
||||
has_values = False
|
||||
for key2, value2 in _available_kwargs.items():
|
||||
if value2 is NotSet:
|
||||
continue
|
||||
has_values = True
|
||||
_kwargs[key1] = value2
|
||||
_available_kwargs[key2] = NotSet
|
||||
break
|
||||
|
||||
if not has_values:
|
||||
break
|
||||
|
||||
_args = []
|
||||
kw_keys = [key for key in _kwargs.keys()]
|
||||
for key in kw_keys:
|
||||
value = _kwargs.pop(key)
|
||||
if value is not NotSet:
|
||||
_args.append(value)
|
||||
continue
|
||||
|
||||
if _has_args:
|
||||
for key, value in _available_kwargs.items():
|
||||
if value is NotSet:
|
||||
continue
|
||||
_args.append(value)
|
||||
|
||||
elif _has_kwargs:
|
||||
for key, value in _available_kwargs.items():
|
||||
if value is NotSet:
|
||||
continue
|
||||
_kwargs[key] = value
|
||||
|
||||
return callback(*_args, **_kwargs)
|
||||
|
||||
def _handle_statics(self, dirpath, path):
|
||||
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()
|
||||
140
pype/services/rest_api/lib/lib.py
Normal file
140
pype/services/rest_api/lib/lib.py
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
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:
|
||||
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):
|
||||
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):
|
||||
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 CallbackResult:
|
||||
_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()
|
||||
|
|
@ -1,260 +1,51 @@
|
|||
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"
|
||||
|
||||
def __repr__(self):
|
||||
return str(self.value)
|
||||
|
||||
def __eq__(self, other):
|
||||
if isinstance(other, str):
|
||||
return self.value == other
|
||||
return self == other
|
||||
|
||||
def __hash__(self):
|
||||
return enum.Enum.__hash__(self)
|
||||
|
||||
def __str__(self):
|
||||
return str(self.value)
|
||||
|
||||
|
||||
class Handler(http.server.SimpleHTTPRequestHandler):
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
class RestApiServer:
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
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]
|
||||
)
|
||||
def register_callback(self, path, callback, url_prefix="", methods=[]):
|
||||
route(path, url_prefix, methods)(callback)
|
||||
|
||||
if isinstance(rest_method, str):
|
||||
rest_method = str(rest_method).upper()
|
||||
def register_statics(self, url_prefix, dir_path):
|
||||
register_statics(url_prefix, dir_path)
|
||||
|
||||
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)
|
||||
)
|
||||
self.registered_callbacks[rest_method][path] = callback
|
||||
|
||||
return True
|
||||
|
||||
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 +67,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 QThreads 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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue