← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~allenap/maas/default-crud into lp:maas

 

Gavin Panella has proposed merging lp:~allenap/maas/default-crud into lp:maas.

Commit message:
Defines a new operations dispatch mechanism on top of Piston.

The existing class decoration code was confusing. This is still somewhat complex, but much less so, and the flow of control is easier to trace. This also takes over the definition of allowed_methods: all operations are exported, and CRUD methods that have not been erased (set to None) are also exported.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)
Related bugs:
  Bug #1049933 in MAAS: "The public-facing API code confusingly conflates CRUD, HTTP methods, and piggybacked operations"
  https://bugs.launchpad.net/maas/+bug/1049933

For more details, see:
https://code.launchpad.net/~allenap/maas/default-crud/+merge/126574

There are three parts to the new dispatch mechanism:

- OperationsResource ensures that handler.dispatch() is called for all methods, rather than GET->read and so forth that the default Resource (from Piston) does.

- OperationsHandlerType is a metaclass that sets a handler's export attribute to a simple mapping of (http-method, op) to functions, where op is from the request parameters, or None if not specified. If op is None the function is one of the CRUD methods, otherwise it's a custom operation.

- OperationsHandlerMixin gets mixed with Piston's BaseHandler and AnonymousBaseHandler to give OperationsHandler and AnonymousOperationsHandler. All it provides is a dispatch method to match the expectations of OperationsResource from earlier. This consults the exports mapping so carefully constructed by OperationsHandlerType, and dispatches accordingly.

-- 
https://code.launchpad.net/~allenap/maas/default-crud/+merge/126574
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~allenap/maas/default-crud into lp:maas.
=== modified file 'src/maasserver/api.py'
--- src/maasserver/api.py	2012-09-26 10:00:14 +0000
+++ src/maasserver/api.py	2012-09-26 23:47:21 +0000
@@ -57,6 +57,7 @@
     "AccountHandler",
     "AnonNodeGroupsHandler",
     "AnonNodesHandler",
+    "AnonymousOperationsHandler",
     "api_doc",
     "api_doc_title",
     "BootImagesHandler",
@@ -69,6 +70,7 @@
     "NodeMacHandler",
     "NodeMacsHandler",
     "NodesHandler",
+    "OperationsHandler",
     "TagHandler",
     "TagsHandler",
     "pxeconfig",
@@ -76,15 +78,17 @@
     ]
 
 from base64 import b64decode
+from cStringIO import StringIO
 from datetime import (
     datetime,
     timedelta,
     )
+from functools import partial
 import httplib
+from inspect import getdoc
 import json
 import sys
 from textwrap import dedent
-import types
 
 from celery.app import app_or_default
 from django.conf import settings
@@ -158,6 +162,7 @@
 from piston.handler import (
     AnonymousBaseHandler,
     BaseHandler,
+    HandlerMetaClass,
     )
 from piston.models import Token
 from piston.resource import Resource
@@ -165,15 +170,18 @@
 from provisioningserver.kernel_opts import KernelParameters
 
 
