Repository: boto/botocore
Pull request #2636 (2022-03-10 22:45:29)

Support stubbing modeled exception fields
This adds support for supplying any additional fields that may be present on a modeled exception's `response` field. The extra fields provided to `modeled_fields` will be validated against the shape if the particular error code is recognized, else the extra fields will be rejected as the error will be parsed as a generic `ClientError`.

Fixes: https://github.com/boto/boto3/issues/3178


Patch:
diff --git a/.changes/next-release/enhancement-Stubber-23740.json b/.changes/next-release/enhancement-Stubber-23740.json
new file mode 100644
index 000000000..88ef602bf
--- /dev/null
+++ b/.changes/next-release/enhancement-Stubber-23740.json
@@ -0,0 +1,5 @@
+{
+  "type": "enhancement",
+  "category": "Stubber",
+  "description": "Added support for modeled exception fields when adding errors to a client stub. Implements boto/boto3`#3178 <https://github.com/boto/botocore/issues/3178>`__."
+}
diff --git a/botocore/stub.py b/botocore/stub.py
index f35ac1c40..7271348aa 100644
--- a/botocore/stub.py
+++ b/botocore/stub.py
@@ -237,7 +237,7 @@ class Stubber(object):
         http_response = AWSResponse(None, 200, {}, None)
 
         operation_name = self.client.meta.method_to_api_mapping.get(method)
-        self._validate_response(operation_name, service_response)
+        self._validate_operation_response(operation_name, service_response)
 
         # Add the service_response to the queue for returning responses
         response = {
@@ -250,7 +250,7 @@ class Stubber(object):
     def add_client_error(self, method, service_error_code='',
                          service_message='', http_status_code=400,
                          service_error_meta=None, expected_params=None,
-                         response_meta=None):
+                         response_meta=None, modeled_fields=None):
         """
         Adds a ``ClientError`` to the response queue.
 
@@ -283,6 +283,12 @@ class Stubber(object):
             response's ResponseMetadata
         :type response_meta: dict
 
+        :param modeled_fields: Additional keys to be added to the response
+            based on fields that are modeled for the particular error code.
+            These keys will be validated against the particular error shape
+            designated by the error code.
+        :type modeled_fields: dict
+
         """
         http_response = AWSResponse(None, http_status_code, {}, None)
 
@@ -303,6 +309,12 @@ class Stubber(object):
         if response_meta is not None:
             parsed_response['ResponseMetadata'].update(response_meta)
 
+        if modeled_fields is not None:
+            service_model = self.client.meta.service_model
+            shape = service_model.shape_for_error_code(service_error_code)
+            self._validate_response(shape, modeled_fields)
+            parsed_response.update(modeled_fields)
+
         operation_name = self.client.meta.method_to_api_mapping.get(method)
         # Note that we do not allow for expected_params while
         # adding errors into the queue yet.
@@ -374,7 +386,7 @@ class Stubber(object):
         if context and context.get('is_presign_request'):
             return True
 
-    def _validate_response(self, operation_name, service_response):
+    def _validate_operation_response(self, operation_name, service_response):
         service_model = self.client.meta.service_model
         operation_model = service_model.operation_model(operation_name)
         output_shape = operation_model.output_shape
@@ -386,8 +398,11 @@ class Stubber(object):
             response = copy.copy(service_response)
             del response['ResponseMetadata']
 
-        if output_shape is not None:
-            validate_parameters(response, output_shape)
+        self._validate_response(output_shape, response)
+
+    def _validate_response(self, shape, response):
+        if shape is not None:
+            validate_parameters(response, shape)
         elif response:
             # If the output shape is None, that means the response should be
             # empty apart from ResponseMetadata


Test patch:
diff --git a/tests/functional/test_stub.py b/tests/functional/test_stub.py
index 46aba3fe1..73bdaf973 100644
--- a/tests/functional/test_stub.py
+++ b/tests/functional/test_stub.py
@@ -80,6 +80,59 @@ class TestStubber(unittest.TestCase):
         with self.assertRaises(ClientError):
             self.client.list_objects(Bucket='foo')
 
+    def test_modeled_client_error_response(self):
+        error_code = "InvalidObjectState"
+        error_message = "Object is in invalid state"
+        modeled_fields = {
+            'StorageClass': 'foo',
+            'AccessTier': 'bar',
+        }
+        self.stubber.add_client_error(
+            'get_object',
+            error_code,
+            error_message,
+            modeled_fields=modeled_fields,
+        )
+        self.stubber.activate()
+
+        actual_exception = None
+        try:
+            self.client.get_object(Bucket='foo', Key='bar')
+        except self.client.exceptions.InvalidObjectState as e:
+            actual_exception = e
+        self.assertIsNotNone(actual_exception)
+        response = actual_exception.response
+        self.assertEqual(response['StorageClass'], 'foo')
+        self.assertEqual(response['AccessTier'], 'bar')
+
+    def test_modeled_client_error_response_validation_error(self):
+        error_code = "InvalidObjectState"
+        error_message = "Object is in invalid state"
+        modeled_fields = {
+            'BadField': 'fail please',
+        }
+        with self.assertRaises(ParamValidationError):
+            self.stubber.add_client_error(
+                'get_object',
+                error_code,
+                error_message,
+                modeled_fields=modeled_fields,
+            )
+
+    def test_modeled_client_unknown_code_validation_error(self):
+        error_code = "NotARealError"
+        error_message = "Message"
+        modeled_fields = {
+            'BadField': 'fail please',
+        }
+        with self.assertRaises(ParamValidationError):
+            self.stubber.add_client_error(
+                'get_object',
+                error_code,
+                error_message,
+                modeled_fields=modeled_fields,
+            )
+
     def test_can_add_expected_params_to_client_error(self):
         self.stubber.add_client_error(
             'list_objects', 'Error', 'error',
diff --git a/tests/unit/test_stub.py b/tests/unit/test_stub.py
index 018d15486..a6aa7317f 100644
--- a/tests/unit/test_stub.py
+++ b/tests/unit/test_stub.py
@@ -190,6 +190,21 @@ class TestStubber(unittest.TestCase):
         self.assertIn('RequestId', actual_response_meta)
         self.assertEqual(actual_response_meta['RequestId'], "79104EXAMPLEB723")
 
+    def test_get_client_error_with_modeled_fields(self):
+        error_code = 'foo'
+        error_message = 'bar'
+        modeled_fields = {
+            'Extra': 'foobar'
+        }
+        self.stubber.add_client_error(
+            'foo', error_code, error_message,
+            http_status_code=301,
+            modeled_fields=modeled_fields)
+        with self.stubber:
+            response = self.emit_get_response_event()
+        self.assertIn('Extra', response[1])
+        self.assertEqual(response[1]['Extra'], 'foobar')
+
     def test_get_response_errors_with_no_stubs(self):
         self.stubber.activate()
         with self.assertRaises(UnStubbedResponseError):