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 = []
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
82 if "url_filter" in payload.keys():
83 key = payload["url_filter"]
84
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
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
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
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
152 rule = rule.copy()
153
154
155 rule["url_filter"] = rule["url_filter"].pattern
156
157
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
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
235 return generate_error_response("There are no rules that match this URL", 500)
236
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
247 super(ApiMockServer, self).__init__()
248
249 self.port = port
250 self.host = "0.0.0.0"
251 self.localhost = self.host_ip()
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
258 """
259 start the mock server
260 """
261 super(ApiMockServer, self).run()
262 self.server.run(host=self.host, port=self.port)
263
267
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
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
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
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
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
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
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
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
404
405 if __name__ == '__main__':
406
407
408 server = ApiMockServer(port=10000)
409 server.start()
410