diff --git a/demos/helloworld/API_Documentation.md b/demos/helloworld/API_Documentation.md index 5972877..83ed39c 100644 --- a/demos/helloworld/API_Documentation.md +++ b/demos/helloworld/API_Documentation.md @@ -2,7 +2,7 @@ **Output schemas only represent `data` and not the full output; see output examples and the JSend specification.** -# /api/asynchelloworld +# /api/asynchelloworld/? Content-Type: application/json @@ -66,7 +66,7 @@ Greets you.

-# /api/helloworld +# /api/helloworld/? Content-Type: application/json @@ -98,7 +98,7 @@ Shouts hello to the world!

-# /api/postit +# /api/postit/? Content-Type: application/json diff --git a/demos/helloworld/helloworld.py b/demos/helloworld/helloworld.py index 12160ba..1b554b5 100755 --- a/demos/helloworld/helloworld.py +++ b/demos/helloworld/helloworld.py @@ -5,12 +5,11 @@ sys.path.append("../../") # ---- Can be removed if Tornado-JSON is installed ----# +import json import tornado.ioloop from tornado_json.routes import get_routes from tornado_json.application import Application -import helloworld - def main(): # Pass the web app's package the get_routes and it will generate @@ -18,8 +17,12 @@ def main(): # request handler name (with 'handler' removed from the end of the # name if it is the name). # [("/api/helloworld", helloworld.api.HelloWorldHandler)] + import helloworld routes = get_routes(helloworld) - + print("Routes\n======\n\n" + json.dumps( + [(url, repr(rh)) for url, rh in routes], + indent=2) + ) # Create the application by passing routes and any settings application = Application(routes=routes, settings={}) diff --git a/demos/rest_api/API_Documentation.md b/demos/rest_api/API_Documentation.md new file mode 100644 index 0000000..77353d0 --- /dev/null +++ b/demos/rest_api/API_Documentation.md @@ -0,0 +1,3 @@ +**This documentation is automatically generated.** + +**Output schemas only represent `data` and not the full output; see output examples and the JSend specification.** diff --git a/demos/rest_api/app.py b/demos/rest_api/app.py new file mode 100755 index 0000000..155747a --- /dev/null +++ b/demos/rest_api/app.py @@ -0,0 +1,32 @@ +#!/usr/bin/env python2.7 + +# ---- The following so demo can be run without having to install package ----# +import sys +sys.path.append("../../") +# ---- Can be removed if Tornado-JSON is installed ----# + +# This module contains essentially the same boilerplate +# as the corresponding one in the helloworld example; +# refer to that for details. + +import json +import tornado.ioloop +from tornado_json.routes import get_routes +from tornado_json.application import Application + + +def main(): + import cars + routes = get_routes(cars) + print("Routes\n======\n\n" + json.dumps( + [(url, repr(rh)) for url, rh in routes], + indent=2) + ) + application = Application(routes=routes, settings={}) + + application.listen(8888) + tornado.ioloop.IOLoop.instance().start() + + +if __name__ == '__main__': + main() diff --git a/demos/rest_api/cars/__init__.py b/demos/rest_api/cars/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/demos/rest_api/cars/api/__init__.py b/demos/rest_api/cars/api/__init__.py new file mode 100644 index 0000000..0be9a3d --- /dev/null +++ b/demos/rest_api/cars/api/__init__.py @@ -0,0 +1,116 @@ +from tornado_json.requesthandlers import APIHandler + + +DATA = { + "Ford": { + "Fusion": { + "2013": "http://www.ford.ca/cars/fusion/2013/", + "2014": "http://www.ford.ca/cars/fusion/2014/" + }, + "Taurus": { + "2013": "http://www.ford.ca/cars/taurus/2013/", + "2014": "http://www.ford.ca/cars/taurus/2014/" + } + } +} + + +class CarsAPIHandler(APIHandler): + + # APIHandler has two "special" attributes: + # * __url_names__ : + # This is a list of names you'd like the + # the requesthandler to be called in auto- + # generated routes, i.e., since the absolute + # path of this handler (in context of the + # ``cars`` package) is, ``cars.api.CarsAPIHandler``, + # if you've read earlier documentation, you'll + # know that the associated URL to this that + # will be autogenerated is "/api/carsapi". + # __url_names__ can change the last ``carsapi`` + # part to whatever is in the list. So in this + # case, we change it to "/api/cars". If we added + # additional names to the list, we would generate + # more routes with the given names. + # Of course, this isn't an actual handler, just + # a handy superclass that we'll let all of the handlers + # below inherit from so they can have a base URL of + # "/api/cars" also, but extended to match additional + # things based on the parameters of their ``get`` methods. + # + # An important note on __url_names__ is that by default, + # it exists as ``__url_names__ = ["__self__"]``. The + # ``__self__`` is a special value, which means that the + # requesthandler should get a URL such as the ones you + # assigned to the handlers in the Hello World demo. + # You can either ADD to the list to keep this, or + # create a new list to not. + # + # * __urls__ : + # I'll mention __urls__ as well; this let's you just + # assign a completely custom URL pattern to match to + # the requesthandlers, i.e., I could add something like, + # ``__urls__ = [r"/api/cars/?"]`` + # and that would give me the exact same URL mapped + # to this handler, but defined by me. Note that both + # URLs generated from ``__url_names__`` and URLs provided + # by you in ``__urls__`` will be created and assigned to + # the associated requesthandler, so make sure to modify/overwrite + # both attributes to get only the URLs mapped that you want. + # + __url_names__ = ["cars"] + + +class MakeListHandler(CarsAPIHandler): + + def get(self): + self.success(DATA.keys()) + + +class MakeHandler(CarsAPIHandler): + + def get(self, make): + try: + self.success(DATA[make]) + except KeyError: + self.fail("No data on such make `{}`.".format(make)) + + +class ModelHandler(CarsAPIHandler): + + def get(self, make, model): + try: + self.success(DATA[make][model]) + except KeyError: + self.fail("No data on `{} {}`.".format(make, model)) + + +class YearHandler(CarsAPIHandler): + + def get(self, make, model, year): + try: + self.success(DATA[make][model][year]) + except KeyError: + self.fail("No data on `{} {} {}`.".format(year, make, model)) + + +# Routes for the handlers above will look like this: +# +# [ +# [ +# "/api/cars/?", +# "" +# ], +# [ +# "/api/cars/(?P[a-zA-Z0-9_]+)/(?P[a-zA-Z0-9_]+)/?$", +# "" +# ], +# [ +# "/api/cars/(?P[a-zA-Z0-9_]+)/(?P[a-zA-Z0-9_]+)/(?P[a-zA-Z0-9_]+)/?$", +# "" +# ], +# [ +# "/api/cars/(?P[a-zA-Z0-9_]+)/?$", +# "" +# ] +# ] diff --git a/docs/changelog.rst b/docs/changelog.rst index 19f0338..67a08da 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -5,6 +5,15 @@ _ --------- +v0.30 - URL Annotations +~~~~~~~~~~~~~~~~~~~~~~~ + +* Added ``__urls__`` and ``__url_names__`` attributes to allow flexible creation of custom URLs that make creating REST APIs etc. easy +* Added a REST API demo as an example for URL annotations +* Added URL annotations documentation +* Refactored and improved route generation in ``routes`` + + v0.20 - Refactor of ``utils`` module ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/docs/index.rst b/docs/index.rst index 5075198..0ae7448 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -32,6 +32,7 @@ Some of the key features the included modules provide: using_tornado_json requesthandler_guidelines docgen + restapi changelog tornado_json diff --git a/docs/restapi.rst b/docs/restapi.rst new file mode 100644 index 0000000..52fc932 --- /dev/null +++ b/docs/restapi.rst @@ -0,0 +1,16 @@ +Creating a REST API Using URL Annotations +========================================= + +You may have noticed that the automatic URL generation +is meant to be quick and easy-to-use for simple cases (creating an +API in 15 minutes kind of thing). + +It is more powerful though, however, as you can customize it +to get the URLs for RequestHandlers how you want without +having to make additions to output from ``routes.get_routes`` +yourself. This is done through the use of "URL annotations". +``APIHandler`` and ``ViewHandler`` have two "magic" attributes +(``__urls__`` and ``__url_names__``) that allow you to define custom routes right in the handler +body. See relevant documentation in the +`REST API `__ +example in the demos. diff --git a/tests/test_tornado_json.py b/tests/test_tornado_json.py index 955cb93..bc2030e 100644 --- a/tests/test_tornado_json.py +++ b/tests/test_tornado_json.py @@ -10,7 +10,9 @@ from tornado_json import exceptions from tornado_json import jsend sys.path.append('demos/helloworld') + sys.path.append('demos/rest_api') import helloworld + import cars except ImportError as e: print("Please run `py.test` from the root project directory") exit(1) @@ -48,12 +50,22 @@ def test_get_routes(self): """Tests routes.get_routes""" assert sorted(routes.get_routes( helloworld)) == sorted([ - ("/api/helloworld", helloworld.api.HelloWorldHandler), - ("/api/asynchelloworld", helloworld.api.AsyncHelloWorld), - ("/api/postit", helloworld.api.PostIt), - ("/api/greeting/(?P[a-zA-Z0-9_]+)/(?P[a-zA-Z0-9_]+)/?$", + ("/api/helloworld/?", helloworld.api.HelloWorldHandler), + ("/api/asynchelloworld/?", helloworld.api.AsyncHelloWorld), + ("/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) + ("/api/freewilled/?", helloworld.api.FreeWilledHandler) + ]) + assert sorted(routes.get_routes( + cars)) == sorted([ + ("/api/cars/?", cars.api.MakeListHandler), + ("/api/cars/(?P[a-zA-Z0-9_]+)/(?P[a-zA-Z0-9_]+)/?$", + cars.api.ModelHandler), + ("/api/cars/(?P[a-zA-Z0-9_]+)/(?P[a-zA-Z0-9_]+)/" + "(?P[a-zA-Z0-9_]+)/?$", cars.api.YearHandler), + ("/api/cars/(?P[a-zA-Z0-9_]+)/?$", cars.api.MakeHandler), ]) def test_gen_submodule_names(self): @@ -64,13 +76,13 @@ def test_gen_submodule_names(self): def test_get_module_routes(self): """Tests routes.get_module_routes""" assert sorted(routes.get_module_routes( - 'helloworld.api')) == sorted([ - ("/api/helloworld", helloworld.api.HelloWorldHandler), - ("/api/asynchelloworld", helloworld.api.AsyncHelloWorld), - ("/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) + "cars.api")) == sorted([ + ("/api/cars/?", cars.api.MakeListHandler), + ("/api/cars/(?P[a-zA-Z0-9_]+)/(?P[a-zA-Z0-9_]+)/?$", + cars.api.ModelHandler), + ("/api/cars/(?P[a-zA-Z0-9_]+)/(?P[a-zA-Z0-9_]+)/" + "(?P[a-zA-Z0-9_]+)/?$", cars.api.YearHandler), + ("/api/cars/(?P[a-zA-Z0-9_]+)/?$", cars.api.MakeHandler), ]) @@ -159,15 +171,15 @@ def post(self): # th = self.TerribleHandler() # rh = self.ReasonableHandler() - # # Expect a TypeError to be raised because of invalid output + # Expect a TypeError to be raised because of invalid output # with pytest.raises(TypeError): # th.get("Duke", "Flywalker") - # # Expect a validation error because of invalid input + # Expect a validation error because of invalid input # with pytest.raises(ValidationError): # th.post() - # # Both of these should succeed as the body matches the schema + # Both of these should succeed as the body matches the schema # with pytest.raises(SuccessException): # rh.get("J", "S") # with pytest.raises(SuccessException): diff --git a/tornado_json/__init__.py b/tornado_json/__init__.py index c9152ac..f3b7f24 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.20' +__version__ = '0.30' diff --git a/tornado_json/requesthandlers.py b/tornado_json/requesthandlers.py index a0dc91f..d6a0770 100644 --- a/tornado_json/requesthandlers.py +++ b/tornado_json/requesthandlers.py @@ -11,6 +11,9 @@ class BaseHandler(RequestHandler): """BaseHandler for all other RequestHandlers""" + __url_names__ = ["__self__"] + __urls__ = [] + @property def db_conn(self): """Returns database connection abstraction diff --git a/tornado_json/routes.py b/tornado_json/routes.py index 9624fc5..b6502fd 100644 --- a/tornado_json/routes.py +++ b/tornado_json/routes.py @@ -101,6 +101,66 @@ def yield_args(module, cls_name, method_name): method = extract_method(wrapped_method) return [a for a in inspect.getargspec(method).args if a not in ["self"]] + def generate_auto_route(module, module_name, cls_name, method_name, url_name): + """Generate URL for auto_route + + :rtype: str + :returns: Constructed URL based on given arguments + """ + def get_handler_name(): + """Get handler identifier for URL + + For the special case where ``url_name`` is + ``__self__``, the handler is named a lowercase + value of its own name with 'handler' removed + from the ending if give; otherwise, we + simply use the provided ``url_name`` + """ + if url_name == "__self__": + if cls_name.lower().endswith('handler'): + return cls_name.lower().replace('handler', '', 1) + return cls_name.lower() + else: + return url_name + + def get_arg_route(): + """Get remainder of URL determined by method argspec + + :returns: Remainder of URL which matches `\w+` regex + with groups named by the method's argument spec. + If there are no arguments given, returns ``""``. + :rtype: str + """ + if yield_args(module, cls_name, method_name): + return "/{}/?$".format("/".join( + ["(?P<{}>[a-zA-Z0-9_]+)".format(argname) for argname + in yield_args(module, cls_name, method_name)] + )) + return r"/?" + + return "/{}/{}{}".format( + "/".join(module_name.split(".")[1:]), + get_handler_name(), + get_arg_route() + ) + + def is_handler_subclass(cls): + """Determines if ``cls`` is indeed a subclass of either + ViewHandler or APIHandler + """ + if isinstance(cls, pyclbr.Class): + return is_handler_subclass(cls.super) + elif isinstance(cls, list): + return any(is_handler_subclass(s) for s in cls) + elif isinstance(cls, str): + return cls in ["ViewHandler", "APIHandler"] + else: + raise TypeError( + "Unexpected pyclbr.Class.super type `{}`".format( + type(cls) + ) + ) + if not custom_routes: custom_routes = [] if not exclusions: @@ -117,34 +177,42 @@ def yield_args(module, cls_name, method_name): # You better believe this is a list comprehension auto_routes = list(chain(*[ - list(set([ - # URL, requesthandler tuple - ( - "/{}/{}{}".format( - "/".join(module_name.split(".")[1:]), - k.lower().replace('handler', '', 1) if - k.lower().endswith('handler') else k.lower(), - "/{}/?$".format("/".join( - ["(?P<{}>[a-zA-Z0-9_]+)".format(argname) for argname - in yield_args(module, k, method_name)] - )) if yield_args(module, k, method_name) else "" - ), - getattr(module, k) - ) + list(set(chain(*[ + # Generate a route for each "name" specified in the + # __url_names__ attribute of the handler + [ + # URL, requesthandler tuple + ( + generate_auto_route( + module, module_name, cls_name, method_name, url_name + ), + getattr(module, cls_name) + ) for url_name in getattr(module, cls_name).__url_names__ + # Add routes for each custom URL specified in the + # __urls__ attribute of the handler + ] + [ + ( + url, + getattr(module, cls_name) + ) for url in getattr(module, cls_name).__urls__ + ] + # We create a route for each HTTP method in the handler + # so that we catch all possible routes if different + # HTTP methods have different argspecs and are expecting + # to catch different routes. Any duplicate routes + # are removed from the set() comparison. for method_name in [ "get", "put", "post", "patch", "delete", "head", "options" - ] if has_method(module, k, method_name) - ])) + ] if has_method(module, cls_name, method_name) + ]))) # foreach classname, pyclbr.Class in rhs - for k, v in rhs.items() + for cls_name, cls in rhs.items() # Only add the pair to auto_routes if: # * the superclass is in the list of supers we want # * the requesthandler isn't already paired in custom_routes # * the requesthandler isn't manually excluded - if any( - True for s in v.super if s in ["ViewHandler", "APIHandler"] - ) - and k not in (custom_routes_s + exclusions) + if is_handler_subclass(cls) + and cls_name not in (custom_routes_s + exclusions) ])) routes = auto_routes + custom_routes