-dispatch_methods = {
-    'GET': 'read',
-    'POST': 'create',
-    'PUT': 'update',
-    'DELETE': 'delete',
-    }
-
-
-class RestrictedResource(Resource):
+class OperationsResource(Resource):
+    """A resource supporting operation dispatch.
+
+    All requests are passed onto the handler's `dispatch` method. See
+    :class:`OperationsHandler`.
+    """
+
+    crudmap = Resource.callmap
+    callmap = dict.fromkeys(crudmap, "dispatch")
+
+
+class RestrictedResource(OperationsResource):
 
     def authenticate(self, request, rm):
         actor, anonymous = super(
@@ -202,123 +210,99 @@
     :param exported_as: Optional operation name; defaults to the name of the
         exported method.
 
-    See also _`api_operations`.
     """
     def _decorator(func):
-        if method not in dispatch_methods:
+        if method not in OperationsResource.callmap:
             raise ValueError("Invalid method: '%s'" % method)
         if exported_as is None:
             func._api_exported = {method: func.__name__}
         else:
             func._api_exported = {method: exported_as}
-        if func._api_exported.get(method) == dispatch_methods.get(method):
-            raise ValueError(
-                "Cannot define a '%s' operation." % dispatch_methods.get(
-                    method))
         return func
     return _decorator
 
 
-# The parameter used to specify the requested operation for POST API calls.
-OP_PARAM = 'op'
-
-
-def is_api_exported(thing, method='POST'):
-    # Check for functions and methods; the latter may be from base classes.
-    op_types = types.FunctionType, types.MethodType
-    return (
-        isinstance(thing, op_types) and
-        getattr(thing, "_api_exported", None) is not None and
-        getattr(thing, "_api_exported", None).get(method, None) is not None)
-
-
-# Define a method that will route requests to the methods registered in
-# handler._available_api_methods.
-def perform_api_operation(handler, request, method='POST', *args, **kwargs):
-    if method == 'POST':
-        data = request.POST
-    else:
-        data = request.GET
-    op = data.get(OP_PARAM, None)
-    if not isinstance(op, unicode):
-        return HttpResponseBadRequest("Unknown operation.")
-    elif method not in handler._available_api_methods:
-        return HttpResponseBadRequest("Unknown operation: '%s'." % op)
-    elif op not in handler._available_api_methods[method]:
-        return HttpResponseBadRequest("Unknown operation: '%s'." % op)
-    else:
-        method = handler._available_api_methods[method][op]
-        return method(handler, request, *args, **kwargs)
-
-
-def api_operations(cls):
-    """Class decorator (PEP 3129) to be used on piston-based handler classes
-    (i.e. classes inheriting from piston.handler.BaseHandler).  It will add
-    the required methods {'create','read','update','delete} to the class.
-    These methods (called by piston to handle POST/GET/PUT/DELETE requests),
-    will route requests to methods decorated with
-    @api_exported(method={'POST','GET','PUT','DELETE'} depending on the
-    operation requested using the 'op' parameter.
-
-    E.g.:
-
-    >>> @api_operations
-    >>> class MyHandler(BaseHandler):
-    >>>
-    >>>    @api_exported(method='POST', exported_as='exported_post_name')
-    >>>    def do_x(self, request):
-    >>>        # process request...
-    >>>
-    >>>    @api_exported(method='GET')
-    >>>    def do_y(self, request):
-    >>>        # process request...
-
-    MyHandler's method 'do_x' will service POST requests with
-    'op=exported_post_name' in its request parameters.
-
-    POST /api/path/to/MyHandler/
-    op=exported_post_name&param1=1
-
-    MyHandler's method 'do_y' will service GET requests with
-    'op=do_y' in its request parameters.
-
-    GET /api/path/to/MyHandler/?op=do_y&param1=1
-
+class OperationsHandlerType(HandlerMetaClass):
+    """Type for handlers that dispatch operations.
+
+    Collects all the exported operations, CRUD and custom, into the class's
+    `exports` attribute. This is a signature:function mapping, where signature
+    is an (http-method, operation-name) tuple. If operation-name is None, it's
+    a CRUD method.
+
+    The `allowed_methods` attribute is calculated as the union of all HTTP
+    methods required for the exported CRUD and custom operations.
     """
-    # Compute the list of methods ('GET', 'POST', etc.) that need to be
-    # overriden.
-    overriden_methods = set()
-    for name, value in vars(cls).items():
-        overriden_methods.update(getattr(value, '_api_exported', {}))
-    # Override the appropriate methods with a 'dispatcher' method.
-    for method in overriden_methods:
+
+    def __new__(metaclass, name, bases, namespace):
+        cls = super(OperationsHandlerType, metaclass).__new__(
+            metaclass, name, bases, namespace)
+
+        # Create an http-method:function mapping for CRUD operations.
+        crud = {
+            http_method: getattr(cls, method)
+            for http_method, method in OperationsResource.crudmap.items()
+            if getattr(cls, method, None) is not None
+            }
+
+        # Create a operation-name:function mapping for non-CRUD operations.
+        # These functions contain an _api_exported attribute that will be
+        # used later on.
         operations = {
-            name: value
-            for name, value in vars(cls).items()
-            if is_api_exported(value, method)}
-        cls._available_api_methods = getattr(
-            cls, "_available_api_methods", {}).copy()
-        cls._available_api_methods[method] = {
-            op._api_exported[method]: op
-                for name, op in operations.items()
-                if method in op._api_exported}
-
-        def dispatcher(self, request, *args, **kwargs):
-            return perform_api_operation(
-                self, request, request.method, *args, **kwargs)
-
-        method_name = str(dispatch_methods[method])
-        dispatcher.__name__ = method_name
-        dispatcher.__doc__ = (
-            "The actual operation to execute depends on the value of the '%s' "
-            "parameter:\n\n" % OP_PARAM)
-        dispatcher.__doc__ += "\n".join(
-            "- Operation '%s' (op=%s):\n\t%s" % (name, name, op.__doc__)
-            for name, op in cls._available_api_methods[method].items())
-
-        # Add {'create','read','update','delete'} method.
-        setattr(cls, method_name, dispatcher)
-    return cls
+            name: attribute for name, attribute in vars(cls).items()
+            if getattr(attribute, "_api_exported", None) is not None
+            }
+
+        # Create the exports mapping.
+        exports = {}
+        exports.update(
+            ((http_method, None), function)
+            for http_method, function in crud.items())
+        exports.update(
+            (signature, function)
+            for name, function in operations.items()
+            for signature in function._api_exported.items())
+
+        # Update the class.
+        cls.exports = exports
+        cls.allowed_methods = frozenset(
+            http_method for http_method, name in exports)
+
+        return cls
+
+
+class OperationsHandlerMixin:
+    """Handler mixin for operations dispatch.
+
+    This enabled dispatch to custom functions that piggyback on HTTP methods
+    that ordinarily, in Piston, are used for CRUD operations.
+
+    This must be used in cooperation with :class:`OperationsResource` and
+    :class:`OperationsHandlerType`.
+    """
+
+    def dispatch(self, request, *args, **kwargs):
+        signature = request.method.upper(), request.REQUEST.get("op")
+        function = self.exports.get(signature)
+        if function is None:
+            return HttpResponseBadRequest(
+                "Unrecognised signature: %s %s" % signature)
+        else:
+            return function(self, request, *args, **kwargs)
+
+
+class OperationsHandler(
+    OperationsHandlerMixin, BaseHandler):
+    """Base handler that supports operation dispatch."""
+
+    __metaclass__ = OperationsHandlerType
+
+
+class AnonymousOperationsHandler(
+    OperationsHandlerMixin, AnonymousBaseHandler):
+    """Anonymous base handler that supports operation dispatch."""
+
+    __metaclass__ = OperationsHandlerType
 
 
 def get_mandatory_param(data, key, validator=None):
@@ -438,10 +422,9 @@
     )
 
 
-@api_operations
-class NodeHandler(BaseHandler):
+class NodeHandler(OperationsHandler):
     """Manage individual Nodes."""
-    allowed_methods = ('GET', 'DELETE', 'POST', 'PUT')
+    create = None  # Disable create.
     model = Node
     fields = DISPLAYED_NODE_FIELDS
 
@@ -586,10 +569,9 @@
         raise ValidationError(form.errors)
 
 
-@api_operations
-class AnonNodesHandler(AnonymousBaseHandler):
+class AnonNodesHandler(AnonymousOperationsHandler):
     """Create Nodes."""
-    allowed_methods = ('GET', 'POST',)
+    create = read = update = delete = None
     fields = DISPLAYED_NODE_FIELDS
 
     @api_exported('POST')
@@ -657,10 +639,9 @@
             if constraint in request_params}
 
 
