Merge "Add detail=[True, False] query string to API list endpoints"

This commit is contained in:
Zuul 2018-06-20 07:17:45 +00:00 committed by Gerrit Code Review
commit fbc74b7924
17 changed files with 370 additions and 30 deletions

View File

@ -203,6 +203,10 @@ List chassis
Lists all chassis.
.. versionadded:: 1.43
Added the ``detail`` boolean request parameter. When specified ``True`` this
causes the response to include complete details about each chassis.
Normal response codes: 200
.. TODO: add error codes
@ -217,6 +221,7 @@ Request
- sort_dir: sort_dir
- sort_key: sort_key
- fields: fields
- detail: detail
Response Parameters
-------------------

View File

@ -132,7 +132,7 @@ and any defaults added for non-specified fields. Most fields default to "null"
or "".
The list and example below are representative of the response as of API
microversion 1.42.
microversion 1.43.
.. rest_parameters:: parameters.yaml
@ -215,6 +215,10 @@ provision state, and maintenance setting for each Node.
.. versionadded:: 1.42
Introduced the ``fault`` field.
.. versionadded:: 1.43
Added the ``detail`` boolean request parameter. When specified ``True`` this
causes the response to include complete details about each node.
Normal response codes: 200
Error codes: 400,403,406
@ -236,6 +240,7 @@ Request
- marker: marker
- sort_dir: sort_dir
- sort_key: sort_key
- detail: detail
Response
--------
@ -261,6 +266,9 @@ List Nodes Detailed
.. rest_method:: GET /v1/nodes/detail
.. deprecated::
Use ?detail=True query string instead.
Return a list of bare metal Nodes with complete details. Some filtering is
possible by passing in flags with the request.

View File

@ -28,6 +28,10 @@ some parameters with the request.
By default, this query will return the UUID, name and address for each Portgroup.
.. versionadded:: 1.43
Added the ``detail`` boolean request parameter. When specified ``True`` this
causes the response to include complete details about each portgroup.
Normal response code: 200
Error codes: 400,401,403,404
@ -44,6 +48,7 @@ Request
- marker: marker
- sort_dir: sort_dir
- sort_key: sort_key
- detail: detail
Response
--------

View File

@ -42,6 +42,10 @@ By default, this query will return the uuid and address for each Port.
.. versionadded:: 1.34
Added the ``physical_network`` field.
.. versionadded:: 1.43
Added the ``detail`` boolean request parameter. When specified ``True`` this
causes the response to include complete details about each port.
Normal response code: 200
Request
@ -58,6 +62,7 @@ Request
- marker: marker
- sort_dir: sort_dir
- sort_key: sort_key
- detail: detail
Response
--------

View File

@ -2,6 +2,12 @@
REST API Version History
========================
1.43 (Rocky, master)
---------------------
Added ``?detail=`` boolean query to the API list endpoints to provide a more
RESTful alternative to the existing ``/nodes/detail`` and similar endpoints.
1.42 (Rocky, master)
--------------------

View File

