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