-@api_operations
-class NodesHandler(BaseHandler):
+class NodesHandler(OperationsHandler):
     """Manage collection of Nodes."""
-    allowed_methods = ('GET', 'POST',)
+    create = read = update = delete = None
     anonymous = AnonNodesHandler
 
     @api_exported('POST')
@@ -776,13 +757,13 @@
         return ('nodes_handler', [])
 
 
-class NodeMacsHandler(BaseHandler):
+class NodeMacsHandler(OperationsHandler):
     """
     Manage all the MAC addresses linked to a Node / Create a new MAC address
     for a Node.
 
     """
-    allowed_methods = ('GET', 'POST',)
+    update = delete = None
 
     def read(self, request, system_id):
         """Read all MAC addresses related to a Node."""
@@ -803,9 +784,9 @@
         return ('node_macs_handler', ['system_id'])
 
 
-class NodeMacHandler(BaseHandler):
+class NodeMacHandler(OperationsHandler):
     """Manage a MAC address linked to a Node."""
-    allowed_methods = ('GET', 'DELETE')
+    create = update = None
     fields = ('mac_address',)
     model = MACAddress
 
@@ -855,8 +836,7 @@
     return HttpResponse(db_file.data.read(), status=httplib.OK)
 
 
-@api_operations
-class AnonFilesHandler(AnonymousBaseHandler):
+class AnonFilesHandler(AnonymousOperationsHandler):
     """Anonymous file operations.
 
     This is needed for Juju. The story goes something like this:
@@ -868,15 +848,14 @@
       without credentials.
 
     """
