← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~allenap/maas/walk-django-resolver into lp:maas

 

Gavin Panella has proposed merging lp:~allenap/maas/walk-django-resolver into lp:maas.

Commit message:
Walks the Django resolver configuration to find out the exposed API resources.

Previously the maasserver.api module was scanned, but this does not necessarily correspond to exposed methods.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)
Related bugs:
  Bug #1058316 in MAAS: "maas-cli reports unknown operation if you're logged in with the wrong credentials"
  https://bugs.launchpad.net/maas/+bug/1058316

For more details, see:
https://code.launchpad.net/~allenap/maas/walk-django-resolver/+merge/128359

First branch of several.
-- 
https://code.launchpad.net/~allenap/maas/walk-django-resolver/+merge/128359
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~allenap/maas/walk-django-resolver into lp:maas.
=== modified file 'src/maasserver/api.py'
--- src/maasserver/api.py	2012-10-05 08:04:58 +0000
+++ src/maasserver/api.py	2012-10-06 17:03:24 +0000
@@ -112,8 +112,8 @@
 from formencode import validators
 from formencode.validators import Invalid
 from maasserver.apidoc import (
-    describe_handler,
-    find_api_handlers,
+    describe_resource,
+    find_api_resources,
     generate_api_docs,
     )
 from maasserver.components import (
@@ -1501,6 +1501,8 @@
     :return: Documentation, in ReST, for the API.
     :rtype: :class:`unicode`
     """
+    from maasserver import urls_api as urlconf
+
     module = sys.modules[__name__]
     output = StringIO()
     line = partial(print, file=output)
@@ -1512,8 +1514,8 @@
     line('----------')
     line()
 
-    handlers = find_api_handlers(module)
-    for doc in generate_api_docs(handlers):
+    resources = find_api_resources(urlconf)
+    for doc in generate_api_docs(resources):
         uri_template = doc.resource_uri_template
         exports = doc.handler.exports.items()
         for (http_method, operation), function in sorted(exports):
@@ -1719,14 +1721,16 @@
 
     Returns a JSON object describing the whole MAAS API.
     """
-    module = sys.modules[__name__]
+    from maasserver import urls_api as urlconf
     description = {
         "doc": "MAAS API",
-        "handlers": [
-            describe_handler(handler)
-            for handler in find_api_handlers(module)
+        "resources": [
+            describe_resource(resource)
+            for resource in find_api_resources(urlconf)
             ],
         }
+    # For backward compatibility, add "handlers" as an alias for "resources".
+    description["handlers"] = description["resources"]
     return HttpResponse(
         json.dumps(description),
         content_type="application/json")

=== modified file 'src/maasserver/apidoc.py'
--- src/maasserver/apidoc.py	2012-09-26 21:57:08 +0000
+++ src/maasserver/apidoc.py	2012-10-06 17:03:24 +0000
@@ -12,7 +12,8 @@
 __metaclass__ = type
 __all__ = [
     "describe_handler",
-    "find_api_handlers",
+    "describe_resource",
+    "find_api_resources",
     "generate_api_docs",
     ]
 
@@ -21,36 +22,53 @@
 from urlparse import urljoin
 
 from django.conf import settings
+from django.core.urlresolvers import (
+    get_resolver,
+    RegexURLPattern,
+    RegexURLResolver,
+    )
 from piston.doc import generate_doc
-from piston.handler import HandlerMetaClass
-
-
-def find_api_handlers(module):
-    """Find the API handlers defined in `module`.
+from piston.handler import BaseHandler
+from piston.resource import Resource
+
+
+def accumulate_api_resources(resolver, accumulator):
+    """Accumulate handlers from the given resolver.
 
     Handlers are of type :class:`HandlerMetaClass`, and must define a
     `resource_uri` method.
 
     :rtype: Generator, yielding handlers.
     """
-    try:
-        names = module.__all__
-    except AttributeError:
-        names = sorted(
-            name for name in dir(module)
-            if not name.startswith("_"))
-    for name in names:
-        candidate = getattr(module, name)
-        if isinstance(candidate, HandlerMetaClass):
-            if getattr(candidate, "resource_uri", None) is not None:
-                yield candidate
-
-
-def generate_api_docs(handlers):
+    p_has_resource_uri = lambda resource: (
+        getattr(resource.handler, "resource_uri", None) is not None)
+    for pattern in resolver.url_patterns:
+        if isinstance(pattern, RegexURLResolver):
+            accumulate_api_resources(pattern, accumulator)
+        elif isinstance(pattern, RegexURLPattern):
+            if isinstance(pattern.callback, Resource):
+                resource = pattern.callback
+                if p_has_resource_uri(resource):
+                    accumulator.add(resource)
+        else:
+            raise AssertionError(
+                "Not a recognised pattern or resolver: %r" % (pattern,))
+
+
+def find_api_resources(urlconf=None):
+    """Find the API resources defined in `urlconf`.
+
+    :rtype: :class:`set` of :class:`Resource` instances.
+    """
+    resolver, accumulator = get_resolver(urlconf), set()
+    accumulate_api_resources(resolver, accumulator)
+    return accumulator
+
+
+def generate_api_docs(resources):
     """Generate ReST documentation objects for the ReST API.
 
-    Yields Piston Documentation objects describing the current registered
-    handlers.
+    Yields Piston Documentation objects describing the given resources.
 
     This also ensures that handlers define 'resource_uri' methods. This is
     easily forgotten and essential in order to generate proper documentation.
@@ -58,7 +76,8 @@
     :return: Generates :class:`piston.doc.HandlerDocumentation` instances.
     """
     sentinel = object()
-    for handler in handlers:
+    for resource in resources:
+        handler = type(resource.handler)
         if getattr(handler, "resource_uri", sentinel) is sentinel:
             raise AssertionError(
                 "Missing resource_uri in %s" % handler.__name__)
@@ -111,8 +130,12 @@
     """Return a serialisable description of a handler.
 
     :type handler: :class:`OperationsHandler` or
-        :class:`AnonymousOperationsHandler` instance.
+        :class:`AnonymousOperationsHandler` instance or subclass.
     """
+    # Want the class, not an instance.
+    if isinstance(handler, BaseHandler):
+        handler = type(handler)
+
     uri_template = generate_doc(handler).resource_uri_template
     if uri_template is None:
         uri_template = settings.DEFAULT_MAAS_URL
@@ -132,3 +155,11 @@
         "params": uri_params,
         "uri": uri_template,
         }
+
+
+def describe_resource(resource):
+    """Return a serialisable description of a resource.
+
+    :type resource: :class:`OperationsResource` instance.
+    """
+    return describe_handler(resource.handler)

=== modified file 'src/maasserver/tests/test_api.py'
--- src/maasserver/tests/test_api.py	2012-10-05 08:04:58 +0000
+++ src/maasserver/tests/test_api.py	2012-10-06 17:03:24 +0000
@@ -3821,5 +3821,6 @@
     def test_describe(self):
         response = self.client.get(reverse('describe'))
         description = json.loads(response.content)
-        self.assertSetEqual({"doc", "handlers"}, set(description))
+        self.assertSetEqual(
+            {"doc", "handlers", "resources"}, set(description))
         self.assertIsInstance(description["handlers"], list)

=== modified file 'src/maasserver/tests/test_apidoc.py'
--- src/maasserver/tests/test_apidoc.py	2012-09-26 23:21:46 +0000
+++ src/maasserver/tests/test_apidoc.py	2012-10-06 17:03:24 +0000
@@ -15,23 +15,32 @@
 import new
 
 from django.conf import settings
+from django.conf.urls import (
+    include,
+    patterns,
+    url,
+    )
+from django.core.exceptions import ImproperlyConfigured
 from maasserver.api import (
     operation,
     OperationsHandler,
+    OperationsResource,
     )
 from maasserver.apidoc import (
     describe_handler,
-    find_api_handlers,
+    describe_resource,
+    find_api_resources,
     generate_api_docs,
     )
 from maasserver.testing.factory import factory
 from maasserver.testing.testcase import TestCase
 from piston.doc import HandlerDocumentation
 from piston.handler import BaseHandler
-
-
-class TestFindingHandlers(TestCase):
-    """Tests for API inspection support: finding handlers."""
+from piston.resource import Resource
+
+
+class TestFindingResources(TestCase):
+    """Tests for API inspection support: finding resources."""
 
     @staticmethod
     def make_module():
@@ -39,98 +48,116 @@
         name = factory.make_name("module").encode("ascii")
         return new.module(name)
 
-    def test_empty_module(self):
-        # No handlers are found in empty modules.
-        module = self.make_module()
-        module.__all__ = []
-        self.assertSequenceEqual(
-            [], list(find_api_handlers(module)))
-
-    def test_empty_module_without_all(self):
-        # The absence of __all__ does not matter.
-        module = self.make_module()
-        self.assertSequenceEqual(
-            [], list(find_api_handlers(module)))
-
-    def test_ignore_non_handlers(self):
-        # Module properties that are not handlers are ignored.
-        module = self.make_module()
-        module.something = 123
-        self.assertSequenceEqual(
-            [], list(find_api_handlers(module)))
-
-    def test_module_with_incomplete_handler(self):
-        # Handlers that don't have a resource_uri method are ignored.
-        module = self.make_module()
-        module.handler = BaseHandler
-        self.assertSequenceEqual(
-            [], list(find_api_handlers(module)))
-
-    def test_module_with_handler(self):
-        # Handlers with resource_uri attributes are discovered in a module and
-        # returned. The type of resource_uri is not checked; it must only be
-        # present and not None.
-        module = self.make_module()
-        module.handler = type(
-            b"MetalHander", (BaseHandler,), {"resource_uri": True})
-        self.assertSequenceEqual(
-            [module.handler], list(find_api_handlers(module)))
-
-    def test_module_with_handler_not_in_all(self):
-        # When __all__ is defined, only the names it defines are searched for
-        # handlers.
-        module = self.make_module()
-        module.handler = BaseHandler
-        module.something = "abc"
-        module.__all__ = ["something"]
-        self.assertSequenceEqual(
-            [], list(find_api_handlers(module)))
+    def test_urlpatterns_empty(self):
+        # No resources are found in empty modules.
+        module = self.make_module()
+        module.urlpatterns = patterns("")
+        self.assertSetEqual(set(), find_api_resources(module))
+
+    def test_urlpatterns_not_present(self):
+        # The absence urlpatterns is an error.
+        module = self.make_module()
+        self.assertRaises(ImproperlyConfigured, find_api_resources, module)
+
+    def test_urlpatterns_with_resource_for_incomplete_handler(self):
+        # Resources for handlers that don't specify resource_uri are ignored.
+        module = self.make_module()
+        module.urlpatterns = patterns("", url("^foo", BaseHandler))
+        self.assertSetEqual(set(), find_api_resources(module))
+
+    def test_urlpatterns_with_resource(self):
+        # Resources for handlers with resource_uri attributes are discovered
+        # in a urlconf module and returned. The type of resource_uri is not
+        # checked; it must only be present and not None.
+        handler = type(b"\m/", (BaseHandler,), {"resource_uri": True})
+        resource = Resource(handler)
+        module = self.make_module()
+        module.urlpatterns = patterns("", url("^metal", resource))
+        self.assertSetEqual({resource}, find_api_resources(module))
+
+    def test_nested_urlpatterns_with_handler(self):
+        # Resources are found in nested urlconfs.
+        handler = type(b"\m/", (BaseHandler,), {"resource_uri": True})
+        resource = Resource(handler)
+        module = self.make_module()
+        submodule = self.make_module()
+        submodule.urlpatterns = patterns("", url("^metal", resource))
+        module.urlpatterns = patterns("", ("^genre/", include(submodule)))
+        self.assertSetEqual({resource}, find_api_resources(module))
+
+    def test_smoke(self):
+        # Resources are found for the MAAS API.
+        from maasserver import urls_api as urlconf
+        self.assertNotEqual(set(), find_api_resources(urlconf))
 
 
 class TestGeneratingDocs(TestCase):
     """Tests for API inspection support: generating docs."""
 
     @staticmethod
-    def make_handler():
+    def make_resource():
         """
-        Return a new `BaseHandler` subclass with a fabricated name and a
-        `resource_uri` class-method.
+        Return a new `OperationsResource` with a `BaseHandler` subclass
+        handler, with a fabricated name and a `resource_uri` class-method.
         """
         name = factory.make_name("handler").encode("ascii")
         resource_uri = lambda cls: factory.make_name("resource-uri")
         namespace = {"resource_uri": classmethod(resource_uri)}
-        return type(name, (BaseHandler,), namespace)
+        handler = type(name, (BaseHandler,), namespace)
+        return OperationsResource(handler)
 
     def test_generates_doc_for_handler(self):
         # generate_api_docs() yields HandlerDocumentation objects for the
         # handlers passed in.
-        handler = self.make_handler()
-        docs = list(generate_api_docs([handler]))
+        resource = self.make_resource()
+        docs = list(generate_api_docs([resource]))
         self.assertEqual(1, len(docs))
         [doc] = docs
         self.assertIsInstance(doc, HandlerDocumentation)
-        self.assertIs(handler, doc.handler)
+        self.assertIs(type(resource.handler), doc.handler)
 
     def test_generates_doc_for_multiple_handlers(self):
         # generate_api_docs() yields HandlerDocumentation objects for the
         # handlers passed in.
-        handlers = [self.make_handler() for _ in range(5)]
-        docs = list(generate_api_docs(handlers))
-        self.assertEqual(len(handlers), len(docs))
-        self.assertEqual(handlers, [doc.handler for doc in docs])
+        resources = [self.make_resource() for _ in range(5)]
+        docs = list(generate_api_docs(resources))
+        self.assertEqual(len(resources), len(docs))
+        self.assertEqual(
+            [type(resource.handler) for resource in resources],
+            [doc.handler for doc in docs])
 
     def test_handler_without_resource_uri(self):
         # generate_api_docs() raises an exception if a handler does not have a
         # resource_uri attribute.
-        handler = self.make_handler()
-        del handler.resource_uri
-        docs = generate_api_docs([handler])
+        resource = self.make_resource()
+        del type(resource.handler).resource_uri
+        docs = generate_api_docs([resource])
         error = self.assertRaises(AssertionError, list, docs)
         self.assertEqual(
-            "Missing resource_uri in %s" % handler.__name__,
+            "Missing resource_uri in %s" % type(resource.handler).__name__,
             unicode(error))
 
 
+class MegadethHandler(OperationsHandler):
+    """The mighty 'deth."""
+
+    create = read = delete = None
+
+    @operation(idempotent=False)
+    def peace_sells_but_whos_buying(self, request, vic, rattlehead):
+        """Released 1986."""
+
+    @operation(idempotent=True)
+    def so_far_so_good_so_what(self, request, vic, rattlehead):
+        """Released 1988."""
+
+    @classmethod
+    def resource_uri(cls):
+        # Note that the arguments, after request, to each of the ops
+        # above matches the parameters (index 1) in the tuple below.
+        return ("megadeth_view", ["vic", "rattlehead"])
+
+
 class TestDescribingAPI(TestCase):
     """Tests for functions that describe a Piston API."""
 
@@ -144,26 +171,6 @@
     def test_describe_handler(self):
         # describe_handler() returns a description of a handler that can be
         # readily serialised into JSON, for example.
-
-        class MegadethHandler(OperationsHandler):
-            """The mighty 'deth."""
-
-            create = read = delete = None
-
-            @operation(idempotent=False)
-            def peace_sells_but_whos_buying(self, request, vic, rattlehead):
-                """Released 1986."""
-
-            @operation(idempotent=True)
-            def so_far_so_good_so_what(self, request, vic, rattlehead):
-                """Released 1988."""
-
-            @classmethod
-            def resource_uri(cls):
-                # Note that the arguments, after request, to each of the ops
-                # above matches the parameters (index 1) in the tuple below.
-                return ("megadeth_view", ["vic", "rattlehead"])
-
         expected_actions = [
             {"doc": "Released 1988.",
              "method": "GET",
@@ -181,7 +188,6 @@
              "op": None,
              "restful": True},
             ]
-
         observed = describe_handler(MegadethHandler)
         # The description contains several entries.
         self.assertSetEqual(
@@ -218,3 +224,11 @@
         self.assertEqual(
             "http://example.com/api/1.0/nodes/{system_id}/";,
             description["uri"])
+
+    def test_describe_resource(self):
+        # describe_resource() returns a description of a resource. Right now
+        # it is just the description of the resource's handler class.
+        resource = OperationsResource(MegadethHandler)
+        self.assertEqual(
+            describe_handler(MegadethHandler),
+            describe_resource(resource))