diff --git a/src/caosdb/utils/server_side_scripting.py b/src/caosdb/utils/server_side_scripting.py new file mode 100644 index 0000000000000000000000000000000000000000..ecfa9da6aa744d5eb67c9a4d46e62e604a27491a --- /dev/null +++ b/src/caosdb/utils/server_side_scripting.py @@ -0,0 +1,132 @@ +# -*- coding: utf-8 -*- +# +# ** header v3.0 +# This file is a part of the CaosDB Project. +# +# Copyright (C) 2020 Timm Fitschen <t.fitschen@indiscale.com> +# Copyright (C) 2020 IndiScale GmbH <info@indiscale.com> +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <https://www.gnu.org/licenses/>. +# +# ** end header +# +"""server_side_scripting + +Helper functions for calling server-side scripts. +""" +from urllib.parse import quote +from lxml import etree + +from caosdb.connection.connection import get_connection +from caosdb.connection.utils import urlencode +from caosdb.connection.encode import MultipartParam, multipart_encode + + +def _make_params(pos_args, opts): + result = {} + for key, val in opts.items(): + result["-O{key}".format(key=key)] = str(val) + for i, val in enumerate(pos_args): + result["-p{i}".format(i=i)] = str(val) + return result + +def _make_multipart_request(call, pos_args, opts, files): + parts = list() + params = _make_params(pos_args, opts) + + parts.append(MultipartParam("call", call)) + for key, val in params.items(): + parts.append(MultipartParam(key, val)) + + for paramname, filename in files.items(): + parts.append(MultipartParam.from_file(paramname=paramname, + filename=filename)) + + body, headers = multipart_encode(parts) + return body, headers + +def _make_form_request(call, pos_args, opts): + form = dict() + form["call"] = call + + params = _make_params(pos_args, opts) + for key, val in params.items(): + form[key] = val + + + headers = {} + headers["Content-Type"] = "application/x-www-form-urlencoded" + return urlencode(form), headers + +def _make_request(call, pos_args, opts, files=None): + """ + Return + ------ + path_segments, body, headers + """ + + if files is not None: + return _make_multipart_request(call, pos_args, opts, files) + + return _make_form_request(call, pos_args, opts) + +def run_server_side_script(call, *args, files=None, **kwargs): + """ + + Return + ------ + response : ScriptingResponse + """ + body, headers = _make_request(call=call, pos_args=args, + opts=kwargs, files=files) + response = get_connection()._http_request(method="POST", + path=quote("scripting"), + body=body, + headers=headers) + xml = etree.parse(response) + code = int(xml.xpath("/Response/script/@code")[0]) + call = xml.xpath("/Response/script/call")[0].text + stdout = xml.xpath("/Response/script/stdout")[0].text + stderr = xml.xpath("/Response/script/stderr")[0].text + + return ScriptingResponse(call=call, + code=code, + stdout=stdout, + stderr=stderr) + + +class ScriptingResponse(): + """ScriptingResponse + + A data class for the response of server-side scripting calls. + + Properties + ---------- + code : int + The return code of the script process. + call : str + The complete call of the script minus the absolute path and the + auth_token. + stdout : str + The STDOUT of the script process. + stderr : str + The STDERR of the script process. + + """ + + def __init__(self, call, code, stdout, stderr): + self.call = call + self.code = code + self.stdout = stdout + self.stderr = stderr diff --git a/unittests/test_server_side_scripting.py b/unittests/test_server_side_scripting.py new file mode 100644 index 0000000000000000000000000000000000000000..f60a0b720ab0406f9f739a044d51c46c8626b698 --- /dev/null +++ b/unittests/test_server_side_scripting.py @@ -0,0 +1,95 @@ +import json +from urllib.parse import parse_qs +from unittest.mock import Mock +from caosdb.utils import server_side_scripting as sss +from caosdb.connection.mockup import MockUpServerConnection, MockUpResponse +from caosdb import configure_connection + +_REMOVE_FILES_AFTERWARDS = [] + +def setup_module(): + c = configure_connection(password_method="unauthenticated", + implementation=MockUpServerConnection) + xml = ('<Response><script code="{code}">' + ' <call>{call}</call>' + ' <stdout>{stdout}</stdout>' + ' <stderr>{stderr}</stderr>' + '</script></Response>') + def scripting_resource(**kwargs): + assert kwargs["path"] == "scripting" + content_type = kwargs["headers"]["Content-Type"] + + if content_type.startswith("multipart/form-data; boundary"): + parts = kwargs["body"] + stdout = [] + for part in parts: + if hasattr(part, "decode"): + stdout.append(part.decode("utf-8")) + else: + stdout.append(part) + stdout = json.dumps(stdout) + else: + assert content_type == "application/x-www-form-urlencoded" + stdout = json.dumps(parse_qs(kwargs["body"].decode("utf-8"), + encoding="utf-8")) + scripting_response = xml.format(code = "123", + call = "call string", + stdout = stdout, + stderr = "stderr string") + return MockUpResponse(200, {}, scripting_response) + c._delegate_connection.resources.append(scripting_resource) + +def teardown_module(): + from os import remove + from os.path import exists, isdir + from shutil import rmtree + for obsolete in _REMOVE_FILES_AFTERWARDS: + if exists(obsolete): + if isdir(obsolete): + rmtree(obsolete) + else: + remove(obsolete) + + +def test_run_server_side_script(): + assert type(sss.run_server_side_script).__name__ == "function" + r = sss.run_server_side_script("cat", "/etc/passwd", files=None, + option1="val1") + assert r.call == "call string" + assert r.code == 123 + assert r.stderr == "stderr string" + + form = json.loads(r.stdout) + assert form["call"] == ["cat"] + assert form["-p0"] == ["/etc/passwd"] + assert form["-Ooption1"] == ["val1"] + + +def test_run_server_side_script_with_file(): + _REMOVE_FILES_AFTERWARDS.append("test_file.txt") + with open("test_file.txt", "w") as f: + f.write("this is a test") + + assert type(sss.run_server_side_script).__name__ == "function" + r = sss.run_server_side_script("cat", "/etc/passwd", + files={"file1": "test_file.txt"}, + option1="val1") + assert r.call == "call string" + assert r.code == 123 + assert r.stderr == "stderr string" + + parts = json.loads(r.stdout) + print(parts) + assert 'name="call"' in parts[0] + assert "\r\n\r\ncat\r\n" in parts[0] + + assert 'name="-Ooption1"' in parts[1] + assert "\r\n\r\nval1\r\n" in parts[1] + + assert 'name="-p0"' in parts[2] + assert "\r\n\r\n/etc/passwd\r\n" in parts[2] + + assert 'name="file1"' in parts[3] + assert 'filename="test_file.txt"' in parts[3] + + assert parts[4] == "this is a test"