-    allowed_methods = ('GET',)
+    create = read = update = delete = None
 
     get = api_exported('GET', exported_as='get')(get_file)
 
 
-@api_operations
-class FilesHandler(BaseHandler):
+class FilesHandler(OperationsHandler):
     """File management operations."""
-    allowed_methods = ('GET', 'POST',)
+    create = read = update = delete = None
     anonymous = AnonFilesHandler
 
     get = api_exported('GET', exported_as='get')(get_file)
@@ -914,10 +893,9 @@
 DISPLAYED_NODEGROUP_FIELDS = ('uuid', 'status', 'name')
 
 
-@api_operations
-class AnonNodeGroupsHandler(AnonymousBaseHandler):
+class AnonNodeGroupsHandler(AnonymousOperationsHandler):
     """Anon Node-groups API."""
-    allowed_methods = ('GET', 'POST')
+    create = read = update = delete = None
     fields = DISPLAYED_NODEGROUP_FIELDS
 
     @api_exported('GET')
@@ -999,11 +977,10 @@
                     "Awaiting admin approval.", status=httplib.ACCEPTED)
 
 
-@api_operations
-class NodeGroupsHandler(BaseHandler):
+class NodeGroupsHandler(OperationsHandler):
     """Node-groups API."""
     anonymous = AnonNodeGroupsHandler
-    allowed_methods = ('GET', 'POST')
+    create = read = update = delete = None
     fields = DISPLAYED_NODEGROUP_FIELDS
 
     @api_exported('GET')
@@ -1069,11 +1046,10 @@
             "Only allowed for the %r worker." % nodegroup.name)
 
 
-@api_operations
-class NodeGroupHandler(BaseHandler):
+class NodeGroupHandler(OperationsHandler):
     """Node-group API."""
 
-    allowed_methods = ('GET', 'POST')
+    create = update = delete = None
     fields = DISPLAYED_NODEGROUP_FIELDS
 
     def read(self, request, uuid):
@@ -1106,10 +1082,9 @@
     'broadcast_ip', 'ip_range_low', 'ip_range_high')
 
 
-@api_operations
-class NodeGroupInterfacesHandler(BaseHandler):
+class NodeGroupInterfacesHandler(OperationsHandler):
     """NodeGroupInterfaces API."""
-    allowed_methods = ('GET', 'POST')
+    create = read = update = delete = None
     fields = DISPLAYED_NODEGROUP_FIELDS
 
     @api_exported('GET')
@@ -1156,9 +1131,9 @@
         return ('nodegroupinterfaces_handler', [uuid])
 
 
