From 00d8f69f09da76095959426ca529d527c2984b7a Mon Sep 17 00:00:00 2001 From: Itsuro Oda Date: Thu, 31 Mar 2022 04:48:25 +0000 Subject: [PATCH] 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 --- tacker/sol_refactored/api/api_version.py | 16 +- tacker/sol_refactored/api/wsgi.py | 157 +++++++++++++++--- tacker/sol_refactored/common/exceptions.py | 19 +++ tacker/sol_refactored/controller/vnflcm_v2.py | 22 ++- .../controller/vnflcm_versions.py | 4 + .../sol_refactored/api/test_api_version.py | 36 ++-- .../unit/sol_refactored/api/test_validator.py | 15 +- .../unit/sol_refactored/api/test_wsgi.py | 6 +- 8 files changed, 215 insertions(+), 60 deletions(-) diff --git a/tacker/sol_refactored/api/api_version.py b/tacker/sol_refactored/api/api_version.py index 3b9751685..6ae08e72b 100644 --- a/tacker/sol_refactored/api/api_version.py +++ b/tacker/sol_refactored/api/api_version.py @@ -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): diff --git a/tacker/sol_refactored/api/wsgi.py b/tacker/sol_refactored/api/wsgi.py index b63d5d6b1..460b4ec53 100644 --- a/tacker/sol_refactored/api/wsgi.py +++ b/tacker/sol_refactored/api/wsgi.py @@ -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'] diff --git a/tacker/sol_refactored/common/exceptions.py b/tacker/sol_refactored/common/exceptions.py index b63df0648..8c17ee11c 100644 --- a/tacker/sol_refactored/common/exceptions.py +++ b/tacker/sol_refactored/common/exceptions.py @@ -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.") diff --git a/tacker/sol_refactored/controller/vnflcm_v2.py b/tacker/sol_refactored/controller/vnflcm_v2.py index c892f5744..85813c11a 100644 --- a/tacker/sol_refactored/controller/vnflcm_v2.py +++ b/tacker/sol_refactored/controller/vnflcm_v2.py @@ -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'] diff --git a/tacker/sol_refactored/controller/vnflcm_versions.py b/tacker/sol_refactored/controller/vnflcm_versions.py index be1a22039..ad4f70bbb 100644 --- a/tacker/sol_refactored/controller/vnflcm_versions.py +++ b/tacker/sol_refactored/controller/vnflcm_versions.py @@ -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 diff --git a/tacker/tests/unit/sol_refactored/api/test_api_version.py b/tacker/tests/unit/sol_refactored/api/test_api_version.py index fa806beff..9cba7b5f6 100644 --- a/tacker/tests/unit/sol_refactored/api/test_api_version.py +++ b/tacker/tests/unit/sol_refactored/api/test_api_version.py @@ -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))) diff --git a/tacker/tests/unit/sol_refactored/api/test_validator.py b/tacker/tests/unit/sol_refactored/api/test_validator.py index 08d6ddf37..eea99ce84 100644 --- a/tacker/tests/unit/sol_refactored/api/test_validator.py +++ b/tacker/tests/unit/sol_refactored/api/test_validator.py @@ -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) diff --git a/tacker/tests/unit/sol_refactored/api/test_wsgi.py b/tacker/tests/unit/sol_refactored/api/test_wsgi.py index 93bd4269e..bab9aec95 100644 --- a/tacker/tests/unit/sol_refactored/api/test_wsgi.py +++ b/tacker/tests/unit/sol_refactored/api/test_wsgi.py @@ -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')