Add initial framing of placement API

The placement API will initially host resource provider
information and then will grow to provide a full placement
service. Eventually it will be fully extracted from Nova.

To facilitate that extraction, this service is being developed
with few ties to existing nova.wsgi structures. Instead it
uses relatively plain WSGI apps that are:

* uses the Routes library with declarative mapping defined in
  ROUTE_DECLARATIONS
* basic wsgi apps, with webob and the request class, for each handler
  define as functions
* does not use a paste-ini file to compose middleware, instead code
  which minimally inspects the oslo config to know how to adjust
  middleware (in the initial case choosing an auth_strategy)

Many of these "features" will be demonstrated in commits that
follow this one that enable specific behaviors for resource
providers and their inventories.

In order to ensure that this change can be merged in an atomic
fashion it includes microversion support that was previously in its
own commit.

The microversion_parse library is used in a WSGI middleware
to parse incoming microversion headers and place the
determined value into the WSGI environment at the
'placement.microversion' key. Response headers are adjusted to
add the required outgoing headers as described in:

http://specs.openstack.org/openstack/api-wg/guidelines/microversion_specification.html

If a client requests an invalid microversion, they will receive
a 400 response. If the microversion is of a valid form but not
available, they will received a 406 response. The format of that
response is dependent on the accept header of the request. If it
is JSON, the min and max available microversions are presented.

A request to '/' will return version discovery information.

Thus far nothing else is done with the microversion information.
It is there for when we need it in the future. For now everything
is version 1.0.

The groundwork for using gabbi to test the API is in place in
nova/tests/functional/api/openstack/placement. The gabbi tests
are included in the functional target. To run just the placement
tests it is possible to run 'tox -efunctional placement'.

Change-Id: Icb9c8f7a1fa8a9eac66c2d72f4b7e4efd4e1944f
Partially-Implements: blueprint generic-resource-pools
This commit is contained in:
Chris Dent 2016-06-13 17:25:03 +00:00
parent 75b9b143dd
commit 2ae10ce522
20 changed files with 825 additions and 0 deletions

View File

@ -6,3 +6,13 @@ test_command=OS_STDOUT_CAPTURE=${OS_STDOUT_CAPTURE:-1} \
test_id_option=--load-list $IDFILE
test_list_option=--list
# NOTE(cdent): The group_regex describes how testrepository will
# group tests into the same process when running concurently. The
# following insures that gabbi tests coming from the same YAML file
# are all in the same process. This is important because each YAML
# file represents an ordered sequence of HTTP requests. Note that
# tests which do not match this regex will not be grouped in any
# special way. See the following for more details.
# http://testrepository.readthedocs.io/en/latest/MANUAL.html#grouping-tests
# https://gabbi.readthedocs.io/en/latest/#purpose
group_regex=(gabbi\.(?:driver|suitemaker)\.test_placement_api_([^_]+))

View File

View File

@ -0,0 +1,73 @@
# 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_context import context
from oslo_log import log as logging
from oslo_middleware import request_id
import webob.dec
import webob.exc
from nova import conf
CONF = conf.CONF
LOG = logging.getLogger(__name__)
class Middleware(object):
def __init__(self, application, **kwargs):
self.application = application
# NOTE(cdent): Only to be used in tests where auth is being faked.
class NoAuthMiddleware(Middleware):
"""Require a token if one isn't present."""
def __init__(self, application):
self.application = application
@webob.dec.wsgify
def __call__(self, req):
if 'X-Auth-Token' not in req.headers:
return webob.exc.HTTPUnauthorized()
token = req.headers['X-Auth-Token']
user_id, _sep, project_id = token.partition(':')
project_id = project_id or user_id
if user_id == 'admin':
roles = ['admin']
else:
roles = []
req.headers['X_USER_ID'] = user_id
req.headers['X_TENANT_ID'] = project_id
req.headers['X_ROLES'] = ','.join(roles)
return self.application
class PlacementKeystoneContext(Middleware):
"""Make a request context from keystone headers."""
@webob.dec.wsgify
def __call__(self, req):
req_id = req.environ.get(request_id.ENV_REQUEST_ID)
ctx = context.RequestContext.from_environ(
req.environ, request_id=req_id)
if ctx.user is None:
LOG.debug("Neither X_USER_ID nor X_USER found in request")
return webob.exc.HTTPUnauthorized()
req.environ['placement.context'] = ctx
return self.application

View File