-class NodeGroupInterfaceHandler(BaseHandler):
+class NodeGroupInterfaceHandler(OperationsHandler):
     """NodeGroupInterface API."""
-    allowed_methods = ('GET', 'PUT')
+    create = delete = None
     fields = DISPLAYED_NODEGROUP_FIELDS
 
     def read(self, request, uuid, interface):
@@ -1212,10 +1187,9 @@
         return ('nodegroupinterface_handler', [uuid, interface_name])
 
 
-@api_operations
-class AccountHandler(BaseHandler):
+class AccountHandler(OperationsHandler):
     """Manage the current logged-in user."""
-    allowed_methods = ('POST',)
+    create = read = update = delete = None
 
     @api_exported('POST')
     def create_authorisation_token(self, request):
@@ -1252,10 +1226,9 @@
         return ('account_handler', [])
 
 
-@api_operations
-class TagHandler(BaseHandler):
+class TagHandler(OperationsHandler):
     """Manage individual Tags."""
-    allowed_methods = ('GET', 'DELETE', 'POST', 'PUT')
+    create = None
     model = Tag
     fields = (
         'name',
@@ -1314,10 +1287,9 @@
         return ('tag_handler', (tag_name, ))
 
 
-@api_operations
-class TagsHandler(BaseHandler):
+class TagsHandler(OperationsHandler):
     """Manage collection of Tags."""
-    allowed_methods = ('GET', 'POST')
+    create = read = update = delete = None
 
     @api_exported('POST')
     def new(self, request):
@@ -1357,10 +1329,9 @@
         raise ValidationError(form.errors)
 
 
-@api_operations
-class MAASHandler(BaseHandler):
+class MAASHandler(OperationsHandler):
     """Manage the MAAS' itself."""
-    allowed_methods = ('POST', 'GET')
+    create = read = update = delete = None
 
     @api_exported('POST')
     def set_config(self, request):
@@ -1409,22 +1380,32 @@
     :rtype: :class:`unicode`
     """
     module = sys.modules[__name__]
-    messages = [
-        __doc__.strip(),
-        '',
-        '',
-        'Operations',
-        '----------',
-        '',
-        ]
+    output = StringIO()
+    line = partial(print, file=output)
+
+    line(getdoc(module))
+    line()
+    line()
+    line('Operations')
+    line('----------')
+    line()
+
     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" % (
-                    method.http_name, doc.resource_uri_template,
-                    method.doc))
-    return '\n'.join(messages)
+        uri_template = doc.resource_uri_template
+        exports = doc.handler.exports.items()
+        for (http_method, operation), function in sorted(exports):
+            line("``%s %s``" % (http_method, uri_template), end="")
+            if operation is not None:
+                line(" ``op=%s``" % operation)
+            line()
+            docstring = getdoc(function)
+            if docstring is not None:
+                for docline in docstring.splitlines():
+                    line("  ", docline, sep="")
+                line()
+
+    return output.getvalue()
 
 
 def reST_to_html_fragment(a_str):
@@ -1512,8 +1493,9 @@
         content_type="application/json")
 
 
-@api_operations
-class BootImagesHandler(BaseHandler):
+class BootImagesHandler(OperationsHandler):
+
+    create = replace = update = delete = None
 
     @classmethod
     def resource_uri(cls):

=== modified file 'src/maasserver/apidoc.py'
--- src/maasserver/apidoc.py	2012-09-13 12:05:54 +0000
+++ src/maasserver/apidoc.py	2012-09-26 23:47:21 +0000
@@ -28,7 +28,8 @@
 def find_api_handlers(module):
     """Find the API handlers defined in `module`.
 
-    Handlers are of type :class:`HandlerMetaClass`.
+    Handlers are of type :class:`HandlerMetaClass`, and must define a
+    `resource_uri` method.
 
     :rtype: Generator, yielding handlers.
     """
@@ -41,7 +42,8 @@
     for name in names:
         candidate = getattr(module, name)
         if isinstance(candidate, HandlerMetaClass):
-            yield candidate
+            if getattr(candidate, "resource_uri", None) is not None:
+                yield candidate
 
 
 def generate_api_docs(handlers):
@@ -95,32 +97,21 @@
       restful: Indicates if this is a CRUD/ReSTful action.
 
     """
-    from maasserver.api import dispatch_methods  # Avoid circular imports.
-    operation_methods = getattr(handler, "_available_api_methods", {})
-    for http_method in handler.allowed_methods:
-        desc_base = dict(method=http_method)
-        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():
-                yield dict(
-                    desc_base, name=op, doc=getdoc(func),
-                    op=op, restful=False)
-        else:
-            # Default Piston CRUD method still stands.
-            name = dispatch_methods[http_method]
-            func = getattr(handler, name)
-            yield dict(
-                desc_base, name=name, doc=getdoc(func),
-                op=None, restful=True)
+    from maasserver.api import OperationsResource
+    getname = OperationsResource.crudmap.get
+    for signature, function in handler.exports.items():
+        http_method, operation = signature
+        name = getname(http_method) if operation is None else operation
+        yield dict(
+            method=http_method, name=name, doc=getdoc(function),
+            op=operation, restful=(operation is None))
 
 
 def describe_handler(handler):
     """Return a serialisable description of a handler.
 
-    :type handler: :class:`BaseHandler` instance that has been decorated by
-        `api_operations`.
+    :type handler: :class:`OperationsHandler` or
+        :class:`AnonymousOperationsHandler` instance.
     """
     uri_template = generate_doc(handler).resource_uri_template
     if uri_template is None:
@@ -128,8 +119,8 @@
     else:
         uri_template = urljoin(settings.DEFAULT_MAAS_URL, uri_template)
 
-    view_name, uri_params, uri_kw = merge(
-        handler.resource_uri(), (None, (), {}))
+    resource_uri = getattr(handler, "resource_uri", lambda: ())
+    view_name, uri_params, uri_kw = merge(resource_uri(), (None, (), {}))
     assert uri_kw == {}, (
         "Resource URI specifications with keyword parameters are not yet "
         "supported: handler=%r; view_name=%r" % (handler, view_name))

=== modified file 'src/maasserver/tests/test_api.py'
--- src/maasserver/tests/test_api.py	2012-09-26 10:00:14 +0000
+++ src/maasserver/tests/test_api.py	2012-09-26 23:47:21 +0000
@@ -358,7 +358,9 @@
 
         self.assertEqual(httplib.BAD_REQUEST, response.status_code)
         self.assertIn('text/html', response['Content-Type'])
-        self.assertEqual("Unknown operation.", response.content)
+        self.assertEqual(
+            "Unrecognised signature: POST None",
+            response.content)
 
     def test_POST_fails_if_mac_duplicated(self):
         # Mac Addresses should be unique.
@@ -394,7 +396,8 @@
 
         self.assertEqual(httplib.BAD_REQUEST, response.status_code)
         self.assertEqual(
-            "Unknown operation: 'invalid_operation'.", response.content)
+            "Unrecognised signature: POST invalid_operation",
+            response.content)
 
     def test_POST_new_rejects_invalid_data(self):
         # If the data provided to create a node with an invalid MAC

