Merge "Updating resources with PATCH"

This commit is contained in:
Jenkins 2013-09-09 16:57:31 +00:00 committed by Gerrit Code Review
commit b21843f038
11 changed files with 470 additions and 69 deletions

View File

@ -21,6 +21,7 @@ General Concepts
- SubResource_ - SubResource_
- Security_ - Security_
- Versioning_ - Versioning_
- `Updating Resources`_
Links and Relationships Links and Relationships
------------------------ ------------------------
@ -141,6 +142,99 @@ with a particular version will result the API will assume the use of the
default version. When both URL version and MIME type are specified and default version. When both URL version and MIME type are specified and
conflicting the URL version takes precedence. conflicting the URL version takes precedence.
Updating Resources
-------------------
The PATCH HTTP method is used to update a resource in the API. PATCH
allows clients to do partial updates to a resource, sending only the
attributes requiring modification. Operations supported are "remove",
"add" and "replace", multiple operations can be combined in a single
request.
The request body must conform to the 'application/json-patch+json'
media type (RFC 6902) and response body will represent the updated
resource entity.
Example::
PATCH /chassis/4505e16b-47d6-424c-ae78-e0ef1b600700
[
{"path": "/description", "value": "new description", "op": "replace"},
{"path": "/extra/foo", "value": "bar", "op": "add"},
{"path": "/extra/noop", "op": "remove"}
]
Different types of attributes that exists in the resource will be either
removed, added or replaced according to the following rules:
Singular attributes
^^^^^^^^^^^^^^^^^^^^
An "add" or "replace" operation replaces the value of an existing
attribute with a new value. Adding new attributes to the root document
of the resource is not allowed.
The "remove" operation resets the target attribute to its default value.
Example, replacing an attribute::
PATCH /chassis/4505e16b-47d6-424c-ae78-e0ef1b600700
[
{"path": "/description", "value": "new description", "op": "replace"}
]
Example, removing an attribute::
PATCH /chassis/4505e16b-47d6-424c-ae78-e0ef1b600700
[
{"path": "/description", "op": "remove"}
]
*Note: This operation will not remove the description attribute from
the document but instead will reset it to its default value.*
Multi-valued attributes
^^^^^^^^^^^^^^^^^^^^^^^^
In case of an "add" operation the attribute is added to the collection
if the it does not exist and merged if a matching attribute is present.
The "remove" operation removes the target attribute from the collection.
The "replace" operation replaces the value at the target attribute with
a new value.
Example, adding an attribute to the collection::
PATCH /chassis/4505e16b-47d6-424c-ae78-e0ef1b600700
[
{"path": "/extra/foo", "value": "bar", "op": "add"}
]
Example, removing an attribute from the collection::
PATCH /chassis/4505e16b-47d6-424c-ae78-e0ef1b600700
[
{"path": "/extra/foo", "op": "remove"}
]
Example, removing **all** attributes from the collection::
PATCH /chassis/4505e16b-47d6-424c-ae78-e0ef1b600700
[
{"path": "/extra", "op": "remove"}
]
Resource Definitions Resource Definitions
##################### #####################
@ -343,7 +437,7 @@ Verb Path Response
GET /nodes List nodes. GET /nodes List nodes.
GET /nodes/<id> Retrieve a specific node. GET /nodes/<id> Retrieve a specific node.
POST /nodes Create a new node POST /nodes Create a new node
PUT /nodes/<id> Update a node PATCH /nodes/<id> Update a node
DELETE /nodes/<id> Delete node and all associated ports DELETE /nodes/<id> Delete node and all associated ports
======= ============= ========== ======= ============= ==========
@ -478,7 +572,7 @@ Verb Path Response
GET /chassis List chassis GET /chassis List chassis
GET /chassis/<id> Retrieve a specific chassis GET /chassis/<id> Retrieve a specific chassis
POST /chassis Create a new chassis POST /chassis Create a new chassis
PUT /chassis/<id> Update a chassis PATCH /chassis/<id> Update a chassis
DELETE /chassis/<id> Delete chassis and remove all associations between DELETE /chassis/<id> Delete chassis and remove all associations between
nodes nodes
======= ============= ========== ======= ============= ==========
@ -543,7 +637,7 @@ Verb Path Response
GET /ports List ports GET /ports List ports
GET /ports/<id> Retrieve a specific port GET /ports/<id> Retrieve a specific port
POST /ports Create a new port POST /ports Create a new port
PUT /ports/<id> Update a port PATCH /ports/<id> Update a port
DELETE /ports/<id> Delete port and remove all associations between nodes DELETE /ports/<id> Delete port and remove all associations between nodes
======= ============= ========== ======= ============= ==========