@ -174,7 +174,7 @@ class ChassisController(rest.RestController):
invalid_sort_key_list = ['extra']
def _get_chassis_collection(self, marker, limit, sort_key, sort_dir,
resource_url=None, fields=None):
resource_url=None, fields=None, detail=None):
limit = api_utils.validate_limit(limit)
sort_dir = api_utils.validate_sort_dir(sort_dir)
marker_obj = None
@ -190,17 +190,22 @@ class ChassisController(rest.RestController):
chassis = objects.Chassis.list(pecan.request.context, limit,
marker_obj, sort_key=sort_key,
sort_dir=sort_dir)
parameters = {}
if detail is not None:
parameters['detail'] = detail
return ChassisCollection.convert_with_links(chassis, limit,
url=resource_url,
fields=fields,
sort_key=sort_key,
sort_dir=sort_dir)
sort_dir=sort_dir,
**parameters)
@METRICS.timer('ChassisController.get_all')
@expose.expose(ChassisCollection, types.uuid, int,
wtypes.text, wtypes.text, types.listtype)
wtypes.text, wtypes.text, types.listtype, types.boolean)
def get_all(self, marker=None, limit=None, sort_key='id', sort_dir='asc',
fields=None):
fields=None, detail=None):
"""Retrieve a list of chassis.
:param marker: pagination marker for large data sets.
@ -217,10 +222,12 @@ class ChassisController(rest.RestController):
policy.authorize('baremetal:chassis:get', cdict, cdict)
api_utils.check_allow_specify_fields(fields)
if fields is None:
fields = _DEFAULT_RETURN_FIELDS
fields = api_utils.get_request_return_fields(fields, detail,
_DEFAULT_RETURN_FIELDS)
return self._get_chassis_collection(marker, limit, sort_key, sort_dir,
fields=fields)
fields=fields, detail=detail)
@METRICS.timer('ChassisController.detail')
@expose.expose(ChassisCollection, types.uuid, int,

View File

@ -1524,7 +1524,7 @@ class NodesController(rest.RestController):
maintenance, provision_state, marker, limit,
sort_key, sort_dir, driver=None,
resource_class=None, resource_url=None,
fields=None, fault=None):
fields=None, fault=None, detail=None):
if self.from_chassis and not chassis_uuid:
raise exception.MissingParameterValue(
_("Chassis id not specified."))
@ -1583,6 +1583,9 @@ class NodesController(rest.RestController):
if maintenance:
parameters['maintenance'] = maintenance
if detail is not None:
parameters['detail'] = detail
return NodeCollection.convert_with_links(nodes, limit,
url=resource_url,
fields=fields,
@ -1676,11 +1679,11 @@ class NodesController(rest.RestController):
@expose.expose(NodeCollection, types.uuid, types.uuid, types.boolean,
types.boolean, wtypes.text, types.uuid, int, wtypes.text,
wtypes.text, wtypes.text, types.listtype, wtypes.text,
wtypes.text)
wtypes.text, types.boolean)
def get_all(self, chassis_uuid=None, instance_uuid=None, associated=None,
maintenance=None, provision_state=None, marker=None,
limit=None, sort_key='id', sort_dir='asc', driver=None,
fields=None, resource_class=None, fault=None):
fields=None, resource_class=None, fault=None, detail=None):
"""Retrieve a list of nodes.
:param chassis_uuid: Optional UUID of a chassis, to get only nodes for
@ -1720,15 +1723,18 @@ class NodesController(rest.RestController):
api_utils.check_allow_specify_driver(driver)
api_utils.check_allow_specify_resource_class(resource_class)
api_utils.check_allow_filter_by_fault(fault)
if fields is None:
fields = _DEFAULT_RETURN_FIELDS
fields = api_utils.get_request_return_fields(fields, detail,
_DEFAULT_RETURN_FIELDS)
return self._get_nodes_collection(chassis_uuid, instance_uuid,
associated, maintenance,
provision_state, marker,
limit, sort_key, sort_dir,
driver=driver,
resource_class=resource_class,
fields=fields, fault=fault)
fields=fields, fault=fault,
detail=detail)
@METRICS.timer('NodesController.detail')
@expose.expose(NodeCollection, types.uuid, types.uuid, types.boolean,

View File

@ -314,7 +314,7 @@ class PortsController(rest.RestController):
def _get_ports_collection(self, node_ident, address, portgroup_ident,
marker, limit, sort_key, sort_dir,
resource_url=None, fields=None):
resource_url=None, fields=None, detail=None):
limit = api_utils.validate_limit(limit)
sort_dir = api_utils.validate_sort_dir(sort_dir)
@ -362,12 +362,17 @@ class PortsController(rest.RestController):
ports = objects.Port.list(pecan.request.context, limit,
marker_obj, sort_key=sort_key,
sort_dir=sort_dir)
parameters = {}
if detail is not None:
parameters['detail'] = detail
return PortCollection.convert_with_links(ports, limit,
url=resource_url,
fields=fields,
sort_key=sort_key,
sort_dir=sort_dir)
sort_dir=sort_dir,
**parameters)
def _get_ports_by_address(self, address):
"""Retrieve a port by its address.
@ -407,10 +412,11 @@ class PortsController(rest.RestController):
@METRICS.timer('PortsController.get_all')
@expose.expose(PortCollection, types.uuid_or_name, types.uuid,
types.macaddress, types.uuid, int, wtypes.text,
wtypes.text, types.listtype, types.uuid_or_name)
wtypes.text, types.listtype, types.uuid_or_name,
types.boolean)
def get_all(self, node=None, node_uuid=None, address=None, marker=None,
limit=None, sort_key='id', sort_dir='asc', fields=None,
portgroup=None):
portgroup=None, detail=None):
"""Retrieve a list of ports.
Note that the 'node_uuid' interface is deprecated in favour
@ -441,11 +447,12 @@ class PortsController(rest.RestController):
api_utils.check_allow_specify_fields(fields)
self._check_allowed_port_fields(fields)
self._check_allowed_port_fields([sort_key])
if portgroup and not api_utils.allow_portgroups_subcontrollers():
raise exception.NotAcceptable()
if fields is None:
fields = _DEFAULT_RETURN_FIELDS
fields = api_utils.get_request_return_fields(fields, detail,
_DEFAULT_RETURN_FIELDS)
if not node_uuid and node:
# We're invoking this interface using positional notation, or
@ -457,7 +464,8 @@ class PortsController(rest.RestController):
return self._get_ports_collection(node_uuid or node, address,
portgroup, marker, limit, sort_key,
sort_dir, fields=fields)
sort_dir, fields=fields,
detail=detail)
@METRICS.timer('PortsController.detail')
@expose.expose(PortCollection, types.uuid_or_name, types.uuid,

View File

@ -262,7 +262,8 @@ class PortgroupsController(pecan.rest.RestController):
def _get_portgroups_collection(self, node_ident, address,
marker, limit, sort_key, sort_dir,
resource_url=None, fields=None):
resource_url=None, fields=None,
detail=None):
"""Return portgroups collection.
:param node_ident: UUID or name of a node.
@ -305,12 +306,16 @@ class PortgroupsController(pecan.rest.RestController):
portgroups = objects.Portgroup.list(pecan.request.context, limit,
marker_obj, sort_key=sort_key,
sort_dir=sort_dir)
parameters = {}
if detail is not None:
parameters['detail'] = detail
return PortgroupCollection.convert_with_links(portgroups, limit,
url=resource_url,
fields=fields,
sort_key=sort_key,
sort_dir=sort_dir)
sort_dir=sort_dir,
**parameters)
def _get_portgroups_by_address(self, address):
"""Retrieve a portgroup by its address.
@ -330,9 +335,11 @@ class PortgroupsController(pecan.rest.RestController):
@METRICS.timer('PortgroupsController.get_all')
@expose.expose(PortgroupCollection, types.uuid_or_name, types.macaddress,
types.uuid, int, wtypes.text, wtypes.text, types.listtype)
types.uuid, int, wtypes.text, wtypes.text, types.listtype,
types.boolean)
def get_all(self, node=None, address=None, marker=None,
limit=None, sort_key='id', sort_dir='asc', fields=None):
limit=None, sort_key='id', sort_dir='asc', fields=None,
detail=None):
"""Retrieve a list of portgroups.
:param node: UUID or name of a node, to get only portgroups for that
@ -358,13 +365,14 @@ class PortgroupsController(pecan.rest.RestController):
api_utils.check_allowed_portgroup_fields(fields)
api_utils.check_allowed_portgroup_fields([sort_key])
if fields is None:
fields = _DEFAULT_RETURN_FIELDS
fields = api_utils.get_request_return_fields(fields, detail,
_DEFAULT_RETURN_FIELDS)
return self._get_portgroups_collection(node, address,
marker, limit,
sort_key, sort_dir,
fields=fields)
fields=fields,
detail=detail)
@METRICS.timer('PortgroupsController.detail')
@expose.expose(PortgroupCollection, types.uuid_or_name, types.macaddress,

View File

@ -850,3 +850,46 @@ def handle_patch_port_like_extra_vif(rpc_object, api_object, patch):
int_info = rpc_object.internal_info.get('tenant_vif_port_id')
if (int_info and int_info == rpc_object.extra.get('vif_port_id')):
api_object.internal_info.pop('tenant_vif_port_id')
def allow_detail_query():
"""Check if passing a detail=True query string is allowed.
Version 1.43 allows a user to pass the detail query string to
list the resource with all the fields.
"""
return (pecan.request.version.minor >=
versions.MINOR_43_ENABLE_DETAIL_QUERY)
def get_request_return_fields(fields, detail, default_fields):
"""Calculate fields to return from an API request
The fields query and detail=True query can not be passed into a request at
the same time. To use the detail query we need to be on a version of the
API greater than 1.43. This function raises an InvalidParameterValue
exception if either of these conditions are not met.
If these checks pass then this function will return either the fields
passed in or the default fields provided.
:param fields: The fields query passed into the API request.
:param detail: The detail query passed into the API request.
:param default_fields: The default fields to return if fields=None and
detail=None.
:raises: InvalidParameterValue if there is an invalid combination of query
strings or API version.
:returns: 'fields' passed in value or 'default_fields'
"""
if detail is not None and not allow_detail_query():
raise exception.InvalidParameterValue(
"Invalid query parameter ?detail=%s received." % detail)
if fields is not None and detail:
raise exception.InvalidParameterValue(
"Can not specify ?detail=True and fields in the same request.")
if fields is None and not detail:
return default_fields
return fields

View File

@ -80,6 +80,7 @@ BASE_VERSION = 1
# Add bios_interface to the node object.
# v1.41: Add inspection abort support.
# v1.42: Expose fault field to node.
# v1.43: Add detail=True flag to all API endpoints
MINOR_0_JUNO = 0
MINOR_1_INITIAL_VERSION = 1
@ -124,6 +125,7 @@ MINOR_39_INSPECT_WAIT = 39
MINOR_40_BIOS_INTERFACE = 40
MINOR_41_INSPECTION_ABORT = 41
MINOR_42_FAULT = 42
MINOR_43_ENABLE_DETAIL_QUERY = 43
# When adding another version, update:
# - MINOR_MAX_VERSION
@ -131,7 +133,7 @@ MINOR_42_FAULT = 42
# explanation of what changed in the new version
# - common/release_mappings.py, RELEASE_MAPPING['master']['api']
MINOR_MAX_VERSION = MINOR_42_FAULT
MINOR_MAX_VERSION = MINOR_43_ENABLE_DETAIL_QUERY
# String representations of the minor and maximum versions
_MIN_VERSION_STRING = '{}.{}'.format(BASE_VERSION, MINOR_1_INITIAL_VERSION)

View File

@ -100,7 +100,7 @@ RELEASE_MAPPING = {
}
},
'master': {
'api': '1.42',
'api': '1.43',
'rpc': '1.44',
'objects': {
'Node': ['1.25'],

View File

@ -123,6 +123,49 @@ class TestListChassis(test_api_base.BaseApiTest):
self.assertIn('extra', data['chassis'][0])
self.assertIn('nodes', data['chassis'][0])
def test_detail_query(self):
chassis = obj_utils.create_test_chassis(self.context)
data = self.get_json(
'/chassis?detail=True',
headers={api_base.Version.string: str(api_v1.max_version())})
self.assertEqual(chassis.uuid, data['chassis'][0]["uuid"])
self.assertIn('extra', data['chassis'][0])
self.assertIn('nodes', data['chassis'][0])
def test_detail_query_false(self):
obj_utils.create_test_chassis(self.context)
data1 = self.get_json(
'/chassis',
headers={api_base.Version.string: str(api_v1.max_version())})
data2 = self.get_json(
'/chassis?detail=False',
headers={api_base.Version.string: str(api_v1.max_version())})
self.assertEqual(data1['chassis'], data2['chassis'])
def test_detail_using_query_and_fields(self):
obj_utils.create_test_chassis(self.context)
response = self.get_json(
'/chassis?detail=True&fields=description',
headers={api_base.Version.string: str(api_v1.max_version())},
expect_errors=True)
self.assertEqual(http_client.BAD_REQUEST, response.status_int)
def test_detail_using_query_false_and_fields(self):
obj_utils.create_test_chassis(self.context)
data = self.get_json(
'/chassis?detail=False&fields=description',
headers={api_base.Version.string: str(api_v1.max_version())})
self.assertIn('description', data['chassis'][0])
self.assertNotIn('uuid', data['chassis'][0])
def test_detail_using_query_old_version(self):
obj_utils.create_test_chassis(self.context)
response = self.get_json(
'/chassis?detail=True',
headers={api_base.Version.string: str(api_v1.min_version())},
expect_errors=True)
self.assertEqual(http_client.BAD_REQUEST, response.status_int)
def test_detail_against_single(self):
chassis = obj_utils.create_test_chassis(self.context)
response = self.get_json('/chassis/%s/detail' % chassis['uuid'],

View File

@ -430,6 +430,72 @@ class TestListNodes(test_api_base.BaseApiTest):
# never expose the chassis_id
self.assertNotIn('chassis_id', data['nodes'][0])
def test_detail_using_query(self):
node = obj_utils.create_test_node(self.context,
chassis_id=self.chassis.id)
data = self.get_json(
'/nodes?detail=True',
headers={api_base.Version.string: str(api_v1.max_version())})
self.assertEqual(node.uuid, data['nodes'][0]["uuid"])
self.assertIn('name', data['nodes'][0])
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_uuid', data['nodes'][0])
self.assertIn('reservation', data['nodes'][0])
self.assertIn('maintenance', data['nodes'][0])
self.assertIn('console_enabled', data['nodes'][0])
self.assertIn('target_power_state', data['nodes'][0])
self.assertIn('target_provision_state', data['nodes'][0])
self.assertIn('provision_updated_at', data['nodes'][0])
self.assertIn('inspection_finished_at', data['nodes'][0])
self.assertIn('inspection_started_at', data['nodes'][0])
self.assertIn('raid_config', data['nodes'][0])
self.assertIn('target_raid_config', data['nodes'][0])
self.assertIn('network_interface', data['nodes'][0])
self.assertIn('resource_class', data['nodes'][0])
for field in api_utils.V31_FIELDS:
self.assertIn(field, data['nodes'][0])
# never expose the chassis_id
self.assertNotIn('chassis_id', data['nodes'][0])
def test_detail_query_false(self):
obj_utils.create_test_node(self.context)
data1 = self.get_json(
'/nodes',
headers={api_base.Version.string: str(api_v1.max_version())})
data2 = self.get_json(
'/nodes?detail=False',
headers={api_base.Version.string: str(api_v1.max_version())})
self.assertEqual(data1['nodes'], data2['nodes'])
def test_detail_using_query_false_and_fields(self):
obj_utils.create_test_node(self.context)
data = self.get_json(
'/nodes?detail=False&fields=name',
headers={api_base.Version.string: str(api_v1.max_version())})
self.assertIn('name', data['nodes'][0])
self.assertNotIn('uuid', data['nodes'][0])
def test_detail_using_query_and_fields(self):
obj_utils.create_test_node(self.context,
chassis_id=self.chassis.id)
response = self.get_json(
'/nodes?detail=True&fields=name',
headers={api_base.Version.string: str(api_v1.max_version())},
expect_errors=True)
self.assertEqual(http_client.BAD_REQUEST, response.status_int)
def test_detail_using_query_old_version(self):
obj_utils.create_test_node(self.context,
chassis_id=self.chassis.id)
response = self.get_json(
'/nodes?detail=True',
headers={api_base.Version.string: str(api_v1.min_version())},
expect_errors=True)
self.assertEqual(http_client.BAD_REQUEST, response.status_int)
def test_detail_against_single(self):
node = obj_utils.create_test_node(self.context)
response = self.get_json('/nodes/%s/detail' % node.uuid,

View File

@ -442,6 +442,74 @@ class TestListPorts(test_api_base.BaseApiTest):
self.assertNotIn('node_id', data['ports'][0])
self.assertNotIn('portgroup_id', data['ports'][0])
def test_detail_query(self):
llc = {'switch_info': 'switch', 'switch_id': 'aa:bb:cc:dd:ee:ff',
'port_id': 'Gig0/1'}
portgroup = obj_utils.create_test_portgroup(self.context,
node_id=self.node.id)
port = obj_utils.create_test_port(self.context, node_id=self.node.id,
portgroup_id=portgroup.id,
pxe_enabled=False,
local_link_connection=llc,
physical_network='physnet1')
data = self.get_json(
'/ports?detail=True',
headers={api_base.Version.string: str(api_v1.max_version())}
)
self.assertEqual(port.uuid, data['ports'][0]["uuid"])
self.assertIn('extra', data['ports'][0])
self.assertIn('internal_info', data['ports'][0])
self.assertIn('node_uuid', data['ports'][0])
self.assertIn('pxe_enabled', data['ports'][0])
self.assertIn('local_link_connection', data['ports'][0])
self.assertIn('portgroup_uuid', data['ports'][0])
self.assertIn('physical_network', data['ports'][0])
# never expose the node_id and portgroup_id
self.assertNotIn('node_id', data['ports'][0])
self.assertNotIn('portgroup_id', data['ports'][0])
def test_detail_query_false(self):
obj_utils.create_test_port(self.context, node_id=self.node.id,
pxe_enabled=False,
physical_network='physnet1')
data1 = self.get_json(
'/ports',
headers={api_base.Version.string: str(api_v1.max_version())})
data2 = self.get_json(
'/ports?detail=False',
headers={api_base.Version.string: str(api_v1.max_version())})
self.assertEqual(data1['ports'], data2['ports'])
def test_detail_using_query_false_and_fields(self):
obj_utils.create_test_port(self.context, node_id=self.node.id,
pxe_enabled=False,
physical_network='physnet1')
data = self.get_json(
'/ports?detail=False&fields=internal_info',
headers={api_base.Version.string: str(api_v1.max_version())})
self.assertIn('internal_info', data['ports'][0])
self.assertNotIn('uuid', data['ports'][0])
def test_detail_using_query_and_fields(self):
obj_utils.create_test_port(self.context, node_id=self.node.id,
pxe_enabled=False,
physical_network='physnet1')
response = self.get_json(
'/ports?detail=True&fields=name',
headers={api_base.Version.string: str(api_v1.max_version())},
expect_errors=True)
self.assertEqual(http_client.BAD_REQUEST, response.status_int)
def test_detail_using_query_old_version(self):
obj_utils.create_test_port(self.context, node_id=self.node.id,
pxe_enabled=False,
physical_network='physnet1')
response = self.get_json(
'/ports?detail=True',
headers={api_base.Version.string: str(api_v1.min_version())},
expect_errors=True)
self.assertEqual(http_client.BAD_REQUEST, response.status_int)
def test_detail_against_single(self):
port = obj_utils.create_test_port(self.context, node_id=self.node.id)
response = self.get_json('/ports/%s/detail' % port.uuid,

View File

@ -201,6 +201,55 @@ class TestListPortgroups(test_api_base.BaseApiTest):
# never expose the node_id
self.assertNotIn('node_id', data['portgroups'][0])
def test_detail_query(self):
portgroup = obj_utils.create_test_portgroup(self.context,
node_id=self.node.id)
data = self.get_json('/portgroups?detail=True', headers=self.headers)
self.assertEqual(portgroup.uuid, data['portgroups'][0]["uuid"])
self.assertIn('extra', data['portgroups'][0])
self.assertIn('node_uuid', data['portgroups'][0])
self.assertIn('standalone_ports_supported', data['portgroups'][0])
# never expose the node_id
self.assertNotIn('node_id', data['portgroups'][0])
def test_detail_query_false(self):
obj_utils.create_test_portgroup(self.context,
node_id=self.node.id)
data1 = self.get_json(
'/portgroups',
headers={api_base.Version.string: str(api_v1.max_version())})
data2 = self.get_json(
'/portgroups?detail=False',
headers={api_base.Version.string: str(api_v1.max_version())})
self.assertEqual(data1['portgroups'], data2['portgroups'])
def test_detail_using_query_false_and_fields(self):
obj_utils.create_test_portgroup(self.context,
node_id=self.node.id)
data = self.get_json(
'/portgroups?detail=False&fields=internal_info',
headers={api_base.Version.string: str(api_v1.max_version())})
self.assertIn('internal_info', data['portgroups'][0])
self.assertNotIn('uuid', data['portgroups'][0])
def test_detail_using_query_and_fields(self):
obj_utils.create_test_portgroup(self.context,
node_id=self.node.id)
response = self.get_json(
'/portgroups?detail=True&fields=name',
headers={api_base.Version.string: str(api_v1.max_version())},
expect_errors=True)
self.assertEqual(http_client.BAD_REQUEST, response.status_int)
def test_detail_using_query_old_version(self):
obj_utils.create_test_portgroup(self.context,
node_id=self.node.id)
response = self.get_json(
'/portgroups?detail=True',
headers={api_base.Version.string: '1.42'},
expect_errors=True)
self.assertEqual(http_client.BAD_REQUEST, response.status_int)
def test_detail_invalid_api_version(self):
response = self.get_json(
'/portgroups/detail',

View File

@ -0,0 +1,11 @@
---
features:
- |
Add ``?detail=`` boolean query to the API list endpoints to provide a more
RESTful alternative to the existing ``/nodes/detail`` and similar endpoints. The
default is False. Now these API requests are possible:
* ``/nodes?detail=True``
* ``/ports?detail=True``
* ``/chassis?detail=True``
* ``/portgroups?detail=True``