launchpad-reviewers team mailing list archive
-
launchpad-reviewers team
-
Mailing list archive
-
Message #12532
[Merge] lp:~allenap/maas/default-crud-rename-api-exported into lp:maas
Gavin Panella has proposed merging lp:~allenap/maas/default-crud-rename-api-exported into lp:maas with lp:~allenap/maas/default-crud as a prerequisite.
Commit message:
Renames the api_exported decorator to operation, and demands a declaration of idempotence instead of an HTTP method.
Other bits of the new dispatch code is also cleaned up.
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-rename-api-exported/+merge/126575
--
https://code.launchpad.net/~allenap/maas/default-crud-rename-api-exported/+merge/126575
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~allenap/maas/default-crud-rename-api-exported into lp:maas.
=== modified file 'src/maasserver/api.py'
--- src/maasserver/api.py 2012-09-26 23:50:29 +0000
+++ src/maasserver/api.py 2012-09-26 23:50:29 +0000
@@ -203,21 +203,21 @@
return actor, anonymous
-def api_exported(method='POST', exported_as=None):
+def operation(idempotent, exported_as=None):
"""Decorator to make a method available on the API.
- :param method: The HTTP method over which to export the operation.
+ :param idempotent: If this operation is idempotent. Idempotent operations
+ are made available via HTTP GET, non-idempotent operations via HTTP
+ POST.
:param exported_as: Optional operation name; defaults to the name of the
exported method.
-
"""
+ method = "GET" if idempotent else "POST"
def _decorator(func):
- if method not in OperationsResource.callmap:
- raise ValueError("Invalid method: '%s'" % method)
if exported_as is None:
- func._api_exported = {method: func.__name__}
+ func.export = method, func.__name__
else:
- func._api_exported = {method: exported_as}
+ func.export = method, exported_as
return func
return _decorator
@@ -238,30 +238,24 @@
cls = super(OperationsHandlerType, metaclass).__new__(
metaclass, name, bases, namespace)
- # Create an http-method:function mapping for CRUD operations.
+ # Create a signature:function mapping for CRUD operations.
crud = {
- http_method: getattr(cls, method)
+ (http_method, None): 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.
+ # Create a signature:function mapping for non-CRUD operations.
operations = {
- name: attribute for name, attribute in vars(cls).items()
- if getattr(attribute, "_api_exported", None) is not None
+ attribute.export: attribute
+ for attribute in vars(cls).values()
+ if getattr(attribute, "export", 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())
+ exports.update(crud)
+ exports.update(operations)
# Update the class.
cls.exports = exports
@@ -491,7 +485,7 @@
node_system_id = node.system_id
return ('node_handler', (node_system_id, ))
- @api_exported('POST')
+ @operation(idempotent=False)
def stop(self, request, system_id):
"""Shut down a node."""
nodes = Node.objects.stop_nodes([system_id], request.user)
@@ -500,7 +494,7 @@
"You are not allowed to shut down this node.")
return nodes[0]
- @api_exported('POST')
+ @operation(idempotent=False)
def start(self, request, system_id):
"""Power up a node.
@@ -532,7 +526,7 @@
"You are not allowed to start up this node.")
return nodes[0]
- @api_exported('POST')
+ @operation(idempotent=False)
def release(self, request, system_id):
"""Release a node. Opposite of `NodesHandler.acquire`."""
node = Node.objects.get_node_or_404(
@@ -574,7 +568,7 @@
create = read = update = delete = None
fields = DISPLAYED_NODE_FIELDS
- @api_exported('POST')
+ @operation(idempotent=False)
def new(self, request):
"""Create a new Node.
@@ -585,7 +579,7 @@
"""
return create_node(request)
- @api_exported('GET')
+ @operation(idempotent=True)
def is_registered(self, request):
"""Returns whether or not the given MAC address is registered within
this MAAS (and attached to a non-retired node).
@@ -600,12 +594,12 @@
mac_address=mac_address).exclude(
node__status=NODE_STATUS.RETIRED).exists()
- @api_exported('POST')
+ @operation(idempotent=False)
def accept(self, request):
"""Accept a node's enlistment: not allowed to anonymous users."""
raise Unauthorized("You must be logged in to accept nodes.")
- @api_exported("POST")
+ @operation(idempotent=False)
def check_commissioning(self, request):
"""Check all commissioning nodes to see if they are taking too long.
@@ -644,7 +638,7 @@
create = read = update = delete = None
anonymous = AnonNodesHandler
- @api_exported('POST')
+ @operation(idempotent=False)
def new(self, request):
"""Create a new Node.
@@ -656,7 +650,7 @@
node.accept_enlistment(request.user)
return node
- @api_exported('POST')
+ @operation(idempotent=False)
def accept(self, request):
"""Accept declared nodes into the MAAS.
@@ -694,7 +688,7 @@
return filter(
None, [node.accept_enlistment(request.user) for node in nodes])
- @api_exported('POST')
+ @operation(idempotent=False)
def accept_all(self, request):
"""Accept all declared nodes into the MAAS.
@@ -713,7 +707,7 @@
nodes = [node.accept_enlistment(request.user) for node in nodes]
return filter(None, nodes)
- @api_exported('GET')
+ @operation(idempotent=True)
def list(self, request):
"""List Nodes visible to the user, optionally filtered by criteria.
@@ -734,7 +728,7 @@
nodes = nodes.filter(macaddress__mac_address__in=match_macs)
return nodes.order_by('id')
- @api_exported('GET')
+ @operation(idempotent=True)
def list_allocated(self, request):
"""Fetch Nodes that were allocated to the User/oauth token."""
token = get_oauth_token(request)
@@ -742,7 +736,7 @@
nodes = Node.objects.get_allocated_visible_nodes(token, match_ids)
return nodes.order_by('id')
- @api_exported('POST')
+ @operation(idempotent=False)
def acquire(self, request):
"""Acquire an available node for deployment."""
node = Node.objects.get_available_node_for_acquisition(
@@ -850,7 +844,7 @@
"""
create = read = update = delete = None
- get = api_exported('GET', exported_as='get')(get_file)
+ get = operation(idempotent=True, exported_as='get')(get_file)
class FilesHandler(OperationsHandler):
@@ -858,9 +852,9 @@
create = read = update = delete = None
anonymous = AnonFilesHandler
- get = api_exported('GET', exported_as='get')(get_file)
+ get = operation(idempotent=True, exported_as='get')(get_file)
- @api_exported('POST')
+ @operation(idempotent=False)
def add(self, request):
"""Add a new file to the file storage.
@@ -898,7 +892,7 @@
create = read = update = delete = None
fields = DISPLAYED_NODEGROUP_FIELDS
- @api_exported('GET')
+ @operation(idempotent=True)
def list(self, request):
"""List of node groups."""
return NodeGroup.objects.all()
@@ -907,7 +901,7 @@
def resource_uri(cls):
return ('nodegroups_handler', [])
- @api_exported('POST')
+ @operation(idempotent=False)
def refresh_workers(self, request):
"""Request an update of all node groups' configurations.
@@ -921,7 +915,7 @@
NodeGroup.objects.refresh_workers()
return HttpResponse("Sending worker refresh.", status=httplib.OK)
- @api_exported('POST')
+ @operation(idempotent=False)
def register(self, request):
"""Register a new `NodeGroup`.
@@ -983,12 +977,12 @@
create = read = update = delete = None
fields = DISPLAYED_NODEGROUP_FIELDS
- @api_exported('GET')
+ @operation(idempotent=True)
def list(self, request):
"""List of node groups."""
return NodeGroup.objects.all()
- @api_exported('POST')
+ @operation(idempotent=False)
def accept(self, request):
"""Accept nodegroup enlistment(s).
@@ -1006,7 +1000,7 @@
else:
raise PermissionDenied("That method is reserved to admin users.")
- @api_exported('POST')
+ @operation(idempotent=False)
def reject(self, request):
"""Reject nodegroup enlistment(s).
@@ -1064,7 +1058,7 @@
uuid = nodegroup.uuid
return ('nodegroup_handler', [uuid])
- @api_exported('POST')
+ @operation(idempotent=False)
def update_leases(self, request, uuid):
leases = get_mandatory_param(request.data, 'leases')
nodegroup = get_object_or_404(NodeGroup, uuid=uuid)
@@ -1087,13 +1081,13 @@
create = read = update = delete = None
fields = DISPLAYED_NODEGROUP_FIELDS
- @api_exported('GET')
+ @operation(idempotent=True)
def list(self, request, uuid):
"""List of NodeGroupInterfaces of a NodeGroup."""
nodegroup = get_object_or_404(NodeGroup, uuid=uuid)
return NodeGroupInterface.objects.filter(nodegroup=nodegroup)
- @api_exported('POST')
+ @operation(idempotent=False)
def new(self, request, uuid):
"""Create a new NodeGroupInterface for this NodeGroup.
@@ -1191,7 +1185,7 @@
"""Manage the current logged-in user."""
create = read = update = delete = None
- @api_exported('POST')
+ @operation(idempotent=False)
def create_authorisation_token(self, request):
"""Create an authorisation OAuth token and OAuth consumer.
@@ -1209,7 +1203,7 @@
'consumer_key': consumer.key,
}
- @api_exported('POST')
+ @operation(idempotent=False)
def delete_authorisation_token(self, request):
"""Delete an authorisation OAuth token and the related OAuth consumer.
@@ -1273,7 +1267,7 @@
# http://pad.lv/1049933
# Essentially, if you have one 'GET' op, then you can no longer get
# the Tag object itself from a plain 'GET' without op.
- @api_exported('POST')
+ @operation(idempotent=False)
def nodes(self, request, name):
"""Get the list of nodes that have this tag."""
return Tag.objects.get_nodes(name, user=request.user)
@@ -1291,13 +1285,13 @@
"""Manage collection of Tags."""
create = read = update = delete = None
- @api_exported('POST')
+ @operation(idempotent=False)
def new(self, request):
"""Create a new `Tag`.
"""
return create_tag(request)
- @api_exported('GET')
+ @operation(idempotent=True)
def list(self, request):
"""List Tags.
"""
@@ -1333,7 +1327,7 @@
"""Manage the MAAS' itself."""
create = read = update = delete = None
- @api_exported('POST')
+ @operation(idempotent=False)
def set_config(self, request):
"""Set a config value.
@@ -1348,7 +1342,7 @@
Config.objects.set_config(name, value)
return rc.ALL_OK
- @api_exported('GET')
+ @operation(idempotent=True)
def get_config(self, request):
"""Get a config value.
@@ -1501,7 +1495,7 @@
def resource_uri(cls):
return ('boot_images_handler', [])
- @api_exported('POST')
+ @operation(idempotent=False)
def report_boot_images(self, request):
"""Report images available to net-boot nodes from.
=== modified file 'src/maasserver/tests/test_api_mechanism.py'
--- src/maasserver/tests/test_api_mechanism.py 2012-09-26 23:50:29 +0000
+++ src/maasserver/tests/test_api_mechanism.py 2012-09-26 23:50:29 +0000
@@ -14,44 +14,51 @@
__metaclass__ = type
__all__ = []
-from maasserver.api import api_exported
+from maasserver.api import operation
from maasserver.testing.factory import factory
from maastesting.testcase import TestCase
-class TestApiExported(TestCase):
- """Testing for the api_exported decorator."""
-
- def test_invalid_method(self):
- # If the supplied HTTP method is not in the allowed set, it should
- # raise a ValueError.
- random_method = factory.make_name('method', sep='')
- decorate = api_exported(random_method)
- self.assertRaises(ValueError, decorate, lambda: None)
+class TestOperationDecorator(TestCase):
+ """Testing for the `operation` decorator."""
def test_valid_decoration(self):
value = "value" + factory.getRandomString()
- decorate = api_exported()
+ decorate = operation(idempotent=False)
decorated = decorate(lambda: value)
self.assertEqual(value, decorated())
- def test_can_pass_export_as(self):
- # Test that passing the optional "export_as" works as expected.
- random_exported_name = factory.make_name("exportedas", sep='')
- decorate = api_exported("POST", exported_as=random_exported_name)
+ def test_can_passexported_as(self):
+ # Test that passing the optional "exported_as" works as expected.
+ randomexported_name = factory.make_name("exportedas", sep='')
+ decorate = operation(
+ idempotent=False, exported_as=randomexported_name)
decorated = decorate(lambda: None)
-
- self.assertEqual(
- random_exported_name, decorated._api_exported["POST"])
-
- def test_export_as_is_optional(self):
- # If export_as is not passed then we expect the function to be
+ self.assertEqual(randomexported_name, decorated.export[1])
+
+ def testexported_as_is_optional(self):
+ # If exported_as is not passed then we expect the function to be
# exported in the API using the actual function name itself.
def exported_function():
pass
- decorate = api_exported("POST")
+ decorate = operation(idempotent=True)
decorated = decorate(exported_function)
-
- self.assertEqual("exported_function", decorated._api_exported["POST"])
+ self.assertEqual("exported_function", decorated.export[1])
+
+ def test_idempotent_uses_GET(self):
+ # If a function is declared as idempotent the export signature
+ # includes the HTTP GET method.
+ func = lambda: None
+ self.assertEqual(
+ ("GET", func.__name__),
+ operation(idempotent=True)(func).export)
+
+ def test_non_idempotent_uses_POST(self):
+ # If a function is declared as not idempotent the export signature
+ # includes the HTTP POST method.
+ func = lambda: None
+ self.assertEqual(
+ ("POST", func.__name__),
+ operation(idempotent=False)(func).export)
=== modified file 'src/maasserver/tests/test_apidoc.py'
--- src/maasserver/tests/test_apidoc.py 2012-09-26 23:50:29 +0000
+++ src/maasserver/tests/test_apidoc.py 2012-09-26 23:50:29 +0000
@@ -16,7 +16,7 @@
from django.conf import settings
from maasserver.api import (
- api_exported,
+ operation,
OperationsHandler,
)
from maasserver.apidoc import (
@@ -150,11 +150,11 @@
create = read = delete = None
- @api_exported("POST")
+ @operation(idempotent=False)
def peace_sells_but_whos_buying(self, request, vic, rattlehead):
"""Released 1986."""
- @api_exported("GET")
+ @operation(idempotent=True)
def so_far_so_good_so_what(self, request, vic, rattlehead):
"""Released 1988."""
=== modified file 'src/metadataserver/api.py'
--- src/metadataserver/api.py 2012-09-26 23:50:29 +0000
+++ src/metadataserver/api.py 2012-09-26 23:50:29 +0000
@@ -23,9 +23,9 @@
from django.http import HttpResponse
from django.shortcuts import get_object_or_404
from maasserver.api import (
- api_exported,
extract_oauth_key,
get_mandatory_param,
+ operation,
OperationsHandler,
)
from maasserver.enum import (
@@ -184,7 +184,7 @@
contents = raw_content.decode('utf-8')
NodeCommissionResult.objects.store_data(node, name, contents)
- @api_exported('POST')
+ @operation(idempotent=False)
def signal(self, request, version=None, mac=None):
"""Signal commissioning status.
@@ -235,7 +235,7 @@
return rc.ALL_OK
- @api_exported('POST')
+ @operation(idempotent=False)
def netboot_off(self, request, version=None, mac=None):
"""Turn off netboot on the node.
@@ -246,7 +246,7 @@
node.set_netboot(False)
return rc.ALL_OK
- @api_exported('POST')
+ @operation(idempotent=False)
def netboot_on(self, request, version=None, mac=None):
"""Turn on netboot on the node."""
node = get_queried_node(request, for_mac=mac)
@@ -374,18 +374,18 @@
class AnonMetaDataHandler(VersionIndexHandler):
"""Anonymous metadata."""
- @api_exported('GET')
+ @operation(idempotent=True)
def get_enlist_preseed(self, request, version=None):
"""Render and return a preseed script for enlistment."""
return HttpResponse(get_enlist_preseed(), mimetype="text/plain")
- @api_exported('GET')
+ @operation(idempotent=True)
def get_preseed(self, request, version=None, system_id=None):
"""Render and return a preseed script for the given node."""
node = get_object_or_404(Node, system_id=system_id)
return HttpResponse(get_preseed(node), mimetype="text/plain")
- @api_exported('POST')
+ @operation(idempotent=False)
def netboot_off(self, request, version=None, system_id=None):
"""Turn off netboot on the node.