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