View File

@ -27,13 +27,6 @@ class APIBase(wtypes.Base):
if hasattr(self, k) and if hasattr(self, k) and
getattr(self, k) != wsme.Unset) getattr(self, k) != wsme.Unset)
def as_terse_dict(self):
"""Render this object as a dict of its non-None fields."""
return dict((k, getattr(self, k))
for k in self.fields
if hasattr(self, k) and
getattr(self, k) not in [wsme.Unset, None])
@classmethod @classmethod
def from_rpc_object(cls, m): def from_rpc_object(cls, m):
return cls(**m.as_dict()) return cls(**m.as_dict())

View File

@ -16,6 +16,8 @@
# License for the specific language governing permissions and limitations # License for the specific language governing permissions and limitations
# under the License. # under the License.
import jsonpatch
import pecan import pecan
from pecan import rest from pecan import rest
@ -23,14 +25,13 @@ import wsme
from wsme import types as wtypes from wsme import types as wtypes
import wsmeext.pecan as wsme_pecan import wsmeext.pecan as wsme_pecan
from ironic import objects
from ironic.api.controllers.v1 import base from ironic.api.controllers.v1 import base
from ironic.api.controllers.v1 import collection from ironic.api.controllers.v1 import collection
from ironic.api.controllers.v1 import link from ironic.api.controllers.v1 import link
from ironic.api.controllers.v1 import node from ironic.api.controllers.v1 import node
from ironic.api.controllers.v1 import utils from ironic.api.controllers.v1 import utils
from ironic.common import exception from ironic.common import exception
from ironic import objects
from ironic.openstack.common import log from ironic.openstack.common import log
LOG = log.getLogger(__name__) LOG = log.getLogger(__name__)
@ -144,21 +145,31 @@ class ChassisController(rest.RestController):
raise wsme.exc.ClientSideError(_("Invalid data")) raise wsme.exc.ClientSideError(_("Invalid data"))
return Chassis.convert_with_links(new_chassis) return Chassis.convert_with_links(new_chassis)
@wsme_pecan.wsexpose(Chassis, unicode, body=Chassis) @wsme_pecan.wsexpose(Chassis, unicode, body=[unicode])
def patch(self, uuid, delta_chassis): def patch(self, uuid, patch):
"""Update an existing chassis.""" """Update an existing chassis."""
chassis = objects.Chassis.get_by_uuid(pecan.request.context, uuid) chassis = objects.Chassis.get_by_uuid(pecan.request.context, uuid)
nn_delta_ch = delta_chassis.as_terse_dict() chassis_dict = chassis.as_dict()
# Ensure immutable keys are not present
# TODO(lucasagomes): Not sure if 'id' will ever be present here
# the translation should occur before it reaches this point
if any(v for v in nn_delta_ch if v in ("id", "uuid")):
raise wsme.exc.ClientSideError(_("'uuid' is immutable"))
for k in nn_delta_ch: # These are internal values that shouldn't be part of the patch
chassis[k] = nn_delta_ch[k] internal_attrs = ['id', 'updated_at', 'created_at']
[chassis_dict.pop(attr, None) for attr in internal_attrs]
utils.validate_patch(patch)
try:
final_patch = jsonpatch.apply_patch(chassis_dict,
jsonpatch.JsonPatch(patch))
except jsonpatch.JsonPatchException as e:
LOG.exception(e)
raise wsme.exc.ClientSideError(_("Patching Error: %s") % e)
# In case of a remove operation, add the missing fields back to
# the document with their default value
defaults = objects.Chassis.get_defaults()
defaults.update(final_patch)
chassis.update(defaults)
chassis.save() chassis.save()
return Chassis.convert_with_links(chassis) return Chassis.convert_with_links(chassis)
@wsme_pecan.wsexpose(None, unicode, status_code=204) @wsme_pecan.wsexpose(None, unicode, status_code=204)

