From cd2e5a7d644fc346c290b39870eda3b5139d4d76 Mon Sep 17 00:00:00 2001 From: vinod pandarinathan Date: Fri, 12 Jun 2015 14:28:46 -0700 Subject: [PATCH] Cloudpulse API handling code Change-Id: I2b50cf9bd59d96274a96561337d0174ab0aabbdf Implements: blueprint cloudpulse-api-handlers --- cloudpulse/api/controllers/v1/__init__.py | 115 ++++++++++ cloudpulse/api/controllers/v1/collection.py | 48 ++++ cloudpulse/api/controllers/v1/cpulse.py | 231 ++++++++++++++++++++ cloudpulse/api/controllers/v1/types.py | 181 +++++++++++++++ cloudpulse/api/controllers/v1/utils.py | 70 ++++++ 5 files changed, 645 insertions(+) create mode 100644 cloudpulse/api/controllers/v1/__init__.py create mode 100644 cloudpulse/api/controllers/v1/collection.py create mode 100644 cloudpulse/api/controllers/v1/cpulse.py create mode 100644 cloudpulse/api/controllers/v1/types.py create mode 100644 cloudpulse/api/controllers/v1/utils.py diff --git a/cloudpulse/api/controllers/v1/__init__.py b/cloudpulse/api/controllers/v1/__init__.py new file mode 100644 index 0000000..6d28f73 --- /dev/null +++ b/cloudpulse/api/controllers/v1/__init__.py @@ -0,0 +1,115 @@ +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +""" +Version 1 of the Cloudpulse API + +NOTE: IN PROGRESS AND NOT FULLY IMPLEMENTED. +""" + +import datetime + +import pecan +from pecan import rest +import wsme +from wsme import types as wtypes +import wsmeext.pecan as wsme_pecan + +from cloudpulse.api.controllers import link +from cloudpulse.api.controllers.v1 import cpulse + + +class APIBase(wtypes.Base): + + created_at = wsme.wsattr(datetime.datetime, readonly=True) + """The time in UTC at which the object is created""" + + updated_at = wsme.wsattr(datetime.datetime, readonly=True) + """The time in UTC at which the object is updated""" + + def as_dict(self): + """Render this object as a dict of its fields.""" + return dict((k, getattr(self, k)) + for k in self.fields + if hasattr(self, k) and + getattr(self, k) != wsme.Unset) + + def unset_fields_except(self, except_list=None): + """Unset fields so they don't appear in the message body. + + :param except_list: A list of fields that won't be touched. + + """ + if except_list is None: + except_list = [] + + for k in self.as_dict(): + if k not in except_list: + setattr(self, k, wsme.Unset) + + +class V1(APIBase): + """The representation of the version 1 of the API.""" + + id = wtypes.text + """The ID of the version, also acts as the release number""" + + cpulse = [link.Link] + """Links to the cpulse resource""" + + extcpulse = [link.Link] + """Links to the cpulse extension resource""" + + @staticmethod + def convert(): + v1 = V1() + v1.id = "v1" + v1.links = [link.Link.make_link('self', pecan.request.host_url, + 'v1', '', bookmark=True), + link.Link.make_link('describedby', + 'http://docs.openstack.org', + 'developer/cloudpulse/dev', + 'api-spec-v1.html', + bookmark=True, type='text/html') + ] + v1.cpulse = [link.Link.make_link('self', pecan.request.host_url, + 'cpulse', ''), + link.Link.make_link('bookmark', + pecan.request.host_url, + 'cpulse', '', + bookmark=True) + ] + v1.cpulse_ext = [link.Link.make_link('self', pecan.request.host_url, + 'cpulse_ext', ''), + link.Link.make_link('bookmark', + pecan.request.host_url, + 'cpulse_ext', '', + bookmark=True) + ] + return v1 + + +class Controller(rest.RestController): + """Version 1 API controller root.""" + + cpulse = cpulse.cpulseController() + + @wsme_pecan.wsexpose(V1) + def get(self): + # NOTE: The reason why convert() it's being called for every + # request is because we need to get the host url from + # the request object to make the links. + return V1.convert() + +__all__ = (Controller) diff --git a/cloudpulse/api/controllers/v1/collection.py b/cloudpulse/api/controllers/v1/collection.py new file mode 100644 index 0000000..62df2d7 --- /dev/null +++ b/cloudpulse/api/controllers/v1/collection.py @@ -0,0 +1,48 @@ +# Copyright 2013 Red Hat, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import pecan +from wsme import types as wtypes + +from cloudpulse.api.controllers import base +from cloudpulse.api.controllers import link + + +class Collection(base.APIBase): + + next = wtypes.text + """A link to retrieve the next subset of the collection""" + + @property + def collection(self): + return getattr(self, self._type) + + def has_next(self, limit): + """Return whether collection has more items.""" + return len(self.collection) and len(self.collection) == limit + + def get_next(self, limit, url=None, **kwargs): + """Return a link to the next subset of the collection.""" + if not self.has_next(limit): + return wtypes.Unset + + resource_url = url or self._type + q_args = ''.join(['%s=%s&' % (key, kwargs[key]) for key in kwargs]) + next_args = ('?%(args)slimit=%(limit)d&marker=%(marker)s' + % {'args': q_args, 'limit': limit, + 'marker': self.collection[-1].uuid}) + + return link.Link.make_link('next', pecan.request.host_url, + resource_url, next_args).href diff --git a/cloudpulse/api/controllers/v1/cpulse.py b/cloudpulse/api/controllers/v1/cpulse.py new file mode 100644 index 0000000..dc4f5fc --- /dev/null +++ b/cloudpulse/api/controllers/v1/cpulse.py @@ -0,0 +1,231 @@ +# Copyright 2013 UnitedStack Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import datetime + +import pecan +from pecan import rest +import wsme +from wsme import types as wtypes +import wsmeext.pecan as wsme_pecan + +from cloudpulse.api.controllers import base +from cloudpulse.api.controllers import link +from cloudpulse.api.controllers.v1 import collection +from cloudpulse.api.controllers.v1 import types +from cloudpulse.api.controllers.v1 import utils as api_utils +from cloudpulse.common import exception +from cloudpulse import objects + + +class CpulsePatchType(types.JsonPatchType): + + @staticmethod + def mandatory_attrs(): + return ['/uuid'] + + +class Cpulse(base.APIBase): + """API representation of a test. + + This class enforces type checking and value constraints, and converts + between the internal object model and the API representation of a test. + """ + id = wtypes.IntegerType(minimum=1) + + uuid = types.uuid + """Unique UUID for this test""" + + name = wtypes.StringType(min_length=1, max_length=255) + """Name of this test""" + + state = wtypes.StringType(min_length=1, max_length=255) + """State of this test""" + + cpulse_create_timeout = wtypes.IntegerType(minimum=0) + """Timeout for creating the test in minutes. Set to 0 for no timeout.""" + links = wsme.wsattr([link.Link], readonly=True) + """A list containing a self link and associated test links""" + + result = wtypes.StringType(min_length=1, max_length=1024) + """Result of this test""" + + def __init__(self, **kwargs): + super(Cpulse, self).__init__() + + self.fields = [] + for field in objects.Cpulse.fields: + # Skip fields we do not expose. + if not hasattr(self, field): + continue + self.fields.append(field) + setattr(self, field, kwargs.get(field, wtypes.Unset)) + + @staticmethod + def _convert_with_links(cpulse, url, expand=True): + if not expand: + cpulse.unset_fields_except(['uuid', 'name', 'state', 'id', 'result' + ]) + return cpulse + + @classmethod + def convert_with_links(cls, rpc_test, expand=True): + test = Cpulse(**rpc_test.as_dict()) + return cls._convert_with_links(test, pecan.request.host_url, expand) + + @classmethod + def sample(cls, expand=True): + sample = cls(uuid='27e3153e-d5bf-4b7e-b517-fb518e17f34c', + name='example', + state="CREATED", + result="NotYetRun", + created_at=datetime.datetime.utcnow(), + updated_at=datetime.datetime.utcnow()) + return cls._convert_with_links(sample, 'http://localhost:9511', expand) + + +class CpulseCollection(collection.Collection): + """API representation of a collection of tests.""" + + cpulses = [Cpulse] + """A list containing tests objects""" + + def __init__(self, **kwargs): + self._type = 'cpulses' + + @staticmethod + def convert_with_links(rpc_tests, limit, url=None, expand=False, **kwargs): + collection = CpulseCollection() + collection.cpulses = [Cpulse.convert_with_links(p, expand) + for p in rpc_tests] + collection.next = collection.get_next(limit, url=url, **kwargs) + return collection + + @classmethod + def sample(cls): + sample = cls() + sample.cpulse = [Cpulse.sample(expand=False)] + return sample + + +class cpulseController(rest.RestController): + """REST controller for Cpulse..""" + def __init__(self): + super(cpulseController, self).__init__() + + _custom_actions = {'detail': ['GET']} + + def _get_tests_collection(self, marker, limit, + sort_key, sort_dir, expand=False, + resource_url=None): + + limit = api_utils.validate_limit(limit) + sort_dir = api_utils.validate_sort_dir(sort_dir) + + marker_obj = None + if marker: + marker_obj = objects.Cpulse.get_by_uuid(pecan.request.context, + marker) + + tests = pecan.request.rpcapi.test_list(pecan.request.context, limit, + marker_obj, sort_key=sort_key, + sort_dir=sort_dir) + + return CpulseCollection.convert_with_links(tests, limit, + url=resource_url, + expand=expand, + sort_key=sort_key, + sort_dir=sort_dir) + + @wsme_pecan.wsexpose(CpulseCollection, types.uuid, + types.uuid, int, wtypes.text, wtypes.text) + def get_all(self, test_uuid=None, marker=None, limit=None, + sort_key='id', sort_dir='asc'): + """Retrieve a list of tests. + + :param marker: pagination marker for large data sets. + :param limit: maximum number of resources to return in a single result. + :param sort_key: column to sort results by. Default: id. + :param sort_dir: direction to sort. "asc" or "desc". Default: asc. + """ + return self._get_tests_collection(marker, limit, sort_key, + sort_dir) + + @wsme_pecan.wsexpose(CpulseCollection, types.uuid, + types.uuid, int, wtypes.text, wtypes.text) + def detail(self, test_uuid=None, marker=None, limit=None, + sort_key='id', sort_dir='asc'): + """Retrieve a list of tests with detail. + + :param test_uuid: UUID of a test, to get only tests for that test. + :param marker: pagination marker for large data sets. + :param limit: maximum number of resources to return in a single result. + :param sort_key: column to sort results by. Default: id. + :param sort_dir: direction to sort. "asc" or "desc". Default: asc. + """ + # NOTE(lucasagomes): /detail should only work agaist collections + parent = pecan.request.path.split('/')[:-1][-1] + if parent != "tests": + raise exception.HTTPNotFound + + expand = True + resource_url = '/'.join(['tests', 'detail']) + return self._get_tests_collection(marker, limit, + sort_key, sort_dir, expand, + resource_url) + + @wsme_pecan.wsexpose(Cpulse, types.uuid_or_name) + def get_one(self, test_ident): + """Retrieve information about the given test. + + :param test_ident: UUID of a test or logical name of the test. + """ + + rpc_test = api_utils.get_rpc_resource('Cpulse', test_ident) + + return Cpulse.convert_with_links(rpc_test) + + @wsme_pecan.wsexpose(Cpulse, body=Cpulse, status_code=201) + def post(self, test): + """Create a new test. + + :param test: a test within the request body. + """ + + test_dict = test.as_dict() + context = pecan.request.context + auth_token = context.auth_token_info['token'] + test_dict['project_id'] = auth_token['project']['id'] + test_dict['user_id'] = auth_token['user']['id'] + ncp = objects.Cpulse(context, **test_dict) + ncp.cpulse_create_timeout = 0 + ncp.result = "NotYetRun" + res_test = pecan.request.rpcapi.test_create(ncp, + ncp.cpulse_create_timeout) + + return Cpulse.convert_with_links(res_test) + + @wsme_pecan.wsexpose(None, types.uuid_or_name, status_code=204) + def delete(self, test_ident): + """Delete a test. + + :param test_ident: UUID of a test or logical name of the test. + """ + + context = pecan.request.context + + rpc_test = api_utils.get_rpc_resource('Cpulse', test_ident) + + pecan.request.rpcapi.test_delete(context, rpc_test.uuid) diff --git a/cloudpulse/api/controllers/v1/types.py b/cloudpulse/api/controllers/v1/types.py new file mode 100644 index 0000000..162ec2e --- /dev/null +++ b/cloudpulse/api/controllers/v1/types.py @@ -0,0 +1,181 @@ +# coding: utf-8 +# +# Copyright 2013 Red Hat, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from oslo_utils import strutils +import wsme +from wsme import types as wtypes + +from cloudpulse.common import exception +from cloudpulse.common import utils +from cloudpulse.openstack.common._i18n import _ + + +class NameType(wtypes.UserType): + """A logical name type.""" + + basetype = wtypes.text + name = 'name' + # FIXME(lucasagomes): When used with wsexpose decorator WSME will try + # to get the name of the type by accessing it's __name__ attribute. + # Remove this __name__ attribute once it's fixed in WSME. + # https://bugs.launchpad.net/wsme/+bug/1265590 + __name__ = name + + @staticmethod + def validate(value): + if not utils.is_name_safe(value): + raise exception.InvalidName(name=value) + return value + + @staticmethod + def frombasetype(value): + if value is None: + return None + return NameType.validate(value) + + +class UuidType(wtypes.UserType): + """A simple UUID type.""" + + basetype = wtypes.text + name = 'uuid' + # FIXME(lucasagomes): When used with wsexpose decorator WSME will try + # to get the name of the type by accessing it's __name__ attribute. + # Remove this __name__ attribute once it's fixed in WSME. + # https://bugs.launchpad.net/wsme/+bug/1265590 + __name__ = name + + @staticmethod + def validate(value): + if not utils.is_uuid_like(value): + raise exception.InvalidUUID(uuid=value) + return value + + @staticmethod + def frombasetype(value): + if value is None: + return None + return UuidType.validate(value) + + +class BooleanType(wtypes.UserType): + """A simple boolean type.""" + + basetype = wtypes.text + name = 'boolean' + # FIXME(lucasagomes): When used with wsexpose decorator WSME will try + # to get the name of the type by accessing it's __name__ attribute. + # Remove this __name__ attribute once it's fixed in WSME. + # https://bugs.launchpad.net/wsme/+bug/1265590 + __name__ = name + + @staticmethod + def validate(value): + try: + return strutils.bool_from_string(value, strict=True) + except ValueError as e: + # raise Invalid to return 400 (BadRequest) in the API + raise exception.Invalid(e) + + @staticmethod + def frombasetype(value): + if value is None: + return None + return BooleanType.validate(value) + + +class MultiType(wtypes.UserType): + """A complex type that represents one or more types. + + Used for validating that a value is an instance of one of the types. + + :param types: Variable-length list of types. + + """ + basetype = wtypes.text + + def __init__(self, *types): + self.types = types + + def __str__(self): + return ' | '.join(map(str, self.types)) + + def validate(self, value): + for t in self.types: + try: + return wtypes.validate_value(t, value) + except (exception.InvalidUUID, ValueError): + pass + else: + raise ValueError(_("Expected '%(type)s', got '%(value)s'") + % {'type': self.types, 'value': type(value)}) + + +uuid = UuidType() +name = NameType() +uuid_or_name = MultiType(UuidType, NameType) +boolean = BooleanType() + + +class JsonPatchType(wtypes.Base): + """A complex type that represents a single json-patch operation.""" + + path = wtypes.wsattr(wtypes.StringType(pattern='^(/[\w-]+)+$'), + mandatory=True) + op = wtypes.wsattr(wtypes.Enum(str, 'add', 'replace', 'remove'), + mandatory=True) + value = MultiType(wtypes.text, int) + + @staticmethod + def internal_attrs(): + """Returns a list of internal attributes. + + Internal attributes can't be added, replaced or removed. This + method may be overwritten by derived class. + + """ + return ['/created_at', '/id', '/links', '/updated_at', '/uuid'] + + @staticmethod + def mandatory_attrs(): + """Retruns a list of mandatory attributes. + + Mandatory attributes can't be removed from the document. This + method should be overwritten by derived class. + + """ + return [] + + @staticmethod + def validate(patch): + if patch.path in patch.internal_attrs(): + msg = _("'%s' is an internal attribute and can not be updated") + raise wsme.exc.ClientSideError(msg % patch.path) + + if patch.path in patch.mandatory_attrs() and patch.op == 'remove': + msg = _("'%s' is a mandatory attribute and can not be removed") + raise wsme.exc.ClientSideError(msg % patch.path) + + if patch.op != 'remove': + if not patch.value: + msg = _("'add' and 'replace' operations needs value") + raise wsme.exc.ClientSideError(msg) + + ret = {'path': patch.path, 'op': patch.op} + if patch.value: + ret['value'] = patch.value + return ret diff --git a/cloudpulse/api/controllers/v1/utils.py b/cloudpulse/api/controllers/v1/utils.py new file mode 100644 index 0000000..9baaef9 --- /dev/null +++ b/cloudpulse/api/controllers/v1/utils.py @@ -0,0 +1,70 @@ +# Copyright 2013 Red Hat, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import jsonpatch +from oslo_config import cfg +import pecan +import wsme + +from cloudpulse.common import exception +from cloudpulse.common import utils +from cloudpulse import objects +from cloudpulse.openstack.common._i18n import _ + +CONF = cfg.CONF + + +JSONPATCH_EXCEPTIONS = (jsonpatch.JsonPatchException, + jsonpatch.JsonPointerException, + KeyError) + + +def validate_limit(limit): + if limit is not None and limit <= 0: + raise wsme.exc.ClientSideError(_("Limit must be positive")) + + return min(CONF.api.max_limit, limit) or CONF.api.max_limit + + +def validate_sort_dir(sort_dir): + if sort_dir not in ['asc', 'desc']: + raise wsme.exc.ClientSideError(_("Invalid sort direction: %s. " + "Acceptable values are " + "'asc' or 'desc'") % sort_dir) + return sort_dir + + +def apply_jsonpatch(doc, patch): + return jsonpatch.apply_patch(doc, jsonpatch.JsonPatch(patch)) + + +def get_rpc_resource(resource, resource_ident): + """Get the RPC resource from the uuid or logical name. + + :param resource: the resource type. + :param resource_ident: the UUID or logical name of the resource. + + :returns: The RPC resource. + :raises: InvalidUuidOrName if the name or uuid provided is not valid. + """ + resource = getattr(objects, resource) + + if utils.is_uuid_like(resource_ident): + return resource.get_by_uuid(pecan.request.context, resource_ident) + + if utils.allow_logical_names(): + return resource.get_by_name(pecan.request.context, resource_ident) + + raise exception.InvalidUuidOrName(name=resource_ident)