@ -0,0 +1,55 @@
# 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.
"""Deployment handling for Placmenent API."""
from keystonemiddleware import auth_token
from oslo_middleware import request_id
from nova.api.openstack.placement import auth
from nova.api.openstack.placement import handler
from nova.api.openstack.placement import microversion
# TODO(cdent): register objects here as this is our startup place,
# but only once we start using them.
# TODO(cdent): NAME points to the config project being used, so for
# now this is "nova" but we probably want "placement" eventually.
NAME = "nova"
def deploy(conf, project_name):
"""Assemble the middleware pipeline leading to the placement app."""
if conf.auth_strategy == 'noauth2':
auth_middleware = auth.NoAuthMiddleware
else:
# Do not provide global conf to middleware here.
auth_middleware = auth_token.filter_factory(
{}, olso_config_project=project_name)
context_middleware = auth.PlacementKeystoneContext
req_id_middleware = request_id.RequestId
microversion_middleware = microversion.MicroversionMiddleware
application = handler.PlacementHandler()
for middleware in (context_middleware,
auth_middleware,
microversion_middleware,
req_id_middleware):
application = middleware(application)
return application
def loadapp(config, project_name=NAME):
application = deploy(config, project_name)
return application

View File

@ -0,0 +1,114 @@
# 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.
"""Handlers for placement API.
Individual handlers are associated with URL paths in the
ROUTE_DECLARATIONS dictionary. At the top level each key is a Routes
compliant path. The value of that key is a dictionary mapping
individual HTTP request methods to a Python function representing a
simple WSGI application for satisfying that request.
The ``make_map`` method processes ROUTE_DECLARATIONS to create a
Routes.Mapper, including automatic handlers to respond with a
405 when a request is made against a valid URL with an invalid
method.
"""
import routes
import webob
from nova.api.openstack.placement.handlers import root
from nova.api.openstack.placement import util
# URLs and Handlers
ROUTE_DECLARATIONS = {
'/': {
'GET': root.home,
},
}
def dispatch(environ, start_response, mapper):
"""Find a matching route for the current request.
If no match is found, raise a 404 response.
If there is a matching route, but no matching handler
for the given method, raise a 405.
"""
result = mapper.match(environ=environ)
if result is None:
raise webob.exc.HTTPNotFound(
json_formatter=util.json_error_formatter)
# We can't reach this code without action being present.
handler = result.pop('action')
environ['wsgiorg.routing_args'] = ((), result)
return handler(environ, start_response)
def handle_405(environ, start_response):
"""Return a 405 response as required.
If _methods are in routing_args, send an allow header listing
the methods that are possible on the provided URL.
"""
_methods = util.wsgi_path_item(environ, '_methods')
headers = {}
if _methods:
headers['allow'] = _methods
raise webob.exc.HTTPMethodNotAllowed(
'The method specified is not allowed for this resource.',
headers=headers, json_formatter=util.json_error_formatter)
def make_map(declarations):
"""Process route declarations to create a Route Mapper."""
mapper = routes.Mapper()
for route, targets in declarations.items():
allowed_methods = []
for method in targets:
mapper.connect(route, action=targets[method],
conditions=dict(method=[method]))
allowed_methods.append(method)
allowed_methods = ', '.join(allowed_methods)
mapper.connect(route, action=handle_405, _methods=allowed_methods)
return mapper
class PlacementHandler(object):
"""Serve Placement API.
Dispatch to handlers defined in ROUTE_DECLARATIONS.
"""
def __init__(self, **local_config):
# NOTE(cdent): Local config currently unused.
self._map = make_map(ROUTE_DECLARATIONS)
def __call__(self, environ, start_response):
# All requests but '/' require admin.
# TODO(cdent): We'll eventually want our own auth context,
# but using nova's is convenient for now.
if environ['PATH_INFO'] != '/':
context = environ['placement.context']
# TODO(cdent): Using is_admin everywhere (except /) is
# insufficiently flexible for future use case but is
# convenient for initial exploration. We will need to
# determine how to manage authorization/policy and
# implement that, probably per handler. Also this is
# just the wrong way to do things, but policy not
# integrated yet.
if 'admin' not in context.to_policy_values()['roles']:
raise webob.exc.HTTPForbidden(
'admin required',
json_formatter=util.json_error_formatter)
return dispatch(environ, start_response, self._map)

View File

