← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~jtv/maas/extract-api-support into lp:maas

 

Jeroen T. Vermeulen has proposed merging lp:~jtv/maas/extract-api-support into lp:maas.

Commit message:
Extract support code from src/maasserver/api.py.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~jtv/maas/extract-api-support/+merge/135710

The API module is vastly oversized, and we've been waiting for a chance to break it up.  This is not that chance.  But there is some supporting code at the top of the module that we can cut away.  The result is slightly cleaner: the metadataserver API module no longer needs to import the maasserver API module, just the common code.

Pre-imped with Gavin.  It's a large diff, but the actual code has merely moved.  I added one or two docstrings and such, but there are no substantial changes.

We get two new modules in this way: api_utils (for helpers) and api_support (for the classes that implement API-building).  The latter is sadly untested, as is much of the former.  It's not my fault!  It's all covered indirectly by API-level testing, but I didn't remove any tests.

There was one lazy import in apidoc.py which I converted to a plain old import at the top of the file.  The most likely reason for the lazy import was just to avoid circular imports, and that would probably no longer be the case with the supporting code split out of the api module.


Jeroen
-- 
https://code.launchpad.net/~jtv/maas/extract-api-support/+merge/135710
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~jtv/maas/extract-api-support into lp:maas.
=== modified file 'src/maasserver/api.py'
--- src/maasserver/api.py	2012-11-21 17:55:05 +0000
+++ src/maasserver/api.py	2012-11-22 14:48:20 +0000
@@ -57,7 +57,6 @@
     "AccountHandler",
     "AnonNodeGroupsHandler",
     "AnonNodesHandler",
-    "AnonymousOperationsHandler",
     "api_doc",
     "api_doc_title",
     "BootImagesHandler",
@@ -70,7 +69,6 @@
     "NodeMacHandler",
     "NodeMacsHandler",
     "NodesHandler",
-    "OperationsHandler",
     "TagHandler",
     "TagsHandler",
     "pxeconfig",
@@ -99,11 +97,7 @@
     )
 from django.db.utils import DatabaseError
 from django.forms.models import model_to_dict
