summaryrefslogtreecommitdiff
path: root/ironic
diff options
context:
space:
mode:
authorSteve Baker <sbaker@redhat.com>2020-07-29 10:41:11 +1200
committerSteve Baker <sbaker@redhat.com>2020-11-16 10:39:40 +1300
commit3846965e9c7407c6b92dcf12dec586ce478c6e60 (patch)
tree3f4b0a6158ef1f09f7e123888a08f503daa1832b /ironic
parent6ea0e8eaf65330e47e9be231fd7871474ad16f76 (diff)
downloadironic-3846965e9c7407c6b92dcf12dec586ce478c6e60.tar.gz
Add expose body decorator, status_code argument
@method.body populates a method argument with the contents of the request JSON. When the request body JSON is a dict, pecan populates the keyword arguments of the method with the values of the dict. This is not desirable for our typical usage so the body decorator also strips these keyword arguments. A status_code argument is also added to @method.expose to set custom responses. Empty response status codes are given special handling. This change also adds unit tests for the @method.expose decorator. Change-Id: I82d75345e4512c79ad7d211e81ca474f1b45a7ca Story: 1651346 Task: 10551
Diffstat (limited to 'ironic')
-rw-r--r--ironic/api/method.py41
-rw-r--r--ironic/tests/unit/api/test_method.py163
2 files changed, 203 insertions, 1 deletions
diff --git a/ironic/api/method.py b/ironic/api/method.py
index 50f672a29..48bd8caa0 100644
--- a/ironic/api/method.py
+++ b/ironic/api/method.py
@@ -32,7 +32,7 @@ pecan_json_decorate = pecan.expose(
generic=False)
-def expose():
+def expose(status_code=None):
def decorate(f):
@@ -40,6 +40,8 @@ def expose():
def callfunction(self, *args, **kwargs):
try:
result = f(self, *args, **kwargs)
+ if status_code:
+ pecan.response.status = status_code
except Exception:
try:
@@ -58,6 +60,18 @@ def expose():
else:
pecan.response.status = 500
+ def _empty():
+ pecan.request.pecan['content_type'] = None
+ pecan.response.content_type = None
+
+ # never return content for NO_CONTENT
+ if pecan.response.status_code == 204:
+ return _empty()
+
+ # don't encode None for ACCEPTED responses
+ if result is None and pecan.response.status_code == 202:
+ return _empty()
+
return json.dumps(result)
pecan_json_decorate(callfunction)
@@ -66,6 +80,31 @@ def expose():
return decorate
+def body(body_arg):
+ """Decorator which places HTTP request body JSON into a method argument
+
+ :param body_arg: Name of argument to populate with body JSON
+ """
+
+ def inner_function(function):
+
+ @functools.wraps(function)
+ def inner_body(*args, **kwargs):
+
+ data = pecan.request.json
+ if isinstance(data, dict):
+ # remove any keyword arguments which pecan has
+ # extracted from the body
+ for field in data.keys():
+ kwargs.pop(field, None)
+
+ kwargs[body_arg] = data
+
+ return function(*args, **kwargs)
+ return inner_body
+ return inner_function
+
+
def format_exception(excinfo, debug=False):
"""Extract informations that can be sent to the client."""
error = excinfo[1]
diff --git a/ironic/tests/unit/api/test_method.py b/ironic/tests/unit/api/test_method.py
new file mode 100644
index 000000000..2b6535977
--- /dev/null
+++ b/ironic/tests/unit/api/test_method.py
@@ -0,0 +1,163 @@
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+
+from http import client as http_client
+import json
+
+import pecan.rest
+import pecan.testing
+
+from ironic import api
+from ironic.api.controllers import root
+from ironic.api.controllers import v1
+from ironic.api import method
+from ironic.api import types as atypes
+from ironic.common import args
+from ironic.tests.unit.api import base as test_api_base
+
+
+class MyThingController(pecan.rest.RestController):
+
+ _custom_actions = {
+ 'no_content': ['GET'],
+ 'response_content': ['GET'],
+ 'response_custom_status': ['GET'],
+ 'ouch': ['GET'],
+ }
+
+ @method.expose()
+ @args.validate(name=args.string, flag=args.boolean)
+ def get(self, name, flag):
+ return {name: flag}
+
+ @method.expose()
+ def no_content(self):
+ api.response.status_code = 204
+ return 'nothing'
+
+ @method.expose()
+ def response_content(self):
+ resp = atypes.PassthruResponse('nothing', status_code=200)
+ api.response.status_code = resp.status_code
+ return resp.obj
+
+ @method.expose(status_code=202)
+ def response_custom_status(self):
+ return 'accepted'
+
+ @method.expose()
+ def ouch(self):
+ raise Exception('ouch')
+
+ @method.expose(status_code=201)
+ @method.body('body')
+ @args.validate(body=args.schema({
+ 'type': 'object',
+ 'properties': {
+ 'three': {'type': 'string'},
+ 'four': {'type': 'string', 'maxLength': 4},
+ 'five': {'type': 'string'},
+ },
+ 'additionalProperties': False,
+ 'required': ['three']
+ }))
+ def post(self, body):
+ return body
+
+
+class MyV1Controller(v1.Controller):
+
+ things = MyThingController()
+
+
+class MyRootController(root.RootController):
+
+ v1 = MyV1Controller()
+
+
+class TestExpose(test_api_base.BaseApiTest):
+
+ block_execute = False
+
+ root_controller = '%s.%s' % (MyRootController.__module__,
+ MyRootController.__name__)
+
+ def test_expose(self):
+ self.assertEqual(
+ {'foo': True},
+ self.get_json('/things', name='foo', flag=True)
+ )
+
+ def test_expose_validation(self):
+ response = self.get_json('/things', name='foo', flag='truish',
+ expect_errors=True)
+ self.assertEqual(http_client.BAD_REQUEST, response.status_int)
+
+ error = json.loads(response.json['error_message'])
+ self.assertEqual('Client', error['faultcode'])
+ self.assertIsNone(error['debuginfo'])
+ self.assertIn("Unrecognized value 'truish'", error['faultstring'])
+
+ def test_response_204(self):
+ response = self.get_json('/things/no_content', expect_errors=True)
+ self.assertEqual(http_client.NO_CONTENT, response.status_int)
+ self.assertIsNone(response.content_type)
+ self.assertEqual(b'', response.normal_body)
+
+ def test_response_content(self):
+ response = self.get_json('/things/response_content',
+ expect_errors=True)
+ self.assertEqual(http_client.OK, response.status_int)
+ self.assertEqual(b'"nothing"', response.normal_body)
+ self.assertEqual('application/json', response.content_type)
+
+ def test_response_custom_status(self):
+ response = self.get_json('/things/response_custom_status',
+ expect_errors=True)
+ self.assertEqual(http_client.ACCEPTED, response.status_int)
+ self.assertEqual(b'"accepted"', response.normal_body)
+ self.assertEqual('application/json', response.content_type)
+
+ def test_exception(self):
+ response = self.get_json('/things/ouch',
+ expect_errors=True)
+ error_message = json.loads(response.json['error_message'])
+ self.assertEqual(http_client.INTERNAL_SERVER_ERROR,
+ response.status_int)
+ self.assertEqual('application/json', response.content_type)
+ self.assertEqual('Server', error_message['faultcode'])
+ self.assertEqual('ouch', error_message['faultstring'])
+
+ def test_post_body(self):
+ data = {
+ 'three': 'three',
+ 'four': 'four',
+ 'five': 'five'
+ }
+ response = self.post_json('/things/', data, expect_errors=True)
+ self.assertEqual(http_client.CREATED, response.status_int)
+ self.assertEqual(data, response.json)
+
+ def test_post_body_validation(self):
+ data = {
+ 'three': 'three',
+ 'four': 'fourrrr',
+ 'five': 'five'
+ }
+ response = self.post_json('/things/', data, expect_errors=True)
+ self.assertEqual(http_client.BAD_REQUEST, response.status_int)
+ error = json.loads(response.json['error_message'])
+ self.assertEqual('Client', error['faultcode'])
+ self.assertIsNone(error['debuginfo'])
+ self.assertIn("Schema error for body:", error['faultstring'])