mirror of
https://github.com/ynput/ayon-core.git
synced 2025-12-24 21:04:40 +01:00
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:
commit
d15c57b57f
14 changed files with 1315 additions and 441 deletions
|
|
@ -14,6 +14,11 @@ class AvalonApps:
|
||||||
self.parent = parent
|
self.parent = parent
|
||||||
self.app_launcher = None
|
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
|
# Definition of Tray menu
|
||||||
def tray_menu(self, parent_menu=None):
|
def tray_menu(self, parent_menu=None):
|
||||||
# Actions
|
# Actions
|
||||||
|
|
|
||||||
86
pype/avalon_apps/rest_api.py
Normal file
86
pype/avalon_apps/rest_api.py
Normal 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)
|
||||||
|
|
@ -22,7 +22,12 @@ import pymongo
|
||||||
from pymongo.client_session import ClientSession
|
from pymongo.client_session import ClientSession
|
||||||
|
|
||||||
class NotActiveTable(Exception):
|
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):
|
def auto_reconnect(func):
|
||||||
"""Handling auto reconnect in 3 retry times"""
|
"""Handling auto reconnect in 3 retry times"""
|
||||||
|
|
@ -37,7 +42,16 @@ def auto_reconnect(func):
|
||||||
time.sleep(0.1)
|
time.sleep(0.1)
|
||||||
else:
|
else:
|
||||||
raise
|
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
|
return decorated
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -53,7 +67,6 @@ def check_active_table(func):
|
||||||
|
|
||||||
|
|
||||||
class DbConnector:
|
class DbConnector:
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
timeout = 1000
|
timeout = 1000
|
||||||
|
|
||||||
|
|
@ -68,10 +81,18 @@ class DbConnector:
|
||||||
|
|
||||||
self.active_table = table_name
|
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):
|
def __getattribute__(self, attr):
|
||||||
|
# not all methods of PyMongo database are implemented with this it is
|
||||||
|
# possible to use them too
|
||||||
try:
|
try:
|
||||||
return super().__getattribute__(attr)
|
return super(DbConnector, self).__getattribute__(attr)
|
||||||
except AttributeError:
|
except AttributeError:
|
||||||
|
if self.active_table is None:
|
||||||
|
raise NotActiveTable()
|
||||||
return self._database[self.active_table].__getattribute__(attr)
|
return self._database[self.active_table].__getattribute__(attr)
|
||||||
|
|
||||||
def install(self):
|
def install(self):
|
||||||
|
|
@ -131,6 +152,15 @@ class DbConnector:
|
||||||
def exist_table(self, table_name):
|
def exist_table(self, table_name):
|
||||||
return table_name in self.tables()
|
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):
|
def tables(self):
|
||||||
"""List available tables
|
"""List available tables
|
||||||
Returns:
|
Returns:
|
||||||
|
|
@ -166,18 +196,21 @@ class DbConnector:
|
||||||
@check_active_table
|
@check_active_table
|
||||||
@auto_reconnect
|
@auto_reconnect
|
||||||
def find(self, filter, projection=None, sort=None, **options):
|
def find(self, filter, projection=None, sort=None, **options):
|
||||||
options["projection"] = projection
|
|
||||||
options["sort"] = sort
|
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
|
@check_active_table
|
||||||
@auto_reconnect
|
@auto_reconnect
|
||||||
def find_one(self, filter, projection=None, sort=None, **options):
|
def find_one(self, filter, projection=None, sort=None, **options):
|
||||||
assert isinstance(filter, dict), "filter must be <dict>"
|
assert isinstance(filter, dict), "filter must be <dict>"
|
||||||
|
|
||||||
options["projection"] = projection
|
|
||||||
options["sort"] = sort
|
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
|
@check_active_table
|
||||||
@auto_reconnect
|
@auto_reconnect
|
||||||
|
|
@ -202,8 +235,8 @@ class DbConnector:
|
||||||
|
|
||||||
@check_active_table
|
@check_active_table
|
||||||
@auto_reconnect
|
@auto_reconnect
|
||||||
def distinct(self, *args, **kwargs):
|
def distinct(self, **options):
|
||||||
return self._database[self.active_table].distinct(*args, **kwargs)
|
return self._database[self.active_table].distinct(**options)
|
||||||
|
|
||||||
@check_active_table
|
@check_active_table
|
||||||
@auto_reconnect
|
@auto_reconnect
|
||||||
|
|
@ -216,10 +249,14 @@ class DbConnector:
|
||||||
@auto_reconnect
|
@auto_reconnect
|
||||||
def delete_one(self, filter, collation=None, **options):
|
def delete_one(self, filter, collation=None, **options):
|
||||||
options["collation"] = collation
|
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
|
@check_active_table
|
||||||
@auto_reconnect
|
@auto_reconnect
|
||||||
def delete_many(self, filter, collation=None, **options):
|
def delete_many(self, filter, collation=None, **options):
|
||||||
options["collation"] = collation
|
options["collation"] = collation
|
||||||
return self._database[self.active_table].delete_many(filter, **options)
|
return self._database[self.active_table].delete_many(
|
||||||
|
filter, **options
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -43,8 +43,10 @@ class MusterModule:
|
||||||
self.aShowLogin.trigger()
|
self.aShowLogin.trigger()
|
||||||
|
|
||||||
if "RestApiServer" in modules:
|
if "RestApiServer" in modules:
|
||||||
|
def api_show_login():
|
||||||
|
self.aShowLogin.trigger()
|
||||||
modules["RestApiServer"].register_callback(
|
modules["RestApiServer"].register_callback(
|
||||||
"muster/show_login", api_callback, "post"
|
"/show_login", api_show_login, "muster", "post"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Definition of Tray menu
|
# Definition of Tray menu
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
from .rest_api import RestApiServer
|
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):
|
def tray_init(tray_widget, main_widget):
|
||||||
|
|
|
||||||
114
pype/services/rest_api/base_class.py
Normal file
114
pype/services/rest_api/base_class.py
Normal 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
|
||||||
9
pype/services/rest_api/lib/__init__.py
Normal file
9
pype/services/rest_api/lib/__init__.py
Normal 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
|
||||||
11
pype/services/rest_api/lib/exceptions.py
Normal file
11
pype/services/rest_api/lib/exceptions.py
Normal 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
|
||||||
353
pype/services/rest_api/lib/factory.py
Normal file
353
pype/services/rest_api/lib/factory.py
Normal 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)
|
||||||
338
pype/services/rest_api/lib/handler.py
Normal file
338
pype/services/rest_api/lib/handler.py
Normal 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()
|
||||||
207
pype/services/rest_api/lib/lib.py
Normal file
207
pype/services/rest_api/lib/lib.py
Normal 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()
|
||||||
|
|
@ -1,260 +1,127 @@
|
||||||
import os
|
import os
|
||||||
import json
|
import re
|
||||||
import enum
|
|
||||||
import collections
|
import collections
|
||||||
import threading
|
import threading
|
||||||
from inspect import signature
|
|
||||||
import socket
|
import socket
|
||||||
import http.server
|
|
||||||
from http import HTTPStatus
|
|
||||||
import socketserver
|
import socketserver
|
||||||
|
|
||||||
from Qt import QtCore
|
from Qt import QtCore
|
||||||
|
|
||||||
|
from .lib import RestApiFactory, Handler
|
||||||
|
from .base_class import route, register_statics
|
||||||
from pypeapp import config, Logger
|
from pypeapp import config, Logger
|
||||||
|
|
||||||
log = Logger().get_logger("RestApiServer")
|
log = Logger().get_logger("RestApiServer")
|
||||||
|
|
||||||
|
|
||||||
class RestMethods(enum.Enum):
|
class RestApiServer:
|
||||||
GET = "GET"
|
"""Rest Api allows to access statics or callbacks with http requests.
|
||||||
POST = "POST"
|
|
||||||
PUT = "PUT"
|
|
||||||
PATCH = "PATCH"
|
|
||||||
DELETE = "DELETE"
|
|
||||||
|
|
||||||
def __repr__(self):
|
To register statics use `register_statics`.
|
||||||
return str(self.value)
|
|
||||||
|
|
||||||
def __eq__(self, other):
|
To register callback use `register_callback` method or use `route` decorator.
|
||||||
if isinstance(other, str):
|
`route` decorator should be used with not-class functions, it is possible
|
||||||
return self.value == other
|
to use within class when inherits `RestApi` (defined in `base_class.py`)
|
||||||
return self == other
|
or created object, with used decorator, is registered with `register_obj`.
|
||||||
|
|
||||||
def __hash__(self):
|
.. code-block:: python
|
||||||
return enum.Enum.__hash__(self)
|
@route("/username", url_prefix="/api", methods=["get"], strict_match=False)
|
||||||
|
def get_username():
|
||||||
|
return {"username": getpass.getuser()}
|
||||||
|
|
||||||
def __str__(self):
|
In that case response to `localhost:{port}/api/username` will be status
|
||||||
return str(self.value)
|
`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):
|
all_projects = {
|
||||||
self.process_request(RestMethods.GET)
|
"Proj1": {"proj_data": []},
|
||||||
|
"Proj2": {"proj_data": []},
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@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.qaction = None
|
||||||
self.failed_icon = None
|
self.failed_icon = None
|
||||||
self._is_running = False
|
self._is_running = False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self.presets = config.get_presets().get(
|
self.presets = config.get_presets()["services"]["rest_api"]
|
||||||
"services", {}).get(
|
|
||||||
"rest_api", {}
|
|
||||||
)
|
|
||||||
except Exception:
|
except Exception:
|
||||||
self.presets = {"default_port": 8011, "exclude_ports": []}
|
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):
|
def set_qaction(self, qaction, failed_icon):
|
||||||
self.qaction = qaction
|
self.qaction = qaction
|
||||||
self.failed_icon = failed_icon
|
self.failed_icon = failed_icon
|
||||||
|
|
||||||
def register_callback(self, path, callback, rest_method=RestMethods.POST):
|
def register_callback(
|
||||||
if isinstance(path, (list, set)):
|
self, path, callback, url_prefix="", methods=[], strict_match=False
|
||||||
path = "/".join([part.lower() for part in path])
|
):
|
||||||
elif isinstance(path, str):
|
RestApiFactory.register_route(
|
||||||
path = "/".join(
|
path, callback, url_prefix, methods, strict_match
|
||||||
[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)
|
|
||||||
)
|
)
|
||||||
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):
|
def register_obj(self, obj):
|
||||||
self.start()
|
RestApiFactory.register_obj(obj)
|
||||||
|
|
||||||
@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 find_port(self):
|
def find_port(self):
|
||||||
start_port = self.presets["default_port"]
|
start_port = self.presets["default_port"]
|
||||||
|
|
@ -276,3 +143,53 @@ class RestApiServer(QtCore.QThread):
|
||||||
found_port
|
found_port
|
||||||
)
|
)
|
||||||
return 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()
|
||||||
|
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
from .statics_server import StaticsServer
|
|
||||||
|
|
||||||
|
|
||||||
def tray_init(tray_widget, main_widget):
|
|
||||||
return StaticsServer()
|
|
||||||
|
|
@ -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
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue