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 Process.__init__(self) 245 self.port = port 246 self.host = "0.0.0.0" # host IP to be server by the Flask server 247 self.localhost = self.host_ip() # IP to communicate with server 248 self.base_url = "http://{host}:{port}".format(host=self.localhost, port=self.port) 249 self.responses_url = self.base_url + "/mock/responses" 250 self.requests_url = self.base_url + "/mock/requests" 251 self.shutdown_url = self.base_url + "/mock/shutdown"
252
253 - def run(self):
254 """ 255 start the mock server 256 """ 257 self.server.run(host=self.host, port=self.port)
258
259 - def stop(self):
260 """ 261 Shutdown the server and terminate process 262 :return: 263 """ 264 self.wait_for_server_to_start() 265 requests.get(self.shutdown_url) 266 self.terminate()
267
268 - def add_response(self, url_filter=None, status_code=200, headers=None, body="", encoding="text", data=None):
269 """ 270 Add the response to the mock server 271 272 :param url_filter: Regular expression to match the url path 273 :param status_code: Expected status code 274 :param headers: Headers as a dictionary 275 :param body: response body as plain text 276 :param encoding: If body is binary, this should be set to string "base64". 277 Otherwise, leave empty or set to "text" 278 :param payload: a python dictionary 279 :return: HTTP status code 280 """ 281 self.wait_for_server_to_start() 282 283 if not data: 284 data = { "url_filter": url_filter, 285 "status_code": status_code, 286 "headers": headers if headers else {}, 287 "body": body, 288 "encoding": encoding} 289 290 return requests.post(self.responses_url, data=json.dumps(data))
291
292 - def add_binary_response(self, url_filter=None, status_code=200, headers=None, body=""):
293 """ 294 Adds a binary response to the mock server 295 296 :param url_filter: Regular expression to match the url path 297 :param status_code: Expected status code 298 :param headers: Headers as a dictionary 299 :param body: response body as plain text 300 :param payload: a python dictionary 301 :return: HTTP status code 302 """ 303 return self.add_response(url_filter, status_code=status_code, headers=headers, encoding="base64", 304 body=base64.b64encode(body))
305
306 - def add_text_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 :param status_code: Expected status code 312 :param headers: Headers as a dictionary 313 :param body: response body as plain text 314 :param payload: a python dictionary 315 :return: HTTP status code 316 """ 317 return self.add_response(url_filter, status_code=status_code, headers=headers, body=body)
318
319 - def get_responses(self):
320 """ 321 Returns all the responses stored on the MockServer 322 :return: 323 """ 324 self.wait_for_server_to_start() 325 response = requests.get(self.responses_url) 326 return response if response else None
327
328 - def clear_responses(self):
329 """ 330 Delete all the responses stored on the MockServer 331 :return: 332 """ 333 self.wait_for_server_to_start() 334 return requests.delete(self.responses_url)
335
336 - def get_requests(self):
337 """ 338 Returns all the requests stored on the MockServer 339 :return: 340 """ 341 self.wait_for_server_to_start() 342 response = requests.get(self.requests_url) 343 if response: 344 return response 345 return None
346
347 - def clear_requests(self):
348 """ 349 Delete all the requests stored on the MockServer 350 :return: 351 """ 352 self.wait_for_server_to_start() 353 return requests.delete(self.requests_url)
354
355 - def host_ip(self):
356 """ 357 Dirty hack to return the localhost IP, 358 works only when you have an internet connection 359 :return: 360 """ 361 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]
362
363 - def _is_server_running(self):
364 """ 365 check if the server is running and port is open 366 """ 367 sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 368 result = sock.connect_ex((self.host_ip(),self.port)) 369 return result == 0
370
371 - def wait_for_server_to_start(self):
372 """ 373 Latency to avoid the access when the server is not started yet 374 """ 375 while not self._is_server_running(): 376 time.sleep(0.1)
377
378 - def get_base_url(self):
379 return self.base_url
380 381 if __name__ == '__main__': 382 #to us as standalone application 383 #run 'python ApiMockServer.py' 384 server = ApiMockServer(port=10000) 385 server.start() 386