Add environment edit API

'/environments/<env_id>/model/<path>' endpoint added.
GET request responds with the subsection of <env_id>'s object
model located in its <path>.
PATCH request applies json-patch from request body to <end_id>'s
model. It does not contain <path> in the URL.

Change-Id: I672d43464ed7d5722cc574f1a10700b070664f34
Implements: bp environment-edit
This commit is contained in:
Valerii Kovalchuk 2016-09-29 14:52:27 +03:00 committed by Alexander Tivelkov
parent f258b577fb
commit b3a06349c5
8 changed files with 468 additions and 31 deletions

View File

@ -12,6 +12,7 @@
# License for the specific language governing permissions and limitations
# under the License.
import jsonpatch
from oslo_db import exception as db_exc
from oslo_log import log as logging
import six
@ -192,6 +193,62 @@ class Controller(object):
result[service_id] = None
return {'lastStatuses': result}
@request_statistics.stats_count(API_NAME, 'GetModel')
@verify_env
def get_model(self, request, environment_id, path):
LOG.debug('Environments:GetModel <Id: %(env_id)s>, Path: %(path)s',
{'env_id': environment_id, 'path': path})
target = {"environment_id": environment_id}
policy.check('show_environment', request.context, target)
session_id = None
if hasattr(request, 'context') and request.context.session:
session_id = request.context.session
get_description = envs.EnvironmentServices.get_environment_description
env_model = get_description(environment_id, session_id)
try:
result = utils.TraverseHelper.get(path, env_model)
except (KeyError, ValueError):
raise exc.HTTPNotFound
return result
@request_statistics.stats_count(API_NAME, 'UpdateModel')
@verify_env
def update_model(self, request, environment_id, body=None):
if not body:
msg = _('Request body is empty: please, provide '
'environment object model patch')
LOG.error(msg)
raise exc.HTTPBadRequest(msg)
LOG.debug('Environments:UpdateModel <Id: %(env_id)s, Body: %(body)s>',
{'env_id': environment_id, 'body': body})
target = {"environment_id": environment_id}
policy.check('update_environment', request.context, target)
session_id = None
if hasattr(request, 'context') and request.context.session:
session_id = request.context.session
get_description = envs.EnvironmentServices.get_environment_description
env_model = get_description(environment_id, session_id)
for change in body:
change['path'] = '/' + '/'.join(change['path'])
patch = jsonpatch.JsonPatch(body)
try:
patch.apply(env_model, in_place=True)
except jsonpatch.JsonPatchException as e:
raise exc.HTTPNotFound(str(e))
save_description = envs.EnvironmentServices. \
save_environment_description
save_description(session_id, env_model)
return env_model
def create_resource():
return wsgi.Resource(Controller())

View File