-from django.http import (
-    HttpResponse,
-    HttpResponseBadRequest,
-    QueryDict,
-    )
+from django.http import HttpResponse
 from django.shortcuts import (
     get_object_or_404,
     render_to_response,
@@ -111,7 +105,19 @@
 from django.template import RequestContext
 from docutils import core
 from formencode import validators
-from formencode.validators import Invalid
+from maasserver.api_support import (
+    AnonymousOperationsHandler,
+    operation,
+    OperationsHandler,
+    )
+from maasserver.api_utils import (
+    extract_oauth_key,
+    get_list_from_dict_or_multidict,
+    get_mandatory_param,
+    get_oauth_token,
+    get_optional_list,
+    get_overrided_query_dict,
+    )
 from maasserver.apidoc import (
     describe_resource,
     find_api_resources,
@@ -170,269 +176,11 @@
     strip_domain,
     )
 from maasserver.utils.orm import get_one
-from piston.handler import (
-    AnonymousBaseHandler,
-    BaseHandler,
-    HandlerMetaClass,
-    )
-from piston.models import Token
-from piston.resource import Resource
 from piston.utils import rc
 from provisioningserver.enum import POWER_TYPE
 from provisioningserver.kernel_opts import KernelParameters
 import simplejson as json
 
-
-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(
-            RestrictedResource, self).authenticate(request, rm)
-        if not anonymous and not request.user.is_active:
-            raise PermissionDenied("User is not allowed access to this API.")
-        else:
-            return actor, anonymous
-
-
-class AdminRestrictedResource(RestrictedResource):
-
-    def authenticate(self, request, rm):
-        actor, anonymous = super(
-            AdminRestrictedResource, self).authenticate(request, rm)
-        if anonymous or not request.user.is_superuser:
-            raise PermissionDenied("User is not allowed access to this API.")
-        else:
-            return actor, anonymous
-
-
-def operation(idempotent, exported_as=None):
-    """Decorator to make a method available on the API.
-
-    :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 exported_as is None:
-            func.export = method, func.__name__
-        else:
-            func.export = method, exported_as
-        return func
-
-    return _decorator
-
-
-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.
-    """
-
-    def __new__(metaclass, name, bases, namespace):
-        cls = super(OperationsHandlerType, metaclass).__new__(
-            metaclass, name, bases, namespace)
-
-        # Create a signature:function mapping for CRUD operations.
-        crud = {
-            (http_method, None): getattr(cls, method)
-            for http_method, method in OperationsResource.crudmap.items()
-            if getattr(cls, method, None) is not None
-            }
-
-        # Create a signature:function mapping for non-CRUD operations.
-        operations = {
-            attribute.export: attribute
-            for attribute in vars(cls).values()
-            if getattr(attribute, "export", None) is not None
-            }
-
-        # Create the exports mapping.
-        exports = {}
-        exports.update(crud)
-        exports.update(operations)
-
-        # 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):
-    """Get the parameter from the provided data dict or raise a ValidationError
-    if this parameter is not present.
-
-    :param data: The data dict (usually request.data or request.GET where
-        request is a django.http.HttpRequest).
-    :param data: dict
-    :param key: The parameter's key.
-    :type key: basestring
-    :param validator: An optional validator that will be used to validate the
-         retrieved value.
-    :type validator: formencode.validators.Validator
-    :return: The value of the parameter.
-    :raises: ValidationError
-    """
-    value = data.get(key, None)
-    if value is None:
-        raise ValidationError("No provided %s!" % key)
-    if validator is not None:
-        try:
-            return validator.to_python(value)
-        except Invalid, e:
-            raise ValidationError("Invalid %s: %s" % (key, e.msg))
-    else:
-        return value
-
-
-def get_optional_list(data, key, default=None):
-    """Get the list from the provided data dict or return a default value.
-    """
-    value = data.getlist(key)
-    if value == []:
-        return default
-    else:
-        return value
-
-
-def get_list_from_dict_or_multidict(data, key, default=None):
-    """Get a list from 'data'.
-
-    If data is a MultiDict, then we use 'getlist' if the data is a plain dict,
-    then we just use __getitem__.
-
-    The rationale is that data POSTed as multipart/form-data gets parsed into a
-    MultiDict, but data POSTed as application/json gets parsed into a plain
-    dict(key:list).
-    """
-    getlist = getattr(data, 'getlist', None)
-    if getlist is not None:
-        return getlist(key, default)
-    return data.get(key, default)
-
-
-def extract_oauth_key_from_auth_header(auth_data):
-    """Extract the oauth key from auth data in HTTP header.
-
-    :param auth_data: {string} The HTTP Authorization header.
-
-    :return: The oauth key from the header, or None.
-    """
-    for entry in auth_data.split():
-        key_value = entry.split('=', 1)
-        if len(key_value) == 2:
-            key, value = key_value
-            if key == 'oauth_token':
-                return value.rstrip(',').strip('"')
-    return None
-
-
-def extract_oauth_key(request):
-    """Extract the oauth key from a request's headers.
-
-    Raises :class:`Unauthorized` if no key is found.
-    """
-    auth_header = request.META.get('HTTP_AUTHORIZATION')
-    if auth_header is None:
-        raise Unauthorized("No authorization header received.")
-    key = extract_oauth_key_from_auth_header(auth_header)
-    if key is None:
-        raise Unauthorized("Did not find request's oauth token.")
-    return key
-
-
-def get_oauth_token(request):
-    """Get the OAuth :class:`piston.models.Token` used for `request`.
-
-    Raises :class:`Unauthorized` if no key is found, or if the token is
-    unknown.
-    """
-    try:
-        return Token.objects.get(key=extract_oauth_key(request))
-    except Token.DoesNotExist:
-        raise Unauthorized("Unknown OAuth token.")
-
-
-def get_overrided_query_dict(defaults, data):
-    """Returns a QueryDict with the values of 'defaults' overridden by the
-    values in 'data'.
-
-    :param defaults: The dictionary containing the default values.
-    :type defaults: dict
-    :param data: The data used to override the defaults.
-    :type data: :class:`django.http.QueryDict`
-    :return: The updated QueryDict.
-    :raises: :class:`django.http.QueryDict`
-    """
-    # Create a writable query dict.
-    new_data = QueryDict('').copy()
-    # Missing fields will be taken from the node's current values.  This
-    # is to circumvent Django's ModelForm (form created from a model)
-    # default behaviour that requires all the fields to be defined.
-    new_data.update(defaults)
-    # We can't use update here because data is a QueryDict and 'update'
-    # does not replaces the old values with the new as one would expect.
-    for k, v in data.items():
-        new_data[k] = v
-    return new_data
-
-
 # Node's fields exposed on the API.
 DISPLAYED_NODE_FIELDS = (
     'system_id',

=== added file 'src/maasserver/api_support.py'
--- src/maasserver/api_support.py	1970-01-01 00:00:00 +0000
+++ src/maasserver/api_support.py	2012-11-22 14:48:20 +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).
+
+"""Supporting infrastructure for Piston-based APIs in MAAS."""
+
+from __future__ import (
+    absolute_import,
+    print_function,
+    unicode_literals,
+    )
+
+__metaclass__ = type
+__all__ = [
+    'AnonymousOperationsHandler',
+    'operation',
+    'OperationsHandler',
+    ]
+
+from django.core.exceptions import PermissionDenied
+from django.http import HttpResponseBadRequest
+from piston.handler import (
+    AnonymousBaseHandler,
+    BaseHandler,
+    HandlerMetaClass,
+    )
+from piston.resource import 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):
+    """A resource that's restricted to active users."""
+
+    def authenticate(self, request, rm):
+        actor, anonymous = super(
+            RestrictedResource, self).authenticate(request, rm)
+        if not anonymous and not request.user.is_active:
+            raise PermissionDenied("User is not allowed access to this API.")
+        else:
+            return actor, anonymous
+
+
+class AdminRestrictedResource(RestrictedResource):
+    """A resource that's restricted to administrators."""
+
+    def authenticate(self, request, rm):
+        actor, anonymous = super(
+            AdminRestrictedResource, self).authenticate(request, rm)
+        if anonymous or not request.user.is_superuser:
+            raise PermissionDenied("User is not allowed access to this API.")
+        else:
+            return actor, anonymous
+
+
+def operation(idempotent, exported_as=None):
+    """Decorator to make a method available on the API.
+
+    :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 exported_as is None:
+            func.export = method, func.__name__
+        else:
+            func.export = method, exported_as
+        return func
+
+    return _decorator
+
+
+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.
+    """
+
+    def __new__(metaclass, name, bases, namespace):
+        cls = super(OperationsHandlerType, metaclass).__new__(
+            metaclass, name, bases, namespace)
+
+        # Create a signature:function mapping for CRUD operations.
+        crud = {
+            (http_method, None): getattr(cls, method)
+            for http_method, method in OperationsResource.crudmap.items()
+            if getattr(cls, method, None) is not None
+            }
+
+        # Create a signature:function mapping for non-CRUD operations.
+        operations = {
+            attribute.export: attribute
+            for attribute in vars(cls).values()
+            if getattr(attribute, "export", None) is not None
+            }
+
+        # Create the exports mapping.
+        exports = {}
+        exports.update(crud)
+        exports.update(operations)
+
+        # 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

