Merge "Refactor wsgi of v2 API"

This commit is contained in:
Zuul 2022-05-17 13:16:46 +00:00 committed by Gerrit Code Review
commit 1b684860f0
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')