@ -0,0 +1,36 @@
# 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.
"""Handler for the root of the Placement API."""
from oslo_serialization import jsonutils
import webob
from nova.api.openstack.placement import microversion
@webob.dec.wsgify
def home(req):
min_version = microversion.min_version_string()
max_version = microversion.max_version_string()
# NOTE(cdent): As sections of the api are added, links can be
# added to this output to align with the guidelines at
# http://specs.openstack.org/openstack/api-wg/guidelines/microversion_specification.html#version-discovery
version_data = {
'id': 'v%s' % min_version,
'max_version': max_version,
'min_version': min_version,
}
version_json = jsonutils.dumps({'versions': [version_data]})
req.response.body = version_json
req.response.content_type = 'application/json'
return req.response

View File

@ -0,0 +1,143 @@
# 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.
"""Microversion handling."""
# NOTE(cdent): This code is taken from enamel:
# https://github.com/jaypipes/enamel and was the original source of
# the code now used in microversion_parse library.
import collections
import microversion_parse
import webob
from nova.api.openstack.placement import util
SERVICE_TYPE = 'placement'
MICROVERSION_ENVIRON = '%s.microversion' % SERVICE_TYPE
# The Canonical Version List
VERSIONS = [
'1.0',
]
def max_version_string():
return VERSIONS[-1]
def min_version_string():
return VERSIONS[0]
def parse_version_string(version_string):
"""Turn a version string into a Version
:param version_string: A string of two numerals, X.Y, or 'latest'
:returns: a Version
:raises: TypeError
"""
if version_string == 'latest':
version_string = max_version_string()
try:
# The combination of int and a limited split with the
# named tuple means that this incantation will raise
# ValueError or TypeError when the incoming data is
# poorly formed but will, however, naturally adapt to
# extraneous whitespace.
return Version(*(int(value) for value
in version_string.split('.', 1)))
except (ValueError, TypeError) as exc:
raise TypeError('invalid version string: %s; %s' % (
version_string, exc))
class MicroversionMiddleware(object):
"""WSGI middleware for getting microversion info."""
def __init__(self, application):
self.application = application
@webob.dec.wsgify
def __call__(self, req):
try:
req.environ[MICROVERSION_ENVIRON] = extract_version(
req.headers)
except ValueError as exc:
raise webob.exc.HTTPNotAcceptable(
'Invalid microversion: %s' % exc,
json_formatter=util.json_error_formatter)
except TypeError as exc:
raise webob.exc.HTTPBadRequest(
'Invalid microversion: %s' % exc,
json_formatter=util.json_error_formatter)
response = req.get_response(self.application)
response.headers.add(Version.HEADER,
'%s %s' % (SERVICE_TYPE,
req.environ[MICROVERSION_ENVIRON]))
response.headers.add('vary', Version.HEADER)
return response
class Version(collections.namedtuple('Version', 'major minor')):
"""A namedtuple containing major and minor values.
Since it is a tuple is automatically comparable.
"""
HEADER = 'OpenStack-API-Version'
MIN_VERSION = None
MAX_VERSION = None
def __str__(self):
return '%s.%s' % (self.major, self.minor)
@property
def max_version(self):
if not self.MAX_VERSION:
self.MAX_VERSION = parse_version_string(max_version_string())
return self.MAX_VERSION
@property
def min_version(self):
if not self.MIN_VERSION:
self.MIN_VERSION = parse_version_string(min_version_string())
return self.MIN_VERSION
def matches(self, min_version=None, max_version=None):
if min_version is None:
min_version = self.min_version
if max_version is None:
max_version = self.max_version
return min_version <= self <= max_version
def extract_version(headers):
"""Extract the microversion from Version.HEADER
There may be multiple headers and some which don't match our
service.
"""
found_version = microversion_parse.get_version(headers,
service_type=SERVICE_TYPE)
version_string = found_version or min_version_string()
request_version = parse_version_string(version_string)
# We need a version that is in VERSION and within MIX and MAX.
# This gives us the option to administratively disable a
# version if we really need to.
if (str(request_version) in VERSIONS and request_version.matches()):
return request_version
raise ValueError('Unacceptable version header: %s' % version_string)

View File

@ -0,0 +1,27 @@
# 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.
"""WSGI script for Placement API
WSGI handler for running Placement API under Apache2, nginx, gunicorn etc.
"""
from nova.api.openstack.placement import deploy
from nova import conf
from nova import config
CONFIG_FILE = '/etc/nova/nova.conf'
config.parse_args([], default_config_files=[CONFIG_FILE])
application = deploy.loadapp(conf.CONF)