View File

@ -15,6 +15,8 @@
# License for the specific language governing permissions and limitations # License for the specific language governing permissions and limitations
# under the License. # under the License.
import jsonpatch
import pecan import pecan
from pecan import rest from pecan import rest
@ -22,8 +24,6 @@ import wsme
from wsme import types as wtypes from wsme import types as wtypes
import wsmeext.pecan as wsme_pecan import wsmeext.pecan as wsme_pecan
from ironic import objects
from ironic.api.controllers.v1 import base from ironic.api.controllers.v1 import base
from ironic.api.controllers.v1 import collection from ironic.api.controllers.v1 import collection
from ironic.api.controllers.v1 import link from ironic.api.controllers.v1 import link
@ -31,6 +31,7 @@ from ironic.api.controllers.v1 import port
from ironic.api.controllers.v1 import state from ironic.api.controllers.v1 import state
from ironic.api.controllers.v1 import utils from ironic.api.controllers.v1 import utils
from ironic.common import exception from ironic.common import exception
from ironic import objects
from ironic.openstack.common import log from ironic.openstack.common import log
LOG = log.getLogger(__name__) LOG = log.getLogger(__name__)
@ -300,34 +301,46 @@ class NodesController(rest.RestController):
raise wsme.exc.ClientSideError(_("Invalid data")) raise wsme.exc.ClientSideError(_("Invalid data"))
return Node.convert_with_links(new_node) return Node.convert_with_links(new_node)
@wsme_pecan.wsexpose(Node, unicode, body=Node, status=200) @wsme_pecan.wsexpose(Node, unicode, body=[unicode])
def patch(self, node_id, node_data): def patch(self, uuid, patch):
"""Update an existing node. """Update an existing node.
TODO(deva): add exception handling TODO(deva): add exception handling
""" """
# NOTE: WSME is creating an api v1 Node object with all fields node = objects.Node.get_by_uuid(pecan.request.context, uuid)
# so we eliminate non-supplied fields by converting node_dict = node.as_dict()
# to a dict and stripping keys with value=None
delta = node_data.as_terse_dict() # These are internal values that shouldn't be part of the patch
internal_attrs = ['id', 'updated_at', 'created_at']
[node_dict.pop(attr, None) for attr in internal_attrs]
utils.validate_patch(patch)
patch_obj = jsonpatch.JsonPatch(patch)
# Prevent states from being updated # Prevent states from being updated
state_rel_attr = ['power_state', 'target_power_state', state_rel_path = ['/power_state', '/target_power_state',
'provision_state', 'target_provision_state'] '/provision_state', '/target_provision_state']
if any((getattr(node_data, attr) for attr in state_rel_attr)): if any(p['path'] in state_rel_path for p in patch_obj):
raise wsme.exc.ClientSideError(_("Changing states is not allowed " raise wsme.exc.ClientSideError(_("Changing states is not allowed "
"here; You must use the " "here; You must use the "
"nodes/%s/state interface.") "nodes/%s/state interface.")
% node_id) % uuid)
try:
final_patch = jsonpatch.apply_patch(node_dict, patch_obj)
except jsonpatch.JsonPatchException as e:
LOG.exception(e)
raise wsme.exc.ClientSideError(_("Patching Error: %s") % e)
response = wsme.api.Response(Node(), status_code=200) response = wsme.api.Response(Node(), status_code=200)
try: try:
node = objects.Node.get_by_uuid( # In case of a remove operation, add the missing fields back to
pecan.request.context, node_id) # the document with their default value
for k in delta.keys(): defaults = objects.Node.get_defaults()
node[k] = delta[k] defaults.update(final_patch)
node = pecan.request.rpcapi.update_node(
pecan.request.context, node) node.update(defaults)
node = pecan.request.rpcapi.update_node(pecan.request.context,
node)
response.obj = node response.obj = node
except exception.InvalidParameterValue: except exception.InvalidParameterValue:
response.status_code = 400 response.status_code = 400
@ -341,7 +354,8 @@ class NodesController(rest.RestController):
# after wsme 0.5b3 is released # after wsme 0.5b3 is released
if response.status_code not in [200, 202]: if response.status_code not in [200, 202]:
raise wsme.exc.ClientSideError(_( raise wsme.exc.ClientSideError(_(
"Error updating node %s") % node_id) "Error updating node %s") % uuid)
return Node.convert_with_links(response.obj) return Node.convert_with_links(response.obj)
@wsme_pecan.wsexpose(None, unicode, status_code=204) @wsme_pecan.wsexpose(None, unicode, status_code=204)

