365 lines
13 KiB
Python
365 lines
13 KiB
Python
# Klipper Web Server Rest API
|
|
#
|
|
# Copyright (C) 2020 Eric Callahan <arksine.code@gmail.com>
|
|
#
|
|
# This file may be distributed under the terms of the GNU GPLv3 license
|
|
|
|
import os
|
|
import mimetypes
|
|
import logging
|
|
import tornado
|
|
from inspect import isclass
|
|
from tornado.escape import url_unescape
|
|
from tornado.routing import Rule, PathMatches, AnyMatches
|
|
from utils import ServerError
|
|
from websockets import WebRequest, WebsocketManager, WebSocket
|
|
from authorization import AuthorizedRequestHandler, AuthorizedFileHandler
|
|
from authorization import Authorization
|
|
|
|
# These endpoints are reserved for klippy/server communication only and are
|
|
# not exposed via http or the websocket
|
|
RESERVED_ENDPOINTS = [
|
|
"list_endpoints", "gcode/subscribe_output",
|
|
"register_remote_method"
|
|
]
|
|
|
|
EXCLUDED_ARGS = ["_", "token", "connection_id"]
|
|
DEFAULT_KLIPPY_LOG_PATH = "/tmp/klippy.log"
|
|
|
|
# Status objects require special parsing
|
|
def _status_parser(request_handler):
|
|
request = request_handler.request
|
|
arg_list = request.arguments.keys()
|
|
args = {}
|
|
for key in arg_list:
|
|
if key in EXCLUDED_ARGS:
|
|
continue
|
|
val = request_handler.get_argument(key)
|
|
if not val:
|
|
args[key] = None
|
|
else:
|
|
args[key] = val.split(',')
|
|
logging.debug(f"Parsed Arguments: {args}")
|
|
return {'objects': args}
|
|
|
|
# Built-in Query String Parser
|
|
def _default_parser(request_handler):
|
|
request = request_handler.request
|
|
arg_list = request.arguments.keys()
|
|
args = {}
|
|
for key in arg_list:
|
|
if key in EXCLUDED_ARGS:
|
|
continue
|
|
args[key] = request_handler.get_argument(key)
|
|
return args
|
|
|
|
class MutableRouter(tornado.web.ReversibleRuleRouter):
|
|
def __init__(self, application):
|
|
self.application = application
|
|
self.pattern_to_rule = {}
|
|
super(MutableRouter, self).__init__(None)
|
|
|
|
def get_target_delegate(self, target, request, **target_params):
|
|
if isclass(target) and issubclass(target, tornado.web.RequestHandler):
|
|
return self.application.get_handler_delegate(
|
|
request, target, **target_params)
|
|
|
|
return super(MutableRouter, self).get_target_delegate(
|
|
target, request, **target_params)
|
|
|
|
def has_rule(self, pattern):
|
|
return pattern in self.pattern_to_rule
|
|
|
|
def add_handler(self, pattern, target, target_params):
|
|
if pattern in self.pattern_to_rule:
|
|
self.remove_handler(pattern)
|
|
new_rule = Rule(PathMatches(pattern), target, target_params)
|
|
self.pattern_to_rule[pattern] = new_rule
|
|
self.rules.append(new_rule)
|
|
|
|
def remove_handler(self, pattern):
|
|
rule = self.pattern_to_rule.pop(pattern, None)
|
|
if rule is not None:
|
|
try:
|
|
self.rules.remove(rule)
|
|
except Exception:
|
|
logging.exception(f"Unable to remove rule: {pattern}")
|
|
|
|
class APIDefinition:
|
|
def __init__(self, endpoint, http_uri, ws_methods,
|
|
request_methods, parser):
|
|
self.endpoint = endpoint
|
|
self.uri = http_uri
|
|
self.ws_methods = ws_methods
|
|
if not isinstance(request_methods, list):
|
|
request_methods = [request_methods]
|
|
self.request_methods = request_methods
|
|
self.parser = parser
|
|
|
|
class MoonrakerApp:
|
|
def __init__(self, config):
|
|
self.server = config.get_server()
|
|
self.tornado_server = None
|
|
self.api_cache = {}
|
|
self.registered_base_handlers = []
|
|
self.max_upload_size = config.getint('max_upload_size', 200)
|
|
self.max_upload_size *= 1024 * 1024
|
|
|
|
# Set Up Websocket and Authorization Managers
|
|
self.wsm = WebsocketManager(self.server)
|
|
self.auth = Authorization(config['authorization'])
|
|
|
|
mimetypes.add_type('text/plain', '.log')
|
|
mimetypes.add_type('text/plain', '.gcode')
|
|
mimetypes.add_type('text/plain', '.cfg')
|
|
debug = config.getboolean('enable_debug_logging', True)
|
|
|
|
# Set up HTTP only requests
|
|
self.mutable_router = MutableRouter(self)
|
|
app_handlers = [
|
|
(AnyMatches(), self.mutable_router),
|
|
(r"/websocket", WebSocket),
|
|
(r"/api/version", EmulateOctoprintHandler)]
|
|
|
|
self.app = tornado.web.Application(
|
|
app_handlers,
|
|
serve_traceback=debug,
|
|
websocket_ping_interval=10,
|
|
websocket_ping_timeout=30,
|
|
parent=self)
|
|
self.get_handler_delegate = self.app.get_handler_delegate
|
|
|
|
# Register handlers
|
|
logfile = config['system_args'].get('logfile')
|
|
if logfile:
|
|
self.register_static_file_handler("moonraker.log", logfile)
|
|
self.register_static_file_handler(
|
|
"klippy.log", DEFAULT_KLIPPY_LOG_PATH)
|
|
self.auth.register_handlers(self)
|
|
|
|
def listen(self, host, port):
|
|
self.tornado_server = self.app.listen(
|
|
port, address=host, max_body_size=self.max_upload_size,
|
|
xheaders=True)
|
|
|
|
def get_server(self):
|
|
return self.server
|
|
|
|
def get_auth(self):
|
|
return self.auth
|
|
|
|
def get_websocket_manager(self):
|
|
return self.wsm
|
|
|
|
async def close(self):
|
|
if self.tornado_server is not None:
|
|
self.tornado_server.stop()
|
|
await self.tornado_server.close_all_connections()
|
|
await self.wsm.close()
|
|
self.auth.close()
|
|
|
|
def register_remote_handler(self, endpoint):
|
|
if endpoint in RESERVED_ENDPOINTS:
|
|
return
|
|
api_def = self._create_api_definition(endpoint)
|
|
if api_def.uri in self.registered_base_handlers:
|
|
# reserved handler or already registered
|
|
return
|
|
logging.info(
|
|
f"Registering remote endpoint - "
|
|
f"HTTP: ({' '.join(api_def.request_methods)}) {api_def.uri}; "
|
|
f"Websocket: {', '.join(api_def.ws_methods)}")
|
|
self.wsm.register_remote_handler(api_def)
|
|
params = {}
|
|
params['arg_parser'] = api_def.parser
|
|
params['remote_callback'] = api_def.endpoint
|
|
self.mutable_router.add_handler(
|
|
api_def.uri, RemoteRequestHandler, params)
|
|
self.registered_base_handlers.append(api_def.uri)
|
|
|
|
def register_local_handler(self, uri, request_methods,
|
|
callback, protocol=["http", "websocket"]):
|
|
if uri in self.registered_base_handlers:
|
|
return
|
|
api_def = self._create_api_definition(
|
|
uri, request_methods, is_remote=False)
|
|
msg = "Registering local endpoint"
|
|
if "http" in protocol:
|
|
msg += f" - HTTP: ({' '.join(request_methods)}) {uri}"
|
|
params = {}
|
|
params['methods'] = request_methods
|
|
params['arg_parser'] = api_def.parser
|
|
params['callback'] = callback
|
|
self.mutable_router.add_handler(uri, LocalRequestHandler, params)
|
|
self.registered_base_handlers.append(uri)
|
|
if "websocket" in protocol:
|
|
msg += f" - Websocket: {', '.join(api_def.ws_methods)}"
|
|
self.wsm.register_local_handler(api_def, callback)
|
|
logging.info(msg)
|
|
|
|
def register_static_file_handler(self, pattern, file_path):
|
|
if pattern[0] != "/":
|
|
pattern = "/server/files/" + pattern
|
|
if os.path.isfile(file_path):
|
|
pattern += '()'
|
|
elif os.path.isdir(file_path):
|
|
if pattern[-1] != "/":
|
|
pattern += "/"
|
|
pattern += "(.*)"
|
|
else:
|
|
logging.info(f"Invalid file path: {file_path}")
|
|
return
|
|
logging.debug(f"Registering static file: ({pattern}) {file_path}")
|
|
params = {'path': file_path}
|
|
self.mutable_router.add_handler(pattern, FileRequestHandler, params)
|
|
|
|
def register_upload_handler(self, pattern):
|
|
self.mutable_router.add_handler(pattern, FileUploadHandler, {})
|
|
|
|
def remove_handler(self, endpoint):
|
|
api_def = self.api_cache.get(endpoint)
|
|
if api_def is not None:
|
|
self.wsm.remove_handler(api_def.uri)
|
|
self.mutable_router.remove_handler(api_def.ws_method)
|
|
|
|
def _create_api_definition(self, endpoint, request_methods=[],
|
|
is_remote=True):
|
|
if endpoint in self.api_cache:
|
|
return self.api_cache[endpoint]
|
|
if endpoint[0] == '/':
|
|
uri = endpoint
|
|
elif is_remote:
|
|
uri = "/printer/" + endpoint
|
|
else:
|
|
uri = "/server/" + endpoint
|
|
ws_methods = []
|
|
if is_remote:
|
|
# Remote requests accept both GET and POST requests. These
|
|
# requests execute the same callback, thus they resolve to
|
|
# only a single websocket method.
|
|
ws_methods.append(uri[1:].replace('/', '.'))
|
|
request_methods = ['GET', 'POST']
|
|
else:
|
|
name_parts = uri[1:].split('/')
|
|
if len(request_methods) > 1:
|
|
for req_mthd in request_methods:
|
|
func_name = req_mthd.lower() + "_" + name_parts[-1]
|
|
ws_methods.append(".".join(name_parts[:-1] + [func_name]))
|
|
else:
|
|
ws_methods.append(".".join(name_parts))
|
|
if not is_remote and len(request_methods) != len(ws_methods):
|
|
raise self.server.error(
|
|
"Invalid API definition. Number of websocket methods must "
|
|
"match the number of request methods")
|
|
if endpoint.startswith("objects/"):
|
|
parser = _status_parser
|
|
else:
|
|
parser = _default_parser
|
|
|
|
api_def = APIDefinition(endpoint, uri, ws_methods,
|
|
request_methods, parser)
|
|
self.api_cache[endpoint] = api_def
|
|
return api_def
|
|
|
|
# ***** Dynamic Handlers*****
|
|
class RemoteRequestHandler(AuthorizedRequestHandler):
|
|
def initialize(self, remote_callback, arg_parser):
|
|
super(RemoteRequestHandler, self).initialize()
|
|
self.remote_callback = remote_callback
|
|
self.query_parser = arg_parser
|
|
|
|
async def get(self):
|
|
await self._process_http_request()
|
|
|
|
async def post(self):
|
|
await self._process_http_request()
|
|
|
|
async def _process_http_request(self):
|
|
conn = self.get_associated_websocket()
|
|
args = self.query_parser(self)
|
|
try:
|
|
result = await self.server.make_request(
|
|
WebRequest(self.remote_callback, args, conn=conn))
|
|
except ServerError as e:
|
|
raise tornado.web.HTTPError(
|
|
e.status_code, str(e)) from e
|
|
self.finish({'result': result})
|
|
|
|
class LocalRequestHandler(AuthorizedRequestHandler):
|
|
def initialize(self, callback, methods, arg_parser):
|
|
super(LocalRequestHandler, self).initialize()
|
|
self.callback = callback
|
|
self.methods = methods
|
|
self.query_parser = arg_parser
|
|
|
|
async def get(self):
|
|
if 'GET' in self.methods:
|
|
await self._process_http_request('GET')
|
|
else:
|
|
raise tornado.web.HTTPError(405)
|
|
|
|
async def post(self):
|
|
if 'POST' in self.methods:
|
|
await self._process_http_request('POST')
|
|
else:
|
|
raise tornado.web.HTTPError(405)
|
|
|
|
async def delete(self):
|
|
if 'DELETE' in self.methods:
|
|
await self._process_http_request('DELETE')
|
|
else:
|
|
raise tornado.web.HTTPError(405)
|
|
|
|
async def _process_http_request(self, method):
|
|
conn = self.get_associated_websocket()
|
|
args = self.query_parser(self)
|
|
try:
|
|
result = await self.callback(
|
|
WebRequest(self.request.path, args, method, conn=conn))
|
|
except ServerError as e:
|
|
raise tornado.web.HTTPError(
|
|
e.status_code, str(e)) from e
|
|
self.finish({'result': result})
|
|
|
|
|
|
class FileRequestHandler(AuthorizedFileHandler):
|
|
def set_extra_headers(self, path):
|
|
# The call below shold never return an empty string,
|
|
# as the path should have already been validated to be
|
|
# a file
|
|
basename = os.path.basename(self.absolute_path)
|
|
self.set_header(
|
|
"Content-Disposition", f"attachment; filename={basename}")
|
|
|
|
async def delete(self, path):
|
|
path = self.request.path.lstrip("/").split("/", 2)[-1]
|
|
path = url_unescape(path, plus=False)
|
|
file_manager = self.server.lookup_plugin('file_manager')
|
|
try:
|
|
filename = await file_manager.delete_file(path)
|
|
except self.server.error as e:
|
|
if e.status_code == 403:
|
|
raise tornado.web.HTTPError(
|
|
403, "File is loaded, DELETE not permitted")
|
|
else:
|
|
raise tornado.web.HTTPError(e.status_code, str(e))
|
|
self.finish({'result': filename})
|
|
|
|
class FileUploadHandler(AuthorizedRequestHandler):
|
|
async def post(self):
|
|
file_manager = self.server.lookup_plugin('file_manager')
|
|
try:
|
|
result = await file_manager.process_file_upload(self.request)
|
|
except ServerError as e:
|
|
raise tornado.web.HTTPError(
|
|
e.status_code, str(e))
|
|
self.finish(result)
|
|
|
|
|
|
class EmulateOctoprintHandler(AuthorizedRequestHandler):
|
|
def get(self):
|
|
self.finish({
|
|
'server': "1.1.1",
|
|
'api': "0.1",
|
|
'text': "OctoPrint Upload Emulator"})
|