=== added file 'src/maasserver/api_utils.py'
--- src/maasserver/api_utils.py	1970-01-01 00:00:00 +0000
+++ src/maasserver/api_utils.py	2012-11-22 14:48:20 +0000
@@ -0,0 +1,145 @@
+# Copyright 2012 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Helpers for Piston-based MAAS APIs."""
+
+from __future__ import (
+    absolute_import,
+    print_function,
+    unicode_literals,
+    )
+
+__metaclass__ = type
+__all__ = [
+    'extract_oauth_key',
+    'get_list_from_dict_or_multidict',
+    'get_mandatory_param',
+    'get_oauth_token',
+    'get_optional_list',
+    'get_overrided_query_dict',
+    ]
+
+from django.core.exceptions import ValidationError
+from django.http import QueryDict
+from formencode.validators import Invalid
+from maasserver.exceptions import Unauthorized
+from piston.models import Token
+
+
+def get_mandatory_param(data, key, validator=None):
+    """Get the parameter from the provided data dict or raise a ValidationError
+    if this parameter is not present.
+
+    :param data: The data dict (usually request.data or request.GET where
+        request is a django.http.HttpRequest).
+    :param data: dict
+    :param key: The parameter's key.
+    :type key: basestring
+    :param validator: An optional validator that will be used to validate the
+         retrieved value.
+    :type validator: formencode.validators.Validator
+    :return: The value of the parameter.
+    :raises: ValidationError
+    """
+    value = data.get(key, None)
+    if value is None:
+        raise ValidationError("No provided %s!" % key)
+    if validator is not None:
+        try:
+            return validator.to_python(value)
+        except Invalid as e:
+            raise ValidationError("Invalid %s: %s" % (key, e.msg))
+    else:
+        return value
+
+
+def get_optional_list(data, key, default=None):
+    """Get the list from the provided data dict or return a default value.
+    """
+    value = data.getlist(key)
+    if value == []:
+        return default
+    else:
+        return value
+
+
+def get_list_from_dict_or_multidict(data, key, default=None):
+    """Get a list from 'data'.
+
+    If data is a MultiDict, then we use 'getlist' if the data is a plain dict,
+    then we just use __getitem__.
+
+    The rationale is that data POSTed as multipart/form-data gets parsed into a
+    MultiDict, but data POSTed as application/json gets parsed into a plain
+    dict(key:list).
+    """
+    getlist = getattr(data, 'getlist', None)
+    if getlist is not None:
+        return getlist(key, default)
+    return data.get(key, default)
+
+
+def get_overrided_query_dict(defaults, data):
+    """Returns a QueryDict with the values of 'defaults' overridden by the
+    values in 'data'.
+
+    :param defaults: The dictionary containing the default values.
+    :type defaults: dict
+    :param data: The data used to override the defaults.
+    :type data: :class:`django.http.QueryDict`
+    :return: The updated QueryDict.
+    :raises: :class:`django.http.QueryDict`
+    """
+    # Create a writable query dict.
+    new_data = QueryDict('').copy()
+    # Missing fields will be taken from the node's current values.  This
+    # is to circumvent Django's ModelForm (form created from a model)
+    # default behaviour that requires all the fields to be defined.
+    new_data.update(defaults)
+    # We can't use update here because data is a QueryDict and 'update'
+    # does not replaces the old values with the new as one would expect.
+    for k, v in data.items():
+        new_data[k] = v
+    return new_data
+
+
+def extract_oauth_key_from_auth_header(auth_data):
+    """Extract the oauth key from auth data in HTTP header.
+
+    :param auth_data: {string} The HTTP Authorization header.
+
+    :return: The oauth key from the header, or None.
+    """
+    for entry in auth_data.split():
+        key_value = entry.split('=', 1)
+        if len(key_value) == 2:
+            key, value = key_value
+            if key == 'oauth_token':
+                return value.rstrip(',').strip('"')
+    return None
+
+
+def extract_oauth_key(request):
+    """Extract the oauth key from a request's headers.
+
+    Raises :class:`Unauthorized` if no key is found.
+    """
+    auth_header = request.META.get('HTTP_AUTHORIZATION')
+    if auth_header is None:
+        raise Unauthorized("No authorization header received.")
+    key = extract_oauth_key_from_auth_header(auth_header)
+    if key is None:
+        raise Unauthorized("Did not find request's oauth token.")
+    return key
+
+
+def get_oauth_token(request):
+    """Get the OAuth :class:`piston.models.Token` used for `request`.
+
+    Raises :class:`Unauthorized` if no key is found, or if the token is
+    unknown.
+    """
+    try:
+        return Token.objects.get(key=extract_oauth_key(request))
+    except Token.DoesNotExist:
+        raise Unauthorized("Unknown OAuth token.")