View File

@ -15,6 +15,8 @@
# License for the specific language governing permissions and limitations # License for the specific language governing permissions and limitations
# under the License. # under the License.
import jsonpatch
import pecan import pecan
from pecan import rest from pecan import rest
@ -22,13 +24,12 @@ import wsme
from wsme import types as wtypes from wsme import types as wtypes
import wsmeext.pecan as wsme_pecan import wsmeext.pecan as wsme_pecan
from ironic import objects
from ironic.api.controllers.v1 import base from ironic.api.controllers.v1 import base
from ironic.api.controllers.v1 import collection from ironic.api.controllers.v1 import collection
from ironic.api.controllers.v1 import link from ironic.api.controllers.v1 import link
from ironic.api.controllers.v1 import utils from ironic.api.controllers.v1 import utils
from ironic.common import exception from ironic.common import exception
from ironic import objects
from ironic.openstack.common import log from ironic.openstack.common import log
LOG = log.getLogger(__name__) LOG = log.getLogger(__name__)
@ -123,15 +124,30 @@ class PortsController(rest.RestController):
raise wsme.exc.ClientSideError(_("Invalid data")) raise wsme.exc.ClientSideError(_("Invalid data"))
return Port.convert_with_links(new_port) return Port.convert_with_links(new_port)
@wsme_pecan.wsexpose(Port, unicode, body=Port) @wsme_pecan.wsexpose(Port, unicode, body=[unicode])
def patch(self, uuid, port_data): def patch(self, uuid, patch):
"""Update an existing port.""" """Update an existing port."""
# TODO(wentian): add rpc handle,
# eg. if update fails because node is already locked
port = objects.Port.get_by_uuid(pecan.request.context, uuid) port = objects.Port.get_by_uuid(pecan.request.context, uuid)
nn_delta_p = port_data.as_terse_dict() port_dict = port.as_dict()
for k in nn_delta_p:
port[k] = nn_delta_p[k] # These are internal values that shouldn't be part of the patch
internal_attrs = ['id', 'updated_at', 'created_at']
[port_dict.pop(attr, None) for attr in internal_attrs]
utils.validate_patch(patch)
try:
final_patch = jsonpatch.apply_patch(port_dict,
jsonpatch.JsonPatch(patch))
except jsonpatch.JsonPatchException as e:
LOG.exception(e)
raise wsme.exc.ClientSideError(_("Patching Error: %s") % e)
# In case of a remove operation, add the missing fields back to
# the document with their default value
defaults = objects.Port.get_defaults()
defaults.update(final_patch)
port.update(defaults)
port.save() port.save()
return Port.convert_with_links(port) return Port.convert_with_links(port)

View File

@ -16,6 +16,7 @@
# License for the specific language governing permissions and limitations # License for the specific language governing permissions and limitations
# under the License. # under the License.
import re
import wsme import wsme
from oslo.config import cfg from oslo.config import cfg
@ -36,3 +37,35 @@ def validate_sort_dir(sort_dir):
"Acceptable values are " "Acceptable values are "
"'asc' or 'desc'") % sort_dir) "'asc' or 'desc'") % sort_dir)
return sort_dir return sort_dir
def validate_patch(patch):
"""Performs a basic validation on patch."""
if not isinstance(patch, list):
patch = [patch]
for p in patch:
path_pattern = re.compile("^/[a-zA-Z0-9-_]+(/[a-zA-Z0-9-_]+)*$")
if not isinstance(p, dict) or \
any(key for key in ["path", "op"] if key not in p):
raise wsme.exc.ClientSideError(_("Invalid patch format: %s")
% str(p))
path = p["path"]
op = p["op"]
if op not in ["add", "replace", "remove"]:
raise wsme.exc.ClientSideError(_("Operation not supported: %s")
% op)
if not path_pattern.match(path):
raise wsme.exc.ClientSideError(_("Invalid path: %s") % path)
if op == "add":
if path.count('/') == 1:
raise wsme.exc.ClientSideError(_("Adding an additional "
"attribute (%s) to the "
"resource is not allowed")
% path)