=== modified file 'src/maasserver/tests/test_api_mechanism.py'
--- src/maasserver/tests/test_api_mechanism.py	2012-06-25 09:03:14 +0000
+++ src/maasserver/tests/test_api_mechanism.py	2012-09-26 23:47:21 +0000
@@ -14,10 +14,7 @@
 __metaclass__ = type
 __all__ = []
 
-from maasserver.api import (
-    api_exported,
-    dispatch_methods,
-    )
+from maasserver.api import api_exported
 from maasserver.testing.factory import factory
 from maastesting.testcase import TestCase
 
@@ -32,23 +29,6 @@
         decorate = api_exported(random_method)
         self.assertRaises(ValueError, decorate, lambda: None)
 
-    def test_disallowed_methods(self):
-        # HTTP methods in dispatch_methods should not be allowed.
-        for method in dispatch_methods:
-            decorate = api_exported(method=dispatch_methods.get(method))
-            self.assertRaises(ValueError, decorate, lambda: None)
-
-    def test_defaulted_names_are_checked_for_validity(self):
-        # When not using exported_as, the validity check should apply to
-        # the method name too.
-
-        def read():
-            # 'read' is prohibited for "GET"
-            pass
-
-        decorate = api_exported("GET")
-        self.assertRaises(ValueError, decorate, read)
-
     def test_valid_decoration(self):
         value = "value" + factory.getRandomString()
         decorate = api_exported()