View File

@ -0,0 +1,65 @@
# 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.
"""Utility methods for placement API."""
from oslo_middleware import request_id
import webob
# NOTE(cdent): avoid cyclical import conflict between util and
# microversion
import nova.api.openstack.placement.microversion
def json_error_formatter(body, status, title, environ):
"""A json_formatter for webob exceptions.
Follows API-WG guidelines at
http://specs.openstack.org/openstack/api-wg/guidelines/errors.html
"""
# Clear out the html that webob sneaks in.
body = webob.exc.strip_tags(body)
error_dict = {
'status': status,
'title': title,
'detail': body
}
# If the request id middleware has had a chance to add an id,
# put it in the error response.
if request_id.ENV_REQUEST_ID in environ:
error_dict['request_id'] = environ[request_id.ENV_REQUEST_ID]
# When there is a no microversion in the environment and a 406,
# microversion parsing failed so we need to include microversion
# min and max information in the error response.
microversion = nova.api.openstack.placement.microversion
# Get status code out of status message. webob's error formatter
# only passes entire status string.
code = status.split(None, 1)[0]
if code == '406' and microversion.MICROVERSION_ENVIRON not in environ:
error_dict['max_version'] = microversion.max_version_string()
error_dict['min_version'] = microversion.max_version_string()
return {'errors': [error_dict]}
def wsgi_path_item(environ, name):
"""Extract the value of a named field in a URL.
Return None if the name is not present or there are no path items.
"""
# NOTE(cdent): For the time being we don't need to urldecode
# the value as the entire placement API has paths that accept no
# encoded values.
try:
return environ['wsgiorg.routing_args'][1][name]
except (KeyError, IndexError):
return None

View File

@ -0,0 +1,41 @@
# 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 gabbi import fixture
from nova.api.openstack.placement import deploy
from nova import conf
from nova import config
CONF = conf.CONF
def setup_app():
return deploy.loadapp(CONF)
class APIFixture(fixture.GabbiFixture):
"""Setup the required backend fixtures for a basic placement service."""
def __init__(self):
self.conf = None
def start_fixture(self):
self.conf = CONF
config.parse_args([], default_config_files=None, configure_db=False,
init_rpc=False)
self.conf.set_override('auth_strategy', 'noauth2')
def stop_fixture(self):
if self.conf:
self.conf.reset()

View File

@ -0,0 +1,39 @@
#
# Test the basic handling of HTTP (expected response codes and the
# like).
#
fixtures:
- APIFixture
defaults:
request_headers:
# NOTE(cdent): Get past keystone, even though at this stage
# we don't require auth.
x-auth-token: admin
accept: application/json
tests:
- name: 404 at no service
GET: /barnabas
status: 404
response_json_paths:
$.errors[0].title: Not Found
- name: error message has request id
GET: /barnabas
status: 404
response_json_paths:
$.errors[0].request_id: /req-[a-fA-F0-9-]+/
- name: 405 on bad method at root
DELETE: /
status: 405
response_headers:
allow: GET
response_json_paths:
$.errors[0].title: Method Not Allowed
- name: 200 at home
GET: /
status: 200

View File

@ -0,0 +1,22 @@
#
# Confirm that the noauth handler is causing a 401 when no fake
# token is provided.
#
fixtures:
- APIFixture
defaults:
request_headers:
accept: application/json
tests:
- name: no token gets 401
GET: /
status: 401
- name: with token 200
GET: /
request_headers:
x-auth-token: admin:admin
status: 200

View File