View File

@ -375,6 +375,13 @@ class IronicObject(object):
for k in self.fields for k in self.fields
if hasattr(self, k)) if hasattr(self, k))
@classmethod
def get_defaults(cls):
"""Return a dict of its fields with their default value."""
return dict((k, v(None))
for k, v in cls.fields.iteritems()
if k != "id" and callable(v))
class ObjectListBase(object): class ObjectListBase(object):
"""Mixin class for lists of objects. """Mixin class for lists of objects.

View File

@ -100,26 +100,114 @@ class TestListChassis(base.FunctionalTest):
class TestPatch(base.FunctionalTest): class TestPatch(base.FunctionalTest):
def test_update_chassis(self): def setUp(self):
super(TestPatch, self).setUp()
cdict = dbutils.get_test_chassis() cdict = dbutils.get_test_chassis()
self.post_json('/chassis', cdict) self.post_json('/chassis', cdict)
description = 'chassis-new-description'
response = self.patch_json('/chassis/%s' % cdict['uuid'],
{'description': description})
self.assertEqual(response.content_type, 'application/json')
self.assertEqual(response.status_code, 200)
result = self.get_json('/chassis/%s' % cdict['uuid'])
self.assertEqual(result['description'], description)
def test_update_not_found(self): def test_update_not_found(self):
uuid = uuidutils.generate_uuid() uuid = uuidutils.generate_uuid()
response = self.patch_json('/chassis/%s' % uuid, {'extra': {'a': 'b'}}, response = self.patch_json('/chassis/%s' % uuid,
[{'path': '/extra/a', 'value': 'b',
'op': 'add'}],
expect_errors=True) expect_errors=True)
# TODO(yuriyz): change to 404 (bug 1200517) # TODO(yuriyz): change to 404 (bug 1200517)
self.assertEqual(response.status_int, 500) self.assertEqual(response.status_int, 500)
self.assertEqual(response.content_type, 'application/json') self.assertEqual(response.content_type, 'application/json')
self.assertTrue(response.json['error_message']) self.assertTrue(response.json['error_message'])
def test_replace_singular(self):
cdict = dbutils.get_test_chassis()
description = 'chassis-new-description'
response = self.patch_json('/chassis/%s' % cdict['uuid'],
[{'path': '/description',
'value': description, 'op': 'replace'}])
self.assertEqual(response.content_type, 'application/json')
self.assertEqual(response.status_code, 200)
result = self.get_json('/chassis/%s' % cdict['uuid'])
self.assertEqual(result['description'], description)
def test_replace_multi(self):
extra = {"foo1": "bar1", "foo2": "bar2", "foo3": "bar3"}
cdict = dbutils.get_test_chassis(extra=extra,
uuid=uuidutils.generate_uuid())
self.post_json('/chassis', cdict)
new_value = 'new value'
response = self.patch_json('/chassis/%s' % cdict['uuid'],
[{'path': '/extra/foo2',
'value': new_value, 'op': 'replace'}])
self.assertEqual(response.content_type, 'application/json')
self.assertEqual(response.status_code, 200)
result = self.get_json('/chassis/%s' % cdict['uuid'])
extra["foo2"] = new_value
self.assertEqual(result['extra'], extra)
def test_remove_singular(self):
cdict = dbutils.get_test_chassis(extra={'a': 'b'},
uuid=uuidutils.generate_uuid())
self.post_json('/chassis', cdict)
response = self.patch_json('/chassis/%s' % cdict['uuid'],
[{'path': '/description', 'op': 'remove'}])
self.assertEqual(response.content_type, 'application/json')
self.assertEqual(response.status_code, 200)
result = self.get_json('/chassis/%s' % cdict['uuid'])
self.assertEqual(result['description'], None)
# Assert nothing else was changed
self.assertEqual(result['uuid'], cdict['uuid'])
self.assertEqual(result['extra'], cdict['extra'])
def test_remove_multi(self):
extra = {"foo1": "bar1", "foo2": "bar2", "foo3": "bar3"}
cdict = dbutils.get_test_chassis(extra=extra, description="foobar",
uuid=uuidutils.generate_uuid())
self.post_json('/chassis', cdict)
# Removing one item from the collection
response = self.patch_json('/chassis/%s' % cdict['uuid'],
[{'path': '/extra/foo2', 'op': 'remove'}])
self.assertEqual(response.content_type, 'application/json')
self.assertEqual(response.status_code, 200)
result = self.get_json('/chassis/%s' % cdict['uuid'])
extra.pop("foo2")
self.assertEqual(result['extra'], extra)
# Removing the collection
response = self.patch_json('/chassis/%s' % cdict['uuid'],
[{'path': '/extra', 'op': 'remove'}])
self.assertEqual(response.content_type, 'application/json')
self.assertEqual(response.status_code, 200)
result = self.get_json('/chassis/%s' % cdict['uuid'])
self.assertEqual(result['extra'], {})
# Assert nothing else was changed
self.assertEqual(result['uuid'], cdict['uuid'])
self.assertEqual(result['description'], cdict['description'])
def test_add_singular(self):
cdict = dbutils.get_test_chassis()
response = self.patch_json('/chassis/%s' % cdict['uuid'],
[{'path': '/foo', 'value': 'bar',
'op': 'add'}],
expect_errors=True)
self.assertEqual(response.content_type, 'application/json')
self.assertEqual(response.status_int, 400)
self.assertTrue(response.json['error_message'])
def test_add_multi(self):
cdict = dbutils.get_test_chassis()
response = self.patch_json('/chassis/%s' % cdict['uuid'],
[{'path': '/extra/foo1', 'value': 'bar1',
'op': 'add'},
{'path': '/extra/foo2', 'value': 'bar2',
'op': 'add'}])
self.assertEqual(response.content_type, 'application/json')
self.assertEqual(response.status_code, 200)
result = self.get_json('/chassis/%s' % cdict['uuid'])
expected = {"foo1": "bar1", "foo2": "bar2"}
self.assertEqual(result['extra'], expected)
class TestPost(base.FunctionalTest): class TestPost(base.FunctionalTest):

