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