diff --git a/.travis.yml b/.travis.yml index b979cb8..daa3924 100644 --- a/.travis.yml +++ b/.travis.yml @@ -8,6 +8,7 @@ install: - "pip install pytest-cov" - "pip install coverage" - "pip install coveralls" + - "pip install mock" script: coverage run --source=tornado_json setup.py test after_success: diff --git a/demos/helloworld/API_Documentation.md b/demos/helloworld/API_Documentation.md index e3aef93..5f1f563 100644 --- a/demos/helloworld/API_Documentation.md +++ b/demos/helloworld/API_Documentation.md @@ -31,7 +31,7 @@ Shouts hello to the world (asynchronously)! -# `/api/greeting/(?P[a-zA-Z0-9_]+)/?$` +# `/api/greeting/(?P[a-zA-Z0-9_]+)/(?P[a-zA-Z0-9_]+)/?$` Content-Type: application/json @@ -50,7 +50,7 @@ null ### Output Example ```json -"Greetings, Greg!" +"Greetings, Named Person!" ``` @@ -85,3 +85,67 @@ null Shouts hello to the world! + + + + +# `/api/postit` + + Content-Type: application/json + +## POST +### Input Schema +```json +{ + "type": "object", + "properties": { + "body": { + "type": "string" + }, + "index": { + "type": "number" + }, + "title": { + "type": "string" + } + } +} +``` + +### Input Example +```json +{ + "body": "Equally important message", + "index": 0, + "title": "Very Important Post-It Note" +} +``` + +### Output Schema +```json +{ + "type": "object", + "properties": { + "message": { + "type": "string" + } + } +} +``` + +### Output Example +```json +{ + "message": "Very Important Post-It Note was posted." +} +``` + + + +POST the required parameters to post a Post-It note + +* `title`: Title of the note +* `body`: Body of the note +* `index`: An easy index with which to find the note + + diff --git a/demos/helloworld/helloworld/api.py b/demos/helloworld/helloworld/api.py index 908d63e..cd0d639 100755 --- a/demos/helloworld/helloworld/api.py +++ b/demos/helloworld/helloworld/api.py @@ -6,16 +6,15 @@ class HelloWorldHandler(APIHandler): - apid = { - "get": { - "input_schema": None, - "output_schema": { - "type": "string", - }, - "output_example": "Hello world!", - "input_example": None, - "doc": "Shouts hello to the world!", + apid = {} + apid["get"] = { + "input_schema": None, + "output_schema": { + "type": "string", }, + "output_example": "Hello world!", + "input_example": None, + "doc": "Shouts hello to the world!", } # Decorate any HTTP methods with the `io_schema` decorator @@ -30,16 +29,15 @@ def get(self): class AsyncHelloWorld(APIHandler): - apid = { - "get": { - "input_schema": None, - "output_schema": { - "type": "string", - }, - "output_example": "Hello (asynchronous) world!", - "input_example": None, - "doc": "Shouts hello to the world (asynchronously)!", + apid = {} + apid["get"] = { + "input_schema": None, + "output_schema": { + "type": "string", }, + "output_example": "Hello (asynchronous) world!", + "input_example": None, + "doc": "Shouts hello to the world (asynchronously)!", } def hello(self, callback=None): @@ -58,31 +56,86 @@ def get(self): # In Python 3.3, using `raise Return(value)` is no longer # necessary and can be replaced with simply `return value`. # For details, see: - # http://www.tornadoweb.org/en/branch3.2/gen.html#tornado.gen.Return + # http://www.tornadoweb.org/en/branch3.2/gen.html#tornado.gen.Return # return res # Python 3.3 raise gen.Return(res) # Python 2.7 -class Greeting(APIHandler): +class PostIt(APIHandler): - apid = { - "get": { - "input_schema": None, - "output_schema": { - "type": "string", + apid = {} + apid["post"] = { + "input_schema": { + "type": "object", + "properties": { + "title": {"type": "string"}, + "body": {"type": "string"}, + "index": {"type": "number"}, }, - "output_example": "Greetings, Greg!", - "input_example": None, - "doc": "Greets you.", }, + "input_example": { + "title": "Very Important Post-It Note", + "body": "Equally important message", + "index": 0 + }, + "output_schema": { + "type": "object", + "properties": { + "message": {"type": "string"} + } + }, + "output_example": { + "message": "Very Important Post-It Note was posted." + }, + "doc": """ +POST the required parameters to post a Post-It note + +* `title`: Title of the note +* `body`: Body of the note +* `index`: An easy index with which to find the note +""" + } + + @io_schema + def post(self): + # io_schema will JSON-decode `self.request.body` for us + # and set self.body as the result, so we can use that here + return { + "message": "{} was posted.".format(self.body["title"]) + } + + +class Greeting(APIHandler): + + apid = {} + apid["get"] = { + "input_schema": None, + "output_schema": { + "type": "string", + }, + "output_example": "Greetings, Named Person!", + "input_example": None, + "doc": "Greets you.", } # When you include extra arguments in the signature of an HTTP # method, Tornado-JSON will generate a route that matches the extra - # arguments; here, you can GET /api/greeting/Greg and you will - # get a response back that says, "Greetings, Greg!" + # arguments; here, you can GET /api/greeting/John/Smith and you will + # get a response back that says, "Greetings, John Smith!" # You can match the regex equivalent of `\w+`. @io_schema - def get(self, name): - return "Greetings, {}!".format(name) + def get(self, fname, lname): + return "Greetings, {} {}!".format(fname, lname) + + +class FreeWilledHandler(APIHandler): + + # And of course, you aren't forced to use schema validation; + # if you want your handlers to do something more custom, + # they definitely can. + def get(self): + self.success("I don't need no stinkin' schema validation.") + # If you're feeling really bold, you could even skip JSend + # altogether and do the following EVIL thing: + # self.write("I'm writing back a string that isn't JSON! Take that!") diff --git a/maintenance.md b/maintenance.md index d0e4e8e..fdac771 100644 --- a/maintenance.md +++ b/maintenance.md @@ -20,3 +20,4 @@ * Run tests from root project directory ```$ py.test --cov="tornado_json" --cov-report=term --cov-report=html``` + ```$ nosetests --with-cov --cov-report term-missing --cov tornado_json tests/``` diff --git a/tornado_json/test/__init__.py b/tests/__init__.py similarity index 100% rename from tornado_json/test/__init__.py rename to tests/__init__.py diff --git a/tests/func_test.py b/tests/func_test.py new file mode 100644 index 0000000..2f59769 --- /dev/null +++ b/tests/func_test.py @@ -0,0 +1,186 @@ +import sys +import json +from tornado.testing import AsyncHTTPTestCase + +try: + sys.path.append('.') + from tornado_json import routes + from tornado_json import utils + from tornado_json import application + from tornado_json import requesthandlers + sys.path.append('demos/helloworld') + import helloworld +except ImportError as e: + print("Please run `py.test` from the root project directory") + exit(1) + + +def jd(obj): + return json.dumps(obj) + + +def jl(s): + return json.loads(s.decode("utf-8")) + + +class DummyView(requesthandlers.ViewHandler): + + """Dummy ViewHandler for coverage""" + + def delete(self): + # Reference db_conn to test for AttributeError + self.db_conn + + +class DBTestHandler(requesthandlers.APIHandler): + + """APIHandler for testing db_conn""" + + def get(self): + # Set application.db_conn to test if db_conn BaseHandler + # property works + self.application.db_conn = {"data": "Nothing to see here."} + self.success(self.db_conn.get("data")) + + +class ExplodingHandler(requesthandlers.APIHandler): + + apid = { + "get": { + "input_schema": "This doesn't matter because GET request", + "output_schema": { + "type": "number", + }, + "doc": """ +This handler is used for testing purposes and is explosive. +""" + }, + "post": { + "input_schema": { + "type": "number", + }, + "output_schema": { + "type": "number", + }, + "doc": """ +This handler is used for testing purposes and is explosive. +""" + }, + } + + @utils.io_schema + def get(self): + return "I am not the handler you are looking for." + + @utils.io_schema + def post(self): + return "Fission mailed." + + +class APIFunctionalTest(AsyncHTTPTestCase): + + def get_app(self): + rts = routes.get_routes(helloworld) + rts += [ + ("/api/explodinghandler", ExplodingHandler), + ("/views/someview", DummyView), + ("/api/dbtest", DBTestHandler) + ] + return application.Application( + routes=rts, + settings={"debug": True}, + db_conn=None + ) + + def test_synchronous_handler(self): + r = self.fetch( + "/api/helloworld" + ) + self.assertEqual(r.code, 200) + self.assertEqual( + jl(r.body)["data"], + "Hello world!" + ) + + def test_asynchronous_handler(self): + r = self.fetch( + "/api/asynchelloworld" + ) + self.assertEqual(r.code, 200) + self.assertEqual( + jl(r.body)["data"], + "Hello (asynchronous) world!" + ) + + def test_post_request(self): + r = self.fetch( + "/api/postit", + method="POST", + body=jd({ + "title": "Very Important Post-It Note", + "body": "Equally important message", + "index": 0 + }) + ) + self.assertEqual(r.code, 200) + self.assertEqual( + jl(r.body)["data"]["message"], + "Very Important Post-It Note was posted." + ) + + def test_url_pattern_route(self): + r = self.fetch( + "/api/greeting/John/Smith" + ) + self.assertEqual(r.code, 200) + self.assertEqual( + jl(r.body)["data"], + "Greetings, John Smith!" + ) + + def test_write_error(self): + # Test malformed output + r = self.fetch( + "/api/explodinghandler" + ) + self.assertEqual(r.code, 500) + self.assertEqual( + jl(r.body)["status"], + "error" + ) + # Test malformed input + r = self.fetch( + "/api/explodinghandler", + method="POST", + body='"Yup", "this is going to end badly."]' + ) + self.assertEqual(r.code, 400) + self.assertEqual( + jl(r.body)["status"], + "fail" + ) + + def test_view_db_conn(self): + r = self.fetch( + "/views/someview", + method="DELETE" + ) + self.assertEqual(r.code, 500) + self.assertTrue( + "No database connection was provided." in r.body.decode("UTF-8") + ) + + def test_db_conn(self): + r = self.fetch( + "/api/dbtest", + method="GET" + ) + self.assertEqual(r.code, 200) + print(r.body) + self.assertEqual( + jl(r.body)["status"], + "success" + ) + self.assertTrue( + "Nothing to see here." in jl(r.body)["data"] + ) diff --git a/tornado_json/test/test_tornado_json.py b/tests/test_tornado_json.py similarity index 90% rename from tornado_json/test/test_tornado_json.py rename to tests/test_tornado_json.py index 5ee7edb..d953ad9 100644 --- a/tornado_json/test/test_tornado_json.py +++ b/tests/test_tornado_json.py @@ -49,8 +49,10 @@ def test_get_routes(self): helloworld)) == sorted([ ("/api/helloworld", helloworld.api.HelloWorldHandler), ("/api/asynchelloworld", helloworld.api.AsyncHelloWorld), - ("/api/greeting/(?P[a-zA-Z0-9_]+)/?$", - helloworld.api.Greeting) + ("/api/postit", helloworld.api.PostIt), + ("/api/greeting/(?P[a-zA-Z0-9_]+)/(?P[a-zA-Z0-9_]+)/?$", + helloworld.api.Greeting), + ("/api/freewilled", helloworld.api.FreeWilledHandler) ]) def test_gen_submodule_names(self): @@ -64,8 +66,10 @@ def test_get_module_routes(self): 'helloworld.api')) == sorted([ ("/api/helloworld", helloworld.api.HelloWorldHandler), ("/api/asynchelloworld", helloworld.api.AsyncHelloWorld), - ("/api/greeting/(?P[a-zA-Z0-9_]+)/?$", - helloworld.api.Greeting) + ("/api/postit", helloworld.api.PostIt), + ("/api/greeting/(?P[a-zA-Z0-9_]+)/(?P[a-zA-Z0-9_]+)/?$", + helloworld.api.Greeting), + ("/api/freewilled", helloworld.api.FreeWilledHandler) ]) @@ -145,9 +149,10 @@ def post(self): assert self.body == {"I am a": "JSON object"} return "Mail received." - # TODO: Test io_schema functionally instead; pytest.raises does + # DONE: Test io_schema functionally instead; pytest.raises does # not seem to be catching errors being thrown after change # to async compatible code. + # The following test left here as antiquity. # def test_io_schema(self): # """Tests the utils.io_schema decorator""" # th = self.TerribleHandler() diff --git a/tornado_json/__init__.py b/tornado_json/__init__.py index 61acf08..cdaba45 100644 --- a/tornado_json/__init__.py +++ b/tornado_json/__init__.py @@ -5,4 +5,4 @@ # Alternatively, just put the version in a text file or something to avoid # this. -__version__ = '0.13' +__version__ = '0.14' diff --git a/tornado_json/requesthandlers.py b/tornado_json/requesthandlers.py index 62810c0..7cd585b 100644 --- a/tornado_json/requesthandlers.py +++ b/tornado_json/requesthandlers.py @@ -60,15 +60,11 @@ def write_error(self, status_code, **kwargs): :type status_code: int :param status_code: HTTP status code """ - self.clear() - - # If exc_info is not in kwargs, something is very fubar - if not "exc_info" in list(kwargs.keys()): - logging.error("exc_info not provided") - self.set_status(500) - self.error(message="Internal Server Error", code=500) - self.finish() + def get_exc_message(exception): + return exception.log_message if \ + hasattr(exception, "log_message") else str(exception) + self.clear() self.set_status(status_code) # Any APIError exceptions raised will result in a JSend fail written @@ -83,10 +79,11 @@ def write_error(self, status_code, **kwargs): # ValidationError is always due to a malformed request if isinstance(exception, ValidationError): self.set_status(400) - self.fail(exception.log_message if - hasattr(exception, "log_message") else str(exception)) + self.fail(get_exc_message(exception)) else: - self.error(message=self._reason, - data=exception.log_message if self.settings.get( - "debug") else None, - code=status_code) + self.error( + message=self._reason, + data=get_exc_message(exception) if self.settings.get("debug") + else None, + code=status_code + ) diff --git a/tornado_json/utils.py b/tornado_json/utils.py index e7c270b..351a270 100644 --- a/tornado_json/utils.py +++ b/tornado_json/utils.py @@ -62,15 +62,21 @@ def _wrapper(self, *args, **kwargs): if method_name not in ["get", "delete"]: # If input is not valid JSON, fail try: - input_ = json.loads(self.request.body) + # TODO: Assuming UTF-8 encoding for all requests, + # find a nice way of determining this from charset + # in headers if provided + encoding = "UTF-8" + input_ = json.loads(self.request.body.decode(encoding)) except ValueError as e: - logging.error(str(e)) - self.fail(str(e)) - return + raise ValidationError( + "Input is malformed; could not decode JSON object." + ) # Validate the received input - validate(input_, type(self) - .apid[method_name]["input_schema"]) + validate( + input_, + type(self).apid[method_name]["input_schema"] + ) else: input_ = None