View File

@ -152,7 +152,9 @@ class TestPatch(base.FunctionalTest):
self.mox.ReplayAll() self.mox.ReplayAll()
response = self.patch_json('/nodes/%s' % self.node['uuid'], response = self.patch_json('/nodes/%s' % self.node['uuid'],
{'instance_uuid': 'fake instance uuid'}) [{'path': '/instance_uuid',
'value': 'fake instance uuid',
'op': 'replace'}])
self.assertEqual(response.content_type, 'application/json') self.assertEqual(response.content_type, 'application/json')
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.mox.VerifyAll() self.mox.VerifyAll()
@ -169,8 +171,13 @@ class TestPatch(base.FunctionalTest):
self.mox.ReplayAll() self.mox.ReplayAll()
response = self.patch_json('/nodes/%s' % self.node['uuid'], response = self.patch_json('/nodes/%s' % self.node['uuid'],
{'driver_info': {'this': 'foo', 'that': 'bar'}}, [{'path': '/driver_info/this',
expect_errors=True) 'value': 'foo',
'op': 'add'},
{'path': '/driver_info/that',
'value': 'bar',
'op': 'add'}],
expect_errors=True)
self.assertEqual(response.content_type, 'application/json') self.assertEqual(response.content_type, 'application/json')
self.assertEqual(response.status_code, 400) self.assertEqual(response.status_code, 400)
self.mox.VerifyAll() self.mox.VerifyAll()
@ -183,13 +190,50 @@ class TestPatch(base.FunctionalTest):
self.mox.ReplayAll() self.mox.ReplayAll()
response = self.patch_json('/nodes/%s' % self.node['uuid'], response = self.patch_json('/nodes/%s' % self.node['uuid'],
{'instance_uuid': 'fake instance uuid'}, [{'path': '/instance_uuid',
expect_errors=True) 'value': 'fake instance uuid',
'op': 'replace'}],
expect_errors=True)
self.assertEqual(response.content_type, 'application/json') self.assertEqual(response.content_type, 'application/json')
# TODO(deva): change to 409 when wsme 0.5b3 released # TODO(deva): change to 409 when wsme 0.5b3 released
self.assertEqual(response.status_code, 400) self.assertEqual(response.status_code, 400)
self.mox.VerifyAll() self.mox.VerifyAll()
def test_add_ok(self):
rpcapi.ConductorAPI.update_node(mox.IgnoreArg(), mox.IgnoreArg()).\
AndReturn(self.node)
self.mox.ReplayAll()
response = self.patch_json('/nodes/%s' % self.node['uuid'],
[{'path': '/extra/foo',
'value': 'bar',
'op': 'add'}])
self.assertEqual(response.content_type, 'application/json')
self.assertEqual(response.status_code, 200)
self.mox.VerifyAll()
def test_add_fail(self):
self.assertRaises(webtest.app.AppError, self.patch_json,
'/nodes/%s' % self.node['uuid'],
[{'path': '/foo', 'value': 'bar', 'op': 'add'}])
def test_remove_ok(self):
rpcapi.ConductorAPI.update_node(mox.IgnoreArg(), mox.IgnoreArg()).\
AndReturn(self.node)
self.mox.ReplayAll()
response = self.patch_json('/nodes/%s' % self.node['uuid'],
[{'path': '/extra',
'op': 'remove'}])
self.assertEqual(response.content_type, 'application/json')
self.assertEqual(response.status_code, 200)
self.mox.VerifyAll()
def test_remove_fail(self):
self.assertRaises(webtest.app.AppError, self.patch_json,
'/nodes/%s' % self.node['uuid'],
[{'path': '/extra/non-existent', 'op': 'remove'}])
class TestPost(base.FunctionalTest): class TestPost(base.FunctionalTest):

