Refactor wsgi of v2 API

Original purpose of this patch is that replacing deprecated
webob.Accept.best_match method to acceptable_offers to analize
'Accept' header of a HTTP request.

Instead of fixing legacy wsgi code, refactor v2 wsgi code not to
use legacy wsgi code because legacy wsgi code is complicated
and v2 wsgi code uses legacy wsgi code very little.

Original purpose mentioned above is included in the refactoring
at the same time. This patch includes the followng changes
related to wsgi framework too.
* check of Content-Type header is also enhanced.
* check of Version header for api_versions API is relaxed.

Change-Id: I4ca0beda850ecd14d65d7bc1a59d465c5ecfeacb
This commit is contained in:
Itsuro Oda 2022-03-31 04:48:25 +00:00
parent 76adf42ead
commit 00d8f69f09
8 changed files with 215 additions and 60 deletions

View File

@ -35,20 +35,27 @@ supported_versions_v2 = {
CURRENT_VERSION = '2.0.0'
supported_versions = [
v1_versions = [
item['version'] for item in supported_versions_v1['apiVersions']
]
v2_versions = [
item['version'] for item in supported_versions_v2['apiVersions']
]
class APIVersion(object):
def __init__(self, version_string=None):
def __init__(self, version_string=None, supported_versions=None):
self.ver_major = 0
self.ver_minor = 0
self.ver_patch = 0
if version_string is None:
return
if supported_versions is None:
return
else:
raise sol_ex.APIVersionMissing()
version_string = self._get_version_id(version_string)
match = re.match(r"^([1-9]\d*)\.([1-9]\d*|0)\.([1-9]\d*|0)$",
@ -60,7 +67,8 @@ class APIVersion(object):
else:
raise sol_ex.InvalidAPIVersionString(version=version_string)
if version_string not in supported_versions:
if (supported_versions is not None and
version_string not in supported_versions):
raise sol_ex.APIVersionNotSupported(version=version_string)
def _get_version_id(self, version_string):

View File

@ -18,9 +18,10 @@ import webob
import oslo_i18n as i18n
from oslo_log import log as logging
from oslo_serialization import jsonutils
from tacker.common import exceptions as common_ex
from tacker import wsgi
from tacker import context
from tacker.sol_refactored.api import api_version
from tacker.sol_refactored.common import config
@ -30,6 +31,28 @@ from tacker.sol_refactored.common import exceptions as sol_ex
LOG = logging.getLogger(__name__)
class SolRequest(webob.Request):
def best_match_accept(self, content_types):
offers = self.accept.acceptable_offers(content_types)
if not offers:
raise sol_ex.NotAllowedContentType(header=self.accept.header_value)
return offers[0][0]
def best_match_language(self):
if not self.accept_language:
return None
all_languages = i18n.get_available_languages('tacker')
return self.accept_language.best_match(all_languages)
@property
def context(self):
if 'tacker.context' not in self.environ:
self.environ['tacker.context'] = context.get_admin_context()
return self.environ['tacker.context']
class SolResponse(object):
# SOL013 4.2.3 Response header field
@ -47,7 +70,7 @@ class SolResponse(object):
self.headers.setdefault('version', api_version.CURRENT_VERSION)
self.headers.setdefault('accept-ranges', 'none')
def serialize(self, request, content_type):
def serialize(self, content_type):
self.headers.setdefault('content_type', content_type)
content_type = self.headers['content_type']
if self.body is None:
@ -57,8 +80,7 @@ class SolResponse(object):
elif content_type == 'application/zip':
body = self.body
else: # 'application/json'
serializer = wsgi.JSONDictSerializer()
body = serializer.serialize(self.body)
body = jsonutils.dump_as_bytes(self.body)
if len(body) > config.CONF.v2_vnfm.max_content_length:
raise sol_ex.ResponseTooBig(
size=config.CONF.v2_vnfm.max_content_length)
@ -71,8 +93,7 @@ class SolResponse(object):
class SolErrorResponse(SolResponse):
def __init__(self, ex, req):
user_locale = req.best_match_language()
def __init__(self, ex, user_locale):
problem_details = {}
if isinstance(ex, sol_ex.SolException):
problem_details = ex.make_problem_details()
@ -94,28 +115,26 @@ class SolErrorResponse(SolResponse):
problem_details)
class SolResource(wsgi.Application):
class SolResource(object):
def __init__(self, controller, policy_name=None):
self.controller = controller
self.policy_name = policy_name
self.deserializer = wsgi.RequestDeserializer()
@webob.dec.wsgify(RequestClass=wsgi.Request)
@webob.dec.wsgify(RequestClass=SolRequest)
def __call__(self, request):
LOG.info("%(method)s %(url)s", {"method": request.method,
"url": request.url})
try:
action, args, accept = self.deserializer.deserialize(request)
self.check_api_version(request)
self.check_policy(request, action)
result = self.dispatch(request, action, args)
response = result.serialize(request, accept)
action, args, accept = self._deserialize_request(request)
self._check_api_version(request, action)
self._check_policy(request, action)
result = self._dispatch(request, action, args)
response = result.serialize(accept)
except Exception as ex:
result = SolErrorResponse(ex, request)
result = SolErrorResponse(ex, request.best_match_language())
try:
response = result.serialize(request,
'application/problem+json')
response = result.serialize('application/problem+json')
except Exception:
LOG.exception("Unknown error")
return webob.exc.HTTPBadRequest(explanation="Unknown error")
@ -125,33 +144,83 @@ class SolResource(wsgi.Application):
return response
def check_api_version(self, request):
def _check_api_version(self, request, action):
# check and set api_version
ver = request.headers.get("Version")
if ver is None:
LOG.info("Version missing")
raise sol_ex.APIVersionMissing()
request.context.api_version = api_version.APIVersion(ver)
request.context.api_version = api_version.APIVersion(
ver, self.controller.supported_api_versions(action))
def check_policy(self, request, action):
def _check_policy(self, request, action):
if self.policy_name is None:
return
if action == 'reject':
return
request.context.can(self.policy_name.format(action))
def dispatch(self, request, action, action_args):
def _dispatch(self, request, action, action_args):
controller_method = getattr(self.controller, action)
return controller_method(request=request, **action_args)
def _deserialize_request(self, request):
action_args = request.environ['wsgiorg.routing_args'][1].copy()
action = action_args.pop('action', None)
action_args.pop('controller', None)
class SolAPIRouter(wsgi.Router):
body = self._deserialize_body(request, action)
if body is not None:
action_args.update({'body': body})
accept = request.best_match_accept(
self.controller.allowed_accept(action))
return (action, action_args, accept)
def _deserialize_body(self, request, action):
if request.method not in ('POST', 'PATCH', 'PUT'):
return
if not request.body:
LOG.debug("Empty body provided in request")
return
content_type = request.content_type
allowed_content_types = self.controller.allowed_content_types(action)
if not content_type:
content_type = allowed_content_types[0]
elif content_type not in allowed_content_types:
raise sol_ex.NotSupportedContentType(header=content_type)
if content_type == 'application/zip':
return request.body_file
else:
# assume json format
# ex. 'application/json', 'application/mergepatch+json'
try:
return request.json
except Exception:
raise sol_ex.MalformedRequestBody()
class SolAPIRouter(object):
"""WSGI middleware that maps incoming requests to WSGI apps."""
controller = None
route_list = {}
@classmethod
def factory(cls, global_config, **local_config):
"""Return an instance of the WSGI Router class."""
return cls()
def __init__(self):
super(SolAPIRouter, self).__init__(routes.Mapper())
mapper = routes.Mapper()
self._setup_routes(mapper)
self._router = routes.middleware.RoutesMiddleware(self._dispatch,
mapper)
@webob.dec.wsgify
def __call__(self, req):
return self._router
def _setup_routes(self, mapper):
for path, methods in self.route_list:
@ -173,8 +242,44 @@ class SolAPIRouter(wsgi.Router):
action='reject',
conditions={'method': missing_methods})
@staticmethod
@webob.dec.wsgify(RequestClass=SolRequest)
def _dispatch(req):
"""Dispatch a Request.
Called by self._router after matching the incoming request to a route
and putting the information into req.environ. Either returns 404
or the routed WSGI app's response.
"""
match = req.environ['wsgiorg.routing_args'][1]
if not match:
language = req.best_match_language()
msg = 'The resource could not be found.'
msg = i18n.translate(msg, language)
return webob.exc.HTTPNotFound(explanation=msg)
app = match['controller']
return app
class SolAPIController(object):
def reject(self, request, **kwargs):
raise sol_ex.MethodNotAllowed(method=request.method)
def supported_api_versions(self, action):
# NOTE: support v2 API by default. if a contorller supports
# and/or v1 API, or depending on action, override this method
# in the subclass.
return api_version.v2_versions
def allowed_content_types(self, action):
# NOTE: if other than 'application/json' is expected depending
# on action, override this method in the subclass.
# NOTE: 'text/plain' is allowed for backward compatibility.
# the body is assumed as json.
return ['application/json', 'text/plain']
def allowed_accept(self, action):
# NOTE: if other than 'application/json' is expected depending
# on action, override this method in the subclass.
return ['application/json']

View File

@ -80,6 +80,11 @@ class SolHttpError409(SolException):
title = 'Conflict'
class SolHttpError415(SolException):
status = 415
title = 'Unsupported Media Type'
class SolHttpError422(SolException):
status = 422
title = 'Unprocessable Entity'
@ -328,3 +333,17 @@ class UpdateK8SResourceFailed(SolHttpError400):
class NotSupportOperationType(SolHttpError404):
message = _("This operation is not currently supported.")
class NotAllowedContentType(SolHttpError406):
message = _("Content type '%(header)s' specified in 'Accept' header"
" is not allowed.")
class NotSupportedContentType(SolHttpError415):
message = _("Content type '%(header)s' specified in 'Content-Type' header"
" is not allowed.")
class MalformedRequestBody(SolHttpError400):
message = _("Malformed request body.")

View File

@ -19,7 +19,7 @@ from datetime import datetime
from oslo_log import log as logging
from oslo_utils import uuidutils
from tacker.sol_refactored.api.api_version import supported_versions_v2
from tacker.sol_refactored.api import api_version
from tacker.sol_refactored.api.schemas import vnflcm_v2 as schema
from tacker.sol_refactored.api import validator
from tacker.sol_refactored.api import wsgi as sol_wsgi
@ -53,7 +53,7 @@ class VnfLcmControllerV2(sol_wsgi.SolAPIController):
self._subsc_view = vnflcm_view.SubscriptionViewBuilder(self.endpoint)
def api_versions(self, request):
return sol_wsgi.SolResponse(200, supported_versions_v2)
return sol_wsgi.SolResponse(200, api_version.supported_versions_v2)
@validator.schema(schema.CreateVnfRequest_V200, '2.0.0')
def create(self, request, body):
@ -652,3 +652,21 @@ class VnfLcmControllerV2(sol_wsgi.SolAPIController):
lcmocc.delete(context)
return sol_wsgi.SolResponse(204, None)
def supported_api_versions(self, action):
if action == 'api_versions':
# support all versions and it is OK there is no Version header.
return None
else:
return api_version.v2_versions
def allowed_content_types(self, action):
if action == 'update':
# Content-Type of Modify request shall be
# 'application/mergepatch+json' according to SOL spec.
# But 'application/json' and 'text/plain' is OK for backward
# compatibility.
return ['application/mergepatch+json', 'application/json',
'text/plain']
else:
return ['application/json', 'text/plain']

View File

@ -25,3 +25,7 @@ class VnfLcmVersionsController(sol_wsgi.SolAPIController):
body = {"uriPrefix": "/vnflcm",
"apiVersions": api_versions}
return sol_wsgi.SolResponse(200, body)
def supported_api_versions(self, action):
# support all versions and it is OK there is no Version header.
return None

View File

@ -13,8 +13,6 @@
# License for the specific language governing permissions and limitations
# under the License.
from unittest import mock
from tacker.sol_refactored.api import api_version
from tacker.sol_refactored.common import exceptions as sol_ex
from tacker.tests import base
@ -26,37 +24,37 @@ class TestAPIVersion(base.BaseTestCase):
vers = api_version.APIVersion()
self.assertTrue(vers.is_null())
@mock.patch.object(api_version, 'supported_versions',
new=["3.1.4159", "2.0.0"])
def test_init(self):
supported_versions = ["3.1.4159", "2.0.0"]
for vers, vers_str in [("2.0.0", "2.0.0"),
("3.1.4159", "3.1.4159"),
("2.0.0-impl:foobar", "2.0.0")]:
v = api_version.APIVersion(vers)
v = api_version.APIVersion(vers, supported_versions)
self.assertEqual(str(v), vers_str)
def test_init_exceptions(self):
supported_versions = ["2.0.0"]
self.assertRaises(sol_ex.InvalidAPIVersionString,
api_version.APIVersion, "0.1.2")
api_version.APIVersion, "0.1.2", supported_versions)
self.assertRaises(sol_ex.APIVersionNotSupported,
api_version.APIVersion, "9.9.9")
api_version.APIVersion, "9.9.9", supported_versions)
@mock.patch.object(api_version, 'supported_versions',
new=["1.3.0", "1.3.1", "2.0.0"])
def test_compare(self):
self.assertTrue(api_version.APIVersion("1.3.0") <
api_version.APIVersion("1.3.1"))
supported_versions = ["1.3.0", "1.3.1", "2.0.0"]
self.assertTrue(api_version.APIVersion("1.3.0", supported_versions) <
api_version.APIVersion("1.3.1", supported_versions))
self.assertTrue(api_version.APIVersion("2.0.0") >
api_version.APIVersion("1.3.1"))
self.assertTrue(api_version.APIVersion("2.0.0", supported_versions) >
api_version.APIVersion("1.3.1", supported_versions))
@mock.patch.object(api_version, 'supported_versions',
new=["1.3.0", "1.3.1", "2.0.0"])
def test_matches(self):
supported_versions = ["1.3.0", "1.3.1", "2.0.0"]
vers = api_version.APIVersion("2.0.0")
self.assertTrue(vers.matches(api_version.APIVersion("1.3.0"),
api_version.APIVersion()))
self.assertTrue(
vers.matches(api_version.APIVersion("1.3.0", supported_versions),
api_version.APIVersion()))
self.assertFalse(vers.matches(api_version.APIVersion(),
api_version.APIVersion("1.3.1")))
self.assertFalse(
vers.matches(api_version.APIVersion(),
api_version.APIVersion("1.3.1", supported_versions)))

