diff --git a/README.md b/README.md index 68ec8c3..a7566b6 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Tornado-JSON is a small extension of [Tornado](http://www.tornadoweb.org/en/stab Some of the key features the included modules provide: -* Input and output [JSON Schema](http://json-schema.org/) validation by decorating RequestHandlers with `io_schema` +* Input and output [JSON Schema](http://json-schema.org/) validation by decorating RequestHandlers with `schema.validate` * Automated *route generation* with `routes.get_routes(package)` * *Automated Public API documentation* using schemas and provided descriptions * Standardized output using the [JSend](http://labs.omniti.com/labs/jsend) specification diff --git a/README.rst b/README.rst index 3a06c94..80c85f7 100644 --- a/README.rst +++ b/README.rst @@ -22,7 +22,7 @@ in the documentation. Some of the key features the included modules provide: - Input and output `JSON Schema `__ validation - by decorating RequestHandlers with ``io_schema`` + by decorating RequestHandlers with ``schema.validate`` - Automated *route generation* with ``routes.get_routes(package)`` - *Automated Public API documentation* using schemas and provided descriptions diff --git a/demos/helloworld/helloworld/api.py b/demos/helloworld/helloworld/api.py index cd0d639..25488b5 100755 --- a/demos/helloworld/helloworld/api.py +++ b/demos/helloworld/helloworld/api.py @@ -1,7 +1,7 @@ from tornado import gen from tornado_json.requesthandlers import APIHandler -from tornado_json.utils import io_schema +from tornado_json import schema class HelloWorldHandler(APIHandler): @@ -17,12 +17,12 @@ class HelloWorldHandler(APIHandler): "doc": "Shouts hello to the world!", } - # Decorate any HTTP methods with the `io_schema` decorator + # Decorate any HTTP methods with the `schema.validate` decorator # to validate input to it and output from it as per the # the schema for the method defined in `apid` # Simply use `return` rather than `self.write` to write back # your output. - @io_schema + @schema.validate def get(self): return "Hello world!" @@ -43,13 +43,13 @@ class AsyncHelloWorld(APIHandler): def hello(self, callback=None): callback("Hello (asynchronous) world!") - @io_schema + @schema.validate @gen.coroutine def get(self): # Asynchronously yield a result from a method res = yield gen.Task(self.hello) - # When using the io_schema decorator asynchronously, + # When using the `schema.validate` decorator asynchronously, # we can return the output desired by raising # `tornado.gen.Return(value)` which returns a # Future that the decorator will yield. @@ -97,9 +97,9 @@ class PostIt(APIHandler): """ } - @io_schema + @schema.validate def post(self): - # io_schema will JSON-decode `self.request.body` for us + # `schema.validate` 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"]) @@ -124,7 +124,7 @@ class Greeting(APIHandler): # 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 + @schema.validate def get(self, fname, lname): return "Greetings, {} {}!".format(fname, lname) @@ -135,6 +135,9 @@ class FreeWilledHandler(APIHandler): # if you want your handlers to do something more custom, # they definitely can. def get(self): + # If you don't know where `self.success` comes from, it is defined + # in the `JSendMixin` mixin in tornado_json.jsend. `APIHandler` + # inherits from this and thus gets the methods. 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: diff --git a/docs/changelog.rst b/docs/changelog.rst index 4b868a5..19f0338 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -4,6 +4,16 @@ Changelog _ --------- + +v0.20 - Refactor of ``utils`` module +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Functions that did not belong in ``utils`` were moved to more relevant modules. This change changes the interface for Tornado-JSON in quite a big way. The following changes were made (that are not backwards compatible). + +* ``api_assert`` and ``APIError`` were moved to ``tornado_json.exceptions`` +* ``io_schema`` was renamed ``validate`` and moved to ``tornado_json.schema`` + + v0.14 - Bugfixes thanks to 100% coverage ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/docs/index.rst b/docs/index.rst index ec7bf18..5075198 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -16,7 +16,7 @@ in the documentation. Some of the key features the included modules provide: - Input and output `JSON Schema `__ validation - by decorating RequestHandlers with ``io_schema`` + by decorating RequestHandlers with ``schema.validate`` - Automated *route generation* with ``routes.get_routes(package)`` - *Automated Public API documentation* using schemas and provided descriptions diff --git a/docs/requesthandler_guidelines.rst b/docs/requesthandler_guidelines.rst index abfb7de..0bef351 100644 --- a/docs/requesthandler_guidelines.rst +++ b/docs/requesthandler_guidelines.rst @@ -29,7 +29,7 @@ for an example. Here is an example for POST: ``doc`` is the **public** accompanying documentation that will be available on the wiki. -Use the ``io_schema`` decorator on methods which will automatically +Use the ``schema.validate`` decorator on methods which will automatically validate the request body and output against the schemas in ``apid[method_name]``. Additionally, ``return`` the data from the request handler, rather than writing it back (the decorator will take @@ -38,7 +38,7 @@ care of that). .. code:: python class ExampleHandler(APIHandler): - @io_schema + @schema.validate def post(self): ... return data @@ -48,15 +48,15 @@ Assertions ---------- -Use ``utils.api_assert`` to fail when some the client does not meet some +Use ``exceptions.api_assert`` to fail when some the client does not meet some API pre-condition/requirement, e.g., an invalid or incomplete request is made. When using an assertion is not suitable, -``raise APIError( ... )``; don't use JSend ``fail`` directly. +``raise APIError( ... )``; don't use ``self.fail`` directly. .. code:: python class ExampleHandler(APIHandler): - @io_schema + @schema.validate def post(self): ... api_assert(condition, status_code, log_message=log_message) diff --git a/docs/using_tornado_json.rst b/docs/using_tornado_json.rst index 13b94df..8262936 100644 --- a/docs/using_tornado_json.rst +++ b/docs/using_tornado_json.rst @@ -58,13 +58,13 @@ helloworld/api.py Now comes the fun part where we develop the actual web app. We'll import ``APIHandler`` (this is the handler you should subclass for API routes), -and the ``io_schema`` decorator which will validate input and output +and the ``schema.validate`` decorator which will validate input and output schema for us. .. code:: python from tornado_json.requesthandlers import APIHandler - from tornado_json.utils import io_schema + from tornado_json import schema class HelloWorldHandler(APIHandler): """Hello!""" @@ -95,12 +95,12 @@ back. Notice that rather than using ``self.write`` as we usually would, we simply return the data we want to write back, which will then be validated against the output schema and be written back according to the `JSend `__ specification. The -``io_schema`` decorator handles all of this so be sure to decorate any +``schema.validate`` decorator handles all of this so be sure to decorate any HTTP methods with it. .. code:: python - @io_schema + @schema.validate def get(self): return "Hello world!" diff --git a/tests/func_test.py b/tests/func_test.py index 2f59769..21f7d9b 100644 --- a/tests/func_test.py +++ b/tests/func_test.py @@ -5,7 +5,7 @@ try: sys.path.append('.') from tornado_json import routes - from tornado_json import utils + from tornado_json import schema from tornado_json import application from tornado_json import requesthandlers sys.path.append('demos/helloworld') @@ -68,11 +68,11 @@ class ExplodingHandler(requesthandlers.APIHandler): }, } - @utils.io_schema + @schema.validate def get(self): return "I am not the handler you are looking for." - @utils.io_schema + @schema.validate def post(self): return "Fission mailed." diff --git a/tests/test_tornado_json.py b/tests/test_tornado_json.py index d953ad9..955cb93 100644 --- a/tests/test_tornado_json.py +++ b/tests/test_tornado_json.py @@ -6,7 +6,8 @@ try: sys.path.append('.') from tornado_json import routes - from tornado_json import utils + from tornado_json import schema + from tornado_json import exceptions from tornado_json import jsend sys.path.append('demos/helloworld') import helloworld @@ -28,7 +29,7 @@ class Request(object): request = Request() def fail(message): - raise utils.APIError(message) + raise exceptions.APIError(message) def success(self, message): raise SuccessException @@ -78,15 +79,15 @@ class TestUtils(TestTornadoJSONBase): """Tests the utils module""" def test_api_assert(self): - """Test utils.api_assert""" - with pytest.raises(utils.APIError): - utils.api_assert(False, 400) + """Test exceptions.api_assert""" + with pytest.raises(exceptions.APIError): + exceptions.api_assert(False, 400) - utils.api_assert(True, 400) + exceptions.api_assert(True, 400) class TerribleHandler(MockRequestHandler): - """This 'handler' is used in test_io_schema""" + """This 'handler' is used in test_validate""" apid = { "get": { @@ -105,17 +106,17 @@ class TerribleHandler(MockRequestHandler): }, } - @utils.io_schema + @schema.validate def get(self): return "I am not the handler you are looking for." - @utils.io_schema + @schema.validate def post(self): return "Fission mailed." class ReasonableHandler(MockRequestHandler): - """This 'handler' is used in test_io_schema""" + """This 'handler' is used in test_validate""" apid = { "get": { @@ -138,23 +139,23 @@ class ReasonableHandler(MockRequestHandler): }, } - @utils.io_schema + @schema.validate def get(self, fname, lname): return "I am the handler you are looking for, {} {}".format( fname, lname) - @utils.io_schema + @schema.validate def post(self): # Test that self.body is available as expected assert self.body == {"I am a": "JSON object"} return "Mail received." - # DONE: Test io_schema functionally instead; pytest.raises does + # DONE: Test validate 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""" + # def test_validate(self): + # """Tests the schema.validate decorator""" # th = self.TerribleHandler() # rh = self.ReasonableHandler() diff --git a/tornado_json/__init__.py b/tornado_json/__init__.py index cdaba45..c9152ac 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.14' +__version__ = '0.20' diff --git a/tornado_json/exceptions.py b/tornado_json/exceptions.py new file mode 100644 index 0000000..1538c6f --- /dev/null +++ b/tornado_json/exceptions.py @@ -0,0 +1,18 @@ +from tornado.web import HTTPError + + +class APIError(HTTPError): + + """Equivalent to ``RequestHandler.HTTPError`` except for in name""" + + +def api_assert(condition, *args, **kwargs): + """Assertion to fail with if not ``condition`` + + Asserts that ``condition`` is ``True``, else raises an ``APIError`` + with the provided ``args`` and ``kwargs`` + + :type condition: bool + """ + if not condition: + raise APIError(*args, **kwargs) diff --git a/tornado_json/requesthandlers.py b/tornado_json/requesthandlers.py index fbaabf3..a0dc91f 100644 --- a/tornado_json/requesthandlers.py +++ b/tornado_json/requesthandlers.py @@ -4,7 +4,7 @@ from jsonschema import ValidationError from tornado_json.jsend import JSendMixin -from tornado_json.utils import APIError +from tornado_json.exceptions import APIError class BaseHandler(RequestHandler): diff --git a/tornado_json/routes.py b/tornado_json/routes.py index 5c4e5ec..9624fc5 100644 --- a/tornado_json/routes.py +++ b/tornado_json/routes.py @@ -72,7 +72,7 @@ def extract_method(wrapped_method): :rtype: any([types.FunctionType, types.MethodType]) """ - # If method was decorated with io_schema, the original method + # If method was decorated with validate, the original method # is available as orig_func thanks to our container decorator return wrapped_method.orig_func if \ hasattr(wrapped_method, "orig_func") else wrapped_method diff --git a/tornado_json/schema.py b/tornado_json/schema.py new file mode 100644 index 0000000..3ecc7c4 --- /dev/null +++ b/tornado_json/schema.py @@ -0,0 +1,90 @@ +import json +import jsonschema + +from tornado import gen +from tornado.concurrent import Future + +from tornado_json.utils import container + + +@container +def validate(rh_method): + """Decorator for RequestHandler schema validation + + This decorator: + + - Validates request body against input schema of the method + - Calls the ``rh_method`` and gets output from it + - Validates output against output schema of the method + - Calls ``JSendMixin.success`` to write the validated output + + :type rh_method: function + :param rh_method: The RequestHandler method to be decorated + :returns: The decorated method + :raises ValidationError: If input is invalid as per the schema or malformed + :raises TypeError: If the output is invalid as per the schema or malformed + """ + + @gen.coroutine + def _wrapper(self, *args, **kwargs): + # Get name of method + method_name = rh_method.__name__ + + # Special case for GET, DELETE requests (since there is no data to + # validate) + if method_name not in ["get", "delete"]: + # If input is not valid JSON, fail + try: + # 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: + raise jsonschema.ValidationError( + "Input is malformed; could not decode JSON object." + ) + + # Validate the received input + jsonschema.validate( + input_, + type(self).apid[method_name]["input_schema"] + ) + else: + input_ = None + + # A json.loads'd version of self.request["body"] is now available + # as self.body + setattr(self, "body", input_) + # Call the requesthandler method + output = rh_method(self, *args, **kwargs) + # If the rh_method returned a Future a la `raise Return(value)` + # we grab the output. + if isinstance(output, Future): + output = yield output + + # We wrap output in an object before validating in case + # output is a string (and ergo not a validatable JSON object) + try: + jsonschema.validate( + {"result": output}, + { + "type": "object", + "properties": { + "result": type(self) + .apid[method_name]["output_schema"] + }, + "required": ["result"] + } + ) + except jsonschema.ValidationError as e: + # We essentially re-raise this as a TypeError because + # we don't want this error data passed back to the client + # because it's a fault on our end. The client should + # only see a 500 - Internal Server Error. + raise TypeError(str(e)) + + # If no ValidationError has been raised up until here, we write + # back output + self.success(output) + return _wrapper diff --git a/tornado_json/utils.py b/tornado_json/utils.py index ec37104..ce1cea8 100644 --- a/tornado_json/utils.py +++ b/tornado_json/utils.py @@ -1,30 +1,4 @@ -# TODO: Better organization for contents of this module - -import json -import logging from functools import wraps -from jsonschema import validate, ValidationError - -from tornado import gen -from tornado.web import HTTPError -from tornado.concurrent import Future - - -class APIError(HTTPError): - - """Equivalent to ``RequestHandler.HTTPError`` except for in name""" - - -def api_assert(condition, *args, **kwargs): - """Assertion to fail with if not ``condition`` - - Asserts that ``condition`` is ``True``, else raises an ``APIError`` - with the provided ``args`` and ``kwargs`` - - :type condition: bool - """ - if not condition: - raise APIError(*args, **kwargs) def container(dec): @@ -43,86 +17,3 @@ def meta_decorator(f): decorator.orig_func = f return decorator return meta_decorator - - -@container -def io_schema(rh_method): - """Decorator for RequestHandler schema validation - - This decorator: - - - Validates request body against input schema of the method - - Calls the ``rh_method`` and gets output from it - - Validates output against output schema of the method - - Calls ``JSendMixin.success`` to write the validated output - - :type rh_method: function - :param rh_method: The RequestHandler method to be decorated - :returns: The decorated method - :raises ValidationError: If input is invalid as per the schema or malformed - :raises TypeError: If the output is invalid as per the schema or malformed - """ - - @gen.coroutine - def _wrapper(self, *args, **kwargs): - # Get name of method - method_name = rh_method.__name__ - - # Special case for GET, DELETE requests (since there is no data to - # validate) - if method_name not in ["get", "delete"]: - # If input is not valid JSON, fail - try: - # 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: - raise ValidationError( - "Input is malformed; could not decode JSON object." - ) - - # Validate the received input - validate( - input_, - type(self).apid[method_name]["input_schema"] - ) - else: - input_ = None - - # A json.loads'd version of self.request["body"] is now available - # as self.body - setattr(self, "body", input_) - # Call the requesthandler method - output = rh_method(self, *args, **kwargs) - # If the rh_method returned a Future a la `raise Return(value)` - # we grab the output. - if isinstance(output, Future): - output = yield output - - # We wrap output in an object before validating in case - # output is a string (and ergo not a validatable JSON object) - try: - validate( - {"result": output}, - { - "type": "object", - "properties": { - "result": type(self) - .apid[method_name]["output_schema"] - }, - "required": ["result"] - } - ) - except ValidationError as e: - # We essentially re-raise this as a TypeError because - # we don't want this error data passed back to the client - # because it's a fault on our end. The client should - # only see a 500 - Internal Server Error. - raise TypeError(str(e)) - - # If no ValidationError has been raised up until here, we write - # back output - self.success(output) - return _wrapper