View File

@ -82,7 +82,9 @@ class TestPatch(base.FunctionalTest):
pdict = dbutils.get_test_port() pdict = dbutils.get_test_port()
extra = {'foo': 'bar'} extra = {'foo': 'bar'}
response = self.patch_json('/ports/%s' % pdict['uuid'], response = self.patch_json('/ports/%s' % pdict['uuid'],
{'extra': extra}) [{'path': '/extra/foo',
'value': 'bar',
'op': 'add'}])
self.assertEqual(response.content_type, 'application/json') self.assertEqual(response.content_type, 'application/json')
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
result = self.get_json('/ports/%s' % pdict['uuid']) result = self.get_json('/ports/%s' % pdict['uuid'])
@ -92,7 +94,9 @@ class TestPatch(base.FunctionalTest):
pdict = dbutils.get_test_port() pdict = dbutils.get_test_port()
extra = {'foo': 'bar'} extra = {'foo': 'bar'}
response = self.patch_json('/ports/%s' % pdict['address'], response = self.patch_json('/ports/%s' % pdict['address'],
{'extra': extra}) [{'path': '/extra/foo',
'value': 'bar',
'op': 'add'}])
self.assertEqual(response.content_type, 'application/json') self.assertEqual(response.content_type, 'application/json')
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
result = self.get_json('/ports/%s' % pdict['uuid']) result = self.get_json('/ports/%s' % pdict['uuid'])
@ -100,13 +104,109 @@ class TestPatch(base.FunctionalTest):
def test_update_not_found(self): def test_update_not_found(self):
uuid = uuidutils.generate_uuid() uuid = uuidutils.generate_uuid()
response = self.patch_json('/ports/%s' % uuid, {'extra': {'a': 'b'}}, response = self.patch_json('/ports/%s' % uuid,
expect_errors=True) [{'path': '/extra/a',
'value': 'b',
'op': 'add'}],
expect_errors=True)
# TODO(yuriyz): change to 404 (bug 1200517) # TODO(yuriyz): change to 404 (bug 1200517)
self.assertEqual(response.status_int, 500) self.assertEqual(response.status_int, 500)
self.assertEqual(response.content_type, 'application/json') self.assertEqual(response.content_type, 'application/json')
self.assertTrue(response.json['error_message']) self.assertTrue(response.json['error_message'])
def test_replace_singular(self):
pdict = dbutils.get_test_port()
address = 'AA:BB:CC:DD:EE:FF'
response = self.patch_json('/ports/%s' % pdict['uuid'],
[{'path': '/address',
'value': address, 'op': 'replace'}])
self.assertEqual(response.content_type, 'application/json')
self.assertEqual(response.status_code, 200)
result = self.get_json('/ports/%s' % pdict['uuid'])
self.assertEqual(result['address'], address)
def test_replace_multi(self):
extra = {"foo1": "bar1", "foo2": "bar2", "foo3": "bar3"}
pdict = dbutils.get_test_port(extra=extra,
uuid=uuidutils.generate_uuid())
self.post_json('/ports', pdict)
new_value = 'new value'
response = self.patch_json('/ports/%s' % pdict['uuid'],
[{'path': '/extra/foo2',
'value': new_value, 'op': 'replace'}])
self.assertEqual(response.content_type, 'application/json')
self.assertEqual(response.status_code, 200)
result = self.get_json('/ports/%s' % pdict['uuid'])
extra["foo2"] = new_value
self.assertEqual(result['extra'], extra)
def test_remove_singular(self):
pdict = dbutils.get_test_port(extra={'a': 'b'},
uuid=uuidutils.generate_uuid())
self.post_json('/ports', pdict)
response = self.patch_json('/ports/%s' % pdict['uuid'],
[{'path': '/address', 'op': 'remove'}])
self.assertEqual(response.content_type, 'application/json')
self.assertEqual(response.status_code, 200)
result = self.get_json('/ports/%s' % pdict['uuid'])
self.assertEqual(result['address'], None)
# Assert nothing else was changed
self.assertEqual(result['uuid'], pdict['uuid'])
self.assertEqual(result['extra'], pdict['extra'])
def test_remove_multi(self):
extra = {"foo1": "bar1", "foo2": "bar2", "foo3": "bar3"}
pdict = dbutils.get_test_port(extra=extra,
address="AA:BB:CC:DD:EE:FF",
uuid=uuidutils.generate_uuid())
self.post_json('/ports', pdict)
# Removing one item from the collection
response = self.patch_json('/ports/%s' % pdict['uuid'],
[{'path': '/extra/foo2', 'op': 'remove'}])
self.assertEqual(response.content_type, 'application/json')
self.assertEqual(response.status_code, 200)
result = self.get_json('/ports/%s' % pdict['uuid'])
extra.pop("foo2")
self.assertEqual(result['extra'], extra)
# Removing the collection
response = self.patch_json('/ports/%s' % pdict['uuid'],
[{'path': '/extra', 'op': 'remove'}])
self.assertEqual(response.content_type, 'application/json')
self.assertEqual(response.status_code, 200)
result = self.get_json('/ports/%s' % pdict['uuid'])
self.assertEqual(result['extra'], {})
# Assert nothing else was changed
self.assertEqual(result['uuid'], pdict['uuid'])
self.assertEqual(result['address'], pdict['address'])
def test_add_singular(self):
pdict = dbutils.get_test_port()
response = self.patch_json('/ports/%s' % pdict['uuid'],
[{'path': '/foo', 'value': 'bar',
'op': 'add'}],
expect_errors=True)
self.assertEqual(response.content_type, 'application/json')
self.assertEqual(response.status_int, 400)
self.assertTrue(response.json['error_message'])
def test_add_multi(self):
pdict = dbutils.get_test_port()
response = self.patch_json('/ports/%s' % pdict['uuid'],
[{'path': '/extra/foo1', 'value': 'bar1',
'op': 'add'},
{'path': '/extra/foo2', 'value': 'bar2',
'op': 'add'}])
self.assertEqual(response.content_type, 'application/json')
self.assertEqual(response.status_code, 200)
result = self.get_json('/ports/%s' % pdict['uuid'])
expected = {"foo1": "bar1", "foo2": "bar2"}
self.assertEqual(result['extra'], expected)
class TestPost(base.FunctionalTest): class TestPost(base.FunctionalTest):

View File

@ -22,5 +22,6 @@ websockify>=0.5.1,<0.6
oslo.config>=1.1.0 oslo.config>=1.1.0
pecan>=0.2.0 pecan>=0.2.0
six<1.4.0 six<1.4.0
jsonpatch>=1.1
WSME>=0.5b2 WSME>=0.5b2
Cheetah>=2.4.4 Cheetah>=2.4.4