diff options
-rw-r--r-- | .coveragerc | 14 | ||||
-rw-r--r-- | Pipfile | 5 | ||||
-rw-r--r-- | Pipfile.lock | 125 | ||||
-rw-r--r-- | snekbox/api/__init__.py | 3 | ||||
-rw-r--r-- | snekbox/api/app.py | 3 | ||||
-rw-r--r-- | snekbox/api/middleware/__init__.py | 3 | ||||
-rw-r--r-- | snekbox/api/middleware/logger.py | 11 | ||||
-rw-r--r-- | snekbox/api/resources/__init__.py | 3 | ||||
-rw-r--r-- | snekbox/api/resources/eval.py | 74 | ||||
-rw-r--r-- | snekbox/api/snekapi.py | 27 | ||||
-rw-r--r-- | snekbox/site/snekapp.py | 35 | ||||
-rw-r--r-- | snekbox/site/templates/index.html | 14 | ||||
-rw-r--r-- | snekbox/site/templates/result.html | 9 | ||||
-rw-r--r-- | tests/__init__.py | 1 | ||||
-rw-r--r-- | tests/api/__init__.py | 17 | ||||
-rw-r--r-- | tests/api/test_eval.py | 49 |
16 files changed, 257 insertions, 136 deletions
diff --git a/.coveragerc b/.coveragerc index c1e718b..d1877d4 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1,10 +1,6 @@ [run] -omit = .venv/*, - tests/*, - snekweb.py - config.py - -[report] -exclude_lines = return jsonify, - raise RuntimeError, - return +branch = True +omit = + .venv/*, + snekbox/api/app.py, + tests/* @@ -4,8 +4,9 @@ verify_ssl = true name = "pypi" [packages] -flask = "*" +falcon = "*" gunicorn = "*" +jsonschema = "*" [dev-packages] pytest = "*" @@ -30,7 +31,7 @@ lint = "flake8" precommit = "pre-commit install" test = "pytest tests --cov . --cov-report term-missing -v" report = "pytest tests --cov . --cov-report=html" -snekbox = "gunicorn -w 2 -b 0.0.0.0:8060 --logger-class snekbox.GunicornLogger --access-logfile - snekbox.site.snekapp:app" +snekbox = "gunicorn -w 2 -b 0.0.0.0:8060 --logger-class snekbox.GunicornLogger --access-logfile - snekbox.api.app" buildbox = "docker build -t pythondiscord/snekbox:latest -f docker/Dockerfile ." pushbox = "docker push pythondiscord/snekbox:latest" buildboxbase = "docker build -t pythondiscord/snekbox-base:latest -f docker/base.Dockerfile ." diff --git a/Pipfile.lock b/Pipfile.lock index b73b997..c09c916 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "c1f4c3df791d8c4758f72cb8fb148a34d5c1ca02298b0d660844899f15f6ba85" + "sha256": "af53793d2c00001698021096041ed23e5de0a5553f974822e9ad7e58f70de4a9" }, "pipfile-spec": 6, "requires": { @@ -16,20 +16,32 @@ ] }, "default": { - "click": { + "attrs": { "hashes": [ - "sha256:2335065e6395b9e67ca716de5f7526736bfa6ceead690adf616d925bdc622b13", - "sha256:5b94b49521f6456670fdb30cd82a4eca9412788a93fa6dd6df72c94d5a8ff2d7" + "sha256:69c0dbf2ed392de1cb5ec704444b08a5ef81680a61cb899dc08127123af36a79", + "sha256:f0b870f674851ecbfbbbd364d6b5cbdff9dcedbc7f3f5e18a6891057f21fe399" ], - "version": "==7.0" + "version": "==19.1.0" }, - "flask": { - "hashes": [ - "sha256:ad7c6d841e64296b962296c2c2dabc6543752985727af86a975072dea984b6f3", - "sha256:e7d32475d1de5facaa55e3958bc4ec66d3762076b074296aa50ef8fdc5b9df61" + "falcon": { + "hashes": [ + "sha256:18157af2a4fc3feedf2b5dcc6196f448639acf01c68bc33d4d5a04c3ef87f494", + "sha256:24adcd2b29a8ffa9d552dc79638cd21736a3fb04eda7d102c6cebafdaadb88ad", + "sha256:54f2cb4b687035b2a03206dbfc538055cc48b59a953187b0458aa1b574d47b53", + "sha256:59d1e8c993b9a37ea06df9d72cf907a46cc8063b30717cdac2f34d1658b6f936", + "sha256:733033ec80c896e30a43ab3e776856096836787197a44eb21022320a61311983", + "sha256:74cf1d18207381c665b9e6292d65100ce146d958707793174b03869dc6e614f4", + "sha256:95bf6ce986c1119aef12c9b348f4dee9c6dcc58391bdd0bc2b0bf353c2b15986", + "sha256:9712975adcf8c6e12876239085ad757b8fdeba223d46d23daef82b47658f83a9", + "sha256:a5ebb22a04c9cc65081938ee7651b4e3b4d2a28522ea8ec04c7bdd2b3e9e8cd8", + "sha256:aa184895d1ad4573fbfaaf803563d02f019ebdf4790e41cc568a330607eae439", + "sha256:e3782b7b92fefd46a6ad1fd8fe63fe6c6f1b7740a95ca56957f48d1aee34b357", + "sha256:e9efa0791b5d9f9dd9689015ea6bce0a27fcd5ecbcd30e6d940bffa4f7f03389", + "sha256:eea593cf466b9c126ce667f6d30503624ef24459f118c75594a69353b6c3d5fc", + "sha256:f93351459f110b4c1ee28556aef9a791832df6f910bea7b3f616109d534df06b" ], "index": "pypi", - "version": "==1.0.3" + "version": "==2.0.0" }, "gunicorn": { "hashes": [ @@ -39,59 +51,26 @@ "index": "pypi", "version": "==19.9.0" }, - "itsdangerous": { - "hashes": [ - "sha256:321b033d07f2a4136d3ec762eac9f16a10ccd60f53c0c91af90217ace7ba1f19", - "sha256:b12271b2047cb23eeb98c8b5622e2e5c5e9abd9784a153e9d8ef9cb4dd09d749" - ], - "version": "==1.1.0" - }, - "jinja2": { - "hashes": [ - "sha256:065c4f02ebe7f7cf559e49ee5a95fb800a9e4528727aec6f24402a5374c65013", - "sha256:14dd6caf1527abb21f08f86c784eac40853ba93edb79552aa1e4b8aef1b61c7b" - ], - "version": "==2.10.1" - }, - "markupsafe": { - "hashes": [ - "sha256:00bc623926325b26bb9605ae9eae8a215691f33cae5df11ca5424f06f2d1f473", - "sha256:09027a7803a62ca78792ad89403b1b7a73a01c8cb65909cd876f7fcebd79b161", - "sha256:09c4b7f37d6c648cb13f9230d847adf22f8171b1ccc4d5682398e77f40309235", - "sha256:1027c282dad077d0bae18be6794e6b6b8c91d58ed8a8d89a89d59693b9131db5", - "sha256:24982cc2533820871eba85ba648cd53d8623687ff11cbb805be4ff7b4c971aff", - "sha256:29872e92839765e546828bb7754a68c418d927cd064fd4708fab9fe9c8bb116b", - "sha256:43a55c2930bbc139570ac2452adf3d70cdbb3cfe5912c71cdce1c2c6bbd9c5d1", - "sha256:46c99d2de99945ec5cb54f23c8cd5689f6d7177305ebff350a58ce5f8de1669e", - "sha256:500d4957e52ddc3351cabf489e79c91c17f6e0899158447047588650b5e69183", - "sha256:535f6fc4d397c1563d08b88e485c3496cf5784e927af890fb3c3aac7f933ec66", - "sha256:62fe6c95e3ec8a7fad637b7f3d372c15ec1caa01ab47926cfdf7a75b40e0eac1", - "sha256:6dd73240d2af64df90aa7c4e7481e23825ea70af4b4922f8ede5b9e35f78a3b1", - "sha256:717ba8fe3ae9cc0006d7c451f0bb265ee07739daf76355d06366154ee68d221e", - "sha256:79855e1c5b8da654cf486b830bd42c06e8780cea587384cf6545b7d9ac013a0b", - "sha256:7c1699dfe0cf8ff607dbdcc1e9b9af1755371f92a68f706051cc8c37d447c905", - "sha256:88e5fcfb52ee7b911e8bb6d6aa2fd21fbecc674eadd44118a9cc3863f938e735", - "sha256:8defac2f2ccd6805ebf65f5eeb132adcf2ab57aa11fdf4c0dd5169a004710e7d", - "sha256:98c7086708b163d425c67c7a91bad6e466bb99d797aa64f965e9d25c12111a5e", - "sha256:9add70b36c5666a2ed02b43b335fe19002ee5235efd4b8a89bfcf9005bebac0d", - "sha256:9bf40443012702a1d2070043cb6291650a0841ece432556f784f004937f0f32c", - "sha256:ade5e387d2ad0d7ebf59146cc00c8044acbd863725f887353a10df825fc8ae21", - "sha256:b00c1de48212e4cc9603895652c5c410df699856a2853135b3967591e4beebc2", - "sha256:b1282f8c00509d99fef04d8ba936b156d419be841854fe901d8ae224c59f0be5", - "sha256:b2051432115498d3562c084a49bba65d97cf251f5a331c64a12ee7e04dacc51b", - "sha256:ba59edeaa2fc6114428f1637ffff42da1e311e29382d81b339c1817d37ec93c6", - "sha256:c8716a48d94b06bb3b2524c2b77e055fb313aeb4ea620c8dd03a105574ba704f", - "sha256:cd5df75523866410809ca100dc9681e301e3c27567cf498077e8551b6d20e42f", - "sha256:e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7" - ], - "version": "==1.1.1" - }, - "werkzeug": { - "hashes": [ - "sha256:865856ebb55c4dcd0630cdd8f3331a1847a819dda7e8c750d3db6f2aa6c0209c", - "sha256:a0b915f0815982fb2a09161cb8f31708052d0951c3ba433ccc5e1aa276507ca6" - ], - "version": "==0.15.4" + "jsonschema": { + "hashes": [ + "sha256:0c0a81564f181de3212efa2d17de1910f8732fa1b71c42266d983cd74304e20d", + "sha256:a5f6559964a3851f59040d3b961de5e68e70971afb88ba519d27e6a039efff1a" + ], + "index": "pypi", + "version": "==3.0.1" + }, + "pyrsistent": { + "hashes": [ + "sha256:16692ee739d42cf5e39cef8d27649a8c1fdb7aa99887098f1460057c5eb75c3a" + ], + "version": "==0.15.2" + }, + "six": { + "hashes": [ + "sha256:3350809f0555b11f552448330d0b52d5f24c91a322ea4a15ef22629740f3761c", + "sha256:d16a0141ec1a18405cd4ce8b4613101da75da0e9a7aec5bdd4fa804d0e0eba73" + ], + "version": "==1.12.0" } }, "develop": { @@ -284,6 +263,13 @@ ], "version": "==1.3.3" }, + "packaging": { + "hashes": [ + "sha256:0c98a5d0be38ed775798ece1b9727178c4469d9c3b4ada66e8e6b7849f8732af", + "sha256:9e1cbf8c12b1f1ce0bb5344b8d7ecf66a6f8a6e91bcb0c84593ed6d3ab5c4ab3" + ], + "version": "==19.0" + }, "pluggy": { "hashes": [ "sha256:0825a152ac059776623854c1543d65a4ad408eb3d33ee114dff91e57ec6ae6fc", @@ -328,13 +314,20 @@ ], "version": "==2.1.1" }, + "pyparsing": { + "hashes": [ + "sha256:1873c03321fc118f4e9746baf201ff990ceb915f433f23b395f5580d1840cb2a", + "sha256:9b6323ef4ab914af344ba97510e966d64ba91055d6b9afa6b30799340e89cc03" + ], + "version": "==2.4.0" + }, "pytest": { "hashes": [ - "sha256:1a8aa4fa958f8f451ac5441f3ac130d9fc86ea38780dd2715e6d5c5882700b24", - "sha256:b8bf138592384bd4e87338cb0f256bf5f615398a649d4bd83915f0e4047a5ca6" + "sha256:6032845e68a17a96e8da3088037f899b56357769a724122056265ca2ea1890ee", + "sha256:bea27a646a3d74cbbcf8d3d4a06b2dfc336baf3dc2cc85cf70ad0157e73e8322" ], "index": "pypi", - "version": "==4.5.0" + "version": "==4.6.2" }, "pytest-cov": { "hashes": [ diff --git a/snekbox/api/__init__.py b/snekbox/api/__init__.py new file mode 100644 index 0000000..d106f26 --- /dev/null +++ b/snekbox/api/__init__.py @@ -0,0 +1,3 @@ +from .snekapi import SnekAPI + +__all__ = ("SnekAPI",) diff --git a/snekbox/api/app.py b/snekbox/api/app.py new file mode 100644 index 0000000..c71e246 --- /dev/null +++ b/snekbox/api/app.py @@ -0,0 +1,3 @@ +from . import SnekAPI + +application = SnekAPI() diff --git a/snekbox/api/middleware/__init__.py b/snekbox/api/middleware/__init__.py new file mode 100644 index 0000000..ba97ca6 --- /dev/null +++ b/snekbox/api/middleware/__init__.py @@ -0,0 +1,3 @@ +from .logger import LoggingMiddleware + +__all__ = ("LoggingMiddleware",) diff --git a/snekbox/api/middleware/logger.py b/snekbox/api/middleware/logger.py new file mode 100644 index 0000000..393fc64 --- /dev/null +++ b/snekbox/api/middleware/logger.py @@ -0,0 +1,11 @@ +import logging + +log = logging.getLogger("snekbox.api") + + +class LoggingMiddleware: + """Log basic information about responses.""" + + def process_response(self, req, resp, resource, req_succeeded): + """Log the method, route, and status of a response.""" + log.info(f"{req.method} {req.relative_uri} {resp.status}") diff --git a/snekbox/api/resources/__init__.py b/snekbox/api/resources/__init__.py new file mode 100644 index 0000000..fe422b8 --- /dev/null +++ b/snekbox/api/resources/__init__.py @@ -0,0 +1,3 @@ +from .eval import EvalResource + +__all__ = ("EvalResource",) diff --git a/snekbox/api/resources/eval.py b/snekbox/api/resources/eval.py new file mode 100644 index 0000000..b2f4260 --- /dev/null +++ b/snekbox/api/resources/eval.py @@ -0,0 +1,74 @@ +import logging + +import falcon +from falcon.media.validators.jsonschema import validate + +from snekbox.nsjail import NsJail + +log = logging.getLogger(__name__) + + +class EvalResource: + """ + Evaluation of Python code. + + Supported methods: + + - POST /eval + Evaluate Python code and return the result + """ + + REQ_SCHEMA = { + "type": "object", + "properties": { + "input": { + "type": "string" + } + }, + "required": [ + "input" + ] + } + + def __init__(self): + self.nsjail = NsJail() + + @validate(REQ_SCHEMA) + def on_post(self, req, resp): + """ + Evaluate Python code and return the result. + + Request body: + + >>> { + ... "input": "print(1 + 1)" + ... } + + Response format: + + >>> { + ... "input": "print(1 + 1)", + ... "output": "2\\n" + ... } + + Status codes: + + - 200 + Successful evaluation; not indicative that the input code itself works + - 400 + Input's JSON schema is invalid + - 415 + Unsupported content type; only application/JSON is supported + """ + code = req.media["input"] + + try: + output = self.nsjail.python3(code) + except Exception: + log.exception("An exception occurred while trying to process the request") + raise falcon.HTTPInternalServerError + + resp.media = { + "input": code, + "output": output + } diff --git a/snekbox/api/snekapi.py b/snekbox/api/snekapi.py new file mode 100644 index 0000000..849e7d6 --- /dev/null +++ b/snekbox/api/snekapi.py @@ -0,0 +1,27 @@ +import falcon + +from .middleware import LoggingMiddleware +from .resources import EvalResource + + +class SnekAPI(falcon.API): + """ + The main entry point to the snekbox JSON API. + + Routes: + + - /eval + Evaluation of Python code + + Error response format: + + >>> { + ... "title": "Unsupported media type", + ... "description": "application/xml is an unsupported media type." + ... } + """ + + def __init__(self, *args, **kwargs): + super().__init__(middleware=[LoggingMiddleware()], *args, **kwargs) + + self.add_route("/eval", EvalResource()) diff --git a/snekbox/site/snekapp.py b/snekbox/site/snekapp.py deleted file mode 100644 index 3954238..0000000 --- a/snekbox/site/snekapp.py +++ /dev/null @@ -1,35 +0,0 @@ -from flask import Flask, jsonify, render_template, request - -from snekbox.nsjail import NsJail - -nsjail = NsJail() - -# Load app -app = Flask(__name__) -app.use_reloader = False - -# Logging -log = app.logger - - [email protected]("/") -def index(): - """Return a page with a form for inputting code to be executed.""" - return render_template("index.html") - - [email protected]("/result", methods=["POST", "GET"]) -def result(): - """Execute code and return a page displaying the results.""" - if request.method == "POST": - code = request.form["Code"] - output = nsjail.python3(code) - return render_template("result.html", code=code, result=output) - - [email protected]("/input", methods=["POST"]) -def code_input(): - """Execute code and return the results.""" - body = request.get_json() - output = nsjail.python3(body["code"]) - return jsonify(input=body["code"], output=output) diff --git a/snekbox/site/templates/index.html b/snekbox/site/templates/index.html deleted file mode 100644 index 41980d1..0000000 --- a/snekbox/site/templates/index.html +++ /dev/null @@ -1,14 +0,0 @@ -<!DOCTYPE html> -<html> - <meta charset="utf-8" /> - <title>snekboxweb</title> - <body> - <form action="/result" method="POST"> - <p>Code:<br><textarea name="Code" rows="20" cols="80"> -def sum(a,b): - return a+b -print( sum(1,2) )</textarea></p> - <p><input type="submit" value="Run"></p> - </form> - </body> -</html> diff --git a/snekbox/site/templates/result.html b/snekbox/site/templates/result.html deleted file mode 100644 index e339605..0000000 --- a/snekbox/site/templates/result.html +++ /dev/null @@ -1,9 +0,0 @@ -<!DOCTYPE html> -<html> - <meta charset="utf-8" /> - <title>snekboxweb</title> - <body> - <p>Code Evaluated:<br><pre>{{ code }}</pre></p> - <p>Results:<br><pre>{{ result }}</pre></p> - </body> -</html> diff --git a/tests/__init__.py b/tests/__init__.py index 792d600..e69de29 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1 +0,0 @@ -# diff --git a/tests/api/__init__.py b/tests/api/__init__.py new file mode 100644 index 0000000..fd4679a --- /dev/null +++ b/tests/api/__init__.py @@ -0,0 +1,17 @@ +from unittest import mock + +from falcon import testing + +from snekbox.api import SnekAPI + + +class SnekAPITestCase(testing.TestCase): + def setUp(self): + super().setUp() + + self.patcher = mock.patch("snekbox.api.resources.eval.NsJail", autospec=True) + self.mock_nsjail = self.patcher.start() + self.mock_nsjail.return_value.python3.return_value = "test output" + self.addCleanup(self.patcher.stop) + + self.app = SnekAPI() diff --git a/tests/api/test_eval.py b/tests/api/test_eval.py new file mode 100644 index 0000000..a5b83fd --- /dev/null +++ b/tests/api/test_eval.py @@ -0,0 +1,49 @@ +from tests.api import SnekAPITestCase + + +class TestEvalResource(SnekAPITestCase): + PATH = "/eval" + + def test_post_valid_200(self): + body = {"input": "foo"} + result = self.simulate_post(self.PATH, json=body) + + self.assertEqual(result.status_code, 200) + self.assertEqual(body["input"], result.json["input"]) + self.assertEqual("test output", result.json["output"]) + + def test_post_invalid_schema_400(self): + body = {"stuff": "foo"} + result = self.simulate_post(self.PATH, json=body) + + self.assertEqual(result.status_code, 400) + + expected = { + "title": "Request data failed validation", + "description": "'input' is a required property" + } + + self.assertEqual(expected, result.json) + + def test_post_invalid_content_type_415(self): + body = "{\"input\": \"foo\"}" + headers = {"Content-Type": "application/xml"} + result = self.simulate_post(self.PATH, body=body, headers=headers) + + self.assertEqual(result.status_code, 415) + + expected = { + "title": "Unsupported media type", + "description": "application/xml is an unsupported media type." + } + + self.assertEqual(expected, result.json) + + def test_disallowed_method_405(self): + result = self.simulate_get(self.PATH) + self.assertEqual(result.status_code, 405) + + def test_options_allow_post_only(self): + result = self.simulate_options(self.PATH) + self.assertEqual(result.status_code, 200) + self.assertEqual(result.headers.get("Allow"), "POST") |