@ -0,0 +1,73 @@
# Tests to build microversion functionality behavior and confirm
# it is present and behaving as expected.
fixtures:
- APIFixture
defaults:
request_headers:
accept: application/json
x-auth-token: user
tests:
- name: root has microversion header
GET: /
response_headers:
vary: /OpenStack-API-Version/
openstack-api-version: /^placement \d+\.\d+$/
- name: root has microversion info
GET: /
response_json_paths:
$.versions[0].max_version: /^\d+\.\d+$/
$.versions[0].min_version: /^\d+\.\d+$/
$.versions[0].id: v1.0
- name: unavailable microversion raises 406
GET: /
request_headers:
openstack-api-version: placement 0.5
status: 406
response_headers:
content-type: /application/json/
response_json_paths:
$.errors.[0].title: Not Acceptable
$.errors.[0].max_version: /^\d+\.\d+$/
$.errors.[0].min_version: /^\d+\.\d+$/
response_strings:
- "Unacceptable version header: 0.5"
- name: latest microversion is 1.0
GET: /
request_headers:
openstack-api-version: placement latest
response_headers:
vary: /OpenStack-API-Version/
openstack-api-version: placement 1.0
- name: other accept header bad version
GET: /
request_headers:
accept: text/html
openstack-api-version: placement 0.5
status: 406
response_headers:
content-type: /text/html/
response_strings:
- "Unacceptable version header: 0.5"
- name: bad format string raises 400
GET: /
request_headers:
openstack-api-version: placement pony.horse
status: 400
response_strings:
- "invalid version string: pony.horse"
- name: bad format multidot raises 400
GET: /
request_headers:
openstack-api-version: placement 1.2.3
status: 400
response_strings:
- "invalid version string: 1.2.3"

View File

@ -0,0 +1,27 @@
# 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 os
from gabbi import driver
from nova.tests.functional.api.openstack.placement import fixtures
TESTS_DIR = 'gabbits'
def load_tests(loader, tests, pattern):
"""Provide a TestSuite to the discovery process."""
test_dir = os.path.join(os.path.dirname(__file__), TESTS_DIR)
return driver.build_tests(test_dir, loader, host=None,
intercept=fixtures.setup_app,
fixture_module=fixtures)

View File

@ -0,0 +1,99 @@
# 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.
"""Unit tests for the functions used by the placement API handlers."""
import mock
import routes
import webob
from nova.api.openstack.placement import handler
from nova import test
from nova.tests import uuidsentinel
# Used in tests below
def start_response(*args, **kwargs):
pass
def _environ(path='/moo', method='GET'):
return {
'PATH_INFO': path,
'REQUEST_METHOD': method,
'SERVER_NAME': 'example.com',
'SERVER_PORT': '80',
'wsgi.url_scheme': 'http',
}
class DispatchTest(test.NoDBTestCase):
def setUp(self):
super(DispatchTest, self).setUp()
self.mapper = routes.Mapper()
self.route_handler = mock.MagicMock()
def test_no_match_null_map(self):
self.assertRaises(webob.exc.HTTPNotFound,
handler.dispatch,
_environ(), start_response,
self.mapper)
def test_no_match_with_map(self):
self.mapper.connect('/foobar', action='hello')
self.assertRaises(webob.exc.HTTPNotFound,
handler.dispatch,
_environ(), start_response,
self.mapper)
def test_simple_match(self):
self.mapper.connect('/foobar', action=self.route_handler,
conditions=dict(method=['GET']))
environ = _environ(path='/foobar')
handler.dispatch(environ, start_response, self.mapper)
self.route_handler.assert_called_with(environ, start_response)
def test_simple_match_routing_args(self):
self.mapper.connect('/foobar/{id}', action=self.route_handler,
conditions=dict(method=['GET']))
environ = _environ(path='/foobar/%s' % uuidsentinel.foobar)
handler.dispatch(environ, start_response, self.mapper)
self.route_handler.assert_called_with(environ, start_response)
self.assertEqual(uuidsentinel.foobar,
environ['wsgiorg.routing_args'][1]['id'])
class MapperTest(test.NoDBTestCase):
def setUp(self):
super(MapperTest, self).setUp()
declarations = {
'/hello': {'GET': 'hello'}
}
self.mapper = handler.make_map(declarations)
def test_no_match(self):
environ = _environ(path='/cow')
self.assertIsNone(self.mapper.match(environ=environ))
def test_match(self):
environ = _environ(path='/hello')
action = self.mapper.match(environ=environ)['action']
self.assertEqual('hello', action)
def test_405(self):
environ = _environ(path='/hello', method='POST')
result = self.mapper.match(environ=environ)
self.assertEqual(handler.handle_405, result['action'])
self.assertEqual('GET', result['_methods'])

View File

@ -24,6 +24,7 @@ testtools>=1.4.0 # MIT
tempest-lib>=0.14.0 # Apache-2.0
bandit>=1.0.1 # Apache-2.0
openstackdocstheme>=1.4.0 # Apache-2.0
gabbi>=1.24.0 # Apache-2.0
# vmwareapi driver specific dependencies
oslo.vmware>=2.11.0 # Apache-2.0