=== modified file 'src/maasserver/tests/test_apidoc.py'
--- src/maasserver/tests/test_apidoc.py	2012-09-13 12:05:54 +0000
+++ src/maasserver/tests/test_apidoc.py	2012-09-26 23:47:21 +0000
@@ -17,7 +17,7 @@
 from django.conf import settings
 from maasserver.api import (
     api_exported,
-    api_operations,
+    OperationsHandler,
     )
 from maasserver.apidoc import (
     describe_handler,
@@ -59,12 +59,22 @@
         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 are discovered in a module and returned.
+        # 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 = BaseHandler
+        module.handler = type(
+            b"MetalHander", (BaseHandler,), {"resource_uri": True})
         self.assertSequenceEqual(
-            [BaseHandler], list(find_api_handlers(module)))
+            [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
@@ -135,11 +145,10 @@
         # describe_handler() returns a description of a handler that can be
         # readily serialised into JSON, for example.
 
-        @api_operations
-        class MegadethHandler(BaseHandler):
+        class MegadethHandler(OperationsHandler):
             """The mighty 'deth."""
 
-            allowed_methods = "GET", "POST", "PUT"
+            create = read = delete = None
 
             @api_exported("POST")
             def peace_sells_but_whos_buying(self, request, vic, rattlehead):
@@ -181,7 +190,7 @@
         self.assertEqual(MegadethHandler.__doc__, observed["doc"])
         self.assertEqual(MegadethHandler.__name__, observed["name"])
         self.assertEqual(["vic", "rattlehead"], observed["params"])
-        self.assertSequenceEqual(expected_actions, observed["actions"])
+        self.assertItemsEqual(expected_actions, observed["actions"])
 
     def test_describe_handler_with_maas_handler(self):
         # Ensure that describe_handler() yields something sensible with a

=== modified file 'src/metadataserver/api.py'
--- src/metadataserver/api.py	2012-09-24 11:44:49 +0000
+++ src/metadataserver/api.py	2012-09-26 23:47:21 +0000
@@ -24,9 +24,9 @@
 from django.shortcuts import get_object_or_404
 from maasserver.api import (
     api_exported,
-    api_operations,
     extract_oauth_key,
     get_mandatory_param,
+    OperationsHandler,
     )
 from maasserver.enum import (
     NODE_STATUS,
@@ -53,7 +53,6 @@
     NodeKey,
     NodeUserData,
     )
-from piston.handler import BaseHandler
 from piston.utils import rc
 
 
@@ -131,8 +130,8 @@
         raise UnknownMetadataVersion("Unknown metadata version: %s" % version)
 
 
-class MetadataViewHandler(BaseHandler):
-    allowed_methods = ('GET',)
+class MetadataViewHandler(OperationsHandler):
+    create = update = delete = None
 
     def read(self, request, mac=None):
         return make_list_response(sorted(self.fields))
@@ -144,10 +143,9 @@
     fields = ('latest', '2012-03-01')
 
 
-@api_operations
 class VersionIndexHandler(MetadataViewHandler):
     """Listing for a given metadata version."""
-    allowed_methods = ('GET', 'POST')
+    create = update = delete = None
     fields = ('meta-data', 'user-data')
 
     # States in which a node is allowed to signal commissioning status.
@@ -332,12 +330,12 @@
             raise MAASAPINotFound("No user data available for this node.")
 
 
-class EnlistMetaDataHandler(BaseHandler):
+class EnlistMetaDataHandler(OperationsHandler):
     """this has to handle the 'meta-data' portion of the meta-data api
     for enlistment only.  It should mimic the read-only portion
     of /VersionIndexHandler"""
 
-    allowed_methods = ('GET',)
+    create = update = delete = None
 
     data = {
         'instance-id': 'i-maas-enlistment',
@@ -357,7 +355,7 @@
         return make_text_response(self.data[item])
 
 
-class EnlistUserDataHandler(BaseHandler):
+class EnlistUserDataHandler(OperationsHandler):
     """User-data for the enlistment environment"""
 
     def read(self, request, version):
@@ -365,15 +363,14 @@
         return HttpResponse(get_enlist_userdata(), mimetype="text/plain")
 
 
-class EnlistVersionIndexHandler(BaseHandler):
-    allowed_methods = ('GET',)
+class EnlistVersionIndexHandler(OperationsHandler):
+    create = update = delete = None
     fields = ('meta-data', 'user-data')
 
     def read(self, request, version):
         return make_list_response(sorted(self.fields))
 
 
-@api_operations
 class AnonMetaDataHandler(VersionIndexHandler):
     """Anonymous metadata."""
 

=== modified file 'src/metadataserver/urls.py'
--- src/metadataserver/urls.py	2012-08-03 15:17:01 +0000
+++ src/metadataserver/urls.py	2012-09-26 23:47:21 +0000
@@ -18,6 +18,7 @@
     patterns,
     url,
     )
+from maasserver.api import OperationsResource
 from maasserver.api_auth import api_auth
 from metadataserver.api import (
     AnonMetaDataHandler,
@@ -29,29 +30,32 @@
     UserDataHandler,
     VersionIndexHandler,
     )
-from piston.resource import Resource
 
 # Handlers for nodes requesting their own metadata.
-meta_data_handler = Resource(MetaDataHandler, authentication=api_auth)
-user_data_handler = Resource(UserDataHandler, authentication=api_auth)
-version_index_handler = Resource(VersionIndexHandler, authentication=api_auth)
-index_handler = Resource(IndexHandler, authentication=api_auth)
+meta_data_handler = OperationsResource(
+    MetaDataHandler, authentication=api_auth)
+user_data_handler = OperationsResource(
+    UserDataHandler, authentication=api_auth)
+version_index_handler = OperationsResource(
+    VersionIndexHandler, authentication=api_auth)
+index_handler = OperationsResource(
+    IndexHandler, authentication=api_auth)
 
 
 # Handlers for anonymous metadata operations.
-meta_data_anon_handler = Resource(AnonMetaDataHandler)
+meta_data_anon_handler = OperationsResource(AnonMetaDataHandler)
 
 
 # Handlers for UNSAFE anonymous random metadata access.
-meta_data_by_mac_handler = Resource(MetaDataHandler)
-user_data_by_mac_handler = Resource(UserDataHandler)
-version_index_by_mac_handler = Resource(VersionIndexHandler)
+meta_data_by_mac_handler = OperationsResource(MetaDataHandler)
+user_data_by_mac_handler = OperationsResource(UserDataHandler)
+version_index_by_mac_handler = OperationsResource(VersionIndexHandler)
 
 # Handlers for the anonymous enlistment metadata service
-enlist_meta_data_handler = Resource(EnlistMetaDataHandler)
-enlist_user_data_handler = Resource(EnlistUserDataHandler)
-enlist_index_handler = Resource(IndexHandler)
-enlist_version_index_handler = Resource(EnlistVersionIndexHandler)
+enlist_meta_data_handler = OperationsResource(EnlistMetaDataHandler)
+enlist_user_data_handler = OperationsResource(EnlistUserDataHandler)
+enlist_index_handler = OperationsResource(IndexHandler)
+enlist_version_index_handler = OperationsResource(EnlistVersionIndexHandler)
 
 # Normal metadata access, available to a node querying its own metadata.
 node_patterns = patterns(