← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~allenap/maas/inspect-api into lp:maas

 

Gavin Panella has proposed merging lp:~allenap/maas/inspect-api into lp:maas.

Requested reviews:
  MAAS Maintainers (maas-maintainers)

For more details, see:
https://code.launchpad.net/~allenap/maas/inspect-api/+merge/123853

This branch has given me headaches.

The API code seems labyrinthine, but I know there's history there, and
an awful lot of this was done for 12.04, under extreme time pressure.
In other words, please don't take any of the following personally.

- It's basically CRUD with resource methods (i.e. RPC with a resource
  as context). Sounds simple...

- However, if a CRUD method has a custom operation attached to it (via
  the @api_operations decorator), the underlying functionality is no
  longer accessible. For example, @api_operations("POST") will prevent
  use C(reate), even though the operation might be unrelated to
  creating things.

- There's terminology confusion. I think this might bubble up from
  Piston:

     HTTP methods: GET POST PUT DELETE

  correspond to:

     Instance methods: read create update delete

  However, @api_operations needs an *HTTP* method when decorating an
  *instance* method, and the handler's allowed_methods property takes
  a list of HTTP methods too.

- It's odd to create methods on a handler that aren't actually useful
  because allowed_methods doesn't correspond to one of them. For
  example, define a "create" method but omit "POST" from
  allowed_methods.

- The code to add custom operations - api_exported, is_api_exported,
  perform_api_operation, api_operations - on top of Piston is
  byzantine (...and I wrote a fair bit of it). There's a lot of
  complex code there to do something which, on the face of it, should
  be simple.

- Things like get_mandatory_param and get_optional_list are used
  instead of declaring the API's interface.

- Going further: the only definition of the API is wound up in its
  implementation.

- There's little distinction between the API's controllers and views
  (if we think of MVC), so that the API cannot be exposed to another
  medium (e.g. a message bus).

-- 
https://code.launchpad.net/~allenap/maas/inspect-api/+merge/123853
Your team MAAS Maintainers is requested to review the proposed merge of lp:~allenap/maas/inspect-api into lp:maas.
=== modified file 'src/maasserver/api.py'
--- src/maasserver/api.py	2012-09-10 14:42:52 +0000
+++ src/maasserver/api.py	2012-09-11 22:45:40 +0000
@@ -54,19 +54,19 @@
 
 __metaclass__ = type
 __all__ = [
+    "AccountHandler",
+    "AnonNodesHandler",
     "api_doc",
     "api_doc_title",
-    "generate_api_doc",
+    "FilesHandler",
     "get_oauth_token",
-    "AccountHandler",
-    "AnonNodesHandler",
-    "FilesHandler",
     "NodeGroupsHandler",
     "NodeHandler",
-    "NodesHandler",
     "NodeMacHandler",
     "NodeMacsHandler",
+    "NodesHandler",
     "pxeconfig",
+    "render_api_docs",
     ]
 
 from base64 import b64decode
@@ -99,6 +99,11 @@
 from docutils import core
 from formencode import validators
 from formencode.validators import Invalid
+from maasserver.apidoc import (
+    describe_handler,
+    find_api_handlers,
+    generate_api_docs,
+    )
 from maasserver.enum import (
     ARCHITECTURE,
     NODE_PERMISSION,
@@ -130,11 +135,9 @@
     )
 from maasserver.server_address import get_maas_facing_server_address
 from maasserver.utils.orm import get_one
-from piston.doc import generate_doc
 from piston.handler import (
     AnonymousBaseHandler,
     BaseHandler,
-    HandlerMetaClass,
     )
 from piston.models import Token
 from piston.resource import Resource
@@ -1015,7 +1018,7 @@
 
 
 # Title section for the API documentation.  Matches in style, format,
-# etc. whatever generate_api_doc() produces, so that you can concatenate
+# etc. whatever render_api_docs() produces, so that you can concatenate
 # the two.
 api_doc_title = dedent("""
     ========
@@ -1024,8 +1027,8 @@
     """.lstrip('\n'))
 
 
-def generate_api_doc():
-    """Generate ReST documentation for the REST API.
+def render_api_docs():
+    """Render ReST documentation for the REST API.
 
     This module's docstring forms the head of the documentation; details of
     the API methods follow.
