Add APIv2 Zones Controller

Change-Id: Ia85d22e4766cbf4e2af630d12a8b0ca3fc00ab1c
This commit is contained in:
Kiall Mac Innes 2013-08-20 11:34:33 +01:00 committed by Kiall Mac Innes
parent ad80f3f6ea
commit 24ca94cda8
21 changed files with 1050 additions and 141 deletions

View File

@ -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,

View File

@ -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))

View File

@ -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))

View File

@ -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

View File

@ -0,0 +1,49 @@
# Copyright 2013 Hewlett-Packard Development Company, L.P.
#
# Author: Kiall Mac Innes <kiall@hp.com>
#
# 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

View File

@ -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 <organization> 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 <COPYRIGHT HOLDER> 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)

View File

@ -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()

View File

@ -0,0 +1,149 @@
# Copyright 2013 Hewlett-Packard Development Company, L.P.
#
# Author: Kiall Mac Innes <kiall@hp.com>
#
# 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 ''

View File

@ -0,0 +1,38 @@
# Copyright 2013 Hewlett-Packard Development Company, L.P.
#
# Author: Kiall Mac Innes <kiall@hp.com>
#
# 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

View File

View File

@ -0,0 +1,118 @@
# Copyright 2013 Hewlett-Packard Development Company, L.P.
#
# Author: Kiall Mac Innes <kiall@hp.com>
#
# 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))

View File

@ -0,0 +1,60 @@
# Copyright 2013 Hewlett-Packard Development Company, L.P.
#
# Author: Kiall Mac Innes <kiall@hp.com>
#
# 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

View File

@ -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"
}
}
}
}
}

View File

@ -29,7 +29,7 @@
"immutable": true
},
"project_id": {
"type": "string",
"type": ["string", "null"],
"description": "Project identifier",
"maxLength": 36,
"immutable": true
@ -92,8 +92,6 @@
"description": "Date and time of last zone modification",
"format": "date-time",
"readOnly": true
}
}
},
"links": {
"type": "object",
@ -107,4 +105,6 @@
}
}
}
}
}
}

View File

@ -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)

View File

@ -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(

View File

@ -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()

View File

@ -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)

View File

@ -0,0 +1,297 @@
# Copyright 2013 Hewlett-Packard Development Company, L.P.
#
# Author: Kiall Mac Innes <kiall@managedit.ie>
#
# 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)

View File

@ -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

View File

@ -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