From 24ca94cda8ee756e2fba7e9b5b1dd0375100166e Mon Sep 17 00:00:00 2001 From: Kiall Mac Innes Date: Tue, 20 Aug 2013 11:34:33 +0100 Subject: [PATCH] Add APIv2 Zones Controller Change-Id: Ia85d22e4766cbf4e2af630d12a8b0ca3fc00ab1c --- designate/api/__init__.py | 1 + designate/api/middleware.py | 75 ++++- designate/api/v1/__init__.py | 71 ----- designate/api/v2/__init__.py | 1 + designate/api/v2/controllers/recordsets.py | 49 +++ designate/api/v2/controllers/rest.py | 130 ++++++++ designate/api/v2/controllers/root.py | 12 +- designate/api/v2/controllers/zones.py | 149 +++++++++ designate/api/v2/patches.py | 38 +++ designate/api/v2/views/__init__.py | 0 designate/api/v2/views/base.py | 118 +++++++ designate/api/v2/views/zones.py | 60 ++++ designate/resources/schemas/v2/recordset.json | 22 +- designate/resources/schemas/v2/zone.json | 22 +- designate/tests/__init__.py | 4 + designate/tests/test_api/test_v1/__init__.py | 3 +- .../tests/test_api/test_v1/test_domains.py | 68 ++-- designate/tests/test_api/test_v2/__init__.py | 3 + .../tests/test_api/test_v2/test_zones.py | 297 ++++++++++++++++++ designate/utils.py | 54 ++++ etc/designate/api-paste.ini | 14 +- 21 files changed, 1050 insertions(+), 141 deletions(-) create mode 100644 designate/api/v2/controllers/recordsets.py create mode 100644 designate/api/v2/controllers/rest.py create mode 100644 designate/api/v2/controllers/zones.py create mode 100644 designate/api/v2/patches.py create mode 100644 designate/api/v2/views/__init__.py create mode 100644 designate/api/v2/views/base.py create mode 100644 designate/api/v2/views/zones.py create mode 100644 designate/tests/test_api/test_v2/test_zones.py diff --git a/designate/api/__init__.py b/designate/api/__init__.py index 9826a15fa..2b19a7b92 100644 --- a/designate/api/__init__.py +++ b/designate/api/__init__.py @@ -24,6 +24,7 @@ cfg.CONF.register_group(cfg.OptGroup( cfg.CONF.register_opts([ cfg.IntOpt('workers', default=None, help='Number of worker processes to spawn'), + cfg.StrOpt('api-base-uri', default='http://127.0.0.1:9001/'), cfg.StrOpt('api_host', default='0.0.0.0', help='API Host'), cfg.IntOpt('api_port', default=9001, diff --git a/designate/api/middleware.py b/designate/api/middleware.py index 8d13efdec..5fefbd54e 100644 --- a/designate/api/middleware.py +++ b/designate/api/middleware.py @@ -14,12 +14,16 @@ # License for the specific language governing permissions and limitations # under the License. import flask +import webob.dec from oslo.config import cfg +from designate import exceptions +from designate import wsgi +from designate.context import DesignateContext +from designate.openstack.common import jsonutils as json from designate.openstack.common import local from designate.openstack.common import log as logging from designate.openstack.common import uuidutils -from designate import wsgi -from designate.context import DesignateContext +from designate.openstack.common.rpc import common as rpc_common LOG = logging.getLogger(__name__) @@ -132,3 +136,70 @@ class NoAuthContextMiddleware(ContextMiddleware): # Attach the context to the request environment request.environ['context'] = context + + +class FaultWrapperMiddleware(wsgi.Middleware): + def __init__(self, application): + super(FaultWrapperMiddleware, self).__init__(application) + + LOG.info('Starting designate faultwrapper middleware') + + @webob.dec.wsgify + def __call__(self, request): + try: + return request.get_response(self.application) + except exceptions.Base as e: + # Handle Designate Exceptions + status = e.error_code if hasattr(e, 'error_code') else 500 + + # Start building up a response + response = { + 'code': status + } + + if e.error_type: + response['type'] = e.error_type + + if e.error_message: + response['message'] = e.error_message + + if e.errors: + response['errors'] = e.errors + + return self._handle_exception(request, e, status, response) + except rpc_common.Timeout as e: + # Special case for RPC timeout's + response = { + 'code': 504, + 'type': 'timeout', + } + + return self._handle_exception(request, e, 504, response) + except Exception as e: + # Handle all other exception types + return self._handle_exception(request, e) + + def _handle_exception(self, request, e, status=500, response={}): + # Log the exception ASAP + LOG.exception(e) + + headers = [ + ('Content-Type', 'application/json'), + ] + + # Set a response code and type, if they are missing. + if 'code' not in response: + response['code'] = status + + if 'type' not in response: + response['type'] = 'unknown' + + # Set the request ID, if we have one + if 'context' in request.environ: + response['request_id'] = request.environ['context'].request_id + + # TODO(kiall): Send a fault notification + + # Return the new response + return flask.Response(status=status, headers=headers, + response=json.dumps(response)) diff --git a/designate/api/v1/__init__.py b/designate/api/v1/__init__.py index 451d90acc..31817fd2d 100644 --- a/designate/api/v1/__init__.py +++ b/designate/api/v1/__init__.py @@ -14,7 +14,6 @@ # License for the specific language governing permissions and limitations # under the License. import flask -import webob.dec from stevedore import extension from stevedore import named from werkzeug import exceptions as wexceptions @@ -22,12 +21,9 @@ from werkzeug import wrappers from werkzeug.routing import BaseConverter from werkzeug.routing import ValidationError from oslo.config import cfg -from designate.openstack.common import jsonutils as json from designate.openstack.common import log as logging from designate.openstack.common import uuidutils -from designate.openstack.common.rpc import common as rpc_common from designate import exceptions -from designate import wsgi LOG = logging.getLogger(__name__) @@ -129,70 +125,3 @@ class UUIDConverter(BaseConverter): def to_url(self, value): return str(value) - - -class FaultWrapperMiddleware(wsgi.Middleware): - def __init__(self, application): - super(FaultWrapperMiddleware, self).__init__(application) - - LOG.info('Starting designate faultwrapper middleware') - - @webob.dec.wsgify - def __call__(self, request): - try: - return request.get_response(self.application) - except exceptions.Base as e: - # Handle Designate Exceptions - status = e.error_code if hasattr(e, 'error_code') else 500 - - # Start building up a response - response = { - 'code': status - } - - if e.error_type: - response['type'] = e.error_type - - if e.error_message: - response['message'] = e.error_message - - if e.errors: - response['errors'] = e.errors - - return self._handle_exception(request, e, status, response) - except rpc_common.Timeout as e: - # Special case for RPC timeout's - response = { - 'code': 504, - 'type': 'timeout', - } - - return self._handle_exception(request, e, 504, response) - except Exception as e: - # Handle all other exception types - return self._handle_exception(request, e) - - def _handle_exception(self, request, e, status=500, response={}): - # Log the exception ASAP - LOG.exception(e) - - headers = [ - ('Content-Type', 'application/json'), - ] - - # Set a response code and type, if they are missing. - if 'code' not in response: - response['code'] = status - - if 'type' not in response: - response['type'] = 'unknown' - - # Set the request ID, if we have one - if 'context' in request.environ: - response['request_id'] = request.environ['context'].request_id - - # TODO(kiall): Send a fault notification - - # Return the new response - return flask.Response(status=status, headers=headers, - response=json.dumps(response)) diff --git a/designate/api/v2/__init__.py b/designate/api/v2/__init__.py index 8b68ec248..8f3c0622d 100644 --- a/designate/api/v2/__init__.py +++ b/designate/api/v2/__init__.py @@ -13,6 +13,7 @@ # 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 designate.api.v2 import patches # flake8: noqa import pecan.deploy from oslo.config import cfg from designate.openstack.common import log as logging diff --git a/designate/api/v2/controllers/recordsets.py b/designate/api/v2/controllers/recordsets.py new file mode 100644 index 000000000..37918586b --- /dev/null +++ b/designate/api/v2/controllers/recordsets.py @@ -0,0 +1,49 @@ +# Copyright 2013 Hewlett-Packard Development Company, L.P. +# +# Author: Kiall Mac Innes +# +# 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 designate.api.v2.controllers import rest +from designate.openstack.common import log as logging + + +LOG = logging.getLogger(__name__) + + +class RecordSetsController(rest.RestController): + @pecan.expose(template='json:', content_type='application/json') + def get_one(self, zone_id, recordset_id): + """ Get RecordSet """ + pass + + @pecan.expose(template='json:', content_type='application/json') + def get_all(self, zone_id): + """ List RecordSets """ + pass + + @pecan.expose(template='json:', content_type='application/json') + def post_all(self, zone_id): + """ Create RecordSet """ + pass + + @pecan.expose(template='json:', content_type='application/json') + @pecan.expose(template='json:', content_type='application/json-patch+json') + def patch_one(self, zone_id, recordset_id): + """ Update RecordSet """ + pass + + @pecan.expose(template='json:', content_type='application/json') + def delete_one(self, zone_id, recordset_id): + """ Delete RecordSet """ + pass diff --git a/designate/api/v2/controllers/rest.py b/designate/api/v2/controllers/rest.py new file mode 100644 index 000000000..fb24754f4 --- /dev/null +++ b/designate/api/v2/controllers/rest.py @@ -0,0 +1,130 @@ +# flake8: noqa +# Copyright (c) <2011>, Jonathan LaCour +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of the nor the +# names of its contributors may be used to endorse or promote products +# derived from this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL BE LIABLE FOR ANY +# DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +# (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +# ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +import inspect +import pecan +import pecan.rest +import pecan.routing +from designate.openstack.common import log as logging + +LOG = logging.getLogger(__name__) + + +class RestController(pecan.rest.RestController): + """ + Extension for Pecan's RestController to better handle POST/PUT/PATCH + requests. + + Ideally, we get these additions merged upstream. + """ + + def _handle_post(self, method, remainder): + ''' + Routes ``POST`` actions to the appropriate controller. + ''' + # route to a post_all or get if no additional parts are available + if not remainder or remainder == ['']: + controller = self._find_controller('post_all', 'post') + if controller: + return controller, [] + pecan.abort(404) + + controller = getattr(self, remainder[0], None) + if controller and not inspect.ismethod(controller): + return pecan.routing.lookup_controller(controller, remainder[1:]) + + # finally, check for the regular post_one/post requests + controller = self._find_controller('post_one', 'post') + if controller: + return controller, remainder + + pecan.abort(404) + + def _handle_patch(self, method, remainder): + ''' + Routes ``PATCH`` actions to the appropriate controller. + ''' + # route to a patch_all or get if no additional parts are available + if not remainder or remainder == ['']: + controller = self._find_controller('patch_all', 'patch') + if controller: + return controller, [] + pecan.abort(404) + + controller = getattr(self, remainder[0], None) + if controller and not inspect.ismethod(controller): + return pecan.routing.lookup_controller(controller, remainder[1:]) + + # finally, check for the regular patch_one/patch requests + controller = self._find_controller('patch_one', 'patch') + if controller: + return controller, remainder + + pecan.abort(404) + + def _handle_put(self, method, remainder): + ''' + Routes ``PUT`` actions to the appropriate controller. + ''' + # route to a put_all or get if no additional parts are available + if not remainder or remainder == ['']: + controller = self._find_controller('put_all', 'put') + if controller: + return controller, [] + pecan.abort(404) + + controller = getattr(self, remainder[0], None) + if controller and not inspect.ismethod(controller): + return pecan.routing.lookup_controller(controller, remainder[1:]) + + # finally, check for the regular put_one/put requests + controller = self._find_controller('put_one', 'put') + if controller: + return controller, remainder + + pecan.abort(404) + + def _handle_delete(self, method, remainder): + ''' + Routes ``DELETE`` actions to the appropriate controller. + ''' + # route to a delete_all or get if no additional parts are available + if not remainder or remainder == ['']: + controller = self._find_controller('delete_all', 'delete') + if controller: + return controller, [] + pecan.abort(404) + + controller = getattr(self, remainder[0], None) + if controller and not inspect.ismethod(controller): + return pecan.routing.lookup_controller(controller, remainder[1:]) + + # finally, check for the regular delete_one/delete requests + controller = self._find_controller('delete_one', 'delete') + if controller: + return controller, remainder + + pecan.abort(404) diff --git a/designate/api/v2/controllers/root.py b/designate/api/v2/controllers/root.py index 266090e18..03be5da56 100644 --- a/designate/api/v2/controllers/root.py +++ b/designate/api/v2/controllers/root.py @@ -14,14 +14,18 @@ # License for the specific language governing permissions and limitations # under the License. from designate.openstack.common import log as logging -from designate.api.v2.controllers import schemas from designate.api.v2.controllers import limits +from designate.api.v2.controllers import schemas +from designate.api.v2.controllers import zones LOG = logging.getLogger(__name__) class RootController(object): - schemas = schemas.SchemasController() + """ + This is /v2/ Controller. Pecan will find all controllers via the object + properties attached to this. + """ limits = limits.LimitsController() - # zones = zones.ZonesController() - # pools = pools.PoolsController() + schemas = schemas.SchemasController() + zones = zones.ZonesController() diff --git a/designate/api/v2/controllers/zones.py b/designate/api/v2/controllers/zones.py new file mode 100644 index 000000000..92288d434 --- /dev/null +++ b/designate/api/v2/controllers/zones.py @@ -0,0 +1,149 @@ +# Copyright 2013 Hewlett-Packard Development Company, L.P. +# +# Author: Kiall Mac Innes +# +# 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 designate import utils +from designate import schema +from designate.api.v2.controllers import rest +from designate.api.v2.controllers import recordsets +from designate.api.v2.views import zones as zones_view +from designate.central import rpcapi as central_rpcapi +from designate.openstack.common import log as logging + + +LOG = logging.getLogger(__name__) +central_api = central_rpcapi.CentralAPI() + + +class ZonesController(rest.RestController): + _view = zones_view.ZonesView() + _resource_schema = schema.Schema('v2', 'zone') + _collection_schema = schema.Schema('v2', 'zones') + + recordsets = recordsets.RecordSetsController() + + @pecan.expose(template='json:', content_type='application/json') + def get_one(self, zone_id): + """ Get Zone """ + request = pecan.request + context = request.environ['context'] + + # TODO(kiall): Validate we have a sane UUID for zone_id + + zone = central_api.get_domain(context, zone_id) + + return self._view.detail(context, request, zone) + + @pecan.expose(template='json:', content_type='application/json') + def get_all(self, **params): + """ List Zones """ + request = pecan.request + context = request.environ['context'] + + # Extract the pagination params + #marker = params.pop('marker', None) + #limit = int(params.pop('limit', 30)) + + # Extract any filter params. + accepted_filters = ('name', 'email') + criterion = dict((k, params[k]) for k in accepted_filters + if k in params) + + zones = central_api.find_domains(context, criterion) + + return self._view.list(context, request, zones) + + @pecan.expose(template='json:', content_type='application/json') + def post_all(self): + """ Create Zone """ + request = pecan.request + response = pecan.response + context = request.environ['context'] + body = request.body_dict + + # Validate the request conforms to the schema + self._resource_schema.validate(body) + + # Convert from APIv2 -> Central format + values = self._view.load(context, request, body) + + # Create the zone + zone = central_api.create_domain(context, values) + + # Prepare the response headers + response.status_int = 201 + response.headers['Location'] = self._view._get_resource_href(request, + zone) + + # Prepare and return the response body + return self._view.detail(context, request, zone) + + @pecan.expose(template='json:', content_type='application/json') + @pecan.expose(template='json:', content_type='application/json-patch+json') + def patch_one(self, zone_id): + """ Update Zone """ + # TODO(kiall): This needs cleanup to say the least.. + request = pecan.request + context = request.environ['context'] + body = request.body_dict + + # TODO(kiall): Validate we have a sane UUID for zone_id + + # Fetch the existing zone + zone = central_api.get_domain(context, zone_id) + + # Convert to APIv2 Format + zone = self._view.detail(context, request, zone) + + if request.content_type == 'application/json-patch+json': + # Possible pattern: + # + # 1) Load existing zone. + # 2) Apply patch, maintain list of changes. + # 3) Return changes, after passing through the code ^ for plain + # JSON. + # + # Difficulties: + # + # 1) "Nested" resources? records inside a recordset. + # 2) What to do when a zone doesn't exist in the first place? + # 3) ...? + raise NotImplemented('json-patch not implemented') + else: + zone = utils.deep_dict_merge(zone, body) + + # Validate the request conforms to the schema + self._resource_schema.validate(zone) + + values = self._view.load(context, request, body) + zone = central_api.update_domain(context, zone_id, values) + + return self._view.detail(context, request, zone) + + @pecan.expose(template=None, content_type='application/json') + def delete_one(self, zone_id): + """ Delete Zone """ + request = pecan.request + response = pecan.response + context = request.environ['context'] + + # TODO(kiall): Validate we have a sane UUID for zone_id + + central_api.delete_domain(context, zone_id) + + response.status_int = 204 + + # NOTE: This is a hack and a half.. But Pecan needs it. + return '' diff --git a/designate/api/v2/patches.py b/designate/api/v2/patches.py new file mode 100644 index 000000000..1f0bf8ada --- /dev/null +++ b/designate/api/v2/patches.py @@ -0,0 +1,38 @@ +# Copyright 2013 Hewlett-Packard Development Company, L.P. +# +# Author: Kiall Mac Innes +# +# 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.core +from designate.openstack.common import jsonutils + +JSON_TYPES = ('application/json', 'application/json-patch+json') + + +class Request(pecan.core.Request): + @property + def body_dict(self): + """ + Returns the body content as a dictonary, deserializing per the + Content-Type header. + + We add this method to ease future XML support, so the main code + is not hardcoded to call pecans "request.json()" method. + """ + if self.content_type in JSON_TYPES: + return jsonutils.load(self.body_file) + else: + raise Exception('TODO: Unsupported Content Type') + + +pecan.core.Request = Request diff --git a/designate/api/v2/views/__init__.py b/designate/api/v2/views/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/designate/api/v2/views/base.py b/designate/api/v2/views/base.py new file mode 100644 index 000000000..5f2bc5977 --- /dev/null +++ b/designate/api/v2/views/base.py @@ -0,0 +1,118 @@ +# Copyright 2013 Hewlett-Packard Development Company, L.P. +# +# Author: Kiall Mac Innes +# +# 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 urllib +from oslo.config import cfg +from designate.openstack.common import log as logging + + +LOG = logging.getLogger(__name__) +CONF = cfg.CONF + + +class BaseView(object): + """ + The Views are responsible for coverting to/from the "intenal" and + "external" representations of collections and resources. This includes + adding "links" and adding/removing any other wrappers returned/received + as part of the API call. + + For example, in the V2 API, we did s/domain/zone/. Adapting a record + resources "domain_id" <-> "zone_id" is the responsibility of a View. + """ + _resource_name = None + _collection_name = None + + def __init__(self): + super(BaseView, self).__init__() + + self.base_uri = CONF['service:api']['api_base_uri'].rstrip('/') + + def list(self, context, request, items): + """ View of a list of items """ + result = { + "links": self._get_collection_links(request, items) + } + + if 'detail' in request.GET and request.GET['detail'] == 'yes': + result[self._collection_name] = [self.detail(context, request, i) + for i in items] + else: + result[self._collection_name] = [self.basic(context, request, i) + for i in items] + + return result + + def basic(self, context, request, item): + """ Non-detailed view of a item """ + return self.detail(context, request, item) + + def _get_resource_links(self, request, item): + return { + "self": self._get_resource_href(request, item) + } + + def _get_collection_links(self, request, items): + # TODO(kiall): Next and previous links should only be included + # when there are more/previous items.. This is what nova + # does.. But I think we can do better. + + params = request.GET + + result = { + "self": self._get_collection_href(request), + } + + if 'marker' in params: + result['previous'] = self._get_previous_href(request, items) + + if 'limit' in params and int(params['limit']) == len(items): + result['next'] = self._get_next_href(request, items) + + return result + + def _get_resource_href(self, request, item): + href = "%s/v2/%s/%s" % (self.base_uri, self._collection_name, + item['id']) + + return href.rstrip('?') + + def _get_collection_href(self, request): + params = request.GET + + href = "%s/v2/%s?%s" % (self.base_uri, self._collection_name, + urllib.urlencode(params)) + + return href.rstrip('?') + + def _get_next_href(self, request, items): + params = request.GET + + # Add/Update the marker and sort_dir params + params['marker'] = items[-1]['id'] + params.pop('sort_dir', None) + + return "%s/v2/%s?%s" % (self.base_uri, self._collection_name, + urllib.urlencode(params)) + + def _get_previous_href(self, request, items): + params = request.GET + + # Add/Update the marker and sort_dir params + params['marker'] = items[0]['id'] + params['sort_dir'] = 'DESC' + + return "%s/v2/%s?%s" % (self.base_uri, self._collection_name, + urllib.urlencode(params)) diff --git a/designate/api/v2/views/zones.py b/designate/api/v2/views/zones.py new file mode 100644 index 000000000..71af59c80 --- /dev/null +++ b/designate/api/v2/views/zones.py @@ -0,0 +1,60 @@ +# Copyright 2013 Hewlett-Packard Development Company, L.P. +# +# Author: Kiall Mac Innes +# +# 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 designate.api.v2.views import base as base_view +from designate.openstack.common import log as logging + + +LOG = logging.getLogger(__name__) + + +class ZonesView(base_view.BaseView): + """ Model a Zone API response as a python dictionary """ + + _resource_name = 'zone' + _collection_name = 'zones' + + def detail(self, context, request, zone): + """ Detailed view of a zone """ + # TODO(kiall): pool_id should not be hardcoded.. even temp :) + return { + "zone": { + "id": zone['id'], + "pool_id": "572ba08c-d929-4c70-8e42-03824bb24ca2", + "project_id": zone['tenant_id'], + "name": zone['name'], + "email": zone['email'], + "description": zone['description'], + "ttl": zone['ttl'], + "serial": zone['serial'], + "status": "ACTIVE", + "version": zone['version'], + "created_at": zone['created_at'], + "updated_at": zone['updated_at'], + "links": self._get_resource_links(request, zone) + } + } + + def load(self, context, request, body): + """ Extract a "central" compatible dict from an API call """ + result = {} + item = body[self._resource_name] + + # Copy keys which need no alterations + for k in ('id', 'name', 'email', 'description', 'ttl'): + if k in item: + result[k] = item[k] + + return result diff --git a/designate/resources/schemas/v2/recordset.json b/designate/resources/schemas/v2/recordset.json index c60c16016..eeab0227c 100644 --- a/designate/resources/schemas/v2/recordset.json +++ b/designate/resources/schemas/v2/recordset.json @@ -79,6 +79,17 @@ "description": "Date and time of last recordset modification", "format": "date-time", "readOnly": true + }, + "links": { + "type": "object", + "additionalProperties": false, + + "properties": { + "self": { + "type": "string", + "format": "url" + } + } } }, "oneOf": [ @@ -94,17 +105,6 @@ {"properties": {"type": {"enum": ["SSHFP"]}, "records": {"items": {"$ref": "rdata/sshfp#"}}}}, {"properties": {"type": {"enum": ["TXT"]}, "records": {"items": {"$ref": "rdata/txt#"}}}} ] - }, - "links": { - "type": "object", - "additionalProperties": false, - - "properties": { - "self": { - "type": "string", - "format": "url" - } - } } } } diff --git a/designate/resources/schemas/v2/zone.json b/designate/resources/schemas/v2/zone.json index c1c3676db..85d87a116 100644 --- a/designate/resources/schemas/v2/zone.json +++ b/designate/resources/schemas/v2/zone.json @@ -29,7 +29,7 @@ "immutable": true }, "project_id": { - "type": "string", + "type": ["string", "null"], "description": "Project identifier", "maxLength": 36, "immutable": true @@ -92,17 +92,17 @@ "description": "Date and time of last zone modification", "format": "date-time", "readOnly": true - } - } - }, - "links": { - "type": "object", - "additionalProperties": false, + }, + "links": { + "type": "object", + "additionalProperties": false, - "properties": { - "self": { - "type": "string", - "format": "url" + "properties": { + "self": { + "type": "string", + "format": "url" + } + } } } } diff --git a/designate/tests/__init__.py b/designate/tests/__init__.py index 701b67bf5..ee4dd0ab5 100644 --- a/designate/tests/__init__.py +++ b/designate/tests/__init__.py @@ -16,6 +16,7 @@ import copy import unittest2 import mox +import nose from oslo.config import cfg from designate.openstack.common import log as logging from designate.openstack.common.notifier import test_notifier @@ -130,6 +131,9 @@ class TestCase(unittest2.TestCase): self.mox.UnsetStubs() super(TestCase, self).tearDown() + def skip(self, message=None): + raise nose.SkipTest(message) + # Config Methods def config(self, **kwargs): group = kwargs.pop('group', None) diff --git a/designate/tests/test_api/test_v1/__init__.py b/designate/tests/test_api/test_v1/__init__.py index 993ebebc9..b6ca653e4 100644 --- a/designate/tests/test_api/test_v1/__init__.py +++ b/designate/tests/test_api/test_v1/__init__.py @@ -36,7 +36,8 @@ class ApiV1Test(ApiTestCase): self.app = api_v1.factory({}) # Inject the FaultWrapper middleware - self.app.wsgi_app = api_v1.FaultWrapperMiddleware(self.app.wsgi_app) + self.app.wsgi_app = middleware.FaultWrapperMiddleware( + self.app.wsgi_app) # Inject the NoAuth middleware self.app.wsgi_app = middleware.NoAuthContextMiddleware( diff --git a/designate/tests/test_api/test_v1/test_domains.py b/designate/tests/test_api/test_v1/test_domains.py index 5132ebbd8..81a8dd761 100644 --- a/designate/tests/test_api/test_v1/test_domains.py +++ b/designate/tests/test_api/test_v1/test_domains.py @@ -114,40 +114,6 @@ class ApiV1DomainsTest(ApiV1Test): #Create the domain, ensuring it fails with a 400 self.post('domains', data=fixture, status_code=400) - def test_get_domains(self): - response = self.get('domains') - - self.assertIn('domains', response.json) - self.assertEqual(0, len(response.json['domains'])) - - # Create a domain - self.create_domain() - - response = self.get('domains') - - self.assertIn('domains', response.json) - self.assertEqual(1, len(response.json['domains'])) - - # Create a second domain - self.create_domain(fixture=1) - - response = self.get('domains') - - self.assertIn('domains', response.json) - self.assertEqual(2, len(response.json['domains'])) - - @patch.object(central_service.Service, 'find_domains') - def test_get_domains_trailing_slash(self, mock): - self.get('domains/') - - # verify that the central service is called - self.assertTrue(mock.called) - - @patch.object(central_service.Service, 'find_domains', - side_effect=rpc_common.Timeout()) - def test_get_domains_timeout(self, _): - self.get('domains', status_code=504) - def test_create_invalid_name(self): # Prepare a domain fixture = self.get_domain_fixture(0) @@ -188,6 +154,40 @@ class ApiV1DomainsTest(ApiV1Test): self.assertNotIn('id', response.json) + def test_get_domains(self): + response = self.get('domains') + + self.assertIn('domains', response.json) + self.assertEqual(0, len(response.json['domains'])) + + # Create a domain + self.create_domain() + + response = self.get('domains') + + self.assertIn('domains', response.json) + self.assertEqual(1, len(response.json['domains'])) + + # Create a second domain + self.create_domain(fixture=1) + + response = self.get('domains') + + self.assertIn('domains', response.json) + self.assertEqual(2, len(response.json['domains'])) + + @patch.object(central_service.Service, 'find_domains') + def test_get_domains_trailing_slash(self, mock): + self.get('domains/') + + # verify that the central service is called + self.assertTrue(mock.called) + + @patch.object(central_service.Service, 'find_domains', + side_effect=rpc_common.Timeout()) + def test_get_domains_timeout(self, _): + self.get('domains', status_code=504) + def test_get_domain(self): # Create a domain domain = self.create_domain() diff --git a/designate/tests/test_api/test_v2/__init__.py b/designate/tests/test_api/test_v2/__init__.py index f0f6b7307..6dbdd9e52 100644 --- a/designate/tests/test_api/test_v2/__init__.py +++ b/designate/tests/test_api/test_v2/__init__.py @@ -35,6 +35,9 @@ class ApiV2TestCase(ApiTestCase): # Create the application self.app = api_v2.factory({}) + # Inject the FaultWrapper middleware + self.app = middleware.FaultWrapperMiddleware(self.app) + # Inject the NoAuth middleware self.app = middleware.NoAuthContextMiddleware(self.app) diff --git a/designate/tests/test_api/test_v2/test_zones.py b/designate/tests/test_api/test_v2/test_zones.py new file mode 100644 index 000000000..c95618915 --- /dev/null +++ b/designate/tests/test_api/test_v2/test_zones.py @@ -0,0 +1,297 @@ +# Copyright 2013 Hewlett-Packard Development Company, L.P. +# +# Author: Kiall Mac Innes +# +# 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 mock import patch +from designate import exceptions +from designate.central import service as central_service +from designate.openstack.common.rpc import common as rpc_common +from designate.tests.test_api.test_v2 import ApiV2TestCase + + +class ApiV2ZonesTest(ApiV2TestCase): + __test__ = True + + def setUp(self): + super(ApiV2ZonesTest, self).setUp() + + # Create a server + self.create_server() + + def test_create_zone(self): + # Create a zone + fixture = self.get_domain_fixture(0) + response = self.client.post_json('/zones/', {'zone': fixture}) + + # Check the headers are what we expect + self.assertEqual(201, response.status_int) + self.assertEqual('application/json', response.content_type) + + # Check the body structure is what we expect + self.assertIn('zone', response.json) + self.assertIn('links', response.json['zone']) + self.assertIn('self', response.json['zone']['links']) + + # Check the values returned are what we expect + self.assertIn('id', response.json['zone']) + self.assertIn('created_at', response.json['zone']) + self.assertEqual('ACTIVE', response.json['zone']['status']) + self.assertIsNone(response.json['zone']['updated_at']) + + for k in fixture: + self.assertEqual(fixture[k], response.json['zone'][k]) + + def test_create_zone_validation(self): + # NOTE: The schemas should be tested separatly to the API. So we + # don't need to test every variation via the API itself. + # Fetch a fixture + fixture = self.get_domain_fixture(0) + + # Add a junk field to the wrapper + body = {'zone': fixture, 'junk': 'Junk Field'} + + # Ensure it fails with a 400 + response = self.client.post_json('/zones/', body, status=400) + self.assertEqual(400, response.status_int) + + # Add a junk field to the body + fixture['junk'] = 'Junk Field' + + # Ensure it fails with a 400 + body = {'zone': fixture} + self.client.post_json('/zones/', body, status=400) + + @patch.object(central_service.Service, 'create_domain', + side_effect=rpc_common.Timeout()) + def test_create_zone_timeout(self, _): + fixture = self.get_domain_fixture(0) + + body = {'zone': fixture} + self.client.post_json('/zones/', body, status=504) + + @patch.object(central_service.Service, 'create_domain', + side_effect=exceptions.DuplicateDomain()) + def test_create_zone_duplicate(self, _): + fixture = self.get_domain_fixture(0) + + body = {'zone': fixture} + self.client.post_json('/zones/', body, status=409) + + def test_get_zones(self): + response = self.client.get('/zones/') + + # Check the headers are what we expect + self.assertEqual(200, response.status_int) + self.assertEqual('application/json', response.content_type) + + # Check the body structure is what we expect + self.assertIn('zones', response.json) + self.assertIn('links', response.json) + self.assertIn('self', response.json['links']) + + # We should start with 0 zones + self.assertEqual(0, len(response.json['zones'])) + + # Test with 1 zone + self.create_domain() + + response = self.client.get('/zones/') + + self.assertIn('zones', response.json) + self.assertEqual(1, len(response.json['zones'])) + + # test with 2 zones + self.create_domain(fixture=1) + + response = self.client.get('/zones/') + + self.assertIn('zones', response.json) + self.assertEqual(2, len(response.json['zones'])) + + @patch.object(central_service.Service, 'find_domains', + side_effect=rpc_common.Timeout()) + def test_get_zones_timeout(self, _): + self.client.get('/zones/', status=504) + + def test_get_zone(self): + # Create a zone + zone = self.create_domain() + + response = self.client.get('/zones/%s' % zone['id']) + + # Check the headers are what we expect + self.assertEqual(200, response.status_int) + self.assertEqual('application/json', response.content_type) + + # Check the body structure is what we expect + self.assertIn('zone', response.json) + self.assertIn('links', response.json['zone']) + self.assertIn('self', response.json['zone']['links']) + + # Check the values returned are what we expect + self.assertIn('id', response.json['zone']) + self.assertIn('created_at', response.json['zone']) + self.assertEqual('ACTIVE', response.json['zone']['status']) + self.assertIsNone(response.json['zone']['updated_at']) + self.assertEqual(zone['name'], response.json['zone']['name']) + self.assertEqual(zone['email'], response.json['zone']['email']) + + @patch.object(central_service.Service, 'get_domain', + side_effect=rpc_common.Timeout()) + def test_get_zone_timeout(self, _): + self.client.get('/zones/2fdadfb1-cf96-4259-ac6b-bb7b6d2ff980', + status=504) + + @patch.object(central_service.Service, 'get_domain', + side_effect=exceptions.DomainNotFound()) + def test_get_zone_missing(self, _): + self.client.get('/zones/2fdadfb1-cf96-4259-ac6b-bb7b6d2ff980', + status=404) + + def test_get_zone_invalid_id(self): + self.skip('We don\'t guard against this in APIv2 yet') + + # The letter "G" is not valid in a UUID + self.client.get('/zones/2fdadfb1-cf96-4259-ac6b-bb7b6d2ff9GG', + status=404) + + # Badly formed UUID + self.client.get('/zones/2fdadfb1cf964259ac6bbb7b6d2ff9GG', status=404) + + # Integer + self.client.get('/zones/12345', status=404) + + def test_update_zone(self): + # Create a zone + zone = self.create_domain() + + # Prepare an update body + body = {'zone': {'email': 'prefix-%s' % zone['email']}} + + response = self.client.patch_json('/zones/%s' % zone['id'], body, + status=200) + + # Check the headers are what we expect + self.assertEqual(200, response.status_int) + self.assertEqual('application/json', response.content_type) + + # Check the body structure is what we expect + self.assertIn('zone', response.json) + self.assertIn('links', response.json['zone']) + self.assertIn('self', response.json['zone']['links']) + + # Check the values returned are what we expect + self.assertIn('id', response.json['zone']) + self.assertIsNotNone(response.json['zone']['updated_at']) + self.assertEqual('prefix-%s' % zone['email'], + response.json['zone']['email']) + + def test_update_zone_validation(self): + # NOTE: The schemas should be tested separatly to the API. So we + # don't need to test every variation via the API itself. + # Create a zone + zone = self.create_domain() + + # Prepare an update body with junk in the wrapper + body = {'zone': {'email': 'prefix-%s' % zone['email']}, + 'junk': 'Junk Field'} + + # Ensure it fails with a 400 + self.client.patch_json('/zones/%s' % zone['id'], body, status=400) + + # Prepare an update body with junk in the body + body = {'zone': {'email': 'prefix-%s' % zone['email'], + 'junk': 'Junk Field'}} + + # Ensure it fails with a 400 + self.client.patch_json('/zones/%s' % zone['id'], body, status=400) + + @patch.object(central_service.Service, 'get_domain', + side_effect=exceptions.DuplicateDomain()) + def test_update_zone_duplicate(self, _): + # Prepare an update body + body = {'zone': {'email': 'example@example.org'}} + + # Ensure it fails with a 409 + self.client.patch_json('/zones/2fdadfb1-cf96-4259-ac6b-bb7b6d2ff980', + body, status=409) + + @patch.object(central_service.Service, 'get_domain', + side_effect=rpc_common.Timeout()) + def test_update_zone_timeout(self, _): + # Prepare an update body + body = {'zone': {'email': 'example@example.org'}} + + # Ensure it fails with a 504 + self.client.patch_json('/zones/2fdadfb1-cf96-4259-ac6b-bb7b6d2ff980', + body, status=504) + + @patch.object(central_service.Service, 'get_domain', + side_effect=exceptions.DomainNotFound()) + def test_update_zone_missing(self, _): + # Prepare an update body + body = {'zone': {'email': 'example@example.org'}} + + # Ensure it fails with a 404 + self.client.patch_json('/zones/2fdadfb1-cf96-4259-ac6b-bb7b6d2ff980', + body, status=404) + + def test_update_zone_invalid_id(self): + self.skip('We don\'t guard against this in APIv2 yet') + + # Prepare an update body + body = {'zone': {'email': 'example@example.org'}} + + # The letter "G" is not valid in a UUID + self.client.patch_json('/zones/2fdadfb1-cf96-4259-ac6b-bb7b6d2ff9GG', + body, status=404) + + # Badly formed UUID + self.client.patch_json('/zones/2fdadfb1cf964259ac6bbb7b6d2ff980', + body, status=404) + + # Integer + self.client.patch_json('/zones/12345', + body, status=404) + + def test_delete_zone(self): + zone = self.create_domain() + + self.client.delete('/zones/%s' % zone['id'], status=204) + + @patch.object(central_service.Service, 'delete_domain', + side_effect=rpc_common.Timeout()) + def test_delete_zone_timeout(self, _): + self.client.delete('/zones/2fdadfb1-cf96-4259-ac6b-bb7b6d2ff980', + status=504) + + @patch.object(central_service.Service, 'delete_domain', + side_effect=exceptions.DomainNotFound()) + def test_delete_zone_missing(self, _): + self.client.delete('/zones/2fdadfb1-cf96-4259-ac6b-bb7b6d2ff980', + status=404) + + def test_delete_zone_invalid_id(self): + self.skip('We don\'t guard against this in APIv2 yet') + + # The letter "G" is not valid in a UUID + self.client.delete('/zones/2fdadfb1-cf96-4259-ac6b-bb7b6d2ff9GG', + status=404) + + # Badly formed UUID + self.client.delete('/zones/2fdadfb1cf964259ac6bbb7b6d2ff980', + status=404) + + # Integer + self.client.delete('/zones/12345', status=404) diff --git a/designate/utils.py b/designate/utils.py index 794461d96..95f6d9bdc 100644 --- a/designate/utils.py +++ b/designate/utils.py @@ -13,6 +13,7 @@ # 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 copy import os import pkg_resources import json @@ -183,3 +184,56 @@ def increment_serial(serial=0): new_serial = serial + 1 return new_serial + + +def quote_string(string): + inparts = string.split(' ') + outparts = [] + tmp = None + + for part in inparts: + if part == '': + continue + elif part[0] == '"' and part[-1:] == '"' and part[-2:] != '\\"': + # Handle Quoted Words + outparts.append(part.strip('"')) + elif part[0] == '"': + # Handle Start of Quoted Sentance + tmp = part[1:] + elif tmp is not None and part[-1:] == '"' and part[-2:] != '\\"': + # Handle End of Quoted Sentance + tmp += " " + part.strip('"') + outparts.append(tmp) + tmp = None + elif tmp is not None: + # Handle Middle of Quoted Sentance + tmp += " " + part + else: + # Handle Standalone words + outparts.append(part) + + if tmp is not None: + # Handle unclosed quoted strings + outparts.append(tmp) + + # This looks odd, but both calls are necessary to ensure the end results + # is always consistent. + outparts = [o.replace('\\"', '"') for o in outparts] + outparts = [o.replace('"', '\\"') for o in outparts] + + return '"' + '" "'.join(outparts) + '"' + + +def deep_dict_merge(a, b): + if not isinstance(b, dict): + return b + + result = copy.deepcopy(a) + + for k, v in b.iteritems(): + if k in result and isinstance(result[k], dict): + result[k] = deep_dict_merge(result[k], v) + else: + result[k] = copy.deepcopy(v) + + return result diff --git a/etc/designate/api-paste.ini b/etc/designate/api-paste.ini index d929bc828..24a4305a4 100644 --- a/etc/designate/api-paste.ini +++ b/etc/designate/api-paste.ini @@ -9,19 +9,16 @@ paste.app_factory = designate.api.versions:factory [composite:osapi_dns_v1] use = call:designate.api.middleware:auth_pipeline_factory -noauth = noauthcontext maintenance faultwrapper_v1 osapi_dns_app_v1 -keystone = authtoken keystonecontext maintenance faultwrapper_v1 osapi_dns_app_v1 +noauth = noauthcontext maintenance faultwrapper osapi_dns_app_v1 +keystone = authtoken keystonecontext maintenance faultwrapper osapi_dns_app_v1 [app:osapi_dns_app_v1] paste.app_factory = designate.api.v1:factory -[filter:faultwrapper_v1] -paste.filter_factory = designate.api.v1:FaultWrapperMiddleware.factory - [composite:osapi_dns_v2] use = call:designate.api.middleware:auth_pipeline_factory -noauth = noauthcontext maintenance osapi_dns_app_v2 -keystone = authtoken keystonecontext maintenance osapi_dns_app_v2 +noauth = noauthcontext maintenance faultwrapper osapi_dns_app_v2 +keystone = authtoken keystonecontext maintenance faultwrapper osapi_dns_app_v2 [app:osapi_dns_app_v2] paste.app_factory = designate.api.v2:factory @@ -35,5 +32,8 @@ paste.filter_factory = designate.api.middleware:NoAuthContextMiddleware.factory [filter:keystonecontext] paste.filter_factory = designate.api.middleware:KeystoneContextMiddleware.factory +[filter:faultwrapper] +paste.filter_factory = designate.api.middleware:FaultWrapperMiddleware.factory + [filter:authtoken] paste.filter_factory = keystoneclient.middleware.auth_token:filter_factory