=== modified file 'src/maasserver/apidoc.py'
--- src/maasserver/apidoc.py	2012-11-08 06:34:48 +0000
+++ src/maasserver/apidoc.py	2012-11-22 14:48:20 +0000
@@ -25,6 +25,7 @@
     RegexURLPattern,
     RegexURLResolver,
     )
+from maasserver.api_support import OperationsResource
 from piston.authentication import NoAuthentication
 from piston.doc import generate_doc
 from piston.handler import BaseHandler
@@ -115,7 +116,6 @@
       restful: Indicates if this is a CRUD/ReSTful action.
 
     """
-    from maasserver.api import OperationsResource
     getname = OperationsResource.crudmap.get
     for signature, function in handler.exports.items():
         http_method, operation = signature

=== modified file 'src/maasserver/tests/test_api.py'
--- src/maasserver/tests/test_api.py	2012-11-21 10:24:27 +0000
+++ src/maasserver/tests/test_api.py	2012-11-22 14:48:20 +0000
@@ -17,7 +17,6 @@
     abstractproperty,
     )
 from base64 import b64encode
-from collections import namedtuple
 from cStringIO import StringIO
 from datetime import (
     datetime,
@@ -51,10 +50,6 @@
     describe,
     DISPLAYED_NODEGROUP_FIELDS,
     extract_constraints,
-    extract_oauth_key,
-    extract_oauth_key_from_auth_header,
-    get_oauth_token,
-    get_overrided_query_dict,
     store_node_power_parameters,
     )
 from maasserver.enum import (
@@ -68,10 +63,7 @@
     NODEGROUP_STATUS,
     NODEGROUPINTERFACE_MANAGEMENT,
     )
-from maasserver.exceptions import (
-    MAASAPIBadRequest,
-    Unauthorized,
-    )
+from maasserver.exceptions import MAASAPIBadRequest
 from maasserver.fields import mac_error_msg
 from maasserver.forms import DEFAULT_ZONE_NAME
 from maasserver.models import (
@@ -170,54 +162,6 @@
 
 class TestModuleHelpers(TestCase):
 
-    def make_fake_request(self, auth_header):
-        """Create a very simple fake request, with just an auth header."""
-        FakeRequest = namedtuple('FakeRequest', ['META'])
-        return FakeRequest(META={'HTTP_AUTHORIZATION': auth_header})
-
-    def test_extract_oauth_key_from_auth_header_returns_key(self):
-        token = factory.getRandomString(18)
-        self.assertEqual(
-            token,
-            extract_oauth_key_from_auth_header(
-                factory.make_oauth_header(oauth_token=token)))
-
-    def test_extract_oauth_key_from_auth_header_returns_None_if_missing(self):
-        self.assertIs(None, extract_oauth_key_from_auth_header(''))
-
-    def test_extract_oauth_key_raises_Unauthorized_if_no_auth_header(self):
-        self.assertRaises(
-            Unauthorized,
-            extract_oauth_key, self.make_fake_request(None))
-
-    def test_extract_oauth_key_raises_Unauthorized_if_no_key(self):
-        self.assertRaises(
-            Unauthorized,
-            extract_oauth_key, self.make_fake_request(''))
-
-    def test_extract_oauth_key_returns_key(self):
-        token = factory.getRandomString(18)
-        self.assertEqual(
-            token,
-            extract_oauth_key(self.make_fake_request(
-                factory.make_oauth_header(oauth_token=token))))
-
-    def test_get_oauth_token_finds_token(self):
-        user = factory.make_user()
-        consumer, token = user.get_profile().create_authorisation_token()
-        self.assertEqual(
-            token,
-            get_oauth_token(
-                self.make_fake_request(
-                    factory.make_oauth_header(oauth_token=token.key))))
-
-    def test_get_oauth_token_raises_Unauthorized_for_unknown_token(self):
-        fake_token = factory.getRandomString(18)
-        header = factory.make_oauth_header(oauth_token=fake_token)
-        self.assertRaises(
-            Unauthorized,
-            get_oauth_token, self.make_fake_request(header))
-
     def test_extract_constraints_ignores_unknown_parameters(self):
         unknown_parameter = "%s=%s" % (
             factory.getRandomString(),
@@ -232,21 +176,6 @@
             {'hostname': name},
             extract_constraints(QueryDict('name=%s' % name)))
 
-    def test_get_overrided_query_dict_returns_QueryDict(self):
-        defaults = {factory.getRandomString(): factory.getRandomString()}
-        results = get_overrided_query_dict(defaults, QueryDict(''))
-        expected_results = QueryDict('').copy()
-        expected_results.update(defaults)
-        self.assertEqual(expected_results, results)
-
-    def test_get_overrided_query_dict_values_in_data_replaces_defaults(self):
-        key = factory.getRandomString()
-        defaults = {key: factory.getRandomString()}
-        data_value = factory.getRandomString()
-        data = {key: data_value}
-        results = get_overrided_query_dict(defaults, data)
-        self.assertEqual([data_value], results.getlist(key))
-
 
 class TestAuthentication(APIv10TestMixin, TestCase):
     """Tests for `maasserver.api_auth`."""

=== added file 'src/maasserver/tests/test_api_utils.py'
--- src/maasserver/tests/test_api_utils.py	1970-01-01 00:00:00 +0000
+++ src/maasserver/tests/test_api_utils.py	2012-11-22 14:48:20 +0000
@@ -0,0 +1,95 @@
+# Copyright 2012 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Tests for API helpers."""
+
+from __future__ import (
+    absolute_import,
+    print_function,
+    unicode_literals,
+    )
+
+__metaclass__ = type
+__all__ = []
+
+from collections import namedtuple
+
+from django.http import QueryDict
+from maasserver.api_utils import (
+    extract_oauth_key,
+    extract_oauth_key_from_auth_header,
+    get_oauth_token,
+    get_overrided_query_dict,
+    )
+from maasserver.exceptions import Unauthorized
+from maasserver.testing.factory import factory
+from maasserver.testing.testcase import TestCase
+
+
+class TestGetOverridedQueryDict(TestCase):
+
+    def test_returns_QueryDict(self):
+        defaults = {factory.getRandomString(): factory.getRandomString()}
+        results = get_overrided_query_dict(defaults, QueryDict(''))
+        expected_results = QueryDict('').copy()
+        expected_results.update(defaults)
+        self.assertEqual(expected_results, results)
+
+    def test_data_values_override_defaults(self):
+        key = factory.getRandomString()
+        defaults = {key: factory.getRandomString()}
+        data_value = factory.getRandomString()
+        data = {key: data_value}
+        results = get_overrided_query_dict(defaults, data)
+        self.assertEqual([data_value], results.getlist(key))
+
+
+class TestOAuthHelpers(TestCase):
+
+    def make_fake_request(self, auth_header):
+        """Create a very simple fake request, with just an auth header."""
+        FakeRequest = namedtuple('FakeRequest', ['META'])
+        return FakeRequest(META={'HTTP_AUTHORIZATION': auth_header})
+
+    def test_extract_oauth_key_from_auth_header_returns_key(self):
+        token = factory.getRandomString(18)
+        self.assertEqual(
+            token,
+            extract_oauth_key_from_auth_header(
+                factory.make_oauth_header(oauth_token=token)))
+
+    def test_extract_oauth_key_from_auth_header_returns_None_if_missing(self):
+        self.assertIs(None, extract_oauth_key_from_auth_header(''))
+
+    def test_extract_oauth_key_raises_Unauthorized_if_no_auth_header(self):
+        self.assertRaises(
+            Unauthorized,
+            extract_oauth_key, self.make_fake_request(None))
+
+    def test_extract_oauth_key_raises_Unauthorized_if_no_key(self):
+        self.assertRaises(
+            Unauthorized,
+            extract_oauth_key, self.make_fake_request(''))
+
+    def test_extract_oauth_key_returns_key(self):
+        token = factory.getRandomString(18)
+        self.assertEqual(
+            token,
+            extract_oauth_key(self.make_fake_request(
+                factory.make_oauth_header(oauth_token=token))))
+
+    def test_get_oauth_token_finds_token(self):
+        user = factory.make_user()
+        consumer, token = user.get_profile().create_authorisation_token()
+        self.assertEqual(
+            token,
+            get_oauth_token(
+                self.make_fake_request(
+                    factory.make_oauth_header(oauth_token=token.key))))
+
+    def test_get_oauth_token_raises_Unauthorized_for_unknown_token(self):
+        fake_token = factory.getRandomString(18)
+        header = factory.make_oauth_header(oauth_token=fake_token)
+        self.assertRaises(
+            Unauthorized,
+            get_oauth_token, self.make_fake_request(header))

