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_
- Security_
- Versioning_
- `Updating Resources`_
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
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
#####################
@ -343,7 +437,7 @@ Verb Path Response
GET /nodes List nodes.
GET /nodes/<id> Retrieve a specific 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
======= ============= ==========
@ -478,7 +572,7 @@ Verb Path Response
GET /chassis List chassis
GET /chassis/<id> Retrieve a specific 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
nodes
======= ============= ==========
@ -543,7 +637,7 @@ Verb Path Response
GET /ports List ports
GET /ports/<id> Retrieve a specific 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
======= ============= ==========

View File

@ -27,13 +27,6 @@ class APIBase(wtypes.Base):
if hasattr(self, k) and
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
def from_rpc_object(cls, m):
return cls(**m.as_dict())

View File

@ -16,6 +16,8 @@
# License for the specific language governing permissions and limitations
# under the License.
import jsonpatch
import pecan
from pecan import rest
@ -23,14 +25,13 @@ import wsme
from wsme import types as wtypes
import wsmeext.pecan as wsme_pecan
from ironic import objects
from ironic.api.controllers.v1 import base
from ironic.api.controllers.v1 import collection
from ironic.api.controllers.v1 import link
from ironic.api.controllers.v1 import node
from ironic.api.controllers.v1 import utils
from ironic.common import exception
from ironic import objects
from ironic.openstack.common import log
LOG = log.getLogger(__name__)
@ -144,21 +145,31 @@ class ChassisController(rest.RestController):
raise wsme.exc.ClientSideError(_("Invalid data"))
return Chassis.convert_with_links(new_chassis)
@wsme_pecan.wsexpose(Chassis, unicode, body=Chassis)
def patch(self, uuid, delta_chassis):
@wsme_pecan.wsexpose(Chassis, unicode, body=[unicode])
def patch(self, uuid, patch):
"""Update an existing chassis."""
chassis = objects.Chassis.get_by_uuid(pecan.request.context, uuid)
nn_delta_ch = delta_chassis.as_terse_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"))
chassis_dict = chassis.as_dict()
for k in nn_delta_ch:
chassis[k] = nn_delta_ch[k]
# These are internal values that shouldn't be part of the patch
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()
return Chassis.convert_with_links(chassis)
@wsme_pecan.wsexpose(None, unicode, status_code=204)

View File

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

View File

@ -15,6 +15,8 @@
# License for the specific language governing permissions and limitations
# under the License.
import jsonpatch
import pecan
from pecan import rest
@ -22,13 +24,12 @@ import wsme
from wsme import types as wtypes
import wsmeext.pecan as wsme_pecan
from ironic import objects
from ironic.api.controllers.v1 import base
from ironic.api.controllers.v1 import collection
from ironic.api.controllers.v1 import link
from ironic.api.controllers.v1 import utils
from ironic.common import exception
from ironic import objects
from ironic.openstack.common import log
LOG = log.getLogger(__name__)
@ -123,15 +124,30 @@ class PortsController(rest.RestController):
raise wsme.exc.ClientSideError(_("Invalid data"))
return Port.convert_with_links(new_port)
@wsme_pecan.wsexpose(Port, unicode, body=Port)
def patch(self, uuid, port_data):
@wsme_pecan.wsexpose(Port, unicode, body=[unicode])
def patch(self, uuid, patch):
"""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)
nn_delta_p = port_data.as_terse_dict()
for k in nn_delta_p:
port[k] = nn_delta_p[k]
port_dict = port.as_dict()
# 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()
return Port.convert_with_links(port)

View File

@ -16,6 +16,7 @@
# License for the specific language governing permissions and limitations
# under the License.
import re
import wsme
from oslo.config import cfg
@ -36,3 +37,35 @@ def validate_sort_dir(sort_dir):
"Acceptable values are "
"'asc' or 'desc'") % 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
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):
"""Mixin class for lists of objects.

View File

@ -100,26 +100,114 @@ class TestListChassis(base.FunctionalTest):
class TestPatch(base.FunctionalTest):
def test_update_chassis(self):
def setUp(self):
super(TestPatch, self).setUp()
cdict = dbutils.get_test_chassis()
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):
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)
# TODO(yuriyz): change to 404 (bug 1200517)
self.assertEqual(response.status_int, 500)
self.assertEqual(response.content_type, 'application/json')
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):

View File

@ -152,7 +152,9 @@ class TestPatch(base.FunctionalTest):
self.mox.ReplayAll()
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.status_code, 200)
self.mox.VerifyAll()
@ -169,8 +171,13 @@ class TestPatch(base.FunctionalTest):
self.mox.ReplayAll()
response = self.patch_json('/nodes/%s' % self.node['uuid'],
{'driver_info': {'this': 'foo', 'that': 'bar'}},
expect_errors=True)
[{'path': '/driver_info/this',
'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.status_code, 400)
self.mox.VerifyAll()
@ -183,13 +190,50 @@ class TestPatch(base.FunctionalTest):
self.mox.ReplayAll()
response = self.patch_json('/nodes/%s' % self.node['uuid'],
{'instance_uuid': 'fake instance uuid'},
expect_errors=True)
[{'path': '/instance_uuid',
'value': 'fake instance uuid',
'op': 'replace'}],
expect_errors=True)
self.assertEqual(response.content_type, 'application/json')
# TODO(deva): change to 409 when wsme 0.5b3 released
self.assertEqual(response.status_code, 400)
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):

View File

@ -82,7 +82,9 @@ class TestPatch(base.FunctionalTest):
pdict = dbutils.get_test_port()
extra = {'foo': 'bar'}
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.status_code, 200)
result = self.get_json('/ports/%s' % pdict['uuid'])
@ -92,7 +94,9 @@ class TestPatch(base.FunctionalTest):
pdict = dbutils.get_test_port()
extra = {'foo': 'bar'}
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.status_code, 200)
result = self.get_json('/ports/%s' % pdict['uuid'])
@ -100,13 +104,109 @@ class TestPatch(base.FunctionalTest):
def test_update_not_found(self):
uuid = uuidutils.generate_uuid()
response = self.patch_json('/ports/%s' % uuid, {'extra': {'a': 'b'}},
expect_errors=True)
response = self.patch_json('/ports/%s' % uuid,
[{'path': '/extra/a',
'value': 'b',
'op': 'add'}],
expect_errors=True)
# TODO(yuriyz): change to 404 (bug 1200517)
self.assertEqual(response.status_int, 500)
self.assertEqual(response.content_type, 'application/json')
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):

View File

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