Package tlib :: Package base :: Module ApiMockServer
[hide private]
[frames] | no frames]

Source Code for Module tlib.base.ApiMockServer

  1  from flask import jsonify, request, Flask, make_response 
  2  import logging 
  3  import re 
  4  from multiprocessing import Process 
  5  import requests 
  6  import json 
  7  import socket 
  8  import time 
  9  from collections import OrderedDict 
 10  from tlib.base.NetworkHelper import NetworkHelper 
 11  import base64 
 12   
 13  mock_server = Flask(__name__) 
 14  _rules = OrderedDict() 
 15  _req_urls = [] 
 16  _requests = [] 
17 18 19 -def generate_error_response(message, status_code):
20 """ 21 Method to build an error response and log it 22 """ 23 logging.error(message) 24 logging.error(request.data) 25 26 r = jsonify(status=message) 27 r.status_code = status_code 28 return r
29
30 31 @mock_server.route("/mock/shutdown", methods=['GET']) 32 -def shutdown():
33 func = request.environ.get('werkzeug.server.shutdown') 34 if func is None: 35 raise RuntimeError('Not running with the Werkzeug Server') 36 func() 37 return jsonify(status='Server is shutting down')
38
39 40 @mock_server.route("/mock/responses", methods=['POST']) 41 -def add_response():
42 """ 43 This method adds new responses to the mock. 44 To add a response send a POST request with a payload like this: 45 46 { 47 "url_filter": ".*", 48 "headers": { 49 "Accept": "text/xml" 50 }, 51 "body": "Sample body", 52 "status_code": 200 53 } 54 55 Server will validate each matching rule and apply the first match 56 If there is no match, it will return a 500 response 57 58 To add binary responses, send data encoded in base64 and add this value to the request 59 "encoding": "base64". 60 For example: 61 62 { 63 "url_filter": "/file.zip", 64 "headers": { 65 "Accept": "application/zip" 66 }, 67 "body": "ADRFDRRFJFJJSMMSK", 68 "encoding": "base64", 69 "status_code": 200 70 } 71 72 """ 73 try: 74 payload = request.get_json(force=True) 75 except Exception as e: 76 return generate_error_response("Payload is not a valid JSON string.\n%s" % e.message, 400) 77 78 if not isinstance(payload, dict): 79 return generate_error_response("Payload is not a valid JSON string", 400) 80 81 # Parse data from request 82 if "url_filter" in payload.keys(): 83 key = payload["url_filter"] 84 # Check filter is not empty 85 if key == "": 86 return generate_error_response("url_filter can't be an empty string", 400) 87 88 try: 89 url_filter = re.compile(payload["url_filter"]) 90 except Exception as e: 91 return generate_error_response("url_filter is not a valid regular expression:\\n%s" % e.message, 400) 92 else: 93 return generate_error_response("url_filter is a required parameter", 400) 94 95 if "headers" in payload.keys(): 96 if type(payload["headers"]) is dict: 97 headers = payload["headers"] 98 else: 99 return generate_error_response("headers is not a dictionary:\\n%s" % payload["headers"], 400) 100 else: 101 headers = {} 102 103 if "body" in payload.keys(): 104 if "encoding" in payload.keys(): 105 # Payload can be plain text or encoded in base64 106 if payload["encoding"] not in ("base64", "text"): 107 return generate_error_response("Encoding '%s' is not valid. Supported encodings are text and base64" % 108 payload["encoding"], 400) 109 110 if payload["encoding"] == "base64": 111 body = base64.b64decode(payload["body"]) 112 else: 113 body = payload["body"] 114 else: 115 # If encoding is not specified, assume it's plain text 116 body = payload["body"] 117 else: 118 body = "" 119 120 if "status_code" in payload.keys(): 121 if not isinstance(payload["status_code"], int): 122 return generate_error_response("Status code is not an integer: %s" % payload["status_code"], 400) 123 124 status_code = payload["status_code"] 125 else: 126 status_code = 200 127 128 # Save parsed data 129 new_rule = {"url_filter": url_filter, "headers": headers, "body": body, "status_code": status_code} 130 _rules[key] = new_rule 131 132 return jsonify(status="OK")
133
134 135 @mock_server.route("/mock/responses", methods=['DELETE']) 136 -def clear_responses():
137 """ 138 Delete existing responses 139 """ 140 _rules.clear() 141 return jsonify(status="All rules were deleted")
142
143 144 @mock_server.route("/mock/responses", methods=['GET']) 145 -def get_responses():
146 """ 147 Get all responses 148 """ 149 rules_as_text = [] 150 for rule in _rules.values(): 151 # make a copy so we don't modify original 152 rule = rule.copy() 153 154 # Convert regex to str 155 rule["url_filter"] = rule["url_filter"].pattern 156 157 # Add rule to list 158 rules_as_text.append(rule) 159 160 return jsonify(rules=rules_as_text)
161
162 163 @mock_server.route("/mock/requests", methods=['DELETE']) 164 -def clear_requests():
165 """ 166 Delete existing requests 167 """ 168 while _req_urls: 169 _req_urls.pop() 170 171 while _requests: 172 _requests.pop() 173 174 return jsonify(status="All requests were deleted")
175
176 177 @mock_server.route("/mock/requests", methods=['GET']) 178 -def get_requests():
179 """ 180 Get all requests. 181 If parameter version is equal to 2, a detailed request (including headers and body) will be returned, otherwise 182 just the URL will be returned 183 """ 184 if request.args.get('version') == '2': 185 return jsonify(requests=_requests) 186 else: 187 return jsonify(requests=_req_urls)
188
189 190 @mock_server.route('/', defaults={'path': ''}, methods=['GET', 'POST', 'PUT', 'DELETE']) 191 @mock_server.route('/<path:path>', methods=['GET', 'POST', 'PUT', 'DELETE']) 192 -def catch_all(path):
193 """ 194 This method will catch all requests for which there are no explicit routes. 195 Here is where we build responses based on the rules that have been configured 196 It will go though the list of rules and apply one by one until a match is found. 197 If there is no match, it will return a 500 response 198 """ 199 _req_urls.append(request.url) 200 201 headers = {} 202 for h in str(request.headers).split("\r\n"): 203 if h: 204 parts = h.split(": ") 205 h_name = parts[0] 206 h_value = ":".join(parts[1:]) 207 headers[h_name] = h_value 208 209 # Store full request 210 req = { 211 'Request': { 212 "Scheme": request.scheme, 213 "Host": NetworkHelper.get_hostname_from_url(request.url), 214 "Port": NetworkHelper.get_port_from_url(request.url), 215 "Path": request.path, 216 "Args": dict(request.args), 217 "Method": request.method, 218 "Headers": headers, 219 "Body": request.data 220 } 221 } 222 223 _requests.append(req) 224 225 for rule in _rules.values(): 226 regex = rule["url_filter"] 227 if regex.search("/" + path): 228 r = make_response() 229 r .headers = rule["headers"] 230 r .data = rule["body"] 231 r .status_code = rule["status_code"] 232 return r 233 234 # Default values returned when there wasn't a match in the rules 235 return generate_error_response("There are no rules that match this URL", 500)
236
237 238 -class ApiMockServer(Process):
239 """ 240 Helper Class to interact with the ApiMockServer 241 Provides methods to add, remove mock objects as well as incoming requests 242 """ 243 244 server = mock_server 245
246 - def __init__(self, port):
247 super(ApiMockServer, self).__init__() 248 249 self.port = port 250 self.host = "0.0.0.0" # host IP to be server by the Flask server 251 self.localhost = self.host_ip() # IP to communicate with server 252 self.base_url = "http://{host}:{port}".format(host=self.localhost, port=self.port) 253 self.responses_url = self.base_url + "/mock/responses" 254 self.requests_url = self.base_url + "/mock/requests" 255 self.shutdown_url = self.base_url + "/mock/shutdown"
256
257 - def run(self):
258 """ 259 start the mock server 260 """ 261 super(ApiMockServer, self).run() 262 self.server.run(host=self.host, port=self.port)
263
264 - def start(self):
265 super(ApiMockServer, self).start() 266 self._wait_for_server_to_start()
267
268 - def stop(self):
269 """ 270 Shutdown the server and terminate process 271 :return: 272 """ 273 self._wait_for_server_to_start() 274 requests.get(self.shutdown_url) 275 self.terminate()
276
277 - def add_response(self, url_filter=None, status_code=200, headers=None, body="", encoding="text", data=None):
278 """ 279 Add the response to the mock server 280 281 :param url_filter: Regular expression to match the url path 282 :type url_filter: str 283 :param status_code: Expected status code 284 :type status_code: int 285 :param headers: Headers to return in the response 286 :type headers: dict 287 :param body: response body as plain text 288 :type body: str 289 :param encoding: If body is binary, this should be set to string "base64". 290 Otherwise, leave empty or set to "text" 291 :type encoding: str 292 :param data: If provided, it will be the rule to add to the mock. 293 :type data: str 294 :return: Mock server response 295 """ 296 self._wait_for_server_to_start() 297 298 if not data: 299 data = {"url_filter": url_filter, 300 "status_code": status_code, 301 "headers": headers if headers else {}, 302 "body": body, 303 "encoding": encoding} 304 305 return requests.post(self.responses_url, data=json.dumps(data))
306
307 - def add_binary_response(self, url_filter=None, status_code=200, headers=None, body=""):
308 """ 309 Adds a binary response to the mock server 310 311 :param url_filter: Regular expression to match the url path 312 :type url_filter: str 313 :param status_code: Expected status code 314 :type status_code: int 315 :param headers: Headers to return in the response 316 :type headers: dict 317 :param body: response body as plain text 318 :type body: str 319 :return: Mock server response 320 """ 321 return self.add_response(url_filter, status_code=status_code, headers=headers, encoding="base64", 322 body=base64.b64encode(body))
323
324 - def add_text_response(self, url_filter=None, status_code=200, headers=None, body=""):
325 """ 326 Adds a binary response to the mock server 327 328 :param url_filter: Regular expression to match the url path 329 :type url_filter: str 330 :param status_code: Expected status code 331 :type status_code: int 332 :param headers: Headers to return in the response 333 :type headers: dict 334 :param body: response body as plain text 335 :type body: str 336 :return: Mock server response 337 """ 338 return self.add_response(url_filter, status_code=status_code, headers=headers, body=body)
339
340 - def get_responses(self):
341 """ 342 Returns all the responses stored on the MockServer 343 :return: 344 """ 345 self._wait_for_server_to_start() 346 response = requests.get(self.responses_url) 347 return response if response else None
348
349 - def clear_responses(self):
350 """ 351 Delete all the responses stored on the MockServer 352 :return: 353 """ 354 self._wait_for_server_to_start() 355 return requests.delete(self.responses_url)
356
357 - def get_requests(self, version=1):
358 """ 359 Returns all the requests stored on the MockServer 360 :param version: If parameter is equal to 2, a detailed request (including headers and body) will be returned, 361 otherwise just the URL will be returned 362 :return: 363 """ 364 self._wait_for_server_to_start() 365 response = requests.get(self.requests_url + "?version=" + version) 366 if response: 367 return response 368 return None
369
370 - def clear_requests(self):
371 """ 372 Delete all the requests stored on the MockServer 373 :return: 374 """ 375 self._wait_for_server_to_start() 376 return requests.delete(self.requests_url)
377
378 - def host_ip(self):
379 """ 380 Dirty hack to return the localhost IP, 381 works only when you have an internet connection 382 :return: 383 """ 384 return [(s.connect(('8.8.8.8', 80)), s.getsockname()[0], s.close()) 385 for s in [socket.socket(socket.AF_INET, socket.SOCK_DGRAM)]][0][1]
386
387 - def _is_server_running(self):
388 """ 389 check if the server is running and port is open 390 """ 391 sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 392 result = sock.connect_ex((self.host_ip(), self.port)) 393 return result == 0
394
396 """ 397 Latency to avoid the access when the server is not started yet 398 """ 399 while not self._is_server_running(): 400 time.sleep(0.1)
401
402 - def get_base_url(self):
403 return self.base_url
404 405 if __name__ == '__main__': 406 # to use as standalone application 407 # run 'python ApiMockServer.py' 408 server = ApiMockServer(port=10000) 409 server.start() 410