@ -97,6 +97,14 @@ class API(wsgi.Router):
controller=environments_resource,
action='last',
conditions={'method': ['GET']})
mapper.connect('/environments/{environment_id}/model/{path:.*?}',
controller=environments_resource,
action='get_model',
conditions={'method': ['GET']})
mapper.connect('/environments/{environment_id}/model/',
controller=environments_resource,
action='update_model',
conditions={'method': ['PATCH']})
templates_resource = templates.create_resource()
mapper.connect('/templates',

View File

@ -12,16 +12,60 @@
# License for the specific language governing permissions and limitations
# under the License.
# TODO(all): write detailed schema.
ENV_SCHEMA = {
"$schema": "http://json-schema.org/draft-04/schema#",
"type": "object",
"properties": {
"id": {"type": "string"},
"name": {"type": "string"}
"?": {
"type": "object",
"properties": {
"id": {"type": "string"},
"name": {"type": "string"},
"type": {"type": "string"},
"_actions": {"type": "object"}
},
"required": ["id", "type"]
},
"name": {"type": "string"},
"region": {"type": ["string", "null"]},
"regions": {"type": "object"},
"defaultNetworks": {
"type": "object",
"properties": {
"environment": {
"type": "object",
"properties": {
"name": {"type": "string"},
"?": {
"type": "object",
"properties": {
"type": {"type": "string"},
"id": {"type": "string"},
"name": {"type": "string"}
},
},
"autoUplink": {"type": "boolean"},
"externalRouterId": {"type": "string"},
"dnsNameServers": {"type": "array"},
"autogenerateSubnet": {"type": "boolean"},
"subnetCidr": {"type": "string"},
"openstackId": {"type": "string"},
"regionName": {"type": "string"}
},
"required": ["name", "?"]
},
"flat": {"type": ["boolean", "null"]}
},
"required": ["environment", "flat"]
},
"services": {
"type": "array",
"minItems": 0,
"items": {"type": "object"}
}
},
"required": ["id", "name"]
"required": ["?", "name", "region", "defaultNetworks"]
}
PKG_UPLOAD_SCHEMA = {

View File

@ -308,6 +308,7 @@ class Request(webob.Request):
default_request_content_types = ('application/json',
'application/xml',
'application/murano-packages-json-patch',
'application/env-model-json-patch',
'multipart/form-data')
default_accept_types = ('application/json',
'application/xml',
@ -737,7 +738,10 @@ class RequestDeserializer(object):
self.body_deserializers = {
'application/xml': XMLDeserializer(),
'application/json': JSONDeserializer(),
'application/murano-packages-json-patch': JSONPatchDeserializer(),
'application/murano-packages-json-patch':
MuranoPackageJSONPatchDeserializer(),
'application/env-model-json-patch':
EnvModelJSONPatchDeserializer(),
'multipart/form-data': FormDataDeserializer()
}
self.body_deserializers.update(body_deserializers or {})
@ -846,12 +850,9 @@ class JSONDeserializer(TextDeserializer):
class JSONPatchDeserializer(TextDeserializer):
allowed_operations = {"categories": ["add", "replace", "remove"],
"tags": ["add", "replace", "remove"],
"is_public": ["replace"],
"enabled": ["replace"],
"description": ["replace"],
"name": ["replace"]}
allowed_operations = {}
schema = None
allow_unknown_path = False
def _from_json_patch(self, datastring):
try:
@ -860,6 +861,10 @@ class JSONPatchDeserializer(TextDeserializer):
msg = _("cannot understand JSON")
raise exceptions.MalformedRequestBody(reason=msg)
if not isinstance(operations, list):
msg = _('JSON-patch must be a list.')
raise webob.exc.HTTPBadRequest(explanation=msg)
changes = []
for raw_change in operations:
if not isinstance(raw_change, dict):
@ -898,32 +903,53 @@ class JSONPatchDeserializer(TextDeserializer):
raise webob.exc.HTTPBadRequest(explanation=msg)
def _validate_change(self, change):
change_path = change['path'][0]
change_op = change['op']
allowed_methods = self.allowed_operations.get(change_path)
self._validate_allowed_methods(change, self.allow_unknown_path)
if not allowed_methods:
msg = _("Attribute '{0}' is invalid").format(change_path)
raise webob.exc.HTTPForbidden(explanation=six.text_type(msg))
if self.schema:
self._validate_schema(change)
def _validate_allowed_methods(self, change, allow_unknown_path=False):
full_path = '/'.join(change['path'])
change_op = change['op']
allowed_methods = self.allowed_operations.get(full_path)
if allowed_methods is None:
if allow_unknown_path:
allowed_methods = ['add', 'replace', 'remove']
else:
msg = _("Attribute '{0}' is invalid").format(full_path)
raise webob.exc.HTTPForbidden(explanation=six.text_type(msg))
if change_op not in allowed_methods:
ops = ', '.join(allowed_methods) if allowed_methods\
else 'no operations'
msg = _("Method '{method}' is not allowed for a path with name "
"'{name}'. Allowed operations are: "
"'{ops}'").format(method=change_op,
name=change_path,
ops=', '.join(allowed_methods))
"{ops}").format(method=change_op, name=full_path, ops=ops)
raise webob.exc.HTTPForbidden(explanation=six.text_type(msg))
property_to_update = {change_path: change['value']}
try:
jsonschema.validate(property_to_update,
validation_schemas.PKG_UPDATE_SCHEMA)
except jsonschema.ValidationError as e:
LOG.error(_LE("Schema validation error occurred: {error}")
.format(error=e))
raise webob.exc.HTTPBadRequest(explanation=e.message)
def _validate_schema(self, change):
property_to_update = change['value']
can_validate = True
schema = self.schema
for p in change['path']:
if schema['type'] == 'array':
try:
schema = schema['items']
except KeyError:
can_validate = False
elif schema['type'] == 'object':
try:
schema = schema['properties'][p]
except KeyError:
can_validate = False
if can_validate:
try:
jsonschema.validate(property_to_update, schema)
except jsonschema.ValidationError as e:
LOG.error(_LE("Schema validation error occurred: %s"), e)
raise webob.exc.HTTPBadRequest(explanation=e.message)
def _decode_json_pointer(self, pointer):
"""Parse a json pointer.
@ -972,13 +998,42 @@ class JSONPatchDeserializer(TextDeserializer):
path_list = self._decode_json_pointer(path)
return op, path_list
def _validate_path(self, path):
pass
def default(self, request):
return {'body': self._from_json_patch(request.body)}
class MuranoPackageJSONPatchDeserializer(JSONPatchDeserializer):
allowed_operations = {"categories": ["add", "replace", "remove"],
"tags": ["add", "replace", "remove"],
"is_public": ["replace"],
"enabled": ["replace"],
"description": ["replace"],
"name": ["replace"]}
allow_unknown_path = False
schema = validation_schemas.PKG_UPDATE_SCHEMA
def _validate_path(self, path):
if len(path) > 1:
msg = _('Nested paths are not allowed')
raise webob.exc.HTTPBadRequest(explanation=msg)
def default(self, request):
return {'body': self._from_json_patch(request.body)}
class EnvModelJSONPatchDeserializer(JSONPatchDeserializer):
allowed_operations = {"": [],
"defaultNetworks": ["replace"],
"defaultNetworks/environment": ["replace"],
"defaultNetworks/environment/?/id": [],
"defaultNetworks/flat": ["replace"],
"name": ["replace"],
"region": ["replace"],
"?/type": ["replace"],
"?/id": []
}
allow_unknown_path = True
schema = validation_schemas.ENV_SCHEMA
class XMLDeserializer(TextDeserializer):

View File

@ -584,3 +584,208 @@ class TestEnvironmentApi(tb.ControllerTest, tb.MuranoApiTestCase):
response_body = jsonutils.loads(request.get_response(self.api).body)
self.assertEqual(ENVIRONMENT_ID, response_body['id'])
def _create_env_and_session(self):
creds = {'tenant': 'test_tenant', 'user': 'test_user'}
self._set_policy_rules(
{'show_environment': '@',
'update_environment': '@'}
)
env_id = '123'
self._create_fake_environment(env_id=env_id)
# Create session
request = self._post('/environments/{environment_id}/configure'
.format(environment_id=env_id), b'',
**creds)
response_body = jsonutils.loads(request.get_response(self.api).body)
session_id = response_body['id']
return env_id, session_id
def test_get_and_update_environment_model(self):
"""Test GET and PATCH requests of an environment object model"""
env_id, session_id = self._create_env_and_session()
# Get entire env's model
self.expect_policy_check('show_environment',
{'environment_id': '123'})
req = self._get('/environments/{0}/model/'.format(env_id))
result = req.get_response(self.api)
self.assertEqual(200, result.status_code)
expected = {'?': {'id': '{0}'.format(env_id)}}
self.assertEqual(expected, jsonutils.loads(result.body))
# Add some data to the '?' section of env's model
self.expect_policy_check('update_environment',
{'environment_id': '123'})
data = [{
"op": "add",
"path": "/?/name",
"value": 'my_env'
}]
expected = {
'id': '{0}'.format(env_id),
'name': 'my_env'
}
req = self._patch('/environments/{0}/model/'.format(env_id),
jsonutils.dump_as_bytes(data),
content_type='application/env-model-json-patch')
req.headers['X-Configuration-Session'] = str(session_id)
req.context.session = session_id
result = req.get_response(self.api)
self.assertEqual(200, result.status_code)
observed = jsonutils.loads(result.body)['?']
self.assertEqual(expected, observed)
# Check that changes are stored in session
self.expect_policy_check('show_environment',
{'environment_id': '123'})
req = self._get('/environments/{0}/model/{1}'.format(
env_id, '/?'))
req.headers['X-Configuration-Session'] = str(session_id)
req.context.session = session_id
result = req.get_response(self.api)
self.assertEqual(200, result.status_code)
self.assertEqual(expected, jsonutils.loads(result.body))
# Check that actual model remains unchanged
self.expect_policy_check('show_environment',
{'environment_id': '123'})
req = self._get('/environments/{0}/model/{1}'.format(
env_id, '/?'))
result = req.get_response(self.api)
self.assertEqual(200, result.status_code)
expected = {'id': '{0}'.format(env_id)}
self.assertEqual(expected, jsonutils.loads(result.body))
def test_get_environment_model_non_existing_path(self):
env_id, session_id = self._create_env_and_session()
# Try to get non-existing section of env's model
self.expect_policy_check('show_environment',
{'environment_id': '123'})
path = 'foo/bar'
req = self._get('/environments/{0}/model/{1}'.format(
env_id, path))
result = req.get_response(self.api)
self.assertEqual(404, result.status_code)
def test_update_environment_model_empty_body(self):
env_id, session_id = self._create_env_and_session()
data = None
req = self._patch('/environments/{0}/model/'.format(env_id),
jsonutils.dump_as_bytes(data),
content_type='application/env-model-json-patch')
req.headers['X-Configuration-Session'] = str(session_id)
req.context.session = session_id
result = req.get_response(self.api)
self.assertEqual(400, result.status_code)
result_msg = result.text.replace('\n', '')
msg = "JSON-patch must be a list."
self.assertIn(msg, result_msg)
def test_update_environment_model_no_patch(self):
env_id, session_id = self._create_env_and_session()
data = ["foo"]
req = self._patch('/environments/{0}/model/'.format(env_id),
jsonutils.dump_as_bytes(data),
content_type='application/env-model-json-patch')
req.headers['X-Configuration-Session'] = str(session_id)
req.context.session = session_id
result = req.get_response(self.api)
self.assertEqual(400, result.status_code)
result_msg = result.text.replace('\n', '')
msg = "Operations must be JSON objects."
self.assertIn(msg, result_msg)
def test_update_environment_model_no_op(self):
env_id, session_id = self._create_env_and_session()
data = [{
"path": "/?/name",
"value": 'my_env'
}]
req = self._patch('/environments/{0}/model/'.format(env_id),
jsonutils.dump_as_bytes(data),
content_type='application/env-model-json-patch')
req.headers['X-Configuration-Session'] = str(session_id)
req.context.session = session_id
result = req.get_response(self.api)
self.assertEqual(400, result.status_code)
result_msg = result.text.replace('\n', '')
msg = "Unable to find 'op' in JSON Schema change"
self.assertIn(msg, result_msg)
def test_update_environment_model_no_path(self):
env_id, session_id = self._create_env_and_session()
data = [{
"op": "add",
"value": 'my_env'
}]
req = self._patch('/environments/{0}/model/'.format(env_id),
jsonutils.dump_as_bytes(data),
content_type='application/env-model-json-patch')
req.headers['X-Configuration-Session'] = str(session_id)
req.context.session = session_id
result = req.get_response(self.api)
self.assertEqual(400, result.status_code)
result_msg = result.text.replace('\n', '')
msg = "Unable to find 'path' in JSON Schema change"
self.assertIn(msg, result_msg)
def test_update_environment_model_no_value(self):
env_id, session_id = self._create_env_and_session()
data = [{
"op": "add",
"path": "/?/name"
}]
req = self._patch('/environments/{0}/model/'.format(env_id),
jsonutils.dump_as_bytes(data),
content_type='application/env-model-json-patch')
req.headers['X-Configuration-Session'] = str(session_id)
req.context.session = session_id
result = req.get_response(self.api)
self.assertEqual(400, result.status_code)
result_msg = result.text.replace('\n', '')
msg = 'Operation "add" requires a member named "value".'
self.assertIn(msg, result_msg)
def test_update_environment_model_forbidden_operation(self):
env_id, session_id = self._create_env_and_session()
data = [{
"op": "add",
"path": "/?/id",
"value": "foo"
}]
req = self._patch('/environments/{0}/model/'.format(env_id),
jsonutils.dump_as_bytes(data),
content_type='application/env-model-json-patch')
req.headers['X-Configuration-Session'] = str(session_id)
req.context.session = session_id
result = req.get_response(self.api)
self.assertEqual(403, result.status_code)
result_msg = result.text.replace('\n', '')
msg = ("Method 'add' is not allowed for a path with name '?/id'. "
"Allowed operations are: no operations")
self.assertIn(msg, result_msg)
def test_update_environment_model_invalid_schema(self):
env_id, session_id = self._create_env_and_session()
data = [{
"op": "add",
"path": "/?/name",
"value": 111
}]
req = self._patch('/environments/{0}/model/'.format(env_id),
jsonutils.dump_as_bytes(data),
content_type='application/env-model-json-patch')
req.headers['X-Configuration-Session'] = str(session_id)
req.context.session = session_id
result = req.get_response(self.api)
self.assertEqual(400, result.status_code)
result_msg = result.text.replace('\n', '')
msg = "111 is not of type 'string'"
self.assertIn(msg, result_msg)

View File

@ -155,6 +155,28 @@ class ApplicationCatalogClient(rest_client.RestClient):
self.expected_success(200, resp.status)
return self._parse_resp(body)
def get_environment_model(self, environment_id, path='/', session_id=None):
headers = self.get_headers()
if session_id:
headers.update(
{'X-Configuration-Session': session_id}
)
uri = '/v1/environments/{id}/model/{path}'.format(
id=environment_id, path=path)
resp, body = self.get(uri, headers=headers)
self.expected_success(200, resp.status)
return json.loads(body)
def update_environment_model(self, environment_id, data, session_id):
headers = self.get_headers(send_type='env-model-json-patch')
headers.update(
{'X-Configuration-Session': session_id}
)
uri = '/v1/environments/{id}/model/'.format(id=environment_id)
resp, body = self.patch(uri, json.dumps(data), headers=headers)
self.expected_success(200, resp.status)
return json.loads(body)
# -----------------------Methods for session manage ---------------------------
def create_session(self, environment_id):
body = None

View File

@ -84,3 +84,42 @@ class TestEnvironments(base.BaseApplicationCatalogTest):
environment = self.application_catalog_client.\
update_environment(self.environment['id'])
self.assertIsNot(self.environment['name'], environment['name'])
@testtools.testcase.attr('smoke')
def test_get_environment_model(self):
model = self.application_catalog_client.\
get_environment_model(self.environment['id'])
self.assertIsInstance(model, dict)
self.assertIn('defaultNetworks', model)
self.assertEqual(self.environment['name'], model['name'])
self.assertEqual(model['?']['type'], "io.murano.Environment")
net_name = self.application_catalog_client.\
get_environment_model(self.environment['id'],
path='/defaultNetworks/environment/name')
self.assertEqual("{0}-network".format(self.environment['name']),
net_name)
@testtools.testcase.attr('smoke')
def test_update_environment_model(self):
session = self.application_catalog_client. \
create_session(self.environment['id'])
patch = [{
"op": "replace",
"path": "/defaultNetworks/flat",
"value": True
}]
new_model = self.application_catalog_client. \
update_environment_model(self.environment['id'], patch,
session['id'])
self.assertTrue(new_model['defaultNetworks']['flat'])
value_draft = self.application_catalog_client. \
get_environment_model(self.environment['id'],
'/defaultNetworks/flat',
session['id'])
self.assertTrue(value_draft)
model_current = self.application_catalog_client. \
get_environment_model(self.environment['id'])
self.assertIsNone(model_current['defaultNetworks']['flat'])

View File

@ -0,0 +1,7 @@
---
features:
- /environments/ENV_ID/model/PATH endpoint added.
GET request responds with the subsection of ENV_ID's object model
located in its PATH.
PATCH request applies json-patch from request body to ENV_ID's model. It
does not contain PATH in the URL.