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