=== modified file 'src/maasserver/tests/test_apidoc.py'
--- src/maasserver/tests/test_apidoc.py	2012-11-08 06:34:48 +0000
+++ src/maasserver/tests/test_apidoc.py	2012-11-22 14:48:20 +0000
@@ -22,7 +22,7 @@
     url,
     )
 from django.core.exceptions import ImproperlyConfigured
-from maasserver.api import (
+from maasserver.api_support import (
     operation,
     OperationsHandler,
     OperationsResource,

=== modified file 'src/maasserver/urls_api.py'
--- src/maasserver/urls_api.py	2012-09-20 15:10:28 +0000
+++ src/maasserver/urls_api.py	2012-11-22 14:48:20 +0000
@@ -18,7 +18,6 @@
     )
 from maasserver.api import (
     AccountHandler,
-    AdminRestrictedResource,
     api_doc,
     BootImagesHandler,
     describe,
@@ -32,12 +31,15 @@
     NodeMacHandler,
     NodeMacsHandler,
     NodesHandler,
+    pxeconfig,
     TagHandler,
     TagsHandler,
-    pxeconfig,
+    )
+from maasserver.api_auth import api_auth
+from maasserver.api_support import (
+    AdminRestrictedResource,
     RestrictedResource,
     )
-from maasserver.api_auth import api_auth
 
 
 account_handler = RestrictedResource(AccountHandler, authentication=api_auth)

=== modified file 'src/metadataserver/api.py'
--- src/metadataserver/api.py	2012-11-14 14:34:06 +0000
+++ src/metadataserver/api.py	2012-11-22 14:48:20 +0000
@@ -25,12 +25,14 @@
 from django.core.exceptions import PermissionDenied
 from django.http import HttpResponse
 from django.shortcuts import get_object_or_404
-from maasserver.api import (
+from maasserver.api import store_node_power_parameters
+from maasserver.api_support import (
+    operation,
+    OperationsHandler,
+    )
+from maasserver.api_utils import (
     extract_oauth_key,
     get_mandatory_param,
-    operation,
-    OperationsHandler,
-    store_node_power_parameters,
     )
 from maasserver.enum import (
     NODE_STATUS,

=== modified file 'src/metadataserver/urls.py'
--- src/metadataserver/urls.py	2012-09-26 21:42:32 +0000
+++ src/metadataserver/urls.py	2012-11-22 14:48:20 +0000
@@ -18,8 +18,8 @@
     patterns,
     url,
     )
-from maasserver.api import OperationsResource
 from maasserver.api_auth import api_auth
+from maasserver.api_support import OperationsResource
 from metadataserver.api import (
     AnonMetaDataHandler,
     EnlistMetaDataHandler,


Follow ups