@@ -1033,24 +1036,7 @@
     :return: Documentation, in ReST, for the API.
     :rtype: :class:`unicode`
     """
-
-    # Fetch all the API Handlers (objects with the class
-    # HandlerMetaClass).
     module = sys.modules[__name__]
-
-    all = [getattr(module, name) for name in module.__all__]
-    handlers = [obj for obj in all if isinstance(obj, HandlerMetaClass)]
-
-    # Make sure each handler defines a 'resource_uri' method (this is
-    # easily forgotten and essential to have a proper documentation).
-    for handler in handlers:
-        sentinel = object()
-        resource_uri = getattr(handler, "resource_uri", sentinel)
-        assert resource_uri is not sentinel, "Missing resource_uri in %s" % (
-            handler.__name__)
-
-    docs = [generate_doc(handler) for handler in handlers]
-
     messages = [
         __doc__.strip(),
         '',
@@ -1059,7 +1045,8 @@
         '----------',
         '',
         ]
-    for doc in docs:
+    handlers = find_api_handlers(module)
+    for doc in generate_api_docs(handlers):
         for method in doc.get_methods():
             messages.append(
                 "%s %s\n  %s\n" % (
@@ -1073,20 +1060,14 @@
     return parts['body_pre_docinfo'] + parts['fragment']
 
 
-_API_DOC = None
-
-
 def api_doc(request):
     """Get ReST documentation for the REST API."""
     # Generate the documentation and keep it cached.  Note that we can't do
     # that at the module level because the API doc generation needs Django
     # fully initialized.
-    global _API_DOC
-    if _API_DOC is None:
-        _API_DOC = generate_api_doc()
     return render_to_response(
         'maasserver/api_doc.html',
-        {'doc': reST_to_html_fragment(generate_api_doc())},
+        {'doc': reST_to_html_fragment(render_api_docs())},
         context_instance=RequestContext(request))
 
 
@@ -1154,3 +1135,21 @@
     return HttpResponse(
         json.dumps(params._asdict()),
         content_type="application/json")
+
+
+def describe(request):
+    """Return a description of the whole MAAS API.
+
+    Returns a JSON object describing the whole MAAS API.
+    """
+    module = sys.modules[__name__]
+    description = {
+        "doc": "MAAS API",
+        "handlers": [
+            describe_handler(handler)
+            for handler in find_api_handlers(module)
+            ],
+        }
+    return HttpResponse(
+        json.dumps(description),
+        content_type="application/json")

=== added file 'src/maasserver/apidoc.py'
--- src/maasserver/apidoc.py	1970-01-01 00:00:00 +0000
+++ src/maasserver/apidoc.py	2012-09-11 22:45:40 +0000
@@ -0,0 +1,159 @@
+# Copyright 2012 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""..."""
+
+from __future__ import (
+    absolute_import,
+    print_function,
+    unicode_literals,
+    )
+
+__metaclass__ = type
+__all__ = [
+    "describe_handler",
+    "find_api_handlers",
+    "generate_api_docs",
+    ]
+
+from inspect import (
+    getargspec,
+    getdoc,
+    )
+from itertools import (
+    chain,
+    izip,
+    repeat,
+    )
+from types import MethodType as instancemethod
+from urllib import quote
+
+from piston.doc import generate_doc
+from piston.handler import HandlerMetaClass
+
+
+def find_api_handlers(module):
+    """Find the API handlers defined in `module`.
+
+    Handlers are of type :class:`HandlerMetaClass`.
+
+    :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):
+            yield candidate
+
+
+def generate_api_docs(handlers):
+    """Generate ReST documentation objects for the ReST API.
+
+    Yields Piston Documentation objects describing the current registered
+    handlers.
+
+    This also ensures that handlers define 'resource_uri' methods. This is
+    easily forgotten and essential in order to generate proper documentation.
+
+    :rtype: :class:`...`
+    """
+    sentinel = object()
+    for handler in handlers:
+        if getattr(handler, "resource_uri", sentinel) is sentinel:
+            raise AssertionError(
+                "Missing resource_uri in %s" % handler.__name__)
+        yield generate_doc(handler)
+
+
+def describe_args(args, defaults):
+    """Generate serialisable descriptions of arguments."""
+    # The end of the defaults list aligns with the end of the args list, hence
+    # we pad it with undefined, in order that we can zip it with args later.
+    undefined = object()
+    if defaults is None:
+        defaults = repeat(undefined)
+    else:
+        defaults = chain(
+            repeat(undefined, len(args) - len(defaults)),
+            defaults)
+    for arg, default in izip(args, defaults):
+        if default is undefined:
+            yield {"name": arg}
+        else:
+            yield {"name": arg, "default": default}
+
+
+def describe_function_args(func):
+    """Generate serialisable descriptions of function arguments.
+
+    When describing the API, we ignore varargs and keywords args.
+    """
+    args, _, _, defaults = getargspec(func)
+    # Trim the first arg (self or cls) if it's uninteresting.
+    if isinstance(func, (instancemethod, classmethod)):
+        args = args[1:]
+    return describe_args(args, defaults)
+
+
+def describe_function(method, func):
+    """Return a serialisable description of a handler function."""
+    return {"doc": getdoc(func), "method": method}
+
+
+def describe_operation(method, func, op):
+    """Return a serialisable description of a handler operation."""
+    description = describe_function(method, func)
+    description["args"] = list(describe_function_args(func))
+    description["op"] = op
+    return description
+
+
+def describe_handler(handler):
+    """Return a serialisable description of a handler.
+
+    :type handler: :class:`BaseHandler` instance that has been decorated by
+        `api_operations`.
+    """
+    # Avoid circular imports.
+    from maasserver.api import dispatch_methods
+
+    uri_template = generate_doc(handler).resource_uri_template
+    if uri_template is None:
+        uri_template = ""
+
+    uri_params = handler.resource_uri()
+    assert len(uri_params) <= 2 or uri_params[2] == {}, (
+        "Resource URIs with keyword parameters are not yet supported.")
+    uri_params = uri_params[1] if len(uri_params) >= 2 else []
+
+    actions = []
+    operation_methods = getattr(handler, "_available_api_methods", {})
+    for http_method in handler.allowed_methods:
+        if http_method in operation_methods:
+            # Default Piston CRUD method has been overridden; inspect
+            # custom operations instead.
+            operations = handler._available_api_methods[http_method]
+            for op, func in operations.items():
+                desc = describe_operation(http_method, func, op)
+                desc["uri"] = "%s?op=%s" % (uri_template, quote(desc["op"]))
+                desc["uri_params"] = uri_params
+                actions.append(desc)
+        else:
+            # Default Piston CRUD method still stands.
+            op = dispatch_methods[http_method]
+            func = getattr(handler, op)
+            desc = describe_function(http_method, func)
+            desc["uri"] = uri_template
+            desc["uri_params"] = uri_params
+            actions.append(desc)
+
+    return {
+        "name": handler.__name__,
+        "doc": getdoc(handler),
+        "actions": actions,
+        }

=== modified file 'src/maasserver/management/commands/generate_api_doc.py'
--- src/maasserver/management/commands/generate_api_doc.py	2012-04-17 13:48:09 +0000
+++ src/maasserver/management/commands/generate_api_doc.py	2012-09-11 22:45:40 +0000
@@ -17,10 +17,10 @@
 from django.core.management.base import BaseCommand
 from maasserver.api import (
     api_doc_title,
-    generate_api_doc,
+    render_api_docs,
     )
 
 
 class Command(BaseCommand):
     def handle(self, *args, **options):
-        self.stdout.write('\n'.join([api_doc_title, generate_api_doc()]))
+        self.stdout.write('\n'.join([api_doc_title, render_api_docs()]))

=== modified file 'src/maasserver/tests/test_api.py'
--- src/maasserver/tests/test_api.py	2012-09-10 14:27:00 +0000
+++ src/maasserver/tests/test_api.py	2012-09-11 22:45:40 +0000
@@ -2593,3 +2593,31 @@
         self.assertEqual(
             httplib.FORBIDDEN, response.status_code,
             explain_unexpected_response(httplib.FORBIDDEN, response))
+
+
+class TestDescribe(AnonAPITestCase):
+
+    def get_describe(self):
+        """Make a request to `describe`, and return its response dict."""
+        response = self.client.get(reverse('describe'))
+        return json.loads(response.content)
+
+    def test_describe_returns_json(self):
+        response = self.client.get(reverse('describe'))
+        self.assertThat(
+            (response.status_code,
+             response['Content-Type'],
+             response.content,
+             response.content),
+            MatchesListwise(
+                (Equals(httplib.OK),
+                 Equals("application/json"),
+                 StartsWith(b'{'),
+                 Contains('name'))),
+            response)
+
+    def test_describe(self):
+        response = self.client.get(reverse('describe'))
+        description = json.loads(response.content)
+        self.assertSetEqual({"doc", "handlers"}, set(description))
+        self.assertIsInstance(description["handlers"], list)

=== added file 'src/maasserver/tests/test_apidoc.py'
--- src/maasserver/tests/test_apidoc.py	1970-01-01 00:00:00 +0000
+++ src/maasserver/tests/test_apidoc.py	2012-09-11 22:45:40 +0000
@@ -0,0 +1,213 @@
+# Copyright 2012 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Test maasserver API documentation functionality."""
+
+from __future__ import (
+    absolute_import,
+    print_function,
+    unicode_literals,
+    )
+
+__metaclass__ = type
+__all__ = []
+
+import new
+
+from maasserver.api import (
+    api_exported,
+    api_operations,
+    )
+from maasserver.apidoc import (
+    describe_args,
+    describe_handler,
+    find_api_handlers,
+    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."""
+
+    @staticmethod
+    def make_module():
+        """Return a new module with a fabricated name."""
+        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_handler(self):
+        # Handlers are discovered in a module and returned.
+        module = self.make_module()
+        module.handler = BaseHandler
+        self.assertSequenceEqual(
+            [BaseHandler], 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)))
+
+
+class TestGeneratingDocs(TestCase):
+    """Tests for API inspection support: generating docs."""
+
+    @staticmethod
+    def make_handler():
+        """
+        Return a new `BaseHandler` subclass 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)
+
+    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]))
+        self.assertEqual(1, len(docs))
+        [doc] = docs
+        self.assertIsInstance(doc, HandlerDocumentation)
+        self.assertIs(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])
+
+    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])
+        error = self.assertRaises(AssertionError, list, docs)
+        self.assertEqual(
+            "Missing resource_uri in %s" % handler.__name__,
+            unicode(error))
+
+
+class TestDescribingAPI(TestCase):
+    """Tests for functions that describe a Piston API."""
+
+    def test_describe_args(self):
+        self.assertEqual(
+            [{"name": "alice"}],
+            list(describe_args(("alice",), ())))
+        self.assertEqual(
+            [{"name": "alice"}, {"name": "bob"}],
+            list(describe_args(("alice", "bob"), ())))
+        self.assertEqual(
+            [{"name": "alice"}, {"name": "bob", "default": "carol"}],
+            list(describe_args(("alice", "bob"), ("carol",))))
+
+    def test_describe_handler(self):
+        # describe_handler() returns a description of a handler that can be
+        # readily serialised into JSON, for example.
+
+        @api_operations
+        class MegadethHandler(BaseHandler):
+            """The mighty 'deth."""
+
+            allowed_methods = "GET", "POST", "PUT"
+
+            @api_exported("POST")
+            def peace_sells(but, whos, buying):
+                """Released 1986."""
+
+            @api_exported("GET")
+            def so_far(so_good, so_what):
+                """Released 1988."""
+
+            @classmethod
+            def resource_uri(self):
+                return ("megadeth_view", ["vic", "rattlehead"])
+
+        expected_actions = [
+            {"args": [
+                    {"name": "so_good"},
+                    {"name": "so_what"},
+                    ],
+             "doc": "Released 1988.",
+             "method": "GET",
+             "op": "so_far",
+             "uri": "?op=so_far",
+             "uri_params": ["vic", "rattlehead"]},
+            {"args": [
+                    {"name": "but"},
+                    {"name": "whos"},
+                    {"name": "buying"},
+                    ],
+             "doc": "Released 1986.",
+             "method": "POST",
+             "op": "peace_sells",
+             "uri": "?op=peace_sells",
+             "uri_params": ["vic", "rattlehead"]},
+            {"doc": None,
+             "method": "PUT",
+             "uri": "",
+             "uri_params": ["vic", "rattlehead"]},
+            ]
+
+        observed = describe_handler(MegadethHandler)
+        # The description contains `uri` and `actions` entries.
+        self.assertSetEqual({"actions", "doc", "name"}, set(observed))
+        self.assertEqual(MegadethHandler.__name__, observed["name"])
+        self.assertEqual(MegadethHandler.__doc__, observed["doc"])
+        self.assertSequenceEqual(expected_actions, observed["actions"])
+
+    def test_describe_handler_with_maas_handler(self):
+        # Ensure that describe_handler() yields something sensible with a
+        # "real" MAAS API handler.
+        from maasserver.api import NodeHandler as handler
+        description = describe_handler(handler)
+        # The RUD of CRUD actions are still available, but the C(reate) action
+        # has been overridden with custom operations. Not entirely sure how
+        # this makes sense, but there we go :)
+        expected_actions = {
+            "DELETE /api/1.0/nodes/{system_id}/",
+            "GET /api/1.0/nodes/{system_id}/",
+            "POST /api/1.0/nodes/{system_id}/?op=release",
+            "POST /api/1.0/nodes/{system_id}/?op=start",
+            "POST /api/1.0/nodes/{system_id}/?op=stop",
+            "PUT /api/1.0/nodes/{system_id}/",
+            }
+        observed_actions = {
+            "%(method)s %(uri)s" % action
+            for action in description["actions"]
+            }
+        self.assertSetEqual(expected_actions, observed_actions)

=== modified file 'src/maasserver/urls_api.py'
--- src/maasserver/urls_api.py	2012-09-10 14:27:00 +0000
+++ src/maasserver/urls_api.py	2012-09-11 22:45:40 +0000
@@ -20,6 +20,7 @@
     AccountHandler,
     AdminRestrictedResource,
     api_doc,
+    describe,
     FilesHandler,
     MAASHandler,
     NodeGroupHandler,
@@ -57,6 +58,7 @@
 # API URLs accessible to anonymous users.
 urlpatterns = patterns('',
     url(r'doc/$', api_doc, name='api-doc'),
+    url(r'describe/$', describe, name='describe'),
     url(r'nodegroups/$', nodegroups_handler, name='nodegroups_handler'),
     url(r'pxeconfig/$', pxeconfig, name='pxeconfig'),
 )


Follow ups