View File

@ -56,25 +56,28 @@ class TestValidator(base.BaseTestCase):
def _test_method(self, request, body):
return True
@mock.patch.object(api_version, 'supported_versions',
new=['2.0.0', '2.0.1', '2.0.2', '2.1.0', '2.2.0'])
def test_validator(self):
supported_versions = ['2.0.0', '2.0.1', '2.0.2', '2.1.0', '2.2.0']
body = {"vnfdId": "vnfd_id", "ProductId": "product_id"}
for ok_ver in ['2.0.0', '2.0.1', '2.0.2']:
self.context.api_version = api_version.APIVersion(ok_ver)
self.context.api_version = api_version.APIVersion(
ok_ver, supported_versions)
result = self._test_method(request=self.request, body=body)
self.assertTrue(result)
for ng_ver in ['2.1.0', '2.2.0']:
self.context.api_version = api_version.APIVersion(ng_ver)
self.context.api_version = api_version.APIVersion(
ng_ver, supported_versions)
self.assertRaises(sol_ex.SolValidationError,
self._test_method, request=self.request, body=body)
body = {"vnfdId": "vnfd_id", "flavourId": "flavour_id"}
for ok_ver in ['2.1.0', '2.2.0']:
self.context.api_version = api_version.APIVersion(ok_ver)
self.context.api_version = api_version.APIVersion(
ok_ver, supported_versions)
result = self._test_method(request=self.request, body=body)
self.assertTrue(result)
for ng_ver in ['2.0.0', '2.0.1', '2.0.2']:
self.context.api_version = api_version.APIVersion(ng_ver)
self.context.api_version = api_version.APIVersion(
ng_ver, supported_versions)
self.assertRaises(sol_ex.SolValidationError,
self._test_method, request=self.request, body=body)

View File

@ -27,7 +27,7 @@ class TestWsgi(base.TestCase):
body = {"key": "value0123456789"}
response = sol_wsgi.SolResponse(200, body)
self.assertRaises(sol_ex.ResponseTooBig,
response.serialize, mock.Mock(), 'application/json')
response.serialize, 'application/json')
def test_unknown_error_response(self):
err_msg = "Test error"
@ -41,8 +41,8 @@ class TestWsgi(base.TestCase):
self.assertEqual(problem_details, response.body)
def test_check_api_version_no_version(self):
resource = sol_wsgi.SolResource(mock.Mock())
resource = sol_wsgi.SolResource(sol_wsgi.SolAPIController())
request = mock.Mock()
request.headers = {}
self.assertRaises(sol_ex.APIVersionMissing,
resource.check_api_version, request)
resource._check_api_version, request, 'action')