From 3dd85586b6f45006d2c30b3335933fc8757da8a5 Mon Sep 17 00:00:00 2001 From: Lucas Alvares Gomes Date: Mon, 23 Sep 2013 17:30:59 +0100 Subject: [PATCH] API GET to return only minimal data Requests to list top-level resources like nodes, chassis or ports will now return only a subset of it's attributes, a subresource called /detail could be used to get the full details of the resource. This changes is supposed to improve performance and UX, also, others OpenStack APIs already do it the same way so it's also about being consistent between other APIs. Change-Id: Ida45febf60e44d50e506f3680ab371e1027010c4 Closes-Bug: #1227431 --- doc/source/dev/api-spec-v1.rst | 27 +++--- ironic/api/controllers/v1/base.py | 11 ++- ironic/api/controllers/v1/chassis.py | 99 +++++++++++----------- ironic/api/controllers/v1/node.py | 121 +++++++++++++++++---------- ironic/api/controllers/v1/port.py | 77 ++++++++++++++--- ironic/common/exception.py | 4 + ironic/tests/api/test_chassis.py | 48 +++++++++++ ironic/tests/api/test_nodes.py | 53 ++++++++++++ ironic/tests/api/test_ports.py | 15 ++++ 9 files changed, 338 insertions(+), 117 deletions(-) diff --git a/doc/source/dev/api-spec-v1.rst b/doc/source/dev/api-spec-v1.rst index fe27c26b7c..9db7de7011 100644 --- a/doc/source/dev/api-spec-v1.rst +++ b/doc/source/dev/api-spec-v1.rst @@ -434,8 +434,9 @@ Usage ======= ============= ========== Verb Path Response ======= ============= ========== -GET /nodes List nodes. -GET /nodes/ Retrieve a specific node. +GET /nodes List nodes +GET /nodes/detail Lists all details for all nodes +GET /nodes/ Retrieve a specific node POST /nodes Create a new node PATCH /nodes/ Update a node DELETE /nodes/ Delete node and all associated ports @@ -566,16 +567,17 @@ Chassis Usage ^^^^^^ -======= ============= ========== -Verb Path Response -======= ============= ========== -GET /chassis List chassis -GET /chassis/ Retrieve a specific chassis -POST /chassis Create a new chassis -PATCH /chassis/ Update a chassis -DELETE /chassis/ Delete chassis and remove all associations between - nodes -======= ============= ========== +======= ============= ========== +Verb Path Response +======= ============= ========== +GET /chassis List chassis +GET /chassis/detail Lists all details for all chassis +GET /chassis/ Retrieve a specific chassis +POST /chassis Create a new chassis +PATCH /chassis/ Update a chassis +DELETE /chassis/ Delete chassis and remove all associations between + nodes +======= ============= ========== Fields @@ -635,6 +637,7 @@ Usage Verb Path Response ======= ============= ========== GET /ports List ports +GET /ports/detail Lists all details for all ports GET /ports/ Retrieve a specific port POST /ports Create a new port PATCH /ports/ Update a port diff --git a/ironic/api/controllers/v1/base.py b/ironic/api/controllers/v1/base.py index 6e62c8c39f..da572573b9 100644 --- a/ironic/api/controllers/v1/base.py +++ b/ironic/api/controllers/v1/base.py @@ -28,5 +28,12 @@ class APIBase(wtypes.Base): getattr(self, k) != wsme.Unset) @classmethod - def from_rpc_object(cls, m): - return cls(**m.as_dict()) + def from_rpc_object(cls, m, fields=None): + """Convert a RPC object to an API object.""" + obj_dict = m.as_dict() + # Unset non-required fields so they do not appear + # in the message body + obj_dict.update(dict((k, wsme.Unset) + for k in obj_dict.keys() + if fields and k not in fields)) + return cls(**obj_dict) diff --git a/ironic/api/controllers/v1/chassis.py b/ironic/api/controllers/v1/chassis.py index 30eafea421..d189ab7fc3 100644 --- a/ironic/api/controllers/v1/chassis.py +++ b/ironic/api/controllers/v1/chassis.py @@ -67,24 +67,28 @@ class Chassis(base.APIBase): setattr(self, k, kwargs.get(k)) @classmethod - def convert_with_links(cls, rpc_chassis): - chassis = Chassis.from_rpc_object(rpc_chassis) - chassis.links = [link.Link.make_link('self', pecan.request.host_url, + def convert_with_links(cls, rpc_chassis, expand=True): + fields = ['uuid', 'description'] if not expand else None + chassis = Chassis.from_rpc_object(rpc_chassis, fields) + chassis.links = [link.Link.make_link('self', + pecan.request.host_url, 'chassis', chassis.uuid), link.Link.make_link('bookmark', pecan.request.host_url, - 'chassis', chassis.uuid, - bookmark=True) - ] - chassis.nodes = [link.Link.make_link('self', pecan.request.host_url, - 'chassis', - chassis.uuid + "/nodes"), - link.Link.make_link('bookmark', - pecan.request.host_url, - 'chassis', - chassis.uuid + "/nodes", - bookmark=True) + 'chassis', chassis.uuid) ] + + if expand: + chassis.nodes = [link.Link.make_link('self', + pecan.request.host_url, + 'chassis', + chassis.uuid + "/nodes"), + link.Link.make_link('bookmark', + pecan.request.host_url, + 'chassis', + chassis.uuid + "/nodes", + bookmark=True) + ] return chassis @@ -98,38 +102,62 @@ class ChassisCollection(collection.Collection): self._type = 'chassis' @classmethod - def convert_with_links(cls, chassis, limit, **kwargs): + def convert_with_links(cls, chassis, limit, url=None, + expand=False, **kwargs): collection = ChassisCollection() - collection.chassis = [Chassis.convert_with_links(ch) for ch in chassis] - collection.next = collection.get_next(limit, **kwargs) + collection.chassis = [Chassis.convert_with_links(ch, expand) + for ch in chassis] + url = url or None + collection.next = collection.get_next(limit, url=url, **kwargs) return collection class ChassisController(rest.RestController): """REST controller for Chassis.""" + nodes = node.NodesController(from_chassis=True) + "Expose nodes as a sub-element of chassis" + _custom_actions = { - 'nodes': ['GET'], + 'detail': ['GET'], } - @wsme_pecan.wsexpose(ChassisCollection, int, unicode, unicode, unicode) - def get_all(self, limit=None, marker=None, sort_key='id', sort_dir='asc'): - """Retrieve a list of chassis.""" + def _get_chassis(self, marker, limit, sort_key, sort_dir): limit = utils.validate_limit(limit) sort_dir = utils.validate_sort_dir(sort_dir) - marker_obj = None if marker: marker_obj = objects.Chassis.get_by_uuid(pecan.request.context, marker) - chassis = pecan.request.dbapi.get_chassis_list(limit, marker_obj, sort_key=sort_key, sort_dir=sort_dir) + return chassis + + @wsme_pecan.wsexpose(ChassisCollection, unicode, int, unicode, unicode) + def get_all(self, marker=None, limit=None, sort_key='id', sort_dir='asc'): + """Retrieve a list of chassis.""" + chassis = self._get_chassis(marker, limit, sort_key, sort_dir) return ChassisCollection.convert_with_links(chassis, limit, sort_key=sort_key, sort_dir=sort_dir) + @wsme_pecan.wsexpose(ChassisCollection, unicode, int, unicode, unicode) + def detail(self, marker=None, limit=None, sort_key='id', sort_dir='asc'): + """Retrieve a list of chassis with detail.""" + # /detail should only work agaist collections + parent = pecan.request.path.split('/')[:-1][-1] + if parent != "chassis": + raise exception.HTTPNotFound + + chassis = self._get_chassis(marker, limit, sort_key, sort_dir) + resource_url = '/'.join(['chassis', 'detail']) + return ChassisCollection.convert_with_links(chassis, limit, + url=resource_url, + expand=True, + sort_key=sort_key, + sort_dir=sort_dir) + @wsme_pecan.wsexpose(Chassis, unicode) def get_one(self, uuid): """Retrieve information about the given chassis.""" @@ -183,28 +211,3 @@ class ChassisController(rest.RestController): def delete(self, uuid): """Delete a chassis.""" pecan.request.dbapi.destroy_chassis(uuid) - - @wsme_pecan.wsexpose(node.NodeCollection, unicode, int, unicode, - unicode, unicode) - def nodes(self, chassis_uuid, limit=None, marker=None, - sort_key='id', sort_dir='asc'): - """Retrieve a list of nodes contained in the chassis.""" - limit = utils.validate_limit(limit) - sort_dir = utils.validate_sort_dir(sort_dir) - - marker_obj = None - if marker: - marker_obj = objects.Node.get_by_uuid(pecan.request.context, - marker) - - nodes = pecan.request.dbapi.get_nodes_by_chassis(chassis_uuid, limit, - marker_obj, - sort_key=sort_key, - sort_dir=sort_dir) - collection = node.NodeCollection() - collection.nodes = [node.Node.convert_with_links(n) for n in nodes] - resource_url = '/'.join(['chassis', chassis_uuid, 'nodes']) - collection.next = collection.get_next(limit, url=resource_url, - sort_key=sort_key, - sort_dir=sort_dir) - return collection diff --git a/ironic/api/controllers/v1/node.py b/ironic/api/controllers/v1/node.py index 54c8a22e75..073d37a871 100644 --- a/ironic/api/controllers/v1/node.py +++ b/ironic/api/controllers/v1/node.py @@ -223,8 +223,12 @@ class Node(base.APIBase): setattr(self, k, kwargs.get(k)) @classmethod - def convert_with_links(cls, rpc_node): - node = Node.from_rpc_object(rpc_node) + def convert_with_links(cls, rpc_node, expand=True): + minimum_fields = ['uuid', 'power_state', 'target_power_state', + 'provision_state', 'target_provision_state', + 'instance_uuid'] + fields = minimum_fields if not expand else None + node = Node.from_rpc_object(rpc_node, fields) node.links = [link.Link.make_link('self', pecan.request.host_url, 'nodes', node.uuid), link.Link.make_link('bookmark', @@ -232,13 +236,14 @@ class Node(base.APIBase): 'nodes', node.uuid, bookmark=True) ] - node.ports = [link.Link.make_link('self', pecan.request.host_url, - 'nodes', node.uuid + "/ports"), - link.Link.make_link('bookmark', - pecan.request.host_url, - 'nodes', node.uuid + "/ports", - bookmark=True) - ] + if expand: + node.ports = [link.Link.make_link('self', pecan.request.host_url, + 'nodes', node.uuid + "/ports"), + link.Link.make_link('bookmark', + pecan.request.host_url, + 'nodes', node.uuid + "/ports", + bookmark=True) + ] return node @@ -252,10 +257,11 @@ class NodeCollection(collection.Collection): self._type = 'nodes' @classmethod - def convert_with_links(cls, nodes, limit, **kwargs): + def convert_with_links(cls, nodes, limit, url=None, + expand=False, **kwargs): collection = NodeCollection() - collection.nodes = [Node.convert_with_links(n) for n in nodes] - collection.next = collection.get_next(limit, **kwargs) + collection.nodes = [Node.convert_with_links(n, expand) for n in nodes] + collection.next = collection.get_next(limit, url=url, **kwargs) return collection @@ -292,13 +298,21 @@ class NodesController(rest.RestController): vendor_passthru = NodeVendorPassthruController() "A resource used for vendors to expose a custom functionality in the API" + ports = port.PortsController(from_nodes=True) + "Expose ports as a sub-element of nodes" + _custom_actions = { - 'ports': ['GET'], + 'detail': ['GET'], } - @wsme_pecan.wsexpose(NodeCollection, int, unicode, unicode, unicode) - def get_all(self, limit=None, marker=None, sort_key='id', sort_dir='asc'): - """Retrieve a list of nodes.""" + def __init__(self, from_chassis=False): + self._from_chassis = from_chassis + + def _get_nodes(self, chassis_id, marker, limit, sort_key, sort_dir): + if self._from_chassis and not chassis_id: + raise exception.InvalidParameterValue(_( + "Chassis id not specified.")) + limit = utils.validate_limit(limit) sort_dir = utils.validate_sort_dir(sort_dir) @@ -307,22 +321,60 @@ class NodesController(rest.RestController): marker_obj = objects.Node.get_by_uuid(pecan.request.context, marker) - nodes = pecan.request.dbapi.get_node_list(limit, marker_obj, - sort_key=sort_key, - sort_dir=sort_dir) + if chassis_id: + nodes = pecan.request.dbapi.get_nodes_by_chassis(chassis_id, limit, + marker_obj, + sort_key=sort_key, + sort_dir=sort_dir) + else: + nodes = pecan.request.dbapi.get_node_list(limit, marker_obj, + sort_key=sort_key, + sort_dir=sort_dir) + return nodes + + @wsme_pecan.wsexpose(NodeCollection, unicode, unicode, int, + unicode, unicode) + def get_all(self, chassis_id=None, marker=None, limit=None, + sort_key='id', sort_dir='asc'): + """Retrieve a list of nodes.""" + nodes = self._get_nodes(chassis_id, marker, limit, sort_key, sort_dir) return NodeCollection.convert_with_links(nodes, limit, sort_key=sort_key, sort_dir=sort_dir) + @wsme_pecan.wsexpose(NodeCollection, unicode, unicode, int, + unicode, unicode) + def detail(self, chassis_id=None, marker=None, limit=None, + sort_key='id', sort_dir='asc'): + """Retrieve a list of nodes with detail.""" + # /detail should only work agaist collections + parent = pecan.request.path.split('/')[:-1][-1] + if parent != "nodes": + raise exception.HTTPNotFound + + nodes = self._get_nodes(chassis_id, marker, limit, sort_key, sort_dir) + resource_url = '/'.join(['nodes', 'detail']) + return NodeCollection.convert_with_links(nodes, limit, + url=resource_url, + expand=True, + sort_key=sort_key, + sort_dir=sort_dir) + @wsme_pecan.wsexpose(Node, unicode) def get_one(self, uuid): """Retrieve information about the given node.""" + if self._from_chassis: + raise exception.OperationNotPermitted + rpc_node = objects.Node.get_by_uuid(pecan.request.context, uuid) return Node.convert_with_links(rpc_node) @wsme_pecan.wsexpose(Node, body=Node) def post(self, node): """Create a new node.""" + if self._from_chassis: + raise exception.OperationNotPermitted + try: new_node = pecan.request.dbapi.create_node(node.as_dict()) except Exception as e: @@ -336,6 +388,9 @@ class NodesController(rest.RestController): TODO(deva): add exception handling """ + if self._from_chassis: + raise exception.OperationNotPermitted + node = objects.Node.get_by_uuid(pecan.request.context, uuid) node_dict = node.as_dict() @@ -407,29 +462,7 @@ class NodesController(rest.RestController): TODO(deva): don't allow deletion of an associated node. """ + if self._from_chassis: + raise exception.OperationNotPermitted + pecan.request.dbapi.destroy_node(node_id) - - @wsme_pecan.wsexpose(port.PortCollection, unicode, int, unicode, - unicode, unicode) - def ports(self, node_uuid, limit=None, marker=None, - sort_key='id', sort_dir='asc'): - """Retrieve a list of ports on this node.""" - limit = utils.validate_limit(limit) - sort_dir = utils.validate_sort_dir(sort_dir) - - marker_obj = None - if marker: - marker_obj = objects.Port.get_by_uuid(pecan.request.context, - marker) - - ports = pecan.request.dbapi.get_ports_by_node(node_uuid, limit, - marker_obj, - sort_key=sort_key, - sort_dir=sort_dir) - collection = port.PortCollection() - collection.ports = [port.Port.convert_with_links(n) for n in ports] - resource_url = '/'.join(['nodes', node_uuid, 'ports']) - collection.next = collection.get_next(limit, url=resource_url, - sort_key=sort_key, - sort_dir=sort_dir) - return collection diff --git a/ironic/api/controllers/v1/port.py b/ironic/api/controllers/v1/port.py index 807a827ebc..a3bb5c85c8 100644 --- a/ironic/api/controllers/v1/port.py +++ b/ironic/api/controllers/v1/port.py @@ -60,8 +60,9 @@ class Port(base.APIBase): setattr(self, k, kwargs.get(k)) @classmethod - def convert_with_links(cls, rpc_port): - port = Port.from_rpc_object(rpc_port) + def convert_with_links(cls, rpc_port, expand=True): + fields = ['uuid', 'address'] if not expand else None + port = Port.from_rpc_object(rpc_port, fields) port.links = [link.Link.make_link('self', pecan.request.host_url, 'ports', port.uuid), link.Link.make_link('bookmark', @@ -82,19 +83,29 @@ class PortCollection(collection.Collection): self._type = 'ports' @classmethod - def convert_with_links(cls, ports, limit, **kwargs): + def convert_with_links(cls, ports, limit, url=None, + expand=False, **kwargs): collection = PortCollection() - collection.ports = [Port.convert_with_links(p) for p in ports] - collection.next = collection.get_next(limit, **kwargs) + collection.ports = [Port.convert_with_links(p, expand) for p in ports] + collection.next = collection.get_next(limit, url=url, **kwargs) return collection class PortsController(rest.RestController): """REST controller for Ports.""" - @wsme_pecan.wsexpose(PortCollection, int, unicode, unicode, unicode) - def get_all(self, limit=None, marker=None, sort_key='id', sort_dir='asc'): - """Retrieve a list of ports.""" + _custom_actions = { + 'detail': ['GET'], + } + + def __init__(self, from_nodes=False): + self._from_nodes = from_nodes + + def _get_ports(self, node_id, marker, limit, sort_key, sort_dir): + if self._from_nodes and not node_id: + raise exception.InvalidParameterValue(_( + "Node id not specified.")) + limit = utils.validate_limit(limit) sort_dir = utils.validate_sort_dir(sort_dir) @@ -103,22 +114,60 @@ class PortsController(rest.RestController): marker_obj = objects.Port.get_by_uuid(pecan.request.context, marker) - ports = pecan.request.dbapi.get_port_list(limit, marker_obj, - sort_key=sort_key, - sort_dir=sort_dir) + if node_id: + ports = pecan.request.dbapi.get_ports_by_node(node_id, limit, + marker_obj, + sort_key=sort_key, + sort_dir=sort_dir) + else: + ports = pecan.request.dbapi.get_port_list(limit, marker_obj, + sort_key=sort_key, + sort_dir=sort_dir) + return ports + + @wsme_pecan.wsexpose(PortCollection, unicode, unicode, int, + unicode, unicode) + def get_all(self, node_id=None, marker=None, limit=None, + sort_key='id', sort_dir='asc'): + """Retrieve a list of ports.""" + ports = self._get_ports(node_id, marker, limit, sort_key, sort_dir) return PortCollection.convert_with_links(ports, limit, sort_key=sort_key, sort_dir=sort_dir) + @wsme_pecan.wsexpose(PortCollection, unicode, unicode, int, + unicode, unicode) + def detail(self, node_id=None, marker=None, limit=None, + sort_key='id', sort_dir='asc'): + """Retrieve a list of ports.""" + # NOTE(lucasagomes): /detail should only work agaist collections + parent = pecan.request.path.split('/')[:-1][-1] + if parent != "ports": + raise exception.HTTPNotFound + + ports = self._get_ports(node_id, marker, limit, sort_key, sort_dir) + resource_url = '/'.join(['ports', 'detail']) + return PortCollection.convert_with_links(ports, limit, + url=resource_url, + expand=True, + sort_key=sort_key, + sort_dir=sort_dir) + @wsme_pecan.wsexpose(Port, unicode) def get_one(self, uuid): """Retrieve information about the given port.""" + if self._from_nodes: + raise exception.OperationNotPermitted + rpc_port = objects.Port.get_by_uuid(pecan.request.context, uuid) return Port.convert_with_links(rpc_port) @wsme_pecan.wsexpose(Port, body=Port) def post(self, port): """Create a new port.""" + if self._from_nodes: + raise exception.OperationNotPermitted + try: new_port = pecan.request.dbapi.create_port(port.as_dict()) except exception.IronicException as e: @@ -129,6 +178,9 @@ class PortsController(rest.RestController): @wsme_pecan.wsexpose(Port, unicode, body=[unicode]) def patch(self, uuid, patch): """Update an existing port.""" + if self._from_nodes: + raise exception.OperationNotPermitted + port = objects.Port.get_by_uuid(pecan.request.context, uuid) port_dict = port.as_dict() @@ -161,4 +213,7 @@ class PortsController(rest.RestController): @wsme_pecan.wsexpose(None, unicode, status_code=204) def delete(self, port_id): """Delete a port.""" + if self._from_nodes: + raise exception.OperationNotPermitted + pecan.request.dbapi.destroy_port(port_id) diff --git a/ironic/common/exception.py b/ironic/common/exception.py index b54a03ec78..10b74fe330 100644 --- a/ironic/common/exception.py +++ b/ironic/common/exception.py @@ -173,6 +173,10 @@ class PolicyNotAuthorized(NotAuthorized): message = _("Policy doesn't allow %(action)s to be performed.") +class OperationNotPermitted(NotAuthorized): + message = _("Operation not permitted.") + + class Invalid(IronicException): message = _("Unacceptable parameters.") code = 400 diff --git a/ironic/tests/api/test_chassis.py b/ironic/tests/api/test_chassis.py index 6a852c7d9d..603d5c632d 100644 --- a/ironic/tests/api/test_chassis.py +++ b/ironic/tests/api/test_chassis.py @@ -32,6 +32,23 @@ class TestListChassis(base.FunctionalTest): chassis = self.dbapi.create_chassis(ndict) data = self.get_json('/chassis') self.assertEqual(chassis['uuid'], data['chassis'][0]["uuid"]) + self.assertNotIn('extra', data['chassis'][0]) + self.assertNotIn('nodes', data['chassis'][0]) + + def test_detail(self): + cdict = dbutils.get_test_chassis() + chassis = self.dbapi.create_chassis(cdict) + data = self.get_json('/chassis/detail') + self.assertEqual(chassis['uuid'], data['chassis'][0]["uuid"]) + self.assertIn('extra', data['chassis'][0]) + self.assertIn('nodes', data['chassis'][0]) + + def test_detail_against_single(self): + cdict = dbutils.get_test_chassis() + chassis = self.dbapi.create_chassis(cdict) + response = self.get_json('/chassis/%s/detail' % chassis['uuid'], + expect_errors=True) + self.assertEqual(response.status_int, 404) def test_many(self): ch_list = [] @@ -93,6 +110,15 @@ class TestListChassis(base.FunctionalTest): self.assertEqual(len(data['nodes']), 1) self.assertIn('next', data.keys()) + def test_nodes_subresource_noid(self): + cdict = dbutils.get_test_chassis() + self.dbapi.create_chassis(cdict) + ndict = dbutils.get_test_node(chassis_id=cdict['id']) + self.dbapi.create_node(ndict) + # No chassis id specified + response = self.get_json('/chassis/nodes', expect_errors=True) + self.assertEqual(response.status_int, 400) + class TestPatch(base.FunctionalTest): @@ -203,6 +229,13 @@ class TestPatch(base.FunctionalTest): expected = {"foo1": "bar1", "foo2": "bar2"} self.assertEqual(result['extra'], expected) + def test_patch_nodes_subresource(self): + cdict = dbutils.get_test_chassis() + response = self.patch_json('/chassis/%s/nodes' % cdict['uuid'], + [{'path': '/extra/foo', 'value': 'bar', + 'op': 'add'}], expect_errors=True) + self.assertEqual(response.status_int, 403) + class TestPost(base.FunctionalTest): @@ -221,6 +254,14 @@ class TestPost(base.FunctionalTest): result['chassis'][0]['description']) self.assertTrue(uuidutils.is_uuid_like(result['chassis'][0]['uuid'])) + def test_post_nodes_subresource(self): + cdict = dbutils.get_test_chassis() + self.post_json('/chassis', cdict) + ndict = dbutils.get_test_node(chassis_id=cdict['id']) + response = self.post_json('/chassis/nodes', ndict, + expect_errors=True) + self.assertEqual(response.status_int, 403) + class TestDelete(base.FunctionalTest): @@ -251,3 +292,10 @@ class TestDelete(base.FunctionalTest): self.assertEqual(response.status_int, 404) self.assertEqual(response.content_type, 'application/json') self.assertTrue(response.json['error_message']) + + def test_delete_nodes_subresource(self): + cdict = dbutils.get_test_chassis() + self.post_json('/chassis', cdict) + response = self.delete('/chassis/%s/nodes' % cdict['uuid'], + expect_errors=True) + self.assertEqual(response.status_int, 403) diff --git a/ironic/tests/api/test_nodes.py b/ironic/tests/api/test_nodes.py index 3372646487..d70c89a0ad 100644 --- a/ironic/tests/api/test_nodes.py +++ b/ironic/tests/api/test_nodes.py @@ -38,6 +38,29 @@ class TestListNodes(base.FunctionalTest): node = self.dbapi.create_node(ndict) data = self.get_json('/nodes') self.assertEqual(node['uuid'], data['nodes'][0]["uuid"]) + self.assertNotIn('driver', data['nodes'][0]) + self.assertNotIn('driver_info', data['nodes'][0]) + self.assertNotIn('extra', data['nodes'][0]) + self.assertNotIn('properties', data['nodes'][0]) + self.assertNotIn('chassis_id', data['nodes'][0]) + + def test_detail(self): + ndict = dbutils.get_test_node() + node = self.dbapi.create_node(ndict) + data = self.get_json('/nodes/detail') + self.assertEqual(node['uuid'], data['nodes'][0]["uuid"]) + self.assertIn('driver', data['nodes'][0]) + self.assertIn('driver_info', data['nodes'][0]) + self.assertIn('extra', data['nodes'][0]) + self.assertIn('properties', data['nodes'][0]) + self.assertIn('chassis_id', data['nodes'][0]) + + def test_detail_against_single(self): + ndict = dbutils.get_test_node() + node = self.dbapi.create_node(ndict) + response = self.get_json('/nodes/%s/detail' % node['uuid'], + expect_errors=True) + self.assertEqual(response.status_int, 404) def test_many(self): nodes = [] @@ -99,6 +122,15 @@ class TestListNodes(base.FunctionalTest): self.assertEqual(len(data['ports']), 1) self.assertIn('next', data.keys()) + def test_nodes_subresource_noid(self): + ndict = dbutils.get_test_node() + self.dbapi.create_node(ndict) + pdict = dbutils.get_test_port(node_id=ndict['id']) + self.dbapi.create_port(pdict) + # No node id specified + response = self.get_json('/nodes/ports', expect_errors=True) + self.assertEqual(response.status_int, 400) + def test_state(self): ndict = dbutils.get_test_node() self.dbapi.create_node(ndict) @@ -239,6 +271,12 @@ class TestPatch(base.FunctionalTest): [{'path': '/extra/foo', 'value': 'bar', 'op': 'add'}]) + def test_patch_ports_subresource(self): + response = self.patch_json('/nodes/%s/ports' % self.node['uuid'], + [{'path': '/extra/foo', 'value': 'bar', + 'op': 'add'}], expect_errors=True) + self.assertEqual(response.status_int, 403) + class TestPost(base.FunctionalTest): @@ -271,6 +309,14 @@ class TestPost(base.FunctionalTest): '/nodes/%s/vendor_passthru' % ndict['uuid'], {'foo': 'bar'}) + def test_post_ports_subresource(self): + ndict = dbutils.get_test_node() + self.post_json('/nodes', ndict) + pdict = dbutils.get_test_port() + response = self.post_json('/nodes/ports', pdict, + expect_errors=True) + self.assertEqual(response.status_int, 403) + class TestDelete(base.FunctionalTest): @@ -284,6 +330,13 @@ class TestDelete(base.FunctionalTest): self.assertEqual(response.content_type, 'application/json') self.assertTrue(response.json['error_message']) + def test_delete_ports_subresource(self): + ndict = dbutils.get_test_node() + self.post_json('/nodes', ndict) + response = self.delete('/nodes/%s/ports' % ndict['uuid'], + expect_errors=True) + self.assertEqual(response.status_int, 403) + class TestPut(base.FunctionalTest): diff --git a/ironic/tests/api/test_ports.py b/ironic/tests/api/test_ports.py index 74dc76eec7..9d9af60381 100644 --- a/ironic/tests/api/test_ports.py +++ b/ironic/tests/api/test_ports.py @@ -32,6 +32,21 @@ class TestListPorts(base.FunctionalTest): port = self.dbapi.create_port(ndict) data = self.get_json('/ports') self.assertEqual(port['uuid'], data['ports'][0]["uuid"]) + self.assertNotIn('extra', data['ports'][0]) + + def test_detail(self): + pdict = dbutils.get_test_port() + port = self.dbapi.create_port(pdict) + data = self.get_json('/ports/detail') + self.assertEqual(port['uuid'], data['ports'][0]["uuid"]) + self.assertIn('extra', data['ports'][0]) + + def test_detail_against_single(self): + pdict = dbutils.get_test_port() + port = self.dbapi.create_port(pdict) + response = self.get_json('/ports/%s/detail' % port['uuid'], + expect_errors=True) + self.assertEqual(response.status_int, 404) def test_many(self): ports = []