Added helpers to pytest tests
[anna.git] / example / diameter / launcher / resources / rest_api / ct / conftest.py
1 # Keep sorted
2 import base64
3 from collections import defaultdict
4 import glob
5 from hyper import HTTP20Connection
6 #import inspect
7 import json
8 import logging
9 import os
10 import pytest
11 import re
12 import xmltodict
13
14 #############
15 # CONSTANTS #
16 #############
17
18 # Endpoint
19 ADML_HOST = 'localhost'
20 ADML_PORT = '8074'
21 ADML_ENDPOINT = ADML_HOST + ':' + ADML_PORT
22 ADML_URI_PREFIX = ''
23
24 # Headers
25 CONTENT_LENGTH = 'content-length'
26
27 # Flow calculation throw admlf (ADML Flow) fixture:
28 #   flow = admlf.getId()
29 #
30 # For sequenced tests, the id is just a monotonically increased number from 1.
31 # For parallel tests, this id is sequenced from 1 for every worker, then, globally,
32 #  this is a handicap to manage flow ids (tests ids) for ADML FSM (finite state machine).
33 # We consider a base multiplier of 10000, so collisions would take place when workers
34 #  reserves more than 10000 flows. With a 'worst-case' assumption of `5 flows per test case`,
35 #  you should cover up to 5000 per worker. Anyway, feel free to increase this value,
36 #  specially if you are thinking in use pytest and ADML Agent for system tests.
37 FLOW_BASE_MULTIPLIER = 10000
38
39 #########
40 # HOOKS #
41 #########
42 #def pytest_runtest_setup(item):
43 #    print("pytest_runtest_setup")
44 #def pytest_runtest_logreport(report):
45 #    print(f'Log Report:{report}')
46 #def pytest_sessionstart(session):
47 #    print("pytest_session start")
48 #def pytest_sessionfinish(session):
49 #    print("pytest_session finish")
50
51 ######################
52 # CLASSES & FIXTURES #
53 ######################
54
55 class Sequencer(object):
56     def __init__(self, request):
57         self.sequence = 0
58         self.request = request
59
60     def __wid(self):
61         """
62         Returns the worker id, or 'master' if not parallel execution is done
63         """
64         wid = 'master'
65         if hasattr(self.request.config, 'slaveinput'):
66           wid = self.request.config.slaveinput['slaveid']
67
68         return wid
69
70     def getId(self):
71         """
72         Returns the next identifier value (monotonically increased in every call)
73         """
74         self.sequence += 1
75
76         wid = self.__wid()
77         if wid == "master":
78           return self.sequence
79
80         # Workers are named: wd0, wd1, wd2, etc.
81         wid_number = int(re.findall(r'\d+', wid)[0])
82
83         return FLOW_BASE_MULTIPLIER * wid_number + self.sequence
84
85
86
87 @pytest.fixture(scope='session')
88 def admlf(request):
89   """
90   ADML Flow
91   """
92   return Sequencer(request)
93
94
95
96 # Logging
97 class MyLogger:
98
99   # CRITICAL ERROR WARNING INFO DEBUG NOSET
100   def setLevelInfo(): logging.getLogger().setLevel(logging.INFO)
101   def setLevelDebug(): logging.getLogger().setLevel(logging.DEBUG)
102
103   def error(message): logging.getLogger().error(message)
104   def warning(message): logging.getLogger().warning(message)
105   def info(message): logging.getLogger().info(message)
106   def debug(message): logging.getLogger().debug(message)
107
108 @pytest.fixture(scope='session')
109 def mylogger():
110   return MyLogger
111
112 MyLogger.logger = logging.getLogger('CT')
113
114 # Base64 encoding:
115 @pytest.fixture(scope='session')
116 def b64_encode():
117   def encode(message):
118     message_bytes = message.encode('ascii')
119     base64_bytes = base64.b64encode(message_bytes)
120     return base64_bytes.decode('ascii')
121   return encode
122
123 # Base64 decoding:
124 @pytest.fixture(scope='session')
125 def b64_decode():
126   def decode(base64_message):
127     base64_bytes = base64_message.encode('ascii')
128     message_bytes = base64.b64decode(base64_bytes)
129     return message_bytes.decode('ascii')
130   return decode
131
132 @pytest.fixture(scope='session')
133 def saveB64Artifact(b64_decode):
134   """
135   Decode base64 string provided to disk
136
137   base64_message: base64 encoded string
138   file_basename: file basename where to write
139   isXML: decoded data corresponds to xml string. In this case, also json equivalente representation is written to disk
140   """
141   def save_b64_artifact(base64_message, file_basename, isXML = True):
142
143     targetFile = file_basename + ".txt"
144     data = b64_decode(base64_message)
145
146     if isXML:
147       targetFile = file_basename + ".xml"
148
149     _file = open(targetFile, "w")
150     n = _file.write(data)
151     _file.close()
152
153     if isXML:
154       targetFile = file_basename + ".json"
155       xml_dict = xmltodict.parse(data)
156       data = json.dumps(xml_dict, indent = 4, sort_keys=True)
157
158       _file = open(targetFile, "w")
159       n = _file.write(data)
160       _file.close()
161
162   return save_b64_artifact
163
164
165 # HTTP communication:
166 class RestClient(object):
167     """A client helper to perform rest operations: GET, POST.
168
169     Attributes:
170         endpoint: server endpoint to make the HTTP2.0 connection
171     """
172
173     def __init__(self, endpoint):
174         """Return a RestClient object for ADML endpoint."""
175         self._endpoint = endpoint
176         self._ip = self._endpoint.split(':')[0]
177         self._connection = HTTP20Connection(host=self._endpoint)
178
179     def _log_http(self, kind, method, url, body, headers):
180         length = len(body) if body else 0
181         MyLogger.info(
182                 '{} {}{} {} headers: {!s} data: {}:{!a}'.format(
183                 method, self._endpoint, url, kind, headers, length, body))
184
185     def _log_request(self, method, url, body, headers):
186         self._log_http('REQUEST', method, url, body, headers)
187
188     def _log_response(self, method, url, response):
189         self._log_http(
190                 'RESPONSE:{}'.format(response["status"]), method, url,
191                 response["body"], response["headers"])
192
193     #def log_event(self, level, log_msg):
194     #    # Log caller function name and formated message
195     #    MyLogger.logger.log(level, inspect.getouterframes(inspect.currentframe())[1].function + ': {!a}'.format(log_msg))
196
197     def parse(self, response):
198         response_body = response.read(decode_content=True).decode('utf-8')
199         if len(response_body) != 0:
200           response_body_dict = json.loads(response_body)
201         else:
202           response_body_dict = ''
203         response_data = { "status":response.status, "body":response_body_dict, "headers":response.headers }
204         return response_data
205
206     def request(self, requestMethod, requestUrl, requestBody=None, requestHeaders=None):
207       """
208       Returns response data dictionary with 'status', 'body' and 'headers'
209       """
210       requestBody = RestClient._pad_body_and_length(requestBody, requestHeaders)
211       self._log_request(requestMethod, requestUrl, requestBody, requestHeaders)
212       self._connection.request(method=requestMethod, url=requestUrl, body=requestBody, headers=requestHeaders)
213       response = self.parse(self._connection.get_response())
214       self._log_response(requestMethod, requestUrl, response)
215       return response
216
217     def _pad_body_and_length(requestBody, requestHeaders):
218         """Pad the body and adjust content-length if needed.
219         When the length of the body is multiple of 1024 this function appends
220         one space to the body and increases by one the content-length.
221
222         This is a workaround for hyper issue 355 [0].
223         The issue has been fixed but it has not been released yet.
224
225         [0]: https://github.com/Lukasa/hyper/issues/355
226
227         EXAMPLE
228         >>> body, headers = ' '*1024, { 'content-length':'41' }
229         >>> body = RestClient._pad_body_and_length(body, headers)
230         >>> ( len(body), headers['content-length'] )
231         (1025, '42')
232         """
233         if requestBody and 0 == (len(requestBody) % 1024):
234             logging.warning( "RestClient.request:" +
235                              " padding body because" +
236                              " its length ({})".format(len(requestBody)) +
237                              " is multiple of 1024")
238             requestBody += " "
239             content_length = CONTENT_LENGTH
240             if requestHeaders and content_length in requestHeaders:
241                 length = int(requestHeaders[content_length])
242                 requestHeaders[content_length] = str(length+1)
243         return requestBody
244
245     def get(self, requestUrl):
246         return self.request('GET', requestUrl)
247
248     def post(self, requestUrl, requestBody = None, requestHeaders={'content-type': 'application/json'}):
249         return self.request('POST', requestUrl, requestBody, requestHeaders)
250
251     def postDict(self, requestUrl, requestBody = None, requestHeaders={'content-type': 'application/json'}):
252         """
253            Accepts request body as python dictionary
254         """
255         requestBodyJson = None
256         if requestBody: requestBodyJson = json.dumps(requestBody, indent=None, separators=(',', ':'))
257         return self.request('POST', requestUrl, requestBodyJson, requestHeaders)
258
259
260     #def delete(self, requestUrl):
261     #  return self.request('DELETE', requestUrl)
262
263     def __assert_received_expected(self, received, expected, what):
264         match = (received == expected)
265         log = "Received {what}: {received} | Expected {what}: {expected}".format(received=received, expected=expected, what=what)
266         if match: MyLogger.info(log)
267         else: MyLogger.error(log)
268
269         assert match
270
271     def check_response_status(self, received, expected, **kwargs):
272         """
273         received: status code received (got from response data parsed, field 'status')
274         expected: status code expected
275         """
276         self.__assert_received_expected(received, expected, "status code")
277
278     #def check_expected_cause(self, response, **kwargs):
279     #    """
280     #    received: response data parsed where field 'body'->'cause' is analyzed
281     #    kwargs: aditional regexp to match expected cause
282     #    """
283     #    if "expected_cause" in kwargs:
284     #        received_content = response["body"]
285     #        received_cause = received_content.get("cause", "")
286     #        regular_expr_cause = kwargs["expected_cause"]
287     #        regular_expr_flag = kwargs.get("regular_expression_flag", 0)
288     #        matchObj = re.match(regular_expr_cause, received_cause, regular_expr_flag)
289     #        log = 'Test error cause: "{}"~=/{}/.'.format(received_cause, regular_expr_cause)
290     #        if matchObj: MyLogger.info(log)
291     #        else: MyLogger.error(log)
292     #
293     #        assert matchObj is not None
294
295     def check_response_body(self, received, expected, inputJsonString = False):
296         """
297         received: body content received (got from response data parsed, field 'body')
298         expected: body content expected
299         inputJsonString: input parameters as json string (default are python dictionaries)
300         """
301         if inputJsonString:
302           # Decode json:
303           received = json.loads(received)
304           expected = json.loads(expected)
305
306         self.__assert_received_expected(received, expected, "body")
307
308     def check_response_headers(self, received, expected):
309         """
310         received: headers received (got from response data parsed, field 'headers')
311         expected: headers expected
312         """
313         self.__assert_received_expected(received, expected, "headers")
314
315     def assert_response__status_body_headers(self, response, status, bodyDict, headersDict = None):
316         """
317         response: Response parsed data
318         status: numeric status code
319         body: body dictionary to match with
320         headers: headers dictionary to match with (by default, not checked: internally length and content-type application/json is verified)
321         """
322         self.check_response_status(response["status"], status)
323         self.check_response_body(response["body"], bodyDict)
324         if headersDict: self.check_response_headers(response["headers"], headersDict)
325
326
327     def close(self):
328       self._connection.close()
329
330
331 # ADML Client simple fixture
332 @pytest.fixture(scope='session')
333 def admlc():
334   admlc = RestClient(ADML_ENDPOINT)
335   yield admlc
336   admlc.close()
337   print("ADMLC Teardown")
338
339 @pytest.fixture(scope='session')
340 def resources():
341   resourcesDict={}
342   MyLogger.info("Gathering test suite resources ...")
343   for resource in glob.glob('resources/*'):
344     f = open(resource, "r")
345     name = os.path.basename(resource)
346     resourcesDict[name] = f.read()
347     f.close()
348
349   def get_resources(key, **kwargs):
350     # Be careful with templates containing curly braces:
351     # https://stackoverflow.com/questions/5466451/how-can-i-print-literal-curly-brace-characters-in-python-string-and-also-use-fo
352     resource = resourcesDict[key]
353
354     if kwargs:
355       args = defaultdict (str, kwargs)
356       resource = resource.format_map(args)
357
358     return resource
359
360   yield get_resources
361
362 ################
363 # Experimental #
364 ################
365
366 REQUEST_BODY_DIAMETER_HEX = '''
367 {{
368    "diameterHex":"{diameterHex}"
369 }}'''
370
371 REQUEST_BODY_NODE = {
372     "name":"{name}"
373 }
374
375
376 PARAMS = [
377   (ADML_URI_PREFIX, '/decode', REQUEST_BODY_DIAMETER_HEX, ADML_ENDPOINT),
378 ]
379
380 # Share RestClient connection for all the tests: session-scoped fixture
381 @pytest.fixture(scope="session", params=PARAMS)
382 def request_data(request):
383   admlc = RestClient(request.param[3])
384   def get_request_data(**kwargs):
385     args = defaultdict (str, kwargs)
386     uri_prefix = request.param[0]
387     request_uri_suffix=request.param[1]
388     formatted_uri=uri_prefix + request_uri_suffix.format_map(args)
389     request_body=request.param[2]
390     formatted_request_body=request_body.format_map(args)
391     return formatted_uri,formatted_request_body,admlc
392
393   yield get_request_data
394   admlc.close()
395   print("RestClient Teardown")
396
397 # Fixture usage example: requestUrl,requestBody,admlc = request_data(diameterHex="<hex content>")
398