Skip to content
This repository was archived by the owner on Oct 12, 2023. It is now read-only.

Commit 9690738

Browse files
authored
Merge pull request #38 from yugangw-msft/bin2
Support playback with binary and large response payload
2 parents b90f321 + 7a452e1 commit 9690738

File tree

8 files changed

+149
-34
lines changed

8 files changed

+149
-34
lines changed

requirements.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
codecov==2.0.9
22
mock==2.0.0;python_version<="2.7"
33
nose==1.3.7
4-
pylint==1.7.1
4+
pylint==1.8.2
55

66
-e .

src/azure_devtools/scenario_tests/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from .exceptions import AzureTestError
88
from .decorators import live_only, record_only
99
from .patches import mock_in_unit_test, patch_time_sleep_api, patch_long_run_operation_delay
10-
from .preparers import AbstractPreparer, SingleValueReplacer
10+
from .preparers import AbstractPreparer, SingleValueReplacer, AllowLargeResponse
1111
from .recording_processors import (
1212
RecordingProcessor, SubscriptionRecordingProcessor,
1313
LargeRequestBodyProcessor, LargeResponseBodyProcessor, LargeResponseBodyReplacer,
@@ -18,7 +18,7 @@
1818
__all__ = ['IntegrationTestBase', 'ReplayableTest', 'LiveTest',
1919
'AzureTestError',
2020
'mock_in_unit_test', 'patch_time_sleep_api', 'patch_long_run_operation_delay',
21-
'AbstractPreparer', 'SingleValueReplacer',
21+
'AbstractPreparer', 'SingleValueReplacer', 'AllowLargeResponse',
2222
'RecordingProcessor', 'SubscriptionRecordingProcessor',
2323
'LargeRequestBodyProcessor', 'LargeResponseBodyProcessor', 'LargeResponseBodyReplacer',
2424
'OAuthRequestResponsesFilter', 'DeploymentNameReplacer', 'GeneralNameReplacer',

src/azure_devtools/scenario_tests/base.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,7 @@ def _process_request_recording(self, request):
162162
return request
163163

164164
def _process_response_recording(self, response):
165+
from .utilities import is_text_payload
165166
if self.in_recording:
166167
# make header name lower case and filter unwanted headers
167168
headers = {}
@@ -171,7 +172,7 @@ def _process_response_recording(self, response):
171172
response['headers'] = headers
172173

173174
body = response['body']['string']
174-
if body and not isinstance(body, six.string_types):
175+
if is_text_payload(response) and body and not isinstance(body, six.string_types):
175176
response['body']['string'] = body.decode('utf-8')
176177

177178
for processor in self.recording_processors:

src/azure_devtools/scenario_tests/preparers.py

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
import functools
99

1010
from .base import ReplayableTest
11-
from .utilities import create_random_name
11+
from .utilities import create_random_name, is_text_payload
1212
from .recording_processors import RecordingProcessor
1313

1414

@@ -111,15 +111,15 @@ def process_request(self, request):
111111
request.uri = request.uri.replace(quote_plus(self.random_name),
112112
quote_plus(self.moniker))
113113

114-
if request.body:
114+
if is_text_payload(request) and request.body:
115115
body = str(request.body)
116116
if self.random_name in body:
117117
request.body = body.replace(self.random_name, self.moniker)
118118

119119
return request
120120

121121
def process_response(self, response):
122-
if response['body']['string']:
122+
if is_text_payload(response) and response['body']['string']:
123123
response['body']['string'] = response['body']['string'].replace(self.random_name,
124124
self.moniker)
125125

@@ -129,6 +129,20 @@ def process_response(self, response):
129129
return response
130130

131131

132+
# Function wise, enabling large payload recording has nothing to do with resource preparers
133+
# We still base on it so that this decorator can chain with other preparers w/o too much hacks
134+
class AllowLargeResponse(AbstractPreparer):
135+
def __init__(self, size_kb=1024):
136+
self.size_kb = size_kb
137+
super(AllowLargeResponse, self).__init__('nanana', 20)
138+
139+
def create_resource(self, _, **kwargs):
140+
from azure_devtools.scenario_tests import LargeResponseBodyProcessor
141+
large_resp_body = next((r for r in self.test_class_instance.recording_processors
142+
if isinstance(r, LargeResponseBodyProcessor)), None)
143+
if large_resp_body:
144+
large_resp_body._max_response_body = self.size_kb # pylint: disable=protected-access
145+
132146
# Utility
133147

134148
def is_preparer_func(fn):

src/azure_devtools/scenario_tests/recording_processors.py

Lines changed: 35 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
# Licensed under the MIT License. See License.txt in the project root for license information.
44
# --------------------------------------------------------------------------------------------
55

6+
from .utilities import is_text_payload, is_json_payload
67

78
class RecordingProcessor(object):
89
def process_request(self, request): # pylint: disable=no-self-use
@@ -31,13 +32,13 @@ def __init__(self, replacement):
3132
def process_request(self, request):
3233
request.uri = self._replace_subscription_id(request.uri)
3334

34-
if request.body:
35+
if is_text_payload(request) and request.body:
3536
request.body = self._replace_subscription_id(request.body.decode()).encode()
3637

3738
return request
3839

3940
def process_response(self, response):
40-
if response['body']['string']:
41+
if is_text_payload(response) and response['body']['string']:
4142
response['body']['string'] = self._replace_subscription_id(response['body']['string'])
4243

4344
self.replace_header_fn(response, 'location', self._replace_subscription_id)
@@ -66,7 +67,7 @@ def __init__(self, max_request_body=128):
6667
self._max_request_body = max_request_body
6768

6869
def process_request(self, request):
69-
if request.body and len(request.body) > self._max_request_body * 1024:
70+
if is_text_payload(request) and request.body and len(request.body) > self._max_request_body * 1024:
7071
request.body = '!!! The request body has been omitted from the recording because its ' \
7172
'size {} is larger than {}KB. !!!'.format(len(request.body),
7273
self._max_request_body)
@@ -81,34 +82,42 @@ def __init__(self, max_response_body=128):
8182
self._max_response_body = max_response_body
8283

8384
def process_response(self, response):
84-
length = len(response['body']['string'] or '')
85-
if length > self._max_response_body * 1024:
86-
response['body']['string'] = \
87-
"!!! The response body has been omitted from the recording because it is larger " \
88-
"than {} KB. It will be replaced with blank content of {} bytes while replay. " \
89-
"{}{}".format(self._max_response_body, length, self.control_flag, length)
90-
85+
if is_text_payload(response):
86+
length = len(response['body']['string'] or '')
87+
if length > self._max_response_body * 1024:
88+
89+
if is_json_payload(response):
90+
from .preparers import AllowLargeResponse # pylint: disable=cyclic-import
91+
raise ValueError("The json response body exceeds the default limit of {}kb. Use '@{}' "
92+
"on your test method to increase the limit or update test logics to avoid "
93+
"big payloads".format(self._max_response_body, AllowLargeResponse.__name__))
94+
95+
response['body']['string'] = \
96+
"!!! The response body has been omitted from the recording because it is larger " \
97+
"than {} KB. It will be replaced with blank content of {} bytes while replay. " \
98+
"{}{}".format(self._max_response_body, length, self.control_flag, length)
9199
return response
92100

93101

94102
class LargeResponseBodyReplacer(RecordingProcessor):
95103
def process_response(self, response):
96-
import six
97-
body = response['body']['string']
104+
if is_text_payload(response) and not is_json_payload(response):
105+
import six
106+
body = response['body']['string']
98107

99-
# backward compatibility. under 2.7 response body is unicode, under 3.5 response body is
100-
# bytes. when set the value back, the same type must be used.
101-
body_is_string = isinstance(body, six.string_types)
108+
# backward compatibility. under 2.7 response body is unicode, under 3.5 response body is
109+
# bytes. when set the value back, the same type must be used.
110+
body_is_string = isinstance(body, six.string_types)
102111

103-
content_in_string = (response['body']['string'] or b'').decode('utf-8')
104-
index = content_in_string.find(LargeResponseBodyProcessor.control_flag)
112+
content_in_string = (response['body']['string'] or b'').decode('utf-8')
113+
index = content_in_string.find(LargeResponseBodyProcessor.control_flag)
105114

106-
if index > -1:
107-
length = int(content_in_string[index + len(LargeResponseBodyProcessor.control_flag):])
108-
if body_is_string:
109-
response['body']['string'] = '0' * length
110-
else:
111-
response['body']['string'] = bytes([0] * length)
115+
if index > -1:
116+
length = int(content_in_string[index + len(LargeResponseBodyProcessor.control_flag):])
117+
if body_is_string:
118+
response['body']['string'] = '0' * length
119+
else:
120+
response['body']['string'] = bytes([0] * length)
112121

113122
return response
114123

@@ -122,6 +131,7 @@ def process_request(self, request):
122131
import re
123132
if not re.match('https://login.microsoftonline.com/([^/]+)/oauth2/token', request.uri):
124133
return request
134+
return None
125135

126136

127137
class DeploymentNameReplacer(RecordingProcessor):
@@ -159,7 +169,7 @@ def process_request(self, request):
159169
for old, new in self.names_name:
160170
request.uri = request.uri.replace(old, new)
161171

162-
if request.body:
172+
if is_text_payload(request) and request.body:
163173
body = str(request.body)
164174
if old in body:
165175
request.body = body.replace(old, new)
@@ -168,7 +178,7 @@ def process_request(self, request):
168178

169179
def process_response(self, response):
170180
for old, new in self.names_name:
171-
if response['body']['string']:
181+
if is_text_payload(response) and response['body']['string']:
172182
response['body']['string'] = response['body']['string'].replace(old, new)
173183

174184
self.replace_header(response, 'location', old, new)

src/azure_devtools/scenario_tests/tests/test_recording_processor.py

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ def test_subscription_recording_processor_for_request(self):
7878
mock_request = mock.Mock()
7979
mock_request.uri = template.format(mock_sub_id)
8080
mock_request.body = self._mock_subscription_request_body(mock_sub_id)
81+
mock_request.headers = {'content-type': 'application/json'}
8182

8283
rp.process_request(mock_request)
8384
self.assertEqual(mock_request.uri, template.format(replaced_subscription_id))
@@ -106,11 +107,35 @@ def test_subscription_recording_processor_for_response(self):
106107
mock_response = dict({'body': {}})
107108
mock_response['body']['string'] = template.format(mock_sub_id)
108109
mock_response['headers'] = {'Location': [location_header_template.format(mock_sub_id)],
109-
'azure-asyncoperation': [asyncoperation_header_template.format(mock_sub_id)]}
110+
'azure-asyncoperation': [asyncoperation_header_template.format(mock_sub_id)],
111+
'content-type': ['application/json']}
110112
rp.process_response(mock_response)
111113
self.assertEqual(mock_response['body']['string'], template.format(replaced_subscription_id))
112114

113115
self.assertSequenceEqual(mock_response['headers']['Location'],
114116
[location_header_template.format(replaced_subscription_id)])
115117
self.assertSequenceEqual(mock_response['headers']['azure-asyncoperation'],
116118
[asyncoperation_header_template.format(replaced_subscription_id)])
119+
120+
121+
def test_recording_processor_skip_body_on_unrecognized_content_type(self):
122+
location_header_template = 'https://graph.windows.net/{}/directoryObjects/' \
123+
'f604c53a-aa21-44d5-a41f-c1ef0b5304bd/Microsoft.DirectoryServices.Application'
124+
replaced_subscription_id = str(uuid.uuid4())
125+
rp = SubscriptionRecordingProcessor(replaced_subscription_id)
126+
127+
mock_sub_id = str(uuid.uuid4())
128+
mock_response = dict({'body': {}})
129+
mock_response['body']['string'] = mock_sub_id
130+
mock_response['headers'] = {
131+
'Location': [location_header_template.format(mock_sub_id)],
132+
'content-type': ['application/foo']
133+
}
134+
135+
# action
136+
rp.process_response(mock_response)
137+
138+
# assert
139+
self.assertEqual(mock_response['body']['string'], mock_sub_id) # body unchanged
140+
self.assertEqual(mock_response['headers']['Location'],
141+
[location_header_template.format(replaced_subscription_id)]) # header changed

src/azure_devtools/scenario_tests/tests/test_utilities.py

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@
44
# --------------------------------------------------------------------------------------------
55

66
import unittest
7-
from azure_devtools.scenario_tests.utilities import create_random_name, get_sha1_hash
7+
import mock
8+
from azure_devtools.scenario_tests.utilities import (create_random_name, get_sha1_hash,
9+
is_text_payload, is_json_payload)
810

911

1012
class TestUtilityFunctions(unittest.TestCase):
@@ -80,3 +82,37 @@ def test_get_sha1_hash(self):
8082
f.seek(0)
8183
hash_value = get_sha1_hash(f.name)
8284
self.assertEqual('6487bbdbd848686338d729e6076da1a795d1ae747642bf906469c6ccd9e642f9', hash_value)
85+
86+
def test_text_payload(self):
87+
http_entity = mock.MagicMock()
88+
headers = {}
89+
http_entity.headers = headers
90+
91+
headers['content-type'] = 'foo/'
92+
self.assertFalse(is_text_payload(http_entity))
93+
94+
headers['content-type'] = 'text/html; charset=utf-8'
95+
self.assertTrue(is_text_payload(http_entity))
96+
97+
headers['content-type'] = 'APPLICATION/JSON; charset=utf-8'
98+
self.assertTrue(is_text_payload(http_entity))
99+
100+
headers['content-type'] = 'APPLICATION/xml'
101+
self.assertTrue(is_text_payload(http_entity))
102+
103+
http_entity.headers = None # default to text mode if there is no header
104+
self.assertTrue(is_text_payload(http_entity))
105+
106+
def test_json_payload(self):
107+
http_entity = mock.MagicMock()
108+
headers = {}
109+
http_entity.headers = headers
110+
111+
headers['content-type'] = 'APPLICATION/JSON; charset=utf-8'
112+
self.assertTrue(is_json_payload(http_entity))
113+
114+
headers['content-type'] = 'application/json; charset=utf-8'
115+
self.assertTrue(is_json_payload(http_entity))
116+
117+
headers['content-type'] = 'application/xml; charset=utf-8'
118+
self.assertFalse(is_json_payload(http_entity))

src/azure_devtools/scenario_tests/utilities.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,3 +34,32 @@ def get_sha1_hash(file_path):
3434
sha1.update(data)
3535

3636
return sha1.hexdigest()
37+
38+
39+
def _get_content_type(entity):
40+
# 'headers' is a field of 'request', but it is a dict-key in 'response'
41+
headers = getattr(entity, 'headers', None)
42+
if headers is None:
43+
headers = entity.get('headers')
44+
45+
content_type = None
46+
if headers:
47+
content_type = headers.get('content-type', None)
48+
if content_type:
49+
# content-type could an array from response, let us extract it out
50+
content_type = content_type[0] if isinstance(content_type, list) else content_type
51+
content_type = content_type.split(";")[0].lower()
52+
return content_type
53+
54+
55+
def is_text_payload(entity):
56+
text_content_list = ['application/json', 'application/xml', 'text/', 'application/test-content']
57+
58+
content_type = _get_content_type(entity)
59+
if content_type:
60+
return any(content_type.startswith(x) for x in text_content_list)
61+
return True
62+
63+
64+
def is_json_payload(entity):
65+
return _get_content_type(entity) == 'application/json'

0 commit comments

Comments
 (0)